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:
Evan Morikawa 2016-03-09 14:33:31 -05:00
parent 801325bb38
commit d4ef6a20e5
25 changed files with 684 additions and 242 deletions

View file

@ -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

View file

@ -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}

View file

@ -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>
)
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View 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() {
}

View file

@ -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()

View file

@ -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},&nbsp;</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>
)
}
}

View file

@ -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>
)
}
}

View 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"
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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

View file

@ -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) ->

View file

@ -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

View file

@ -66,6 +66,8 @@ class Thread extends ModelWithMetadata
itemClass: Category
'participants': Attributes.Collection
queryable: true
joinOnField: 'email'
modelKey: 'participants'
itemClass: Contact

View file

@ -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

View file

@ -16,7 +16,7 @@ DatabaseTransaction = require './database-transaction'
{ipcRenderer} = require 'electron'
DatabaseVersion = 19
DatabaseVersion = 20
DatabasePhase =
Setup: 'setup'
Ready: 'ready'

View file

@ -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

View file

@ -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: ->