mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 15:56:10 +08:00
feat(sidebar): Add thread list of currently selected participants
Summary: WIP. I added a collection index to make displaying the threads of a currently selected participant on the sidebar easy and fast. The problem is that the `participants` of a thread, while a collection of `Contact` objects, have no "ids" for those contact objects. One idea was to create the join table but access contacts by email instead of id. This required a minor change to the way the data is entered in the join table. This means the sidebar can now simply do: `DatabaseStore.findAll(Thread).where(Thread.attributes.participants.contains('foo@bar.com'))` While I didn't for this initial test, we could also/instead create the `Message-Contact` join table. The trick about a Message-Contact table is that I believe we'd have to create additional columns further specifying which field we're interested in. The following two queries: `DatabaseStore.findAll(Message).where(Message.attributes.to.contains('foo@bar.com'))` `DatabaseStore.findAll(Message).where(Message.attributes.from.contains('foo@bar.com'))` would require additional columns in the `Message-Contact` join table because currently the only columns are `id` and `value`. In the case of the sidebar use case, I think the Thread participants is what you want to see anyway. Unfortunately an email-centric scheme can't distinguish between `noreply@phab.com <Evan>` and `noreply@phab.com <Juan>`. I actually think this may be a good thing since I think most people think in terms of email address as the unique key anyway and for the use case of showing related emails in the sidebar I'd rather overshow than undershow. This solution seems to be working pretty well in initial testing, but I want to see if you guys can think of anything this may subtly screw up down the line, or if you can think of a simpler way to do this. Test Plan: todo Reviewers: juan, bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2687
This commit is contained in:
parent
801325bb38
commit
d4ef6a20e5
|
@ -5,9 +5,8 @@ MessageToolbarItems = require "./message-toolbar-items"
|
|||
ExtensionRegistry,
|
||||
WorkspaceStore} = require 'nylas-exports'
|
||||
|
||||
{SidebarContactCard,
|
||||
SidebarSpacer,
|
||||
SidebarContactList} = require "./sidebar-components"
|
||||
SidebarPluginContainer = require "./sidebar-plugin-container"
|
||||
SidebarParticipantPicker = require './sidebar-participant-picker'
|
||||
|
||||
ThreadStarButton = require './thread-star-button'
|
||||
ThreadArchiveButton = require './thread-archive-button'
|
||||
|
@ -28,11 +27,9 @@ module.exports =
|
|||
ComponentRegistry.register MessageToolbarItems,
|
||||
location: WorkspaceStore.Location.MessageList.Toolbar
|
||||
|
||||
ComponentRegistry.register SidebarContactCard,
|
||||
ComponentRegistry.register SidebarParticipantPicker,
|
||||
location: WorkspaceStore.Location.MessageListSidebar
|
||||
ComponentRegistry.register SidebarSpacer,
|
||||
location: WorkspaceStore.Location.MessageListSidebar
|
||||
ComponentRegistry.register SidebarContactList,
|
||||
ComponentRegistry.register SidebarPluginContainer,
|
||||
location: WorkspaceStore.Location.MessageListSidebar
|
||||
|
||||
ComponentRegistry.register ThreadStarButton,
|
||||
|
@ -60,9 +57,8 @@ module.exports =
|
|||
ComponentRegistry.unregister ThreadTrashButton
|
||||
ComponentRegistry.unregister ThreadToggleUnreadButton
|
||||
ComponentRegistry.unregister MessageToolbarItems
|
||||
ComponentRegistry.unregister SidebarContactCard
|
||||
ComponentRegistry.unregister SidebarSpacer
|
||||
ComponentRegistry.unregister SidebarContactList
|
||||
ComponentRegistry.unregister SidebarPluginContainer
|
||||
ComponentRegistry.unregister SidebarParticipantPicker
|
||||
ExtensionRegistry.MessageView.unregister AutolinkerExtension
|
||||
ExtensionRegistry.MessageView.unregister TrackingPixelsExtension
|
||||
|
||||
|
|
|
@ -1,111 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
React = require "react"
|
||||
|
||||
{Actions, FocusedContactsStore} = require("nylas-exports")
|
||||
{TimeoutTransitionGroup,
|
||||
InjectedComponentSet,
|
||||
Flexbox} = require("nylas-component-kit")
|
||||
|
||||
class FocusedContactStorePropsContainer extends React.Component
|
||||
constructor: (@props) ->
|
||||
@state = @_getStateFromStores()
|
||||
|
||||
componentDidMount: =>
|
||||
@unsubscribe = FocusedContactsStore.listen(@_onChange)
|
||||
|
||||
componentWillUnmount: =>
|
||||
@unsubscribe()
|
||||
|
||||
render: ->
|
||||
classname = "sidebar-section"
|
||||
if @state.focusedContact
|
||||
classname += " visible"
|
||||
inner = React.cloneElement(@props.children, @state)
|
||||
|
||||
<div className={classname}>{inner}</div>
|
||||
|
||||
_onChange: =>
|
||||
@setState(@_getStateFromStores())
|
||||
|
||||
_getStateFromStores: =>
|
||||
sortedContacts: FocusedContactsStore.sortedContacts()
|
||||
focusedContact: FocusedContactsStore.focusedContact()
|
||||
|
||||
|
||||
class SidebarSpacer extends React.Component
|
||||
@displayName: 'SidebarSpacer'
|
||||
@containerStyles:
|
||||
order: 50
|
||||
flex: 1
|
||||
|
||||
constructor: (@props) ->
|
||||
|
||||
render: ->
|
||||
<div style={flex: 1}></div>
|
||||
|
||||
class SidebarContactList extends React.Component
|
||||
@displayName: 'SidebarContactList'
|
||||
@containerStyles:
|
||||
order: 100
|
||||
flexShrink: 0
|
||||
|
||||
constructor: (@props) ->
|
||||
|
||||
render: ->
|
||||
<FocusedContactStorePropsContainer>
|
||||
<SidebarContactListInner/>
|
||||
</FocusedContactStorePropsContainer>
|
||||
|
||||
class SidebarContactListInner extends React.Component
|
||||
constructor: (@props) ->
|
||||
|
||||
render: ->
|
||||
<div className="sidebar-contact-list">
|
||||
<h2>Thread Participants</h2>
|
||||
{@_renderSortedContacts()}
|
||||
</div>
|
||||
|
||||
_renderSortedContacts: =>
|
||||
@props.sortedContacts.map (contact) =>
|
||||
if contact.email is @props.focusedContact.email
|
||||
selected = "selected"
|
||||
else
|
||||
selected = ""
|
||||
|
||||
<div className="contact #{selected}"
|
||||
onClick={=> @_onSelectContact(contact)}
|
||||
key={contact.email + contact.name}>
|
||||
{contact.name}
|
||||
</div>
|
||||
|
||||
_onSelectContact: (contact) =>
|
||||
Actions.focusContact(contact)
|
||||
|
||||
class SidebarContactCard extends React.Component
|
||||
@displayName: 'SidebarContactCard'
|
||||
|
||||
@containerStyles:
|
||||
order: 0
|
||||
flexShrink: 0
|
||||
minWidth:200
|
||||
maxWidth:300
|
||||
|
||||
constructor: (@props) ->
|
||||
|
||||
render: ->
|
||||
<FocusedContactStorePropsContainer>
|
||||
<SidebarContactCardInner />
|
||||
</FocusedContactStorePropsContainer>
|
||||
|
||||
class SidebarContactCardInner extends React.Component
|
||||
constructor: (@props) ->
|
||||
|
||||
render: ->
|
||||
<InjectedComponentSet
|
||||
className="sidebar-contact-card"
|
||||
key={@props.focusedContact.email}
|
||||
matching={role: "MessageListSidebar:ContactCard"}
|
||||
direction="column"
|
||||
exposedProps={contact: @props.focusedContact}/>
|
||||
|
||||
module.exports = {SidebarContactCard, SidebarSpacer, SidebarContactList}
|
|
@ -0,0 +1,74 @@
|
|||
/** @babel */
|
||||
|
||||
import _ from 'underscore'
|
||||
import React from 'react';
|
||||
import {Actions, FocusedContactsStore} from 'nylas-exports'
|
||||
|
||||
const SPLIT_KEY = "---splitvalue---"
|
||||
|
||||
export default class SidebarParticipantPicker extends React.Component {
|
||||
static displayName = 'SidebarParticipantPicker';
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.props = props;
|
||||
this._onSelectContact = this._onSelectContact.bind(this);
|
||||
this.state = this._getStateFromStores();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._usub = FocusedContactsStore.listen(() => {
|
||||
return this.setState(this._getStateFromStores());
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._usub();
|
||||
}
|
||||
|
||||
static containerStyles = {
|
||||
order: 0,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
_getStateFromStores() {
|
||||
return {
|
||||
sortedContacts: FocusedContactsStore.sortedContacts(),
|
||||
focusedContact: FocusedContactsStore.focusedContact(),
|
||||
};
|
||||
}
|
||||
|
||||
_renderSortedContacts() {
|
||||
return this.state.sortedContacts.map((contact) => {
|
||||
const selected = contact.email === (this.state.focusedContact || {}).email
|
||||
const key = contact.email + SPLIT_KEY + contact.name;
|
||||
|
||||
return (
|
||||
<option selected={selected} value={key} key={key}>
|
||||
{contact.displayName({includeAccountLabel: true, forceAccountLabel: true})}
|
||||
</option>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
_onSelectContact = (event) => {
|
||||
const [email, name] = event.target.value.split(SPLIT_KEY);
|
||||
const contact = _.filter(this.state.sortedContacts, (c) => {
|
||||
return c.name === name && c.email === email;
|
||||
})[0];
|
||||
return Actions.focusContact(contact);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sidebar-participant-picker">
|
||||
<select onChange={this._onSelectContact}>
|
||||
{this._renderSortedContacts()}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
_ = require 'underscore'
|
||||
React = require "react"
|
||||
{FocusedContactsStore} = require("nylas-exports")
|
||||
{InjectedComponentSet} = require("nylas-component-kit")
|
||||
|
||||
class FocusedContactStorePropsContainer extends React.Component
|
||||
@displayName: 'FocusedContactStorePropsContainer'
|
||||
|
||||
constructor: (@props) ->
|
||||
@state = @_getStateFromStores()
|
||||
|
||||
componentDidMount: =>
|
||||
@unsubscribe = FocusedContactsStore.listen(@_onChange)
|
||||
|
||||
componentWillUnmount: =>
|
||||
@unsubscribe()
|
||||
|
||||
render: ->
|
||||
classname = "sidebar-section"
|
||||
if @state.focusedContact
|
||||
classname += " visible"
|
||||
inner = React.cloneElement(@props.children, @state)
|
||||
|
||||
<div className={classname}>{inner}</div>
|
||||
|
||||
_onChange: =>
|
||||
@setState(@_getStateFromStores())
|
||||
|
||||
_getStateFromStores: =>
|
||||
sortedContacts: FocusedContactsStore.sortedContacts()
|
||||
focusedContact: FocusedContactsStore.focusedContact()
|
||||
focusedContactThreads: FocusedContactsStore.focusedContactThreads()
|
||||
|
||||
class SidebarPluginContainer extends React.Component
|
||||
@displayName: 'SidebarPluginContainer'
|
||||
|
||||
@containerStyles:
|
||||
order: 1
|
||||
flexShrink: 0
|
||||
minWidth:200
|
||||
maxWidth:300
|
||||
|
||||
constructor: (@props) ->
|
||||
|
||||
render: ->
|
||||
<FocusedContactStorePropsContainer>
|
||||
<SidebarPluginContainerInner />
|
||||
</FocusedContactStorePropsContainer>
|
||||
|
||||
class SidebarPluginContainerInner extends React.Component
|
||||
constructor: (@props) ->
|
||||
|
||||
render: ->
|
||||
<InjectedComponentSet
|
||||
className="sidebar-contact-card"
|
||||
key={@props.focusedContact.email}
|
||||
matching={role: "MessageListSidebar:ContactCard"}
|
||||
direction="column"
|
||||
exposedProps={contact: @props.focusedContact, contactThreads: @props.focusedContactThreads}/>
|
||||
|
||||
module.exports = SidebarPluginContainer
|
|
@ -625,8 +625,13 @@ body.platform-win32 {
|
|||
///////////////////////////////
|
||||
.sidebar-section {
|
||||
opacity: 0;
|
||||
padding: @spacing-standard;
|
||||
margin: 5px;
|
||||
cursor: default;
|
||||
border: 1px solid @border-primary-bg;
|
||||
border-radius: @border-radius-large;
|
||||
background: @background-primary;
|
||||
padding: 15px;
|
||||
|
||||
&.visible {
|
||||
transition: opacity 0.1s ease-out;
|
||||
opacity: 1;
|
||||
|
@ -643,22 +648,14 @@ body.platform-win32 {
|
|||
|
||||
.sidebar-contact-card {
|
||||
}
|
||||
.sidebar-contact-list {
|
||||
min-height:120px;
|
||||
|
||||
.contact {
|
||||
color: @text-color-subtle;
|
||||
font-size: @font-size-smaller;
|
||||
&.selected {
|
||||
font-weight: @font-weight-semi-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sidebar-participant-picker {
|
||||
padding: 10px 5px 20px 5px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
.column-MessageListSidebar {
|
||||
background-color: @background-off-primary;
|
||||
background-color: @background-secondary;
|
||||
overflow: auto;
|
||||
border-left: 1px solid @border-color-divider;
|
||||
color: @text-color-subtle;
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 394 B |
Binary file not shown.
After Width: | Height: | Size: 542 B |
Binary file not shown.
After Width: | Height: | Size: 734 B |
Binary file not shown.
After Width: | Height: | Size: 1 KiB |
|
@ -0,0 +1,45 @@
|
|||
# This file is in coffeescript just to use the existential operator!
|
||||
{AccountStore, EdgehillAPI} = require 'nylas-exports'
|
||||
|
||||
module.exports = class ClearbitDataSource
|
||||
clearbitAPI: ->
|
||||
return "https://person.clearbit.com/v2/combined"
|
||||
|
||||
find: ({email}) ->
|
||||
tok = AccountStore.tokenForAccountId(AccountStore.accounts()[0].id)
|
||||
new Promise (resolve, reject) =>
|
||||
EdgehillAPI.request
|
||||
path: "/proxy/clearbit/#{@clearbitAPI()}/find?email=#{email}&api_token=#{tok}",
|
||||
success: (body) =>
|
||||
resolve(@parseResponse(body))
|
||||
error: reject
|
||||
|
||||
# The clearbit -> Nylas adapater
|
||||
parseResponse: (resp={}) ->
|
||||
person = resp.person
|
||||
return null unless person
|
||||
cacheDate: Date.now()
|
||||
email: person.email # Used as checksum
|
||||
bio: person.bio ? person.twitter?.bio ? person.aboutme?.bio,
|
||||
location: person.location ? person.geo?.city
|
||||
currentTitle: person.employment?.title,
|
||||
currentEmployer: person.employment?.name,
|
||||
profilePhotoUrl: person.avatar,
|
||||
socialProfiles: @_socialProfiles(person)
|
||||
|
||||
_socialProfiles: (person={}) ->
|
||||
profiles = {}
|
||||
if person.twitter
|
||||
profiles.twitter =
|
||||
handle: person.twitter.handle
|
||||
url: "https://twitter.com/#{person.twitter.handle}"
|
||||
if person.facebook
|
||||
profiles.facebook =
|
||||
handle: person.facebook.handle
|
||||
url: "https://facebook.com/#{person.facebook.handle}"
|
||||
if person.linkedin
|
||||
profiles.linkedin =
|
||||
handle: person.linkedin.handle
|
||||
url: "https://linkedin.com/#{person.linkedin.handle}"
|
||||
|
||||
return profiles
|
22
internal_packages/participant-profile/lib/main.js
Normal file
22
internal_packages/participant-profile/lib/main.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
/** @babel */
|
||||
import {ComponentRegistry} from 'nylas-exports'
|
||||
import ParticipantProfileStore from './participant-profile-store'
|
||||
import SidebarParticipantProfile from './sidebar-participant-profile'
|
||||
import SidebarRelatedThreads from './sidebar-related-threads'
|
||||
|
||||
export function activate() {
|
||||
ParticipantProfileStore.activate()
|
||||
ComponentRegistry.register(SidebarParticipantProfile, {role: 'MessageListSidebar:ContactCard'})
|
||||
ComponentRegistry.register(SidebarRelatedThreads, {role: 'MessageListSidebar:ContactCard'})
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(SidebarParticipantProfile)
|
||||
ComponentRegistry.unregister(SidebarRelatedThreads)
|
||||
ParticipantProfileStore.deactivate()
|
||||
}
|
||||
|
||||
export function serialize() {
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/** @babel */
|
||||
import NylasStore from 'nylas-store'
|
||||
import ClearbitDataSource from './clearbit-data-source'
|
||||
import {Utils} from 'nylas-exports'
|
||||
|
||||
// TODO: Back with Metadata
|
||||
const contactCache = {}
|
||||
const CACHE_SIZE = 100
|
||||
const contactCacheKeyIndex = []
|
||||
|
||||
class ParticipantProfileStore extends NylasStore {
|
||||
activate() {
|
||||
this.cacheExpiry = 1000 * 60 * 60 * 24 // 1 day
|
||||
this.dataSource = new ClearbitDataSource()
|
||||
}
|
||||
|
||||
dataForContact(contact) {
|
||||
if (!contact) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (Utils.likelyNonHumanEmail(contact.email)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (this.inCache(contact)) {
|
||||
return this.getCache(contact)
|
||||
}
|
||||
|
||||
this.dataSource.find({email: contact.email}).then((data) => {
|
||||
if (data.email === contact.email) {
|
||||
this.setCache(contact, data);
|
||||
this.trigger()
|
||||
}
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
// TODO: Back by metadata.
|
||||
getCache(contact) {
|
||||
return contactCache[contact.email]
|
||||
}
|
||||
|
||||
inCache(contact) {
|
||||
const cache = contactCache[contact.email]
|
||||
if (!cache) { return false }
|
||||
if (!cache.cacheDate || Date.now() - cache.cacheDate > this.cacheExpiry) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setCache(contact, value) {
|
||||
contactCache[contact.email] = value
|
||||
contactCacheKeyIndex.push(contact.email)
|
||||
if (contactCacheKeyIndex.length > CACHE_SIZE) {
|
||||
delete contactCache[contactCacheKeyIndex.shift()]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
// no op
|
||||
}
|
||||
}
|
||||
module.exports = new ParticipantProfileStore()
|
|
@ -0,0 +1,151 @@
|
|||
/** @babel */
|
||||
import _ from 'underscore'
|
||||
import React from 'react'
|
||||
import {shell} from 'electron'
|
||||
import {Utils} from 'nylas-exports'
|
||||
import {RetinaImg} from 'nylas-component-kit'
|
||||
import ParticipantProfileStore from './participant-profile-store'
|
||||
|
||||
export default class SidebarParticipantProfile extends React.Component {
|
||||
static displayName = "SidebarParticipantProfile";
|
||||
|
||||
static propTypes = {
|
||||
contact: React.PropTypes.object,
|
||||
contactThreads: React.PropTypes.array,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
/* We expect ParticipantProfileStore.dataForContact to return the
|
||||
* following schema:
|
||||
* {
|
||||
* profilePhotoUrl: string
|
||||
* bio: string
|
||||
* location: string
|
||||
* currentTitle: string
|
||||
* currentEmployer: string
|
||||
* socialProfiles: hash keyed by type: ('twitter', 'facebook' etc)
|
||||
* url: string
|
||||
* handle: string
|
||||
* }
|
||||
*/
|
||||
this.state = ParticipantProfileStore.dataForContact(props.contact)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.usub = ParticipantProfileStore.listen(() => {
|
||||
this.setState(ParticipantProfileStore.dataForContact(this.props.contact))
|
||||
})
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.usub()
|
||||
}
|
||||
|
||||
static containerStyles = {
|
||||
order: 0,
|
||||
}
|
||||
|
||||
_renderProfilePhoto() {
|
||||
if (this.state.profilePhotoUrl) {
|
||||
return (
|
||||
<div className="profile-photo-wrap">
|
||||
<div className="profile-photo">
|
||||
<img src={this.state.profilePhotoUrl}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this._renderDefaultProfileImage()
|
||||
}
|
||||
|
||||
_renderDefaultProfileImage() {
|
||||
const hue = Utils.hueForString(this.props.contact.email);
|
||||
const bgColor = `hsl(${hue}, 50%, 34%)`
|
||||
const abv = this.props.contact.nameAbbreviation()
|
||||
return (
|
||||
<div className="profile-photo-wrap">
|
||||
<div className="profile-photo">
|
||||
<div className="default-profile-image"
|
||||
style={{backgroundColor: bgColor}}>{abv}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
_renderCorePersonalInfo() {
|
||||
return (
|
||||
<div className="core-personal-info">
|
||||
<div className="full-name">{this.props.contact.fullName()}</div>
|
||||
<div className="email">{this.props.contact.email}</div>
|
||||
<div className="social-profiles-wrap">{this._renderSocialProfiles()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
_renderSocialProfiles() {
|
||||
if (!this.state.socialProfiles) { return false }
|
||||
return _.map(this.state.socialProfiles, (profile, type) => {
|
||||
const linkFn = () => {shell.openExternal(profile.url)}
|
||||
return (
|
||||
<a className="social-profile-item" onClick={linkFn} key={type}>
|
||||
<RetinaImg url={`nylas://participant-profile/assets/${type}-sidebar-icon@2x.png`}
|
||||
mode={RetinaImg.Mode.ContentPreserve} />
|
||||
</a>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
_renderAdditionalInfo() {
|
||||
return (
|
||||
<div className="additional-info">
|
||||
{this._renderCurrentJob()}
|
||||
{this._renderBio()}
|
||||
{this._renderLocation()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
_renderCurrentJob() {
|
||||
if (!this.state.employer) { return false; }
|
||||
let title = false;
|
||||
if (this.state.title) {
|
||||
title = <span>{this.state.title}, </span>
|
||||
}
|
||||
return (
|
||||
<p className="current-job">{title}{this.state.employer}</p>
|
||||
)
|
||||
}
|
||||
|
||||
_renderBio() {
|
||||
if (!this.state.bio) { return false; }
|
||||
return (
|
||||
<p className="bio">{this.state.bio}</p>
|
||||
)
|
||||
}
|
||||
|
||||
_renderLocation() {
|
||||
if (!this.state.location) { return false; }
|
||||
return (
|
||||
<p className="location">
|
||||
<RetinaImg url={`nylas://participant-profile/assets/location-icon@2x.png`}
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
style={{marginRight: 10}} />
|
||||
{this.state.location}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="participant-profile">
|
||||
{this._renderProfilePhoto()}
|
||||
{this._renderCorePersonalInfo()}
|
||||
{this._renderAdditionalInfo()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import React from 'react'
|
||||
import {Actions} from 'nylas-exports'
|
||||
|
||||
export default class RelatedThreads extends React.Component {
|
||||
static displayName = "RelatedThreads";
|
||||
|
||||
static propTypes = {
|
||||
contact: React.PropTypes.object,
|
||||
contactThreads: React.PropTypes.array,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {expanded: false}
|
||||
this.DEFAULT_NUM = 3
|
||||
}
|
||||
|
||||
static containerStyles = {
|
||||
order: 1,
|
||||
}
|
||||
|
||||
_onClick(thread) {
|
||||
Actions.setFocus({collection: 'thread', item: thread})
|
||||
}
|
||||
|
||||
_toggle = () => {
|
||||
this.setState({expanded: !this.state.expanded})
|
||||
}
|
||||
|
||||
_renderToggle() {
|
||||
if (!this._hasToggle()) { return false; }
|
||||
const msg = this.state.expanded ? "Collapse" : "Show more"
|
||||
return (
|
||||
<div className="toggle" onClick={this._toggle}>{msg}</div>
|
||||
)
|
||||
}
|
||||
|
||||
_hasToggle() {
|
||||
return (this.props.contactThreads.length > this.DEFAULT_NUM)
|
||||
}
|
||||
|
||||
render() {
|
||||
let limit;
|
||||
if (this.state.expanded) {
|
||||
limit = this.props.contactThreads.length;
|
||||
} else {
|
||||
limit = Math.min(this.props.contactThreads.length, this.DEFAULT_NUM)
|
||||
}
|
||||
|
||||
const height = ((limit + (this._hasToggle() ? 1 : 0)) * 31) + 5;
|
||||
const shownThreads = this.props.contactThreads.slice(0, limit)
|
||||
const threads = shownThreads.map((thread) => {
|
||||
const onClick = () => { this._onClick(thread) }
|
||||
return (
|
||||
<div key={thread.id} className="related-thread" onClick={onClick} >
|
||||
{thread.subject}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="related-threads" style={{height}}>
|
||||
{threads}
|
||||
{this._renderToggle()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
15
internal_packages/participant-profile/package.json
Normal file
15
internal_packages/participant-profile/package.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "participant-profile",
|
||||
"version": "0.1.0",
|
||||
"title": "Participant Profile",
|
||||
"description": "Information about a participant",
|
||||
"isOptional": true,
|
||||
"main": "lib/main",
|
||||
"windowTypes": {
|
||||
"default": true
|
||||
},
|
||||
"dependencies": {
|
||||
"clearbit": "^1.2"
|
||||
},
|
||||
"license": "GPL-3.0"
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
@import 'ui-variables';
|
||||
|
||||
.related-threads {
|
||||
width: calc(~"100% + 30px");
|
||||
position: relative;
|
||||
left: -15px;
|
||||
background: #f9fcfe;
|
||||
border-top: 1px solid rgba(0,0,0,0.15);
|
||||
transition: height 150ms ease-in-out;
|
||||
top: 15px;
|
||||
margin-top: -15px;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 @border-radius-large @border-radius-large;
|
||||
|
||||
.related-thread {
|
||||
font-size: 12px;
|
||||
color: #566f86;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0.5em 15px;
|
||||
border-top: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
text-align: center;
|
||||
padding: 0.5em 15px;
|
||||
border-top: 1px solid rgba(0,0,0,0.08);
|
||||
color: @text-color-link;
|
||||
}
|
||||
}
|
||||
|
||||
.participant-profile {
|
||||
.profile-photo-wrap {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: @border-radius-base;
|
||||
padding: 3px;
|
||||
box-shadow: 0 0 1px rgba(0,0,0,0.5);
|
||||
position: absolute;
|
||||
left: calc(~"50% - 25px");
|
||||
top: -31px;
|
||||
background: @background-primary;
|
||||
|
||||
.profile-photo {
|
||||
border-radius: @border-radius-small;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
|
||||
img, .default-profile-image {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.default-profile-image {
|
||||
line-height: 44px;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.core-personal-info {
|
||||
padding-top: 30px;
|
||||
text-align: center;
|
||||
.full-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
.email {
|
||||
color: @text-color-very-subtle;
|
||||
}
|
||||
.social-profiles-wrap {
|
||||
padding: 10px;
|
||||
}
|
||||
.social-profile-item {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
.additional-info {
|
||||
font-size: 12px;
|
||||
p {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.bio {
|
||||
color: @text-color-very-subtle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.platform-win32 {
|
||||
.participant-profile {
|
||||
* {
|
||||
border-radius: 0;
|
||||
}
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
|
@ -7,10 +7,10 @@ module.exports =
|
|||
item: null
|
||||
|
||||
activate: (@state={}) ->
|
||||
ComponentRegistry.register SidebarFullContact,
|
||||
role: "MessageListSidebar:ContactCard"
|
||||
# ComponentRegistry.register SidebarFullContact,
|
||||
# role: "MessageListSidebar:ContactCard"
|
||||
|
||||
deactivate: ->
|
||||
ComponentRegistry.unregister(SidebarFullContact)
|
||||
# ComponentRegistry.unregister(SidebarFullContact)
|
||||
|
||||
serialize: -> @state
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
# ipcMain = require 'ipcMain'
|
||||
# BrowserWindow = require 'browser-window'
|
||||
#
|
||||
# class NativeNotificationManagerUnavailable
|
||||
#
|
||||
# class NativeNotificationManagerWindows
|
||||
# constructor: ->
|
||||
#
|
||||
# class NativeNotificationManagerMacOSX
|
||||
# constructor: ->
|
||||
# @$ = require('nodobjc')
|
||||
# @$.framework('Foundation')
|
||||
#
|
||||
# @_lastNotifId = 1
|
||||
#
|
||||
# @_center = @$.NSUserNotificationCenter('defaultUserNotificationCenter')
|
||||
# @_center('removeAllDeliveredNotifications')
|
||||
#
|
||||
# Delegate = @$.NSObject.extend('NylasNotificationDelegate')
|
||||
# Delegate.addMethod('userNotificationCenter:didActivateNotification:', [@$.void, [Delegate, @$.selector, @$.id, @$.id]], @didActivateNotification)
|
||||
# Delegate.addMethod('userNotificationCenter:shouldPresentNotification:', ['c', [Delegate, @$.selector, @$.id, @$.id]], @shouldPresentNotification)
|
||||
# Delegate.register()
|
||||
# @_delegate = Delegate('alloc')('init')
|
||||
# @_center('setDelegate', @_delegate)
|
||||
#
|
||||
# # Ensure that these objects are never, ever garbage collected
|
||||
# global.__nativeNotificationManagerMacOSXDelegate = Delegate
|
||||
# global.__nativeNotificationManagerMacOSX = @
|
||||
#
|
||||
# ipcMain.on('fire-native-notification', @onFireNotification)
|
||||
#
|
||||
# shouldPresentNotification: (self, _cmd, center, notif) =>
|
||||
# return true
|
||||
#
|
||||
# didActivateNotification: (self, _cmd, center, notif) =>
|
||||
# center("removeDeliveredNotification", notif)
|
||||
#
|
||||
# [header, id, tag] = (""+notif('identifier')).split(':::')
|
||||
#
|
||||
# # Avoid potential conflicts with other libraries that may have pushed
|
||||
# # notifications on our behalf.
|
||||
# return unless header is 'N1'
|
||||
#
|
||||
# NSUserNotificationActivationType = [
|
||||
# "none",
|
||||
# "contents-clicked",
|
||||
# "action-clicked",
|
||||
# "replied",
|
||||
# "additional-action-clicked"
|
||||
# ]
|
||||
#
|
||||
# payload =
|
||||
# tag: tag
|
||||
# activationType: NSUserNotificationActivationType[(""+notif('activationType'))/1]
|
||||
#
|
||||
# if payload.activationType is "replied"
|
||||
# payload.response = (""+notif('response')).replace("{\n}", '')
|
||||
# if payload.response is "null"
|
||||
# payload.response = null
|
||||
#
|
||||
# console.log("Received notification: " + JSON.stringify(payload))
|
||||
# BrowserWindow.getAllWindows().forEach (win) ->
|
||||
# win.webContents.send('activate-native-notification', payload)
|
||||
#
|
||||
# onFireNotification: (event, {title, subtitle, body, tag, canReply}) =>
|
||||
# # By default on Mac OS X, delivering another notification with the same identifier
|
||||
# # triggers an update, which does not re-display the notification. To make subsequent
|
||||
# # calls with the same `tag` redisplay the notification, we:
|
||||
#
|
||||
# # 1. Assign each notification a unique identifier, so it's not considered an update
|
||||
# identifier = "N1:::#{@_lastNotifId}:::#{tag}"
|
||||
# @_lastNotifId += 1
|
||||
#
|
||||
# # 2. Manually remove any previous notification with the same tag
|
||||
# delivered = @_center("deliveredNotifications")
|
||||
# for existing in delivered
|
||||
# [x, x, existingTag] = (""+existing('identifier')).split(':::')
|
||||
# if existingTag is tag
|
||||
# @_center('removeDeliveredNotification', existing)
|
||||
#
|
||||
# # 3. Fire a new notification
|
||||
# notification = @$.NSUserNotification('alloc')('init')
|
||||
# notification('setTitle', @$.NSString('stringWithUTF8String', title))
|
||||
# notification('setIdentifier', @$.NSString('stringWithUTF8String', identifier))
|
||||
# notification('setSubtitle', @$.NSString('stringWithUTF8String', subtitle)) if subtitle
|
||||
# notification('setInformativeText', @$.NSString('stringWithUTF8String', body)) if body
|
||||
# notification('setHasReplyButton', canReply)
|
||||
# @_center('deliverNotification', notification)
|
||||
#
|
||||
# if process.platform is 'darwin'
|
||||
# module.exports = NativeNotificationManagerMacOSX
|
||||
# else if process.platform is 'win32'
|
||||
# module.exports = NativeNotificationManagerWindows
|
||||
# else
|
||||
# module.exports = NativeNotificationManagerUnavailable
|
|
@ -32,9 +32,10 @@ The value of this attribute is always an array of other model objects.
|
|||
Section: Database
|
||||
###
|
||||
class AttributeCollection extends Attribute
|
||||
constructor: ({modelKey, jsonKey, itemClass}) ->
|
||||
constructor: ({modelKey, jsonKey, itemClass, joinOnField}) ->
|
||||
super
|
||||
@itemClass = itemClass
|
||||
@joinOnField = joinOnField
|
||||
@
|
||||
|
||||
toJSON: (vals) ->
|
||||
|
|
|
@ -98,13 +98,13 @@ class Contact extends Model
|
|||
account = AccountStore.accountForEmail(@email)
|
||||
return account?
|
||||
|
||||
isMePhrase: ({includeAccountLabel} = {}) ->
|
||||
isMePhrase: ({includeAccountLabel, forceAccountLabel} = {}) ->
|
||||
account = AccountStore.accountForEmail(@email)
|
||||
return null unless account
|
||||
|
||||
if includeAccountLabel
|
||||
FocusedPerspectiveStore ?= require '../stores/focused-perspective-store'
|
||||
if account and FocusedPerspectiveStore.current().accountIds.length > 1
|
||||
if account and (FocusedPerspectiveStore.current().accountIds.length > 1 || forceAccountLabel)
|
||||
return "You (#{account.label})"
|
||||
|
||||
return "You"
|
||||
|
@ -120,14 +120,17 @@ class Contact extends Model
|
|||
# - compact: If the contact has a name, make the name as short as possible
|
||||
# (generally returns just the first name.)
|
||||
#
|
||||
displayName: ({includeAccountLabel, compact} = {}) ->
|
||||
displayName: ({includeAccountLabel, compact, forceAccountLabel} = {}) ->
|
||||
compact ?= false
|
||||
includeAccountLabel ?= !compact
|
||||
|
||||
if compact
|
||||
@isMePhrase({includeAccountLabel}) ? @firstName()
|
||||
@isMePhrase({includeAccountLabel, forceAccountLabel}) ? @firstName()
|
||||
else
|
||||
@isMePhrase({includeAccountLabel}) ? @_nameParts().join(' ')
|
||||
@isMePhrase({includeAccountLabel, forceAccountLabel}) ? @fullName()
|
||||
|
||||
fullName: ->
|
||||
return @_nameParts().join(' ')
|
||||
|
||||
firstName: ->
|
||||
articles = ['a', 'the']
|
||||
|
@ -139,6 +142,11 @@ class Contact extends Model
|
|||
lastName: ->
|
||||
@_nameParts()[1..-1]?.join(" ") ? ""
|
||||
|
||||
nameAbbreviation: ->
|
||||
c1 = @firstName()[0]?.toUpperCase() ? ""
|
||||
c2 = @lastName()[0]?.toUpperCase() ? ""
|
||||
return c1+c2
|
||||
|
||||
_nameParts: ->
|
||||
name = @name
|
||||
|
||||
|
|
|
@ -66,6 +66,8 @@ class Thread extends ModelWithMetadata
|
|||
itemClass: Category
|
||||
|
||||
'participants': Attributes.Collection
|
||||
queryable: true
|
||||
joinOnField: 'email'
|
||||
modelKey: 'participants'
|
||||
itemClass: Contact
|
||||
|
||||
|
|
|
@ -507,3 +507,32 @@ Utils =
|
|||
catch err
|
||||
console.error("JSON parse error: #{err}")
|
||||
return data
|
||||
|
||||
hueForString: (str='') ->
|
||||
str.split('').map((c) -> c.charCodeAt()).reduce((n,a) -> n+a) % 360
|
||||
|
||||
# Emails that nave no-reply or similar phrases in them are likely not a
|
||||
# human. As such it's not worth the cost to do a lookup on that person.
|
||||
#
|
||||
# Also emails that are really long are likely computer-generated email
|
||||
# strings used for bcc-based automated teasks.
|
||||
likelyNonHumanEmail: (email) ->
|
||||
prefixes = [
|
||||
"noreply"
|
||||
"no-reply"
|
||||
"donotreply"
|
||||
"do-not-reply"
|
||||
"bounce[s]?@"
|
||||
"notification[s]?@"
|
||||
"support@"
|
||||
"alert[s]?@"
|
||||
"news@"
|
||||
"automated@"
|
||||
"list[s]?@"
|
||||
"distribute[s]?@"
|
||||
"catchall@"
|
||||
"catch-all@"
|
||||
]
|
||||
reStr = "(#{prefixes.join("|")})"
|
||||
re = new RegExp(reStr, "gi")
|
||||
return re.test(email) or email.length > 64
|
||||
|
|
|
@ -16,7 +16,7 @@ DatabaseTransaction = require './database-transaction'
|
|||
|
||||
{ipcRenderer} = require 'electron'
|
||||
|
||||
DatabaseVersion = 19
|
||||
DatabaseVersion = 20
|
||||
DatabasePhase =
|
||||
Setup: 'setup'
|
||||
Ready: 'ready'
|
||||
|
|
|
@ -188,7 +188,7 @@ class DatabaseTransaction
|
|||
promises.push @_query("REPLACE INTO `#{klass.name}` (#{columnsSQL}) VALUES #{marksSQL}", values)
|
||||
|
||||
# For each join table property, find all the items in the join table for this
|
||||
# model and delte them. Insert each new value back into the table.
|
||||
# model and delete them. Insert each new value back into the table.
|
||||
collectionAttributes = _.filter attributes, (attr) ->
|
||||
attr.queryable && attr instanceof AttributeCollection
|
||||
|
||||
|
@ -204,7 +204,8 @@ class DatabaseTransaction
|
|||
if joinedModels
|
||||
for joined in joinedModels
|
||||
joinMarks.push('(?,?)')
|
||||
joinedValues.push(model.id, joined.id)
|
||||
joinValue = joined[attr.joinOnField ? "id"]
|
||||
joinedValues.push(model.id, joinValue)
|
||||
|
||||
unless joinedValues.length is 0
|
||||
# Write no more than 200 items (400 values) at once to avoid sqlite limits
|
||||
|
|
|
@ -4,6 +4,7 @@ Rx = require 'rx-lite'
|
|||
Utils = require '../models/utils'
|
||||
Actions = require '../actions'
|
||||
NylasStore = require 'nylas-store'
|
||||
Thread = require '../models/thread'
|
||||
Contact = require '../models/contact'
|
||||
MessageStore = require './message-store'
|
||||
AccountStore = require './account-store'
|
||||
|
@ -21,6 +22,8 @@ class FocusedContactsStore extends NylasStore
|
|||
|
||||
focusedContact: -> @_currentFocusedContact
|
||||
|
||||
focusedContactThreads: -> @_currentParticipantThreads ? []
|
||||
|
||||
# We need to wait now for the MessageStore to grab all of the
|
||||
# appropriate messages for the given thread.
|
||||
|
||||
|
@ -57,10 +60,12 @@ class FocusedContactsStore extends NylasStore
|
|||
@_unsubFocusedContact = null
|
||||
@_currentFocusedContact = null
|
||||
@_currentThread = null
|
||||
@_currentParticipantThreads = []
|
||||
|
||||
_onFocusContact: (contact) =>
|
||||
@_unsubFocusedContact?.dispose()
|
||||
@_unsubFocusedContact = null
|
||||
@_currentParticipantThreads = []
|
||||
|
||||
if contact
|
||||
query = DatabaseStore.findBy(Contact, {
|
||||
|
@ -70,10 +75,16 @@ class FocusedContactsStore extends NylasStore
|
|||
@_unsubFocusedContact = Rx.Observable.fromQuery(query).subscribe (match) =>
|
||||
@_currentFocusedContact = match ? contact
|
||||
@trigger()
|
||||
@_loadCurrentParticipantThreads(contact.email)
|
||||
else
|
||||
@_currentFocusedContact = null
|
||||
@trigger()
|
||||
|
||||
_loadCurrentParticipantThreads: (email) ->
|
||||
DatabaseStore.findAll(Thread).where(Thread.attributes.participants.contains(email)).limit(100).then (threads = []) =>
|
||||
@_currentParticipantThreads = threads
|
||||
@trigger()
|
||||
|
||||
# We score everyone to determine who's the most relevant to display in
|
||||
# the sidebar.
|
||||
_scoreAllParticipants: ->
|
||||
|
|
Loading…
Reference in a new issue