+
+
+
+
+
Saved Public Keys
+
+
+
+ { pubKeys }
+
+
+
+
Saved Private Keys
+
+
+
+ { privKeys }
+
+
diff --git a/packages/client-app/internal_packages/keybase/lib/keybase-search.cjsx b/packages/client-app/internal_packages/keybase/lib/keybase-search.cjsx
new file mode 100755
index 0000000000..ec07b1c7f3
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/keybase-search.cjsx
@@ -0,0 +1,189 @@
+{Utils,
+ React,
+ ReactDOM,
+ Actions,
+ RegExpUtils,
+ IdentityStore,
+ AccountStore,
+ LegacyEdgehillAPI} = require 'nylas-exports'
+{RetinaImg} = require 'nylas-component-kit'
+EmailPopover = require './email-popover'
+PGPKeyStore = require './pgp-key-store'
+KeybaseUser = require '../lib/keybase-user'
+Identity = require './identity'
+kb = require './keybase'
+_ = require 'underscore'
+
+class KeybaseInviteButton extends React.Component
+
+ constructor: (@props) ->
+ @state = {
+ loading: false,
+ }
+
+ _onGetKeybaseInvite: =>
+ @setState({loading: true})
+
+ errorHandler = (err) =>
+ @setState({loading: false})
+ NylasEnv.showErrorDialog(err.message)
+
+ req = LegacyEdgehillAPI.makeRequest({
+ authWithNylasAPI: true
+ path: "/keybase-invite",
+ method: "POST",
+ body:
+ n1_id: IdentityStore.identityId(),
+ })
+ req.run()
+ .then((body) =>
+ @setState({loading: false})
+ try
+ if not (body instanceof Object) or not body.invite_url
+ throw new Error("We were unable to retrieve an invitation.")
+ catch err
+ errorHandler(err)
+ require('electron').shell.openExternal(body.invite_url)
+ )
+ .catch(errorHandler)
+
+ render: =>
+ if @state.loading
+ Processing...
+ else
+ We've got an invite for you!
+
+module.exports =
+class KeybaseSearch extends React.Component
+ @displayName: 'KeybaseSearch'
+
+ @propTypes:
+ initialSearch: React.PropTypes.string
+ # importFunc: a alternate function to execute when the "import" button is
+ # clicked instead of the "please specify an email" popover
+ importFunc: React.PropTypes.func
+ # TODO consider just passing in a pre-specified email instead of a func?
+ inPreferences: React.PropTypes.bool
+
+ @defaultProps:
+ initialSearch: ""
+ importFunc: null
+ inPreferences: false
+
+ constructor: (props) ->
+ super(props)
+ @state = {
+ query: props.initialSearch
+ results: []
+ loading: false
+ searchedByEmail: false
+ }
+
+ @debouncedSearch = _.debounce(@_search, 300)
+
+ componentDidMount: ->
+ @_search()
+
+ componentWillReceiveProps: (props) ->
+ @setState({query: props.initialSearch})
+
+ _search: ->
+ oldquery = @state.query
+ if @state.query != "" and @state.loading == false
+ @setState({loading: true})
+ kb.autocomplete(@state.query, (error, profiles) =>
+ if profiles?
+ profiles = _.map(profiles, (profile) ->
+ return new Identity({keybase_profile: profile, isPriv: false})
+ )
+ @setState({results: profiles, loading: false})
+ else
+ @setState({results: [], loading: false})
+ if @state.query != oldquery
+ @debouncedSearch()
+ )
+ else
+ # no query - empty out the results
+ @setState({results: []})
+
+ _importKey: (profile, event) =>
+ # opens a popover requesting user to enter 1+ emails to associate with a
+ # key - a button in the popover then calls _save to actually import the key
+ popoverTarget = event.target.getBoundingClientRect()
+
+ Actions.openPopover(
+ ,
+ {originRect: popoverTarget, direction: 'left'}
+ )
+
+ _popoverDone: (addresses, identity) =>
+ if addresses.length < 1
+ # no email addresses added, noop
+ return
+ else
+ identity.addresses = addresses
+ # TODO validate the addresses?
+ @_save(identity)
+
+ _save: (identity) =>
+ # save/import a key from keybase
+ keybaseUsername = identity.keybase_profile.components.username.val
+
+ kb.getKey(keybaseUsername, (error, key) =>
+ if error
+ console.error error
+ else
+ PGPKeyStore.saveNewKey(identity, key)
+ )
+
+ _queryChange: (event) =>
+ emailQuery = RegExpUtils.emailRegex().test(event.target.value)
+ @setState({query: event.target.value, searchedByEmail: emailQuery})
+ @debouncedSearch()
+
+ render: ->
+ profiles = _.map(@state.results, (profile) =>
+
+ # allow for overriding the import function
+ if typeof @props.importFunc is "function"
+ boundFunc = @props.importFunc
+ else
+ boundFunc = @_importKey
+
+ saveButton = ( boundFunc(profile, event) } ref="button">
+ Import Key
+
+ )
+
+ # TODO improved deduping? tricky because of the kbprofile - email association
+ if not profile.keyPath?
+ return
+ )
+
+ if not profiles? or profiles.length < 1
+ profiles = []
+
+ badSearch = null
+ loading = null
+ empty = null
+
+ if profiles.length < 1 and @state.searchedByEmail
+ badSearch = Keybase cannot be searched by email address. Try entering a name, or a username from GitHub, Keybase or Twitter.
+
+ if @state.loading
+ loading =
+
+ if @props.inPreferences and not loading and not badSearch and profiles.length is 0
+ empty = Not a Keybase user yet?
+
+
+
+
+ {empty}
+
{ loading }
+
+
+ { profiles }
+ { badSearch }
+
+
diff --git a/packages/client-app/internal_packages/keybase/lib/keybase-user.cjsx b/packages/client-app/internal_packages/keybase/lib/keybase-user.cjsx
new file mode 100755
index 0000000000..9f65965f32
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/keybase-user.cjsx
@@ -0,0 +1,135 @@
+{Utils, React, Actions} = require 'nylas-exports'
+{ParticipantsTextField} = require 'nylas-component-kit'
+PGPKeyStore = require './pgp-key-store'
+EmailPopover = require './email-popover'
+Identity = require './identity'
+kb = require './keybase'
+_ = require 'underscore'
+
+module.exports =
+class KeybaseUser extends React.Component
+ @displayName: 'KeybaseUserProfile'
+
+ @propTypes:
+ profile: React.PropTypes.instanceOf(Identity).isRequired
+ actionButton: React.PropTypes.node
+ displayEmailList: React.PropTypes.bool
+
+ @defaultProps:
+ actionButton: false
+ displayEmailList: true
+
+ constructor: (props) ->
+ super(props)
+
+ componentDidMount: ->
+ PGPKeyStore.getKeybaseData(@props.profile)
+
+ _addEmail: (email) =>
+ PGPKeyStore.addAddressToKey(@props.profile, email)
+
+ _addEmailClick: (event) =>
+ popoverTarget = event.target.getBoundingClientRect()
+
+ Actions.openPopover(
+ ,
+ {originRect: popoverTarget, direction: 'left'}
+ )
+
+ _popoverDone: (addresses, identity) =>
+ if addresses.length < 1
+ # no email addresses added, noop
+ return
+ else
+ _.each(addresses, (address) =>
+ @_addEmail(address))
+
+ _removeEmail: (email) =>
+ PGPKeyStore.removeAddressFromKey(@props.profile, email)
+
+ render: =>
+ {profile} = @props
+
+ keybaseDetails =
+ if profile.keybase_profile?
+ keybase = profile.keybase_profile
+
+ # profile picture
+ if keybase.thumbnail?
+ picture =
+ else
+ hue = Utils.hueForString("Keybase")
+ bgColor = "hsl(#{hue}, 50%, 45%)"
+ abv = "K"
+ picture = {abv}
+
+ # full name
+ if keybase.components.full_name?.val?
+ fullname = keybase.components.full_name.val
+ else
+ fullname = username
+ username = false
+
+ # link to keybase profile
+ keybase_url = "keybase.io/#{keybase.components.username.val}"
+ if keybase_url.length > 25
+ keybase_string = keybase_url.slice(0, 23).concat('...')
+ else
+ keybase_string = keybase_url
+ username = {keybase_string}
+
+ # TODO: potentially display confirmation on keybase-user objects
+ ###
+ possible_profiles = ["twitter", "github", "coinbase"]
+ profiles = _.map(possible_profiles, (possible) =>
+ if keybase.components[possible]?.val?
+ # TODO icon instead of weird "service: username" text
+ return ({ possible } : { keybase.components[possible].val } )
+ )
+ profiles = _.reject(profiles, (profile) -> profile is undefined)
+
+ profiles = _.map(profiles, (profile) ->
+ return { profile } )
+ profileList = ({ profiles } )
+ ###
+
+ keybaseDetails = (
+
+ { fullname }
+
+
+ { username }
+
+
)
+ else
+ # if no keybase profile, default image is based on email address
+ hue = Utils.hueForString(@props.profile.addresses[0])
+ bgColor = "hsl(#{hue}, 50%, 45%)"
+ abv = @props.profile.addresses[0][0].toUpperCase()
+ picture = {abv}
+
+ # email addresses
+ if profile.addresses?.length > 0
+ emails = _.map(profile.addresses, (email) =>
+ # TODO make that remove button not terrible
+ return { email } @_removeEmail(email) }>(X) )
+ emailList = ()
+
+ emailListDiv = ()
+
+
+
+ { keybaseDetails }
+ {if @props.displayEmailList then emailListDiv}
+ { @props.actionButton }
+
diff --git a/packages/client-app/internal_packages/keybase/lib/keybase.coffee b/packages/client-app/internal_packages/keybase/lib/keybase.coffee
new file mode 100755
index 0000000000..d0859104dd
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/keybase.coffee
@@ -0,0 +1,61 @@
+_ = require 'underscore'
+request = require 'request'
+
+class KeybaseAPI
+ constructor: ->
+ @baseUrl = "https://keybase.io"
+
+ getUser: (key, keyType, callback) =>
+ if not keyType in ['usernames', 'domain', 'twitter', 'github', 'reddit',
+ 'hackernews', 'coinbase', 'key_fingerprint']
+ console.error 'keyType must be a supported Keybase query type.'
+
+ this._keybaseRequest("/_/api/1.0/user/lookup.json?#{keyType}=#{key}", (err, resp, obj) =>
+ return callback(err, null) if err
+ return callback(new Error("Empty response!"), null) if not obj? or not obj.them?
+ if obj.status?
+ return callback(new Error(obj.status.desc), null) if obj.status.name != "OK"
+
+ callback(null, _.map(obj.them, @_regularToAutocomplete))
+ )
+
+ getKey: (username, callback) =>
+ request({url: @baseUrl + "/#{username}/key.asc", headers: {'User-Agent': 'request'}}, (err, resp, obj) =>
+ return callback(err, null) if err
+ return callback(new Error("No key found for #{username}"), null) if not obj?
+ return callback(new Error("No key returned from keybase for #{username}"), null) if not obj.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----")
+ callback(null, obj)
+ )
+
+ autocomplete: (query, callback) =>
+ url = "/_/api/1.0/user/autocomplete.json"
+ request({url: @baseUrl + url, form: {q: query}, headers: {'User-Agent': 'request'}, json: true}, (err, resp, obj) =>
+ return callback(err, null) if err
+ if obj.status?
+ return callback(new Error(obj.status.desc), null) if obj.status.name != "OK"
+
+ callback(null, obj.completions)
+ )
+
+ _keybaseRequest: (url, callback) =>
+ return request({url: @baseUrl + url, headers: {'User-Agent': 'request'}, json: true}, callback)
+
+ _regularToAutocomplete: (profile) ->
+ # converts a keybase profile to the weird format used in the autocomplete
+ # endpoint for backward compatability
+ # (does NOT translate accounts - e.g. twitter, github - yet)
+ # TODO this should be the other way around
+ cleanedProfile = {components: {}}
+ cleanedProfile.thumbnail = null
+ if profile.pictures?.primary?
+ cleanedProfile.thumbnail = profile.pictures.primary.url
+ safe_name = if profile.profile? then profile.profile.full_name else ""
+ cleanedProfile.components = {full_name: {val: safe_name }, username: {val: profile.basics.username}}
+ _.each(profile.proofs_summary.all, (connectedAccount) =>
+ component = {}
+ component[connectedAccount.proof_type] = {val: connectedAccount.nametag}
+ cleanedProfile.components = _.extend(cleanedProfile.components, component)
+ )
+ return cleanedProfile
+
+module.exports = new KeybaseAPI()
diff --git a/packages/client-app/internal_packages/keybase/lib/main.es6 b/packages/client-app/internal_packages/keybase/lib/main.es6
new file mode 100755
index 0000000000..5f4d02dced
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/main.es6
@@ -0,0 +1,34 @@
+import {PreferencesUIStore, ComponentRegistry, ExtensionRegistry} from 'nylas-exports';
+
+import EncryptMessageButton from './encrypt-button';
+import DecryptMessageButton from './decrypt-button';
+import DecryptPGPExtension from './decryption-preprocess';
+import RecipientKeyChip from './recipient-key-chip';
+import PreferencesKeybase from './preferences-keybase';
+
+const PREFERENCE_TAB_ID = 'Encryption'
+
+export function activate() {
+ const preferencesTab = new PreferencesUIStore.TabItem({
+ tabId: PREFERENCE_TAB_ID,
+ displayName: 'Encryption',
+ component: PreferencesKeybase,
+ });
+ ComponentRegistry.register(EncryptMessageButton, {role: 'Composer:ActionButton'});
+ ComponentRegistry.register(DecryptMessageButton, {role: 'message:BodyHeader'});
+ ComponentRegistry.register(RecipientKeyChip, {role: 'Composer:RecipientChip'});
+ ExtensionRegistry.MessageView.register(DecryptPGPExtension);
+ PreferencesUIStore.registerPreferencesTab(preferencesTab);
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(EncryptMessageButton);
+ ComponentRegistry.unregister(DecryptMessageButton);
+ ComponentRegistry.unregister(RecipientKeyChip);
+ ExtensionRegistry.MessageView.unregister(DecryptPGPExtension);
+ PreferencesUIStore.unregisterPreferencesTab(PREFERENCE_TAB_ID);
+}
+
+export function serialize() {
+ return {};
+}
diff --git a/packages/client-app/internal_packages/keybase/lib/modal-key-recommender.cjsx b/packages/client-app/internal_packages/keybase/lib/modal-key-recommender.cjsx
new file mode 100644
index 0000000000..d912108b63
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/modal-key-recommender.cjsx
@@ -0,0 +1,157 @@
+{Utils, React, Actions} = require 'nylas-exports'
+PGPKeyStore = require './pgp-key-store'
+KeybaseSearch = require './keybase-search'
+KeybaseUser = require './keybase-user'
+kb = require './keybase'
+_ = require 'underscore'
+
+module.exports =
+class ModalKeyRecommender extends React.Component
+
+ @displayName: 'ModalKeyRecommender'
+
+ @propTypes:
+ contacts: React.PropTypes.array.isRequired
+ emails: React.PropTypes.array
+ callback: React.PropTypes.func
+
+ @defaultProps:
+ callback: -> return # NOP
+
+ constructor: (props) ->
+ super(props)
+ @state = Object.assign({
+ currentContact: 0},
+ @_getStateFromStores())
+
+ componentDidMount: ->
+ @unlistenKeystore = PGPKeyStore.listen(@_onKeystoreChange)
+
+ componentWillUnmount: ->
+ @unlistenKeystore()
+
+ _onKeystoreChange: =>
+ @setState(@_getStateFromStores())
+
+ _getStateFromStores: =>
+ identities: PGPKeyStore.pubKeys(@props.emails)
+
+ _selectProfile: (address, identity) =>
+ # TODO this is an almost exact duplicate of keybase-search.cjsx:_save
+ keybaseUsername = identity.keybase_profile.components.username.val
+ identity.addresses.push(address)
+ kb.getKey(keybaseUsername, (error, key) =>
+ if error
+ console.error error
+ else
+ PGPKeyStore.saveNewKey(identity, key)
+ )
+
+ _onNext: =>
+ # NOTE: this doesn't do bounds checks! you must do that in render()!
+ @setState({currentContact: @state.currentContact + 1})
+
+ _onPrev: =>
+ # NOTE: this doesn't do bounds checks! you must do that in render()!
+ @setState({currentContact: @state.currentContact - 1})
+
+ _setPage: (page) =>
+ # NOTE: this doesn't do bounds checks! you must do that in render()!
+ @setState({currentContact: page})
+ # indexes from 0 because what kind of monster doesn't
+
+ _onDone: =>
+ if @state.identities.length < @props.emails.length
+ if !PGPKeyStore._displayDialog(
+ 'Encrypt without keys for all recipients?',
+ 'Some recipients are missing PGP public keys. They will not be able to decrypt this message.',
+ ['Encrypt', 'Cancel']
+ )
+ return
+
+ emptyIdents = _.filter(@state.identities, (identity) -> !identity.key?)
+ if emptyIdents.length == 0
+ Actions.closePopover()
+ @props.callback(@state.identities)
+ else
+ newIdents = []
+ for idIndex of emptyIdents
+ identity = emptyIdents[idIndex]
+ if idIndex < emptyIdents.length - 1
+ PGPKeyStore.getKeyContents(key: identity, callback: (identity) => newIdents.push(identity))
+ else
+ PGPKeyStore.getKeyContents(key: identity, callback: (identity) =>
+ newIdents.push(identity)
+ @props.callback(newIdents)
+ Actions.closePopover()
+ )
+
+ _onManageKeys: =>
+ Actions.switchPreferencesTab('Encryption')
+ Actions.openPreferences()
+
+ render: ->
+ # find the email we're dealing with now
+ email = @props.emails[@state.currentContact]
+ # and a corresponding contact
+ contact = _.findWhere(@props.contacts, {'email': email})
+ contactString = if contact? then contact.toString() else email
+ # find the identity object that goes with this email (if any)
+ identity = _.find(@state.identities, (identity) ->
+ return email in identity.addresses
+ )
+
+ if @state.currentContact == (@props.emails.length - 1)
+ # last one
+ if @props.emails.length == 1
+ # only one
+ backButton = false
+ else
+ backButton = Back
+ nextButton = Done
+ else if @state.currentContact == 0
+ # first one
+ backButton = false
+ nextButton = Next
+ else
+ # somewhere in the middle
+ backButton = Back
+ nextButton = Next
+
+ if identity?
+ deleteButton = ( PGPKeyStore.deleteKey(identity) } ref="button">
+ Delete Key
+
+ )
+ body = [
+ This PGP public key has been saved for { contactString }.
+
+
+
+ ]
+ else
+ if contact?
+ query = contact.fullName()
+ # don't search Keybase for emails, won't work anyways
+ if not query.match(/\s/)?
+ query = ""
+ else
+ query = ""
+ importFunc = ((identity) => @_selectProfile(email, identity))
+
+ body = [
+ There is no PGP public key saved for { contactString }.
+
+ ]
+
+ prefsButton = Advanced Key Management
+
+
+ { body }
+
+
+
{ backButton }
+ { prefsButton }
+
{ nextButton }
+
+
diff --git a/packages/client-app/internal_packages/keybase/lib/passphrase-popover.cjsx b/packages/client-app/internal_packages/keybase/lib/passphrase-popover.cjsx
new file mode 100644
index 0000000000..423f6edade
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/passphrase-popover.cjsx
@@ -0,0 +1,80 @@
+{React, Actions} = require 'nylas-exports'
+Identity = require './identity'
+PGPKeyStore = require './pgp-key-store'
+_ = require 'underscore'
+fs = require 'fs'
+pgp = require 'kbpgp'
+
+module.exports =
+class PassphrasePopover extends React.Component
+ constructor: ->
+ @state = {
+ passphrase: ""
+ placeholder: "PGP private key password"
+ error: false
+ mounted: true
+ }
+
+ componentDidMount: ->
+ @_mounted = true
+
+ componentWillUnmount: ->
+ @_mounted = false
+
+ @propTypes:
+ identity: React.PropTypes.instanceOf(Identity)
+ addresses: React.PropTypes.array
+
+ render: ->
+ classNames = if @state.error then "key-passphrase-input form-control bad-passphrase" else "key-passphrase-input form-control"
+
+
+ Done
+
+
+ _onPassphraseChange: (event) =>
+ @setState
+ passphrase: event.target.value
+ placeholder: "PGP private key password"
+ error: false
+
+ _onKeyUp: (event) =>
+ if event.keyCode == 13
+ @_validatePassphrase()
+
+ _validatePassphrase: =>
+ passphrase = @state.passphrase
+ for emailIndex of @props.addresses
+ email = @props.addresses[emailIndex]
+ privateKeys = PGPKeyStore.privKeys(address: email, timed: false)
+ for keyIndex of privateKeys
+ # check to see if the password unlocks the key
+ key = privateKeys[keyIndex]
+ fs.readFile(key.keyPath, (err, data) =>
+ pgp.KeyManager.import_from_armored_pgp {
+ armored: data
+ }, (err, km) =>
+ if err
+ console.warn err
+ else
+ km.unlock_pgp { passphrase: passphrase }, (err) =>
+ if err
+ if parseInt(keyIndex, 10) == privateKeys.length - 1
+ if parseInt(emailIndex, 10) == @props.addresses.length - 1
+ # every key has been tried, the password failed on all of them
+ if @_mounted
+ @setState
+ passphrase: ""
+ placeholder: "Incorrect password"
+ error: true
+ else
+ # the password unlocked a key; that key should be used
+ @_onDone()
+ )
+
+ _onDone: =>
+ if @props.identity?
+ @props.onPopoverDone(@state.passphrase, @props.identity)
+ else
+ @props.onPopoverDone(@state.passphrase)
+ Actions.closePopover()
diff --git a/packages/client-app/internal_packages/keybase/lib/pgp-key-store.cjsx b/packages/client-app/internal_packages/keybase/lib/pgp-key-store.cjsx
new file mode 100755
index 0000000000..5275be3506
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/pgp-key-store.cjsx
@@ -0,0 +1,498 @@
+NylasStore = require 'nylas-store'
+{Actions, FileDownloadStore, DraftStore, MessageBodyProcessor, RegExpUtils} = require 'nylas-exports'
+{remote, shell} = require 'electron'
+Identity = require './identity'
+kb = require './keybase'
+pgp = require 'kbpgp'
+_ = require 'underscore'
+path = require 'path'
+fs = require 'fs'
+os = require 'os'
+
+class PGPKeyStore extends NylasStore
+
+ constructor: ->
+ super()
+
+ @_identities = {}
+
+ @_msgCache = []
+ @_msgStatus = []
+
+ # Recursive subdir watching only works on OSX / Windows. annoying
+ @_pubWatcher = null
+ @_privWatcher = null
+
+ @_keyDir = path.join(NylasEnv.getConfigDirPath(), 'keys')
+ @_pubKeyDir = path.join(@_keyDir, 'public')
+ @_privKeyDir = path.join(@_keyDir, 'private')
+
+ # Create the key storage file system if it doesn't already exist
+ fs.access(@_keyDir, fs.R_OK | fs.W_OK, (err) =>
+ if err
+ fs.mkdir(@_keyDir, (err) =>
+ if err
+ console.warn err
+ else
+ fs.mkdir(@_pubKeyDir, (err) =>
+ if err
+ console.warn err
+ else
+ fs.mkdir(@_privKeyDir, (err) =>
+ if err
+ console.warn err
+ else
+ @watch())))
+ else
+ fs.access(@_pubKeyDir, fs.R_OK | fs.W_OK, (err) =>
+ if err
+ fs.mkdir(@_pubKeyDir, (err) =>
+ if err
+ console.warn err))
+ fs.access(@_privKeyDir, fs.R_OK | fs.W_OK, (err) =>
+ if err
+ fs.mkdir(@_privKeyDir, (err) =>
+ if err
+ console.warn err))
+ @_populate()
+ @watch())
+
+ validAddress: (address, isPub) =>
+ if (!address || address.length == 0)
+ @_displayError('You must provide an email address.')
+ return false
+ if not (RegExpUtils.emailRegex().test(address))
+ @_displayError('Invalid email address.')
+ return false
+ keys = if isPub then @pubKeys(address) else @privKeys({address: address, timed: false})
+ keystate = if isPub then 'public' else 'private'
+ if (keys.length > 0)
+ @_displayError("A PGP #{keystate} key for that email address already exists.")
+ return false
+ return true
+
+ ### I/O and File Tracking ###
+
+ watch: =>
+ if (!@_pubWatcher)
+ @_pubWatcher = fs.watch(@_pubKeyDir, @_populate)
+ if (!@_privWatcher)
+ @_privWatcher = fs.watch(@_privKeyDir, @_populate)
+
+ unwatch: =>
+ if (@_pubWatcher)
+ @_pubWatcher.close()
+ @_pubWatcher = null
+ if (@_privWatcher)
+ @_privWatcher.close()
+ @_privWatcher = null
+
+ _populate: =>
+ # add identity elements to later be populated with keys from disk
+ # TODO if this function is called multiple times in quick succession it
+ # will duplicate keys - need to do deduplication on add
+ fs.readdir(@_pubKeyDir, (err, pubFilenames) =>
+ fs.readdir(@_privKeyDir, (err, privFilenames) =>
+ @_identities = {}
+ _.each([[pubFilenames, false], [privFilenames, true]], (readresults) =>
+ filenames = readresults[0]
+ i = 0
+ if filenames.length == 0
+ @trigger(@)
+ while i < filenames.length
+ filename = filenames[i]
+ if filename[0] == '.'
+ continue
+ ident = new Identity({
+ addresses: filename.split(" ")
+ isPriv: readresults[1]
+ })
+ @_identities[ident.clientId] = ident
+ @trigger(@)
+ i++)
+ )
+ )
+
+ getKeyContents: ({key, passphrase, callback}) =>
+ # Reads an actual PGP key from disk and adds it to the preexisting metadata
+ if not key.keyPath?
+ console.error "Identity has no path for key!", key
+ return
+ fs.readFile(key.keyPath, (err, data) =>
+ pgp.KeyManager.import_from_armored_pgp {
+ armored: data
+ }, (err, km) =>
+ if err
+ console.warn err
+ else
+ if km.is_pgp_locked()
+ # private key - check passphrase
+ passphrase ?= ""
+ km.unlock_pgp { passphrase: passphrase }, (err) =>
+ if err
+ # decrypt checks all keys, so DON'T open an error dialog
+ console.warn err
+ return
+ else
+ key.key = km
+ key.setTimeout()
+ if callback?
+ callback(key)
+ else
+ # public key - get keybase data
+ key.key = km
+ key.setTimeout()
+ @getKeybaseData(key)
+ if callback?
+ callback(key)
+ @trigger(@)
+ )
+
+ getKeybaseData: (identity) =>
+ # Given a key, fetches metadata from keybase about that key
+ # TODO currently only works for public keys
+ if not identity.key? and not identity.isPriv and not identity.keybase_profile
+ @getKeyContents(key: identity)
+ else
+ fingerprint = identity.fingerprint()
+ if fingerprint?
+ kb.getUser(fingerprint, 'key_fingerprint', (err, user) =>
+ if err
+ console.error(err)
+ if user?.length == 1
+ identity.keybase_profile = user[0]
+ @trigger(@)
+ )
+
+ saveNewKey: (identity, contents) =>
+ # Validate the email address(es), then write to file.
+ if not identity instanceof Identity
+ console.error "saveNewKey requires an identity object"
+ return
+ addresses = identity.addresses
+ if addresses.length < 1
+ console.error "Identity must have at least one email address to save key"
+ return
+ if _.every(addresses, (address) => @validAddress(address, !identity.isPriv))
+ # Just say no to trailing whitespace.
+ if contents.charAt(contents.length - 1) != '-'
+ contents = contents.slice(0, -1)
+ fs.writeFile(identity.keyPath, contents, (err) =>
+ if (err)
+ @_displayError(err)
+ )
+
+ exportKey: ({identity, passphrase}) =>
+ atIndex = identity.addresses[0].indexOf("@")
+ suffix = if identity.isPriv then "-private.asc" else ".asc"
+ shortName = identity.addresses[0].slice(0, atIndex).concat(suffix)
+ NylasEnv.savedState.lastKeybaseDownloadDirectory ?= os.homedir()
+ savePath = path.join(NylasEnv.savedState.lastKeybaseDownloadDirectory, shortName)
+ @getKeyContents(key: identity, passphrase: passphrase, callback: ( (identity) =>
+ NylasEnv.showSaveDialog({
+ title: "Export PGP Key",
+ defaultPath: savePath,
+ }, (keyPath) =>
+ if (!keyPath)
+ return
+ NylasEnv.savedState.lastKeybaseDownloadDirectory = keyPath.slice(0, keyPath.length - shortName.length)
+ if passphrase?
+ identity.key.export_pgp_private {passphrase: passphrase}, (err, pgp_private) =>
+ if (err)
+ @_displayError(err)
+ fs.writeFile(keyPath, pgp_private, (err) =>
+ if (err)
+ @_displayError(err)
+ shell.showItemInFolder(keyPath)
+ )
+ else
+ identity.key.export_pgp_public {}, (err, pgp_public) =>
+ fs.writeFile(keyPath, pgp_public, (err) =>
+ if (err)
+ @_displayError(err)
+ shell.showItemInFolder(keyPath)
+ )
+ )
+ )
+ )
+
+ deleteKey: (key) =>
+ if this._displayDialog(
+ 'Delete this key?',
+ 'The key will be permanently deleted.',
+ ['Delete', 'Cancel']
+ )
+ fs.unlink(key.keyPath, (err) =>
+ if (err)
+ @_displayError(err)
+ @_populate()
+ )
+
+ addAddressToKey: (profile, address) =>
+ if @validAddress(address, !profile.isPriv)
+ oldPath = profile.keyPath
+ profile.addresses.push(address)
+ fs.rename(oldPath, profile.keyPath, (err) =>
+ if (err)
+ @_displayError(err)
+ )
+
+ removeAddressFromKey: (profile, address) =>
+ if profile.addresses.length > 1
+ oldPath = profile.keyPath
+ profile.addresses = _.without(profile.addresses, address)
+ fs.rename(oldPath, profile.keyPath, (err) =>
+ if (err)
+ @_displayError(err)
+ )
+ else
+ @deleteKey(profile)
+
+ ### Internal Key Management ###
+
+ pubKeys: (addresses) =>
+ # fetch public identity/ies for an address (synchronous)
+ # if no address, return them all
+ identities = _.where(_.values(@_identities), {isPriv: false})
+
+ if not addresses?
+ return identities
+
+ if typeof addresses is "string"
+ addresses = [addresses]
+
+ identities = _.filter(identities, (identity) ->
+ return _.intersection(addresses, identity.addresses).length > 0
+ )
+ return identities
+
+ privKeys: ({address, timed} = {timed: true}) =>
+ # fetch private identity/ies for an address (synchronous).
+ # by default, only return non-timed-out keys
+ # if no address, return them all
+ identities = _.where(_.values(@_identities), {isPriv: true})
+
+ if address?
+ identities = _.filter(identities, (identity) ->
+ return address in identity.addresses
+ )
+
+ if timed
+ identities = _.reject(identities, (identity) ->
+ return identity.isTimedOut()
+ )
+
+ return identities
+
+ _displayError: (err) ->
+ dialog = remote.dialog
+ dialog.showErrorBox('Key Management Error', err.toString())
+
+ _displayDialog: (title, message, buttons) ->
+ dialog = remote.dialog
+ return (dialog.showMessageBox({
+ title: title,
+ message: title,
+ detail: message,
+ buttons: buttons,
+ type: 'info',
+ }) == 0)
+
+ msgStatus: (msg) ->
+ # fetch the latest status of a message
+ if not msg?
+ return null
+ else
+ clientId = msg.clientId
+ statuses = _.filter @_msgStatus, (status) ->
+ return status.clientId == clientId
+ status = _.max statuses, (stat) ->
+ return stat.time
+ return status.message
+
+ isDecrypted: (message) ->
+ # if the message is already decrypted, return true
+ # if the message has no encrypted component, return true
+ # if the message has an encrypted component that is not yet decrypted, return false
+ if not @hasEncryptedComponent(message)
+ return true
+ else if @getDecrypted(message)?
+ return true
+ else
+ return false
+
+ getDecrypted: (message) =>
+ # Fetch a cached decrypted message
+ # (synchronous)
+
+ if message.clientId in _.pluck(@_msgCache, 'clientId')
+ msg = _.findWhere(@_msgCache, {clientId: message.clientId})
+ if msg.timeout > Date.now()
+ return msg.body
+
+ # otherwise
+ return null
+
+ hasEncryptedComponent: (message) ->
+ if not message.body?
+ return false
+
+ # find a PGP block
+ pgpStart = "-----BEGIN PGP MESSAGE-----"
+ pgpEnd = "-----END PGP MESSAGE-----"
+
+ blockStart = message.body.indexOf(pgpStart)
+ blockEnd = message.body.indexOf(pgpEnd)
+ # if they're both present, assume an encrypted block
+ return (blockStart >= 0 and blockEnd >= 0)
+
+ fetchEncryptedAttachments: (message) ->
+ encrypted = _.map(message.files, (file) =>
+ # calendars don't have filenames
+ if file.filename?
+ tokenized = file.filename.split('.')
+ extension = tokenized[tokenized.length - 1]
+ if extension == "asc" or extension == "pgp"
+ # something.asc or something.pgp -> assume encrypted attachment
+ return file
+ else
+ return null
+ else
+ return null
+ )
+ # NOTE for now we don't verify that the .asc/.pgp files actually have a PGP
+ # block inside
+
+ return _.compact(encrypted)
+
+ decrypt: (message) =>
+ # decrypt a message, cache the result
+ # (asynchronous)
+
+ # check to make sure we haven't already decrypted and cached the message
+ # note: could be a race condition here causing us to decrypt multiple times
+ # (not that that's a big deal other than minor resource wastage)
+ if @getDecrypted(message)?
+ return
+
+ if not @hasEncryptedComponent(message)
+ return
+
+ # fill our keyring with all possible private keys
+ ring = new pgp.keyring.KeyRing
+ # (the unbox function will use the right one)
+
+ for key in @privKeys({timed: true})
+ if key.key?
+ ring.add_key_manager(key.key)
+
+ # find a PGP block
+ pgpStart = "-----BEGIN PGP MESSAGE-----"
+ blockStart = message.body.indexOf(pgpStart)
+
+ pgpEnd = "-----END PGP MESSAGE-----"
+ blockEnd = message.body.indexOf(pgpEnd) + pgpEnd.length
+
+ # if we don't find those, it isn't encrypted
+ return unless (blockStart >= 0 and blockEnd >= 0)
+
+ pgpMsg = message.body.slice(blockStart, blockEnd)
+
+ # Some users may send messages from sources that pollute the encrypted block.
+ pgpMsg = pgpMsg.replace(/+/gm,'+')
+ pgpMsg = pgpMsg.replace(/( )/g, '\n')
+ pgpMsg = pgpMsg.replace(/<\/(blockquote|div|dl|dt|dd|form|h1|h2|h3|h4|h5|h6|hr|ol|p|pre|table|tr|td|ul|li|section|header|footer)>/g, '\n')
+ pgpMsg = pgpMsg.replace(/<(.+?)>/g, '')
+ pgpMsg = pgpMsg.replace(/ /g, ' ')
+
+ pgp.unbox { keyfetch: ring, armored: pgpMsg }, (err, literals, warnings, subkey) =>
+ if err
+ console.warn err
+ errMsg = "Unable to decrypt message."
+ if err.toString().indexOf("tailer found") >= 0 or err.toString().indexOf("checksum mismatch") >= 0
+ errMsg = "Unable to decrypt message. Encrypted block is malformed."
+ else if err.toString().indexOf("key not found:") >= 0
+ errMsg = "Unable to decrypt message. Private key does not match encrypted block."
+ if !@msgStatus(message)?
+ errMsg = "Decryption preprocessing failed."
+ Actions.recordUserEvent("Email Decryption Errored", {error: errMsg})
+ @_msgStatus.push({"clientId": message.clientId, "time": Date.now(), "message": errMsg})
+ else
+ if warnings._w.length > 0
+ console.warn warnings._w
+
+ if literals.length > 0
+ plaintext = literals[0].toString('utf8')
+
+ # tag for consistent styling
+ if plaintext.indexOf("") == -1
+ plaintext = "\n" + plaintext + "\n "
+
+ # can't use _.template :(
+ body = message.body.slice(0, blockStart) + plaintext + message.body.slice(blockEnd)
+
+ # TODO if message is already in the cache, consider updating its TTL
+ timeout = 1000 * 60 * 30 # 30 minutes in ms
+ @_msgCache.push({clientId: message.clientId, body: body, timeout: Date.now() + timeout})
+ keyprint = subkey.get_fingerprint().toString('hex')
+ @_msgStatus.push({"clientId": message.clientId, "time": Date.now(), "message": "Message decrypted with key #{keyprint}"})
+ # re-render messages
+ Actions.recordUserEvent("Email Decrypted")
+ MessageBodyProcessor.resetCache()
+ @trigger(@)
+ else
+ console.warn "Unable to decrypt message."
+ @_msgStatus.push({"clientId": message.clientId, "time": Date.now(), "message": "Unable to decrypt message."})
+
+ decryptAttachments: (identity, files) =>
+ # fill our keyring with all possible private keys
+ keyring = new pgp.keyring.KeyRing
+ # (the unbox function will use the right one)
+
+ if identity.key?
+ keyring.add_key_manager(identity.key)
+
+ FileDownloadStore._fetchAndSaveAll(files).then((filepaths) ->
+ # open, decrypt, and resave each of the newly-downloaded files in place
+ _.each(filepaths, (filepath) =>
+ fs.readFile(filepath, (err, data) =>
+ # find a PGP block
+ pgpStart = "-----BEGIN PGP MESSAGE-----"
+ blockStart = data.indexOf(pgpStart)
+
+ pgpEnd = "-----END PGP MESSAGE-----"
+ blockEnd = data.indexOf(pgpEnd) + pgpEnd.length
+
+ # if we don't find those, it isn't encrypted
+ return unless (blockStart >= 0 and blockEnd >= 0)
+
+ pgpMsg = data.slice(blockStart, blockEnd)
+
+ # decrypt the file
+ pgp.unbox({ keyfetch: keyring, armored: pgpMsg }, (err, literals, warnings, subkey) =>
+ if err
+ console.warn err
+ else
+ if warnings._w.length > 0
+ console.warn warnings._w
+
+ literalLen = literals?.length
+ # if we have no literals, failed to decrypt and should abort
+ return unless literalLen?
+
+ if literalLen == 1
+ # success! replace old encrypted file with awesome decrypted file
+ filepath = filepath.slice(0, filepath.length-3).concat("txt")
+ fs.writeFile(filepath, literals[0].toBuffer(), (err) =>
+ if err
+ console.warn err
+ )
+ else
+ console.warn "Attempt to decrypt attachment failed: #{literalLen} literals found, expected 1."
+ )
+ )
+ )
+ )
+
+
+module.exports = new PGPKeyStore()
diff --git a/packages/client-app/internal_packages/keybase/lib/preferences-keybase.cjsx b/packages/client-app/internal_packages/keybase/lib/preferences-keybase.cjsx
new file mode 100755
index 0000000000..2440c967a9
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/preferences-keybase.cjsx
@@ -0,0 +1,50 @@
+{React, RegExpUtils} = require 'nylas-exports'
+PGPKeyStore = require './pgp-key-store'
+KeybaseSearch = require './keybase-search'
+KeyManager = require './key-manager'
+KeyAdder = require './key-adder'
+
+class PreferencesKeybase extends React.Component
+ @displayName: 'PreferencesKeybase'
+
+ constructor: (@props) ->
+ @_keySaveQueue = {}
+
+ {pubKeys, privKeys} = @_getStateFromStores()
+ @state =
+ pubKeys: pubKeys
+ privKeys: privKeys
+
+ componentDidMount: =>
+ @unlistenKeystore = PGPKeyStore.listen(@_onChange, @)
+
+ componentWillUnmount: =>
+ @unlistenKeystore()
+
+ _onChange: =>
+ @setState @_getStateFromStores()
+
+ _getStateFromStores: ->
+ pubKeys = PGPKeyStore.pubKeys()
+ privKeys = PGPKeyStore.privKeys(timed: false)
+ return {pubKeys, privKeys}
+
+ render: =>
+ noKeysMessage =
+
+ You have no saved PGP keys!
+
+
+ keyManager =
+
+
+
+
+
+ {if @state.pubKeys.length == 0 and @state.privKeys.length == 0 then noKeysMessage else keyManager}
+
+
+
+module.exports = PreferencesKeybase
diff --git a/packages/client-app/internal_packages/keybase/lib/private-key-popover.cjsx b/packages/client-app/internal_packages/keybase/lib/private-key-popover.cjsx
new file mode 100644
index 0000000000..a53f84b8d0
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/private-key-popover.cjsx
@@ -0,0 +1,138 @@
+{React, Actions, AccountStore} = require 'nylas-exports'
+{remote} = require 'electron'
+Identity = require './identity'
+PGPKeyStore = require './pgp-key-store'
+PassphrasePopover = require './passphrase-popover'
+_ = require 'underscore'
+fs = require 'fs'
+pgp = require 'kbpgp'
+
+module.exports =
+class PrivateKeyPopover extends React.Component
+ constructor: ->
+ @state = {
+ selectedAddress: "0"
+ keyBody: ""
+ paste: false
+ import: false
+ validKeyBody: false
+ }
+
+ @propTypes:
+ addresses: React.PropTypes.array
+
+ render: =>
+ errorBar = Invalid key body.
+ keyArea =
+
+ saveBtnClass = if !(@state.validKeyBody) then "btn modal-done-button btn-disabled" else "btn modal-done-button"
+ saveButton = Save
+
+
+
No PGP private key found. Add a key for {@_renderAddresses()}
+
+ Paste in a Key
+ Import from File
+
+ {if (@state.import or @state.paste) and !@state.validKeyBody and @state.keyBody != "" then errorBar}
+ {if @state.import or @state.paste then keyArea}
+
+
Actions.closePopover()}>Cancel
+
Advanced
+
{saveButton}
+
+
+
+ _renderAddresses: =>
+ signedIn = _.pluck(AccountStore.accounts(), "emailAddress")
+ suggestions = _.intersection(signedIn, @props.addresses)
+
+ if suggestions.length == 1
+ addresses = {suggestions[0]}.
+ else if suggestions.length > 1
+ options = suggestions.map((address) => {address} )
+ addresses =
+
+ {options}
+
+ else
+ throw new Error("How did you receive a message that you're not in the TO field for?")
+
+ _onSelectAddress: (event) =>
+ @setState
+ selectedAddress: parseInt(event.target.value, 10)
+
+ _displayError: (err) ->
+ dialog = remote.dialog
+ dialog.showErrorBox('Private Key Error', err.toString())
+
+ _onClickAdvanced: =>
+ Actions.switchPreferencesTab('Encryption')
+ Actions.openPreferences()
+
+ _onClickImport: (event) =>
+ NylasEnv.showOpenDialog({
+ title: "Import PGP Key",
+ buttonLabel: "Import",
+ properties: ['openFile']
+ }, (filepath) =>
+ if filepath?
+ fs.readFile(filepath[0], (err, data) =>
+ pgp.KeyManager.import_from_armored_pgp {
+ armored: data
+ }, (err, km) =>
+ if err
+ @_displayError("File is not a valid PGP private key.")
+ return
+ else
+ privateStart = "-----BEGIN PGP PRIVATE KEY BLOCK-----"
+ if km.armored_pgp_public.indexOf(privateStart) >= 0
+ @setState
+ paste: false
+ import: true
+ keyBody: km.armored_pgp_public
+ validKeyBody: true
+ else
+ @_displayError("File is not a valid PGP private key.")
+ )
+ )
+
+ _onClickPaste: (event) =>
+ @setState
+ paste: !@state.paste
+ import: false
+ keyBody: ""
+ validKeyBody: false
+
+ _onKeyChange: (event) =>
+ @setState
+ keyBody: event.target.value
+ pgp.KeyManager.import_from_armored_pgp {
+ armored: event.target.value
+ }, (err, km) =>
+ if err
+ valid = false
+ else
+ privateStart = "-----BEGIN PGP PRIVATE KEY BLOCK-----"
+ if km.armored_pgp_public.indexOf(privateStart) >= 0
+ valid = true
+ else
+ valid = false
+ @setState
+ validKeyBody: valid
+
+ _onDone: =>
+ signedIn = _.pluck(AccountStore.accounts(), "emailAddress")
+ suggestions = _.intersection(signedIn, @props.addresses)
+ selectedAddress = suggestions[@state.selectedAddress]
+ ident = new Identity({
+ addresses: [selectedAddress]
+ isPriv: true
+ })
+ @unlistenKeystore = PGPKeyStore.listen(@_onKeySaved, @)
+ PGPKeyStore.saveNewKey(ident, @state.keyBody)
+
+ _onKeySaved: =>
+ @unlistenKeystore()
+ Actions.closePopover()
+ @props.callback()
diff --git a/packages/client-app/internal_packages/keybase/lib/recipient-key-chip.cjsx b/packages/client-app/internal_packages/keybase/lib/recipient-key-chip.cjsx
new file mode 100755
index 0000000000..acfc919b3d
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/lib/recipient-key-chip.cjsx
@@ -0,0 +1,53 @@
+{MessageStore, React} = require 'nylas-exports'
+{RetinaImg} = require 'nylas-component-kit'
+PGPKeyStore = require './pgp-key-store'
+pgp = require 'kbpgp'
+_ = require 'underscore'
+
+# Sits next to recipient chips in the composer and turns them green/red
+# depending on whether or not there's a PGP key present for that user
+class RecipientKeyChip extends React.Component
+
+ @displayName: 'RecipientKeyChip'
+
+ @propTypes:
+ contact: React.PropTypes.object.isRequired
+
+ constructor: (props) ->
+ super(props)
+ @state = @_getStateFromStores()
+
+ componentDidMount: ->
+ # fetch the actual key(s) from disk
+ keys = PGPKeyStore.pubKeys(@props.contact.email)
+ _.each(keys, (key) ->
+ PGPKeyStore.getKeyContents(key: key)
+ )
+ @unlistenKeystore = PGPKeyStore.listen(@_onKeystoreChange, @)
+
+ componentWillUnmount: ->
+ @unlistenKeystore()
+
+ _getStateFromStores: ->
+ return {
+ # true if there is at least one loaded key for the account
+ keys: PGPKeyStore.pubKeys(@props.contact.email).some((cv, ind, arr) =>
+ cv.hasOwnProperty('key')
+ )
+ }
+
+ _onKeystoreChange: ->
+ @setState(@_getStateFromStores())
+
+ render: ->
+ if @state.keys
+
+
+
+ else
+
+
+
+
+
+module.exports = RecipientKeyChip
diff --git a/packages/client-app/internal_packages/keybase/package.json b/packages/client-app/internal_packages/keybase/package.json
new file mode 100755
index 0000000000..234ec49d11
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "keybase",
+ "main": "./lib/main",
+ "version": "0.1.0",
+ "engines": {
+ "nylas": "*"
+ },
+ "isOptional": true,
+ "isHiddenOnPluginsPage": true,
+
+ "title": "Encryption",
+ "description": "Send and receive encrypted messages using Keybase for public key exchange.",
+ "icon": "./icon.png",
+ "license": "GPL-3.0",
+ "windowTypes": {
+ "default": true,
+ "composer": true,
+ "thread-popout": true
+ }
+}
diff --git a/packages/client-app/internal_packages/keybase/spec/decrypt-buttons-spec.cjsx b/packages/client-app/internal_packages/keybase/spec/decrypt-buttons-spec.cjsx
new file mode 100755
index 0000000000..c2b48016e6
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/spec/decrypt-buttons-spec.cjsx
@@ -0,0 +1,83 @@
+{React, ReactTestUtils, DraftStore, Message} = require 'nylas-exports'
+pgp = require 'kbpgp'
+
+DecryptMessageButton = require '../lib/decrypt-button'
+PGPKeyStore = require '../lib/pgp-key-store'
+
+describe "DecryptMessageButton", ->
+ beforeEach ->
+ @unencryptedMsg = new Message({clientId: 'test', subject: 'Subject', body: 'Body
'})
+ body = """-----BEGIN PGP MESSAGE-----
+ Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto
+
+ wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw=
+ =1aPN
+ -----END PGP MESSAGE-----"""
+ @encryptedMsg = new Message({clientId: 'test2', subject: 'Subject', body: body})
+
+ @msg = new Message({subject: 'Subject', body: 'Body
'})
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ xit "should try to decrypt the message whenever a new key is unlocked", ->
+ spyOn(PGPKeyStore, "decrypt")
+ spyOn(PGPKeyStore, "isDecrypted").andCallFake((message) =>
+ return false
+ )
+ spyOn(PGPKeyStore, "hasEncryptedComponent").andCallFake((message) =>
+ return true
+ )
+
+ PGPKeyStore.trigger(PGPKeyStore)
+ expect(PGPKeyStore.decrypt).toHaveBeenCalled()
+
+ xit "should not try to decrypt the message whenever a new key is unlocked
+ if the message is already decrypted", ->
+ spyOn(PGPKeyStore, "decrypt")
+ spyOn(PGPKeyStore, "isDecrypted").andCallFake((message) =>
+ return true)
+ spyOn(PGPKeyStore, "hasEncryptedComponent").andCallFake((message) =>
+ return true)
+
+ # TODO for some reason the above spyOn calls aren't working and false is
+ # being returned from isDecrypted, causing this test to fail
+ PGPKeyStore.trigger(PGPKeyStore)
+
+ expect(PGPKeyStore.decrypt).not.toHaveBeenCalled()
+
+ it "should have a button to decrypt a message", ->
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ expect(@component.refs.button).toBeDefined()
+
+ it "should not allow for the unlocking of a message with no encrypted component", ->
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ expect(@component.refs.button).not.toBeDefined()
+
+ it "should indicate when a message has been decrypted", ->
+ spyOn(PGPKeyStore, "isDecrypted").andCallFake((message) =>
+ return true)
+
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ expect(@component.refs.button).not.toBeDefined()
+
+ it "should open a popover when clicked", ->
+ spyOn(DecryptMessageButton.prototype, "_onClickDecrypt")
+
+ msg = @encryptedMsg
+ msg.to = [{email: "test@example.com"}]
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+ expect(@component.refs.button).toBeDefined()
+ ReactTestUtils.Simulate.click(@component.refs.button)
+ expect(DecryptMessageButton.prototype._onClickDecrypt).toHaveBeenCalled()
diff --git a/packages/client-app/internal_packages/keybase/spec/encrypt-button-spec.cjsx b/packages/client-app/internal_packages/keybase/spec/encrypt-button-spec.cjsx
new file mode 100755
index 0000000000..ef325fbc22
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/spec/encrypt-button-spec.cjsx
@@ -0,0 +1,142 @@
+{React, ReactDOM, ReactTestUtils, DraftStore, Message} = require 'nylas-exports'
+pgp = require 'kbpgp'
+
+EncryptMessageButton = require '../lib/encrypt-button'
+PGPKeyStore = require '../lib/pgp-key-store'
+
+describe "EncryptMessageButton", ->
+ beforeEach ->
+ key = """-----BEGIN PGP PRIVATE KEY BLOCK-----
+ Version: GnuPG v1
+
+ lQOYBFbgdCwBCADP7pEHzySjYHIlQK7T3XlqfFaot7VAgwmBUmXwFNRsYxGFj5sC
+ qEvhcw3nGvhVOul9A5S3yDZCtEDMqZSFDXNNIptpbhJgEqae0stfmHzHNUJSz+3w
+ ZE8Bvz1D5MU8YsMCUbt/wM/dBsp0EdbCS+zWIfM7Gzhb5vYOYx/wAeUxORCljQ6i
+ E80iGKII7EYmpscIOjb6QgaM7wih6GT3GWFYOMRG0uKGDVGWgWQ3EJgdcJq6Dvmx
+ GgrEQL7R8chtuLn9iyG3t5ZUfNvoH6PM7L7ei2ceMjxvLOfaHWNVKc+9YPeEOcvB
+ uQi5NEqSEZOSqd1jPPaOiSTnIOCeVXXMyZVlABEBAAEAB/0Q2OWLWm8/hYr6FbmU
+ lPdHd3eWB/x5k6Rrg/+aajWj6or65V3L41Lym13fAcJpNXLBnE6qbWBoGy685miQ
+ NzzGXS12Z2K5wgkaCT5NKo/BnEEZcJt4xMfZ/mK6Y4jPkbj3MSQd/8NXxzsUGHXs
+ HDa+StXoThZM6/O3yrRFwAGP8UhMVYOSwZB0u+DZ8EFaImqKJmznRvyNOaaGDrI5
+ cNdB4Xkk7L/tDxUxqc60WMQ49BEA9HW7miqymb3MEBA4Gd931pGYRM3hzQDhg+VI
+ oGlw2Xl9YjUGWVHMyufKzxTYhWWHDSpfjSVikeKwqbJWVqZ0a9/4GghhQRMdo2ho
+ AerpBADeXox+MRdbf2SgerxN4dPMBL5A5LD89Cu8AeY+6Ae1KlvGQFEOOQlW6Cwh
+ R1Tqn1p8JFG8jr7zg/nbPcIvOH/F00Dozfe+BW4BPJ8uv1E0ON/p54Bnp/XaNlGM
+ KyCDqRK+KDVpMXgP+rFK94+xLOuimMU3PhIDq623mezc8+u2CwQA72ELj49/OtqD
+ 6VzEG6MKGfAOkW8l0xuxqo3SgLBU2E45zA9JYaocQ+z1fzFTUmMruFQaD1SxX7kr
+ Ml1s0BBiiEh323Cf01y1DXWQhWtw0s5phSzfzgB5GFZV42xtyQ+qZqf20TihJ8/O
+ b56J1tM7DsVXbVtcZdKRtUbRZ8vuOE8D/1oIuDT1a8Eqzl0KuS5VLOuVYvl8pbMc
+ aRkPtSkG4+nRw3LTQb771M39HpjgEv2Jw9aACHsWZ8DnNtoc8DA7UUeAouCT+Ev4
+ u3o9LrQ/+A/NUSLwBibViflo/gsR5L8tYn51zhJ3573FucFJP9ej7JncSL9x615q
+ Il2+Ry2pfUUZRj20OURha290YSBOZWxzb24gKFRlc3QgS2V5IEZvciBOMSBQbHVn
+ aW4pIDxkYWtvdGFAbnlsYXMuY29tPokBOAQTAQIAIgUCVuB0LAIbAwYLCQgHAwIG
+ FQgCCQoLBBYCAwECHgECF4AACgkQJgGhql9yqOCb5wgAqATlYC2ysjyUN66IfatW
+ rZij5lbIcjZyq5an1fxW9J0ofxeOIQ2duqnwoLFoDS2lNz4/kFlOn8vyvApsSfzC
+ +Gy1T46rc32CUBMjtD5Lh5fQ7fSNysii813MZAwfhdR0H6XO6kFj4RTJe4nzKnmM
+ sSSBbS/kbl9ZWZ993gisun8/PyDO4/1Yon8BDHABaJRJD5rqd1ZwtMIZguSgipXu
+ HqrdLpDxNUPr+YQ0C5r0kVJLFu0TVIz9grjV+MMCNVlDJvFla7vvRTdnym3HnbZo
+ XBeq/8zEnFcDWQC9Gkl4TrcuIwUYvcaO9j5V/E2fN+3b7YQp/0iwjZCHe+BgK5Hd
+ TJ0DmARW4HQsAQgAtSb1ove+EOJEspTwFP2gmnZ32SF6qGLcXkZkHJ7bYzudoKrQ
+ rkYcs61foyyeH/UrvOdHWsEOFnekE44oA/y7dGZiHAUcuYrqxtEF7QhmbcK0aRKS
+ JqmjO17rZ4Xz2MXsFxnGup5D94ZLxv2XktZX8EexMjdfU5Zdx1wu0GsMZX5Gj6AP
+ lQb0E1KDDnFII2uRs32j6GuO5WZJk1hdvz0DSTaaJ2pY3/WtMiUEBap9qSRR8WIK
+ kUO+TbzeogDXW10EiRyhIQadnfQTFjSVpGEos9b1k7zNNk/hb7yvlNL+pRY+8UcH
+ zRRMjC9wv6V7xmVOF/GhdGLLwzs36lxCbeheWQARAQABAAf/Vua0qZQtUo4pJH48
+ WeV9uPuh7MCZxdN/IZ6lAfHXDtiXem7XIvMxa6R9H5sU1AHaFInieg/owTBtvo/Q
+ dHE2P9WptQVizUNt8yhsrlP8RyVDRLCK+g8g5idXyFbDLrdr1X0hD39C3ahIC9K1
+ dtRqZTMPNybHDSMyI6P+NS9VSA4naigzzIzz4GLUgnzI/55M6QFcWxrnXc8B3XPQ
+ QxerSL3UseuNNr6nRhYt5arPpD7YhgmRakib+guPnmD5ZIbHOVFqS6RCkNkQ91zJ
+ nCo+o72gHbUDupEo8l/739k2SknWrNFt4S+mrvBM3c29cCnFaKQyRBNNGXtwmNnE
+ Dwr8DQQAxvQ+6Ijh4so4mdlI4+UT0d50gYQcnjz6BLtcRfewpT/EadIb0OuVS1Eh
+ MxM9QN5hXFKzT7GRS+nuk4NvrGr8aJ7mDPXzOHE/rnnAuikMuB1F13I8ELbya36B
+ j5wTvOBBjtNkcA1e9wX+iN4PyBVpzRUZZY6y0Xcyp9DsQwVpMvcEAOkYAeg4UCfO
+ PumYjdBRqcAuCKSQ8/UOrTOu5BDiIoyYBD3mrWSe61zZTuR7kb8/IkGHDTC7tLVZ
+ vKzdkRinh+qISpjI5OHSsITBV1uh/iko+K2rKca8gonjQBsxeAPMZwvMfUROGKkS
+ eXm/5sLUWlRtGkfVED1rYwUkE720tFUvBACGilgE7ezuoH7ZukyPPw9RziI7/CQp
+ u0KhFTGzLMGJWfiGgMC7l1jnS0EJxvs3ZpBme//vsKCjPGVg3/OqOHqCY0p9Uqjt
+ 7v8o7y62AMzHKEGuMubSzDZZalo0515HQilfwnOGTHN14693icg1W/daB8aGI+Uz
+ cH3NziXnu23zc0VMiQEfBBgBAgAJBQJW4HQsAhsMAAoJECYBoapfcqjghFEH/ioJ
+ c4jot40O3Xa0K9ZFXol2seUHIf5rLgvcnwAKEiibK81/cZzlL6uXpgxVA4GOgdw5
+ nfGVd7b9jB7S6aUKcVoLDmy47qmJkWvZ45cjgv+K+ZoV22IN0J9Hhhdnqe+QJd4A
+ vIqb67gb9cw0xUDqcLdYywsXHoF9WkAYpIvBw4klHgd77XTzYz6xv4vVl469CPdk
+ +1dlOKpCHTLh7t38StP/rSu4ZrAYGET0e2+Ayqj44VHS9VwEbR/D2xrbjo43URZB
+ VsVlQKtXimFLpck1z0BPQ0NmRdEzRHQwP2WNYfxdNCeFAGDL4tpblBzw/vp/CFTO
+ 217s2OKjpJqtpHPf2vY=
+ =UY7Y
+ -----END PGP PRIVATE KEY BLOCK-----"""
+
+ pgp.KeyManager.import_from_armored_pgp {
+ armored: key
+ }, (err, km) =>
+ @km = km
+
+ waitsFor (=> @km?), "getting a key took too long", 1000
+
+ @msg = new Message({subject: 'Subject', body: 'Body
', draft: true})
+ @session =
+ draft: =>
+ return @msg
+ changes:
+ add: (changes) =>
+ @output = changes
+
+ @output = null
+
+ add = jasmine.createSpy('add')
+ spyOn(DraftStore, 'sessionForClientId').andCallFake((draftClientId) =>
+ return Promise.resolve(@session)
+ )
+
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "should render into the page", ->
+ expect(@component).toBeDefined()
+
+ it "should have a displayName", ->
+ expect(EncryptMessageButton.displayName).toBe('EncryptMessageButton')
+
+ it "should have an onClick behavior which encrypts the message", ->
+ spyOn(@component, '_onClick')
+ buttonNode = ReactDOM.findDOMNode(@component.refs.button)
+ ReactTestUtils.Simulate.click(buttonNode)
+ expect(@component._onClick).toHaveBeenCalled()
+
+ it "should store the message body's plaintext on encryption", ->
+ spyOn(@component, '_onClick')
+ buttonNode = ReactDOM.findDOMNode(@component.refs.button)
+ ReactTestUtils.Simulate.click(buttonNode)
+ expect(@component.plaintext is @msg.body)
+
+ it "should mark itself as encrypted", ->
+ spyOn(@component, '_onClick')
+ buttonNode = ReactDOM.findDOMNode(@component.refs.button)
+ ReactTestUtils.Simulate.click(buttonNode)
+ expect(@component.currentlyEncrypted is true)
+
+ xit "should be able to encrypt messages", ->
+ # NOTE: this doesn't work.
+ # As best I can tell, something is wrong with the pgp.box function -
+ # nothing seems to get it to complete. Weird.
+
+ runs( =>
+ console.log @km
+ @component._encrypt("test text", [@km])
+
+ @flag = false
+ pgp.box {encrypt_for: [@km], msg: "test text"}, (err, result_string) =>
+ expect(not err?)
+ @err = err
+ @result_string = result_string
+ @flag = true
+ )
+
+ waitsFor (=> console.log @flag; @flag), "encryption took too long", 5000
+
+ runs( =>
+ console.log @err
+ console.log @result_string
+ console.log @output
+
+ expect(@output is @result_string))
diff --git a/packages/client-app/internal_packages/keybase/spec/keybase-profile-spec.cjsx b/packages/client-app/internal_packages/keybase/spec/keybase-profile-spec.cjsx
new file mode 100755
index 0000000000..80826d9503
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/spec/keybase-profile-spec.cjsx
@@ -0,0 +1,9 @@
+{React, ReactTestUtils, Message} = require 'nylas-exports'
+
+KeybaseUser = require '../lib/keybase-user'
+
+describe "KeybaseUserProfile", ->
+ it "should have a displayName", ->
+ expect(KeybaseUser.displayName).toBe('KeybaseUserProfile')
+
+# behold, the most comprehensive test suite of all time
diff --git a/packages/client-app/internal_packages/keybase/spec/keybase-search-spec.cjsx b/packages/client-app/internal_packages/keybase/spec/keybase-search-spec.cjsx
new file mode 100755
index 0000000000..b608226f5a
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/spec/keybase-search-spec.cjsx
@@ -0,0 +1,16 @@
+{React, ReactTestUtils, Message} = require 'nylas-exports'
+
+KeybaseSearch = require '../lib/keybase-search'
+
+describe "KeybaseSearch", ->
+ it "should have a displayName", ->
+ expect(KeybaseSearch.displayName).toBe('KeybaseSearch')
+
+ it "should have no results when rendered", ->
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ expect(@component.state.results).toEqual([])
+
+# behold, the most comprehensive test suite of all time
diff --git a/packages/client-app/internal_packages/keybase/spec/keybase-spec.coffee b/packages/client-app/internal_packages/keybase/spec/keybase-spec.coffee
new file mode 100755
index 0000000000..c6e730585c
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/spec/keybase-spec.coffee
@@ -0,0 +1,51 @@
+kb = require '../lib/keybase'
+
+xdescribe "keybase lib", ->
+ # TODO stub keybase calls?
+ it "should be able to fetch an account by username", ->
+ @them = null
+ runs( =>
+ kb.getUser('dakota', 'usernames', (err, them) =>
+ @them = them
+ )
+ )
+ waitsFor((=> @them != null), 2000)
+ runs( =>
+ expect(@them?[0].components.username.val).toEqual("dakota")
+ )
+
+ it "should be able to fetch an account by key fingerprint", ->
+ @them = null
+ runs( =>
+ kb.getUser('7FA5A43BBF2BAD1845C8D0E8145FCCD989968E3B', 'key_fingerprint', (err, them) =>
+ @them = them
+ )
+ )
+ waitsFor((=> @them != null), 2000)
+ runs( =>
+ expect(@them?[0].components.username.val).toEqual("dakota")
+ )
+
+ it "should be able to fetch a user's key", ->
+ @key = null
+ runs( =>
+ kb.getKey('dakota', (error, key) =>
+ @key = key
+ )
+ )
+ waitsFor((=> @key != null), 2000)
+ runs( =>
+ expect(@key?.startsWith('-----BEGIN PGP PUBLIC KEY BLOCK-----'))
+ )
+
+ it "should be able to return an autocomplete query", ->
+ @completions = null
+ runs( =>
+ kb.autocomplete('dakota', (error, completions) =>
+ @completions = completions
+ )
+ )
+ waitsFor((=> @completions != null), 2000)
+ runs( =>
+ expect(@completions[0].components.username.val).toEqual("dakota")
+ )
diff --git a/packages/client-app/internal_packages/keybase/spec/main-spec.coffee b/packages/client-app/internal_packages/keybase/spec/main-spec.coffee
new file mode 100755
index 0000000000..3b7c9539cd
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/spec/main-spec.coffee
@@ -0,0 +1,39 @@
+{ComponentRegistry, ExtensionRegistry} = require 'nylas-exports'
+{activate, deactivate} = require '../lib/main'
+
+EncryptMessageButton = require '../lib/encrypt-button'
+DecryptMessageButton = require '../lib/decrypt-button'
+DecryptPGPExtension = require '../lib/decryption-preprocess'
+
+describe "activate", ->
+ it "should register the encryption button", ->
+ spyOn(ComponentRegistry, 'register')
+ activate()
+ expect(ComponentRegistry.register).toHaveBeenCalledWith(EncryptMessageButton, {role: 'Composer:ActionButton'})
+
+ it "should register the decryption button", ->
+ spyOn(ComponentRegistry, 'register')
+ activate()
+ expect(ComponentRegistry.register).toHaveBeenCalledWith(DecryptMessageButton, {role: 'message:BodyHeader'})
+
+ it "should register the decryption processor", ->
+ spyOn(ExtensionRegistry.MessageView, 'register')
+ activate()
+ expect(ExtensionRegistry.MessageView.register).toHaveBeenCalledWith(DecryptPGPExtension)
+
+
+describe "deactivate", ->
+ it "should unregister the encrypt button", ->
+ spyOn(ComponentRegistry, 'unregister')
+ deactivate()
+ expect(ComponentRegistry.unregister).toHaveBeenCalledWith(EncryptMessageButton)
+
+ it "should unregister the decryption button", ->
+ spyOn(ComponentRegistry, 'unregister')
+ deactivate()
+ expect(ComponentRegistry.unregister).toHaveBeenCalledWith(DecryptMessageButton)
+
+ it "should unregister the decryption processor", ->
+ spyOn(ExtensionRegistry.MessageView, 'unregister')
+ deactivate()
+ expect(ExtensionRegistry.MessageView.unregister).toHaveBeenCalledWith(DecryptPGPExtension)
diff --git a/packages/client-app/internal_packages/keybase/spec/pgp-key-store-spec.cjsx b/packages/client-app/internal_packages/keybase/spec/pgp-key-store-spec.cjsx
new file mode 100755
index 0000000000..94a4eadb80
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/spec/pgp-key-store-spec.cjsx
@@ -0,0 +1,209 @@
+{React, ReactTestUtils, DraftStore, Message} = require 'nylas-exports'
+pgp = require 'kbpgp'
+_ = require 'underscore'
+fs = require 'fs'
+
+Identity = require '../lib/identity'
+PGPKeyStore = require '../lib/pgp-key-store'
+
+describe "PGPKeyStore", ->
+ beforeEach ->
+ @TEST_KEY = """-----BEGIN PGP PRIVATE KEY BLOCK-----
+ Version: GnuPG v1
+
+ lQOYBFbgdCwBCADP7pEHzySjYHIlQK7T3XlqfFaot7VAgwmBUmXwFNRsYxGFj5sC
+ qEvhcw3nGvhVOul9A5S3yDZCtEDMqZSFDXNNIptpbhJgEqae0stfmHzHNUJSz+3w
+ ZE8Bvz1D5MU8YsMCUbt/wM/dBsp0EdbCS+zWIfM7Gzhb5vYOYx/wAeUxORCljQ6i
+ E80iGKII7EYmpscIOjb6QgaM7wih6GT3GWFYOMRG0uKGDVGWgWQ3EJgdcJq6Dvmx
+ GgrEQL7R8chtuLn9iyG3t5ZUfNvoH6PM7L7ei2ceMjxvLOfaHWNVKc+9YPeEOcvB
+ uQi5NEqSEZOSqd1jPPaOiSTnIOCeVXXMyZVlABEBAAEAB/0Q2OWLWm8/hYr6FbmU
+ lPdHd3eWB/x5k6Rrg/+aajWj6or65V3L41Lym13fAcJpNXLBnE6qbWBoGy685miQ
+ NzzGXS12Z2K5wgkaCT5NKo/BnEEZcJt4xMfZ/mK6Y4jPkbj3MSQd/8NXxzsUGHXs
+ HDa+StXoThZM6/O3yrRFwAGP8UhMVYOSwZB0u+DZ8EFaImqKJmznRvyNOaaGDrI5
+ cNdB4Xkk7L/tDxUxqc60WMQ49BEA9HW7miqymb3MEBA4Gd931pGYRM3hzQDhg+VI
+ oGlw2Xl9YjUGWVHMyufKzxTYhWWHDSpfjSVikeKwqbJWVqZ0a9/4GghhQRMdo2ho
+ AerpBADeXox+MRdbf2SgerxN4dPMBL5A5LD89Cu8AeY+6Ae1KlvGQFEOOQlW6Cwh
+ R1Tqn1p8JFG8jr7zg/nbPcIvOH/F00Dozfe+BW4BPJ8uv1E0ON/p54Bnp/XaNlGM
+ KyCDqRK+KDVpMXgP+rFK94+xLOuimMU3PhIDq623mezc8+u2CwQA72ELj49/OtqD
+ 6VzEG6MKGfAOkW8l0xuxqo3SgLBU2E45zA9JYaocQ+z1fzFTUmMruFQaD1SxX7kr
+ Ml1s0BBiiEh323Cf01y1DXWQhWtw0s5phSzfzgB5GFZV42xtyQ+qZqf20TihJ8/O
+ b56J1tM7DsVXbVtcZdKRtUbRZ8vuOE8D/1oIuDT1a8Eqzl0KuS5VLOuVYvl8pbMc
+ aRkPtSkG4+nRw3LTQb771M39HpjgEv2Jw9aACHsWZ8DnNtoc8DA7UUeAouCT+Ev4
+ u3o9LrQ/+A/NUSLwBibViflo/gsR5L8tYn51zhJ3573FucFJP9ej7JncSL9x615q
+ Il2+Ry2pfUUZRj20OURha290YSBOZWxzb24gKFRlc3QgS2V5IEZvciBOMSBQbHVn
+ aW4pIDxkYWtvdGFAbnlsYXMuY29tPokBOAQTAQIAIgUCVuB0LAIbAwYLCQgHAwIG
+ FQgCCQoLBBYCAwECHgECF4AACgkQJgGhql9yqOCb5wgAqATlYC2ysjyUN66IfatW
+ rZij5lbIcjZyq5an1fxW9J0ofxeOIQ2duqnwoLFoDS2lNz4/kFlOn8vyvApsSfzC
+ +Gy1T46rc32CUBMjtD5Lh5fQ7fSNysii813MZAwfhdR0H6XO6kFj4RTJe4nzKnmM
+ sSSBbS/kbl9ZWZ993gisun8/PyDO4/1Yon8BDHABaJRJD5rqd1ZwtMIZguSgipXu
+ HqrdLpDxNUPr+YQ0C5r0kVJLFu0TVIz9grjV+MMCNVlDJvFla7vvRTdnym3HnbZo
+ XBeq/8zEnFcDWQC9Gkl4TrcuIwUYvcaO9j5V/E2fN+3b7YQp/0iwjZCHe+BgK5Hd
+ TJ0DmARW4HQsAQgAtSb1ove+EOJEspTwFP2gmnZ32SF6qGLcXkZkHJ7bYzudoKrQ
+ rkYcs61foyyeH/UrvOdHWsEOFnekE44oA/y7dGZiHAUcuYrqxtEF7QhmbcK0aRKS
+ JqmjO17rZ4Xz2MXsFxnGup5D94ZLxv2XktZX8EexMjdfU5Zdx1wu0GsMZX5Gj6AP
+ lQb0E1KDDnFII2uRs32j6GuO5WZJk1hdvz0DSTaaJ2pY3/WtMiUEBap9qSRR8WIK
+ kUO+TbzeogDXW10EiRyhIQadnfQTFjSVpGEos9b1k7zNNk/hb7yvlNL+pRY+8UcH
+ zRRMjC9wv6V7xmVOF/GhdGLLwzs36lxCbeheWQARAQABAAf/Vua0qZQtUo4pJH48
+ WeV9uPuh7MCZxdN/IZ6lAfHXDtiXem7XIvMxa6R9H5sU1AHaFInieg/owTBtvo/Q
+ dHE2P9WptQVizUNt8yhsrlP8RyVDRLCK+g8g5idXyFbDLrdr1X0hD39C3ahIC9K1
+ dtRqZTMPNybHDSMyI6P+NS9VSA4naigzzIzz4GLUgnzI/55M6QFcWxrnXc8B3XPQ
+ QxerSL3UseuNNr6nRhYt5arPpD7YhgmRakib+guPnmD5ZIbHOVFqS6RCkNkQ91zJ
+ nCo+o72gHbUDupEo8l/739k2SknWrNFt4S+mrvBM3c29cCnFaKQyRBNNGXtwmNnE
+ Dwr8DQQAxvQ+6Ijh4so4mdlI4+UT0d50gYQcnjz6BLtcRfewpT/EadIb0OuVS1Eh
+ MxM9QN5hXFKzT7GRS+nuk4NvrGr8aJ7mDPXzOHE/rnnAuikMuB1F13I8ELbya36B
+ j5wTvOBBjtNkcA1e9wX+iN4PyBVpzRUZZY6y0Xcyp9DsQwVpMvcEAOkYAeg4UCfO
+ PumYjdBRqcAuCKSQ8/UOrTOu5BDiIoyYBD3mrWSe61zZTuR7kb8/IkGHDTC7tLVZ
+ vKzdkRinh+qISpjI5OHSsITBV1uh/iko+K2rKca8gonjQBsxeAPMZwvMfUROGKkS
+ eXm/5sLUWlRtGkfVED1rYwUkE720tFUvBACGilgE7ezuoH7ZukyPPw9RziI7/CQp
+ u0KhFTGzLMGJWfiGgMC7l1jnS0EJxvs3ZpBme//vsKCjPGVg3/OqOHqCY0p9Uqjt
+ 7v8o7y62AMzHKEGuMubSzDZZalo0515HQilfwnOGTHN14693icg1W/daB8aGI+Uz
+ cH3NziXnu23zc0VMiQEfBBgBAgAJBQJW4HQsAhsMAAoJECYBoapfcqjghFEH/ioJ
+ c4jot40O3Xa0K9ZFXol2seUHIf5rLgvcnwAKEiibK81/cZzlL6uXpgxVA4GOgdw5
+ nfGVd7b9jB7S6aUKcVoLDmy47qmJkWvZ45cjgv+K+ZoV22IN0J9Hhhdnqe+QJd4A
+ vIqb67gb9cw0xUDqcLdYywsXHoF9WkAYpIvBw4klHgd77XTzYz6xv4vVl469CPdk
+ +1dlOKpCHTLh7t38StP/rSu4ZrAYGET0e2+Ayqj44VHS9VwEbR/D2xrbjo43URZB
+ VsVlQKtXimFLpck1z0BPQ0NmRdEzRHQwP2WNYfxdNCeFAGDL4tpblBzw/vp/CFTO
+ 217s2OKjpJqtpHPf2vY=
+ =UY7Y
+ -----END PGP PRIVATE KEY BLOCK-----"""
+
+ # mock getKeyContents to get rid of all the fs.readFiles
+ spyOn(PGPKeyStore, "getKeyContents").andCallFake( ({key, passphrase, callback}) =>
+ data = @TEST_KEY
+ pgp.KeyManager.import_from_armored_pgp {
+ armored: data
+ }, (err, km) =>
+ expect(err).toEqual(null)
+ if km.is_pgp_locked()
+ expect(passphrase).toBeDefined()
+ km.unlock_pgp { passphrase: passphrase }, (err) =>
+ expect(err).toEqual(null)
+ key.key = km
+ key.setTimeout()
+ if callback?
+ callback()
+ )
+
+ # define an encrypted and an unencrypted message
+ @unencryptedMsg = new Message({clientId: 'test', subject: 'Subject', body: 'Body
'})
+ body = """-----BEGIN PGP MESSAGE-----
+ Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto
+
+ wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw=
+ =1aPN
+ -----END PGP MESSAGE-----"""
+ @encryptedMsg = new Message({clientId: 'test2', subject: 'Subject', body: body})
+
+ # blow away the saved identities and set up a test pub/priv keypair
+ PGPKeyStore._identities = {}
+ pubIdent = new Identity({
+ addresses: ["benbitdiddle@icloud.com"]
+ isPriv: false
+ })
+ PGPKeyStore._identities[pubIdent.clientId] = pubIdent
+ privIdent = new Identity({
+ addresses: ["benbitdiddle@icloud.com"]
+ isPriv: true
+ })
+ PGPKeyStore._identities[privIdent.clientId] = privIdent
+
+ describe "when handling private keys", ->
+ it 'should be able to retrieve and unlock a private key', ->
+ expect(PGPKeyStore.privKeys().some((cv, index, array) =>
+ cv.hasOwnProperty("key"))).toBeFalsey
+ key = PGPKeyStore.privKeys(address: "benbitdiddle@icloud.com", timed: false)[0]
+ PGPKeyStore.getKeyContents(key: key, passphrase: "", callback: =>
+ expect(PGPKeyStore.privKeys({timed: false}).some((cv, index, array) =>
+ cv.hasOwnProperty("key"))).toBeTruthy
+ )
+
+ it 'should not return a private key after its timeout has passed', ->
+ expect(PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false}).length).toEqual(1)
+ PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0].timeout = Date.now() - 5
+ expect(PGPKeyStore.privKeys(address: "benbitdiddle@icloud.com", timed: true).length).toEqual(0)
+ PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0].setTimeout()
+
+ it 'should only return the key(s) corresponding to a supplied email address', ->
+ expect(PGPKeyStore.privKeys(address: "wrong@example.com", timed: true).length).toEqual(0)
+
+ it 'should return all private keys when an address is not supplied', ->
+ expect(PGPKeyStore.privKeys({timed: false}).length).toEqual(1)
+
+ it 'should update an existing key when it is unlocked, not add a new one', ->
+ timeout = PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0].timeout
+ PGPKeyStore.getKeyContents(key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: =>
+ # expect no new keys to have been added
+ expect(PGPKeyStore.privKeys({timed: false}).length).toEqual(1)
+ # make sure the timeout is updated
+ expect(timeout < PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false}).timeout)
+ )
+
+ describe "when decrypting messages", ->
+ xit 'should be able to decrypt a message', ->
+ # TODO for some reason, the pgp.unbox has a problem with the message body
+ runs( =>
+ spyOn(PGPKeyStore, 'trigger')
+ PGPKeyStore.getKeyContents(key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: =>
+ PGPKeyStore.decrypt(@encryptedMsg)
+ )
+ )
+ waitsFor((=> PGPKeyStore.trigger.callCount > 0), 'message to decrypt')
+ runs( =>
+ expect(_.findWhere(PGPKeyStore._msgCache,
+ {clientId: @encryptedMsg.clientId})).toExist()
+ )
+
+ it 'should be able to handle an unencrypted message', ->
+ PGPKeyStore.decrypt(@unencryptedMsg)
+ expect(_.findWhere(PGPKeyStore._msgCache,
+ {clientId: @unencryptedMsg.clientId})).not.toBeDefined()
+
+ it 'should be able to tell when a message has no encrypted component', ->
+ expect(PGPKeyStore.hasEncryptedComponent(@unencryptedMsg)).not
+ expect(PGPKeyStore.hasEncryptedComponent(@encryptedMsg))
+
+ it 'should be able to handle a message with no BEGIN PGP MESSAGE block', ->
+ body = """Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto
+
+ wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw=
+ =1aPN
+ -----END PGP MESSAGE-----"""
+ badMsg = new Message({clientId: 'test2', subject: 'Subject', body: body})
+
+ PGPKeyStore.getKeyContents(key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: =>
+ PGPKeyStore.decrypt(badMsg)
+ expect(_.findWhere(PGPKeyStore._msgCache,
+ {clientId: badMsg.clientId})).not.toBeDefined()
+ )
+
+ it 'should be able to handle a message with no END PGP MESSAGE block', ->
+ body = """-----BEGIN PGP MESSAGE-----
+ Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto
+
+ wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw=
+ =1aPN"""
+ badMsg = new Message({clientId: 'test2', subject: 'Subject', body: body})
+
+ PGPKeyStore.getKeyContents(key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: =>
+ PGPKeyStore.decrypt(badMsg)
+ expect(_.findWhere(PGPKeyStore._msgCache,
+ {clientId: badMsg.clientId})).not.toBeDefined()
+ )
+
+ it 'should not return a decrypted message which has timed out', ->
+ PGPKeyStore._msgCache.push({clientId: "testID", body: "example body", timeout: Date.now()})
+
+ msg = new Message({clientId: "testID"})
+ expect(PGPKeyStore.getDecrypted(msg)).toEqual(null)
+
+ it 'should return a decrypted message', ->
+ timeout = Date.now() + (1000*60*60)
+ PGPKeyStore._msgCache.push({clientId: "testID2", body: "example body", timeout: timeout})
+
+ msg = new Message({clientId: "testID2", body: "example body"})
+ expect(PGPKeyStore.getDecrypted(msg)).toEqual(msg.body)
+
+ describe "when handling public keys", ->
+
+ it "should immediately return a pre-cached key", ->
+ expect(PGPKeyStore.pubKeys('benbitdiddle@icloud.com').length).toEqual(1)
diff --git a/packages/client-app/internal_packages/keybase/spec/recipient-key-chip-spec.cjsx b/packages/client-app/internal_packages/keybase/spec/recipient-key-chip-spec.cjsx
new file mode 100755
index 0000000000..d46ce46fac
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/spec/recipient-key-chip-spec.cjsx
@@ -0,0 +1,40 @@
+{React, ReactTestUtils, DraftStore, Contact} = require 'nylas-exports'
+pgp = require 'kbpgp'
+
+RecipientKeyChip = require '../lib/recipient-key-chip'
+PGPKeyStore = require '../lib/pgp-key-store'
+
+describe "DecryptMessageButton", ->
+ beforeEach ->
+ @contact = new Contact({email: "test@example.com"})
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "should render into the page", ->
+ expect(@component).toBeDefined()
+
+ it "should have a displayName", ->
+ expect(RecipientKeyChip.displayName).toBe('RecipientKeyChip')
+
+ xit "should indicate when a recipient has a PGP key available", ->
+ spyOn(PGPKeyStore, "pubKeys").andCallFake((address) =>
+ return [{'key':0}])
+ key = PGPKeyStore.pubKeys(@contact.email)
+ expect(key).toBeDefined()
+
+ # TODO these calls crash the tester because they require a call to getKeyContents
+ expect(@component.refs.keyIcon).toBeDefined()
+ expect(@component.refs.noKeyIcon).not.toBeDefined()
+
+ xit "should indicate when a recipient does not have a PGP key available", ->
+ component = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ key = PGPKeyStore.pubKeys(@contact.email)
+ expect(key).toEqual([])
+
+ # TODO these calls crash the tester because they require a call to getKeyContents
+ expect(component.refs.keyIcon).not.toBeDefined()
+ expect(component.refs.noKeyIcon).toBeDefined()
diff --git a/packages/client-app/internal_packages/keybase/stylesheets/main.less b/packages/client-app/internal_packages/keybase/stylesheets/main.less
new file mode 100755
index 0000000000..8ad660ec36
--- /dev/null
+++ b/packages/client-app/internal_packages/keybase/stylesheets/main.less
@@ -0,0 +1,526 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+@code-bg-color: #fcf4db;
+
+.keybase {
+
+ .no-keys-message {
+ text-align: center;
+ }
+
+}
+
+.container-keybase {
+ max-width: 640px;
+ margin: 0 auto;
+}
+
+.keybase-profile {
+ border: 1px solid @border-color-primary;
+ border-top: 0;
+ background: @background-primary;
+ padding: 10px;
+ overflow: auto;
+ display: flex;
+
+ .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);
+ 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: 18px;
+ font-weight: 500;
+ color: white;
+ box-shadow: inset 0 0 1px rgba(0,0,0,0.18);
+ background-image: linear-gradient(to bottom, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 100%);
+ }
+
+ .user-picture {
+ background: @background-secondary;
+ width: 44px;
+ height: 44px;
+ }
+ }
+ }
+
+ .key-actions {
+ display: flex;
+ flex-direction: column;
+
+ button {
+ margin: 2px 0 2px 10px;
+ white-space: nowrap;
+ display: inline-block;
+ float: right;
+ }
+ }
+
+ .details {
+ margin-left: 10px;
+ flex: 1;
+ }
+
+ button {
+ margin: 10px 0 10px 10px;
+ white-space: nowrap;
+ display: inline-block;
+ float: right;
+ }
+
+ keybase-participant-field {
+ float: right;
+ }
+
+ ul {
+ list-style-type: none;
+ }
+
+ .email-list {
+ padding-left: 10px;
+ word-break: break-all;
+ flex-grow: 3;
+ text-align: right;
+ }
+}
+
+.keybase-profile:first-child {
+ border-top: 1px solid @border-color-primary;
+}
+
+.fixed-popover-container, .email-list {
+ .keybase-participant-field {
+ margin-bottom: 10px;
+
+ .n1-keybase-recipient-key-chip {
+ display: none;
+ }
+
+ .tokenizing-field-label {
+ display: none;
+ padding-top: 0;
+ }
+
+ .tokenizing-field-input {
+ padding-left: 0;
+ padding-top: 0;
+
+ input {
+ border: none;
+ }
+ }
+ }
+}
+
+.fixed-popover-container {
+ .keybase-participant-field {
+ width: 300px;
+ background: @input-bg;
+ border: 1px solid @input-border-color;
+
+ .menu .content-container {
+ background: @background-secondary;
+ }
+ }
+
+ .passphrase-popover {
+ margin: 10px;
+ display: flex;
+
+ button {
+ margin-left: 5px;
+ flex: 0;
+ }
+
+ input {
+ min-width: 180px;
+ flex: 1;
+ }
+
+ .bad-passphrase {
+ border-color: @color-error;
+ }
+ }
+
+ .keybase-import-popover {
+ margin: 10px;
+
+ button {
+ width: 100%;
+ }
+
+ .title {
+ margin: 0 auto;
+ white-space: nowrap;
+ }
+ }
+
+ .private-key-popover {
+ display: flex;
+ flex-direction: column;
+ width: 300px;
+ margin: 5px 10px;
+
+ .picker-title {
+ margin-left: auto;
+ margin-right: auto;
+ text-align: center;
+ }
+
+ textarea {
+ margin-top: 5px;
+ }
+
+ .invalid-key-body {
+ background-color: @code-bg-color;
+ color: darken(@code-bg-color, 70%);
+ border: 1.5px solid darken(@code-bg-color, 10%);
+ border-radius: @border-radius-small;
+ font-size: @font-size-small;
+ margin: 5px 0 0 0;
+ text-align: center;
+ }
+
+ .key-add-buttons {
+ display: flex;
+ flex-direction: row;
+
+ button {
+ width: 147px;
+ margin: 5px 0 0 0;
+ }
+
+ .paste-btn {
+ margin-right: 6px;
+ }
+ }
+
+ .picker-controls {
+ width: 100%;
+ margin: 5px auto;
+ display: flex;
+ flex-shrink: 0;
+ flex-direction: row;
+
+ .modal-cancel-button {
+ float: left;
+ }
+
+ .modal-prefs-button {
+ flex: 1;
+ margin: 0 35px;
+ }
+
+ .modal-done-button {
+ float: right;
+ }
+ }
+ }
+}
+
+.email-list {
+ .keybase-participant-field {
+ width: 200px;
+ border-bottom: 1px solid @gray-light;
+ }
+}
+
+.keybase-decrypt {
+
+ div.line-w-label {
+ display: flex;
+ align-items: center;
+ color: rgba(128, 128, 128, 0.5);
+ }
+
+ div.decrypt-bar {
+ padding: 5px;
+ border: 1.5px solid rgba(128, 128, 128, 0.5);
+ border-radius: @border-radius-large;
+ align-items: center;
+ display: flex;
+
+ .title-text {
+ flex: 1;
+ margin: auto 0;
+ }
+
+ .decryption-interface {
+ button {
+ margin-left: 5px;
+ }
+ }
+ }
+
+ div.error-decrypt-bar {
+ border: 1.5px solid @color-error;
+
+ .title-text {
+ color: @color-error;
+ }
+ }
+
+ div.done-decrypt-bar {
+ border: 1.5px solid @color-success;
+
+ .title-text {
+ color: @color-success;
+ }
+ }
+
+ div.border {
+ height: 1px;
+ background: rgba(128, 128, 128, 0.5);
+ flex: 1;
+ }
+
+ div.error-border {
+ background: @color-error;
+ }
+
+ div.done-border {
+ background: @color-success;
+ }
+}
+
+.key-manager {
+
+ div.line-w-label {
+ display: flex;
+ align-items: center;
+ color: rgba(128, 128, 128, 0.5);
+ margin: 10px 0;
+ }
+ div.title-text {
+ padding: 0 10px;
+ }
+ div.border {
+ height: 1px;
+ background: rgba(128, 128, 128, 0.5);
+ flex: 1;
+ }
+}
+
+.key-status-bar {
+ background-color: @code-bg-color;
+ color: darken(@code-bg-color, 70%);
+ border: 1.5px solid darken(@code-bg-color, 10%);
+ border-radius: @border-radius-small;
+ font-size: @font-size-small;
+ margin-bottom: 10px;
+}
+
+.key-add {
+ padding-top:10px;
+
+ .no-keys-message {
+ text-align: center;
+ }
+
+ .key-adder {
+ position: relative;
+ border: 1px solid @input-border-color;
+ padding: 10px;
+ padding-top: 0;
+ margin-bottom: 10px;
+
+ .key-text {
+ margin-top: 10px;
+ min-height: 200px;
+ display: flex;
+
+ .loading {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, 50%);
+ }
+
+ textarea {
+ border: 0;
+ padding: 0;
+ font-size: 0.9em;
+ flex: 1;
+ }
+ }
+ }
+
+ .credentials {
+ display: flex;
+ flex-direction: row;
+
+ .key-add-btn {
+ margin: 10px 5px 0 0;
+ flex: 0;
+ }
+
+ .key-email-input {
+ margin: 10px 5px 0 0;
+ flex: 1;
+ }
+
+ .key-passphrase-input {
+ margin: 10px 5px 0 0;
+ flex: 1;
+ }
+
+ .invalid-msg {
+ color: #AAA;
+ white-space: nowrap;
+ text-align: right;
+ margin: 12px 5px 0 0;
+ flex: 1;
+ }
+ }
+
+ .key-creation-button {
+ display: inline-block;
+ margin: 0 5px 10px 5px;
+ }
+
+ .editor-note {
+ color: #AAA;
+ }
+}
+
+.key-instructions {
+ color: #333;
+ font-size: small;
+ margin-top: 20px;
+}
+
+.keybase-search {
+ margin-top: 15px;
+ margin-bottom: 15px;
+ overflow: scroll;
+ position: relative;
+
+ input {
+ padding: 10px;
+ margin-bottom: 10px;
+ }
+
+ .empty {
+ text-align: center;
+ }
+
+ .loading {
+ position: absolute;
+ right: 10px;
+ top: 8px; // lol I wonder how long until this is a problem
+ }
+
+ .bad-search-msg {
+ display: inline-block;
+ width: 100%;
+ text-align: center;
+ color: rgba(128, 128, 128, 0.5);
+
+ br {
+ display: none;
+ }
+ }
+}
+
+.key-picker-modal {
+ width: 400px;
+ height: 400px;
+ display: flex;
+ flex-direction: column;
+
+ .keybase-search {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ max-width: 400px;
+ overflow: hidden;
+ margin-bottom: 0;
+ margin-top: 10px;
+
+ .searchbar {
+ width: 380px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .loading {
+ right: 20px;
+ }
+
+ .results {
+ overflow: auto;
+ height: 100%;
+ width: 100%;
+ }
+
+ .bad-search-msg {
+ br {
+ display: inline;
+ }
+ }
+ }
+
+ .picker-controls {
+ width: 380px;
+ margin: 5px auto 10px auto;
+ display: flex;
+ flex-shrink: 0;
+ flex-direction: row;
+
+ .modal-back-button {
+ float: left;
+ }
+
+ .modal-prefs-button {
+ flex: 1;
+ margin: 0 35px;
+ }
+
+ .modal-next-button {
+ float: right;
+ }
+ }
+
+ .keybase-profile-solo {
+ border: 1px solid @border-color-primary;
+ margin-top: 10px;
+ }
+
+ .picker-title {
+ margin-top: 10px;
+ margin-left: auto;
+ margin-right: auto;
+ text-align: center;
+ }
+}
+
+.decrypted {
+ display: block;
+ box-sizing: border-box;
+ -webkit-print-color-adjust: exact;
+ padding: 8px 12px;
+ margin-bottom: 5px;
+ border: 1px solid rgb(235, 204, 209);
+ border-radius: 4px;
+ background-color: rgb(121, 212, 91);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/packages/client-app/internal_packages/main-calendar/README.md b/packages/client-app/internal_packages/main-calendar/README.md
new file mode 100644
index 0000000000..fe0aa095c8
--- /dev/null
+++ b/packages/client-app/internal_packages/main-calendar/README.md
@@ -0,0 +1 @@
+# composer package
diff --git a/packages/client-app/internal_packages/main-calendar/lib/calendar-wrapper.jsx b/packages/client-app/internal_packages/main-calendar/lib/calendar-wrapper.jsx
new file mode 100644
index 0000000000..efc0d1853f
--- /dev/null
+++ b/packages/client-app/internal_packages/main-calendar/lib/calendar-wrapper.jsx
@@ -0,0 +1,107 @@
+import {
+ Actions,
+ DestroyModelTask,
+ CalendarDataSource,
+} from 'nylas-exports';
+import {
+ NylasCalendar,
+ KeyCommandsRegion,
+ CalendarEventPopover,
+} from 'nylas-component-kit';
+import React from 'react';
+import {remote} from 'electron';
+
+
+export default class CalendarWrapper extends React.Component {
+ static displayName = 'CalendarWrapper';
+ static containerRequired = false;
+
+ constructor(props) {
+ super(props);
+ this._dataSource = new CalendarDataSource();
+ this.state = {selectedEvents: []};
+ }
+
+ _openEventPopover(eventModel) {
+ const eventEl = document.getElementById(eventModel.id);
+ if (!eventEl) { return; }
+ const eventRect = eventEl.getBoundingClientRect()
+
+ Actions.openPopover(
+
+ , {
+ originRect: eventRect,
+ direction: 'right',
+ fallbackDirection: 'left',
+ })
+ }
+
+ _onEventClick = (e, event) => {
+ let next = [].concat(this.state.selectedEvents);
+
+ if (e.shiftKey || e.metaKey) {
+ const idx = next.findIndex(({id}) => event.id === id)
+ if (idx === -1) {
+ next.push(event)
+ } else {
+ next.splice(idx, 1)
+ }
+ } else {
+ next = [event];
+ }
+
+ this.setState({
+ selectedEvents: next,
+ });
+ }
+
+ _onEventDoubleClick = (eventModel) => {
+ this._openEventPopover(eventModel)
+ }
+
+ _onEventFocused = (eventModel) => {
+ this._openEventPopover(eventModel)
+ }
+
+ _onDeleteSelectedEvents = () => {
+ if (this.state.selectedEvents.length === 0) {
+ return;
+ }
+ const response = remote.dialog.showMessageBox(remote.getCurrentWindow(), {
+ type: 'warning',
+ buttons: ['Delete', 'Cancel'],
+ message: 'Delete or decline these events?',
+ detail: `Are you sure you want to delete or decline invitations for the selected event(s)?`,
+ });
+ if (response === 0) { // response is button array index
+ for (const event of this.state.selectedEvents) {
+ const task = new DestroyModelTask({
+ clientId: event.clientId,
+ modelName: event.constructor.name,
+ endpoint: '/events',
+ accountId: event.accountId,
+ })
+ Actions.queueTask(task);
+ }
+ }
+ }
+
+ render() {
+ return (
+
+
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/main-calendar/lib/event-description-frame.jsx b/packages/client-app/internal_packages/main-calendar/lib/event-description-frame.jsx
new file mode 100644
index 0000000000..9a218ce635
--- /dev/null
+++ b/packages/client-app/internal_packages/main-calendar/lib/event-description-frame.jsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {EventedIFrame} from 'nylas-component-kit';
+import {Utils} from 'nylas-exports';
+
+export default class EmailFrame extends React.Component {
+ static displayName = 'EmailFrame';
+
+ static propTypes = {
+ content: React.PropTypes.string.isRequired,
+ };
+
+ componentDidMount() {
+ this._mounted = true;
+ this._writeContent();
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (!Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state));
+ }
+
+ componentDidUpdate() {
+ this._writeContent();
+ }
+
+ componentWillUnmount() {
+ this._mounted = false;
+ if (this._unlisten) {
+ this._unlisten();
+ }
+ }
+
+ _writeContent = () => {
+ const iframeNode = ReactDOM.findDOMNode(this.refs.iframe);
+ const doc = iframeNode.contentDocument;
+ if (!doc) { return; }
+ doc.open();
+
+ // NOTE: The iframe must have a modern DOCTYPE. The lack of this line
+ // will cause some bizzare non-standards compliant rendering with the
+ // message bodies. This is particularly felt with elements use
+ // the `border-collapse: collapse` css property while setting a
+ // `padding`.
+ doc.write("");
+ doc.write(`${this.props.content}
`);
+ doc.close();
+
+ // autolink(doc, {async: true});
+ // autoscaleImages(doc);
+ // addInlineDownloadPrompts(doc);
+
+ // Notify the EventedIFrame that we've replaced it's document (with `open`)
+ // so it can attach event listeners again.
+ this.refs.iframe.didReplaceDocument();
+ this._onMustRecalculateFrameHeight();
+ }
+
+ _onMustRecalculateFrameHeight = () => {
+ this.refs.iframe.setHeightQuietly(0);
+ this._lastComputedHeight = 0;
+ this._setFrameHeight();
+ }
+
+ _getFrameHeight = (doc) => {
+ let height = 0;
+
+ if (doc && doc.body) {
+ // Why reset the height? body.scrollHeight will always be 0 if the height
+ // of the body is dependent on the iframe height e.g. if height ===
+ // 100% in inline styles or an email stylesheet
+ const style = window.getComputedStyle(doc.body)
+ if (style.height === '0px') {
+ doc.body.style.height = "auto"
+ }
+ height = doc.body.scrollHeight;
+ }
+
+ if (doc && doc.documentElement) {
+ height = doc.documentElement.scrollHeight;
+ }
+
+ // scrollHeight does not include space required by scrollbar
+ return height + 25;
+ }
+
+ _setFrameHeight = () => {
+ if (!this._mounted) {
+ return;
+ }
+
+ // Q: What's up with this holder?
+ // A: If you resize the window, or do something to trigger setFrameHeight
+ // on an already-loaded message view, all the heights go to zero for a brief
+ // second while the heights are recomputed. This causes the ScrollRegion to
+ // reset it's scrollTop to ~0 (the new combined heiht of all children).
+ // To prevent this, the holderNode holds the last computed height until
+ // the new height is computed.
+ const holderNode = ReactDOM.findDOMNode(this.refs.iframeHeightHolder);
+ const iframeNode = ReactDOM.findDOMNode(this.refs.iframe);
+ const height = this._getFrameHeight(iframeNode.contentDocument);
+
+ // Why 5px? Some emails have elements with a height of 100%, and then put
+ // tracking pixels beneath that. In these scenarios, the scrollHeight of the
+ // message is always <100% + 1px>, which leads us to resize them constantly.
+ // This is a hack, but I'm not sure of a better solution.
+ if (Math.abs(height - this._lastComputedHeight) > 5) {
+ this.refs.iframe.setHeightQuietly(height);
+ holderNode.style.height = `${height}px`;
+ this._lastComputedHeight = height;
+ }
+
+ if (iframeNode.contentDocument.readyState !== 'complete') {
+ setTimeout(() => this._setFrameHeight(), 0);
+ }
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/main-calendar/lib/main.jsx b/packages/client-app/internal_packages/main-calendar/lib/main.jsx
new file mode 100644
index 0000000000..3434cba4a6
--- /dev/null
+++ b/packages/client-app/internal_packages/main-calendar/lib/main.jsx
@@ -0,0 +1,70 @@
+// import {exec} from 'child_process';
+// import fs from 'fs';
+// import path from 'path';
+// import {WorkspaceStore, ComponentRegistry} from 'nylas-exports';
+// import CalendarWrapper from './calendar-wrapper';
+// import QuickEventButton from './quick-event-button';
+
+//
+// function resolveHelperPath(callback) {
+// const resourcesPath = NylasEnv.getLoadSettings().resourcePath;
+// let pathToCalendarApp = path.join(resourcesPath, '..', 'Nylas Calendar.app');
+//
+// fs.exists(pathToCalendarApp, (exists) => {
+// if (exists) {
+// callback(pathToCalendarApp);
+// return;
+// }
+//
+// pathToCalendarApp = path.join(resourcesPath, 'build', 'resources', 'mac', 'Nylas Calendar.app');
+// fs.exists(pathToCalendarApp, (fallbackExists) => {
+// if (fallbackExists) {
+// callback(pathToCalendarApp);
+// return;
+// }
+// callback(null);
+// });
+// });
+// }
+
+export function activate() {
+ return;
+ // WorkspaceStore.defineSheet('Main', {root: true}, {list: ['Center']});
+ //
+ // if (process.platform === 'darwin') {
+ // resolveHelperPath((helperPath) => {
+ // if (!helperPath) {
+ // return;
+ // }
+ //
+ // exec(`chmod +x "${helperPath}/Contents/MacOS/Nylas Calendar"`, () => {
+ // exec(`open "${helperPath}"`);
+ // });
+ //
+ // if (!NylasEnv.config.get('addedToDockCalendar')) {
+ // exec(`defaults write com.apple.dock persistent-apps -array-add "tile-data file-data _CFURLString ${helperPath}/ _CFURLStringType 0 "`, () => {
+ // NylasEnv.config.set('addedToDockCalendar', true);
+ // exec(`killall Dock`);
+ // });
+ // }
+ // });
+ //
+ // NylasEnv.onBeforeUnload(() => {
+ // exec('killall "Nylas Calendar"');
+ // return true;
+ // });
+ // }
+ //
+ // ComponentRegistry.register(CalendarWrapper, {
+ // location: WorkspaceStore.Location.Center,
+ // });
+ // ComponentRegistry.register(QuickEventButton, {
+ // location: WorkspaceStore.Location.Center.Toolbar,
+ // });
+}
+
+export function deactivate() {
+ return;
+ // ComponentRegistry.unregister(CalendarWrapper);
+ // ComponentRegistry.unregister(QuickEventButton);
+}
diff --git a/packages/client-app/internal_packages/main-calendar/lib/quick-event-button.jsx b/packages/client-app/internal_packages/main-calendar/lib/quick-event-button.jsx
new file mode 100644
index 0000000000..ec40008fc7
--- /dev/null
+++ b/packages/client-app/internal_packages/main-calendar/lib/quick-event-button.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {Actions} from 'nylas-exports';
+import QuickEventPopover from './quick-event-popover';
+
+export default class QuickEventButton extends React.Component {
+ static displayName = "QuickEventButton";
+
+ onClick = (event) => {
+ event.stopPropagation()
+ const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
+ Actions.openPopover(
+ ,
+ {originRect: buttonRect, direction: 'down'}
+ )
+ };
+
+ render() {
+ return (
+
+ +
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/main-calendar/lib/quick-event-popover.jsx b/packages/client-app/internal_packages/main-calendar/lib/quick-event-popover.jsx
new file mode 100644
index 0000000000..ec88219f2c
--- /dev/null
+++ b/packages/client-app/internal_packages/main-calendar/lib/quick-event-popover.jsx
@@ -0,0 +1,96 @@
+import React from 'react';
+import {
+ Actions,
+ Calendar,
+ DatabaseStore,
+ DateUtils,
+ Event,
+ SyncbackEventTask,
+} from 'nylas-exports'
+
+export default class QuickEventPopover extends React.Component {
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ start: null,
+ end: null,
+ leftoverText: null,
+ }
+ }
+
+ onInputKeyDown = (event) => {
+ const {key, target: {value}} = event;
+ if (value.length > 0 && ["Enter", "Return"].includes(key)) {
+ // This prevents onInputChange from being fired
+ event.stopPropagation();
+ this.createEvent(DateUtils.parseDateString(value));
+ Actions.closePopover();
+ }
+ };
+
+ onInputChange = (event) => {
+ this.setState(DateUtils.parseDateString(event.target.value));
+ };
+
+ createEvent = ({leftoverText, start, end}) => {
+ DatabaseStore.findAll(Calendar).then((allCalendars) => {
+ if (allCalendars.length === 0) {
+ throw new Error("Can't create an event, you have no calendars");
+ }
+ const cals = allCalendars.filter(c => !c.readOnly);
+ if (cals.length === 0) {
+ NylasEnv.showErrorDialog("This account has no editable calendars. We can't " +
+ "create an event for you. Please make sure you have an editable calendar " +
+ "with your account provider.");
+ return Promise.reject();
+ }
+
+ const event = new Event({
+ calendarId: cals[0].id,
+ accountId: cals[0].accountId,
+ start: start.unix(),
+ end: end.unix(),
+ when: {
+ start_time: start.unix(),
+ end_time: end.unix(),
+ },
+ title: leftoverText,
+ })
+
+ return DatabaseStore.inTransaction((t) => {
+ return t.persistModel(event)
+ }).then(() => {
+ const task = new SyncbackEventTask(event.clientId);
+ Actions.queueTask(task);
+ })
+ })
+ }
+
+
+ render() {
+ let dateInterpretation;
+ if (this.state.start) {
+ dateInterpretation = (
+
+ Title: {this.state.leftoverText}
+ Start: {DateUtils.format(this.state.start, DateUtils.DATE_FORMAT_SHORT)}
+ End: {DateUtils.format(this.state.end, DateUtils.DATE_FORMAT_SHORT)}
+
+ );
+ }
+
+ return (
+
+
+ {dateInterpretation}
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/main-calendar/package.json b/packages/client-app/internal_packages/main-calendar/package.json
new file mode 100644
index 0000000000..f0598a598e
--- /dev/null
+++ b/packages/client-app/internal_packages/main-calendar/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "main-calendar",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Nylas Calendar Sidebar",
+ "license": "GPL-3.0",
+ "private": true,
+ "scripts": {
+ },
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "calendar": true
+ }
+}
diff --git a/packages/client-app/internal_packages/main-calendar/stylesheets/main-calendar.less b/packages/client-app/internal_packages/main-calendar/stylesheets/main-calendar.less
new file mode 100644
index 0000000000..ce8d1f7edb
--- /dev/null
+++ b/packages/client-app/internal_packages/main-calendar/stylesheets/main-calendar.less
@@ -0,0 +1,67 @@
+// The ui-variables file is provided by base themes provided by N1.
+@import "ui-variables";
+@import "ui-mixins";
+
+.main-calendar {
+ height: 100%;
+
+ .event-grid-legend {
+ border-left: 1px solid @border-color-divider;
+ }
+}
+
+.calendar-event-popover {
+ color: fadeout(@text-color, 20%);
+ background-color: @background-primary;
+ display: flex;
+ flex-direction: column;
+ font-size: @font-size-small;
+ width: 300px;
+
+ .location {
+ color: @text-color-very-subtle;
+ padding: @padding-base-vertical @padding-base-horizontal;
+ word-wrap: break-word;
+ }
+ .title-wrapper {
+ color: @text-color-inverse;
+ display: flex;
+ font-size: @font-size-larger;
+ background-color: @accent-primary;
+ border-top-left-radius: @border-radius-base;
+ border-top-right-radius: @border-radius-base;
+ padding: @padding-base-vertical @padding-base-horizontal;
+ }
+ .edit-icon {
+ background-color: @text-color-inverse;
+ cursor: pointer;
+ }
+ .description .scroll-region-content {
+ max-height:300px;
+ word-wrap: break-word;
+ position: relative;
+ }
+ .label {
+ color: @text-color-very-subtle;
+ }
+ .section {
+ border-top: 1px solid @border-color-divider;
+ padding: @padding-base-vertical @padding-base-horizontal;
+ }
+ .row.time {
+ .time-picker {
+ text-align: center;
+ }
+ .time-picker-wrap {
+ margin-right: 5px;
+
+ .time-options {
+ z-index: 10; // So the time pickers show over
+ }
+ }
+ }
+}
+
+.quick-event-popover {
+ width: 250px;
+}
diff --git a/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-actions.es6 b/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-actions.es6
new file mode 100644
index 0000000000..2983405e9b
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-actions.es6
@@ -0,0 +1,13 @@
+import Reflux from 'reflux';
+
+const ActionNames = [
+ 'temporarilyEnableImages',
+ 'permanentlyEnableImages',
+];
+
+const Actions = Reflux.createActions(ActionNames);
+ActionNames.forEach((name) => {
+ Actions[name].sync = true;
+});
+
+export default Actions;
diff --git a/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-extension.es6 b/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-extension.es6
new file mode 100644
index 0000000000..5ade2d0913
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-extension.es6
@@ -0,0 +1,12 @@
+import {MessageViewExtension} from 'nylas-exports';
+import AutoloadImagesStore from './autoload-images-store';
+
+export default class AutoloadImagesExtension extends MessageViewExtension {
+ static formatMessageBody = ({message}) => {
+ if (AutoloadImagesStore.shouldBlockImagesIn(message)) {
+ message.body = message.body.replace(AutoloadImagesStore.ImagesRegexp, (match, prefix) => {
+ return `${prefix}#`;
+ });
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-header.jsx b/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-header.jsx
new file mode 100644
index 0000000000..bb7200be2b
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-header.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import {Message} from 'nylas-exports';
+
+import AutoloadImagesStore from './autoload-images-store';
+import Actions from './autoload-images-actions';
+
+export default class AutoloadImagesHeader extends React.Component {
+ static displayName = 'AutoloadImagesHeader';
+
+ static propTypes = {
+ message: React.PropTypes.instanceOf(Message).isRequired,
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ blocking: AutoloadImagesStore.shouldBlockImagesIn(this.props.message),
+ };
+ }
+
+ componentDidMount() {
+ this._unlisten = AutoloadImagesStore.listen(() => {
+ const blocking = AutoloadImagesStore.shouldBlockImagesIn(this.props.message);
+ if (blocking !== this.state.blocking) {
+ this.setState({blocking});
+ }
+ });
+ }
+
+ componentWillUnmount() {
+ this._unlisten();
+ }
+
+ render() {
+ const {message} = this.props;
+ const {blocking} = this.state;
+
+ if (blocking === false) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-store.es6 b/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-store.es6
new file mode 100644
index 0000000000..8a3fca3989
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-store.es6
@@ -0,0 +1,89 @@
+import NylasStore from 'nylas-store';
+import fs from 'fs';
+import path from 'path';
+import {Utils, MessageBodyProcessor} from 'nylas-exports';
+import AutoloadImagesActions from './autoload-images-actions';
+
+const ImagesRegexp = /((?:src|background|placeholder|icon|background|poster|srcset)\s*=\s*['"]?(?=\w*:\/\/)|:\s*url\()+([^"')]*)/gi;
+
+class AutoloadImagesStore extends NylasStore {
+
+ constructor() {
+ super();
+
+ this.ImagesRegexp = ImagesRegexp;
+
+ this._whitelistEmails = {}
+ this._whitelistMessageIds = {}
+
+ const filename = 'autoload-images-whitelist.txt';
+ this._whitelistEmailsPath = path.join(NylasEnv.getConfigDirPath(), filename);
+
+ this._loadWhitelist();
+
+ this.listenTo(AutoloadImagesActions.temporarilyEnableImages, this._onTemporarilyEnableImages);
+ this.listenTo(AutoloadImagesActions.permanentlyEnableImages, this._onPermanentlyEnableImages);
+
+ NylasEnv.config.onDidChange('core.reading.autoloadImages', () => {
+ MessageBodyProcessor.resetCache();
+ });
+ }
+
+ shouldBlockImagesIn = (message) => {
+ if (NylasEnv.config.get('core.reading.autoloadImages') === true) {
+ return false;
+ }
+ if (this._whitelistEmails[Utils.toEquivalentEmailForm(message.fromContact().email)]) {
+ return false;
+ }
+ if (this._whitelistMessageIds[message.id]) {
+ return false;
+ }
+
+ return ImagesRegexp.test(message.body);
+ }
+
+ _loadWhitelist = () => {
+ fs.exists(this._whitelistEmailsPath, (exists) => {
+ if (!exists) { return; }
+
+ fs.readFile(this._whitelistEmailsPath, (err, body) => {
+ if (err || !body) {
+ console.log(err);
+ return;
+ }
+
+ this._whitelistEmails = {}
+ body.toString().split(/[\n\r]+/).forEach((email) => {
+ this._whitelistEmails[Utils.toEquivalentEmailForm(email)] = true;
+ });
+ this.trigger();
+ });
+ });
+ }
+
+ _saveWhitelist = () => {
+ const data = Object.keys(this._whitelistEmails).join('\n');
+ fs.writeFile(this._whitelistEmailsPath, data, (err) => {
+ if (err) {
+ console.error(`AutoloadImagesStore could not save whitelist: ${err.toString()}`);
+ }
+ });
+ }
+
+ _onTemporarilyEnableImages = (message) => {
+ this._whitelistMessageIds[message.id] = true;
+ MessageBodyProcessor.resetCache();
+ this.trigger();
+ }
+
+ _onPermanentlyEnableImages = (message) => {
+ const email = Utils.toEquivalentEmailForm(message.fromContact().email);
+ this._whitelistEmails[email] = true;
+ MessageBodyProcessor.resetCache();
+ setTimeout(this._saveWhitelist, 1);
+ this.trigger();
+ }
+}
+
+export default new AutoloadImagesStore();
diff --git a/packages/client-app/internal_packages/message-autoload-images/lib/main.es6 b/packages/client-app/internal_packages/message-autoload-images/lib/main.es6
new file mode 100644
index 0000000000..49baeda7a2
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/lib/main.es6
@@ -0,0 +1,37 @@
+import {
+ ComponentRegistry,
+ ExtensionRegistry,
+} from 'nylas-exports';
+
+import AutoloadImagesExtension from './autoload-images-extension';
+import AutoloadImagesHeader from './autoload-images-header';
+
+/*
+All packages must export a basic object that has at least the following 3
+methods:
+
+1. `activate` - Actions to take once the package gets turned on.
+Pre-enabled packages get activated on N1 bootup. They can also be
+activated manually by a user.
+
+2. `deactivate` - Actions to take when a package gets turned off. This can
+happen when a user manually disables a package.
+
+3. `serialize` - A simple serializable object that gets saved to disk
+before N1 quits. This gets passed back into `activate` next time N1 boots
+up or your package is manually activated.
+*/
+export function activate() {
+ // Register Message List Actions we provide globally
+ ExtensionRegistry.MessageView.register(AutoloadImagesExtension);
+ ComponentRegistry.register(AutoloadImagesHeader, {
+ role: 'message:BodyHeader',
+ });
+}
+
+export function serialize() {}
+
+export function deactivate() {
+ ExtensionRegistry.MessageView.unregister(AutoloadImagesExtension);
+ ComponentRegistry.unregister(AutoloadImagesHeader);
+}
diff --git a/packages/client-app/internal_packages/message-autoload-images/package.json b/packages/client-app/internal_packages/message-autoload-images/package.json
new file mode 100755
index 0000000000..edf45f194b
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "message-autoload-images",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Option to conditionally load the images in messages",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "default": true,
+ "thread-popout": true
+ }
+}
diff --git a/packages/client-app/internal_packages/message-autoload-images/spec/autoload-images-extension-spec.es6 b/packages/client-app/internal_packages/message-autoload-images/spec/autoload-images-extension-spec.es6
new file mode 100644
index 0000000000..a8c36322e6
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/spec/autoload-images-extension-spec.es6
@@ -0,0 +1,36 @@
+import fs from 'fs';
+import path from 'path';
+import AutoloadImagesExtension from '../lib/autoload-images-extension';
+import AutoloadImagesStore from '../lib/autoload-images-store';
+
+describe('AutoloadImagesExtension', function autoloadImagesExtension() {
+ describe("formatMessageBody", () => {
+ const scenarios = [];
+ const fixtures = path.resolve(path.join(__dirname, 'fixtures'));
+
+ fs.readdirSync(fixtures).forEach((filename) => {
+ if (filename.endsWith('-in.html')) {
+ const name = filename.replace('-in.html', '');
+
+ scenarios.push({
+ 'name': name,
+ 'in': fs.readFileSync(path.join(fixtures, filename)).toString(),
+ 'out': fs.readFileSync(path.join(fixtures, `${name}-out.html`)).toString(),
+ });
+ }
+ });
+
+ scenarios.forEach((scenario) => {
+ it(`should process ${scenario.name}`, () => {
+ spyOn(AutoloadImagesStore, 'shouldBlockImagesIn').andReturn(true);
+
+ const message = {
+ body: scenario.in,
+ };
+ AutoloadImagesExtension.formatMessageBody({message});
+
+ expect(message.body === scenario.out).toBe(true);
+ });
+ });
+ });
+});
diff --git a/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/linkedin-in.html b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/linkedin-in.html
new file mode 100644
index 0000000000..7c30059ab5
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/linkedin-in.html
@@ -0,0 +1,1206 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Published by your network
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Richard A. Moran
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ryan Holmes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Recommended for you
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Jeff Haden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Katie Carroll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Liz Ryan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Have your own perspective to share?
+
+
+
+
+
+
+ Start writing on LinkedIn
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You are receiving notification emails from LinkedIn. Unsubscribe
+
+
+
+
+
+ This email was intended for Benjamin Hartester (Software Developer). Learn why we included this.
+
+
+ If you need assistance or have questions, please contact LinkedIn Customer Service .
+
+
+
+
+
+
+ © 2015 LinkedIn Corporation, 2029 Stierlin Court, Mountain View CA 94043. LinkedIn and the LinkedIn logo are registered trademarks of LinkedIn.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/linkedin-out.html b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/linkedin-out.html
new file mode 100644
index 0000000000..e3a25e3f82
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/linkedin-out.html
@@ -0,0 +1,1206 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Published by your network
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Richard A. Moran
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ryan Holmes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Recommended for you
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Jeff Haden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Katie Carroll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Liz Ryan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Have your own perspective to share?
+
+
+
+
+
+
+ Start writing on LinkedIn
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You are receiving notification emails from LinkedIn. Unsubscribe
+
+
+
+
+
+ This email was intended for Benjamin Hartester (Software Developer). Learn why we included this.
+
+
+ If you need assistance or have questions, please contact LinkedIn Customer Service .
+
+
+
+
+
+
+ © 2015 LinkedIn Corporation, 2029 Stierlin Court, Mountain View CA 94043. LinkedIn and the LinkedIn logo are registered trademarks of LinkedIn.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/marketing-email-in.html b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/marketing-email-in.html
new file mode 100644
index 0000000000..64628496a2
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/marketing-email-in.html
@@ -0,0 +1,1262 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The extensible, open source mail client.
+
+
+
+
+
+
+
+
+
+
+
+ New features, speed & plugins for N1.
+
+
+
+
+
+
+
+
+
+ It's been almost 2 months since we released Nylas Mail. Our team has been hard at work on this latest update, including awesome new plugins, a beautiful Windows version, and details of our roadmap. Read on for full details!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tame your calendar with QuickSchedule.
Say goodbye to the hassle of scheduling! This new plugin lets you avoid the typical back-and-forth of picking a time to meet. Just select a few options, and your recipient confirms with one click. It's the best way to instantly schedule meetings.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ It's full of stars!
Starring the N1 repo is a great way to show your support and bookmark the codebase for later. It also means you'll see pre-release product updates in your GitHub feed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ N1 is now available on Windows!
Are you tired of Outlook and looking for something fresh? N1 now works great on Windows with all the same features available on Mac and Linux. Plus you can connect both your Gmail and Exchange accounts.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Faster and with fewer bugs!
We've closed hundreds of bug reports and made big improvements to speed and memory usage of N1. If you've already downloaded, make sure to update the app.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Features On Deck
Our team is hard at work on features including unified inbox, mail rules, and support for aliases and signatures. To stay up to date, you should follow us on Twitter
+ here . You can also vote up features on our
+open roadmap .
Those are the latest updates. Thanks for trying N1 and for the continued feedback! If you'd like to join the experimental beta channel for N1, just reply to this message with your address. (We'll push more frequent updates, but they might have occasional issues.)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Want to create the future of email?
Nylas is hiring!
+Our small team in SF is growing, and we're looking for great engineers, designers, PMs, and more to help shape the future of email.
Curious? Learn a bit more about the team behind N1,
+and see what it's like to work at Nylas . We welcome applications from those of all background.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PS: Not sure why you're receiving this message? Nylas was previously called InboxApp and launched last year . You probably signed up then
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/marketing-email-out.html b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/marketing-email-out.html
new file mode 100644
index 0000000000..bd82c446fa
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/marketing-email-out.html
@@ -0,0 +1,1262 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The extensible, open source mail client.
+
+
+
+
+
+
+
+
+
+
+
+ New features, speed & plugins for N1.
+
+
+
+
+
+
+
+
+
+ It's been almost 2 months since we released Nylas Mail. Our team has been hard at work on this latest update, including awesome new plugins, a beautiful Windows version, and details of our roadmap. Read on for full details!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tame your calendar with QuickSchedule.
Say goodbye to the hassle of scheduling! This new plugin lets you avoid the typical back-and-forth of picking a time to meet. Just select a few options, and your recipient confirms with one click. It's the best way to instantly schedule meetings.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ It's full of stars!
Starring the N1 repo is a great way to show your support and bookmark the codebase for later. It also means you'll see pre-release product updates in your GitHub feed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ N1 is now available on Windows!
Are you tired of Outlook and looking for something fresh? N1 now works great on Windows with all the same features available on Mac and Linux. Plus you can connect both your Gmail and Exchange accounts.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Faster and with fewer bugs!
We've closed hundreds of bug reports and made big improvements to speed and memory usage of N1. If you've already downloaded, make sure to update the app.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Features On Deck
Our team is hard at work on features including unified inbox, mail rules, and support for aliases and signatures. To stay up to date, you should follow us on Twitter
+ here . You can also vote up features on our
+open roadmap .
Those are the latest updates. Thanks for trying N1 and for the continued feedback! If you'd like to join the experimental beta channel for N1, just reply to this message with your address. (We'll push more frequent updates, but they might have occasional issues.)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Want to create the future of email?
Nylas is hiring!
+Our small team in SF is growing, and we're looking for great engineers, designers, PMs, and more to help shape the future of email.
Curious? Learn a bit more about the team behind N1,
+and see what it's like to work at Nylas . We welcome applications from those of all background.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PS: Not sure why you're receiving this message? Nylas was previously called InboxApp and launched last year . You probably signed up then
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/no-image-extensions-in.html b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/no-image-extensions-in.html
new file mode 100644
index 0000000000..5773587a5a
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/no-image-extensions-in.html
@@ -0,0 +1,251 @@
+
+
+
+
+
+ Google Play
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cowboy hat? Check. Crushin' it? Check. Now all you need is Brad Paisley's latest album, Moonshine in the Trunk. Lucky for you, it’s free on Google Play for a limited time.*
+
+
+
+ Get It Free
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Kacey Musgraves
+
+
+
+
+
+
+
+
+ Buy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Brett Eldredge
+
+
+
+
+
+
+
+
+ Buy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Miranda Lambert
+
+
+
+
+
+
+
+
+ Buy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ © 2015 Google Inc. 1600 Amphitheatre Parkway, Mountain View, CA 94043, USA
+
+
+
+ This message was sent to careless@foundry376.com because you asked us to keep you up to date with the latest news and offers from Google Play. If you do not wish to receive these emails, please unsubscribe here . You can also change your email preferences on Google Play by logging in at https://play.google.com/settings .
+
+
+
+ Downloading free music, TV shows, and certain free books and magazines is still considered a transaction, even when the price of the item is $0.00. If you don't have a credit card associated with your Google Payments account or if you haven't set up a Google Payments account, you'll be prompted to add a new payment method upon downloading some types of content on Google Play.
+*Promotion valid while supplies last. Promotion is not transferable, cannot be sold or bartered, has no cash value, and is non-refundable. Promotion is void where prohibited by law. Requires Google Play account. Offer good for users 13+ in United States only. Compatible internet connected devices required. Google reserves the right to terminate or modify this promotion. © Google Inc.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/no-image-extensions-out.html b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/no-image-extensions-out.html
new file mode 100644
index 0000000000..f218edf8d2
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/no-image-extensions-out.html
@@ -0,0 +1,251 @@
+
+
+
+
+
+ Google Play
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cowboy hat? Check. Crushin' it? Check. Now all you need is Brad Paisley's latest album, Moonshine in the Trunk. Lucky for you, it’s free on Google Play for a limited time.*
+
+
+
+ Get It Free
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Kacey Musgraves
+
+
+
+
+
+
+
+
+ Buy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Brett Eldredge
+
+
+
+
+
+
+
+
+ Buy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Miranda Lambert
+
+
+
+
+
+
+
+
+ Buy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ © 2015 Google Inc. 1600 Amphitheatre Parkway, Mountain View, CA 94043, USA
+
+
+
+ This message was sent to careless@foundry376.com because you asked us to keep you up to date with the latest news and offers from Google Play. If you do not wish to receive these emails, please unsubscribe here . You can also change your email preferences on Google Play by logging in at https://play.google.com/settings .
+
+
+
+ Downloading free music, TV shows, and certain free books and magazines is still considered a transaction, even when the price of the item is $0.00. If you don't have a credit card associated with your Google Payments account or if you haven't set up a Google Payments account, you'll be prompted to add a new payment method upon downloading some types of content on Google Play.
+*Promotion valid while supplies last. Promotion is not transferable, cannot be sold or bartered, has no cash value, and is non-refundable. Promotion is void where prohibited by law. Requires Google Play account. Offer good for users 13+ in United States only. Compatible internet connected devices required. Google reserves the right to terminate or modify this promotion. © Google Inc.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/table-body-in.html b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/table-body-in.html
new file mode 100644
index 0000000000..286220c72d
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/table-body-in.html
@@ -0,0 +1 @@
+
diff --git a/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/table-body-out.html b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/table-body-out.html
new file mode 100644
index 0000000000..88a69513c7
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/spec/fixtures/table-body-out.html
@@ -0,0 +1 @@
+
diff --git a/packages/client-app/internal_packages/message-autoload-images/stylesheets/message-autoload-images.less b/packages/client-app/internal_packages/message-autoload-images/stylesheets/message-autoload-images.less
new file mode 100644
index 0000000000..9953b82cc6
--- /dev/null
+++ b/packages/client-app/internal_packages/message-autoload-images/stylesheets/message-autoload-images.less
@@ -0,0 +1,19 @@
+@import "ui-variables";
+
+.autoload-images-header {
+ background-color: mix(@background-primary, #FFCC11, 80%);
+ border: 1px solid darken(mix(@background-primary, #FFCC11, 50%), 25%);
+ color: mix(@text-color-subtle, #FFCC11, 40%);
+ margin: @padding-base-vertical 0;
+ padding: @padding-base-vertical @padding-base-horizontal;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ .option {
+ color: fade(mix(@text-color-subtle, #FFCC11, 80%), 70%);
+ }
+ .option:hover {
+ color: mix(@text-color-subtle, #FFCC11, 80%);
+ }
+}
diff --git a/packages/client-app/internal_packages/message-list/assets/spinner.gif b/packages/client-app/internal_packages/message-list/assets/spinner.gif
new file mode 100644
index 0000000000..87985e9385
Binary files /dev/null and b/packages/client-app/internal_packages/message-list/assets/spinner.gif differ
diff --git a/packages/client-app/internal_packages/message-list/lib/autolinker.es6 b/packages/client-app/internal_packages/message-list/lib/autolinker.es6
new file mode 100644
index 0000000000..976376cfbc
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/autolinker.es6
@@ -0,0 +1,95 @@
+import {RegExpUtils, DOMUtils} from 'nylas-exports';
+
+function _matchesAnyRegexp(text, regexps) {
+ for (const excludeRegexp of regexps) {
+ if (excludeRegexp.test(text)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function _runOnTextNode(node, matchers) {
+ if (node.parentElement) {
+ const withinScript = node.parentElement.tagName === "SCRIPT";
+ const withinStyle = node.parentElement.tagName === "STYLE";
+ const withinA = (node.parentElement.closest('a') !== null);
+ if (withinScript || withinA || withinStyle) {
+ return;
+ }
+ }
+ if (node.textContent.trim().length < 4) {
+ return;
+ }
+
+ let longest = null;
+ let longestLength = null;
+ for (const [prefix, regex, options = {}] of matchers) {
+ regex.lastIndex = 0;
+ const match = regex.exec(node.textContent);
+ if (match !== null) {
+ if (options.exclude && _matchesAnyRegexp(match[0], options.exclude)) {
+ continue;
+ }
+ if (match[0].length > longestLength) {
+ longest = [prefix, match];
+ longestLength = match[0].length;
+ }
+ }
+ }
+
+ if (longest) {
+ const [prefix, match] = longest;
+ const href = `${prefix}${match[0]}`;
+ const range = document.createRange();
+ range.setStart(node, match.index);
+ range.setEnd(node, match.index + match[0].length);
+ const aTag = DOMUtils.wrap(range, 'A');
+ aTag.href = href;
+ aTag.title = href;
+ return;
+ }
+}
+
+export function autolink(doc, {async} = {}) {
+ // Traverse the new DOM tree and make things that look like links clickable,
+ // and ensure anything with an href has a title attribute.
+ const textWalker = document.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
+ const matchers = [
+ ['mailto:', RegExpUtils.emailRegex(), {
+ // Technically, gmail.com/bengotow@gmail.com is an email address. After
+ // matching, manully exclude any email that follows the .*[/?].*@ pattern.
+ exclude: [/\..*[/|?].*@/],
+ }],
+ ['tel:', RegExpUtils.phoneRegex()],
+ ['', RegExpUtils.nylasCommandRegex()],
+ ['', RegExpUtils.urlRegex({matchEntireString: false})],
+ ];
+
+ if (async) {
+ const fn = (deadline) => {
+ while (textWalker.nextNode()) {
+ _runOnTextNode(textWalker.currentNode, matchers);
+ if (deadline.timeRemaining() <= 0) {
+ window.requestIdleCallback(fn, {timeout: 500});
+ return;
+ }
+ }
+ };
+ window.requestIdleCallback(fn, {timeout: 500});
+ } else {
+ while (textWalker.nextNode()) {
+ _runOnTextNode(textWalker.currentNode, matchers);
+ }
+ }
+
+ // Traverse the new DOM tree and make sure everything with an href has a title.
+ const aTagWalker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node) =>
+ (node.href ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP)
+ ,
+ });
+ while (aTagWalker.nextNode()) {
+ aTagWalker.currentNode.title = aTagWalker.currentNode.getAttribute('href');
+ }
+}
diff --git a/packages/client-app/internal_packages/message-list/lib/autoscale-images.es6 b/packages/client-app/internal_packages/message-list/lib/autoscale-images.es6
new file mode 100644
index 0000000000..27778fc15c
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/autoscale-images.es6
@@ -0,0 +1,53 @@
+
+function _getDimension(node, dim) {
+ const raw = node.style[dim] || node[dim];
+ if (!raw) {
+ return [null, ''];
+ }
+ const valueRegexp = /(\d*)(.*)/;
+ const match = valueRegexp.exec(raw);
+ if (!match) {
+ return [null, ''];
+ }
+
+ const value = match[1];
+ const units = match[2] || 'px';
+ return [value / 1, units];
+}
+
+function _runOnImageNode(node) {
+ const [width, widthUnits] = _getDimension(node, 'width');
+ const [height, heightUnits] = _getDimension(node, 'height');
+
+ if (node.style.maxWidth || node.style.maxHeight) {
+ return;
+ }
+ // VW is like %, but always basd on the iframe width, regardless of whether
+ // a container is position: relative.
+ // https://web-design-weekly.com/2014/11/18/viewport-units-vw-vh-vmin-vmax/
+ if (width && height && (widthUnits === heightUnits)) {
+ node.style.maxWidth = '100vw';
+ node.style.maxHeight = `${100 * height / width}vw`;
+ } else if (!height) {
+ node.style.maxWidth = '100vw';
+ } else {
+ // If your image has a width and height in different units, or a height and
+ // no width, we don't want to screw with it because it would change the
+ // aspect ratio.
+ }
+}
+
+export function autoscaleImages(doc) {
+ const imgTagWalker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node) => {
+ if (node.nodeName === 'IMG') {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_SKIP;
+ },
+ });
+
+ while (imgTagWalker.nextNode()) {
+ _runOnImageNode(imgTagWalker.currentNode);
+ }
+}
diff --git a/packages/client-app/internal_packages/message-list/lib/email-frame-styles-store.coffee b/packages/client-app/internal_packages/message-list/lib/email-frame-styles-store.coffee
new file mode 100644
index 0000000000..2f54d11d15
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/email-frame-styles-store.coffee
@@ -0,0 +1,28 @@
+NylasStore = require 'nylas-store'
+
+class EmailFrameStylesStore extends NylasStore
+
+ constructor: ->
+
+ styles: =>
+ if not @_styles
+ @_findStyles()
+ @_listenToStyles()
+ @_styles
+
+ _findStyles: =>
+ @_styles = ""
+ for sheet in document.querySelectorAll('[source-path*="email-frame.less"]')
+ @_styles += "\n"+sheet.innerText
+ @_styles = @_styles.replace(/.ignore-in-parent-frame/g, '')
+ @trigger()
+
+ _listenToStyles: =>
+ target = document.getElementsByTagName('nylas-styles')[0]
+ @_mutationObserver = new MutationObserver(@_findStyles)
+ @_mutationObserver.observe(target, attributes: true, subtree: true, childList: true)
+
+ _unlistenToStyles: =>
+ @_mutationObserver?.disconnect()
+
+ module.exports = new EmailFrameStylesStore()
diff --git a/packages/client-app/internal_packages/message-list/lib/email-frame.jsx b/packages/client-app/internal_packages/message-list/lib/email-frame.jsx
new file mode 100644
index 0000000000..038d5477bc
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/email-frame.jsx
@@ -0,0 +1,170 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import _ from "underscore";
+import {EventedIFrame} from 'nylas-component-kit';
+import {Utils, QuotedHTMLTransformer, MessageStore} from 'nylas-exports';
+import {autolink} from './autolinker';
+import {autoscaleImages} from './autoscale-images';
+import {addInlineImageListeners} from './inline-image-listeners';
+import EmailFrameStylesStore from './email-frame-styles-store';
+
+export default class EmailFrame extends React.Component {
+ static displayName = 'EmailFrame';
+
+ static propTypes = {
+ content: React.PropTypes.string.isRequired,
+ message: React.PropTypes.object,
+ showQuotedText: React.PropTypes.bool,
+ onLoad: React.PropTypes.func,
+ };
+
+ componentDidMount() {
+ this._mounted = true;
+ this._writeContent();
+ this._unlisten = EmailFrameStylesStore.listen(this._writeContent);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (!Utils.isEqualReact(nextProps, this.props) ||
+ !Utils.isEqualReact(nextState, this.state));
+ }
+
+ componentDidUpdate() {
+ this._writeContent();
+ }
+
+ componentWillUnmount() {
+ this._mounted = false;
+ if (this._unlisten) {
+ this._unlisten();
+ }
+ }
+
+ _emailContent = () => {
+ // When showing quoted text, always return the pure content
+ if (this.props.showQuotedText) {
+ return this.props.content;
+ }
+ return QuotedHTMLTransformer.removeQuotedHTML(this.props.content, {
+ keepIfWholeBodyIsQuote: true,
+ });
+ }
+
+ _writeContent = () => {
+ const iframeNode = ReactDOM.findDOMNode(this.refs.iframe);
+ const doc = iframeNode.contentDocument;
+ if (!doc) { return; }
+ doc.open();
+
+ // NOTE: The iframe must have a modern DOCTYPE. The lack of this line
+ // will cause some bizzare non-standards compliant rendering with the
+ // message bodies. This is particularly felt with elements use
+ // the `border-collapse: collapse` css property while setting a
+ // `padding`.
+ doc.write("");
+ const styles = EmailFrameStylesStore.styles();
+ if (styles) {
+ doc.write(``);
+ }
+ doc.write(`${this._emailContent()}
`);
+ doc.close();
+
+ autolink(doc, {async: true});
+ autoscaleImages(doc);
+ addInlineImageListeners(doc);
+
+ for (const extension of MessageStore.extensions()) {
+ if (!extension.renderedMessageBodyIntoDocument) {
+ continue;
+ }
+ try {
+ extension.renderedMessageBodyIntoDocument({
+ document: doc,
+ message: this.props.message,
+ iframe: iframeNode,
+ });
+ } catch (e) {
+ NylasEnv.reportError(e);
+ }
+ }
+
+ // Notify the EventedIFrame that we've replaced it's document (with `open`)
+ // so it can attach event listeners again.
+ this.refs.iframe.didReplaceDocument();
+ this._onMustRecalculateFrameHeight();
+ }
+
+ _onMustRecalculateFrameHeight = () => {
+ this.refs.iframe.setHeightQuietly(0);
+ this._lastComputedHeight = 0;
+ this._setFrameHeight();
+ }
+
+ _getFrameHeight = (doc) => {
+ let height = 0;
+
+ // If documentElement has a scroll height, prioritize that as height
+ // If not, fall back to body scroll height by setting it to auto
+ if (doc && doc.documentElement && doc.documentElement.scrollHeight > 0) {
+ height = doc.documentElement.scrollHeight;
+ } else if (doc && doc.body) {
+ const style = window.getComputedStyle(doc.body);
+ if (style.height === '0px') {
+ doc.body.style.height = "auto";
+ }
+ height = doc.body.scrollHeight;
+ }
+
+ // scrollHeight does not include space required by scrollbar
+ return height + 25;
+ }
+
+ _setFrameHeight = () => {
+ if (!this._mounted) {
+ return;
+ }
+
+ // Q: What's up with this holder?
+ // A: If you resize the window, or do something to trigger setFrameHeight
+ // on an already-loaded message view, all the heights go to zero for a brief
+ // second while the heights are recomputed. This causes the ScrollRegion to
+ // reset it's scrollTop to ~0 (the new combined heiht of all children).
+ // To prevent this, the holderNode holds the last computed height until
+ // the new height is computed.
+ const holderNode = ReactDOM.findDOMNode(this.refs.iframeHeightHolder);
+ const iframeNode = ReactDOM.findDOMNode(this.refs.iframe);
+ const height = this._getFrameHeight(iframeNode.contentDocument);
+
+ // Why 5px? Some emails have elements with a height of 100%, and then put
+ // tracking pixels beneath that. In these scenarios, the scrollHeight of the
+ // message is always <100% + 1px>, which leads us to resize them constantly.
+ // This is a hack, but I'm not sure of a better solution.
+ if (Math.abs(height - this._lastComputedHeight) > 5) {
+ this.refs.iframe.setHeightQuietly(height);
+ holderNode.style.height = `${height}px`;
+ this._lastComputedHeight = height;
+ }
+
+ if (iframeNode.contentDocument.readyState !== 'complete') {
+ _.defer(() => this._setFrameHeight());
+ }
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/message-list/lib/find-in-thread.jsx b/packages/client-app/internal_packages/message-list/lib/find-in-thread.jsx
new file mode 100644
index 0000000000..650358027e
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/find-in-thread.jsx
@@ -0,0 +1,152 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import classnames from 'classnames'
+import {Actions, MessageStore, SearchableComponentStore} from 'nylas-exports'
+import {RetinaImg, KeyCommandsRegion} from 'nylas-component-kit'
+
+export default class FindInThread extends React.Component {
+ static displayName = "FindInThread";
+
+ constructor(props) {
+ super(props);
+ this.state = SearchableComponentStore.getCurrentSearchData()
+ }
+
+ componentDidMount() {
+ this._usub = SearchableComponentStore.listen(this._onSearchableChange)
+ }
+
+ componentWillUnmount() {
+ this._usub()
+ }
+
+ _globalKeymapHandlers() {
+ return {
+ 'core:find-in-thread': this._onFindInThread,
+ 'core:find-in-thread-next': this._onNextResult,
+ 'core:find-in-thread-previous': this._onPrevResult,
+ }
+ }
+
+ _onFindInThread = () => {
+ if (this.state.searchTerm === null) {
+ Actions.findInThread("");
+ if (MessageStore.hasCollapsedItems()) {
+ Actions.toggleAllMessagesExpanded()
+ }
+ }
+ this._focusSearch()
+ }
+
+ _onSearchableChange = () => {
+ this.setState(SearchableComponentStore.getCurrentSearchData())
+ }
+
+ _onFindChange = (event) => {
+ Actions.findInThread(event.target.value)
+ }
+
+ _onFindKeyDown = (event) => {
+ if (event.key === "Enter") {
+ return event.shiftKey ? this._onPrevResult() : this._onNextResult()
+ } else if (event.key === "Escape") {
+ this._clearSearch()
+ ReactDOM.findDOMNode(this.refs.searchBox).blur()
+ }
+ return null
+ }
+
+ _selectionText() {
+ if (this.state.globalIndex !== null && this.state.resultsLength > 0) {
+ return `${this.state.globalIndex + 1} of ${this.state.resultsLength}`
+ }
+ return ""
+ }
+
+ _navEnabled() {
+ return this.state.resultsLength > 0;
+ }
+
+ _onPrevResult = () => {
+ if (this._navEnabled()) { Actions.previousSearchResult() }
+ }
+
+ _onNextResult = () => {
+ if (this._navEnabled()) { Actions.nextSearchResult() }
+ }
+
+ _clearSearch = () => {
+ Actions.findInThread(null)
+ }
+
+ _focusSearch = (event) => {
+ const cw = ReactDOM.findDOMNode(this.refs.controlsWrap)
+ if (!event || !(cw && cw.contains(event.target))) {
+ ReactDOM.findDOMNode(this.refs.searchBox).focus()
+ }
+ }
+
+ render() {
+ const rootCls = classnames({
+ "find-in-thread": true,
+ "enabled": this.state.searchTerm !== null,
+ })
+ const btnCls = "btn btn-find-in-thread";
+ return (
+
+
+
+
+
+
+
+
{this._selectionText()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+}
diff --git a/packages/client-app/internal_packages/message-list/lib/inline-image-listeners.es6 b/packages/client-app/internal_packages/message-list/lib/inline-image-listeners.es6
new file mode 100644
index 0000000000..15c86b1559
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/inline-image-listeners.es6
@@ -0,0 +1,56 @@
+import {Actions, Utils} from 'nylas-exports';
+
+function safeEncode(str) {
+ return btoa(unescape(encodeURIComponent(str)));
+}
+
+function safeDecode(str) {
+ return atob(decodeURIComponent(escape(str)))
+}
+
+function _runOnImageNode(node) {
+ if (node.src && node.dataset.nylasFile) {
+ node.addEventListener('error', () => {
+ const file = JSON.parse(safeDecode(node.dataset.nylasFile), Utils.registeredObjectReviver);
+ const initialDisplay = node.style.display;
+ const downloadButton = document.createElement('a');
+ downloadButton.classList.add('inline-download-prompt')
+ downloadButton.textContent = "Click to download inline image";
+ downloadButton.addEventListener('click', () => {
+ Actions.fetchFile(file);
+ node.parentNode.removeChild(downloadButton);
+ node.addEventListener('load', () => {
+ node.style.display = initialDisplay;
+ });
+ });
+ node.style.display = 'none';
+ node.parentNode.insertBefore(downloadButton, node);
+ });
+
+ node.addEventListener('load', () => {
+ const file = JSON.parse(safeDecode(node.dataset.nylasFile), Utils.registeredObjectReviver);
+ node.addEventListener('dblclick', () => {
+ Actions.fetchAndOpenFile(file);
+ });
+ });
+ }
+}
+
+export function encodedAttributeForFile(file) {
+ return safeEncode(JSON.stringify(file, Utils.registeredObjectReplacer));
+}
+
+export function addInlineImageListeners(doc) {
+ const imgTagWalker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node) => {
+ if (node.nodeName === 'IMG') {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_SKIP;
+ },
+ });
+
+ while (imgTagWalker.nextNode()) {
+ _runOnImageNode(imgTagWalker.currentNode);
+ }
+}
diff --git a/packages/client-app/internal_packages/message-list/lib/main.cjsx b/packages/client-app/internal_packages/message-list/lib/main.cjsx
new file mode 100644
index 0000000000..72ea4edd86
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/main.cjsx
@@ -0,0 +1,47 @@
+{MailboxPerspective,
+ ComponentRegistry,
+ ExtensionRegistry,
+ WorkspaceStore,
+ DatabaseStore,
+ Actions,
+ Thread} = require 'nylas-exports'
+
+MessageList = require("./message-list")
+MessageListHiddenMessagesToggle = require('./message-list-hidden-messages-toggle').default
+
+SidebarPluginContainer = require "./sidebar-plugin-container"
+SidebarParticipantPicker = require('./sidebar-participant-picker').default
+
+module.exports =
+ activate: ->
+ if NylasEnv.isMainWindow()
+ # Register Message List Actions we provide globally
+ ComponentRegistry.register MessageList,
+ location: WorkspaceStore.Location.MessageList
+
+ ComponentRegistry.register SidebarParticipantPicker,
+ location: WorkspaceStore.Location.MessageListSidebar
+
+ ComponentRegistry.register SidebarPluginContainer,
+ location: WorkspaceStore.Location.MessageListSidebar
+
+ ComponentRegistry.register MessageListHiddenMessagesToggle,
+ role: 'MessageListHeaders'
+ else
+ # This is for the thread-popout window.
+ {threadId, perspectiveJSON} = NylasEnv.getWindowProps()
+ ComponentRegistry.register(MessageList, {location: WorkspaceStore.Location.Center})
+ # We need to locate the thread and focus it so that the MessageList displays it
+ DatabaseStore.find(Thread, threadId).then((thread) =>
+ Actions.setFocus({collection: 'thread', item: thread})
+ )
+ # Set the focused perspective and hide the proper messages
+ # (e.g. we should hide deleted items from the inbox, but not from trash)
+ Actions.focusMailboxPerspective(MailboxPerspective.fromJSON(perspectiveJSON))
+ ComponentRegistry.register MessageListHiddenMessagesToggle,
+ role: 'MessageListHeaders'
+
+ deactivate: ->
+ ComponentRegistry.unregister MessageList
+ ComponentRegistry.unregister SidebarPluginContainer
+ ComponentRegistry.unregister SidebarParticipantPicker
diff --git a/packages/client-app/internal_packages/message-list/lib/message-controls.cjsx b/packages/client-app/internal_packages/message-list/lib/message-controls.cjsx
new file mode 100644
index 0000000000..705f3f926e
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/message-controls.cjsx
@@ -0,0 +1,158 @@
+React = require 'react'
+{remote} = require 'electron'
+{Actions, NylasAPI, NylasAPIRequest, AccountStore} = require 'nylas-exports'
+{RetinaImg, ButtonDropdown, Menu} = require 'nylas-component-kit'
+
+class MessageControls extends React.Component
+ @displayName: "MessageControls"
+ @propTypes:
+ thread: React.PropTypes.object.isRequired
+ message: React.PropTypes.object.isRequired
+
+ constructor: (@props) ->
+
+ render: =>
+ items = @_items()
+
+
+
}
+ primaryTitle={items[0].name}
+ primaryClick={items[0].select}
+ closeOnMenuClick={true}
+ menu={@_dropdownMenu(items[1..-1])}/>
+
+
+
+
+
+ _items: ->
+ reply =
+ name: 'Reply',
+ image: 'ic-dropdown-reply.png'
+ select: @_onReply
+ replyAll =
+ name: 'Reply All',
+ image: 'ic-dropdown-replyall.png'
+ select: @_onReplyAll
+ forward =
+ name: 'Forward',
+ image: 'ic-dropdown-forward.png'
+ select: @_onForward
+
+ if @props.message.canReplyAll()
+ defaultReplyType = NylasEnv.config.get('core.sending.defaultReplyType')
+ if defaultReplyType is 'reply-all'
+ return [replyAll, reply, forward]
+ else
+ return [reply, replyAll, forward]
+ else
+ return [reply, forward]
+
+ _account: =>
+ AccountStore.accountForId(@props.message.accountId)
+
+ _dropdownMenu: (items) ->
+ itemContent = (item) ->
+
+
+ {item.name}
+
+
+ item.name }
+ itemContent={itemContent}
+ onSelect={ (item) => item.select() }
+ />
+
+ _onReply: =>
+ {thread, message} = @props
+ Actions.composeReply({thread, message, type: 'reply', behavior: 'prefer-existing-if-pristine'})
+
+ _onReplyAll: =>
+ {thread, message} = @props
+ Actions.composeReply({thread, message, type: 'reply-all', behavior: 'prefer-existing-if-pristine'})
+
+ _onForward: =>
+ {thread, message} = @props
+ Actions.composeForward({thread, message})
+
+ _onShowActionsMenu: =>
+ SystemMenu = remote.Menu
+ SystemMenuItem = remote.MenuItem
+
+ # Todo: refactor this so that message actions are provided
+ # dynamically. Waiting to see if this will be used often.
+ menu = new SystemMenu()
+ menu.append(new SystemMenuItem({ label: 'Log Data', click: => @_onLogData()}))
+ menu.append(new SystemMenuItem({ label: 'Show Original', click: => @_onShowOriginal()}))
+ menu.append(new SystemMenuItem({ label: 'Copy Debug Info to Clipboard', click: => @_onCopyToClipboard()}))
+ menu.append(new SystemMenuItem({ type: 'separator'}))
+ menu.append(new SystemMenuItem({ label: 'Report Issue: Quoted Text', click: => @_onReport('Quoted Text')}))
+ menu.append(new SystemMenuItem({ label: 'Report Issue: Rendering', click: => @_onReport('Rendering')}))
+ menu.popup(remote.getCurrentWindow())
+
+ _onReport: (issueType) =>
+ {Contact, Message, DatabaseStore, AccountStore} = require 'nylas-exports'
+
+ draft = new Message
+ from: [@_account().me()]
+ to: [new Contact(name: "Nylas Team", email: "n1-support@nylas.com")]
+ date: (new Date)
+ draft: true
+ subject: "Feedback - Message Display Issue (#{issueType})"
+ accountId: @_account().id
+ body: @props.message.body
+
+ DatabaseStore.inTransaction (t) =>
+ t.persistModel(draft)
+ .then =>
+ Actions.sendDraft(draft.clientId)
+
+ dialog = remote.dialog
+ dialog.showMessageBox remote.getCurrentWindow(), {
+ type: 'warning'
+ buttons: ['OK'],
+ message: "Thank you."
+ detail: "The contents of this message have been sent to the N1 team and will be added to a test suite."
+ }
+
+ _onShowOriginal: =>
+ fs = require 'fs'
+ path = require 'path'
+ BrowserWindow = remote.BrowserWindow
+ app = remote.app
+ tmpfile = path.join(app.getPath('temp'), @props.message.id)
+
+ request = new NylasAPIRequest
+ api: NylasAPI
+ options:
+ headers:
+ Accept: 'message/rfc822'
+ path: "/messages/#{@props.message.id}"
+ accountId: @props.message.accountId
+ json:false
+ request.run()
+ .then((body) =>
+ fs.writeFile tmpfile, body, =>
+ window = new BrowserWindow(width: 800, height: 600, title: "#{@props.message.subject} - RFC822")
+ window.loadURL('file://'+tmpfile)
+ )
+
+ _onLogData: =>
+ console.log @props.message
+ window.__message = @props.message
+ window.__thread = @props.thread
+ console.log "Also now available in window.__message and window.__thread"
+
+ _onCopyToClipboard: =>
+ clipboard = require('electron').clipboard
+ data = "AccountID: #{@props.message.accountId}\n"+
+ "Message ID: #{@props.message.serverId}\n"+
+ "Message Metadata: #{JSON.stringify(@props.message.pluginMetadata, null, ' ')}\n"+
+ "Thread ID: #{@props.thread.serverId}\n"+
+ "Thread Metadata: #{JSON.stringify(@props.thread.pluginMetadata, null, ' ')}\n"
+
+ clipboard.writeText(data)
+
+module.exports = MessageControls
diff --git a/packages/client-app/internal_packages/message-list/lib/message-item-body.cjsx b/packages/client-app/internal_packages/message-list/lib/message-item-body.cjsx
new file mode 100644
index 0000000000..7f195a6662
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/message-item-body.cjsx
@@ -0,0 +1,117 @@
+React = require 'react'
+_ = require 'underscore'
+EmailFrame = require('./email-frame').default
+{encodedAttributeForFile} = require('./inline-image-listeners')
+{
+ DraftHelpers,
+ CanvasUtils,
+ NylasAPI,
+ NylasAPIRequest,
+ MessageUtils,
+ MessageBodyProcessor,
+ QuotedHTMLTransformer,
+ FileDownloadStore
+} = require 'nylas-exports'
+{
+ InjectedComponentSet,
+ RetinaImg
+} = require 'nylas-component-kit'
+
+TransparentPixel = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNikAQAACIAHF/uBd8AAAAASUVORK5CYII="
+
+class MessageItemBody extends React.Component
+ @displayName: 'MessageItemBody'
+ @propTypes:
+ message: React.PropTypes.object.isRequired
+ downloads: React.PropTypes.object.isRequired
+ onLoad: React.PropTypes.func
+
+ constructor: (@props) ->
+ @_mounted = false
+ @state =
+ showQuotedText: DraftHelpers.isForwardedMessage(@props.message)
+ processedBody: null
+
+ componentWillMount: =>
+ @_unsub = MessageBodyProcessor.subscribe @props.message, (processedBody) =>
+ @setState({processedBody})
+
+ componentDidMount: =>
+ @_mounted = true
+
+ componentWillReceiveProps: (nextProps) ->
+ if nextProps.message.id isnt @props.message.id
+ @_unsub?()
+ @_unsub = MessageBodyProcessor.subscribe nextProps.message, (processedBody) =>
+ @setState({processedBody})
+
+ componentWillUnmount: =>
+ @_mounted = false
+ @_unsub?()
+
+ render: =>
+
+
+ {@_renderBody()}
+ {@_renderQuotedTextControl()}
+
+
+ _renderBody: =>
+ if _.isString(@props.message.body) and _.isString(@state.processedBody)
+
+ else
+
+
+
+
+ _renderQuotedTextControl: =>
+ return null unless QuotedHTMLTransformer.hasQuotedHTML(@props.message.body)
+
+ •••
+
+
+ _toggleQuotedText: =>
+ @setState
+ showQuotedText: !@state.showQuotedText
+
+ _mergeBodyWithFiles: (body) =>
+ # Replace cid: references with the paths to downloaded files
+ for file in @props.message.files
+ download = @props.downloads[file.id]
+
+ # Note: I don't like doing this with RegExp before the body is inserted into
+ # the DOM, but we want to avoid "could not load cid://" in the console.
+
+ if download and download.state isnt 'finished'
+ inlineImgRegexp = new RegExp("<\s*img.*src=['\"]cid:#{file.contentId}['\"][^>]*>", 'gi')
+ # Render a spinner
+ body = body.replace inlineImgRegexp, =>
+ ' '
+ else
+ # Render the completed download. We include data-nylas-file so that if the image fails
+ # to load, we can parse the file out and call `Actions.fetchFile` to retrieve it.
+ # (Necessary when attachment download mode is set to "manual")
+ cidRegexp = new RegExp("cid:#{file.contentId}(['\"])", 'gi')
+ body = body.replace cidRegexp, (text, quoteCharacter) ->
+ "file://#{FileDownloadStore.pathForFile(file)}#{quoteCharacter} data-nylas-file=\"#{encodedAttributeForFile(file)}\" "
+
+ # Replace remaining cid: references - we will not display them since they'll
+ # throw "unknown ERR_UNKNOWN_URL_SCHEME". Show a transparent pixel so that there's
+ # no "missing image" region shown, just a space.
+ body = body.replace(MessageUtils.cidRegex, "src=\"#{TransparentPixel}\"")
+
+ return body
+
+module.exports = MessageItemBody
diff --git a/packages/client-app/internal_packages/message-list/lib/message-item-container.cjsx b/packages/client-app/internal_packages/message-list/lib/message-item-container.cjsx
new file mode 100644
index 0000000000..e112d3ec85
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/message-item-container.cjsx
@@ -0,0 +1,94 @@
+React = require 'react'
+classNames = require 'classnames'
+
+MessageItem = require './message-item'
+
+{Utils,
+ DraftStore,
+ ComponentRegistry,
+ MessageStore} = require 'nylas-exports'
+
+
+class MessageItemContainer extends React.Component
+ @displayName = 'MessageItemContainer'
+
+ @propTypes =
+ thread: React.PropTypes.object.isRequired
+ message: React.PropTypes.object.isRequired
+ messages: React.PropTypes.array.isRequired
+ collapsed: React.PropTypes.bool
+ isLastMsg: React.PropTypes.bool
+ isBeforeReplyArea: React.PropTypes.bool
+ scrollTo: React.PropTypes.func
+ onLoad: React.PropTypes.func
+
+ constructor: (@props) ->
+ @state = @_getStateFromStores()
+
+ componentWillReceiveProps: (newProps) ->
+ @setState(@_getStateFromStores(newProps))
+
+ componentDidMount: =>
+ if @props.message.draft
+ @_unlisten = DraftStore.listen @_onSendingStateChanged
+
+ shouldComponentUpdate: (nextProps, nextState) =>
+ not Utils.isEqualReact(nextProps, @props) or
+ not Utils.isEqualReact(nextState, @state)
+
+ componentWillUnmount: =>
+ @_unlisten() if @_unlisten
+
+ focus: =>
+ @refs.message.focus()
+
+ render: =>
+ if @state.isSending
+ @_renderMessage(pending: true)
+ else if @props.message.draft
+ @_renderComposer()
+ else
+ @_renderMessage(pending: false)
+
+ _renderMessage: ({pending}) =>
+
+
+ _renderComposer: =>
+ Composer = ComponentRegistry.findComponentsMatching(role: 'Composer')[0]
+ if (!Composer)
+ return
+
+
+
+ _classNames: => classNames
+ "draft": @props.message.draft
+ "unread": @props.message.unread
+ "collapsed": @props.collapsed
+ "message-item-wrap": true
+ "before-reply-area": @props.isBeforeReplyArea
+
+ _onSendingStateChanged: (draftClientId) =>
+ if draftClientId is @props.message.clientId
+ @setState(@_getStateFromStores())
+
+ _getStateFromStores: (props = @props) ->
+ isSending: DraftStore.isSendingDraft(props.message.clientId)
+
+module.exports = MessageItemContainer
diff --git a/packages/client-app/internal_packages/message-list/lib/message-item.cjsx b/packages/client-app/internal_packages/message-list/lib/message-item.cjsx
new file mode 100644
index 0000000000..9fab5758d7
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/message-item.cjsx
@@ -0,0 +1,279 @@
+React = require 'react'
+ReactDOM = require 'react-dom'
+classNames = require 'classnames'
+_ = require 'underscore'
+MessageParticipants = require "./message-participants"
+MessageItemBody = require "./message-item-body"
+MessageTimestamp = require("./message-timestamp").default
+MessageControls = require './message-controls'
+{Utils,
+ Actions,
+ MessageUtils,
+ AccountStore,
+ MessageBodyProcessor,
+ QuotedHTMLTransformer,
+ ComponentRegistry,
+ FileDownloadStore} = require 'nylas-exports'
+{RetinaImg,
+ InjectedComponentSet,
+ InjectedComponent} = require 'nylas-component-kit'
+
+TransparentPixel = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNikAQAACIAHF/uBd8AAAAASUVORK5CYII="
+
+class MessageItem extends React.Component
+ @displayName = 'MessageItem'
+
+ @propTypes =
+ thread: React.PropTypes.object.isRequired
+ message: React.PropTypes.object.isRequired
+ messages: React.PropTypes.array.isRequired
+ collapsed: React.PropTypes.bool
+ onLoad: React.PropTypes.func
+
+ constructor: (@props) ->
+ fileIds = @props.message.fileIds()
+ @state =
+ # Holds the downloadData (if any) for all of our files. It's a hash
+ # keyed by a fileId. The value is the downloadData.
+ downloads: FileDownloadStore.getDownloadDataForFiles(fileIds)
+ filePreviewPaths: FileDownloadStore.previewPathsForFiles(fileIds)
+ detailedHeaders: false
+ detailedHeadersTogglePos: {top: 18}
+
+ componentDidMount: =>
+ @_storeUnlisten = FileDownloadStore.listen(@_onDownloadStoreChange)
+ @_setDetailedHeadersTogglePos()
+
+ componentDidUpdate: =>
+ @_setDetailedHeadersTogglePos()
+
+ componentWillUnmount: =>
+ @_storeUnlisten() if @_storeUnlisten
+
+ shouldComponentUpdate: (nextProps, nextState) =>
+ not Utils.isEqualReact(nextProps, @props) or
+ not Utils.isEqualReact(nextState, @state)
+
+ render: =>
+ if @props.collapsed
+ @_renderCollapsed()
+ else
+ @_renderFull()
+
+ _renderCollapsed: =>
+ attachmentIcon = []
+ if Utils.showIconForAttachments(@props.message.files)
+ attachmentIcon =
+
+
+
+
+
+ {@props.message.from?[0]?.displayName(compact: true)}
+
+
+ {@props.message.snippet}
+
+
+
+
+ {attachmentIcon}
+
+
+
+
+ _renderFull: =>
+
+
+
+ {@_renderHeader()}
+
+ {@_renderAttachments()}
+ {@_renderFooterStatus()}
+
+
+
+
+ _renderHeader: =>
+ classes = classNames
+ "message-header": true
+ "pending": @props.pending
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@_renderFolder()}
+ {@_renderHeaderDetailToggle()}
+
+
+ _renderFolder: =>
+ return [] unless @state.detailedHeaders
+ acct = AccountStore.accountForId(@props.message.accountId)
+ acctUsesFolders = acct and acct.usesFolders()
+ folder = @props.message.categories?[0]
+ return unless folder and acctUsesFolders
+
+
Folder:
+
{folder.displayName}
+
+
+ _onClickParticipants: (e) =>
+ el = e.target
+ while el isnt e.currentTarget
+ if "collapsed-participants" in el.classList
+ @setState(detailedHeaders: true)
+ e.stopPropagation()
+ return
+ el = el.parentElement
+ return
+
+ _onClickHeader: (e) =>
+ return if @state.detailedHeaders
+ el = e.target
+ while el isnt e.currentTarget
+ wl = ["message-header-right",
+ "collapsed-participants",
+ "header-toggle-control"]
+ if "message-header-right" in el.classList then return
+ if "collapsed-participants" in el.classList then return
+ el = el.parentElement
+ @_toggleCollapsed()
+
+ _onDownloadAll: =>
+ Actions.fetchAndSaveAllFiles(@props.message.files)
+
+ _renderDownloadAllButton: =>
+
+
+
+ {@props.message.files.length} attachments
+
+
-
+
+
+ Download all
+
+
+
+
+ _renderAttachments: =>
+ files = (@props.message.files ? []).filter((f) => @_isRealFile(f))
+ messageClientId = @props.message.clientId
+ {filePreviewPaths, downloads} = @state
+ if files.length > 0
+
+ {if files.length > 1 then @_renderDownloadAllButton()}
+
+
+
+
+ else
+
+
+ _renderFooterStatus: =>
+
+
+ _setDetailedHeadersTogglePos: =>
+ header = ReactDOM.findDOMNode(@refs.header)
+ if !header
+ return
+ fromNode = header.querySelector('.participant-name.from-contact,.participant-primary')
+ if !fromNode
+ return
+ fromRect = fromNode.getBoundingClientRect()
+ topPos = Math.floor(fromNode.offsetTop + (fromRect.height / 2) - 10)
+ if topPos isnt @state.detailedHeadersTogglePos.top
+ @setState({detailedHeadersTogglePos: {top: topPos}})
+
+ _renderHeaderDetailToggle: =>
+ return null if @props.pending
+ {top} = @state.detailedHeadersTogglePos
+ if @state.detailedHeaders
+ @setState(detailedHeaders: false); e.stopPropagation()}
+ >
+
+
+ else
+ @setState(detailedHeaders: true); e.stopPropagation()}
+ >
+
+
+
+ _toggleCollapsed: =>
+ return if @props.isLastMsg
+ Actions.toggleMessageIdExpanded(@props.message.id)
+
+ _isRealFile: (file) ->
+ hasCIDInBody = file.contentId? and @props.message.body?.indexOf(file.contentId) > 0
+ return not hasCIDInBody
+
+ _onDownloadStoreChange: =>
+ fileIds = @props.message.fileIds()
+ @setState
+ downloads: FileDownloadStore.getDownloadDataForFiles(fileIds)
+ filePreviewPaths: FileDownloadStore.previewPathsForFiles(fileIds)
+
+module.exports = MessageItem
diff --git a/packages/client-app/internal_packages/message-list/lib/message-list-hidden-messages-toggle.jsx b/packages/client-app/internal_packages/message-list/lib/message-list-hidden-messages-toggle.jsx
new file mode 100644
index 0000000000..91dbb60e9a
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/message-list-hidden-messages-toggle.jsx
@@ -0,0 +1,64 @@
+import {
+ React,
+ Actions,
+ MessageStore,
+ FocusedPerspectiveStore,
+} from 'nylas-exports';
+
+export default class MessageListHiddenMessagesToggle extends React.Component {
+
+ static displayName = 'MessageListHiddenMessagesToggle';
+
+ constructor() {
+ super();
+ this.state = {
+ numberOfHiddenItems: MessageStore.numberOfHiddenItems(),
+ };
+ }
+
+ componentDidMount() {
+ this._unlisten = MessageStore.listen(() => {
+ this.setState({
+ numberOfHiddenItems: MessageStore.numberOfHiddenItems(),
+ });
+ });
+ }
+
+ componentWillUnmount() {
+ this._unlisten();
+ }
+
+ render() {
+ const {numberOfHiddenItems} = this.state;
+ if (numberOfHiddenItems === 0) {
+ return ( );
+ }
+
+
+ const viewing = FocusedPerspectiveStore.current().categoriesSharedName();
+ let message = null;
+
+ if (MessageStore.CategoryNamesHiddenByDefault.includes(viewing)) {
+ if (numberOfHiddenItems > 1) {
+ message = `There are ${numberOfHiddenItems} more messages in this thread that are not in spam or trash.`;
+ } else {
+ message = `There is one more message in this thread that is not in spam or trash.`;
+ }
+ } else {
+ if (numberOfHiddenItems > 1) {
+ message = `${numberOfHiddenItems} messages in this thread are hidden because it was moved to trash or spam.`;
+ } else {
+ message = `One message in this thread is hidden because it was moved to trash or spam.`;
+ }
+ }
+
+ return (
+
+ );
+ }
+}
+
+MessageListHiddenMessagesToggle.containerRequired = false;
diff --git a/packages/client-app/internal_packages/message-list/lib/message-list.cjsx b/packages/client-app/internal_packages/message-list/lib/message-list.cjsx
new file mode 100644
index 0000000000..b2a305acb8
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/message-list.cjsx
@@ -0,0 +1,436 @@
+_ = require 'underscore'
+React = require 'react'
+ReactDOM = require 'react-dom'
+classNames = require 'classnames'
+FindInThread = require('./find-in-thread').default
+MessageItemContainer = require './message-item-container'
+
+{Utils,
+ Actions,
+ Message,
+ DraftStore,
+ MessageStore,
+ AccountStore,
+ DatabaseStore,
+ WorkspaceStore,
+ ChangeLabelsTask,
+ ComponentRegistry,
+ SearchableComponentStore
+ SearchableComponentMaker} = require("nylas-exports")
+
+{Spinner,
+ RetinaImg,
+ MailLabelSet,
+ ScrollRegion,
+ MailImportantIcon,
+ InjectedComponent,
+ KeyCommandsRegion,
+ InjectedComponentSet} = require('nylas-component-kit')
+
+class MessageListScrollTooltip extends React.Component
+ @displayName: 'MessageListScrollTooltip'
+ @propTypes:
+ viewportCenter: React.PropTypes.number.isRequired
+ totalHeight: React.PropTypes.number.isRequired
+
+ componentWillMount: =>
+ @setupForProps(@props)
+
+ componentWillReceiveProps: (newProps) =>
+ @setupForProps(newProps)
+
+ shouldComponentUpdate: (newProps, newState) =>
+ not _.isEqual(@state,newState)
+
+ setupForProps: (props) ->
+ # Technically, we could have MessageList provide the currently visible
+ # item index, but the DOM approach is simple and self-contained.
+ #
+ els = document.querySelectorAll('.message-item-wrap')
+ idx = _.findIndex els, (el) -> el.offsetTop > props.viewportCenter
+ if idx is -1
+ idx = els.length
+
+ @setState
+ idx: idx
+ count: els.length
+
+ render: ->
+
+ {@state.idx} of {@state.count}
+
+
+class MessageList extends React.Component
+ @displayName: 'MessageList'
+ @containerRequired: false
+ @containerStyles:
+ minWidth: 500
+ maxWidth: 999999
+
+ constructor: (@props) ->
+ @state = @_getStateFromStores()
+ @state.minified = true
+ @_draftScrollInProgress = false
+ @MINIFY_THRESHOLD = 3
+
+ componentDidMount: =>
+ @_unsubscribers = []
+ @_unsubscribers.push MessageStore.listen @_onChange
+ @_unsubscribers.push Actions.focusDraft.listen ({draftClientId}) =>
+ Utils.waitFor( => @_getMessageContainer(draftClientId)?).then =>
+ @_focusDraft(@_getMessageContainer(draftClientId))
+ .catch =>
+
+ componentWillUnmount: =>
+ unsubscribe() for unsubscribe in @_unsubscribers
+
+ shouldComponentUpdate: (nextProps, nextState) =>
+ not Utils.isEqualReact(nextProps, @props) or
+ not Utils.isEqualReact(nextState, @state)
+
+ componentDidUpdate: (prevProps, prevState) =>
+
+ _globalMenuItems: ->
+ toggleExpandedLabel = if @state.hasCollapsedItems then "Expand" else "Collapse"
+ [
+ {
+ "label": "Thread",
+ "submenu": [{
+ "label": "#{toggleExpandedLabel} conversation",
+ "command": "message-list:toggle-expanded",
+ "position": "endof=view-actions",
+ }]
+ }
+ ]
+
+ _globalKeymapHandlers: ->
+ handlers =
+ 'core:reply': =>
+ Actions.composeReply({
+ thread: @state.currentThread,
+ message: @_lastMessage(),
+ type: 'reply',
+ behavior: 'prefer-existing',
+ })
+ 'core:reply-all': =>
+ Actions.composeReply({
+ thread: @state.currentThread,
+ message: @_lastMessage(),
+ type: 'reply-all',
+ behavior: 'prefer-existing',
+ })
+ 'core:forward': => @_onForward()
+ 'core:print-thread': => @_onPrintThread()
+ 'core:messages-page-up': => @_onScrollByPage(-1)
+ 'core:messages-page-down': => @_onScrollByPage(1)
+
+ if @state.canCollapse
+ handlers['message-list:toggle-expanded'] = => @_onToggleAllMessagesExpanded()
+
+ handlers
+
+ _getMessageContainer: (clientId) =>
+ @refs["message-container-#{clientId}"]
+
+ _focusDraft: (draftElement) =>
+ # Note: We don't want the contenteditable view competing for scroll offset,
+ # so we block incoming childScrollRequests while we scroll to the new draft.
+ @_draftScrollInProgress = true
+ draftElement.focus()
+ @refs.messageWrap.scrollTo(draftElement, {
+ position: ScrollRegion.ScrollPosition.Top,
+ settle: true,
+ done: =>
+ @_draftScrollInProgress = false
+ })
+
+ _onForward: =>
+ return unless @state.currentThread
+ Actions.composeForward(thread: @state.currentThread)
+
+ render: =>
+ if not @state.currentThread
+ return
+
+ wrapClass = classNames
+ "messages-wrap": true
+ "ready": not @state.loading
+
+ messageListClass = classNames
+ "message-list": true
+ "height-fix": SearchableComponentStore.searchTerm isnt null
+
+
+
+
+
+ {@_renderSubject()}
+
+
+
+ {@_messageElements()}
+
+
+
+
+
+ _renderSubject: ->
+ subject = @state.currentThread.subject
+ subject = "(No Subject)" if not subject or subject.length is 0
+
+
+
+
+ {subject}
+
+
+ {@_renderIcons()}
+
+
+ _renderIcons: =>
+
+ {@_renderExpandToggle()}
+
+
+
+ {@_renderPopoutToggle()}
+
+
+ _renderExpandToggle: =>
+ return unless @state.canCollapse
+
+ if @state.hasCollapsedItems
+
+
+
+ else
+
+
+
+
+ _renderPopoutToggle: =>
+ if NylasEnv.isThreadWindow()
+
+
+
+ else
+
+
+
+
+
+ _renderReplyArea: =>
+
+
+ _lastMessage: =>
+ _.last(_.filter((@state.messages ? []), (m) -> not m.draft))
+
+ # Returns either "reply" or "reply-all"
+ _replyType: =>
+ defaultReplyType = NylasEnv.config.get('core.sending.defaultReplyType')
+ lastMessage = @_lastMessage()
+ return 'reply' unless lastMessage
+
+ if lastMessage.canReplyAll()
+ if defaultReplyType is 'reply-all'
+ return 'reply-all'
+ else
+ return 'reply'
+ else
+ return 'reply'
+
+ _onToggleAllMessagesExpanded: ->
+ Actions.toggleAllMessagesExpanded()
+
+ _onPrintThread: =>
+ node = ReactDOM.findDOMNode(@)
+ Actions.printThread(@state.currentThread, node.innerHTML)
+
+ _onPopThreadIn: =>
+ return unless @state.currentThread
+ Actions.focusThreadMainWindow(@state.currentThread)
+ NylasEnv.close()
+
+ _onPopoutThread: =>
+ return unless @state.currentThread
+ Actions.popoutThread(@state.currentThread)
+ # This returns the single-pane view to the inbox, and does nothing for
+ # double-pane view because we're at the root sheet.
+ Actions.popSheet()
+
+ _onClickReplyArea: =>
+ return unless @state.currentThread
+ Actions.composeReply({
+ thread: @state.currentThread,
+ message: @_lastMessage(),
+ type: @_replyType(),
+ behavior: 'prefer-existing-if-pristine',
+ })
+
+ _messageElements: =>
+ elements = []
+
+ hasReplyArea = not _.last(@state.messages)?.draft
+ messages = @_messagesWithMinification(@state.messages)
+ messages.forEach (message, idx) =>
+
+ if message.type is "minifiedBundle"
+ elements.push(@_renderMinifiedBundle(message))
+ return
+
+ collapsed = !@state.messagesExpandedState[message.id]
+ isLastMsg = (messages.length - 1 is idx)
+ isBeforeReplyArea = isLastMsg and hasReplyArea
+
+ elements.push(
+
+ )
+
+ if hasReplyArea
+ elements.push(@_renderReplyArea())
+
+ return elements
+
+ _renderMinifiedBundle: (bundle) ->
+ BUNDLE_HEIGHT = 36
+ lines = bundle.messages[0...10]
+ h = Math.round(BUNDLE_HEIGHT / lines.length)
+
+ @setState minified: false }
+ key={Utils.generateTempId()}>
+
{bundle.messages.length} older messages
+
+ {lines.map (msg, i) ->
+
}
+
+
+
+ _messagesWithMinification: (messages=[]) =>
+ return messages unless @state.minified
+
+ messages = _.clone(messages)
+ minifyRanges = []
+ consecutiveCollapsed = 0
+
+ messages.forEach (message, idx) =>
+ return if idx is 0 # Never minify the 1st message
+
+ expandState = @state.messagesExpandedState[message.id]
+
+ if not expandState
+ consecutiveCollapsed += 1
+ else
+ # We add a +1 because we don't minify the last collapsed message,
+ # but the MINIFY_THRESHOLD refers to the smallest N that can be in
+ # the "N older messages" minified block.
+ if expandState is "default"
+ minifyOffset = 1
+ else # if expandState is "explicit"
+ minifyOffset = 0
+
+ if consecutiveCollapsed >= @MINIFY_THRESHOLD + minifyOffset
+ minifyRanges.push
+ start: idx - consecutiveCollapsed
+ length: (consecutiveCollapsed - minifyOffset)
+ consecutiveCollapsed = 0
+
+ indexOffset = 0
+ for range in minifyRanges
+ start = range.start - indexOffset
+ minified =
+ type: "minifiedBundle"
+ messages: messages[start...(start+range.length)]
+ messages.splice(start, range.length, minified)
+
+ # While we removed `range.length` items, we also added 1 back in.
+ indexOffset += (range.length - 1)
+
+ return messages
+
+ # Some child components (like the composer) might request that we scroll
+ # to a given location. If `selectionTop` is defined that means we should
+ # scroll to that absolute position.
+ #
+ # If messageId and location are defined, that means we want to scroll
+ # smoothly to the top of a particular message.
+ _scrollTo: ({clientId, rect, position}={}) =>
+ return if @_draftScrollInProgress
+ if clientId
+ messageElement = @_getMessageContainer(clientId)
+ return unless messageElement
+ pos = position ? ScrollRegion.ScrollPosition.Visible
+ @refs.messageWrap.scrollTo(messageElement, {
+ position: pos
+ })
+ else if rect
+ @refs.messageWrap.scrollToRect(rect, {
+ position: ScrollRegion.ScrollPosition.CenterIfInvisible
+ })
+ else
+ throw new Error("onChildScrollRequest: expected clientId or rect")
+
+ _onMessageLoaded: =>
+ if @state.currentThread
+ timerKey = "select-thread-#{@state.currentThread.id}"
+ if NylasEnv.timer.isPending(timerKey)
+ actionTimeMs = NylasEnv.timer.stop(timerKey)
+ messageCount = (@state.messages || []).length
+ Actions.recordPerfMetric({
+ sample: 0.1,
+ action: 'select-thread',
+ actionTimeMs,
+ messageCount,
+ })
+
+ _onScrollByPage: (direction) =>
+ height = ReactDOM.findDOMNode(@refs.messageWrap).clientHeight
+ @refs.messageWrap.scrollTop += height * direction
+
+ _onChange: =>
+ newState = @_getStateFromStores()
+ if @state.currentThread?.id isnt newState.currentThread?.id
+ newState.minified = true
+ @setState(newState)
+
+ _getStateFromStores: =>
+ messages: (MessageStore.items() ? [])
+ messagesExpandedState: MessageStore.itemsExpandedState()
+ canCollapse: MessageStore.items().length > 1
+ hasCollapsedItems: MessageStore.hasCollapsedItems()
+ currentThread: MessageStore.thread()
+ loading: MessageStore.itemsLoading()
+
+module.exports = SearchableComponentMaker.extend(MessageList)
diff --git a/packages/client-app/internal_packages/message-list/lib/message-participants.cjsx b/packages/client-app/internal_packages/message-list/lib/message-participants.cjsx
new file mode 100644
index 0000000000..3228c8ce7d
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/message-participants.cjsx
@@ -0,0 +1,170 @@
+_ = require 'underscore'
+React = require "react"
+classnames = require 'classnames'
+{Actions, Contact} = require 'nylas-exports'
+{Menu, MenuItem} = require('electron').remote
+
+
+MAX_COLLAPSED = 5
+
+class MessageParticipants extends React.Component
+ @displayName: 'MessageParticipants'
+
+ @propTypes:
+ to: React.PropTypes.array
+ cc: React.PropTypes.array
+ bcc: React.PropTypes.array
+ from: React.PropTypes.array
+ onClick: React.PropTypes.func
+ isDetailed: React.PropTypes.bool
+
+ @defaultProps:
+ to: []
+ cc: []
+ bcc: []
+ from: []
+
+
+ # Helpers
+
+ _allToParticipants: =>
+ _.union(@props.to, @props.cc, @props.bcc)
+
+ _selectText: (e) =>
+ textNode = e.currentTarget.childNodes[0]
+
+ range = document.createRange()
+ range.setStart(textNode, 0)
+ range.setEnd(textNode, textNode.length)
+ selection = document.getSelection()
+ selection.removeAllRanges()
+ selection.addRange(range)
+
+ _shortNames: (contacts = [], max = MAX_COLLAPSED) =>
+ names = _.map(contacts, (c) -> c.displayName(includeAccountLabel: true, compact: true))
+ if names.length > max
+ extra = names.length - max
+ names = names.slice(0, max)
+ names.push("and #{extra} more")
+ names.join(", ")
+
+ _onContactContextMenu: (contact) =>
+ menu = new Menu()
+ menu.append(new MenuItem({role: 'copy'}))
+ menu.append(new MenuItem({
+ label: "Email #{contact.email}",
+ click: => Actions.composeNewDraftToRecipient(contact)
+ }))
+ menu.popup(NylasEnv.getCurrentWindow())
+
+ # Renderers
+
+ _renderFullContacts: (contacts = []) =>
+ _.map(contacts, (c, i) =>
+ if contacts.length is 1 then comma = ""
+ else if i is contacts.length-1 then comma = ""
+ else comma = ","
+
+ if c.name?.length > 0 and c.name isnt c.email
+
+
+ {c.fullName()}
+
+
+ {" <"}
+ @_onContactContextMenu(c)}
+ >
+ {c.email}
+
+ {">#{comma}"}
+
+
+ else
+
+
+ @_onContactContextMenu(c)}
+ >
+ {c.email}
+
+ {comma}
+
+
+ )
+
+ _renderExpandedField: (name, field, {includeLabel} = {}) =>
+ includeLabel ?= true
+
+ {
+ if includeLabel
+
{name}:
+ else
+ undefined
+ }
+
+ {@_renderFullContacts(field)}
+
+
+
+ _renderExpanded: =>
+ expanded = []
+
+ if @props.from.length > 0
+ expanded.push(
+ @_renderExpandedField('from', @props.from, includeLabel: false)
+ )
+
+ if @props.to.length > 0
+ expanded.push(
+ @_renderExpandedField('to', @props.to)
+ )
+
+ if @props.cc.length > 0
+ expanded.push(
+ @_renderExpandedField('cc', @props.cc)
+ )
+
+ if @props.bcc.length > 0
+ expanded.push(
+ @_renderExpandedField('bcc', @props.bcc)
+ )
+
+
+ {expanded}
+
+
+ _renderCollapsed: =>
+ childSpans = []
+ toParticipants = @_allToParticipants()
+
+ if @props.from.length > 0
+ childSpans.push(
+ {@_shortNames(@props.from)}
+ )
+
+ if toParticipants.length > 0
+ childSpans.push(
+ To:
+ {@_shortNames(toParticipants)}
+ )
+
+
+ {childSpans}
+
+
+ render: =>
+ classSet = classnames
+ "participants": true
+ "message-participants": true
+ "collapsed": not @props.isDetailed
+ "from-participants": @props.from.length > 0
+ "to-participants": @_allToParticipants().length > 0
+
+
+ {if @props.isDetailed then @_renderExpanded() else @_renderCollapsed()}
+
+
+module.exports = MessageParticipants
diff --git a/packages/client-app/internal_packages/message-list/lib/message-timestamp.jsx b/packages/client-app/internal_packages/message-list/lib/message-timestamp.jsx
new file mode 100644
index 0000000000..eb78d198ea
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/message-timestamp.jsx
@@ -0,0 +1,40 @@
+import React from 'react'
+import {DateUtils} from 'nylas-exports'
+
+class MessageTimestamp extends React.Component {
+ static displayName = 'MessageTimestamp'
+
+ static propTypes = {
+ date: React.PropTypes.object.isRequired,
+ className: React.PropTypes.string,
+ isDetailed: React.PropTypes.bool,
+ onClick: React.PropTypes.func,
+ }
+
+ shouldComponentUpdate(nextProps) {
+ return (
+ nextProps.date !== this.props.date ||
+ nextProps.isDetailed !== this.props.isDetailed
+ )
+ }
+
+ render() {
+ let formattedDate = null
+ if (this.props.isDetailed) {
+ formattedDate = DateUtils.mediumTimeString(this.props.date)
+ } else {
+ formattedDate = DateUtils.shortTimeString(this.props.date)
+ }
+ return (
+
+ {formattedDate}
+
+ )
+ }
+}
+
+export default MessageTimestamp
diff --git a/packages/client-app/internal_packages/message-list/lib/sidebar-participant-picker.jsx b/packages/client-app/internal_packages/message-list/lib/sidebar-participant-picker.jsx
new file mode 100644
index 0000000000..fc1a1b7117
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/sidebar-participant-picker.jsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import {Actions, FocusedContactsStore} from 'nylas-exports'
+
+const SPLIT_KEY = "---splitvalue---"
+
+export default class SidebarParticipantPicker extends React.Component {
+ static displayName = 'SidebarParticipantPicker';
+
+ static containerStyles = {
+ order: 0,
+ flexShrink: 0,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = this._getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._usub = FocusedContactsStore.listen(() => {
+ return this.setState(this._getStateFromStores());
+ });
+ }
+
+ componentWillUnmount() {
+ this._usub();
+ }
+
+ _getStateFromStores() {
+ return {
+ sortedContacts: FocusedContactsStore.sortedContacts(),
+ focusedContact: FocusedContactsStore.focusedContact(),
+ };
+ }
+
+ _getKeyForContact(contact) {
+ if (!contact) {
+ return null
+ }
+ return contact.email + SPLIT_KEY + contact.name
+ }
+
+ _onSelectContact = (event) => {
+ const {sortedContacts} = this.state
+ const [email, name] = event.target.value.split(SPLIT_KEY);
+ const contact = sortedContacts.find((c) => c.name === name && c.email === email)
+ return Actions.focusContact(contact);
+ }
+
+ _renderSortedContacts() {
+ return this.state.sortedContacts.map((contact) => {
+ const key = this._getKeyForContact(contact)
+
+ return (
+
+ {contact.displayName({includeAccountLabel: true, forceAccountLabel: true})}
+
+ )
+ });
+ }
+
+ render() {
+ const {sortedContacts, focusedContact} = this.state
+ const value = this._getKeyForContact(focusedContact)
+ if (sortedContacts.length === 0 || !value) {
+ return false
+ }
+ return (
+
+
+ {this._renderSortedContacts()}
+
+
+ )
+ }
+
+
+}
diff --git a/packages/client-app/internal_packages/message-list/lib/sidebar-plugin-container.cjsx b/packages/client-app/internal_packages/message-list/lib/sidebar-plugin-container.cjsx
new file mode 100644
index 0000000000..8b1fafd426
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/sidebar-plugin-container.cjsx
@@ -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)
+
+ {inner}
+
+ _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: ->
+
+
+
+
+class SidebarPluginContainerInner extends React.Component
+ constructor: (@props) ->
+
+ render: ->
+
+
+module.exports = SidebarPluginContainer
diff --git a/packages/client-app/internal_packages/message-list/lib/thread-archive-button.cjsx b/packages/client-app/internal_packages/message-list/lib/thread-archive-button.cjsx
new file mode 100644
index 0000000000..0948d97f22
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/thread-archive-button.cjsx
@@ -0,0 +1,36 @@
+{RetinaImg} = require 'nylas-component-kit'
+{Actions,
+ React,
+ TaskFactory,
+ DOMUtils,
+ AccountStore,
+ FocusedPerspectiveStore} = require 'nylas-exports'
+
+class ThreadArchiveButton extends React.Component
+ @displayName: "ThreadArchiveButton"
+ @containerRequired: false
+
+ @propTypes:
+ thread: React.PropTypes.object.isRequired
+
+ render: =>
+ canArchiveThreads = FocusedPerspectiveStore.current().canArchiveThreads([@props.thread])
+ return unless canArchiveThreads
+
+
+
+
+
+ _onArchive: (e) =>
+ return unless DOMUtils.nodeIsVisible(e.currentTarget)
+ Actions.archiveThreads({
+ threads: [@props.thread],
+ source: 'Toolbar Button: Message List',
+ })
+ Actions.popSheet()
+ e.stopPropagation()
+
+module.exports = ThreadArchiveButton
diff --git a/packages/client-app/internal_packages/message-list/lib/thread-star-button.cjsx b/packages/client-app/internal_packages/message-list/lib/thread-star-button.cjsx
new file mode 100644
index 0000000000..faab98514e
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/thread-star-button.cjsx
@@ -0,0 +1,29 @@
+_ = require 'underscore'
+React = require 'react'
+{Actions, Utils} = require 'nylas-exports'
+{RetinaImg} = require 'nylas-component-kit'
+
+class StarButton extends React.Component
+ @displayName: "StarButton"
+ @containerRequired: false
+ @propTypes:
+ thread: React.PropTypes.object
+
+ render: =>
+ selected = @props.thread? and @props.thread.starred
+
+
+
+
+ _onStarToggle: (e) =>
+ Actions.toggleStarredThreads({
+ source: "Toolbar Button: Message List",
+ threads: [@props.thread]
+ })
+ e.stopPropagation()
+
+
+module.exports = StarButton
diff --git a/packages/client-app/internal_packages/message-list/lib/thread-toggle-unread-button.cjsx b/packages/client-app/internal_packages/message-list/lib/thread-toggle-unread-button.cjsx
new file mode 100644
index 0000000000..08bfe20cca
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/thread-toggle-unread-button.cjsx
@@ -0,0 +1,26 @@
+{Actions, React, FocusedContentStore} = require 'nylas-exports'
+{RetinaImg} = require 'nylas-component-kit'
+
+class ThreadToggleUnreadButton extends React.Component
+ @displayName: "ThreadToggleUnreadButton"
+ @containerRequired: false
+
+ render: =>
+ fragment = if @props.thread?.unread then "read" else "unread"
+
+
+
+
+ _onClick: (e) =>
+ Actions.toggleUnreadThreads({
+ source: "Toolbar Button: Thread List",
+ threads: [@props.thread],
+ })
+ Actions.popSheet()
+ e.stopPropagation()
+
+module.exports = ThreadToggleUnreadButton
diff --git a/packages/client-app/internal_packages/message-list/lib/thread-trash-button.cjsx b/packages/client-app/internal_packages/message-list/lib/thread-trash-button.cjsx
new file mode 100644
index 0000000000..e6c2a998e3
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/lib/thread-trash-button.cjsx
@@ -0,0 +1,38 @@
+_ = require 'underscore'
+React = require 'react'
+{Actions,
+ DOMUtils,
+ TaskFactory,
+ AccountStore,
+ FocusedPerspectiveStore} = require 'nylas-exports'
+{RetinaImg} = require 'nylas-component-kit'
+
+class ThreadTrashButton extends React.Component
+ @displayName: "ThreadTrashButton"
+ @containerRequired: false
+
+ @propTypes:
+ thread: React.PropTypes.object.isRequired
+
+ render: =>
+ allowed = FocusedPerspectiveStore.current().canMoveThreadsTo([@props.thread], 'trash')
+ return unless allowed
+
+
+
+
+
+ _onRemove: (e) =>
+ return unless DOMUtils.nodeIsVisible(e.currentTarget)
+ Actions.trashThreads({
+ source: "Toolbar Button: Thread List",
+ threads: [@props.thread],
+ })
+ Actions.popSheet()
+ e.stopPropagation()
+
+
+module.exports = ThreadTrashButton
diff --git a/packages/client-app/internal_packages/message-list/package.json b/packages/client-app/internal_packages/message-list/package.json
new file mode 100755
index 0000000000..0524f48937
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "message-list",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "View messages for a thread",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "default": true,
+ "thread-popout": true
+ }
+}
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/both-email-and-url-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/both-email-and-url-in.html
new file mode 100644
index 0000000000..87e893b886
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/both-email-and-url-in.html
@@ -0,0 +1,4 @@
+To test this, send https://www.google.com/search?q=test@example.com or gmail.com?q=bengotow@gmail.com
+to yourself from a client that allows plaintext or html editing.
+
+What about gmail.com/bengotow@gmail.com - Oh man you're asking for trouble.
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/both-email-and-url-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/both-email-and-url-out.html
new file mode 100644
index 0000000000..51d7ebf17e
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/both-email-and-url-out.html
@@ -0,0 +1,4 @@
+To test this, send https://www.google.com/search?q=test@example.com or gmail.com?q=bengotow@gmail.com
+to yourself from a client that allows plaintext or html editing.
+
+What about gmail.com/bengotow@gmail.com - Oh man you're asking for trouble.
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/gmail-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/gmail-in.html
new file mode 100644
index 0000000000..d2b52345af
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/gmail-in.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+New sign-in from Chrome on Mac
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Hi Ben,
+
+
+
+Your Google Account careless@foundry376.com was just used to sign
+in from Chrome on
+Mac .
+
+
+
+
+
+
+Ben Gotow (Careless)
+
+careless@foundry376.com
+
+
+
+
+
+
+
+
+
+
+Mac
+
+Monday, July 13, 2015 3:49 PM (Pacific Daylight Time)
+San Francisco, CA, USA*
+Chrome
+
+
+Don't recognize this activity?
+Review your recently used devices now.
+
+Why are we sending this? We take security very seriously and we
+want to keep you in the loop on important actions in your
+account.
+We were unable to determine whether you have used this browser or
+device with your account before. This can happen when you sign in
+for the first time on a new computer, phone or browser, when you
+use your browser's incognito or private browsing mode or clear your
+cookies, or when somebody else is accessing your account.
+
+
+
+
+
+
+Best,
+The Google Accounts team
+
+
+
+
+
+
+*The location is approximate and determined by the IP address it
+was coming from.
+This email can't receive replies. To give us feedback on this
+alert, click here .
+For more information, visit the Google
+Accounts Help Center .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+You received this mandatory email service announcement to update
+you about important changes to your Google product or account.
+© 2015 Google Inc.,
+1600 Amphitheatre Parkway, Mountain View, CA 94043, USA
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/gmail-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/gmail-out.html
new file mode 100644
index 0000000000..e6c5efb6aa
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/gmail-out.html
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+New sign-in from Chrome on Mac
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Hi Ben,
+
+
+
+Your Google Account careless@foundry376.com was just used to sign
+in from Chrome on
+Mac .
+
+
+
+
+
+
+Ben Gotow (Careless)
+
+careless@foundry376.com
+
+
+
+
+
+
+
+
+
+
+Mac
+
+Monday, July 13, 2015 3:49 PM (Pacific Daylight Time)
+San Francisco, CA, USA*
+Chrome
+
+
+Don't recognize this activity?
+Review your recently used devices now.
+
+Why are we sending this? We take security very seriously and we
+want to keep you in the loop on important actions in your
+account.
+We were unable to determine whether you have used this browser or
+device with your account before. This can happen when you sign in
+for the first time on a new computer, phone or browser, when you
+use your browser's incognito or private browsing mode or clear your
+cookies, or when somebody else is accessing your account.
+
+
+
+
+
+
+Best,
+The Google Accounts team
+
+
+
+
+
+
+*The location is approximate and determined by the IP address it
+was coming from.
+This email can't receive replies. To give us feedback on this
+alert, click here .
+For more information, visit the Google
+Accounts Help Center .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+You received this mandatory email service announcement to update
+you about important changes to your Google product or account.
+© 2015 Google Inc.,
+1600 Amphitheatre Parkway, Mountain View, CA 94043, USA
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/linkedin-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/linkedin-in.html
new file mode 100644
index 0000000000..7cb8be2a75
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/linkedin-in.html
@@ -0,0 +1,1248 @@
+
+
+
+
+
+
+"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Recommended for you
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Liz Claman
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Trish Nicolas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Josh Kopelman
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Dr. Travis Bradberry
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Alex Baydin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Have your own perspective to share?
+
+
+
+
+
+
+
+
+Start
+writing on LinkedIn
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+You are receiving notification emails from LinkedIn.
+Unsubscribe
+
+
+
+
+
+This email was intended for Benjamin Hartester (Software
+Developer). Learn why we included
+this.
+
+
+If you need assistance or have questions, please contact
+LinkedIn Customer
+Service .
+
+
+
+
+
+
+
+© 2016 LinkedIn Corporation, 2029 Stierlin Court, Mountain View
+CA 94043. LinkedIn and the LinkedIn logo are registered trademarks
+of LinkedIn.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "
+
+
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/linkedin-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/linkedin-out.html
new file mode 100644
index 0000000000..6befa37d9e
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/linkedin-out.html
@@ -0,0 +1,1010 @@
+
+
+
+
+
+
+"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Recommended for you
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Liz Claman
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Trish Nicolas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Josh Kopelman
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Dr. Travis Bradberry
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Alex Baydin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Have your own perspective to share?
+
+
+
+
+
+
+
+
+Start
+writing on LinkedIn
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+You are receiving notification emails from LinkedIn.
+Unsubscribe
+
+
+
+
+
+This email was intended for Benjamin Hartester (Software
+Developer). Learn why we included
+this.
+
+
+If you need assistance or have questions, please contact
+LinkedIn Customer
+Service .
+
+
+
+
+
+
+
+© 2016 LinkedIn Corporation, 2029 Stierlin Court, Mountain View
+CA 94043. LinkedIn and the LinkedIn logo are registered trademarks
+of LinkedIn.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "
+
+
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/medium-post-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/medium-post-in.html
new file mode 100644
index 0000000000..28ea1f256e
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/medium-post-in.html
@@ -0,0 +1,5 @@
+Reported on GitHub:
+
+https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.3fela2o72
+
+Geez they have messy URLs.
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/medium-post-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/medium-post-out.html
new file mode 100644
index 0000000000..56caf45006
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/medium-post-out.html
@@ -0,0 +1,5 @@
+Reported on GitHub:
+
+https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.3fela2o72
+
+Geez they have messy URLs.
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/nylas-url-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/nylas-url-in.html
new file mode 100644
index 0000000000..ac0d3f54f2
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/nylas-url-in.html
@@ -0,0 +1,11 @@
+Hello world
+
+nylas is cool.
+
+nylas://plugins?test=stuff
+
+nylas:plugins?test=stuff
+
+nylas://plugins?test=stuff
+
+Don't you like nylas?
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/nylas-url-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/nylas-url-out.html
new file mode 100644
index 0000000000..f5e06723c2
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/nylas-url-out.html
@@ -0,0 +1,11 @@
+Hello world
+
+nylas is cool.
+
+nylas://plugins?test=stuff
+
+nylas:plugins?test=stuff
+
+nylas://plugins?test=stuff
+
+Don't you like nylas?
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/plaintext-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/plaintext-in.html
new file mode 100644
index 0000000000..efc6bd12eb
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/plaintext-in.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+http://apple.com/
+
+https://dropbox.com/
+
+whatever.com
+
+kinda-looks-like-a-link.com
+
+ftp://helloworld.com/asd
+
+540-250-2334
+
++1-524-123-3333
+
+550.555.1234
+
+bengotow@gmail.com
+
+
+
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/plaintext-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/plaintext-out.html
new file mode 100644
index 0000000000..07792b3f45
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/plaintext-out.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+http://apple.com/
+
+
+
+
+
+
+
+
+
+
+
+
+
+550.555.1234
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/readme-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/readme-in.html
new file mode 100644
index 0000000000..9ae24b35b4
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/readme-in.html
@@ -0,0 +1,81 @@
+To get up and running with the api you'll need to follow these steps.
+
+1. Once you feel familiar with the endpoints and http responses go ham!!!
+
+> [ ID] Interval Transfer Bandwidth Jitter Lost/Total
+> [ 4] 0.00-10.00 sec 11.8 MBytes 9.90 Mbits/sec 0.687 ms 1397/1497 (93%)
+
+diff --git a/drivers/video/fbdev/nvidia/nv_local.h b/drivers/video/fbdev/nvidia/nv_local.h
+index 68e508d..2c6baa1 100644
+--- a/drivers/video/fbdev/nvidia/nv_local.h
++++ b/drivers/video/fbdev/nvidia/nv_local.h
+
+This is the correct solution as there is really no imx6sl-fox-p1.dts file.
+
+## Support
+
+Please visit https://github.com/a/b/issues/new.
+
+Please [open an issue](https://github.com/a/b/issues/new) for support.
+
+Also see https://nylas.com/cloud/docs#receiving_notifications
+
+Also see https://nylas.com/tag#about%20me
+
+Also see https://nylas.com/tag#about%20
+
+dev.tellform.com/#!/verify/xcFfUbvQL0FG298GsB0nBJGS7QRi7nsWVjS9iSyaeyBCFgUv
+
+## Contributing
+If you would like to contribute to the axefax api (which you are encouraged to do) here are the basics.
+
+- Ruby version: 2.2.2
+- System dependencies: Rails, AWS, postgres, eb-cli
+- Configuration:
+```bash
+$ git clone https://github.com/a/b.git
+$ bundle install
+```
+- Database intitialization/creation:
+```bash
+$ rake db:reset db:setup db:seed
+```
+- How to run the test suite:
+```bash
+$ rspec spec
+```
+- or alternatively:
+```bash
+$ guard
+```
+- run the server:
+```bash
+$ rails server
+```
+- Git Guidelines:
+ - Please create a contributor/feature branch for any changes you make.
+ - Be sure to always pull down the latest master branch before pushing.
+ - etc...
+- Generating Documentation:
+ - This app makes use of the (Apipie Gem)[https://github.com/Apipie/apipie-rails]
+ - To Auto/Re-Generate Documentation for API Endpoints based on config/routes.rb
+ and the spec suite run...
+```bash
+$ APIPIE_RECORD=params rake spec:controllers
+$ APIPIE_RECORD=examples rake spec:controllers
+```
+ - Then to generate static HTML files for production...
+```bash
+$ rake apipie:static
+```
+- Deployment instructions:
+ - If the test suite is passing and you've successfully merged to master and pushed up to github...
+```bash
+$ eb deploy
+```
+ - Hopefully you won't need to ssh into the remote server to run migrations but if you do...
+```bash
+$ eb ssh
+remote:ec2 ~ $ cd /var/app/current/
+```
+ - From here you have access to a limited set of railsy stuffs. But for example rake db:migrate
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/readme-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/readme-out.html
new file mode 100644
index 0000000000..25cb9dc62d
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/readme-out.html
@@ -0,0 +1,81 @@
+To get up and running with the api you'll need to follow these steps.
+
+1. Once you feel familiar with the endpoints and http responses go ham!!!
+
+> [ ID] Interval Transfer Bandwidth Jitter Lost/Total
+> [ 4] 0.00-10.00 sec 11.8 MBytes 9.90 Mbits/sec 0.687 ms 1397/1497 (93%)
+
+diff --git a/drivers/video/fbdev/nvidia/nv_local.h b/drivers/video/fbdev/nvidia/nv_local.h
+index 68e508d..2c6baa1 100644
+--- a/drivers/video/fbdev/nvidia/nv_local.h
++++ b/drivers/video/fbdev/nvidia/nv_local.h
+
+This is the correct solution as there is really no imx6sl-fox-p1.dts file.
+
+## Support
+
+Please visit https://github.com/a/b/issues/new .
+
+Please [open an issue](https://github.com/a/b/issues/new ) for support.
+
+Also see https://nylas.com/cloud/docs#receiving_notifications
+
+Also see https://nylas.com/tag#about%20me
+
+Also see https://nylas.com/tag#about%20
+
+dev.tellform.com/#!/verify/xcFfUbvQL0FG298GsB0nBJGS7QRi7nsWVjS9iSyaeyBCFgUv
+
+## Contributing
+If you would like to contribute to the axefax api (which you are encouraged to do) here are the basics.
+
+- Ruby version: 2.2.2
+- System dependencies: Rails, AWS, postgres, eb-cli
+- Configuration:
+```bash
+$ git clone https://github.com/a/b.git
+$ bundle install
+```
+- Database intitialization/creation:
+```bash
+$ rake db:reset db:setup db:seed
+```
+- How to run the test suite:
+```bash
+$ rspec spec
+```
+- or alternatively:
+```bash
+$ guard
+```
+- run the server:
+```bash
+$ rails server
+```
+- Git Guidelines:
+ - Please create a contributor/feature branch for any changes you make.
+ - Be sure to always pull down the latest master branch before pushing.
+ - etc...
+- Generating Documentation:
+ - This app makes use of the (Apipie Gem)[https://github.com/Apipie/apipie-rails ]
+ - To Auto/Re-Generate Documentation for API Endpoints based on config/routes.rb
+ and the spec suite run...
+```bash
+$ APIPIE_RECORD=params rake spec:controllers
+$ APIPIE_RECORD=examples rake spec:controllers
+```
+ - Then to generate static HTML files for production...
+```bash
+$ rake apipie:static
+```
+- Deployment instructions:
+ - If the test suite is passing and you've successfully merged to master and pushed up to github...
+```bash
+$ eb deploy
+```
+ - Hopefully you won't need to ssh into the remote server to run migrations but if you do...
+```bash
+$ eb ssh
+remote:ec2 ~ $ cd /var/app/current/
+```
+ - From here you have access to a limited set of railsy stuffs. But for example rake db:migrate
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangeemails-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangeemails-in.html
new file mode 100644
index 0000000000..7c90a0e66a
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangeemails-in.html
@@ -0,0 +1,10 @@
+1/ Regarding the duplicated notifications, did you send an email
+from "joshua90@gmail.com" to "joshua@drntric.com"? Since we're
+syncing those two accounts, you should be receiving webhooks for
+both of them.
+
+mailbox+tag@hostanme.com
+
+Miles.O'Brian@example.com
+
+785ee39055efcd86359b6e05a9bef0e7@example.com
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangeemails-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangeemails-out.html
new file mode 100644
index 0000000000..edd8b41b0a
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangeemails-out.html
@@ -0,0 +1,10 @@
+1/ Regarding the duplicated notifications, did you send an email
+from "joshua90@gmail.com " to "joshua@drntric.com "? Since we're
+syncing those two accounts, you should be receiving webhooks for
+both of them.
+
+mailbox+tag@hostanme.com
+
+Miles.O'Brian@example.com
+
+785ee39055efcd86359b6e05a9bef0e7@example.com
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangephones-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangephones-in.html
new file mode 100644
index 0000000000..86c2c13a38
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangephones-in.html
@@ -0,0 +1,19 @@
+Give us a call at 540-250-1231. Thanks!
+Give us a call at +1540-250-1231. Thanks!
+Give us a call at +1-540-250-1231. Thanks!
+Give us a call at 1-540-250-1231. Thanks!
+Give us a call at +1-(540)-250-1231. Thanks!
+Give us a call at (540)-250-1231. Thanks!
+Give us a call at (540) 250 1231. Thanks!
+Give us a call at 540 250 1231. Thanks!
+Give us a call at +1 540 250 1231. Thanks!
+Give us a call at 6641234567. Thanks!
+Give us a call at 664 123 4567. Thanks!
+Give us a call at (044) 664 123 4567. Thanks!
+Give us a call at 0333 320 1030. Thanks!
+
+123123-1223-12-312-31-23-123123-12341515124124-123124
+1111123123123-1231
+123123123123123123123123123123123
+
+Here's the number:(540) 250 1231
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangephones-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangephones-out.html
new file mode 100644
index 0000000000..183e98c4cb
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangephones-out.html
@@ -0,0 +1,19 @@
+Give us a call at 540-250-1231 . Thanks!
+Give us a call at +1540-250-1231 . Thanks!
+Give us a call at +1-540-250-1231 . Thanks!
+Give us a call at 1-540-250-1231 . Thanks!
+Give us a call at +1-(540)-250-1231 . Thanks!
+Give us a call at (540)-250-1231 . Thanks!
+Give us a call at (540) 250 1231 . Thanks!
+Give us a call at 540 250 1231 . Thanks!
+Give us a call at +1 540 250 1231 . Thanks!
+Give us a call at 6641234567. Thanks!
+Give us a call at 664 123 4567 . Thanks!
+Give us a call at (044) 664 123 4567 . Thanks!
+Give us a call at 0333 320 1030 . Thanks!
+
+123123-1223-12-312-31-23-123123-12341515124124-123124
+1111123123123-1231
+123123123123123123123123123123123
+
+Here's the number:(540) 250 1231
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/twitter-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/twitter-in.html
new file mode 100644
index 0000000000..b923eadd41
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/twitter-in.html
@@ -0,0 +1,3 @@
+Reported on GitHub:
+
+https://twitter.com/SF_emergency/status/714901408298893317
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/twitter-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/twitter-out.html
new file mode 100644
index 0000000000..e77d06d5a3
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/twitter-out.html
@@ -0,0 +1,3 @@
+Reported on GitHub:
+
+https://twitter.com/SF_emergency/status/714901408298893317
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/url-with-port-in.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/url-with-port-in.html
new file mode 100644
index 0000000000..ee34575f86
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/url-with-port-in.html
@@ -0,0 +1,2 @@
+HTTP links with port in them don't link correctly either,
+e.g. http://example.com:8080/path/ only links http://example.com.
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/url-with-port-out.html b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/url-with-port-out.html
new file mode 100644
index 0000000000..227ddd62b9
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/url-with-port-out.html
@@ -0,0 +1,2 @@
+HTTP links with port in them don't link correctly either,
+e.g. http://example.com:8080/path/ only links http://example.com .
diff --git a/packages/client-app/internal_packages/message-list/spec/autolinker-spec.es6 b/packages/client-app/internal_packages/message-list/spec/autolinker-spec.es6
new file mode 100644
index 0000000000..8ee38f13e1
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/autolinker-spec.es6
@@ -0,0 +1,24 @@
+import fs from 'fs';
+import path from 'path';
+import {autolink} from '../lib/autolinker';
+
+describe('autolink', function autolinkSpec() {
+ const fixturesDir = path.join(__dirname, 'autolinker-fixtures');
+ fs.readdirSync(fixturesDir).filter(filename =>
+ filename.indexOf('-in.html') !== -1
+ ).forEach((filename) => {
+ it(`should properly autolink a variety of email bodies ${filename}`, () => {
+ const div = document.createElement('div');
+ const inputPath = path.join(fixturesDir, filename);
+ const expectedPath = inputPath.replace('-in', '-out');
+
+ const input = fs.readFileSync(inputPath).toString();
+ const expected = fs.readFileSync(expectedPath).toString();
+
+ div.innerHTML = input;
+ autolink({body: div});
+
+ expect(div.innerHTML).toEqual(expected);
+ });
+ });
+});
diff --git a/packages/client-app/internal_packages/message-list/spec/message-item-body-spec.cjsx b/packages/client-app/internal_packages/message-list/spec/message-item-body-spec.cjsx
new file mode 100644
index 0000000000..128135d9de
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/message-item-body-spec.cjsx
@@ -0,0 +1,244 @@
+proxyquire = require 'proxyquire'
+React = require "react"
+ReactDOM = require "react-dom"
+ReactTestUtils = require('react-addons-test-utils')
+
+{Contact,
+ Message,
+ File,
+ FileDownloadStore,
+ MessageBodyProcessor} = require "nylas-exports"
+
+EmailFrameStub = React.createClass({render: ->
})
+
+{InjectedComponent} = require 'nylas-component-kit'
+
+file = new File
+ id: 'file_1_id'
+ filename: 'a.png'
+ contentType: 'image/png'
+ size: 10
+file_not_downloaded = new File
+ id: 'file_2_id'
+ filename: 'b.png'
+ contentType: 'image/png'
+ size: 10
+file_inline = new File
+ id: 'file_inline_id'
+ filename: 'c.png'
+ contentId: 'file_inline_id'
+ contentType: 'image/png'
+ size: 10
+file_inline_downloading = new File
+ id: 'file_inline_downloading_id'
+ filename: 'd.png'
+ contentId: 'file_inline_downloading_id'
+ contentType: 'image/png'
+ size: 10
+file_inline_not_downloaded = new File
+ id: 'file_inline_not_downloaded_id'
+ filename: 'e.png'
+ contentId: 'file_inline_not_downloaded_id'
+ contentType: 'image/png'
+ size: 10
+file_cid_but_not_referenced = new File
+ id: 'file_cid_but_not_referenced'
+ filename: 'f.png'
+ contentId: 'file_cid_but_not_referenced'
+ contentType: 'image/png'
+ size: 10
+file_cid_but_not_referenced_or_image = new File
+ id: 'file_cid_but_not_referenced_or_image'
+ filename: 'ansible notes.txt'
+ contentId: 'file_cid_but_not_referenced_or_image'
+ contentType: 'text/plain'
+ size: 300
+file_without_filename = new File
+ id: 'file_without_filename'
+ contentType: 'image/png'
+ size: 10
+
+download =
+ fileId: 'file_1_id'
+download_inline =
+ fileId: 'file_inline_downloading_id'
+
+user_1 = new Contact
+ name: "User One"
+ email: "user1@nylas.com"
+user_2 = new Contact
+ name: "User Two"
+ email: "user2@nylas.com"
+user_3 = new Contact
+ name: "User Three"
+ email: "user3@nylas.com"
+user_4 = new Contact
+ name: "User Four"
+ email: "user4@nylas.com"
+
+MessageItemBody = proxyquire '../lib/message-item-body',
+ './email-frame': {default: EmailFrameStub}
+
+
+xdescribe "MessageItem", ->
+ beforeEach ->
+ spyOn(FileDownloadStore, 'pathForFile').andCallFake (f) ->
+ return '/fake/path.png' if f.id is file.id
+ return '/fake/path-inline.png' if f.id is file_inline.id
+ return '/fake/path-downloading.png' if f.id is file_inline_downloading.id
+ return null
+ spyOn(MessageBodyProcessor, '_addToCache').andCallFake ->
+
+ @downloads =
+ 'file_1_id': download,
+ 'file_inline_downloading_id': download_inline
+
+ @message = new Message
+ id: "111"
+ from: [user_1]
+ to: [user_2]
+ cc: [user_3, user_4]
+ bcc: null
+ body: "Body One"
+ date: new Date(1415814587)
+ draft: false
+ files: []
+ unread: false
+ snippet: "snippet one..."
+ subject: "Subject One"
+ threadId: "thread_12345"
+ accountId: window.TEST_ACCOUNT_ID
+
+ # Generate the test component. Should be called after @message is configured
+ # for the test, since MessageItem assumes attributes of the message will not
+ # change after getInitialState runs.
+ @createComponent = ({collapsed} = {}) =>
+ collapsed ?= false
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+ advanceClock()
+
+ describe "when the message contains attachments", ->
+ beforeEach ->
+ @message.files = [
+ file,
+ file_not_downloaded,
+ file_cid_but_not_referenced,
+ file_cid_but_not_referenced_or_image,
+
+ file_inline,
+ file_inline_downloading,
+ file_inline_not_downloaded,
+ file_without_filename
+ ]
+
+ describe "inline", ->
+ beforeEach ->
+ @message.body = """
+
+
+
+
+ Hello world!
+ """
+ @createComponent()
+ waitsFor =>
+ ReactTestUtils.scryRenderedComponentsWithType(@component, EmailFrameStub).length
+
+ it "should never leave src=cid: in the message body", ->
+ runs =>
+ body = ReactTestUtils.findRenderedComponentWithType(@component, EmailFrameStub).props.content
+ expect(body.indexOf('cid')).toEqual(-1)
+
+ it "should replace cid: with the FileDownloadStore's path for the file", ->
+ runs =>
+ body = ReactTestUtils.findRenderedComponentWithType(@component, EmailFrameStub).props.content
+ expect(body.indexOf('alt="A" src="file:///fake/path-inline.png"')).toEqual(@message.body.indexOf('alt="A"'))
+
+ it "should not replace cid: with the FileDownloadStore's path if the download is in progress", ->
+ runs =>
+ body = ReactTestUtils.findRenderedComponentWithType(@component, EmailFrameStub).props.content
+ expect(body.indexOf('/fake/path-downloading.png')).toEqual(-1)
+
+ describe "showQuotedText", ->
+ it "should be initialized to false", ->
+ @createComponent()
+ expect(@component.state.showQuotedText).toBe(false)
+
+ it "shouldn't render the quoted text control if there's no quoted text", ->
+ @message.body = "no quotes here!"
+ @createComponent()
+ toggles = ReactTestUtils.scryRenderedDOMComponentsWithClass(@component, 'quoted-text-control')
+ expect(toggles.length).toBe 0
+
+ describe 'quoted text control toggle button', ->
+ beforeEach ->
+ @message.body = """
+ Message
+
+ Quoted message
+
+ """
+ @createComponent()
+ @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control')
+
+ it 'should be rendered', ->
+ expect(@toggle).toBeDefined()
+
+ it "should be initialized to true if the message contains `Forwarded`...", ->
+ @message.body = """
+ Hi guys, take a look at this. Very relevant. -mg
+
+
+
+ ---- Forwarded Message -----
+ blablalba
+
+ """
+ @createComponent()
+ expect(@component.state.showQuotedText).toBe(true)
+
+ it "should be initialized to false if the message is a response to a Forwarded message", ->
+ @message.body = """
+ Thanks mg, that indeed looks very relevant. Will bring it up
+ with the rest of the team.
+
+ On Sunday, March 4th at 12:32AM, Michael Grinich Wrote:
+
+ Hi guys, take a look at this. Very relevant. -mg
+
+
+
+ ---- Forwarded Message -----
+ blablalba
+
+
+ """
+ @createComponent()
+ expect(@component.state.showQuotedText).toBe(false)
+
+ describe "when showQuotedText is true", ->
+ beforeEach ->
+ @message.body = """
+ Message
+
+ Quoted message
+
+ """
+ @createComponent()
+ @component.state.showQuotedText = true
+ waitsFor =>
+ ReactTestUtils.scryRenderedComponentsWithType(@component, EmailFrameStub).length
+
+ describe 'quoted text control toggle button', ->
+ beforeEach ->
+ @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control')
+
+ it 'should be rendered', ->
+ expect(@toggle).toBeDefined()
+
+ it "should pass the value into the EmailFrame", ->
+ runs =>
+ frame = ReactTestUtils.findRenderedComponentWithType(@component, EmailFrameStub)
+ expect(frame.props.showQuotedText).toBe(true)
diff --git a/packages/client-app/internal_packages/message-list/spec/message-item-container-spec.cjsx b/packages/client-app/internal_packages/message-list/spec/message-item-container-spec.cjsx
new file mode 100644
index 0000000000..8badf1da59
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/message-item-container-spec.cjsx
@@ -0,0 +1,60 @@
+React = require "react"
+proxyquire = require("proxyquire").noPreserveCache()
+ReactTestUtils = require('react-addons-test-utils')
+
+{Thread,
+ Message,
+ ComponentRegistry,
+ DraftStore} = require 'nylas-exports'
+
+class StubMessageItem extends React.Component
+ @displayName: "StubMessageItem"
+ render: ->
+
+class StubComposer extends React.Component
+ @displayName: "StubComposer"
+ render: ->
+
+MessageItemContainer = proxyquire '../lib/message-item-container',
+ "./message-item": StubMessageItem
+
+testThread = new Thread(id: "t1", accountId: TEST_ACCOUNT_ID)
+testClientId = "local-id"
+testMessage = new Message(id: "m1", draft: false, unread: true, accountId: TEST_ACCOUNT_ID)
+testDraft = new Message(id: "d1", draft: true, unread: true, accountId: TEST_ACCOUNT_ID)
+
+xdescribe 'MessageItemContainer', ->
+
+ beforeEach ->
+ @isSendingDraft = false
+ spyOn(DraftStore, "isSendingDraft").andCallFake => @isSendingDraft
+ ComponentRegistry.register(StubComposer, role: 'Composer')
+
+ afterEach ->
+ ComponentRegistry.register(StubComposer, role: 'Composer')
+
+ renderContainer = (message) ->
+ ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "shows composer if it's a draft", ->
+ @isSendingDraft = false
+ doc = renderContainer(testDraft)
+ items = ReactTestUtils.scryRenderedComponentsWithType(doc, StubComposer)
+ expect(items.length).toBe 1
+
+ it "renders a message if it's a draft that is sending", ->
+ @isSendingDraft = true
+ doc = renderContainer(testDraft)
+ items = ReactTestUtils.scryRenderedComponentsWithType(doc, StubMessageItem)
+ expect(items.length).toBe 1
+ expect(items[0].props.pending).toBe true
+
+ it "renders a message if it's not a draft", ->
+ @isSendingDraft = false
+ doc = renderContainer(testMessage)
+ items = ReactTestUtils.scryRenderedComponentsWithType(doc, StubMessageItem)
+ expect(items.length).toBe 1
diff --git a/packages/client-app/internal_packages/message-list/spec/message-item-spec.cjsx b/packages/client-app/internal_packages/message-list/spec/message-item-spec.cjsx
new file mode 100644
index 0000000000..0cfca138e8
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/message-item-spec.cjsx
@@ -0,0 +1,223 @@
+proxyquire = require 'proxyquire'
+React = require "react"
+ReactDOM = require "react-dom"
+ReactTestUtils = require 'react-addons-test-utils'
+
+{Contact,
+ Message,
+ File,
+ Thread,
+ Utils,
+ QuotedHTMLTransformer,
+ FileDownloadStore,
+ MessageBodyProcessor} = require "nylas-exports"
+
+MessageItemBody = React.createClass({render: ->
})
+
+{InjectedComponent} = require 'nylas-component-kit'
+
+file = new File
+ id: 'file_1_id'
+ filename: 'a.png'
+ contentType: 'image/png'
+ size: 10
+file_not_downloaded = new File
+ id: 'file_2_id'
+ filename: 'b.png'
+ contentType: 'image/png'
+ size: 10
+file_inline = new File
+ id: 'file_inline_id'
+ filename: 'c.png'
+ contentId: 'file_inline_id'
+ contentType: 'image/png'
+ size: 10
+file_inline_downloading = new File
+ id: 'file_inline_downloading_id'
+ filename: 'd.png'
+ contentId: 'file_inline_downloading_id'
+ contentType: 'image/png'
+ size: 10
+file_inline_not_downloaded = new File
+ id: 'file_inline_not_downloaded_id'
+ filename: 'e.png'
+ contentId: 'file_inline_not_downloaded_id'
+ contentType: 'image/png'
+ size: 10
+file_cid_but_not_referenced = new File
+ id: 'file_cid_but_not_referenced'
+ filename: 'f.png'
+ contentId: 'file_cid_but_not_referenced'
+ contentType: 'image/png'
+ size: 10
+file_cid_but_not_referenced_or_image = new File
+ id: 'file_cid_but_not_referenced_or_image'
+ filename: 'ansible notes.txt'
+ contentId: 'file_cid_but_not_referenced_or_image'
+ contentType: 'text/plain'
+ size: 300
+file_without_filename = new File
+ id: 'file_without_filename'
+ contentType: 'image/png'
+ size: 10
+
+download =
+ fileId: 'file_1_id'
+download_inline =
+ fileId: 'file_inline_downloading_id'
+
+user_1 = new Contact
+ name: "User One"
+ email: "user1@nylas.com"
+user_2 = new Contact
+ name: "User Two"
+ email: "user2@nylas.com"
+user_3 = new Contact
+ name: "User Three"
+ email: "user3@nylas.com"
+user_4 = new Contact
+ name: "User Four"
+ email: "user4@nylas.com"
+user_5 = new Contact
+ name: "User Five"
+ email: "user5@nylas.com"
+
+
+MessageItem = proxyquire '../lib/message-item',
+ './message-item-body': MessageItemBody
+
+MessageTimestamp = require('../lib/message-timestamp').default
+
+
+xdescribe "MessageItem", ->
+ beforeEach ->
+ spyOn(FileDownloadStore, 'pathForFile').andCallFake (f) ->
+ return '/fake/path.png' if f.id is file.id
+ return '/fake/path-inline.png' if f.id is file_inline.id
+ return '/fake/path-downloading.png' if f.id is file_inline_downloading.id
+ return null
+ spyOn(FileDownloadStore, 'getDownloadDataForFiles').andCallFake (ids) ->
+ return {'file_1_id': download, 'file_inline_downloading_id': download_inline}
+
+ spyOn(MessageBodyProcessor, '_addToCache').andCallFake ->
+
+ @message = new Message
+ id: "111"
+ from: [user_1]
+ to: [user_2]
+ cc: [user_3, user_4]
+ bcc: null
+ body: "Body One"
+ date: new Date(1415814587)
+ draft: false
+ files: []
+ unread: false
+ snippet: "snippet one..."
+ subject: "Subject One"
+ threadId: "thread_12345"
+ accountId: TEST_ACCOUNT_ID
+
+ @thread = new Thread
+ id: 'thread-111'
+ accountId: TEST_ACCOUNT_ID
+
+ @threadParticipants = [user_1, user_2, user_3, user_4]
+
+ # Generate the test component. Should be called after @message is configured
+ # for the test, since MessageItem assumes attributes of the message will not
+ # change after getInitialState runs.
+ @createComponent = ({collapsed} = {}) =>
+ collapsed ?= false
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ # TODO: We currently don't support collapsed messages
+ # describe "when collapsed", ->
+ # beforeEach ->
+ # @createComponent({collapsed: true})
+ #
+ # it "should not render the EmailFrame", ->
+ # expect( -> ReactTestUtils.findRenderedComponentWithType(@component, EmailFrameStub)).toThrow()
+ #
+ # it "should have the `collapsed` class", ->
+ # expect(ReactDOM.findDOMNode(@component).className.indexOf('collapsed') >= 0).toBe(true)
+
+ describe "when displaying detailed headers", ->
+ beforeEach ->
+ @createComponent({collapsed: false})
+ @component.setState detailedHeaders: true
+
+ it "correctly sets the participant states", ->
+ participants = ReactTestUtils.scryRenderedDOMComponentsWithClass(@component, "expanded-participants")
+ expect(participants.length).toBe 2
+ expect(-> ReactTestUtils.findRenderedDOMComponentWithClass(@component, "collapsed-participants")).toThrow()
+
+ it "correctly sets the timestamp", ->
+ ts = ReactTestUtils.findRenderedComponentWithType(@component, MessageTimestamp)
+ expect(ts.props.isDetailed).toBe true
+
+ describe "when not collapsed", ->
+ beforeEach ->
+ @createComponent({collapsed: false})
+
+ it "should render the MessageItemBody", ->
+ frame = ReactTestUtils.findRenderedComponentWithType(@component, MessageItemBody)
+ expect(frame).toBeDefined()
+
+ it "should not have the `collapsed` class", ->
+ expect(ReactDOM.findDOMNode(@component).className.indexOf('collapsed') >= 0).toBe(false)
+
+ xdescribe "when the message contains attachments", ->
+ beforeEach ->
+ @message.files = [
+ file,
+ file_not_downloaded,
+ file_cid_but_not_referenced,
+ file_cid_but_not_referenced_or_image,
+
+ file_inline,
+ file_inline_downloading,
+ file_inline_not_downloaded,
+ file_without_filename
+ ]
+ @message.body = """
+
+
+
+
+ """
+ @createComponent()
+
+ it "should include the attachments area", ->
+ attachments = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'attachments-area')
+ expect(attachments).toBeDefined()
+
+ it 'injects a MessageAttachments component for any present attachments', ->
+ els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@component, InjectedComponent, matching: {role: "MessageAttachments"})
+ expect(els.length).toBe 1
+
+ it "should list attachments that are not mentioned in the body via cid", ->
+ els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@component, InjectedComponent, matching: {role: "MessageAttachments"})
+ attachments = els[0].props.exposedProps.files
+ expect(attachments.length).toEqual(5)
+ expect(attachments[0]).toBe(file)
+ expect(attachments[1]).toBe(file_not_downloaded)
+ expect(attachments[2]).toBe(file_cid_but_not_referenced)
+ expect(attachments[3]).toBe(file_cid_but_not_referenced_or_image)
+
+ it "should provide the correct file download state for each attachment", ->
+ els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@component, InjectedComponent, matching: {role: "MessageAttachments"})
+ {downloads} = els[0].props.exposedProps
+ expect(downloads['file_1_id']).toBe(download)
+ expect(downloads['file_not_downloaded']).toBe(undefined)
+
+ it "should still list attachments when the message has no body", ->
+ @message.body = ""
+ @createComponent()
+ els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@component, InjectedComponent, matching: {role: "MessageAttachments"})
+ attachments = els[0].props.exposedProps.files
+ expect(attachments.length).toEqual(8)
diff --git a/packages/client-app/internal_packages/message-list/spec/message-list-spec.cjsx b/packages/client-app/internal_packages/message-list/spec/message-list-spec.cjsx
new file mode 100644
index 0000000000..bd97b6f647
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/message-list-spec.cjsx
@@ -0,0 +1,406 @@
+_ = require "underscore"
+moment = require "moment"
+proxyquire = require("proxyquire").noPreserveCache()
+
+React = require "react"
+ReactDOM = require "react-dom"
+ReactTestUtils = require 'react-addons-test-utils'
+
+{Thread,
+ Contact,
+ Actions,
+ Message,
+ Account,
+ DraftStore,
+ MessageStore,
+ AccountStore,
+ NylasTestUtils,
+ ComponentRegistry} = require "nylas-exports"
+
+MessageParticipants = require "../lib/message-participants"
+MessageItemContainer = require "../lib/message-item-container"
+MessageList = require '../lib/message-list'
+
+# User_1 needs to be "me" so that when we calculate who we should reply
+# to, it properly matches the AccountStore
+user_1 = new Contact
+ name: TEST_ACCOUNT_NAME
+ email: TEST_ACCOUNT_EMAIL
+user_2 = new Contact
+ name: "User Two"
+ email: "user2@nylas.com"
+user_3 = new Contact
+ name: "User Three"
+ email: "user3@nylas.com"
+user_4 = new Contact
+ name: "User Four"
+ email: "user4@nylas.com"
+user_5 = new Contact
+ name: "User Five"
+ email: "user5@nylas.com"
+
+m1 = (new Message).fromJSON({
+ "id" : "111",
+ "from" : [ user_1 ],
+ "to" : [ user_2 ],
+ "cc" : [ user_3, user_4 ],
+ "bcc" : null,
+ "body" : "Body One",
+ "date" : 1415814587,
+ "draft" : false
+ "files" : [],
+ "unread" : false,
+ "object" : "message",
+ "snippet" : "snippet one...",
+ "subject" : "Subject One",
+ "thread_id" : "thread_12345",
+ "account_id" : TEST_ACCOUNT_ID
+})
+m2 = (new Message).fromJSON({
+ "id" : "222",
+ "from" : [ user_2 ],
+ "to" : [ user_1 ],
+ "cc" : [ user_3, user_4 ],
+ "bcc" : null,
+ "body" : "Body Two",
+ "date" : 1415814587,
+ "draft" : false
+ "files" : [],
+ "unread" : false,
+ "object" : "message",
+ "snippet" : "snippet Two...",
+ "subject" : "Subject Two",
+ "thread_id" : "thread_12345",
+ "account_id" : TEST_ACCOUNT_ID
+})
+m3 = (new Message).fromJSON({
+ "id" : "333",
+ "from" : [ user_3 ],
+ "to" : [ user_1 ],
+ "cc" : [ user_2, user_4 ],
+ "bcc" : [],
+ "body" : "Body Three",
+ "date" : 1415814587,
+ "draft" : false
+ "files" : [],
+ "unread" : false,
+ "object" : "message",
+ "snippet" : "snippet Three...",
+ "subject" : "Subject Three",
+ "thread_id" : "thread_12345",
+ "account_id" : TEST_ACCOUNT_ID
+})
+m4 = (new Message).fromJSON({
+ "id" : "444",
+ "from" : [ user_4 ],
+ "to" : [ user_1 ],
+ "cc" : [],
+ "bcc" : [ user_5 ],
+ "body" : "Body Four",
+ "date" : 1415814587,
+ "draft" : false
+ "files" : [],
+ "unread" : false,
+ "object" : "message",
+ "snippet" : "snippet Four...",
+ "subject" : "Subject Four",
+ "thread_id" : "thread_12345",
+ "account_id" : TEST_ACCOUNT_ID
+})
+m5 = (new Message).fromJSON({
+ "id" : "555",
+ "from" : [ user_1 ],
+ "to" : [ user_4 ],
+ "cc" : [],
+ "bcc" : [],
+ "body" : "Body Five",
+ "date" : 1415814587,
+ "draft" : false
+ "files" : [],
+ "unread" : false,
+ "object" : "message",
+ "snippet" : "snippet Five...",
+ "subject" : "Subject Five",
+ "thread_id" : "thread_12345",
+ "account_id" : TEST_ACCOUNT_ID
+})
+testMessages = [m1, m2, m3, m4, m5]
+draftMessages = [
+ (new Message).fromJSON({
+ "id" : "666",
+ "from" : [ user_1 ],
+ "to" : [ ],
+ "cc" : [ ],
+ "bcc" : null,
+ "body" : "Body One",
+ "date" : 1415814587,
+ "draft" : true
+ "files" : [],
+ "unread" : false,
+ "object" : "draft",
+ "snippet" : "draft snippet one...",
+ "subject" : "Draft One",
+ "thread_id" : "thread_12345",
+ "account_id" : TEST_ACCOUNT_ID
+ }),
+]
+
+test_thread = (new Thread).fromJSON({
+ "id": "12345"
+ "id" : "thread_12345"
+ "subject" : "Subject 12345",
+ "account_id" : TEST_ACCOUNT_ID
+})
+
+describe "MessageList", ->
+ beforeEach ->
+ MessageStore._items = []
+ MessageStore._threadId = null
+ spyOn(MessageStore, "itemsLoading").andCallFake ->
+ false
+
+ @messageList = ReactTestUtils.renderIntoDocument( )
+ @messageList_node = ReactDOM.findDOMNode(@messageList)
+
+ it "renders into the document", ->
+ expect(ReactTestUtils.isCompositeComponentWithType(@messageList,
+ MessageList)).toBe true
+
+ it "by default has zero children", ->
+ items = ReactTestUtils.scryRenderedComponentsWithType(@messageList,
+ MessageItemContainer)
+
+ expect(items.length).toBe 0
+
+ describe "Populated Message list", ->
+ beforeEach ->
+ MessageStore._items = testMessages
+ MessageStore._expandItemsToDefault()
+ MessageStore.trigger(MessageStore)
+ @messageList.setState(currentThread: test_thread)
+ NylasTestUtils.loadKeymap("keymaps/base")
+
+ it "renders all the correct number of messages", ->
+ items = ReactTestUtils.scryRenderedComponentsWithType(@messageList,
+ MessageItemContainer)
+ expect(items.length).toBe 5
+
+ it "renders the correct number of expanded messages", ->
+ msgs = ReactTestUtils.scryRenderedDOMComponentsWithClass(@messageList, "collapsed message-item-wrap")
+ expect(msgs.length).toBe 4
+
+ it "displays lists of participants on the page", ->
+ items = ReactTestUtils.scryRenderedComponentsWithType(@messageList,
+ MessageParticipants)
+ expect(items.length).toBe 2
+
+ it "includes drafts as message item containers", ->
+ msgs = @messageList.state.messages
+ @messageList.setState
+ messages: msgs.concat(draftMessages)
+ items = ReactTestUtils.scryRenderedComponentsWithType(@messageList,
+ MessageItemContainer)
+ expect(items.length).toBe 6
+
+ describe "reply type", ->
+ it "prompts for a reply when there's only one participant", ->
+ MessageStore._items = [m3, m5]
+ MessageStore._thread = test_thread
+ MessageStore.trigger()
+ expect(@messageList._replyType()).toBe "reply"
+ cs = ReactTestUtils.scryRenderedDOMComponentsWithClass(@messageList, "footer-reply-area")
+ expect(cs.length).toBe 1
+
+ it "prompts for a reply-all when there's more than one participant and the default is reply-all", ->
+ spyOn(NylasEnv.config, "get").andReturn "reply-all"
+ MessageStore._items = [m5, m3]
+ MessageStore._thread = test_thread
+ MessageStore.trigger()
+ expect(@messageList._replyType()).toBe "reply-all"
+ cs = ReactTestUtils.scryRenderedDOMComponentsWithClass(@messageList, "footer-reply-area")
+ expect(cs.length).toBe 1
+
+ it "prompts for a reply-all when there's more than one participant and the default is reply", ->
+ spyOn(NylasEnv.config, "get").andReturn "reply"
+ MessageStore._items = [m5, m3]
+ MessageStore._thread = test_thread
+ MessageStore.trigger()
+ expect(@messageList._replyType()).toBe "reply"
+ cs = ReactTestUtils.scryRenderedDOMComponentsWithClass(@messageList, "footer-reply-area")
+ expect(cs.length).toBe 1
+
+ it "hides the reply type if the last message is a draft", ->
+ MessageStore._items = [m5, m3, draftMessages[0]]
+ MessageStore._thread = test_thread
+ MessageStore.trigger()
+ cs = ReactTestUtils.scryRenderedDOMComponentsWithClass(@messageList, "footer-reply-area")
+ expect(cs.length).toBe 0
+
+ describe "Message minification", ->
+ beforeEach ->
+ @messageList.MINIFY_THRESHOLD = 3
+ @messageList.setState minified: true
+ @messages = [
+ {id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}, {id: 'f'}, {id: 'g'}
+ ]
+
+ it "ignores the first message if it's collapsed", ->
+ @messageList.setState messagesExpandedState:
+ a: false, b: false, c: false, d: false, e: false, f: false, g: "default"
+
+ out = @messageList._messagesWithMinification(@messages)
+ expect(out).toEqual [
+ {id: 'a'},
+ {
+ type: "minifiedBundle"
+ messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}]
+ },
+ {id: 'f'},
+ {id: 'g'}
+ ]
+
+ it "ignores the first message if it's expanded", ->
+ @messageList.setState messagesExpandedState:
+ a: "default", b: false, c: false, d: false, e: false, f: false, g: "default"
+
+ out = @messageList._messagesWithMinification(@messages)
+ expect(out).toEqual [
+ {id: 'a'},
+ {
+ type: "minifiedBundle"
+ messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}]
+ },
+ {id: 'f'},
+ {id: 'g'}
+ ]
+
+ it "doesn't minify the last collapsed message", ->
+ @messageList.setState messagesExpandedState:
+ a: false, b: false, c: false, d: false, e: false, f: "default", g: "default"
+
+ out = @messageList._messagesWithMinification(@messages)
+ expect(out).toEqual [
+ {id: 'a'},
+ {
+ type: "minifiedBundle"
+ messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}]
+ },
+ {id: 'e'},
+ {id: 'f'},
+ {id: 'g'}
+ ]
+
+ it "allows explicitly expanded messages", ->
+ @messageList.setState messagesExpandedState:
+ a: false, b: false, c: false, d: false, e: false, f: "explicit", g: "default"
+
+ out = @messageList._messagesWithMinification(@messages)
+ expect(out).toEqual [
+ {id: 'a'},
+ {
+ type: "minifiedBundle"
+ messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}]
+ },
+ {id: 'f'},
+ {id: 'g'}
+ ]
+
+ it "doesn't minify if the threshold isn't reached", ->
+ @messageList.setState messagesExpandedState:
+ a: false, b: "default", c: false, d: "default", e: false, f: "default", g: "default"
+
+ out = @messageList._messagesWithMinification(@messages)
+ expect(out).toEqual [
+ {id: 'a'},
+ {id: 'b'},
+ {id: 'c'},
+ {id: 'd'},
+ {id: 'e'},
+ {id: 'f'},
+ {id: 'g'}
+ ]
+
+ it "doesn't minify if the threshold isn't reached due to the rule about not minifying the last collapsed messages", ->
+ @messageList.setState messagesExpandedState:
+ a: false, b: false, c: false, d: false, e: "default", f: "default", g: "default"
+
+ out = @messageList._messagesWithMinification(@messages)
+ expect(out).toEqual [
+ {id: 'a'},
+ {id: 'b'},
+ {id: 'c'},
+ {id: 'd'},
+ {id: 'e'},
+ {id: 'f'},
+ {id: 'g'}
+ ]
+
+ it "minifies at the threshold if the message is explicitly expanded", ->
+ @messageList.setState messagesExpandedState:
+ a: false, b: false, c: false, d: false, e: "explicit", f: "default", g: "default"
+
+ out = @messageList._messagesWithMinification(@messages)
+ expect(out).toEqual [
+ {id: 'a'},
+ {
+ type: "minifiedBundle"
+ messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}]
+ },
+ {id: 'e'},
+ {id: 'f'},
+ {id: 'g'}
+ ]
+
+ it "can have multiple minification blocks", ->
+ messages = [
+ {id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}, {id: 'f'},
+ {id: 'g'}, {id: 'h'}, {id: 'i'}, {id: 'j'}, {id: 'k'}, {id: 'l'}
+ ]
+
+ @messageList.setState messagesExpandedState:
+ a: false, b: false, c: false, d: false, e: false, f: "default",
+ g: false, h: false, i: false, j: false, k: false, l: "default"
+
+ out = @messageList._messagesWithMinification(messages)
+ expect(out).toEqual [
+ {id: 'a'},
+ {
+ type: "minifiedBundle"
+ messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}]
+ },
+ {id: 'e'},
+ {id: 'f'},
+ {
+ type: "minifiedBundle"
+ messages: [{id: 'g'}, {id: 'h'}, {id: 'i'}, {id: 'j'}]
+ },
+ {id: 'k'},
+ {id: 'l'}
+ ]
+
+ it "can have multiple minification blocks next to explicitly expanded messages", ->
+ messages = [
+ {id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}, {id: 'f'},
+ {id: 'g'}, {id: 'h'}, {id: 'i'}, {id: 'j'}, {id: 'k'}, {id: 'l'}
+ ]
+
+ @messageList.setState messagesExpandedState:
+ a: false, b: false, c: false, d: false, e: "explicit", f: "default",
+ g: false, h: false, i: false, j: false, k: "explicit", l: "default"
+
+ out = @messageList._messagesWithMinification(messages)
+ expect(out).toEqual [
+ {id: 'a'},
+ {
+ type: "minifiedBundle"
+ messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}]
+ },
+ {id: 'e'},
+ {id: 'f'},
+ {
+ type: "minifiedBundle"
+ messages: [{id: 'g'}, {id: 'h'}, {id: 'i'}, {id: 'j'}]
+ },
+ {id: 'k'},
+ {id: 'l'}
+ ]
diff --git a/packages/client-app/internal_packages/message-list/spec/message-participants-spec.cjsx b/packages/client-app/internal_packages/message-list/spec/message-participants-spec.cjsx
new file mode 100644
index 0000000000..f92d03c18c
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/message-participants-spec.cjsx
@@ -0,0 +1,125 @@
+_ = require 'underscore'
+React = require "react"
+ReactDOM = require "react-dom"
+ReactTestUtils = require 'react-addons-test-utils'
+{Contact, Message, DOMUtils} = require "nylas-exports"
+MessageParticipants = require "../lib/message-participants"
+
+user_1 =
+ name: "User One"
+ email: "user1@nylas.com"
+user_2 =
+ name: "User Two"
+ email: "user2@nylas.com"
+user_3 =
+ name: "User Three"
+ email: "user3@nylas.com"
+user_4 =
+ name: "User Four"
+ email: "user4@nylas.com"
+user_5 =
+ name: "User Five"
+ email: "user5@nylas.com"
+
+many_users = (new Contact({name: "User #{i}", email:"#{i}@app.com"}) for i in [0..100])
+
+test_message = (new Message).fromJSON({
+ "id" : "111",
+ "from" : [ user_1 ],
+ "to" : [ user_2 ],
+ "cc" : [ user_3, user_4 ],
+ "bcc" : [ user_5 ]
+})
+
+big_test_message = (new Message).fromJSON({
+ "id" : "222",
+ "from" : [ user_1 ],
+ "to" : many_users
+})
+
+many_thread_users = [user_1].concat(many_users)
+
+describe "MessageParticipants", ->
+ describe "when collapsed", ->
+ makeParticipants = (props) ->
+ ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "renders into the document", ->
+ participants = makeParticipants(to: test_message.to, cc: test_message.cc,
+ from: test_message.from, message_participants: test_message.participants())
+ expect(participants).toBeDefined()
+
+ it "uses short names", ->
+ actualOut = makeParticipants(to: test_message.to)
+ to = ReactTestUtils.findRenderedDOMComponentWithClass(actualOut, "to-contact")
+ expect(ReactDOM.findDOMNode(to).innerHTML).toBe "User"
+
+ it "doesn't render any To nodes if To array is empty", ->
+ actualOut = makeParticipants(to: [])
+ findToField = ->
+ ReactTestUtils.findRenderedDOMComponentWithClass(actualOut, "to-contact")
+ expect(findToField).toThrow()
+
+ it "doesn't render any Cc nodes if Cc array is empty", ->
+ actualOut = makeParticipants(cc: [])
+ findCcField = ->
+ ReactTestUtils.findRenderedDOMComponentWithClass(actualOut, "cc-contact")
+ expect(findCcField).toThrow()
+
+ it "doesn't render any Bcc nodes if Bcc array is empty", ->
+ actualOut = makeParticipants(bcc: [])
+ findBccField = ->
+ ReactTestUtils.findRenderedDOMComponentWithClass(actualOut, "bcc-contact")
+ expect(findBccField).toThrow()
+
+ describe "when expanded", ->
+ beforeEach ->
+ @participants = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "renders into the document", ->
+ participants = ReactTestUtils.findRenderedDOMComponentWithClass(@participants, "expanded-participants")
+ expect(participants).toBeDefined()
+
+ it "uses full names", ->
+ to = ReactTestUtils.findRenderedDOMComponentWithClass(@participants, "to-contact")
+ expect(ReactDOM.findDOMNode(to).innerText.trim()).toEqual "User Two "
+
+
+ # TODO: We no longer display "to everyone"
+ #
+ # it "determines the message is to everyone", ->
+ # p1 = TestUtils.renderIntoDocument(
+ #
+ # )
+ # expect(p1._isToEveryone()).toBe true
+ #
+ # it "knows when the message isn't to everyone due to participant mismatch", ->
+ # p2 = TestUtils.renderIntoDocument(
+ #
+ # )
+ # # this should be false because we don't count bccs
+ # expect(p2._isToEveryone()).toBe false
+ #
+ # it "knows when the message isn't to everyone due to participant size", ->
+ # p2 = TestUtils.renderIntoDocument(
+ #
+ # )
+ # # this should be false because we don't count bccs
+ # expect(p2._isToEveryone()).toBe false
diff --git a/packages/client-app/internal_packages/message-list/spec/message-timestamp-spec.cjsx b/packages/client-app/internal_packages/message-list/spec/message-timestamp-spec.cjsx
new file mode 100644
index 0000000000..8e3b6213d1
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/spec/message-timestamp-spec.cjsx
@@ -0,0 +1,20 @@
+moment = require 'moment'
+React = require "react"
+ReactDOM = require "react-dom"
+ReactTestUtils = require 'react-addons-test-utils'
+MessageTimestamp = require('../lib/message-timestamp').default
+
+msgTime = ->
+ moment([2010, 1, 14, 15, 25, 50, 125]) # Feb 14, 2010 at 3:25 PM
+
+describe "MessageTimestamp", ->
+ beforeEach ->
+ @item = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "still processes one day, even if it crosses a month divider", ->
+ # this should be tested in moment.js, but we add a test here for our own sanity too
+ feb28 = moment([2015, 1, 28])
+ mar01 = moment([2015, 2, 1])
+ expect(mar01.diff(feb28, 'days')).toBe 1
diff --git a/packages/client-app/internal_packages/message-list/stylesheets/find-in-thread.less b/packages/client-app/internal_packages/message-list/stylesheets/find-in-thread.less
new file mode 100644
index 0000000000..071363b87c
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/stylesheets/find-in-thread.less
@@ -0,0 +1,58 @@
+@import 'ui-variables';
+
+body.platform-win32 {
+ .find-in-thread {
+ }
+}
+
+.find-in-thread {
+ background: @background-secondary;
+ text-align: right;
+ overflow: hidden;
+
+ height: 0;
+ padding: 0 8px;
+ transition: all 125ms ease-in-out;
+ border-bottom: 0;
+ &.enabled {
+ padding: 4px 8px;
+ height: 35px;
+ border-bottom: 1px solid @border-color-secondary;
+ }
+
+ .controls-wrap {
+ display: inline-block;
+ }
+
+ .selection-progress {
+ color: @text-color-very-subtle;
+ position: absolute;
+ top: 4px;
+ right: 54px;
+ font-size: 12px;
+ }
+
+ .btn.btn-find-in-thread {
+ border: 0;
+ box-shadow: 0 0 0;
+ border-radius: 0;
+ background: transparent;
+ display: inline-block;
+ }
+ .input-wrap {
+ display: inline-block;
+ position: relative;
+ input {
+ height: 26px;
+ width: 230px;
+ padding-left: 8px;
+ font-size: 12px;
+ }
+ .btn-wrap {
+ width: 54px;
+ position: absolute;
+ top: 0;
+ right: 0;
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/message-list/stylesheets/message-list.less b/packages/client-app/internal_packages/message-list/stylesheets/message-list.less
new file mode 100644
index 0000000000..0cce774f59
--- /dev/null
+++ b/packages/client-app/internal_packages/message-list/stylesheets/message-list.less
@@ -0,0 +1,748 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+@message-max-width: 800px;
+@message-spacing: 6px;
+
+.tag-picker {
+ .menu {
+ .content-container {
+ height:250px;
+ overflow-y:scroll;
+ }
+ }
+}
+
+body.platform-win32 {
+ .sheet-toolbar {
+ .message-toolbar-arrow.down {
+ margin: 0 0 0 1px;
+ padding: 0 5px;
+ .windows-btn-bg;
+ &:hover {
+ background: #e5e5e5;
+ }
+ &.btn-icon:hover {
+ color: @text-color;
+ img.content-mask { background: rgba(35, 31, 32, 0.8); }
+ }
+ }
+ .message-toolbar-arrow.up {
+ margin: 0 0 0 1px;
+ padding: 0 5px;
+ .windows-btn-bg;
+ &.btn-icon:hover {
+ color: @text-color;
+ img.content-mask { background: rgba(35, 31, 32, 0.8); }
+ }
+ }
+ .message-toolbar-arrow.disabled {
+ &:hover {
+ background: transparent;
+ }
+ }
+ }
+
+ #message-list {
+ .message-item-wrap {
+ .message-item-white-wrap {
+ border-radius: 0;
+ }
+ }
+ .minified-bundle {
+ .num-messages {
+ border-radius: 0;
+ }
+ .msg-line {
+ border-radius: 0;
+ }
+ }
+ .footer-reply-area-wrap {
+ border-radius: 0;
+ }
+ }
+
+ .sidebar-section {
+ border-radius: 0;
+ }
+}
+
+.sheet-toolbar {
+ // This class wraps the items that appear above the message list in the
+ // toolbar. We want the toolbar items to sit right above the centered
+ // content, so we need another 800px-wide container in the toolbar...
+ .message-toolbar-items {
+ order: 200;
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+
+ .message-toolbar-arrow.down {
+ order:201;
+ margin-right: 0;
+ margin-left: @spacing-standard * 1.5;
+ }
+ .message-toolbar-arrow.up {
+ order:202;
+ // <1 because of hit region padding on the button
+ margin-right: @spacing-standard * 0.75;
+ }
+ .message-toolbar-arrow.disabled {
+ opacity: 0.3;
+ }
+}
+
+.mode-split {
+ .message-nav-title {
+ display: none;
+ }
+}
+
+.hide-sidebar-button {
+ font-size: @font-size-small;
+ color: @text-color-subtle;
+ margin-left: @spacing-standard;
+ cursor:default;
+ -webkit-user-select: none;
+ .img-wrap {
+ margin-right: @spacing-half;
+ position: relative;
+ top: -1px;
+ }
+ img { background: @text-color-subtle; }
+}
+
+#message-list.height-fix {
+ height: calc(~"100% - 35px");
+ min-height: calc(~"100% - 35px");
+}
+
+#message-list {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ background: @background-secondary;
+
+ transition: all 125ms ease-in-out;
+ width: 100%;
+ height: 100%;
+ min-height: 100%;
+ padding: 0;
+ order: 2;
+
+ search-match, .search-match {
+ background: @text-color-search-match;
+ border-radius: @border-radius-base;
+ box-shadow: 0 0.5px 0.5px rgba(0,0,0,0.25);
+ &.current-match {
+ background: @text-color-search-current-match;
+ }
+ }
+
+ .show-hidden-messages {
+ background-color: darken(@background-secondary, 4%);
+ border: 1px solid darken(@background-secondary, 8%);
+ border-radius: @border-radius-base;
+ color: @text-color-very-subtle;
+ margin-bottom: @padding-large-vertical;
+ cursor: default;
+ padding: @padding-base-vertical @padding-base-horizontal;
+ a { float: right; }
+ }
+
+ .message-body-error {
+ background-color: @background-secondary;
+ border: 1px solid darken(@background-secondary, 8%);
+ color: @text-color-very-subtle;
+ margin-top: @padding-large-vertical;
+ cursor: default;
+ padding: @padding-base-vertical @padding-base-horizontal;
+ a { float: right; }
+ }
+
+ .message-body-loading {
+ height: 1em;
+ align-content: center;
+ margin-top: @padding-large-vertical;
+ margin-bottom: @padding-large-vertical;
+ }
+
+ .message-subject-wrap {
+ max-width: @message-max-width;
+ margin: 5px auto 10px auto;
+ -webkit-user-select: text;
+ line-height: @font-size-large * 1.8;
+ display: flex;
+ align-items: center;
+ padding: 0 @padding-base-horizontal;
+ }
+ .mail-important-icon {
+ margin-right:@spacing-half;
+ margin-bottom:1px;
+ flex-shrink: 0;
+ }
+ .message-subject {
+ font-size: @font-size-large;
+ color: @text-color;
+ margin-right: @spacing-standard;
+ }
+ .message-icons-wrap {
+ flex-shrink: 0;
+ cursor: pointer;
+ -webkit-user-select: none;
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+
+ img {
+ background: @text-color-subtle;
+ }
+ div + div {
+ margin-left: @padding-small-horizontal;
+ }
+ }
+ .thread-injected-mail-labels {
+ vertical-align: top;
+ }
+ .message-list-headers {
+ margin: 0 auto;
+ width: 100%;
+ max-width: @message-max-width;
+ display:block;
+
+ .participants {
+ .contact-chip {
+ display:inline-block;
+ }
+ }
+ }
+
+ .messages-wrap {
+ flex: 1;
+ opacity:0;
+ transition: opacity 0s;
+
+ &.ready {
+ opacity:1;
+ transition: opacity .1s linear;
+ }
+
+ .scroll-region-content-inner {
+ padding: 6px;
+ }
+ }
+
+ .minified-bundle + .message-item-wrap {
+ margin-top: -5px;
+ }
+
+ .message-item-wrap {
+ transition: height 0.1s;
+ position: relative;
+ max-width: @message-max-width;
+ margin: 0 auto;
+
+ .message-item-white-wrap {
+ background: @background-primary;
+ border: 0;
+ box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.28), 0 1px 1.5px rgba(0, 0, 0, 0.08);
+ border-radius: 4px;
+ }
+
+ padding-bottom: @message-spacing * 2;
+ &.before-reply-area { padding-bottom: 0; }
+
+ &.collapsed {
+ .message-item-white-wrap {
+ background-color: darken(@background-primary, 2%);
+ padding-top: 19px;
+ padding-bottom: 8px;
+ margin-bottom: 0;
+ }
+
+ &+.minified-bundle {
+ margin-top: -@message-spacing
+ }
+ }
+
+ &.collapsed .message-item-area {
+ padding-bottom: 10px;
+ display: flex;
+ flex-direction: row;
+ font-size: @font-size-small;
+
+ .collapsed-snippet {
+ flex: 1;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ cursor: default;
+ color: @text-color-very-subtle;
+ }
+
+ .collapsed-attachment {
+ width:15px;
+ height:15px;
+ background-size: 15px;
+ background-repeat: no-repeat;
+ background-position:center;
+ padding:12px;
+ margin-left: 0.5em;
+ background-image:url(../static/images/message-list/icon-attachment-@2x.png);
+ position: relative;
+ top: -2px;
+ }
+
+ .collapsed-from {
+ font-weight: @font-weight-semi-bold;
+ color: @text-color-very-subtle;
+ // min-width: 60px;
+ margin-right: 1em;
+ }
+
+ .collapsed-timestamp {
+ margin-left: 0.5em;
+ color: @text-color-very-subtle;
+ }
+ }
+
+ }
+
+
+ .message-item-divider {
+ border:0; // remove default hr border left, right
+ border-top: 2px solid @border-color-secondary;
+ height: 3px;
+ background: @background-secondary;
+ border-bottom: 1px solid @border-color-primary;
+ margin: 0;
+
+ &.collapsed {
+ height: 0;
+ border-bottom: 0;
+ }
+ }
+
+ .minified-bundle {
+ position: relative;
+ .num-messages {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-left: -80px;
+ margin-top: -15px;
+ border-radius: 15px;
+ border: 1px solid @border-color-divider;
+ width: 160px;
+ background: @background-primary;
+ text-align: center;
+ color: @text-color-very-subtle;
+ z-index: 2;
+ background: @background-primary;
+ &:hover {
+ cursor: default;
+ }
+ }
+ .msg-lines {
+ max-width: @message-max-width;
+ margin: 0 auto;
+ width: 100%;
+ margin-top: -13px;
+ }
+ .msg-line {
+ border-radius: 4px 4px 0 0;
+ position: relative;
+ border-top: 1px solid @border-color-divider;
+ background-color: darken(@background-primary, 2%);
+ box-shadow: 0 0.5px 0 rgba(0,0,0,0.1), 0 -0.5px 0 rgba(0,0,0,0.1), 0.5px 0 0 rgba(0,0,0,0.1), -0.5px 0 0 rgba(0,0,0,0.1);
+ }
+ }
+
+ .message-header {
+ position: relative;
+ font-size: @font-size-small;
+ padding-bottom: 0;
+ padding-top: 19px;
+
+ &.pending {
+ .message-actions-wrap {
+ width: 0;
+ opacity: 0;
+ position: absolute;
+ }
+ .pending-spinner {
+ opacity: 1;
+ }
+ }
+
+ .pending-spinner {
+ transition: opacity 100ms;
+ transition-delay: 50ms, 0ms;
+ transition-timing-function: ease-in;
+ opacity: 0;
+ }
+
+ .header-row {
+ margin-top: 0.5em;
+ color: @text-color-very-subtle;
+
+ .header-label {
+ float: left;
+ display: block;
+ font-weight: @font-weight-normal;
+ margin-left: 0;
+ }
+
+ .header-name {
+ }
+ }
+
+ .message-actions-wrap {
+ transition: opacity 100ms, width 150ms;
+ transition-delay: 50ms, 0ms;
+ transition-timing-function: ease-in-out;
+ opacity: 1;
+ text-align: left;
+ }
+
+ .message-actions-ellipsis {
+ display: block;
+ float: left;
+ }
+
+ .message-actions {
+ display: inline-block;
+ height: 23px;
+ border: 1px solid lighten(@border-color-divider, 6%);
+ border-radius: 11px;
+
+ z-index: 4;
+ margin-top: 0.35em;
+ margin-left: 0.5em;
+ text-align: center;
+
+ .btn-icon {
+ opacity: 0.75;
+ padding: 0 @spacing-half;
+ height: 20px;
+ line-height: 10px;
+ border-radius: 0;
+ border-right: 1px solid lighten(@border-color-divider, 6%);
+ &:last-child { border-right: 0; }
+ margin: 0;
+ &:active {background: transparent;}
+ }
+ }
+
+ .message-time {
+ padding-top: 4px;
+ z-index: 2; position: relative;
+ display: inline-block;
+ min-width: 125px;
+ cursor: default;
+ }
+ .msg-actions-tooltip {
+ display: inline-block;
+ margin-left: 1em;
+ }
+
+ .message-time, .message-indicator {
+ color: @text-color-very-subtle;
+ }
+
+ .message-header-right {
+ z-index: 4;
+ position: relative;
+ top: -5px;
+ float: right;
+ text-align: right;
+ display: flex;
+ height: 2em;
+ }
+
+ }
+
+ .message-item-area {
+ width: 100%;
+ max-width: @message-max-width;
+ margin: 0 auto;
+ padding: 0 20px @spacing-standard 20px;
+
+ .iframe-container {
+ margin-top: 10px;
+ width: 100%;
+
+ iframe {
+ width: 100%;
+ border: 0;
+ padding: 0;
+ overflow: auto;
+ }
+ }
+ }
+
+ .collapse-region {
+ width: calc(~"100% - 30px");
+ height: 56px;
+ position: absolute;
+ top: 0;
+ }
+
+ .header-toggle-control {
+ &.inactive { display: none; }
+ z-index: 3;
+ position: absolute;
+ top: 0;
+ left: -1 * 13px;
+ img { background: @text-color-very-subtle; }
+ }
+ .message-item-wrap:hover {
+ .header-toggle-control.inactive { display: block; }
+ }
+
+ .footer-reply-area-wrap {
+ overflow: hidden;
+
+ max-width: @message-max-width;
+ margin: -3px auto 0 auto;
+
+ position: relative;
+ z-index: 2;
+
+ border: 0;
+ box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.28), 0 1px 1.5px rgba(0, 0, 0, 0.08);
+ border-top: 1px dashed @border-color-divider;
+ border-radius: 0 0 4px 4px;
+ background: @background-primary;
+
+ color: @text-color-very-subtle;
+ img.content-mask { background-color:@text-color-very-subtle; }
+
+ &:hover {
+ cursor: default;
+ }
+
+ .footer-reply-area {
+ width: 100%;
+ max-width: @message-max-width;
+ margin: 0 auto;
+ padding: 12px @spacing-standard * 1.5;
+ }
+ .reply-text {
+ display: inline-block;
+ vertical-align: middle;
+ margin-left: 0.5em;
+ }
+ }
+
+}
+
+.download-all {
+ @download-btn-color: fadeout(#929292, 20%);
+ @download-hover-color: fadeout(@component-active-color, 20%);
+
+ display: flex;
+ align-items: center;
+ color: @download-btn-color;
+ font-size: 0.9em;
+ cursor: default;
+ margin-top: @spacing-three-quarters;
+
+ .separator {
+ margin: 0 5px;
+ }
+
+ .attachment-number {
+ display: flex;
+ align-items: center;
+ }
+
+ img {
+ vertical-align: middle;
+ margin-right: @spacing-half;
+ background-color: @download-btn-color;
+ }
+
+ .download-all-action:hover {
+ color: @download-hover-color;
+ img {
+ background-color: @download-hover-color;
+ }
+ }
+}
+
+.attachments-area {
+ padding-top: @spacing-half + 2;
+
+ // attachments are padded on both sides so that things like the remove "X" can
+ // overhang them. To make the attachments line up with the body, we need to outdent
+ margin-left: -@spacing-standard;
+ margin-right: -@spacing-standard;
+
+ cursor:default;
+}
+
+
+///////////////////////////////
+// message-participants.cjsx //
+///////////////////////////////
+.pending {
+ .message-participants {
+ padding-left: 34px;
+ }
+}
+.message-participants {
+ z-index: 1;
+ display: flex;
+ transition: padding-left 150ms;
+ transition-timing-function: ease-in-out;
+
+ &.collapsed:hover {cursor: default;}
+
+ .from-contact {
+ font-weight: @headings-font-weight;
+ color: @text-color;
+ }
+ .from-label, .to-label, .cc-label, .bcc-label {
+ color: @text-color-very-subtle;
+ }
+ .to-contact, .cc-contact, .bcc-contact, .to-everyone {
+ color: @text-color-very-subtle;
+ }
+
+ &.to-participants {
+ width: 100%;
+
+ .collapsed-participants {
+ width: 100%;
+ margin-top: -6px;
+ }
+ }
+
+ .collapsed-participants {
+ display: flex;
+ align-items: center;
+
+ .to-contact {
+ display: inline-block;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+
+ .expanded-participants {
+ padding-right: 1.2em;
+ width: 100%;
+
+ .participant {
+ display: inline-block;
+ margin-right: 0.25em;
+ }
+
+ .participant-type {
+ margin-top: 0.5em;
+ &:first-child {margin-top: 0;}
+ }
+
+ .from-label, .to-label, .cc-label, .bcc-label {
+ float: left;
+ display: block;
+ text-transform: capitalize;
+ font-weight: @font-weight-normal;
+ margin-left: 0;
+ }
+
+ .from-contact, .subject {
+ font-weight: @font-weight-semi-bold;
+ }
+
+ // .from-label { margin-right: 1em; }
+ .to-label, .cc-label { margin-right: 0.5em; }
+ .bcc-label { margin-right: 0; }
+
+ .participant-primary {
+ color: @text-color-very-subtle;
+ margin-right: 0.15em;
+ display:inline-block;
+ }
+ .participant-secondary {
+ color: @text-color-very-subtle;
+ display:inline-block;
+ }
+
+ .from-contact {
+ .participant-primary {
+ color: @text-color;
+ }
+ .participant-secondary {
+ color: @text-color;
+ }
+ }
+ }
+}
+
+///////////////////////////////
+// sidebar-contact-card.cjsx //
+///////////////////////////////
+.sidebar-section {
+ opacity: 0;
+ margin: 5px;
+ cursor: default;
+ border: 1px solid @border-color-primary;
+ border-radius: @border-radius-large;
+ background: @background-primary;
+ padding: 15px;
+
+ &.visible {
+ transition: opacity 0.1s ease-out;
+ opacity: 1;
+ }
+
+ h2 {
+ font-size: 11px;
+ font-weight: @font-weight-semi-bold;
+ text-transform: uppercase;
+ color: @text-color-very-subtle;
+ margin: 0 0 18px 0;
+ position: relative;
+
+ &:after {
+ content: " ";
+ background-image: url(images/sidebar/sidebar-section-divider@2x.png);
+ background-size: 100%;
+ background-repeat: repeat-x;
+ background-color: transparent;
+ position: absolute;
+ left: -15px;
+ bottom: -10px;
+ width: calc(~"100% + 30px");
+ height: 3px;
+ }
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ .sidebar-contact-card {
+ }
+}
+.sidebar-participant-picker {
+ padding: 10px 5px 20px 5px;
+ text-align: right;
+ select {
+ max-width: 100%;
+ width: 100%;
+ }
+}
+
+.column-MessageListSidebar {
+ background-color: @background-secondary;
+ overflow: auto;
+ border-left: 1px solid @border-color-divider;
+ color: @text-color-subtle;
+ .flexbox-handle-horizontal div {
+ border-right: 0;
+ width: 1px;
+ }
+}
diff --git a/packages/client-app/internal_packages/message-view-on-github/README.md b/packages/client-app/internal_packages/message-view-on-github/README.md
new file mode 100644
index 0000000000..9bd21cc0ac
--- /dev/null
+++ b/packages/client-app/internal_packages/message-view-on-github/README.md
@@ -0,0 +1,21 @@
+# View on GitHub
+
+The "View on GitHub" plugin adds a button to the toolbar above the message view.
+When you view a message from GitHub that contains a "View on GitHub" link,
+the button appears and makes it easy to jump to the issue / pull request / comment
+on GitHub.
+
+This example is a good starting point for plugins that want to create custom
+actions.
+
+#### Install this plugin
+
+1. Download and run N1
+
+2. From the menu, select `Developer > Install a Plugin Manually...`
+ The dialog will default to this examples directory. Just choose the
+ package to install it!
+
+ > When you install packages, they're moved to `~/.nylas-mail/packages`,
+ > and N1 runs `apm install` on the command line to fetch dependencies
+ > listed in the package's `package.json`
diff --git a/packages/client-app/internal_packages/message-view-on-github/assets/github@2x.png b/packages/client-app/internal_packages/message-view-on-github/assets/github@2x.png
new file mode 100644
index 0000000000..18d0c33576
Binary files /dev/null and b/packages/client-app/internal_packages/message-view-on-github/assets/github@2x.png differ
diff --git a/packages/client-app/internal_packages/message-view-on-github/icon.png b/packages/client-app/internal_packages/message-view-on-github/icon.png
new file mode 100644
index 0000000000..22c22ee05e
Binary files /dev/null and b/packages/client-app/internal_packages/message-view-on-github/icon.png differ
diff --git a/packages/client-app/internal_packages/message-view-on-github/keymaps/github.json b/packages/client-app/internal_packages/message-view-on-github/keymaps/github.json
new file mode 100644
index 0000000000..f9aaa43505
--- /dev/null
+++ b/packages/client-app/internal_packages/message-view-on-github/keymaps/github.json
@@ -0,0 +1,3 @@
+{
+ "github:open": "mod-G"
+}
diff --git a/packages/client-app/internal_packages/message-view-on-github/lib/github-store.es6 b/packages/client-app/internal_packages/message-view-on-github/lib/github-store.es6
new file mode 100644
index 0000000000..a8aba940a5
--- /dev/null
+++ b/packages/client-app/internal_packages/message-view-on-github/lib/github-store.es6
@@ -0,0 +1,78 @@
+import _ from 'underscore';
+import NylasStore from 'nylas-store';
+import {MessageStore} from 'nylas-exports';
+
+class GithubStore extends NylasStore {
+ // It's very common practive for {NylasStore}s to listen to other parts of N1.
+ // Since Stores are singletons and constructed once on `require`, there is no
+ // teardown step to turn off listeners.
+ constructor() {
+ super();
+ this.listenTo(MessageStore, this._onMessageStoreChanged);
+ }
+
+ // This is the only public method on `GithubStore` and it's read only.
+ // All {NylasStore}s ONLY have reader methods. No setter methods. Use an
+ // `Action` instead!
+ //
+ // This is the computed & cached value that our `ViewOnGithubButton` will
+ // render.
+ link() {
+ return this._link;
+ }
+
+ // Private methods
+
+ _onMessageStoreChanged() {
+ if (!MessageStore.threadId()) {
+ return;
+ }
+
+ const itemIds = _.pluck(MessageStore.items(), "id");
+ if ((itemIds.length === 0) || _.isEqual(itemIds, this._lastItemIds)) {
+ return;
+ }
+
+ this._lastItemIds = itemIds;
+ this._link = this._isRelevantThread() ? this._findGitHubLink() : null;
+ this.trigger();
+ }
+
+ _findGitHubLink() {
+ let msg = MessageStore.items()[0];
+ if (!msg.body) {
+ // The msg body may be null if it's collapsed. In that case, use the
+ // last message. This may be less relaiable since the last message
+ // might be a side-thread that doesn't contain the link in the quoted
+ // text.
+ msg = _.last(MessageStore.items());
+ }
+
+ // Use a regex to parse the message body for GitHub URLs - this is a quick
+ // and dirty method to determine the GitHub object the email is about:
+ // https://regex101.com/r/aW8bI4/2
+ const re = //gmi;
+ const firstMatch = re.exec(msg.body);
+ if (firstMatch) {
+ // [0] is the full match and [1] is the matching group
+ return firstMatch[1];
+ }
+
+ return null;
+ }
+
+ _isRelevantThread() {
+ const participants = MessageStore.thread().participants || [];
+ const githubDomainRegex = /@github\.com/gi;
+ return _.any(participants, contact => githubDomainRegex.test(contact.email));
+ }
+}
+
+/*
+IMPORTANT NOTE:
+
+All {NylasStore}s are constructed upon their first `require` by another
+module. Since `require` is cached, they are only constructed once and
+are therefore singletons.
+*/
+export default new GithubStore();
diff --git a/packages/client-app/internal_packages/message-view-on-github/lib/main.jsx b/packages/client-app/internal_packages/message-view-on-github/lib/main.jsx
new file mode 100644
index 0000000000..aa52b52a85
--- /dev/null
+++ b/packages/client-app/internal_packages/message-view-on-github/lib/main.jsx
@@ -0,0 +1,57 @@
+/*
+This package displays a "Vew on Github Button" whenever the message you're
+looking at contains a "view it on Github" link.
+
+This is the entry point of an N1 package. All packages must have a file
+called `main` in their `/lib` folder.
+
+The `activate` method of the package gets called when it is activated.
+This happens during N1's bootup. It can also happen when a user manually
+enables your package.
+
+Nearly all N1 packages have similar `activate` methods. The most common
+action is to register a {React} component with the {ComponentRegistry}
+
+See more details about how this works in the {ComponentRegistry}
+documentation.
+
+In this case the `ViewOnGithubButton` React Component will get rendered
+whenever the `"MessageList:ThreadActionsToolbarButton"` region gets rendered.
+
+Since the `ViewOnGithubButton` doesn't know who owns the
+`"MessageList:ThreadActionsToolbarButton"` region, or even when or where it will be rendered, it
+has to load its internal `state` from the `GithubStore`.
+
+The `GithubStore` is responsible for figuring out what message you're
+looking at, if it has a relevant Github link, and what that link is. Once
+it figures that out, it makes that data available for the
+`ViewOnGithubButton` to display.
+*/
+
+import {ComponentRegistry} from 'nylas-exports';
+import ViewOnGithubButton from "./view-on-github-button";
+
+/*
+All packages must export a basic object that has at least the following 3
+methods:
+
+1. `activate` - Actions to take once the package gets turned on.
+Pre-enabled packages get activated on N1 bootup. They can also be
+activated manually by a user.
+
+2. `deactivate` - Actions to take when a package gets turned off. This can
+happen when a user manually disables a package.
+
+3. `serialize` - A simple serializable object that gets saved to disk
+before N1 quits. This gets passed back into `activate` next time N1 boots
+up or your package is manually activated.
+*/
+export function activate() {
+ ComponentRegistry.register(ViewOnGithubButton, {
+ role: 'ThreadActionsToolbarButton',
+ });
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(ViewOnGithubButton);
+}
diff --git a/packages/client-app/internal_packages/message-view-on-github/lib/view-on-github-button.jsx b/packages/client-app/internal_packages/message-view-on-github/lib/view-on-github-button.jsx
new file mode 100644
index 0000000000..0c7ae86da1
--- /dev/null
+++ b/packages/client-app/internal_packages/message-view-on-github/lib/view-on-github-button.jsx
@@ -0,0 +1,167 @@
+import {shell} from 'electron'
+import {Actions, React} from 'nylas-exports'
+import {RetinaImg, KeyCommandsRegion} from 'nylas-component-kit'
+
+import GithubStore from './github-store'
+
+/**
+The `ViewOnGithubButton` displays a button whenever there's a relevant
+Github asset to link to.
+
+When creating this React component the first consideration was when &
+where we'd be rendered. The next consideration was what data we need to
+display.
+
+Unlike a traditional React application, N1 components have very few
+guarantees on who will render them and where they will be rendered. In our
+`lib/main.cjsx` file we registered this component with our
+{ComponentRegistry} for the `"ThreadActionsToolbarButton"` role. That means that
+whenever the "ThreadActionsToolbarButton" region gets rendered, we'll render
+everything registered with that area. Other buttons, such as "Archive" and
+the "Change Label" button are reigstered with that role, so we should
+expect ourselves to showup alongside them.
+
+The only data we need is a single relevant to Github. If we have one,
+we'll open it up in a browser. If we don't have one, we'll hide the
+component.
+
+Getting that url takes a bit of message parsing. We need to retrieve a
+message body then implement some kind of regex to find and parse out that
+link.
+
+We could have put all of that logic in this React Component, but that's
+not what React components should be doing. In N1 a component's only job is
+to display known data and be the first responders to user interaction.
+
+We instead create a {GithubStore} to handle the fetching and preparation
+of the data. See that file's documentation for more on how that works.
+
+As far as this component is concerned, there will be an entity called
+`GitHubStore` that will expose the correct `link`. That store will then
+notify us when the `link` changes so we can update our state.
+
+Once we know our `link` our `render` method can simply be a description of
+how we want to display that link. In this case we're going to make a
+simple button with a GitHub logo in it.
+
+We'll also display nothing if there is no link.
+*/
+export default class ViewOnGithubButton extends React.Component {
+ static displayName = "ViewOnGithubButton"
+
+ static containerRequired = false
+
+ static propTypes = {
+ items: React.PropTypes.array,
+ }
+
+ /** ** React methods ****
+ * The following methods are React methods that we override. See {React}
+ * documentation for more info
+ */
+
+ constructor(props) {
+ super(props)
+ this.state = this._getStateFromStores()
+ }
+
+ /*
+ * When components mount, it's very common to have them listen to a
+ * `Store`. Since most of our React Components in N1 are registered into
+ * {ComponentRegistry} regions instead of manually rendered top-down much
+ * of our data is side-loaded from stores instead of passed in as props.
+ */
+ componentDidMount() {
+ /*
+ * The `listen` method of {NylasStore}s (which {GithubStore}
+ * subclasses) returns an "unlistener" function. When the unlistener is
+ * invoked (as it is in `componentWillUnmount`) the listener references
+ * are cleaned up. Every time the `GithubStore` calls its `trigger`
+ * method, the `_onStoreChanged` callback will be fired.
+ */
+ this._unlisten = GithubStore.listen(this._onStoreChanged)
+ }
+
+ componentWillUnmount() {
+ this._unlisten()
+ }
+
+ _keymapHandlers() {
+ return {
+ 'github:open': this._openLink,
+ }
+ }
+
+ /** ** Super common N1 Component private methods ****
+ /*
+ * An extremely common pattern for all N1 components are the methods
+ * `onStoreChanged` and `getStateFromStores`.
+ *
+ * Most N1 components listen to some source of data, which is usally a
+ * Store. When the store notifies that something has changed, we need to
+ * fetch the fresh data and updated our state.
+ *
+ * Note that when a Store updates it does not let us know what changed.
+ * This is intentional! This forces us to fresh the full latest state
+ * from the stores in a more declarative, easy-to-follow way. There are a
+ * couple rare exceptions that are only used for performance
+ * optimizations.
+
+ * Note that we bind this method to the class instance's `this`. Any
+ * method used as a callback must be bound. In Coffeescript we use the
+ * fat arrow (`=>`)
+ */
+ _onStoreChanged = () => {
+ this.setState(this._getStateFromStores())
+ }
+
+ /*
+ * getStateFromStores fetches the data the view needs from the
+ * appropriate data source (our GithubStore). We return a basic object
+ * that can be passed directly into `setState`.
+ */
+ _getStateFromStores() {
+ return {
+ link: GithubStore.link(),
+ }
+ }
+
+ /** ** Other utility "private" methods ****
+ /*
+ * This responds to user interaction. Since it's a callback we have to
+ * bind it to the instances's `this` (Coffeescript fat arrow `=>`)
+ *
+ * In the case of this component we use the Electron `shell` module to
+ * request the computer to open the default browser.
+ *
+ * In other very common cases, user interaction handlers may fire an
+ * `Action` across the system for other Stores to respond to. They may
+ * also queue a {Task} to eventually perform a mutating API POST or PUT
+ * request.
+ */
+ _openLink = () => {
+ Actions.recordUserEvent("Github Thread Opened", {pageUrl: this.state.link})
+ if (this.state.link) {
+ shell.openExternal(this.state.link)
+ }
+ }
+
+ render() {
+ if (this.props.items.length !== 1) { return false }
+ if (!this.state.link) { return false }
+ return (
+
+
+
+
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/message-view-on-github/package.json b/packages/client-app/internal_packages/message-view-on-github/package.json
new file mode 100644
index 0000000000..e9d2bfdf54
--- /dev/null
+++ b/packages/client-app/internal_packages/message-view-on-github/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "message-view-on-github",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "View on Github button",
+ "isHiddenOnPluginsPage": true,
+ "license": "GPL-3.0",
+
+ "title":"View on GitHub",
+ "description": "Add a \"View On GitHub\" button that appears when viewing GitHub emails.",
+ "icon": "./icon.png",
+ "isOptional": true,
+
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/message-view-on-github/stylesheets/github.less b/packages/client-app/internal_packages/message-view-on-github/stylesheets/github.less
new file mode 100644
index 0000000000..97970ea5db
--- /dev/null
+++ b/packages/client-app/internal_packages/message-view-on-github/stylesheets/github.less
@@ -0,0 +1,6 @@
+
+.btn.btn-toolbar.btn-view-on-github {
+ &:only-of-type {
+ margin-right: 0;
+ }
+}
diff --git a/packages/client-app/internal_packages/mode-switch/lib/main.es6 b/packages/client-app/internal_packages/mode-switch/lib/main.es6
new file mode 100644
index 0000000000..d92eaa5e7e
--- /dev/null
+++ b/packages/client-app/internal_packages/mode-switch/lib/main.es6
@@ -0,0 +1,34 @@
+import {ComponentRegistry, WorkspaceStore} from 'nylas-exports';
+import {HasTutorialTip} from 'nylas-component-kit';
+
+import ModeToggle from './mode-toggle';
+
+const ToggleWithTutorialTip = HasTutorialTip(ModeToggle, {
+ title: 'Compose with context',
+ instructions: "Nylas Mail shows you everything about your contacts right inside your inbox. See LinkedIn profiles, Twitter bios, message history, and more.",
+});
+
+// NOTE: this is a hack to allow ComponentRegistry
+// to register the same component multiple times in
+// different areas. if we do this more than once, let's
+// dry this out.
+class ToggleWithTutorialTipList extends ToggleWithTutorialTip {
+ static displayName = 'ModeToggleList'
+}
+
+export function activate() {
+ ComponentRegistry.register(ToggleWithTutorialTipList, {
+ location: WorkspaceStore.Sheet.Thread.Toolbar.Right,
+ modes: ['list'],
+ });
+
+ ComponentRegistry.register(ToggleWithTutorialTip, {
+ location: WorkspaceStore.Sheet.Threads.Toolbar.Right,
+ modes: ['split'],
+ });
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(ToggleWithTutorialTip);
+ ComponentRegistry.unregister(ToggleWithTutorialTipList);
+}
diff --git a/packages/client-app/internal_packages/mode-switch/lib/mode-toggle.cjsx b/packages/client-app/internal_packages/mode-switch/lib/mode-toggle.cjsx
new file mode 100644
index 0000000000..a99a3ff772
--- /dev/null
+++ b/packages/client-app/internal_packages/mode-switch/lib/mode-toggle.cjsx
@@ -0,0 +1,52 @@
+{ComponentRegistry,
+ WorkspaceStore,
+ Actions} = require "nylas-exports"
+{RetinaImg} = require 'nylas-component-kit'
+React = require "react"
+_ = require "underscore"
+
+class ModeToggle extends React.Component
+ @displayName: 'ModeToggle'
+
+ constructor: (@props) ->
+ @column = WorkspaceStore.Location.MessageListSidebar
+ @state = @_getStateFromStores()
+
+ componentDidMount: =>
+ @_unsubscriber = WorkspaceStore.listen(@_onStateChanged)
+ @_mounted = true
+
+ componentWillUnmount: =>
+ @_mounted = false
+ @_unsubscriber?()
+
+ render: =>
+
+
+
+
+ _onStateChanged: =>
+ # We need to keep track of this because our parent unmounts us in the same
+ # event listener cycle that we receive the event in. ie:
+ #
+ # for listener in listeners
+ # # 1. workspaceView remove left column
+ # # ---- Mode toggle unmounts, listeners array mutated in place
+ # # 2. ModeToggle update
+ return unless @_mounted
+ @setState(@_getStateFromStores())
+
+ _getStateFromStores: =>
+ {hidden: WorkspaceStore.isLocationHidden(@column)}
+
+ _onToggleMode: =>
+ Actions.toggleWorkspaceLocationHidden(@column)
+
+
+module.exports = ModeToggle
diff --git a/packages/client-app/internal_packages/mode-switch/package.json b/packages/client-app/internal_packages/mode-switch/package.json
new file mode 100644
index 0000000000..889ac03d65
--- /dev/null
+++ b/packages/client-app/internal_packages/mode-switch/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "mode-switch",
+ "version": "0.0.1",
+ "description": "Mode switch",
+ "main": "./lib/main",
+ "license": "GPL-3.0",
+ "engines": {
+ "nylas": "*"
+ },
+ "private": true
+}
diff --git a/packages/client-app/internal_packages/mode-switch/stylesheets/mode-switch.less b/packages/client-app/internal_packages/mode-switch/stylesheets/mode-switch.less
new file mode 100644
index 0000000000..e4c8ac41c1
--- /dev/null
+++ b/packages/client-app/internal_packages/mode-switch/stylesheets/mode-switch.less
@@ -0,0 +1,11 @@
+@import 'ui-variables';
+
+.btn-toolbar.mode-toggle {
+ z-index: 1000;
+ position: relative;
+}
+.btn-toolbar.mode-toggle.mode-false {
+ img.content-mask {
+ background-color: @component-active-color;
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/assets/icon-alert-onred@1x.png b/packages/client-app/internal_packages/notifications/assets/icon-alert-onred@1x.png
new file mode 100644
index 0000000000..734d5d7fdb
Binary files /dev/null and b/packages/client-app/internal_packages/notifications/assets/icon-alert-onred@1x.png differ
diff --git a/packages/client-app/internal_packages/notifications/assets/icon-alert-onred@2x.png b/packages/client-app/internal_packages/notifications/assets/icon-alert-onred@2x.png
new file mode 100644
index 0000000000..f76b1a1bd3
Binary files /dev/null and b/packages/client-app/internal_packages/notifications/assets/icon-alert-onred@2x.png differ
diff --git a/packages/client-app/internal_packages/notifications/assets/icon-alert-sourcelist@1x.png b/packages/client-app/internal_packages/notifications/assets/icon-alert-sourcelist@1x.png
new file mode 100644
index 0000000000..a2e488fbc0
Binary files /dev/null and b/packages/client-app/internal_packages/notifications/assets/icon-alert-sourcelist@1x.png differ
diff --git a/packages/client-app/internal_packages/notifications/assets/icon-alert-sourcelist@2x.png b/packages/client-app/internal_packages/notifications/assets/icon-alert-sourcelist@2x.png
new file mode 100644
index 0000000000..048627605f
Binary files /dev/null and b/packages/client-app/internal_packages/notifications/assets/icon-alert-sourcelist@2x.png differ
diff --git a/packages/client-app/internal_packages/notifications/assets/minichevron@2x.png b/packages/client-app/internal_packages/notifications/assets/minichevron@2x.png
new file mode 100644
index 0000000000..383441b62a
Binary files /dev/null and b/packages/client-app/internal_packages/notifications/assets/minichevron@2x.png differ
diff --git a/packages/client-app/internal_packages/notifications/lib/items/account-error-notif.jsx b/packages/client-app/internal_packages/notifications/lib/items/account-error-notif.jsx
new file mode 100644
index 0000000000..0182cc30c5
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/items/account-error-notif.jsx
@@ -0,0 +1,144 @@
+import {shell, ipcRenderer} from 'electron';
+import {React, Account, AccountStore, Actions} from 'nylas-exports';
+import {Notification} from 'nylas-component-kit';
+
+export default class AccountErrorNotification extends React.Component {
+ static displayName = 'AccountErrorNotification';
+
+ constructor() {
+ super();
+ this._checkingTimeout = null
+ this.state = {
+ checking: false,
+ debugKeyPressed: false,
+ accounts: AccountStore.accounts(),
+ }
+ }
+
+ componentDidMount() {
+ this.unlisten = AccountStore.listen(() => this.setState({
+ accounts: AccountStore.accounts(),
+ }));
+ }
+
+ componentWillUnmount() {
+ this.unlisten();
+ }
+
+ _onContactSupport = (erroredAccount) => {
+ let url = 'https://support.nylas.com/hc/en-us/requests/new'
+ if (erroredAccount) {
+ url += `?email=${encodeURIComponent(erroredAccount.emailAddress)}`
+ const {syncError} = erroredAccount
+ if (syncError != null) {
+ url += `&subject=${encodeURIComponent('Sync Error')}`
+ const description = encodeURIComponent(
+ `Sync Error:\n\`\`\`\n${JSON.stringify(syncError, null, 2)}\n\`\`\``
+ )
+ url += `&description=${description}`
+ }
+ }
+ shell.openExternal(url);
+ }
+
+ _onReconnect = (existingAccount) => {
+ ipcRenderer.send('command', 'application:add-account', {existingAccount, source: 'Reconnect from error notification'});
+ }
+
+ _onOpenAccountPreferences = () => {
+ Actions.switchPreferencesTab('Accounts');
+ Actions.openPreferences()
+ }
+
+ _onCheckAgain(event, account) {
+ if (event.metaKey) {
+ Actions.debugSync()
+ return
+ }
+ clearTimeout(this._checkingTimeout)
+ this.setState({checking: true})
+ this._checkingTimeout = setTimeout(() => this.setState({checking: false}), 10000)
+
+ if (account) {
+ Actions.wakeLocalSyncWorkerForAccount(account.id)
+ return
+ }
+ const erroredAccounts = this.state.accounts.filter(a => a.hasSyncStateError());
+ erroredAccounts.forEach(acc => Actions.wakeLocalSyncWorkerForAccount(acc.id))
+ }
+
+ render() {
+ const erroredAccounts = this.state.accounts.filter(a =>
+ a.hasN1CloudError() || a.hasSyncStateError()
+ );
+ const checkAgainLabel = this.state.checking ? 'Checking...' : 'Check Again'
+ let title;
+ let subtitle;
+ let subtitleAction;
+ let actions;
+ if (erroredAccounts.length === 0) {
+ return
+ } else if (erroredAccounts.length > 1) {
+ title = "Several of your accounts are having issues";
+ actions = [{
+ label: checkAgainLabel,
+ fn: (e) => this._onCheckAgain(e),
+ }, {
+ label: "Manage",
+ fn: this._onOpenAccountPreferences,
+ }];
+ } else {
+ const erroredAccount = erroredAccounts[0];
+ if (erroredAccount.hasN1CloudError()) {
+ title = `Cannot authenticate Nylas Mail Cloud Services with ${erroredAccount.emailAddress}`;
+ actions = [{
+ label: checkAgainLabel,
+ fn: (e) => this._onCheckAgain(e, erroredAccount),
+ }, {
+ label: 'Reconnect',
+ fn: () => this._onReconnect(erroredAccount),
+ }];
+ } else {
+ switch (erroredAccount.syncState) {
+ case Account.SYNC_STATE_AUTH_FAILED:
+ title = `Cannot authenticate with ${erroredAccount.emailAddress}`;
+ actions = [{
+ label: checkAgainLabel,
+ fn: (e) => this._onCheckAgain(e, erroredAccount),
+ }, {
+ label: 'Reconnect',
+ fn: () => this._onReconnect(erroredAccount),
+ }];
+ break;
+ default: {
+ title = `Encountered an error while syncing ${erroredAccount.emailAddress}`;
+ let label = this.state.checking ? 'Retrying...' : 'Try Again'
+ if (this.state.debugKeyPressed) {
+ label = 'Debug'
+ }
+ actions = [{
+ label,
+ fn: (e) => this._onCheckAgain(e, erroredAccount),
+ props: {
+ onMouseEnter: (e) => this.setState({debugKeyPressed: e.metaKey}),
+ onMouseLeave: () => this.setState({debugKeyPressed: false}),
+ },
+ }];
+ }
+ }
+ }
+ }
+
+ return (
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/items/default-client-notif.jsx b/packages/client-app/internal_packages/notifications/lib/items/default-client-notif.jsx
new file mode 100644
index 0000000000..30e33333b7
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/items/default-client-notif.jsx
@@ -0,0 +1,74 @@
+import {React, DefaultClientHelper} from 'nylas-exports';
+import {Notification} from 'nylas-component-kit';
+
+const SETTINGS_KEY = 'nylas.mailto.prompted-about-default'
+
+export default class DefaultClientNotification extends React.Component {
+ static displayName = 'DefaultClientNotification';
+
+ constructor() {
+ super();
+ this.helper = new DefaultClientHelper();
+ this.state = this.getStateFromStores();
+ this.state.initializing = true;
+ this.mounted = false;
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.helper.isRegisteredForURLScheme('mailto', (registered) => {
+ if (this.mounted) {
+ this.setState({
+ initializing: false,
+ registered: registered,
+ })
+ }
+ })
+ this.disposable = NylasEnv.config.onDidChange(SETTINGS_KEY,
+ () => this.setState(this.getStateFromStores()));
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ this.disposable.dispose();
+ }
+
+ getStateFromStores() {
+ return {
+ alreadyPrompted: NylasEnv.config.get(SETTINGS_KEY),
+ }
+ }
+
+ _onAccept = () => {
+ this.helper.registerForURLScheme('mailto', (err) => {
+ if (err) {
+ NylasEnv.reportError(err)
+ }
+ });
+ NylasEnv.config.set(SETTINGS_KEY, true)
+ }
+
+ _onDecline = () => {
+ NylasEnv.config.set(SETTINGS_KEY, true)
+ }
+
+ render() {
+ if (this.state.initializing || this.state.alreadyPrompted || this.state.registered) {
+ return
+ }
+ return (
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/items/dev-mode-notif.jsx b/packages/client-app/internal_packages/notifications/lib/items/dev-mode-notif.jsx
new file mode 100644
index 0000000000..45ba3da339
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/items/dev-mode-notif.jsx
@@ -0,0 +1,27 @@
+import {React} from 'nylas-exports';
+import {Notification} from 'nylas-component-kit';
+
+export default class DevModeNotification extends React.Component {
+ static displayName = 'DevModeNotification';
+
+ constructor() {
+ super();
+ // Don't need listeners to update this, since toggling dev mode reloads
+ // the entire window anyway
+ this.state = {
+ inDevMode: NylasEnv.inDevMode(),
+ }
+ }
+
+ render() {
+ if (!this.state.inDevMode) {
+ return
+ }
+ return (
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/items/disabled-mail-rules-notif.jsx b/packages/client-app/internal_packages/notifications/lib/items/disabled-mail-rules-notif.jsx
new file mode 100644
index 0000000000..6ecb09fb84
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/items/disabled-mail-rules-notif.jsx
@@ -0,0 +1,48 @@
+import {React, MailRulesStore, Actions} from 'nylas-exports';
+import {Notification} from 'nylas-component-kit';
+
+export default class DisabledMailRulesNotification extends React.Component {
+ static displayName = 'DisabledMailRulesNotification';
+
+ constructor() {
+ super();
+ this.state = this.getStateFromStores();
+ }
+
+ componentDidMount() {
+ this.unlisten = MailRulesStore.listen(() => this.setState(this.getStateFromStores()));
+ }
+
+ componentWillUnmount() {
+ this.unlisten();
+ }
+
+ getStateFromStores() {
+ return {
+ disabledRules: MailRulesStore.disabledRules(),
+ }
+ }
+
+ _onOpenMailRulesPreferences = () => {
+ Actions.switchPreferencesTab('Mail Rules', {accountId: this.state.disabledRules[0].accountId})
+ Actions.openPreferences()
+ }
+
+ render() {
+ if (this.state.disabledRules.length === 0) {
+ return
+ }
+ return (
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/items/offline-notification.jsx b/packages/client-app/internal_packages/notifications/lib/items/offline-notification.jsx
new file mode 100644
index 0000000000..d72f108b00
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/items/offline-notification.jsx
@@ -0,0 +1,42 @@
+import {OnlineStatusStore, React, Actions} from 'nylas-exports';
+import {Notification, ListensToFluxStore} from 'nylas-component-kit';
+
+
+function OfflineNotification({isOnline, retryingInSeconds}) {
+ if (isOnline) {
+ return false
+ }
+ const subtitle = retryingInSeconds ?
+ `Retrying in ${retryingInSeconds} second${retryingInSeconds > 1 ? 's' : ''}` :
+ `Retrying now...`;
+
+ return (
+ Actions.checkOnlineStatus(),
+ }]}
+ />
+ )
+}
+OfflineNotification.displayName = 'OfflineNotification'
+OfflineNotification.propTypes = {
+ isOnline: React.PropTypes.bool,
+ retryingInSeconds: React.PropTypes.number,
+}
+
+export default ListensToFluxStore(OfflineNotification, {
+ stores: [OnlineStatusStore],
+ getStateFromStores() {
+ return {
+ isOnline: OnlineStatusStore.isOnline(),
+ retryingInSeconds: OnlineStatusStore.retryingInSeconds(),
+ }
+ },
+})
diff --git a/packages/client-app/internal_packages/notifications/lib/items/unstable-channel-notif.jsx b/packages/client-app/internal_packages/notifications/lib/items/unstable-channel-notif.jsx
new file mode 100644
index 0000000000..04166b9cdb
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/items/unstable-channel-notif.jsx
@@ -0,0 +1,52 @@
+import {React, UpdateChannelStore} from 'nylas-exports';
+import {Notification} from 'nylas-component-kit';
+
+export default class UnstableChannelNotification extends React.Component {
+ static displayName = 'UnstableChannelNotification';
+
+ constructor() {
+ super();
+ this.state = {
+ isUnstableChannel: UpdateChannelStore.currentIsUnstable(),
+ }
+ }
+
+ componentDidMount() {
+ this._unsub = UpdateChannelStore.listen(() => {
+ this.setState({
+ isUnstableChannel: UpdateChannelStore.currentIsUnstable(),
+ });
+ });
+ }
+
+ componentWillUnmount() {
+ if (this._unsub) {
+ this._unsub();
+ }
+ }
+
+ _onReportIssue = () => {
+ NylasEnv.windowEventHandler.openLink({href: 'mailto:support@nylas.com'})
+ }
+
+ render() {
+ if (!this.state.isUnstableChannel) {
+ return
+ }
+ return (
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/items/update-notification.jsx b/packages/client-app/internal_packages/notifications/lib/items/update-notification.jsx
new file mode 100644
index 0000000000..a4b33a3db7
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/items/update-notification.jsx
@@ -0,0 +1,59 @@
+import {React} from 'nylas-exports';
+import {ipcRenderer, remote, shell} from 'electron';
+import {Notification} from 'nylas-component-kit';
+
+export default class UpdateNotification extends React.Component {
+ static displayName = 'UpdateNotification';
+
+ constructor() {
+ super();
+ this.state = this.getStateFromStores();
+ }
+
+ componentDidMount() {
+ this.disposable = NylasEnv.onUpdateAvailable(() => {
+ this.setState(this.getStateFromStores())
+ });
+ }
+
+ componentWillUnmount() {
+ this.disposable.dispose();
+ }
+
+ getStateFromStores() {
+ const updater = remote.getGlobal('application').autoUpdateManager;
+
+ return {
+ updateAvailable: updater.getState() === 'update-available',
+ version: updater.releaseVersion,
+ }
+ }
+
+ _onUpdate = () => {
+ ipcRenderer.send('command', 'application:install-update')
+ }
+
+ _onViewChangelog = () => {
+ shell.openExternal('https://github.com/nylas/nylas-mail/releases/latest')
+ }
+
+ render() {
+ if (!this.state.updateAvailable) {
+ return
+ }
+ const version = this.state.version ? `(${this.state.version})` : '';
+ return (
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/main.es6 b/packages/client-app/internal_packages/notifications/lib/main.es6
new file mode 100644
index 0000000000..cdd3f4470d
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/main.es6
@@ -0,0 +1,46 @@
+/* eslint no-unused-vars:0 */
+
+import {ComponentRegistry, WorkspaceStore} from 'nylas-exports';
+import ActivitySidebar from "./sidebar/activity-sidebar";
+import NotifWrapper from "./notif-wrapper";
+
+import AccountErrorNotification from "./items/account-error-notif";
+import DefaultClientNotification from "./items/default-client-notif";
+import UnstableChannelNotification from "./items/unstable-channel-notif";
+import DevModeNotification from "./items/dev-mode-notif";
+import DisabledMailRulesNotification from "./items/disabled-mail-rules-notif";
+import OfflineNotification from "./items/offline-notification";
+import UpdateNotification from "./items/update-notification";
+
+const notifications = [
+ AccountErrorNotification,
+ DefaultClientNotification,
+ UnstableChannelNotification,
+ DevModeNotification,
+ DisabledMailRulesNotification,
+ UpdateNotification,
+]
+
+if (NylasEnv.config.get('core.workspace.showOfflineNotification')) {
+ notifications.push(OfflineNotification)
+}
+
+export function activate() {
+ ComponentRegistry.register(ActivitySidebar, {location: WorkspaceStore.Location.RootSidebar});
+ ComponentRegistry.register(NotifWrapper, {location: WorkspaceStore.Location.RootSidebar});
+
+ for (const notification of notifications) {
+ ComponentRegistry.register(notification, {role: 'RootSidebar:Notifications'});
+ }
+}
+
+export function serialize() {}
+
+export function deactivate() {
+ ComponentRegistry.unregister(ActivitySidebar);
+ ComponentRegistry.unregister(NotifWrapper);
+
+ for (const notification of notifications) {
+ ComponentRegistry.unregister(notification)
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/notif-wrapper.jsx b/packages/client-app/internal_packages/notifications/lib/notif-wrapper.jsx
new file mode 100644
index 0000000000..f78a0eb22b
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/notif-wrapper.jsx
@@ -0,0 +1,50 @@
+import _ from 'underscore';
+import {React, ReactDOM} from 'nylas-exports'
+import {InjectedComponentSet} from 'nylas-component-kit'
+
+const ROLE = "RootSidebar:Notifications";
+
+export default class NotifWrapper extends React.Component {
+ static displayName = 'NotifWrapper';
+
+ componentDidMount() {
+ this.observer = new MutationObserver(this.update);
+ this.observer.observe(ReactDOM.findDOMNode(this), {childList: true})
+ this.update() // Necessary if notifications are already mounted
+ }
+
+ componentWillUnmount() {
+ this.observer.disconnect();
+ }
+
+ update = () => {
+ const className = "highest-priority";
+ const node = ReactDOM.findDOMNode(this);
+
+ const oldHighestPriorityElems = node.querySelectorAll(`.${className}`);
+ for (const oldElem of oldHighestPriorityElems) {
+ oldElem.classList.remove(className)
+ }
+
+ const elemsWithPriority = node.querySelectorAll("[data-priority]")
+ if (elemsWithPriority.length === 0) {
+ return;
+ }
+
+ const highestPriorityElem = _.max(elemsWithPriority,
+ (elem) => parseInt(elem.dataset.priority, 10))
+
+ highestPriorityElem.classList.add(className);
+ }
+
+ render() {
+ return (
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/sidebar/activity-sidebar.cjsx b/packages/client-app/internal_packages/notifications/lib/sidebar/activity-sidebar.cjsx
new file mode 100644
index 0000000000..9aa8cfaf22
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/sidebar/activity-sidebar.cjsx
@@ -0,0 +1,87 @@
+React = require 'react'
+ReactDOM = require 'react-dom'
+ReactCSSTransitionGroup = require 'react-addons-css-transition-group'
+_ = require 'underscore'
+classNames = require 'classnames'
+
+SyncActivity = require("./sync-activity").default
+SyncbackActivity = require("./syncback-activity").default
+
+{Utils,
+ Actions,
+ TaskQueue,
+ AccountStore,
+ FolderSyncProgressStore,
+ TaskQueueStatusStore
+ PerformSendActionTask,
+ SendDraftTask} = require 'nylas-exports'
+
+SEND_TASK_CLASSES = [PerformSendActionTask, SendDraftTask]
+
+class ActivitySidebar extends React.Component
+ @displayName: 'ActivitySidebar'
+
+ @containerRequired: false
+ @containerStyles:
+ minWidth: 165
+ maxWidth: 400
+
+ constructor: (@props) ->
+ @state = @_getStateFromStores()
+
+ shouldComponentUpdate: (nextProps, nextState) =>
+ not Utils.isEqualReact(nextProps, @props) or
+ not Utils.isEqualReact(nextState, @state)
+
+ componentDidMount: =>
+ @_unlisteners = []
+ @_unlisteners.push TaskQueueStatusStore.listen @_onDataChanged
+ @_unlisteners.push FolderSyncProgressStore.listen @_onDataChanged
+
+ componentWillUnmount: =>
+ unlisten() for unlisten in @_unlisteners
+
+ render: =>
+ sendTasks = []
+ nonSendTasks = []
+ @state.tasks.forEach (task) ->
+ if SEND_TASK_CLASSES.some(((taskClass) -> task instanceof taskClass ))
+ sendTasks.push(task)
+ else
+ nonSendTasks.push(task)
+
+
+ names = classNames
+ "sidebar-activity": true
+ "sidebar-activity-error": error?
+
+ wrapperClass = "sidebar-activity-transition-wrapper "
+
+ inside =
+
+
+
+
+
+ {inside}
+
+
+ _onDataChanged: =>
+ @setState(@_getStateFromStores())
+
+ _getStateFromStores: =>
+ tasks: TaskQueueStatusStore.queue()
+ isInitialSyncComplete: FolderSyncProgressStore.isSyncComplete()
+
+module.exports = ActivitySidebar
diff --git a/packages/client-app/internal_packages/notifications/lib/sidebar/initial-sync-activity.jsx b/packages/client-app/internal_packages/notifications/lib/sidebar/initial-sync-activity.jsx
new file mode 100644
index 0000000000..92ee7104aa
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/sidebar/initial-sync-activity.jsx
@@ -0,0 +1,118 @@
+import _ from 'underscore';
+import _str from 'underscore.string';
+import {Utils, AccountStore, FolderSyncProgressStore, React} from 'nylas-exports';
+
+const MONTH_SHORT_FORMATS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul',
+ 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
+export default class InitialSyncActivity extends React.Component {
+ static displayName = 'InitialSyncActivity';
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ syncState: FolderSyncProgressStore.getSyncState(),
+ }
+ this.mounted = false;
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.unsub = FolderSyncProgressStore.listen(this.onDataChanged)
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) ||
+ !Utils.isEqualReact(nextState, this.state);
+ }
+
+ componentWillUnmount() {
+ this.unsub();
+ this.mounted = false;
+ }
+
+ onDataChanged = () => {
+ const syncState = Utils.deepClone(FolderSyncProgressStore.getSyncState())
+ this.setState({syncState});
+ }
+
+ renderFolderProgress(name, progress, oldestProcessedDate) {
+ let status = 'busy';
+ let progressLabel = 'In Progress'
+ let syncedThrough = 'Syncing this past month';
+ if (progress === 1) {
+ status = 'complete';
+ progressLabel = '';
+ syncedThrough = 'Up to date'
+ } else {
+ let month = oldestProcessedDate.getMonth();
+ let year = oldestProcessedDate.getFullYear();
+ const currentDate = new Date();
+ if (month !== currentDate.getMonth() || year !== currentDate.getFullYear()) {
+ // We're currently syncing in `month`, which mean's we've synced through all
+ // of the month *after* it.
+ month++;
+ if (month === 12) {
+ month = 0;
+ year++;
+ }
+ syncedThrough = `Synced through ${MONTH_SHORT_FORMATS[month]} ${year}`;
+ }
+ }
+
+ return (
+
+ {_str.titleize(name)} {progressLabel}
+
+ )
+ }
+
+ render() {
+ if (!AccountStore.accountsAreSyncing() || FolderSyncProgressStore.isSyncComplete()) {
+ return false;
+ }
+
+ let maxHeight = 0;
+ let accounts = _.map(this.state.syncState, (accountSyncState, accountId) => {
+ const account = _.findWhere(AccountStore.accounts(), {id: accountId});
+ if (!account) {
+ return false;
+ }
+
+ const {folderSyncProgress} = accountSyncState
+ let folderStates = _.map(folderSyncProgress, ({progress, oldestProcessedDate}, name) => {
+ return this.renderFolderProgress(name, progress, oldestProcessedDate)
+ })
+
+ if (folderStates.length === 0) {
+ folderStates = Gathering folders...
+ }
+
+ // A row for the account email address plus a row for each folder state,
+ const numRows = 1 + (folderStates.length || 1)
+ maxHeight += 50 * numRows;
+
+ return (
+
+
{account.emailAddress}
+ {folderStates}
+
+ )
+ });
+
+ if (accounts.length === 0) {
+ accounts = Looking for accounts...
+ }
+
+ return (
+
+ {accounts}
+
+ )
+ }
+
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/sidebar/sync-activity.jsx b/packages/client-app/internal_packages/notifications/lib/sidebar/sync-activity.jsx
new file mode 100644
index 0000000000..8a39c836e3
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/sidebar/sync-activity.jsx
@@ -0,0 +1,101 @@
+import classNames from 'classnames';
+import {Actions, React, Utils} from 'nylas-exports';
+
+import InitialSyncActivity from './initial-sync-activity';
+import SyncbackActivity from './syncback-activity';
+
+export default class SyncActivity extends React.Component {
+
+ static propTypes = {
+ initialSync: React.PropTypes.bool,
+ syncbackTasks: React.PropTypes.array,
+ }
+
+ constructor() {
+ super()
+ this.state = {
+ expanded: false,
+ blink: false,
+ }
+ this.mounted = false;
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.unsub = Actions.expandInitialSyncState.listen(this.showExpandedState);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) ||
+ !Utils.isEqualReact(nextState, this.state);
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ this.unsub();
+ }
+
+ showExpandedState = () => {
+ if (!this.state.expanded) {
+ this.setState({expanded: true});
+ } else {
+ this.setState({blink: true});
+ setTimeout(() => {
+ if (this.mounted) {
+ this.setState({blink: false});
+ }
+ }, 1000)
+ }
+ }
+
+ hideExpandedState = () => {
+ this.setState({expanded: false});
+ }
+
+ _renderInitialSync() {
+ if (!this.props.initialSync) { return false; }
+ return
+ }
+
+ _renderSyncbackTasks() {
+ return
+ }
+
+ _renderExpandedDetails() {
+ return (
+
+
Hide
+ {this._renderSyncbackTasks()}
+ {this._renderInitialSync()}
+
+ )
+ }
+
+ render() {
+ const {initialSync, syncbackTasks} = this.props;
+ if (!initialSync && (!syncbackTasks || syncbackTasks.length === 0)) {
+ return false;
+ }
+
+ const classSet = classNames({
+ 'item': true,
+ 'expanded-sync': this.state.expanded,
+ 'blink': this.state.blink,
+ });
+
+ const ellipses = [1, 2, 3].map((i) => (
+ . )
+ );
+
+ return (
+ (this.setState({expanded: !this.state.expanded}))}
+ >
+
Syncing your mailbox{ellipses}
+ {this.state.expanded ? this._renderExpandedDetails() : false}
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/lib/sidebar/syncback-activity.jsx b/packages/client-app/internal_packages/notifications/lib/sidebar/syncback-activity.jsx
new file mode 100644
index 0000000000..88fac2eea8
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/lib/sidebar/syncback-activity.jsx
@@ -0,0 +1,59 @@
+import _ from 'underscore';
+import {React, Utils} from 'nylas-exports';
+
+export default class SyncbackActivity extends React.Component {
+ static propTypes = {
+ syncbackTasks: React.PropTypes.array,
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) ||
+ !Utils.isEqualReact(nextState, this.state);
+ }
+
+ render() {
+ const {syncbackTasks} = this.props;
+ if (!syncbackTasks || syncbackTasks.length === 0) { return false; }
+
+ const counts = {}
+ this.props.syncbackTasks.forEach((task) => {
+ const label = task.label ? task.label() : null;
+ if (!label) { return; }
+ if (!counts[label]) {
+ counts[label] = 0;
+ }
+ counts[label] += +task.numberOfImpactedItems()
+ });
+
+ const ellipses = [1, 2, 3].map((i) => (
+ . )
+ );
+
+ const items = _.pairs(counts).map(([label, count]) => {
+ return (
+
+
+ ({count.toLocaleString()})
+ {label}{ellipses}
+
+
+ )
+ });
+
+ if (items.length === 0) {
+ items.push(
+
+ )
+ }
+
+ return (
+
+ {items}
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/package.json b/packages/client-app/internal_packages/notifications/package.json
new file mode 100755
index 0000000000..3344b3c3ee
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "notifications",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Notifications",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/spec/account-error-notif-spec.jsx b/packages/client-app/internal_packages/notifications/spec/account-error-notif-spec.jsx
new file mode 100644
index 0000000000..2236594fe1
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/spec/account-error-notif-spec.jsx
@@ -0,0 +1,82 @@
+import {mount} from 'enzyme';
+import {AccountStore, Account, Actions, React} from 'nylas-exports';
+import {ipcRenderer} from 'electron';
+
+import AccountErrorNotification from '../lib/items/account-error-notif';
+
+describe("AccountErrorNotif", function AccountErrorNotifTests() {
+ describe("when one account is in the `invalid` state", () => {
+ beforeEach(() => {
+ spyOn(AccountStore, 'accounts').andReturn([
+ new Account({id: 'A', syncState: 'invalid', emailAddress: '123@gmail.com'}),
+ new Account({id: 'B', syncState: 'running', emailAddress: 'other@gmail.com'}),
+ ])
+ });
+
+ it("renders an error bar that mentions the account email", () => {
+ const notif = mount( );
+ expect(notif.find('.title').text().indexOf('123@gmail.com') > 0).toBe(true);
+ });
+
+ it("allows the user to refresh the account", () => {
+ const notif = mount( );
+ spyOn(Actions, 'wakeLocalSyncWorkerForAccount').andReturn(Promise.resolve());
+ notif.find('#action-0').simulate('click'); // Expects first action to be the refresh action
+ expect(Actions.wakeLocalSyncWorkerForAccount).toHaveBeenCalled();
+ });
+
+ it("allows the user to reconnect the account", () => {
+ const notif = mount( );
+ spyOn(ipcRenderer, 'send');
+ notif.find('#action-1').simulate('click'); // Expects second action to be the reconnect action
+ expect(ipcRenderer.send).toHaveBeenCalledWith('command', 'application:add-account', {
+ existingAccount: AccountStore.accounts()[0],
+ source: 'Reconnect from error notification',
+ });
+ });
+ });
+
+ describe("when more than one account is in the `invalid` state", () => {
+ beforeEach(() => {
+ spyOn(AccountStore, 'accounts').andReturn([
+ new Account({id: 'A', syncState: 'invalid', emailAddress: '123@gmail.com'}),
+ new Account({id: 'B', syncState: 'invalid', emailAddress: 'other@gmail.com'}),
+ ])
+ });
+
+ it("renders an error bar", () => {
+ const notif = mount( );
+ expect(notif.find('.notification').exists()).toEqual(true);
+ });
+
+ it("allows the user to refresh the accounts", () => {
+ const notif = mount( );
+ spyOn(Actions, 'wakeLocalSyncWorkerForAccount').andReturn(Promise.resolve());
+ notif.find('#action-0').simulate('click'); // Expects first action to be the refresh action
+ expect(Actions.wakeLocalSyncWorkerForAccount).toHaveBeenCalled();
+ });
+
+ it("allows the user to open preferences", () => {
+ spyOn(Actions, 'switchPreferencesTab')
+ spyOn(Actions, 'openPreferences')
+ const notif = mount( );
+ notif.find('#action-1').simulate('click'); // Expects second action to be the preferences action
+ expect(Actions.openPreferences).toHaveBeenCalled();
+ expect(Actions.switchPreferencesTab).toHaveBeenCalledWith('Accounts');
+ });
+ });
+
+ describe("when all accounts are fine", () => {
+ beforeEach(() => {
+ spyOn(AccountStore, 'accounts').andReturn([
+ new Account({id: 'A', syncState: 'running', emailAddress: '123@gmail.com'}),
+ new Account({id: 'B', syncState: 'running', emailAddress: 'other@gmail.com'}),
+ ])
+ });
+
+ it("renders nothing", () => {
+ const notif = mount( );
+ expect(notif.find('.notification').exists()).toEqual(false);
+ });
+ });
+});
diff --git a/packages/client-app/internal_packages/notifications/spec/default-client-notif-spec.jsx b/packages/client-app/internal_packages/notifications/spec/default-client-notif-spec.jsx
new file mode 100644
index 0000000000..afda2dab82
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/spec/default-client-notif-spec.jsx
@@ -0,0 +1,72 @@
+import {mount} from 'enzyme';
+import proxyquire from 'proxyquire';
+import {React} from 'nylas-exports';
+
+let stubIsRegistered = null;
+let stubRegister = () => {};
+const patched = proxyquire('../lib/items/default-client-notif',
+ {
+ 'nylas-exports': {
+ DefaultClientHelper: class {
+ constructor() {
+ this.isRegisteredForURLScheme = (urlScheme, callback) => { callback(stubIsRegistered) };
+ this.registerForURLScheme = (urlScheme) => { stubRegister(urlScheme) };
+ }
+ },
+ },
+ }
+)
+const DefaultClientNotification = patched.default;
+const SETTINGS_KEY = 'nylas.mailto.prompted-about-default';
+
+describe("DefaultClientNotif", function DefaultClientNotifTests() {
+ describe("when N1 isn't the default mail client", () => {
+ beforeEach(() => {
+ stubIsRegistered = false;
+ })
+ describe("when the user has already responded", () => {
+ beforeEach(() => {
+ spyOn(NylasEnv.config, "get").andReturn(true);
+ this.notif = mount( );
+ expect(NylasEnv.config.get).toHaveBeenCalledWith(SETTINGS_KEY);
+ });
+ it("renders nothing", () => {
+ expect(this.notif.find('.notification').exists()).toEqual(false);
+ });
+ });
+
+ describe("when the user has yet to respond", () => {
+ beforeEach(() => {
+ spyOn(NylasEnv.config, "get").andReturn(false);
+ this.notif = mount( );
+ expect(NylasEnv.config.get).toHaveBeenCalledWith(SETTINGS_KEY);
+ });
+ it("renders a notification", () => {
+ expect(this.notif.find('.notification').exists()).toEqual(true);
+ });
+
+ it("allows the user to set N1 as the default client", () => {
+ let scheme = null;
+ stubRegister = (urlScheme) => { scheme = urlScheme };
+ this.notif.find('#action-0').simulate('click'); // Expects first action to set N1 as default
+ expect(scheme).toEqual('mailto');
+ });
+
+ it("allows the user to decline", () => {
+ spyOn(NylasEnv.config, "set")
+ this.notif.find('#action-1').simulate('click'); // Expects second action to decline
+ expect(NylasEnv.config.set).toHaveBeenCalledWith(SETTINGS_KEY, true);
+ });
+ })
+ });
+
+ describe("when N1 is the default mail client", () => {
+ beforeEach(() => {
+ stubIsRegistered = true;
+ this.notif = mount( )
+ })
+ it("renders nothing", () => {
+ expect(this.notif.find('.notification').exists()).toEqual(false);
+ });
+ })
+});
diff --git a/packages/client-app/internal_packages/notifications/spec/dev-mode-notif-spec.jsx b/packages/client-app/internal_packages/notifications/spec/dev-mode-notif-spec.jsx
new file mode 100644
index 0000000000..0c8fcb99ca
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/spec/dev-mode-notif-spec.jsx
@@ -0,0 +1,25 @@
+import {mount} from 'enzyme';
+import {React} from 'nylas-exports';
+import DevModeNotification from '../lib/items/dev-mode-notif';
+
+describe("DevModeNotif", function DevModeNotifTests() {
+ describe("When the window is in dev mode", () => {
+ beforeEach(() => {
+ spyOn(NylasEnv, "inDevMode").andReturn(true);
+ this.notif = mount( );
+ })
+ it("displays a notification", () => {
+ expect(this.notif.find('.notification').exists()).toEqual(true);
+ })
+ })
+
+ describe("When the window is not in dev mode", () => {
+ beforeEach(() => {
+ spyOn(NylasEnv, "inDevMode").andReturn(false);
+ this.notif = mount( );
+ })
+ it("doesn't display a notification", () => {
+ expect(this.notif.find('.notification').exists()).toEqual(false);
+ })
+ })
+});
diff --git a/packages/client-app/internal_packages/notifications/spec/disabled-mail-rules-notif-spec.jsx b/packages/client-app/internal_packages/notifications/spec/disabled-mail-rules-notif-spec.jsx
new file mode 100644
index 0000000000..552c018eeb
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/spec/disabled-mail-rules-notif-spec.jsx
@@ -0,0 +1,57 @@
+import {mount} from 'enzyme';
+import {React, AccountStore, Account, Actions, MailRulesStore} from 'nylas-exports';
+import DisabledMailRulesNotification from '../lib/items/disabled-mail-rules-notif';
+
+describe("DisabledMailRulesNotification", function DisabledMailRulesNotifTests() {
+ beforeEach(() => {
+ spyOn(AccountStore, 'accounts').andReturn([
+ new Account({id: 'A', syncState: 'running', emailAddress: '123@gmail.com'}),
+ ])
+ })
+ describe("When there is one disabled mail rule", () => {
+ beforeEach(() => {
+ spyOn(MailRulesStore, "disabledRules").andReturn([{accountId: 'A'}])
+ this.notif = mount( )
+ })
+ it("displays a notification", () => {
+ expect(this.notif.find('.notification').exists()).toEqual(true);
+ })
+
+ it("allows users to open the preferences", () => {
+ spyOn(Actions, "switchPreferencesTab")
+ spyOn(Actions, "openPreferences")
+ this.notif.find('#action-0').simulate('click');
+ expect(Actions.switchPreferencesTab).toHaveBeenCalledWith('Mail Rules', {accountId: 'A'})
+ expect(Actions.openPreferences).toHaveBeenCalled();
+ })
+ });
+
+ describe("When there are multiple disabled mail rules", () => {
+ beforeEach(() => {
+ spyOn(MailRulesStore, "disabledRules").andReturn([{accountId: 'A'},
+ {accountId: 'A'}])
+ this.notif = mount( )
+ })
+ it("displays a notification", () => {
+ expect(this.notif.find('.notification').exists()).toEqual(true);
+ })
+
+ it("allows users to open the preferences", () => {
+ spyOn(Actions, "switchPreferencesTab")
+ spyOn(Actions, "openPreferences")
+ this.notif.find('#action-0').simulate('click');
+ expect(Actions.switchPreferencesTab).toHaveBeenCalledWith('Mail Rules', {accountId: 'A'})
+ expect(Actions.openPreferences).toHaveBeenCalled();
+ })
+ });
+
+ describe("When there are no disabled mail rules", () => {
+ beforeEach(() => {
+ spyOn(MailRulesStore, "disabledRules").andReturn([])
+ this.notif = mount( )
+ })
+ it("does not display a notification", () => {
+ expect(this.notif.find('.notification').exists()).toEqual(false);
+ })
+ })
+})
diff --git a/packages/client-app/internal_packages/notifications/spec/priority-spec.jsx b/packages/client-app/internal_packages/notifications/spec/priority-spec.jsx
new file mode 100644
index 0000000000..93478082c7
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/spec/priority-spec.jsx
@@ -0,0 +1,66 @@
+import {mount} from 'enzyme';
+import {ComponentRegistry, React} from 'nylas-exports';
+import {Notification} from 'nylas-component-kit';
+
+import NotifWrapper from '../lib/notif-wrapper';
+
+const stubNotif = (priority) => {
+ return class extends React.Component {
+ static displayName = `NotifPriority${priority}`;
+ static containerRequired = false;
+ render() { return }
+ }
+};
+
+const checkHighestPriority = (expectedPriority, wrapper) => {
+ const visibleElems = wrapper.find(".highest-priority")
+ expect(visibleElems.exists()).toEqual(true);
+ const titleElem = visibleElems.first().find('.title');
+ expect(titleElem.exists()).toEqual(true);
+ expect(titleElem.text().trim()).toEqual(`Priority ${expectedPriority}`);
+ // Make sure there's only one highest-priority elem
+ expect(visibleElems.get(1)).toEqual(undefined);
+}
+
+describe("NotifPriority", function notifPriorityTests() {
+ beforeEach(() => {
+ this.wrapper = mount( )
+ this.trigger = () => {
+ ComponentRegistry.trigger();
+ this.wrapper.get(0).update();
+ }
+ })
+ describe("When there is only one notification", () => {
+ beforeEach(() => {
+ ComponentRegistry._clear();
+ ComponentRegistry.register(stubNotif(5), {role: 'RootSidebar:Notifications'})
+ this.trigger();
+ })
+ it("should mark it as highest-priority", () => {
+ checkHighestPriority(5, this.wrapper);
+ })
+ })
+ describe("when there are multiple notifications", () => {
+ beforeEach(() => {
+ this.components = [stubNotif(5), stubNotif(7), stubNotif(3), stubNotif(2)]
+ ComponentRegistry._clear();
+ this.components.forEach((item) => {
+ ComponentRegistry.register(item, {role: 'RootSidebar:Notifications'})
+ })
+ this.trigger();
+ })
+ it("should mark the proper one as highest-priority", () => {
+ checkHighestPriority(7, this.wrapper);
+ })
+ it("properly updates when a highest-priority notification is removed", () => {
+ ComponentRegistry.unregister(this.components[1])
+ this.trigger();
+ checkHighestPriority(5, this.wrapper);
+ })
+ it("properly updates when a higher priority notifcation is added", () => {
+ ComponentRegistry.register(stubNotif(10), {role: 'RootSidebar:Notifications'});
+ this.trigger();
+ checkHighestPriority(10, this.wrapper);
+ })
+ })
+});
diff --git a/packages/client-app/internal_packages/notifications/spec/update-notif-spec.jsx b/packages/client-app/internal_packages/notifications/spec/update-notif-spec.jsx
new file mode 100644
index 0000000000..04e3a176dc
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/spec/update-notif-spec.jsx
@@ -0,0 +1,77 @@
+import {mount} from 'enzyme';
+import proxyquire from 'proxyquire';
+import {React} from 'nylas-exports';
+
+let stubUpdaterState = null
+let stubUpdaterReleaseVersion = null
+let ipcSendArgs = null
+
+const patched = proxyquire("../lib/items/update-notification",
+ {
+ electron: {
+ ipcRenderer: {
+ send: (...args) => {
+ ipcSendArgs = args
+ },
+ },
+ remote: {
+ getGlobal: () => {
+ return {
+ autoUpdateManager: {
+ releaseVersion: stubUpdaterReleaseVersion,
+ getState: () => stubUpdaterState,
+ },
+ }
+ },
+ },
+ },
+ }
+)
+
+const UpdateNotification = patched.default;
+
+describe("UpdateNotification", function describeBlock() {
+ beforeEach(() => {
+ stubUpdaterState = 'idle'
+ stubUpdaterReleaseVersion = undefined
+ ipcSendArgs = null
+ })
+
+ describe("mounting", () => {
+ it("should display a notification immediately if one is available", () => {
+ stubUpdaterState = 'update-available'
+ const notif = mount( );
+ expect(notif.find('.notification').exists()).toEqual(true);
+ })
+
+ it("should not display a notification if no update is avialable", () => {
+ stubUpdaterState = 'no-update-available'
+ const notif = mount( );
+ expect(notif.find('.notification').exists()).toEqual(false);
+ })
+
+ it("should listen for `window:update-available`", () => {
+ spyOn(NylasEnv, 'onUpdateAvailable').andCallThrough()
+ mount( );
+ expect(NylasEnv.onUpdateAvailable).toHaveBeenCalled()
+ })
+ })
+
+ describe("displayNotification", () => {
+ it("should include the version if one is provided", () => {
+ stubUpdaterState = 'update-available'
+ stubUpdaterReleaseVersion = '0.515.0-123123'
+ const notif = mount( );
+ expect(notif.find('.title').text().indexOf('0.515.0-123123') >= 0).toBe(true);
+ })
+
+ describe("when the action is taken", () => {
+ it("should fire the `application:install-update` IPC event", () => {
+ stubUpdaterState = 'update-available'
+ const notif = mount( );
+ notif.find('#action-0').simulate('click'); // Expects the first action to be the install action
+ expect(ipcSendArgs).toEqual(['command', 'application:install-update'])
+ })
+ })
+ })
+})
diff --git a/packages/client-app/internal_packages/notifications/stylesheets/notifications.less b/packages/client-app/internal_packages/notifications/stylesheets/notifications.less
new file mode 100644
index 0000000000..bd8762275e
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/stylesheets/notifications.less
@@ -0,0 +1,268 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+.sidebar-activity-transition-wrapper {
+ order: 2;
+ z-index: 2;
+ overflow-y: auto;
+}
+
+.sidebar-activity {
+ display: block;
+ width: 100%;
+ bottom: 0;
+ background: @background-off-primary;
+ font-size: @font-size-small;
+ color: @text-color-subtle;
+ line-height:@line-height-computed * 0.95;
+ box-shadow:inset 0 1px 0 @border-color-divider;
+ &:hover { cursor: default }
+
+
+ .item {
+ &:hover { cursor: default }
+ .clickable { cursor: pointer; }
+
+ .inner {
+ padding: @padding-large-vertical @padding-base-horizontal @padding-large-vertical @padding-base-horizontal;
+ border-bottom: 1px solid rgba(0,0,0,0.1);
+
+ .ellipsis1 {
+ animation: show-ellipsis 3s 0s infinite;
+ }
+ .ellipsis2 {
+ animation: show-ellipsis 3s 250ms infinite;
+ }
+ .ellipsis3 {
+ animation: show-ellipsis 3s 500ms infinite;
+ }
+ }
+ .count {
+ color: @text-color-very-subtle;
+ float:right;
+ }
+ .btn {
+ display:block;
+ text-align:center;
+ margin-top:4px;
+ margin-bottom:4px;
+ font-size: @font-size-small;
+ }
+ // TODO: Necessary for Chromium 42 to render `activity-opacity-leave` animation
+ // properly. Removing position relative causes the div to remain visible
+ position:relative;
+ opacity: 1;
+
+ .account-detail-area {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.2s;
+ }
+ &.expanded-sync {
+ .account {
+ padding: @padding-base-vertical @padding-base-horizontal @padding-large-vertical*1.5 @padding-base-horizontal;
+ border-bottom: 1px solid rgba(0,0,0,0.1);
+
+ .model-progress::before {
+ height: 10px;
+ width: 10px;
+ background-color: #00dd00;
+ content: '';
+ display: inline-block;
+ border-radius: 5px;
+ margin-right: 5px;
+ box-sizing: border-box;
+ }
+
+ .model-progress.busy::before {
+ background-color: transparent;
+ border: solid 2px #00dd00;
+ animation: border-pulse 3s infinite;
+ }
+
+ .model-progress {
+ font-size: @font-size-base;
+ margin: 3px;
+ max-width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ .progress-label {
+ color: @text-color-very-subtle;
+ margin-left: 2px;
+ font-size: @font-size-smaller;
+ }
+ }
+ }
+ }
+ h2 {
+ font-size: 14px;
+ margin: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-weight: 500;
+ margin-bottom: @padding-large-vertical;
+ }
+ h3 {
+ font-size: 14px;
+ margin: 10px 0 4px 0;
+ }
+ .amount {
+ margin-top: 2px;
+ font-size: 12px;
+ color: @text-color-subtle;
+ }
+ .close-expanded {
+ padding: @padding-large-vertical @padding-base-horizontal;
+ position: absolute;
+ top: 0;
+ right: 0;
+ cursor: pointer;
+ }
+ }
+
+ transition: height 0.4s;
+ transition-delay: 2s;
+ &.sidebar-activity-error {
+ .progress {
+ background-color: @color-error;
+ }
+ }
+}
+
+.activity-opacity-enter {
+ opacity:0;
+ transition: opacity .125s ease-out;
+}
+
+.activity-opacity-enter.activity-opacity-enter-active {
+ opacity:1;
+}
+
+.activity-opacity-leave {
+ opacity:1;
+ transition: opacity .125s ease-in;
+ transition-delay: 0.5s;
+}
+
+.activity-opacity-leave.activity-opacity-leave-active {
+ transition-delay: 0.5s;
+ opacity:0;
+}
+
+.notifications-sticky {
+ width:100%;
+
+ .notification-info {
+ background-color: @background-color-info;
+ }
+ .notification-developer {
+ background-color: #615396;
+ }
+ .notification-upgrade {
+ background-image: -webkit-linear-gradient(bottom, #429E91, #40b1ac);
+ img { background-color: @text-color-inverse; }
+ }
+ .notification-error {
+ background: linear-gradient(to top, darken(@background-color-error, 4%) 0%, @background-color-error 100%);
+ border-color: @background-color-error;
+ color: @color-error;
+ }
+ .notification-offline {
+ background: linear-gradient(to top, darken(#CC9900, 4%) 0%, #CC9900 100%);
+ border-color: darken(#CC9900, 5%);
+ }
+
+ .notifications-sticky-item {
+ display:flex;
+ font-size: @font-size-base;
+ color: @text-color-inverse;
+ border-bottom:1px solid rgba(0,0,0,0.25);
+ padding-left: @padding-base-horizontal;
+ line-height: @line-height-base * 1.5;
+ align-items: baseline;
+ a {
+ flex-shrink: 0;
+ color:@text-color-inverse;
+ padding: 0 @padding-base-horizontal;
+ }
+ a:hover {
+ background-color: rgba(255,255,255,0.15);
+ text-decoration:none;
+ color:@text-color-inverse;
+ }
+ a.default {
+ background-color: rgba(0,0,0,0.15);
+ }
+ a.default:hover {
+ background-color: rgba(255,255,255,0.15);
+ }
+ i {
+ margin-right:@padding-base-horizontal;
+ }
+ .icon {
+ display: inline-block;
+ align-self: center;
+ line-height: 16px;
+ margin-right:@padding-base-horizontal;
+
+ img {
+ vertical-align: initial;
+ }
+ }
+
+ div.message {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: @line-height-base * 1.1;
+ padding: @padding-small-vertical 0;
+ }
+
+ &.has-default-action:hover {
+ -webkit-filter: brightness(110%);
+ cursor:default;
+ }
+ }
+}
+
+.blink {
+ animation: blink 1s ease;
+}
+
+@-webkit-keyframes blink {
+ 0%, 100%{
+ box-shadow: none;
+ }
+ 50% {
+ box-shadow: 5px 5px 1px rgba(37, 143, 225, 1) inset,
+ -5px -5px 1px rgba(37, 143, 225, 1) inset;
+ }
+}
+
+@-webkit-keyframes border-pulse {
+ 0%, 100%{
+ border-color: #00dd00;
+ }
+ 50% {
+ border-color: transparent;
+ }
+}
+
+@-webkit-keyframes show-ellipsis {
+ 0%, 100% {opacity: 0;}
+ 50%, {opacity: 1.0;}
+}
+
+// Windows Changes
+
+body.platform-win32 {
+ .notifications-sticky {
+ .notifications-sticky-item {
+ a {
+ border-radius: 0;
+ }
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/notifications/stylesheets/styles.less b/packages/client-app/internal_packages/notifications/stylesheets/styles.less
new file mode 100644
index 0000000000..46ad8ce85a
--- /dev/null
+++ b/packages/client-app/internal_packages/notifications/stylesheets/styles.less
@@ -0,0 +1,96 @@
+@import 'ui-variables';
+
+.notifications {
+ background-color: @panel-background-color;
+ box-shadow: 0 -6px 4px @panel-background-color;
+ z-index: 2;
+}
+
+.notification {
+ background: @background-color-info;
+ display: none;
+ color: @text-color-inverse;
+ margin: 10px;
+ margin-top: 0;
+ border-radius: @border-radius-large;
+}
+
+.notification.error {
+ background: @background-color-error;
+}
+
+.notification.offline {
+ background: linear-gradient(to top, darken(#CC9900, 4%) 0%, #CC9900 100%);
+}
+
+.notification.highest-priority {
+ display: block;
+}
+
+.notif-top {
+ display: flex;
+ align-items: flex-start;
+ padding: 10px;
+}
+
+.notification .icon {
+ margin-right: 10px;
+}
+
+.notification .title {
+ padding: 10px;
+}
+
+.notification .subtitle {
+ font-size: @font-size-smaller;
+ position: relative;
+ opacity: 0.8;
+}
+
+.notification .subtitle.has-action {
+ cursor: pointer;
+}
+
+.notification .subtitle.has-action::after {
+ content:'';
+ background: url(nylas://notifications/assets/minichevron@2x.png) top left no-repeat;
+ background-size: 4.5px 7px;
+ margin-left:3px;
+ display: inline-block;
+ width:4.5px;
+ height:7px;
+ vertical-align: baseline;
+}
+
+.notification .actions-wrapper {
+ display: flex;
+}
+
+.notification .action {
+ text-align: center;
+ flex: 1;
+ border-top: solid rgba(255, 255, 255, 0.5) 1px;
+ border-left: solid rgba(255, 255, 255, 0.5) 1px;
+ padding: 10px;
+ cursor: pointer;
+
+ /* The semi-transparent backgrounds that can be layered on top
+ of this class shouldn't have sharp corners on the bottom */
+ border-bottom-left-radius: @border-radius-large;
+ border-bottom-right-radius: @border-radius-large;
+}
+
+.notification .action:first-child {
+ border-left: none;
+}
+
+.notification .action:hover {
+ background-color: rgba(255, 255, 255, 0.2);
+ box-shadow: @standard-shadow inset;
+}
+
+.notification .action.loading {
+ cursor: progress;
+ background-color: rgba(0, 0, 0, 0.2);
+ box-shadow: @standard-shadow inset;
+}
diff --git a/packages/client-app/internal_packages/onboarding/assets/app-screenshot@2x.png b/packages/client-app/internal_packages/onboarding/assets/app-screenshot@2x.png
new file mode 100644
index 0000000000..c446ddeb03
Binary files /dev/null and b/packages/client-app/internal_packages/onboarding/assets/app-screenshot@2x.png differ
diff --git a/packages/client-app/internal_packages/onboarding/assets/feature-activity@2x.png b/packages/client-app/internal_packages/onboarding/assets/feature-activity@2x.png
new file mode 100755
index 0000000000..78413c0a18
Binary files /dev/null and b/packages/client-app/internal_packages/onboarding/assets/feature-activity@2x.png differ
diff --git a/packages/client-app/internal_packages/onboarding/assets/feature-composer@2x.png b/packages/client-app/internal_packages/onboarding/assets/feature-composer@2x.png
new file mode 100755
index 0000000000..e1a9c7fd27
Binary files /dev/null and b/packages/client-app/internal_packages/onboarding/assets/feature-composer@2x.png differ
diff --git a/packages/client-app/internal_packages/onboarding/assets/feature-people@2x.png b/packages/client-app/internal_packages/onboarding/assets/feature-people@2x.png
new file mode 100755
index 0000000000..90fc1cef71
Binary files /dev/null and b/packages/client-app/internal_packages/onboarding/assets/feature-people@2x.png differ
diff --git a/packages/client-app/internal_packages/onboarding/assets/feature-snooze@2x.png b/packages/client-app/internal_packages/onboarding/assets/feature-snooze@2x.png
new file mode 100755
index 0000000000..d2ad6d1b0e
Binary files /dev/null and b/packages/client-app/internal_packages/onboarding/assets/feature-snooze@2x.png differ
diff --git a/packages/client-app/internal_packages/onboarding/assets/icons-bg@2x.png b/packages/client-app/internal_packages/onboarding/assets/icons-bg@2x.png
new file mode 100644
index 0000000000..f10417dde6
Binary files /dev/null and b/packages/client-app/internal_packages/onboarding/assets/icons-bg@2x.png differ
diff --git a/packages/client-app/internal_packages/onboarding/assets/lock@2x.png b/packages/client-app/internal_packages/onboarding/assets/lock@2x.png
new file mode 100644
index 0000000000..86b3b0e849
Binary files /dev/null and b/packages/client-app/internal_packages/onboarding/assets/lock@2x.png differ
diff --git a/packages/client-app/internal_packages/onboarding/assets/nylas-logo@2x.png b/packages/client-app/internal_packages/onboarding/assets/nylas-logo@2x.png
new file mode 100644
index 0000000000..a13a6ba745
Binary files /dev/null and b/packages/client-app/internal_packages/onboarding/assets/nylas-logo@2x.png differ
diff --git a/packages/client-app/internal_packages/onboarding/assets/nylas-love@2x.png b/packages/client-app/internal_packages/onboarding/assets/nylas-love@2x.png
new file mode 100644
index 0000000000..93e4deecf3
Binary files /dev/null and b/packages/client-app/internal_packages/onboarding/assets/nylas-love@2x.png differ
diff --git a/packages/client-app/internal_packages/onboarding/lib/account-types.es6 b/packages/client-app/internal_packages/onboarding/lib/account-types.es6
new file mode 100644
index 0000000000..7d3027dd37
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/account-types.es6
@@ -0,0 +1,65 @@
+// const TODO_ACCOUNT_TYPES = [
+// {
+// type: 'exchange',
+// displayName: 'Microsoft Exchange',
+// icon: 'ic-settings-account-eas.png',
+// headerIcon: 'setup-icon-provider-exchange.png',
+// color: '#1ea2a3',
+// },
+// {
+// type: 'outlook',
+// displayName: 'Outlook.com',
+// icon: 'ic-settings-account-outlook.png',
+// headerIcon: 'setup-icon-provider-outlook.png',
+// color: '#1174c3',
+// },
+// ]
+
+const AccountTypes = [
+ {
+ type: 'gmail',
+ displayName: 'Gmail or G Suite',
+ icon: 'ic-settings-account-gmail.png',
+ headerIcon: 'setup-icon-provider-gmail.png',
+ color: '#e99999',
+ },
+ {
+ type: 'office365',
+ displayName: 'Office 365',
+ icon: 'ic-settings-account-outlook.png',
+ headerIcon: 'setup-icon-provider-outlook.png',
+ color: '#0078d7',
+ },
+ {
+ type: 'yahoo',
+ displayName: 'Yahoo',
+ icon: 'ic-settings-account-yahoo.png',
+ headerIcon: 'setup-icon-provider-yahoo.png',
+ color: '#a76ead',
+ },
+ {
+ type: 'icloud',
+ displayName: 'iCloud',
+ icon: 'ic-settings-account-icloud.png',
+ headerIcon: 'setup-icon-provider-icloud.png',
+ color: '#61bfe9',
+ },
+ {
+ type: 'fastmail',
+ displayName: 'FastMail',
+ title: 'Set up your account',
+ icon: 'ic-settings-account-fastmail.png',
+ headerIcon: 'setup-icon-provider-fastmail.png',
+ color: '#24345a',
+ },
+ {
+ type: 'imap',
+ displayName: 'IMAP / SMTP',
+ title: 'Set up your IMAP account',
+ icon: 'ic-settings-account-imap.png',
+ headerIcon: 'setup-icon-provider-imap.png',
+ color: '#aaa',
+ },
+]
+
+export default AccountTypes;
diff --git a/packages/client-app/internal_packages/onboarding/lib/decorators/create-page-for-form.jsx b/packages/client-app/internal_packages/onboarding/lib/decorators/create-page-for-form.jsx
new file mode 100644
index 0000000000..0df8ff0a41
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/decorators/create-page-for-form.jsx
@@ -0,0 +1,247 @@
+import {shell} from 'electron'
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {RetinaImg} from 'nylas-component-kit';
+import {NylasAPI, Actions} from 'nylas-exports';
+
+import OnboardingActions from '../onboarding-actions';
+import {runAuthRequest} from '../onboarding-helpers';
+import FormErrorMessage from '../form-error-message';
+import AccountTypes from '../account-types'
+
+const CreatePageForForm = (FormComponent) => {
+ return class Composed extends React.Component {
+ static displayName = FormComponent.displayName;
+
+ static propTypes = {
+ accountInfo: React.PropTypes.object,
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.state = Object.assign({
+ accountInfo: JSON.parse(JSON.stringify(this.props.accountInfo)),
+ errorFieldNames: [],
+ errorMessage: null,
+ }, FormComponent.validateAccountInfo(this.props.accountInfo));
+ }
+
+ componentDidMount() {
+ this._applyFocus();
+ }
+
+ componentDidUpdate() {
+ this._applyFocus();
+ }
+
+ _applyFocus() {
+ const anyInputFocused = document.activeElement && document.activeElement.nodeName === 'INPUT';
+ if (anyInputFocused) {
+ return;
+ }
+
+ const inputs = Array.from(ReactDOM.findDOMNode(this).querySelectorAll('input'));
+ if (inputs.length === 0) {
+ return;
+ }
+
+ for (const input of inputs) {
+ if (input.value === '') {
+ input.focus();
+ return;
+ }
+ }
+ inputs[0].focus();
+ }
+
+ _isValid() {
+ const {populated, errorFieldNames} = this.state
+ return errorFieldNames.length === 0 && populated
+ }
+
+ onFieldChange = (event) => {
+ const changes = {};
+ if (event.target.type === 'checkbox') {
+ changes[event.target.id] = event.target.checked;
+ } else {
+ changes[event.target.id] = event.target.value;
+ if (event.target.id === 'email') {
+ changes[event.target.id] = event.target.value.trim();
+ }
+ }
+
+ const accountInfo = Object.assign({}, this.state.accountInfo, changes);
+ const {errorFieldNames, errorMessage, populated} = FormComponent.validateAccountInfo(accountInfo);
+
+ this.setState({accountInfo, errorFieldNames, errorMessage, populated, errorStatusCode: null});
+ }
+
+ onSubmit = () => {
+ OnboardingActions.setAccountInfo(this.state.accountInfo);
+ this.refs.form.submit();
+ }
+
+ onFieldKeyPress = (event) => {
+ if (!this._isValid()) { return }
+ if (['Enter', 'Return'].includes(event.key)) {
+ this.onSubmit();
+ }
+ }
+
+ onBack = () => {
+ OnboardingActions.setAccountInfo(this.state.accountInfo);
+ OnboardingActions.moveToPreviousPage();
+ }
+
+ onConnect = (updatedAccountInfo) => {
+ const accountInfo = updatedAccountInfo || this.state.accountInfo;
+
+ this.setState({submitting: true});
+
+ runAuthRequest(accountInfo)
+ .then((json) => {
+ OnboardingActions.moveToPage('account-onboarding-success')
+ OnboardingActions.accountJSONReceived(json, json.localToken, json.cloudToken)
+ })
+ .catch((err) => {
+ Actions.recordUserEvent('Email Account Auth Failed', {
+ errorMessage: err.message,
+ provider: accountInfo.type,
+ })
+
+ const errorFieldNames = err.body ? (err.body.missing_fields || err.body.missing_settings || []) : []
+ let errorMessage = err.message;
+ const errorStatusCode = err.statusCode
+
+ if (err.errorType === "setting_update_error") {
+ errorMessage = 'The IMAP/SMTP servers for this account do not match our records. Please verify that any server names you entered are correct. If your IMAP/SMTP server has changed, first remove this account from Nylas Mail, then try logging in again.';
+ }
+ if (err.errorType && err.errorType.includes("autodiscover") && (accountInfo.type === 'exchange')) {
+ errorFieldNames.push('eas_server_host')
+ errorFieldNames.push('username');
+ }
+ if (err.statusCode === 401) {
+ if (/smtp/i.test(err.message)) {
+ errorFieldNames.push('smtp_username');
+ errorFieldNames.push('smtp_password');
+ }
+ if (/imap/i.test(err.message)) {
+ errorFieldNames.push('imap_username');
+ errorFieldNames.push('imap_password');
+ }
+ // not sure what these are for -- backcompat?
+ errorFieldNames.push('password')
+ errorFieldNames.push('email');
+ errorFieldNames.push('username');
+ }
+ if (NylasAPI.TimeoutErrorCodes.includes(err.statusCode)) { // timeout
+ errorMessage = "We were unable to reach your mail provider. Please try again."
+ }
+
+ this.setState({errorMessage, errorStatusCode, errorFieldNames, submitting: false});
+ });
+ }
+
+ _renderButton() {
+ const {accountInfo, submitting} = this.state;
+ const buttonLabel = FormComponent.submitLabel(accountInfo);
+
+ // We're not on the last page.
+ if (submitting) {
+ return (
+
+
+ Adding account…
+
+ );
+ }
+
+ if (!this._isValid()) {
+ return (
+ {buttonLabel}
+ );
+ }
+
+ return (
+ {buttonLabel}
+ );
+ }
+
+ // When a user enters the wrong credentials, show a message that could
+ // help with common problems. For instance, they may need an app password,
+ // or to enable specific settings with their provider.
+ _renderCredentialsNote() {
+ const {errorStatusCode, accountInfo} = this.state;
+ if (errorStatusCode !== 401) { return false; }
+ let message;
+ let articleURL;
+ if (accountInfo.email.includes("@yahoo.com")) {
+ message = "Have you enabled access through Yahoo?";
+ articleURL = "https://support.nylas.com/hc/en-us/articles/115001076128";
+ } else {
+ message = "Some providers require an app password."
+ articleURL = "https://support.nylas.com/hc/en-us/articles/115001056608";
+ }
+ // We don't use a FormErrorMessage component because the content
+ // we need to display has HTML.
+ return (
+
+ );
+ }
+
+ render() {
+ const {accountInfo, errorMessage, errorFieldNames, submitting} = this.state;
+ const AccountType = AccountTypes.find(a => a.type === accountInfo.type);
+
+ if (!AccountType) {
+ throw new Error(`Cannot find account type ${accountInfo.type}`);
+ }
+
+ const hideTitle = errorMessage && errorMessage.length > 120;
+
+ return (
+
+
+
+
+ {hideTitle ?
:
{FormComponent.titleLabel(AccountType)} }
+
+ { this._renderCredentialsNote() }
+
+
+
Back
+ {this._renderButton()}
+
+
+ );
+ }
+ }
+}
+
+export default CreatePageForForm;
diff --git a/packages/client-app/internal_packages/onboarding/lib/form-error-message.jsx b/packages/client-app/internal_packages/onboarding/lib/form-error-message.jsx
new file mode 100644
index 0000000000..1f86f562b9
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/form-error-message.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import {RegExpUtils} from 'nylas-exports';
+
+const FormErrorMessage = (props) => {
+ const {message, empty} = props;
+ if (!message) {
+ return {empty}
;
+ }
+
+ const result = RegExpUtils.urlRegex({matchEntireString: false}).exec(message);
+ if (result) {
+ const link = result[0];
+ return (
+
+ {message.substr(0, result.index)}
+
{link}
+ {message.substr(result.index + link.length)}
+
+ );
+ }
+
+ return (
+
+ {message}
+
+ );
+}
+
+FormErrorMessage.propTypes = {
+ empty: React.PropTypes.string,
+ message: React.PropTypes.string,
+};
+
+export default FormErrorMessage;
diff --git a/packages/client-app/internal_packages/onboarding/lib/form-field.jsx b/packages/client-app/internal_packages/onboarding/lib/form-field.jsx
new file mode 100644
index 0000000000..2e187bf432
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/form-field.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+
+const FormField = (props) => {
+ return (
+
+ {props.title}:
+
+
+ );
+}
+
+FormField.propTypes = {
+ field: React.PropTypes.string,
+ title: React.PropTypes.string,
+ type: React.PropTypes.string,
+ style: React.PropTypes.object,
+ submitting: React.PropTypes.bool,
+ onFieldKeyPress: React.PropTypes.func,
+ onFieldChange: React.PropTypes.func,
+ errorFieldNames: React.PropTypes.array,
+ accountInfo: React.PropTypes.object,
+}
+
+export default FormField;
diff --git a/packages/client-app/internal_packages/onboarding/lib/main.es6 b/packages/client-app/internal_packages/onboarding/lib/main.es6
new file mode 100644
index 0000000000..1644e02ef7
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/main.es6
@@ -0,0 +1,34 @@
+import {SystemStartService, WorkspaceStore, ComponentRegistry} from 'nylas-exports';
+import OnboardingRoot from './onboarding-root';
+
+export function activate() {
+ WorkspaceStore.defineSheet('Main', {root: true}, {list: ['Center']});
+
+ ComponentRegistry.register(OnboardingRoot, {
+ location: WorkspaceStore.Location.Center,
+ });
+
+ const accounts = NylasEnv.config.get('nylas.accounts') || [];
+
+ if (accounts.length === 0) {
+ const startService = new SystemStartService();
+ startService.checkAvailability().then((available) => {
+ if (!available) {
+ return;
+ }
+ startService.doesLaunchOnSystemStart().then((launchesOnStart) => {
+ if (!launchesOnStart) {
+ startService.configureToLaunchOnSystemStart();
+ }
+ });
+ });
+ }
+}
+
+export function deactivate() {
+
+}
+
+export function serialize() {
+
+}
diff --git a/packages/client-app/internal_packages/onboarding/lib/onboarding-actions.es6 b/packages/client-app/internal_packages/onboarding/lib/onboarding-actions.es6
new file mode 100644
index 0000000000..c2602e76f9
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/onboarding-actions.es6
@@ -0,0 +1,16 @@
+import Reflux from 'reflux';
+
+const OnboardingActions = Reflux.createActions([
+ "setAccountInfo",
+ "setAccountType",
+ "moveToPreviousPage",
+ "moveToPage",
+ "authenticationJSONReceived",
+ "accountJSONReceived",
+]);
+
+for (const key of Object.keys(OnboardingActions)) {
+ OnboardingActions[key].sync = true;
+}
+
+export default OnboardingActions;
diff --git a/packages/client-app/internal_packages/onboarding/lib/onboarding-helpers.es6 b/packages/client-app/internal_packages/onboarding/lib/onboarding-helpers.es6
new file mode 100644
index 0000000000..55c848e80b
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/onboarding-helpers.es6
@@ -0,0 +1,201 @@
+/* eslint global-require: 0 */
+
+import crypto from 'crypto';
+import {CommonProviderSettings} from 'isomorphic-core'
+import {
+ N1CloudAPI,
+ NylasAPI,
+ NylasAPIRequest,
+ RegExpUtils,
+} from 'nylas-exports';
+
+const IMAP_FIELDS = new Set([
+ "imap_host",
+ "imap_port",
+ "imap_username",
+ "imap_password",
+ "imap_security",
+ "imap_allow_insecure_ssl",
+ "smtp_host",
+ "smtp_port",
+ "smtp_username",
+ "smtp_password",
+ "smtp_security",
+ "smtp_allow_insecure_ssl",
+]);
+
+function base64url(inBuffer) {
+ let buffer;
+ if (typeof inBuffer === "string") {
+ buffer = new Buffer(inBuffer);
+ } else if (inBuffer instanceof Buffer) {
+ buffer = inBuffer;
+ } else {
+ throw new Error(`${inBuffer} must be a string or Buffer`)
+ }
+ return buffer.toString('base64')
+ .replace(/\+/g, '-') // Convert '+' to '-'
+ .replace(/\//g, '_'); // Convert '/' to '_'
+}
+
+const NO_AUTH = { user: '', pass: '', sendImmediately: true };
+
+export async function makeGmailOAuthRequest(sessionKey) {
+ const remoteRequest = new NylasAPIRequest({
+ api: N1CloudAPI,
+ options: {
+ path: `/auth/gmail/token?key=${sessionKey}`,
+ method: 'GET',
+ auth: NO_AUTH,
+ },
+ });
+ return remoteRequest.run()
+}
+
+export async function authIMAPForGmail(tokenData) {
+ const localRequest = new NylasAPIRequest({
+ api: NylasAPI,
+ options: {
+ path: `/auth`,
+ method: 'POST',
+ auth: NO_AUTH,
+ timeout: 1000 * 90, // Connecting to IMAP could take up to 90 seconds, so we don't want to hang up too soon
+ body: {
+ email: tokenData.email_address,
+ name: tokenData.name,
+ provider: 'gmail',
+ settings: {
+ xoauth2: tokenData.resolved_settings.xoauth2,
+ expiry_date: tokenData.resolved_settings.expiry_date,
+ },
+ },
+ },
+ })
+ const localJSON = await localRequest.run()
+ const account = Object.assign({}, localJSON);
+ account.localToken = localJSON.account_token;
+ account.cloudToken = tokenData.account_token;
+ return account
+}
+
+export function buildGmailSessionKey() {
+ return base64url(crypto.randomBytes(40));
+}
+
+export function buildGmailAuthURL(sessionKey) {
+ return `${N1CloudAPI.APIRoot}/auth/gmail?state=${sessionKey}`;
+}
+
+export function runAuthRequest(accountInfo) {
+ const {username, type, email, name} = accountInfo;
+
+ const data = {
+ provider: type,
+ email: email,
+ name: name,
+ settings: Object.assign({}, accountInfo),
+ };
+
+ // handle special case for exchange/outlook/hotmail username field
+ data.settings.username = username || email;
+
+ if (data.settings.imap_port) {
+ data.settings.imap_port /= 1;
+ }
+ if (data.settings.smtp_port) {
+ data.settings.smtp_port /= 1;
+ }
+ // if there's an account with this email, get the ID for it to notify the backend of re-auth
+ // const account = AccountStore.accountForEmail(accountInfo.email);
+ // const reauthParam = account ? `&reauth=${account.id}` : "";
+
+ /**
+ * Only include the required IMAP fields. Auth validation does not allow
+ * extra fields
+ */
+ if (type !== "gmail" && type !== "office365") {
+ for (const key of Object.keys(data.settings)) {
+ if (!IMAP_FIELDS.has(key)) {
+ delete data.settings[key]
+ }
+ }
+ }
+
+ const noauth = {
+ user: '',
+ pass: '',
+ sendImmediately: true,
+ };
+
+ // Send the form data directly to Nylas to get code
+ // If this succeeds, send the received code to N1 server to register the account
+ // Otherwise process the error message from the server and highlight UI as needed
+ const n1CloudIMAPAuthRequest = new NylasAPIRequest({
+ api: N1CloudAPI,
+ options: {
+ path: '/auth',
+ method: 'POST',
+ timeout: 1000 * 180, // Same timeout as server timeout (most requests are faster than 90s, but server validation can be slow in some cases)
+ body: data,
+ auth: noauth,
+ },
+ })
+ return n1CloudIMAPAuthRequest.run().then((remoteJSON) => {
+ const localSyncIMAPAuthRequest = new NylasAPIRequest({
+ api: NylasAPI,
+ options: {
+ path: `/auth`,
+ method: 'POST',
+ timeout: 1000 * 180, // Same timeout as server timeout (most requests are faster than 90s, but server validation can be slow in some cases)
+ body: data,
+ auth: noauth,
+ },
+ })
+ return localSyncIMAPAuthRequest.run().then((localJSON) => {
+ const accountWithTokens = Object.assign({}, localJSON);
+ accountWithTokens.localToken = localJSON.account_token;
+ accountWithTokens.cloudToken = remoteJSON.account_token;
+ return accountWithTokens
+ })
+ })
+}
+
+export function isValidHost(value) {
+ return RegExpUtils.domainRegex().test(value) || RegExpUtils.ipAddressRegex().test(value);
+}
+
+export function accountInfoWithIMAPAutocompletions(existingAccountInfo) {
+ const {email, type} = existingAccountInfo;
+ const domain = email.split('@').pop().toLowerCase();
+ let template = CommonProviderSettings[domain] || CommonProviderSettings[type] || {};
+ if (template.alias) {
+ template = CommonProviderSettings[template.alias];
+ }
+
+ const usernameWithFormat = (format) => {
+ if (format === 'email') {
+ return email
+ }
+ if (format === 'email-without-domain') {
+ return email.split('@').shift();
+ }
+ return undefined;
+ }
+
+ const defaults = {
+ imap_host: template.imap_host,
+ imap_port: template.imap_port || 993,
+ imap_username: usernameWithFormat(template.imap_user_format),
+ imap_password: existingAccountInfo.password,
+ imap_security: template.imap_security || "SSL / TLS",
+ imap_allow_insecure_ssl: template.imap_allow_insecure_ssl || false,
+ smtp_host: template.smtp_host,
+ smtp_port: template.smtp_port || 587,
+ smtp_username: usernameWithFormat(template.smtp_user_format),
+ smtp_password: existingAccountInfo.password,
+ smtp_security: template.smtp_security || "STARTTLS",
+ smtp_allow_insecure_ssl: template.smtp_allow_insecure_ssl || false,
+ }
+
+ return Object.assign({}, existingAccountInfo, defaults);
+}
diff --git a/packages/client-app/internal_packages/onboarding/lib/onboarding-root.jsx b/packages/client-app/internal_packages/onboarding/lib/onboarding-root.jsx
new file mode 100644
index 0000000000..7ad1c59847
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/onboarding-root.jsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
+import {Actions} from 'nylas-exports'
+import OnboardingStore from './onboarding-store';
+import PageTopBar from './page-top-bar';
+
+import WelcomePage from './page-welcome';
+import TutorialPage from './page-tutorial';
+import AuthenticatePage from './page-authenticate';
+import AccountChoosePage from './page-account-choose';
+import AccountSettingsPage from './page-account-settings';
+import AccountSettingsPageGmail from './page-account-settings-gmail';
+import AccountSettingsPageIMAP from './page-account-settings-imap';
+import AccountOnboardingSuccess from './page-account-onboarding-success';
+import AccountSettingsPageExchange from './page-account-settings-exchange';
+import InitialPreferencesPage from './page-initial-preferences';
+
+
+const PageComponents = {
+ "welcome": WelcomePage,
+ "tutorial": TutorialPage,
+ "authenticate": AuthenticatePage,
+ "account-choose": AccountChoosePage,
+ "account-settings": AccountSettingsPage,
+ "account-settings-gmail": AccountSettingsPageGmail,
+ "account-settings-imap": AccountSettingsPageIMAP,
+ "account-settings-exchange": AccountSettingsPageExchange,
+ "account-onboarding-success": AccountOnboardingSuccess,
+ "initial-preferences": InitialPreferencesPage,
+}
+
+export default class OnboardingRoot extends React.Component {
+ static displayName = 'OnboardingRoot';
+ static containerRequired = false;
+
+ constructor(props) {
+ super(props);
+ this.state = this._getStateFromStore();
+ }
+
+ componentDidMount() {
+ this.unsubscribe = OnboardingStore.listen(this._onStateChanged, this);
+ NylasEnv.center();
+ NylasEnv.displayWindow();
+
+ if (NylasEnv.timer.isPending('open-add-account-window')) {
+ const {source} = NylasEnv.getWindowProps()
+ Actions.recordPerfMetric({
+ source,
+ action: 'open-add-account-window',
+ actionTimeMs: NylasEnv.timer.stop('open-add-account-window'),
+ maxValue: 4 * 1000,
+ })
+ }
+
+ if (NylasEnv.timer.isPending('app-boot')) {
+ // If this component is mounted and we are /still/ timing `app-boot`, it
+ // means that the app booted for an unauthenticated user and we are
+ // showing the onboarding window for the first time.
+ // In this case, we can't report `app-boot` time because we don't have a
+ // nylasId or accountId required to report a metric.
+ // However, we do want to clear the timer by stopping it
+ NylasEnv.timer.stop('app-boot')
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) {
+ this.unsubscribe();
+ }
+ }
+
+ _getStateFromStore = () => {
+ return {
+ page: OnboardingStore.page(),
+ pageDepth: OnboardingStore.pageDepth(),
+ accountInfo: OnboardingStore.accountInfo(),
+ };
+ }
+
+ _onStateChanged = () => {
+ this.setState(this._getStateFromStore());
+ }
+
+ render() {
+ const Component = PageComponents[this.state.page];
+ if (!Component) {
+ throw new Error(`Cannot find component for page: ${this.state.page}`);
+ }
+
+ return (
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/onboarding/lib/onboarding-store.es6 b/packages/client-app/internal_packages/onboarding/lib/onboarding-store.es6
new file mode 100644
index 0000000000..de336eb447
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/onboarding-store.es6
@@ -0,0 +1,203 @@
+import {AccountStore, Actions, IdentityStore, FolderSyncProgressStore} from 'nylas-exports';
+import {ipcRenderer} from 'electron';
+import NylasStore from 'nylas-store';
+
+import OnboardingActions from './onboarding-actions';
+
+function accountTypeForProvider(provider) {
+ if (provider === 'eas') {
+ return 'exchange';
+ }
+ if (provider === 'custom') {
+ return 'imap';
+ }
+ return provider;
+}
+
+class OnboardingStore extends NylasStore {
+ constructor() {
+ super();
+
+ NylasEnv.config.onDidChange('env', this._onEnvChanged);
+ this._onEnvChanged();
+
+ this.listenTo(OnboardingActions.moveToPreviousPage, this._onMoveToPreviousPage)
+ this.listenTo(OnboardingActions.moveToPage, this._onMoveToPage)
+ this.listenTo(OnboardingActions.accountJSONReceived, this._onAccountJSONReceived)
+ this.listenTo(OnboardingActions.authenticationJSONReceived, this._onAuthenticationJSONReceived)
+ this.listenTo(OnboardingActions.setAccountInfo, this._onSetAccountInfo);
+ this.listenTo(OnboardingActions.setAccountType, this._onSetAccountType);
+ ipcRenderer.on('set-account-type', (e, type) => {
+ if (type) {
+ this._onSetAccountType(type)
+ } else {
+ this._pageStack = ['account-choose']
+ this.trigger()
+ }
+ })
+
+ const {existingAccount, addingAccount, accountType} = NylasEnv.getWindowProps();
+
+ const hasAccounts = (AccountStore.accounts().length > 0)
+ const identity = IdentityStore.identity();
+
+ if (identity) {
+ this._accountInfo = {
+ name: `${identity.firstname || ""} ${identity.lastname || ""}`,
+ };
+ } else {
+ this._accountInfo = {};
+ }
+
+ if (existingAccount) {
+ // Used when re-adding an account after re-connecting
+ const existingAccountType = accountTypeForProvider(existingAccount.provider);
+ this._pageStack = ['account-choose']
+ this._accountInfo = {
+ name: existingAccount.name,
+ email: existingAccount.emailAddress,
+ };
+ this._onSetAccountType(existingAccountType);
+ } else if (addingAccount) {
+ // Adding a new, unknown account
+ this._pageStack = ['account-choose'];
+ if (accountType) {
+ this._onSetAccountType(accountType);
+ }
+ } else if (identity) {
+ // Should only happen if config was edited to remove all accounts,
+ // but don't want to re-login to Nylas account. Very useful when
+ // switching environments.
+ this._pageStack = ['account-choose'];
+ } else if (hasAccounts) {
+ // Should only happen when the user has "signed out" of their Nylas ID,
+ // but already has accounts synced. Or is upgrading from a very old build.
+ // We used to show "Welcome Back", but now just jump to sign in.
+ this._pageStack = ['authenticate'];
+ } else {
+ // Standard new user onboarding flow.
+ this._pageStack = ['welcome'];
+ }
+ }
+
+ _onEnvChanged = () => {
+ const env = NylasEnv.config.get('env')
+ if (['development', 'local'].includes(env)) {
+ this.welcomeRoot = "http://0.0.0.0:5555";
+ } else if (env === 'experimental') {
+ this.welcomeRoot = "https://www-experimental.nylas.com";
+ } else if (env === 'staging') {
+ this.welcomeRoot = "https://www-staging.nylas.com";
+ } else {
+ this.welcomeRoot = "https://nylas.com";
+ }
+ }
+
+ _onOnboardingComplete = () => {
+ // When account JSON is received, we want to notify external services
+ // that it succeeded. Unfortunately in this case we're likely to
+ // close the window before those requests can be made. We add a short
+ // delay here to ensure that any pending requests have a chance to
+ // clear before the window closes.
+ setTimeout(() => {
+ ipcRenderer.send('account-setup-successful');
+ }, 100);
+ }
+
+ _onSetAccountType = (type) => {
+ let nextPage = "account-settings";
+ if (type === 'gmail') {
+ nextPage = "account-settings-gmail";
+ } else if (type === 'exchange') {
+ nextPage = "account-settings-exchange";
+ }
+
+ Actions.recordUserEvent('Selected Account Type', {
+ provider: type,
+ });
+
+ // Don't carry over any type-specific account information
+ const {email, name, password} = this._accountInfo;
+ this._onSetAccountInfo({email, name, password, type});
+ this._onMoveToPage(nextPage);
+ }
+
+ _onSetAccountInfo = (info) => {
+ this._accountInfo = info;
+ this.trigger();
+ }
+
+ _onMoveToPreviousPage = () => {
+ this._pageStack.pop();
+ this.trigger();
+ }
+
+ _onMoveToPage = (page) => {
+ this._pageStack.push(page)
+ this.trigger();
+ }
+
+ _onAuthenticationJSONReceived = async (json) => {
+ const isFirstAccount = AccountStore.accounts().length === 0;
+
+ await IdentityStore.saveIdentity(json);
+
+ setTimeout(() => {
+ if (isFirstAccount) {
+ this._onSetAccountInfo(Object.assign({}, this._accountInfo, {
+ name: `${json.firstname || ""} ${json.lastname || ""}`,
+ email: json.email,
+ }));
+ OnboardingActions.moveToPage('account-choose');
+ } else {
+ this._onOnboardingComplete();
+ }
+ }, 1000);
+ }
+
+ _onAccountJSONReceived = async (json, localToken, cloudToken) => {
+ try {
+ const isFirstAccount = AccountStore.accounts().length === 0;
+
+ AccountStore.addAccountFromJSON(json, localToken, cloudToken);
+ this._accountFromAuth = AccountStore.accountForEmail(json.email_address);
+
+ Actions.recordUserEvent('Email Account Auth Succeeded', {
+ provider: this._accountFromAuth.provider,
+ });
+ ipcRenderer.send('new-account-added');
+ NylasEnv.displayWindow();
+
+ if (isFirstAccount) {
+ this._onMoveToPage('initial-preferences');
+ Actions.recordUserEvent('First Account Linked', {
+ provider: this._accountFromAuth.provider,
+ });
+ } else {
+ await FolderSyncProgressStore.whenCategoryListSynced(json.id)
+ this._onOnboardingComplete();
+ }
+ } catch (e) {
+ NylasEnv.reportError(e);
+ NylasEnv.showErrorDialog("Unable to Connect Account", "Sorry, something went wrong on the Nylas server. Please try again. If you're still having issues, contact us at support@nylas.com.");
+ }
+ }
+
+ page() {
+ return this._pageStack[this._pageStack.length - 1];
+ }
+
+ pageDepth() {
+ return this._pageStack.length;
+ }
+
+ accountInfo() {
+ return this._accountInfo;
+ }
+
+ accountFromAuth() {
+ return this._accountFromAuth;
+ }
+}
+
+export default new OnboardingStore();
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-account-choose.jsx b/packages/client-app/internal_packages/onboarding/lib/page-account-choose.jsx
new file mode 100644
index 0000000000..b4326ffe09
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-account-choose.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import {RetinaImg} from 'nylas-component-kit';
+import OnboardingActions from './onboarding-actions';
+import AccountTypes from './account-types';
+
+export default class AccountChoosePage extends React.Component {
+ static displayName = "AccountChoosePage";
+
+ static propTypes = {
+ accountInfo: React.PropTypes.object,
+ }
+
+ _renderAccountTypes() {
+ return AccountTypes.map((accountType) =>
+ OnboardingActions.setAccountType(accountType.type)}
+ >
+
+
+
+
{accountType.displayName}
+
+ );
+ }
+
+ render() {
+ return (
+
+
+ Connect an email account
+
+
+ {this._renderAccountTypes()}
+
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-account-onboarding-success.jsx b/packages/client-app/internal_packages/onboarding/lib/page-account-onboarding-success.jsx
new file mode 100644
index 0000000000..8ecea12366
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-account-onboarding-success.jsx
@@ -0,0 +1,35 @@
+import React, {Component, PropTypes} from 'react';
+import {RetinaImg} from 'nylas-component-kit';
+import AccountTypes from './account-types'
+
+
+class AccountOnboardingSuccess extends Component { // eslint-disable-line
+ static displayName = 'AccountOnboardingSuccess'
+
+ static propTypes = {
+ accountInfo: PropTypes.object,
+ }
+
+ render() {
+ const {accountInfo} = this.props
+ const accountType = AccountTypes.find(a => a.type === accountInfo.type);
+ return (
+
+
+
+
+
+
Successfully connected to {accountType.displayName}!
+ Adding your account to Nylas Mail…
+
+
+ )
+ }
+}
+
+export default AccountOnboardingSuccess
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-account-settings-exchange.jsx b/packages/client-app/internal_packages/onboarding/lib/page-account-settings-exchange.jsx
new file mode 100644
index 0000000000..5a02d27801
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-account-settings-exchange.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import {RegExpUtils} from 'nylas-exports';
+import {isValidHost} from './onboarding-helpers';
+import CreatePageForForm from './decorators/create-page-for-form';
+import FormField from './form-field';
+
+class AccountExchangeSettingsForm extends React.Component {
+ static displayName = 'AccountExchangeSettingsForm';
+
+ static propTypes = {
+ accountInfo: React.PropTypes.object,
+ errorFieldNames: React.PropTypes.array,
+ submitting: React.PropTypes.bool,
+ onConnect: React.PropTypes.func,
+ onFieldChange: React.PropTypes.func,
+ onFieldKeyPress: React.PropTypes.func,
+ };
+
+ static submitLabel = () => {
+ return 'Connect Account';
+ }
+
+ static titleLabel = () => {
+ return 'Add your Exchange account';
+ }
+
+ static subtitleLabel = () => {
+ return 'Enter your Exchange credentials to get started.';
+ }
+
+ static validateAccountInfo = (accountInfo) => {
+ const {email, password, name} = accountInfo;
+ const errorFieldNames = [];
+ let errorMessage = null;
+
+ if (!email || !password || !name) {
+ return {errorMessage, errorFieldNames, populated: false};
+ }
+
+ if (!RegExpUtils.emailRegex().test(accountInfo.email)) {
+ errorFieldNames.push('email')
+ errorMessage = "Please provide a valid email address."
+ }
+ if (!accountInfo.password) {
+ errorFieldNames.push('password')
+ errorMessage = "Please provide a password for your account."
+ }
+ if (!accountInfo.name) {
+ errorFieldNames.push('name')
+ errorMessage = "Please provide your name."
+ }
+ if (accountInfo.eas_server_host && !isValidHost(accountInfo.eas_server_host)) {
+ errorFieldNames.push('eas_server_host')
+ errorMessage = "Please provide a valid host name."
+ }
+
+ return {errorMessage, errorFieldNames, populated: true};
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {showAdvanced: false};
+ }
+
+ submit() {
+ this.props.onConnect();
+ }
+
+ render() {
+ const {errorFieldNames, accountInfo} = this.props;
+ const showAdvanced = (
+ this.state.showAdvanced ||
+ errorFieldNames.includes('eas_server_host') ||
+ errorFieldNames.includes('username') ||
+ accountInfo.eas_server_host ||
+ accountInfo.username
+ );
+
+ let classnames = "twocol";
+ if (!showAdvanced) {
+ classnames += " hide-second-column";
+ }
+
+ return (
+
+ )
+ }
+}
+
+export default CreatePageForForm(AccountExchangeSettingsForm);
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-account-settings-gmail.jsx b/packages/client-app/internal_packages/onboarding/lib/page-account-settings-gmail.jsx
new file mode 100644
index 0000000000..6fd77fdcc8
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-account-settings-gmail.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import {OAuthSignInPage} from 'nylas-component-kit';
+
+import {
+ makeGmailOAuthRequest,
+ authIMAPForGmail,
+ buildGmailSessionKey,
+ buildGmailAuthURL,
+} from './onboarding-helpers';
+
+import OnboardingActions from './onboarding-actions';
+import AccountTypes from './account-types';
+
+
+export default class AccountSettingsPageGmail extends React.Component {
+ static displayName = "AccountSettingsPageGmail";
+
+ static propTypes = {
+ accountInfo: React.PropTypes.object,
+ };
+
+ constructor() {
+ super()
+ this._sessionKey = buildGmailSessionKey();
+ this._gmailAuthUrl = buildGmailAuthURL(this._sessionKey)
+ }
+
+ onSuccess(account) {
+ OnboardingActions.accountJSONReceived(account, account.localToken, account.cloudToken);
+ }
+
+ render() {
+ const {accountInfo} = this.props;
+ const accountType = AccountTypes.find(a => a.type === accountInfo.type)
+ const {headerIcon} = accountType;
+ const goBack = () => OnboardingActions.moveToPreviousPage()
+
+ return (
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-account-settings-imap.jsx b/packages/client-app/internal_packages/onboarding/lib/page-account-settings-imap.jsx
new file mode 100644
index 0000000000..e330131d86
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-account-settings-imap.jsx
@@ -0,0 +1,170 @@
+import React from 'react';
+import {isValidHost} from './onboarding-helpers';
+import CreatePageForForm from './decorators/create-page-for-form';
+import FormField from './form-field';
+
+class AccountIMAPSettingsForm extends React.Component {
+ static displayName = 'AccountIMAPSettingsForm';
+
+ static propTypes = {
+ accountInfo: React.PropTypes.object,
+ errorFieldNames: React.PropTypes.array,
+ submitting: React.PropTypes.bool,
+ onConnect: React.PropTypes.func,
+ onFieldChange: React.PropTypes.func,
+ onFieldKeyPress: React.PropTypes.func,
+ };
+
+ static submitLabel = () => {
+ return 'Connect Account';
+ }
+
+ static titleLabel = () => {
+ return 'Set up your account';
+ }
+
+ static subtitleLabel = () => {
+ return 'Complete the IMAP and SMTP settings below to connect your account.';
+ }
+
+ static validateAccountInfo = (accountInfo) => {
+ let errorMessage = null;
+ const errorFieldNames = [];
+
+ for (const type of ['imap', 'smtp']) {
+ if (!accountInfo[`${type}_host`] || !accountInfo[`${type}_username`] || !accountInfo[`${type}_password`]) {
+ return {errorMessage, errorFieldNames, populated: false};
+ }
+ if (!isValidHost(accountInfo[`${type}_host`])) {
+ errorMessage = "Please provide a valid hostname or IP adddress.";
+ errorFieldNames.push(`${type}_host`);
+ }
+ if (accountInfo[`${type}_host`] === 'imap.gmail.com') {
+ errorMessage = "Please link Gmail accounts by choosing 'Google' on the account type screen.";
+ errorFieldNames.push(`${type}_host`);
+ }
+ if (!Number.isInteger(accountInfo[`${type}_port`] / 1)) {
+ errorMessage = "Please provide a valid port number.";
+ errorFieldNames.push(`${type}_port`);
+ }
+ }
+
+ return {errorMessage, errorFieldNames, populated: true};
+ }
+
+ submit() {
+ this.props.onConnect();
+ }
+
+ renderPortDropdown(protocol) {
+ if (!["imap", "smtp"].includes(protocol)) {
+ throw new Error(`Can't render port dropdown for protocol '${protocol}'`);
+ }
+ const {accountInfo, submitting, onFieldKeyPress, onFieldChange} = this.props;
+
+ if (protocol === "imap") {
+ return (
+
+ Port:
+
+ 143
+ 993
+
+
+ )
+ }
+ if (protocol === "smtp") {
+ return (
+
+ Port:
+
+ 25
+ 465
+ 587
+
+
+ )
+ }
+ return "";
+ }
+
+ renderSecurityDropdown(protocol) {
+ const {accountInfo, submitting, onFieldKeyPress, onFieldChange} = this.props;
+
+ return (
+
+
+ Security:
+
+ SSL / TLS
+ STARTTLS
+ none
+
+
+
+
+ Allow insecure SSL
+
+
+ )
+ }
+
+ renderFieldsForType(type) {
+ return (
+
+
+
+ {this.renderPortDropdown(type)}
+ {this.renderSecurityDropdown(type)}
+
+
+
+
+ );
+ }
+
+ render() {
+ return (
+
+
+
Incoming Mail (IMAP):
+ {this.renderFieldsForType('imap')}
+
+
+
Outgoing Mail (SMTP):
+ {this.renderFieldsForType('smtp')}
+
+
+ )
+ }
+}
+
+export default CreatePageForForm(AccountIMAPSettingsForm);
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-account-settings.jsx b/packages/client-app/internal_packages/onboarding/lib/page-account-settings.jsx
new file mode 100644
index 0000000000..2c065098b6
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-account-settings.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import {RegExpUtils} from 'nylas-exports';
+
+import OnboardingActions from './onboarding-actions';
+import CreatePageForForm from './decorators/create-page-for-form';
+import {accountInfoWithIMAPAutocompletions} from './onboarding-helpers';
+import FormField from './form-field';
+
+class AccountBasicSettingsForm extends React.Component {
+ static displayName = 'AccountBasicSettingsForm';
+
+ static propTypes = {
+ accountInfo: React.PropTypes.object,
+ errorFieldNames: React.PropTypes.array,
+ submitting: React.PropTypes.bool,
+ onConnect: React.PropTypes.func,
+ onFieldChange: React.PropTypes.func,
+ onFieldKeyPress: React.PropTypes.func,
+ };
+
+ static submitLabel = (accountInfo) => {
+ return (accountInfo.type === 'imap') ? 'Continue' : 'Connect Account';
+ }
+
+ static titleLabel = (AccountType) => {
+ return AccountType.title || `Add your ${AccountType.displayName} account`;
+ }
+
+ static subtitleLabel = () => {
+ return 'Enter your email account credentials to get started.';
+ }
+
+ static validateAccountInfo = (accountInfo) => {
+ const {email, password, name} = accountInfo;
+ const errorFieldNames = [];
+ let errorMessage = null;
+
+ if (!email || !password || !name) {
+ return {errorMessage, errorFieldNames, populated: false};
+ }
+
+ if (!RegExpUtils.emailRegex().test(accountInfo.email)) {
+ errorFieldNames.push('email')
+ errorMessage = "Please provide a valid email address."
+ }
+ if (!accountInfo.password) {
+ errorFieldNames.push('password')
+ errorMessage = "Please provide a password for your account."
+ }
+ if (!accountInfo.name) {
+ errorFieldNames.push('name')
+ errorMessage = "Please provide your name."
+ }
+
+ return {errorMessage, errorFieldNames, populated: true};
+ }
+
+ submit() {
+ if (!['gmail', 'office365'].includes(this.props.accountInfo.type)) {
+ const accountInfo = accountInfoWithIMAPAutocompletions(this.props.accountInfo);
+ OnboardingActions.setAccountInfo(accountInfo);
+ if (this.props.accountInfo.type === 'imap') {
+ OnboardingActions.moveToPage('account-settings-imap');
+ } else {
+ // We have to pass in the updated accountInfo, because the onConnect()
+ // we're calling exists on a component that won't have had it's state
+ // updated from the OnboardingStore change yet.
+ this.props.onConnect(accountInfo);
+ }
+ } else {
+ this.props.onConnect();
+ }
+ }
+
+ render() {
+ return (
+
+ )
+ }
+}
+
+export default CreatePageForForm(AccountBasicSettingsForm);
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-authenticate.jsx b/packages/client-app/internal_packages/onboarding/lib/page-authenticate.jsx
new file mode 100644
index 0000000000..1045ace3d6
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-authenticate.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import {IdentityStore} from 'nylas-exports';
+import {Webview} from 'nylas-component-kit';
+import OnboardingActions from './onboarding-actions';
+
+export default class AuthenticatePage extends React.Component {
+ static displayName = "AuthenticatePage";
+
+ static propTypes = {
+ accountInfo: React.PropTypes.object,
+ };
+
+ _src() {
+ const n1Version = NylasEnv.getVersion();
+ return `${IdentityStore.URLRoot}/onboarding?utm_medium=N1&utm_source=OnboardingPage&N1_version=${n1Version}&client_edition=basic`
+ }
+
+ _onDidFinishLoad = (webview) => {
+ const receiveUserInfo = `
+ var a = document.querySelector('#pro-account');
+ result = a ? a.innerText : null;
+ `;
+ webview.executeJavaScript(receiveUserInfo, false, (result) => {
+ this.setState({ready: true, webviewLoading: false});
+ if (result !== null) {
+ OnboardingActions.authenticationJSONReceived(JSON.parse(result));
+ }
+ });
+
+ const openExternalLink = `
+ var el = document.querySelector('.open-external');
+ if (el) {el.addEventListener('click', function(event) {console.log(this.href); event.preventDefault(); return false;})}
+ `;
+ webview.executeJavaScript(openExternalLink);
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-initial-preferences.cjsx b/packages/client-app/internal_packages/onboarding/lib/page-initial-preferences.cjsx
new file mode 100644
index 0000000000..510c657eab
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-initial-preferences.cjsx
@@ -0,0 +1,143 @@
+React = require 'react'
+path = require 'path'
+fs = require 'fs'
+_ = require 'underscore'
+{RetinaImg, Flexbox, ConfigPropContainer, NewsletterSignup} = require 'nylas-component-kit'
+{EdgehillAPI, AccountStore} = require 'nylas-exports'
+OnboardingActions = require('./onboarding-actions').default
+
+# NOTE: Temporarily copied from preferences module
+class AppearanceModeOption extends React.Component
+ @propTypes:
+ mode: React.PropTypes.string.isRequired
+ active: React.PropTypes.bool
+ onClick: React.PropTypes.func
+
+ render: =>
+ classname = "appearance-mode"
+ classname += " active" if @props.active
+
+ label = {
+ 'list': 'Reading Pane Off'
+ 'split': 'Reading Pane On'
+ }[@props.mode]
+
+
+
+
+class InitialPreferencesOptions extends React.Component
+ @propTypes:
+ config: React.PropTypes.object
+
+ constructor: (@props) ->
+ @state =
+ templates: []
+ @_loadTemplates()
+
+ _loadTemplates: =>
+ templatesDir = path.join(NylasEnv.getLoadSettings().resourcePath, 'keymaps', 'templates')
+ fs.readdir templatesDir, (err, files) =>
+ return unless files and files instanceof Array
+ templates = files.filter (filename) =>
+ path.extname(filename) is '.cson' or path.extname(filename) is '.json'
+ templates = templates.map (filename) =>
+ path.parse(filename).name
+ @setState(templates: templates)
+ @_setConfigDefaultsForAccount(templates)
+
+ _setConfigDefaultsForAccount: (templates) =>
+ return unless @props.account
+
+ templateWithBasename = (name) =>
+ _.find templates, (t) -> t.indexOf(name) is 0
+
+ if @props.account.provider is 'gmail'
+ @props.config.set('core.workspace.mode', 'list')
+ @props.config.set('core.keymapTemplate', templateWithBasename('Gmail'))
+ else if @props.account.provider is 'eas' or @props.account.provider is 'office365'
+ @props.config.set('core.workspace.mode', 'split')
+ @props.config.set('core.keymapTemplate', templateWithBasename('Outlook'))
+ else
+ @props.config.set('core.workspace.mode', 'split')
+ if process.platform is 'darwin'
+ @props.config.set('core.keymapTemplate', templateWithBasename('Apple Mail'))
+ else
+ @props.config.set('core.keymapTemplate', templateWithBasename('Outlook'))
+
+ render: =>
+ return false unless @props.config
+
+
+
+
+ Do you prefer a single panel layout (like Gmail)
+ or a two panel layout?
+
+
+ {['list', 'split'].map (mode) =>
+ @props.config.set('core.workspace.mode', mode)} />
+ }
+
+
+
+
+
+ We've picked a set of keyboard shortcuts based on your email
+ account and platform. You can also pick another set:
+
+
@props.config.set('core.keymapTemplate', event.target.value) }>
+ { @state.templates.map (template) =>
+ {template}
+ }
+
+
+
+
+
+
+
+
+
+class InitialPreferencesPage extends React.Component
+ @displayName: "InitialPreferencesPage"
+
+ constructor:(@props) ->
+ @state = {account: AccountStore.accounts()[0]}
+
+ componentDidMount: =>
+ @_unlisten = AccountStore.listen(@_onAccountStoreChange)
+
+ componentWillUnmount: =>
+ @_unlisten?()
+
+ _onAccountStoreChange: =>
+ @setState(account: AccountStore.accounts()[0])
+
+ render: =>
+
+
Welcome to Nylas Mail
+ Let's set things up to your liking.
+
+
+
+
+ Looks Good!
+
+
+
+ _onFinished: =>
+ require('electron').ipcRenderer.send('account-setup-successful')
+
+module.exports = InitialPreferencesPage
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-top-bar.jsx b/packages/client-app/internal_packages/onboarding/lib/page-top-bar.jsx
new file mode 100644
index 0000000000..d543751da1
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-top-bar.jsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import {AccountStore} from 'nylas-exports';
+import {RetinaImg} from 'nylas-component-kit';
+import OnboardingActions from './onboarding-actions';
+
+const PageTopBar = (props) => {
+ const {pageDepth} = props;
+
+ const closeClass = (pageDepth > 1) ? 'back' : 'close';
+ const closeIcon = (pageDepth > 1) ? 'onboarding-back.png' : 'onboarding-close.png';
+ const closeAction = () => {
+ const webview = document.querySelector('webview');
+ if (webview && webview.canGoBack()) {
+ webview.goBack();
+ } else if (pageDepth > 1) {
+ OnboardingActions.moveToPreviousPage();
+ } else {
+ if (AccountStore.accounts().length === 0) {
+ NylasEnv.quit();
+ } else {
+ NylasEnv.close();
+ }
+ }
+ }
+
+ let backButton = (
+
+
+
+ )
+ if (props.pageDepth > 1 && !props.allowMoveBack) {
+ backButton = null;
+ }
+
+ return (
+
+ {backButton}
+
+ )
+}
+
+PageTopBar.propTypes = {
+ pageDepth: React.PropTypes.number,
+ allowMoveBack: React.PropTypes.bool,
+};
+
+export default PageTopBar;
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-tutorial.jsx b/packages/client-app/internal_packages/onboarding/lib/page-tutorial.jsx
new file mode 100644
index 0000000000..62589971c3
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-tutorial.jsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import OnboardingActions from './onboarding-actions';
+
+const Steps = [
+ {
+ seen: false,
+ id: 'people',
+ title: 'Compose with context',
+ image: 'feature-people@2x.png',
+ description: "Nylas Mail shows you everything about your contacts right inside your inbox. See LinkedIn profiles, Twitter bios, message history, and more.",
+ x: 96.6,
+ y: 1.3,
+ xDot: 93.5,
+ yDot: 5.4,
+ },
+ {
+ seen: false,
+ id: 'activity',
+ title: 'Track opens and clicks',
+ image: 'feature-activity@2x.png',
+ description: "With activity tracking, you’ll know as soon as someone reads your message. Sending to a group? Nylas Mail shows you which recipients opened your email so you can follow up with precision.",
+ x: 12.8,
+ y: 1,
+ xDot: 15,
+ yDot: 5.1,
+ },
+ {
+ seen: false,
+ id: 'snooze',
+ title: 'Send on your own schedule',
+ image: 'feature-snooze@2x.png',
+ description: "Snooze emails to return at any time that suits you. Schedule messages to send at the ideal time. Nylas Mail makes it easy to control the fabric of spacetime!",
+ x: 5.5,
+ y: 23.3,
+ xDot: 10,
+ yDot: 25.9,
+ },
+ // {
+ // seen: false,
+ // id: 'composer',
+ // title: 'Eliminate hacky extensions',
+ // image: 'feature-composer@2x.png',
+ // description: "Embed calendar invitations, propose meeting times, use quick reply templates, send mass emails with mail merge, and more—all directly from Nylas Mail’s powerful composer.",
+ // x: 60.95,
+ // y: 66,
+ // xDot: 60.3,
+ // yDot: 65.0,
+ // },
+];
+
+export default class TutorialPage extends React.Component {
+ static displayName = "TutorialPage";
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ appeared: false,
+ seen: [],
+ current: Steps[0],
+ }
+ }
+
+ componentDidMount() {
+ this._timer = setTimeout(() => {
+ this.setState({appeared: true})
+ }, 200);
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this._timer);
+ }
+
+ _onBack = () => {
+ const nextItem = this.state.seen.pop();
+ if (!nextItem) {
+ OnboardingActions.moveToPreviousPage();
+ } else {
+ this.setState({current: nextItem});
+ }
+ }
+
+ _onNextUnseen = () => {
+ const nextSeen = [].concat(this.state.seen, [this.state.current]);
+ const nextItem = Steps.find(s => !nextSeen.includes(s));
+ if (nextItem) {
+ this.setState({current: nextItem, seen: nextSeen});
+ } else {
+ OnboardingActions.moveToPage('authenticate');
+ }
+ }
+
+ _onMouseOverOverlay = (event) => {
+ const item = Steps.find(i => i.id === event.target.id);
+ if (item) {
+ if (!this.state.seen.includes(item)) {
+ this.state.seen.push(item);
+ }
+ this.setState({current: item});
+ }
+ }
+
+ render() {
+ const {current, seen, appeared} = this.state;
+
+ return (
+
+
+
+
+ {Steps.map((step) =>
+
+ )}
+
+
+
+
+
{current.title}
+
{current.description}
+
+
+
+
+ Back
+
+
+ {seen.length < Steps.length ? 'Next' : 'Get Started'}
+
+
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/onboarding/lib/page-welcome.jsx b/packages/client-app/internal_packages/onboarding/lib/page-welcome.jsx
new file mode 100644
index 0000000000..4471afdd38
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/lib/page-welcome.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import {RetinaImg} from 'nylas-component-kit';
+import OnboardingActions from './onboarding-actions';
+
+export default class WelcomePage extends React.Component {
+ static displayName = "WelcomePage";
+
+ _onContinue = () => {
+ // We don't have a NylasId yet and therefore can't track the "Welcome
+ // Page Finished" event.
+ //
+ // If a user already has a Nylas ID and gets to this page (which
+ // happens if they sign out of all of their accounts), then it would
+ // properly fire. This is a rare case though and we don't want
+ // analytics users thinking it's part of the full funnel.
+ //
+ // Actions.recordUserEvent('Welcome Page Finished');
+ OnboardingActions.moveToPage("tutorial");
+ }
+
+ render() {
+ return (
+
+
+
+
+
Welcome to Nylas Mail
+
+
+
+
+ Get Started
+
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/onboarding/package.json b/packages/client-app/internal_packages/onboarding/package.json
new file mode 100755
index 0000000000..dd0d9d0bb1
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "onboarding",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "The sign in experience",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "onboarding": true
+ }
+}
diff --git a/packages/client-app/internal_packages/onboarding/stylesheets/onboarding-reset.less b/packages/client-app/internal_packages/onboarding/stylesheets/onboarding-reset.less
new file mode 100644
index 0000000000..2e2121352a
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/stylesheets/onboarding-reset.less
@@ -0,0 +1,187 @@
+@import "ui-variables";
+
+/* The Onboarding window should never adopt theme styles. This re-assigns UI
+variables and resets commonly overridden styles to ensure the onboarding window
+always looks good. Previously we tried to make the theme just not load in the
+window, but it uses a hot window which makes that difficult now. */
+
+@black: #231f20;
+@gray-base: #0a0b0c;
+@gray-darker: lighten(@gray-base, 13.5%); // #222
+@gray-dark: lighten(@gray-base, 20%); // #333
+@gray: lighten(@gray-base, 33.5%); // #555
+@gray-light: lighten(@gray-base, 46.7%); // #777
+@gray-lighter: lighten(@gray-base, 92.5%); // #eee
+@white: #ffffff;
+
+@blue-dark: #3187e1;
+@blue: #419bf9;
+
+//== Color Descriptors
+@accent-primary: @blue;
+@accent-primary-dark: @blue-dark;
+
+@background-primary: @white;
+@background-off-primary: #fdfdfd;
+@background-secondary: #f6f6f6;
+@background-tertiary: #6d7987;
+
+@text-color: @black;
+@text-color-subtle: fadeout(@text-color, 20%);
+@text-color-very-subtle: fadeout(@text-color, 50%);
+@text-color-inverse: @white;
+@text-color-inverse-subtle: fadeout(@text-color-inverse, 20%);
+@text-color-inverse-very-subtle: fadeout(@text-color-inverse, 50%);
+
+@text-color-heading: #434648;
+@font-family-sans-serif: "Nylas-Pro", "Helvetica", sans-serif;
+@font-family-serif: Georgia, "Times New Roman", Times, serif;
+@font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
+
+@font-family: @font-family-sans-serif;
+@font-family-heading: @font-family-sans-serif;
+@font-size-base: 14px;
+
+@line-height-base: 1.5; // 22.5/15
+@line-height-computed: floor((@font-size-base * @line-height-base)); // ~20px
+@line-height-heading: 1.1;
+
+@component-active-color: @accent-primary-dark;
+@component-active-bg: @background-primary;
+
+@input-bg: @white;
+@input-bg-disabled: @gray-lighter;
+
+h1, h2, h3, h4, h5, h6 {
+ font-family: @font-family-heading;
+ line-height: @line-height-heading;
+ color: @text-color-heading;
+
+ small,
+ .small {
+ line-height: 1;
+ }
+}
+
+h1 {
+ font-size: @font-size-h1;
+ font-weight: @font-weight-semi-bold;
+}
+h2 {
+ font-size: @font-size-h2;
+ font-weight: @font-weight-blond;
+}
+h3 {
+ font-size: @font-size-h3;
+ font-weight: @font-weight-blond;
+}
+h4 { font-size: @font-size-h4; }
+h5 { font-size: @font-size-h5; }
+h6 { font-size: @font-size-h6; }
+
+h1, h2, h3{
+ margin-top: @line-height-computed;
+ margin-bottom: (@line-height-computed / 2);
+
+ small,
+ .small {
+ font-size: 65%;
+ }
+}
+h4, h5, h6 {
+ margin-top: (@line-height-computed / 2);
+ margin-bottom: (@line-height-computed / 2);
+
+ small,
+ .small {
+ font-size: 75%;
+ }
+}
+
+
+.btn {
+ padding: 0 0.8em;
+ border-radius: @border-radius-base;
+ border: 0;
+ cursor: default;
+ display:inline-block;
+ color: @btn-default-text-color;
+ background: @background-primary;
+
+ img.content-mask { background-color: @btn-default-text-color; }
+
+ // Use 4 box shadows to create a 0.5px hairline around the button, and another
+ // for the actual shadow. Pending https://code.google.com/p/chromium/issues/detail?id=236371
+ // Yes, 1px border looks really bad on retina.
+ box-shadow: 0 0.5px 0 rgba(0,0,0,0.15), 0 -0.5px 0 rgba(0,0,0,0.15), 0.5px 0 0 rgba(0,0,0,0.15), -0.5px 0 0 rgba(0,0,0,0.15), 0 0.5px 1px rgba(0, 0, 0, 0.15);
+
+ height: 1.9em;
+ line-height: 1.9em;
+
+ .text {
+ margin-left: 6px;
+ }
+
+ &:active {
+ cursor: default;
+ background: darken(@btn-default-bg-color, 9%);
+ }
+ &:focus {
+ outline: none
+ }
+
+ font-size: @font-size-small;
+
+ &.btn-small {
+ font-size: @font-size-smaller;
+ }
+ &.btn-large {
+ font-size: @font-size-base;
+ padding: 0 1.3em;
+ line-height: 2.2em;
+ height: 2.3em;
+ }
+ &.btn-larger {
+ font-size: @font-size-large;
+ padding: 0 1.6em;
+ }
+
+ &.btn-disabled {
+ color: fadeout(@btn-default-text-color, 40%);
+ background: fadeout(@btn-default-bg-color, 15%);
+ &:active {
+ background: fadeout(@btn-default-bg-color, 15%);
+ }
+ }
+
+ &.btn-emphasis {
+ position: relative;
+ color: @btn-emphasis-text-color;
+ font-weight: @font-weight-medium;
+
+ img.content-mask { background-color:@btn-emphasis-text-color; }
+
+ background: linear-gradient(to bottom, #6bb1f9 0%, #0a80ff 100%);
+ box-shadow: none;
+ border: 1px solid darken(@btn-emphasis-bg-color, 7%);
+
+ &.btn-disabled {
+ opacity: 0.4;
+ }
+
+ &:before {
+ content: ' ';
+ width: calc(~"100% + 2px");
+ height: calc(~"100% + 2px");
+ border-radius: @border-radius-base + 1;
+ top: -1px;
+ left: -1px;
+ position: absolute;
+ z-index: -1;
+ background: linear-gradient(to bottom, #4ca2f9 0%, #015cff 100%);
+ }
+ &:active {
+ background: -webkit-gradient(linear, left top, left bottom, from(darken(@btn-emphasis-bg-color,10%)), to(darken(@btn-emphasis-bg-color, 4%)));
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/onboarding/stylesheets/onboarding.less b/packages/client-app/internal_packages/onboarding/stylesheets/onboarding.less
new file mode 100644
index 0000000000..0c6224bd67
--- /dev/null
+++ b/packages/client-app/internal_packages/onboarding/stylesheets/onboarding.less
@@ -0,0 +1,616 @@
+@import "onboarding-reset";
+
+@-webkit-keyframes fadein {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.alpha-fade-enter {
+ opacity: 0.01;
+ transition: all .15s ease-out;
+}
+
+.alpha-fade-enter.alpha-fade-enter-active {
+ opacity: 1;
+}
+.alpha-fade-leave {
+ opacity: 1;
+ transition: all .15s ease-in;
+}
+
+.alpha-fade-leave.alpha-fade-leave-active {
+ opacity: 0.01;
+}
+
+.page-frame {
+ text-align: center;
+ flex: 1;
+
+ .page-container {
+ display: flex;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ }
+
+ .page {
+ background-color: #F3F3F3;
+ flex: 1;
+ }
+
+ h1 {
+ font-weight: 100;
+ font-size: 40pt;
+ margin:0;
+ }
+
+ h2 {
+ line-height: 1.3em;
+ font-size: 30pt;
+ font-weight: 200;
+ }
+
+ h4 {
+ font-weight: 400;
+ font-size: 20pt;
+ }
+
+ .logo-container {
+ width: 117px;
+ height: 117px;
+ display: inline-block;
+ padding-top: 40px;
+ box-sizing: content-box;
+ }
+
+ .btn-add-account {
+ width: 170px;
+ margin-left: 10px;
+ transition: width 150ms ease-in-out;
+ }
+ .btn-add-account.spinning {
+ img { vertical-align: middle; margin-right: 5px; margin-bottom: 2px; }
+ }
+
+ .prompt {
+ color:#5D5D5D;
+ font-size:1.07em;
+ font-weight:300;
+ margin-top:20px;
+ margin-bottom:14px;
+ }
+
+ .close {
+ position: fixed;
+ z-index: 100;
+ top: 1px;
+ left: 6px;
+ }
+
+ .back {
+ position: fixed;
+ top: 15px;
+ left: 15px;
+ padding: 10px;
+ }
+
+ .message {
+ margin-bottom:15px;
+ max-width: 600px;
+ margin: auto;
+
+ &.error {
+ color: #A33;
+ -webkit-user-select: text;
+ a {
+ color: #A33;
+ }
+ }
+ &.empty {
+ color: gray;
+ }
+ }
+
+ form.settings {
+ padding: 0 20px;
+ padding-bottom: 20px;
+ }
+ input {
+ display: inline-block;
+ width: 100%;
+ padding: 7px;
+ margin-bottom: 10px;
+ background: #FFF;
+ color: #333;
+ text-align: left;
+ border: 1px solid #AAA;
+
+ &::-webkit-input-placeholder {
+ color: #C6C6C6;
+ }
+
+ &[type=checkbox] {
+ width: initial;
+ margin-right: 5px;
+ }
+
+ &:disabled {
+ background: fadeout(@input-bg, 40%);
+ }
+ &.error {
+ border: 1px solid #A33;
+ }
+ }
+
+ label {
+ display: inline-block;
+ white-space: nowrap;
+ width: 100%;
+ color: #888;
+ text-align: left;
+ padding:3px 0;
+ }
+
+ label[for=subscribe-check] {
+ color: black;
+ white-space: inherit;
+ }
+
+ label.checkbox {
+ width: inherit;
+ }
+
+ .toggle-advanced {
+ display: inline-block;
+ width: 100%;
+ font-size: 0.94em;
+ text-align: right;
+ padding: 0;
+ }
+
+ .btn {
+ margin-top:8px;
+ }
+}
+
+.page.authenticate {
+ flex: 1;
+ display: flex;
+
+ webview {
+ display: flex;
+ flex: 1;
+ }
+
+ .webview-loading-spinner {
+ position: absolute;
+ right: 17px;
+ top: 17px;
+ opacity: 0;
+ transition: opacity 200ms ease-in-out;
+ transition-delay: 200ms;
+ &.loading-true {
+ opacity: 1;
+ }
+ }
+
+ .webview-cover {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #F3F3F3;
+ opacity: 1;
+ transition: opacity 200ms ease-out;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ .message {
+ color: #444;
+ opacity: 0;
+ margin-top: 20px;
+ transition: opacity 200ms ease-out;
+ }
+ .try-again {
+ opacity: 0;
+ transition: opacity 200ms ease-out;
+ }
+ }
+ .webview-cover.slow,
+ .webview-cover.error {
+ .message {
+ opacity: 1;
+ max-width: 400px;
+ }
+ }
+ .webview-cover.error {
+ .spinner { visibility: hidden;}
+ .try-again {
+ opacity: 1;
+ }
+ }
+ .webview-cover.ready {
+ pointer-events: none;
+ opacity: 0;
+ }
+}
+
+.page.account-choose {
+ h2 {
+ margin-top: 90px;
+ margin-bottom: 20px;
+ }
+
+ .provider-list {
+ margin:auto;
+ width: 280px;
+ }
+ .cloud-sync-note {
+ margin-bottom: 20px;
+ cursor: default;
+ color: @text-color-very-subtle;
+ }
+ .provider-name {
+ font-size: 18px;
+ font-weight: 300;
+ color: rgba(0,0,0,0.7);
+ }
+
+ .provider {
+ text-align: left;
+ cursor: default;
+ line-height: 63px;
+
+ .icon-container {
+ width: 50px;
+ height: 50px;
+ display: inline-block;
+ box-sizing: content-box;
+ padding: 0 15px 0 20px;
+ vertical-align: top;
+ zoom: 0.9;
+ }
+ }
+ .provider:hover{
+ background: rgba(255,255,255,0.4);
+ }
+}
+
+.page.account-setup {
+ form {
+ width: 400px;
+ padding-top: 20px;
+ margin: auto;
+ }
+ .twocol {
+ display: flex;
+ flex-direction: row;
+ width: 700px;
+ margin: auto;
+ transition: width 400ms ease-in-out;
+ }
+ .twocol.hide-second-column {
+ width: 400px;
+ .col:nth-child(2) {
+ opacity: 0;
+ flex: 0;
+ padding: 0;
+ flex-shrink: 1;
+ }
+ .col:first-child {
+ }
+ }
+ .col {
+ flex: 1;
+ padding: 0 20px;
+ opacity: 1;
+ border-left: 1px solid #ddd;
+ overflow: hidden;
+ transition: all 400ms ease-in-out;
+ }
+ .col:first-child {
+ border-left: none;
+ }
+ .col-heading {
+ text-align: left;
+ padding-bottom: 15px;
+ }
+}
+
+.page.account-setup.AccountExchangeSettingsForm {
+ .logo-container {
+ padding-top: 36px;
+ }
+ .twocol {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+}
+
+.page.account-setup.AccountIMAPSettingsForm {
+ h2 {
+ padding-top: 36px;
+ }
+ .logo-container {
+ display: none;
+ }
+ .twocol {
+ padding-top: 20px;
+ padding-bottom: 10px;
+ }
+}
+.page.account-setup.google, .page.account-setup.AccountOnboardingSuccess {
+ .logo-container {
+ padding-top: 160px;
+ }
+}
+
+.page.tutorial {
+ display: flex;
+ flex-direction: column;
+
+ &.appeared-false {
+ .tutorial-container .left {
+ transform: translate3d(-30px, 0, 0);
+ opacity: 0;
+ }
+ .tutorial-container .right {
+ transform: translate3d(30px, 0, 0);
+ opacity: 0;
+ }
+ }
+
+ .tutorial-container {
+ background-color: #F9F9F9;
+ display: flex;
+ flex-direction: row;
+ flex: 1;
+
+ .left {
+ align-self: center;
+ flex: 2;
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ transition: all ease-in-out 400ms;
+
+ .screenshot {
+ width: 523px;
+ height: 385px;
+ background:url(nylas://onboarding/assets/app-screenshot@2x.png) top left no-repeat;
+ background-size: contain;
+ margin:auto;
+ position: relative;
+
+ .overlay {
+ position: absolute;
+ width:40px;
+ height:40px;
+ border: 2px solid rgba(0,0,0,0.7);
+ border-radius: 20px;
+ transform:translate3d(-50%, -50%, 0);
+ transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 260ms;
+
+ .overlay-content {
+ transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 260ms;
+ transform: translate3d(-67px,-67px,0) scale(0.21);
+ background:url(nylas://onboarding/assets/app-screenshot@2x.png) top left no-repeat;
+ background-position: 10% 20%;
+ border-radius: 73px;
+ width: 146px;
+ height: 146px;
+ opacity: 0;
+ display: block;
+ position: absolute;
+ }
+ }
+ .overlay.seen {
+ border: 2px solid rgba(0,0,0,0.3);
+ }
+ .overlay.expanded {
+ width:150px;
+ height:150px;
+ border: 2px solid rgba(0,0,0,0.7);
+ border-radius: 75px;
+ box-shadow: 0 0 15px fade(#2673D1, 50%);
+ z-index: 2;
+
+ .overlay-content {
+ transform:scale(1);
+ opacity: 1;
+ }
+ }
+ }
+ }
+
+ .right {
+ flex: 1;
+ padding: 30px;
+ padding-left: 0;
+ opacity: 1;
+ transform:translate3d(0, 0, 0);
+ transition: all ease-in-out 400ms;
+
+ h2 {
+ font-size: 28px;
+ font-weight: 300;
+ text-align: center;
+ }
+ p {
+ font-size: 16px;
+ line-height: 1.85em;
+ text-align: left;
+ padding: 10px 0;
+ color: #333;
+ }
+ }
+ }
+}
+
+.page.welcome {
+ display: flex;
+ flex-direction: column;
+
+ .footer {
+ background-image: linear-gradient(to right, rgba(167,214,134,1) 0%,rgba(122,201,201,1) 100%);
+ }
+
+ @-webkit-keyframes slideIn {
+ from {
+ transform: translate3d(20,0,0);
+ opacity: 0;
+ }
+ to {
+ transform: translate3d(0,0,0);
+ opacity: 1;
+ }
+ }
+
+ a {
+ color: white;
+ border-bottom: 1px solid white;
+ text-decoration: none;
+ font-weight: 300;
+ &:hover {
+ background-color: rgba(255,255,255,0.1);
+ }
+ }
+
+ .steps-container {
+ position: relative;
+ flex: 1;
+ background-image: linear-gradient(to right, rgba(149,205,107,1) 0%,rgba(60,176,176,1) 100%);
+ color: white;
+ overflow: hidden;
+ }
+
+ .hero-text {
+ font-size: 34px;
+ line-height: 41px;
+ font-weight: 200;
+ cursor: default;
+ -webkit-font-smoothing: subpixel-antialiased;
+ }
+
+ .sub-text {
+ font-size: 17px;
+ font-weight: 300;
+ }
+
+ img.icons {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ }
+}
+
+.page.welcome,
+.page.tutorial {
+ .footer {
+ text-align: center;
+ background-color: #ececec;
+ border-top: 1px solid rgba(0,0,0,0.10);
+ box-shadow: 0 1px 1px solid rgba(255,255,255,0.25);
+
+ .btn-next,
+ .btn-prev {
+ width: 160px;
+ margin: 20px 10px;
+ }
+ .btn-continue {
+ font-weight: 300;
+ margin: 20px 0;
+ width: 296px;
+ line-height: 2.5em;
+ height: 2.5em;
+ }
+ }
+}
+
+body.platform-win32 {
+ .page-frame {
+ .alpha-fade-enter {
+ transition: all .01s ease-out;
+ }
+ .alpha-fade-leave {
+ transition: opacity .01s ease-in;
+ }
+ }
+}
+
+
+// Individual Components
+
+.appearance-mode {
+ background-color:#f7f9f9;
+ border-radius: 10px;
+ border: 1px solid #c6c7c7;
+ text-align: center;
+ flex: 1;
+ padding:9px;
+ padding-top:10px;
+ margin:10px;
+ margin-top:0;
+ img {
+ background-color: #c6c7c7;
+ }
+ div {
+ margin-top: 10px;
+ text-transform: capitalize;
+ cursor: default;
+ }
+}
+.appearance-mode.active {
+ border:1px solid @component-active-color;
+ color: @component-active-color;
+ img { background-color: @component-active-color; }
+}
+
+
+.alternative-auth {
+ p {
+ color: @text-color-heading;
+ }
+ .url-copy-target {
+ width: 50%;
+ border: 1px solid #c6c7c7;
+ margin: 10px;
+ }
+ .copy-to-clipboard {
+ display: inline-block;
+ cursor: pointer;
+ img {
+ background-color: @btn-icon-color;
+ }
+ img:active {
+ background-color: @black;
+ }
+ }
+
+ .hidden {
+ opacity: 0;
+ }
+
+ .visible {
+ opacity: 1;
+ margin-bottom: 0;
+ }
+
+ .fadein {
+ opacity: 1;
+ transition: opacity 2s linear;
+ }
+
+ .fadeout {
+ opacity: 0;
+ transition: opacity 1s linear;
+ }
+
+ input {
+ margin-top: 0;
+ }
+
+}
diff --git a/packages/client-app/internal_packages/participant-profile/assets/facebook-sidebar-icon@2x.png b/packages/client-app/internal_packages/participant-profile/assets/facebook-sidebar-icon@2x.png
new file mode 100644
index 0000000000..7839917498
Binary files /dev/null and b/packages/client-app/internal_packages/participant-profile/assets/facebook-sidebar-icon@2x.png differ
diff --git a/packages/client-app/internal_packages/participant-profile/assets/linkedin-sidebar-icon@2x.png b/packages/client-app/internal_packages/participant-profile/assets/linkedin-sidebar-icon@2x.png
new file mode 100644
index 0000000000..4dc4313052
Binary files /dev/null and b/packages/client-app/internal_packages/participant-profile/assets/linkedin-sidebar-icon@2x.png differ
diff --git a/packages/client-app/internal_packages/participant-profile/assets/location-icon@2x.png b/packages/client-app/internal_packages/participant-profile/assets/location-icon@2x.png
new file mode 100644
index 0000000000..662cc3561f
Binary files /dev/null and b/packages/client-app/internal_packages/participant-profile/assets/location-icon@2x.png differ
diff --git a/packages/client-app/internal_packages/participant-profile/assets/twitter-sidebar-icon@2x.png b/packages/client-app/internal_packages/participant-profile/assets/twitter-sidebar-icon@2x.png
new file mode 100644
index 0000000000..581d3a6c4b
Binary files /dev/null and b/packages/client-app/internal_packages/participant-profile/assets/twitter-sidebar-icon@2x.png differ
diff --git a/packages/client-app/internal_packages/participant-profile/lib/clearbit-data-source.coffee b/packages/client-app/internal_packages/participant-profile/lib/clearbit-data-source.coffee
new file mode 100644
index 0000000000..19437012ed
--- /dev/null
+++ b/packages/client-app/internal_packages/participant-profile/lib/clearbit-data-source.coffee
@@ -0,0 +1,72 @@
+# This file is in coffeescript just to use the existential operator!
+{AccountStore, LegacyEdgehillAPI} = require 'nylas-exports'
+
+MAX_RETRY = 10
+
+module.exports = class ClearbitDataSource
+ clearbitAPI: ->
+ return "https://person.clearbit.com/v2/combined"
+
+ find: ({email, tryCount}) ->
+ if (tryCount ? 0) >= MAX_RETRY
+ return Promise.resolve(null)
+ new Promise (resolve, reject) =>
+ req = LegacyEdgehillAPI.makeRequest({
+ authWithNylasAPI: true
+ path: "/proxy/clearbit/#{@clearbitAPI()}/find?email=#{email}",
+ })
+ req.run()
+ .then((body) =>
+ @parseResponse(body, req.response.statusCode, email, tryCount).then(resolve).catch(reject)
+ )
+
+ # The clearbit -> Nylas adapater
+ parseResponse: (body={}, statusCode, requestedEmail, tryCount=0) =>
+ new Promise (resolve, reject) =>
+ # This means it's in the process of fetching. Return null so we don't
+ # cache and try again.
+ if statusCode is 202
+ setTimeout =>
+ @find({email: requestedEmail, tryCount: tryCount+1}).then(resolve).catch(reject)
+ , 1000
+ return
+ else if statusCode isnt 200
+ resolve(null)
+ return
+
+ person = body.person
+
+ # This means there was no data about the person available. Return a
+ # valid, but empty object for us to cache. This can happen when we
+ # have company data, but no personal data.
+ if not person
+ person = {email: requestedEmail}
+
+ resolve({
+ cacheDate: Date.now()
+ email: requestedEmail # 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,
+ rawClearbitData: body,
+ socialProfiles: @_socialProfiles(person)
+ })
+
+ _socialProfiles: (person={}) ->
+ profiles = {}
+ if (person.twitter?.handle ? "").length > 0
+ profiles.twitter =
+ handle: person.twitter.handle
+ url: "https://twitter.com/#{person.twitter.handle}"
+ if (person.facebook?.handle ? "").length > 0
+ profiles.facebook =
+ handle: person.facebook.handle
+ url: "https://facebook.com/#{person.facebook.handle}"
+ if (person.linkedin?.handle ? "").length > 0
+ profiles.linkedin =
+ handle: person.linkedin.handle
+ url: "https://linkedin.com/#{person.linkedin.handle}"
+
+ return profiles
diff --git a/packages/client-app/internal_packages/participant-profile/lib/main.es6 b/packages/client-app/internal_packages/participant-profile/lib/main.es6
new file mode 100644
index 0000000000..23560ce6e1
--- /dev/null
+++ b/packages/client-app/internal_packages/participant-profile/lib/main.es6
@@ -0,0 +1,21 @@
+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() {
+
+}
+
diff --git a/packages/client-app/internal_packages/participant-profile/lib/participant-profile-store.es6 b/packages/client-app/internal_packages/participant-profile/lib/participant-profile-store.es6
new file mode 100644
index 0000000000..ea1660b9e8
--- /dev/null
+++ b/packages/client-app/internal_packages/participant-profile/lib/participant-profile-store.es6
@@ -0,0 +1,88 @@
+import {DatabaseStore, Utils} from 'nylas-exports'
+import NylasStore from 'nylas-store'
+import ClearbitDataSource from './clearbit-data-source'
+
+// 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)) {
+ const data = this.getCache(contact);
+ if (data.cacheDate) {
+ return data
+ }
+ return {}
+ }
+
+ this.dataSource.find({email: contact.email}).then((data) => {
+ if (data && data.email === contact.email) {
+ this.saveDataToContact(contact, data)
+ this.setCache(contact, data);
+ this.trigger()
+ }
+ }).catch((err = {}) => {
+ if (err.statusCode !== 404) {
+ throw err
+ }
+ })
+ 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
+ }
+
+ /**
+ * We save the clearbit data to the contat object in the database.
+ * This lets us load extra Clearbit data from other windows without
+ * needing to call a very expensive API again.
+ */
+ saveDataToContact(contact, data) {
+ return DatabaseStore.inTransaction((t) => {
+ if (!contact.thirdPartyData) contact.thirdPartyData = {};
+ contact.thirdPartyData.clearbit = data
+ return t.persistModel(contact)
+ })
+ }
+
+ deactivate() {
+ // no op
+ }
+}
+const store = new ParticipantProfileStore()
+export default store
diff --git a/packages/client-app/internal_packages/participant-profile/lib/sidebar-participant-profile.jsx b/packages/client-app/internal_packages/participant-profile/lib/sidebar-participant-profile.jsx
new file mode 100644
index 0000000000..6a52f1476d
--- /dev/null
+++ b/packages/client-app/internal_packages/participant-profile/lib/sidebar-participant-profile.jsx
@@ -0,0 +1,203 @@
+import _ from 'underscore'
+import React from 'react'
+import {DOMUtils, RegExpUtils, 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,
+ }
+
+ static containerStyles = {
+ order: 0,
+ }
+
+ 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()
+ }
+
+ _renderProfilePhoto() {
+ if (this.state.profilePhotoUrl) {
+ return (
+
+
+
+
+
+ )
+ }
+ return this._renderDefaultProfileImage()
+ }
+
+ _renderDefaultProfileImage() {
+ const hue = Utils.hueForString(this.props.contact.email);
+ const bgColor = `hsl(${hue}, 50%, 45%)`
+ const abv = this.props.contact.nameAbbreviation()
+ return (
+
+ )
+ }
+
+ _renderCorePersonalInfo() {
+ const fullName = this.props.contact.fullName();
+ let renderName = false;
+ if (fullName !== this.props.contact.email) {
+ renderName = {this.props.contact.fullName()}
+ }
+ return (
+
+ {renderName}
+
{this.props.contact.email}
+ {this._renderSocialProfiles()}
+
+ )
+ }
+
+ _renderSocialProfiles() {
+ if (!this.state.socialProfiles) { return false }
+ const profiles = _.map(this.state.socialProfiles, (profile, type) => {
+ return (
+
+
+
+ )
+ });
+ return {profiles}
+ }
+
+ _renderAdditionalInfo() {
+ return (
+
+ {this._renderCurrentJob()}
+ {this._renderBio()}
+ {this._renderLocation()}
+
+ )
+ }
+
+ _renderCurrentJob() {
+ if (!this.state.employer) { return false; }
+ let title = false;
+ if (this.state.title) {
+ title = {this.state.title},
+ }
+ return (
+ {title}{this.state.employer}
+ )
+ }
+
+ _renderBio() {
+ if (!this.state.bio) { return false; }
+
+ const bioNodes = [];
+ const hashtagOrMentionRegex = RegExpUtils.hashtagOrMentionRegex();
+
+ let bioRemainder = this.state.bio;
+ let match = null;
+ let count = 0;
+
+ /* I thought we were friends. */
+ /* eslint no-cond-assign: 0 */
+ while (match = hashtagOrMentionRegex.exec(bioRemainder)) {
+ // the first char of the match is whitespace, match[1] is # or @, match[2] is the tag itself.
+ bioNodes.push(bioRemainder.substr(0, match.index + 1));
+ if (match[1] === '#') {
+ bioNodes.push({`#${match[2]}`} );
+ }
+ if (match[1] === '@') {
+ bioNodes.push({`@${match[2]}`} );
+ }
+ bioRemainder = bioRemainder.substr(match.index + match[0].length);
+ count += 1;
+ }
+ bioNodes.push(bioRemainder);
+
+ return (
+ {bioNodes}
+ )
+ }
+
+ _renderLocation() {
+ if (!this.state.location) { return false; }
+ return (
+
+
+ {this.state.location}
+
+ )
+ }
+
+ _select(event) {
+ const el = event.target;
+ const sel = document.getSelection()
+ if (el.contains(sel.anchorNode) && !sel.isCollapsed) {
+ return
+ }
+ const anchor = DOMUtils.findFirstTextNode(el)
+ const focus = DOMUtils.findLastTextNode(el)
+ if (anchor && focus && focus.data) {
+ sel.setBaseAndExtent(anchor, 0, focus, focus.data.length)
+ }
+ }
+
+ render() {
+ return (
+
+ {this._renderProfilePhoto()}
+ {this._renderCorePersonalInfo()}
+ {this._renderAdditionalInfo()}
+
+ )
+ }
+
+}
diff --git a/packages/client-app/internal_packages/participant-profile/lib/sidebar-related-threads.jsx b/packages/client-app/internal_packages/participant-profile/lib/sidebar-related-threads.jsx
new file mode 100644
index 0000000000..9be4ecd311
--- /dev/null
+++ b/packages/client-app/internal_packages/participant-profile/lib/sidebar-related-threads.jsx
@@ -0,0 +1,75 @@
+import React from 'react'
+import {Actions, DateUtils} from 'nylas-exports'
+
+export default class RelatedThreads extends React.Component {
+ static displayName = "RelatedThreads";
+
+ static propTypes = {
+ contact: React.PropTypes.object,
+ contactThreads: React.PropTypes.array,
+ }
+
+ static containerStyles = {
+ order: 99,
+ }
+
+ constructor(props) {
+ super(props)
+ this.state = {expanded: false}
+ this.DEFAULT_NUM = 3
+ }
+
+ _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 (
+ {msg}
+ )
+ }
+
+ _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);
+ const shownThreads = this.props.contactThreads.slice(0, limit)
+ const threads = shownThreads.map((thread) => {
+ const {snippet, subject, lastMessageReceivedTimestamp} = thread;
+ const snippetStyles = (subject && subject.length) ? {marginLeft: '1em'} : {};
+ const onClick = () => { this._onClick(thread) }
+
+ return (
+
+
+ {subject}
+ {snippet}
+
+ {DateUtils.shortTimeString(lastMessageReceivedTimestamp)}
+
+ )
+ })
+
+ return (
+
+ {threads}
+ {this._renderToggle()}
+
+ )
+ }
+}
diff --git a/packages/client-app/internal_packages/participant-profile/package.json b/packages/client-app/internal_packages/participant-profile/package.json
new file mode 100644
index 0000000000..1916111b1a
--- /dev/null
+++ b/packages/client-app/internal_packages/participant-profile/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "participant-profile",
+ "version": "0.1.0",
+ "title": "Participant Profile",
+ "description": "Information about a participant",
+ "isOptional": false,
+ "main": "lib/main",
+ "windowTypes": {
+ "default": true
+ },
+ "engines": {
+ "nylas": "*"
+ },
+ "license": "GPL-3.0"
+}
diff --git a/packages/client-app/internal_packages/participant-profile/stylesheets/participant-profile.less b/packages/client-app/internal_packages/participant-profile/stylesheets/participant-profile.less
new file mode 100644
index 0000000000..c460a61db9
--- /dev/null
+++ b/packages/client-app/internal_packages/participant-profile/stylesheets/participant-profile.less
@@ -0,0 +1,131 @@
+@import 'ui-variables';
+
+.related-threads {
+ width: calc(~"100% + 30px");
+ position: relative;
+ left: -15px;
+ 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 {
+ display: flex;
+ font-size: 12px;
+ color: @text-color-very-subtle;
+ width: 100%;
+ padding: 0.5em 15px;
+ border-top: 1px solid rgba(0,0,0,0.08);
+
+ &:hover {
+ background: @list-hover-bg;
+ }
+
+ .content {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding-right: 1em;
+
+ .snippet {
+ opacity: 0.5;
+ }
+ }
+ }
+
+ .toggle {
+ font-size: 12px;
+ text-align: center;
+ padding: 0.5em 15px;
+ border-top: 1px solid rgba(0,0,0,0.08);
+ color: @text-color-link;
+ }
+}
+
+.participant-profile {
+ margin-bottom: 22px;
+
+ .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: 18px;
+ font-weight: 500;
+ color: white;
+ box-shadow: inset 0 0 1px rgba(0,0,0,0.18);
+ background-image: linear-gradient(to bottom, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 100%);
+ }
+ }
+ }
+
+ .core-personal-info {
+ padding-top: 30px;
+ text-align: center;
+ margin-bottom: @spacing-standard;
+
+ .full-name, .email {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .full-name {
+ font-size: 16px;
+ }
+ .email {
+ color: @text-color-very-subtle;
+ margin-bottom: @spacing-standard;
+ }
+ .social-profiles-wrap {
+ margin-bottom: @spacing-standard;
+ }
+ .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;
+ .profile-photo {
+ border-radius: 0;
+ }
+ }
+ .related-threads {
+ border-radius: 0;
+ }
+}
diff --git a/packages/client-app/internal_packages/personal-level-indicators/README.md b/packages/client-app/internal_packages/personal-level-indicators/README.md
new file mode 100644
index 0000000000..c17d956a9f
--- /dev/null
+++ b/packages/client-app/internal_packages/personal-level-indicators/README.md
@@ -0,0 +1,15 @@
+# Personal Level Icon
+
+An icon to indicate whether an email was sent to either just you, or you and other recipients, or a mailing list that you were on.
+
+
+
+#### Enable this plugin
+
+1. Download and run N1
+
+2. Navigate to Preferences > Plugins and click "Enable" beside the plugin.
+
+#### Who?
+
+This package is annotated for developers who have no experience with React, Flux, Electron, or N1.
diff --git a/packages/client-app/internal_packages/personal-level-indicators/assets/PLI-Level1@2x.png b/packages/client-app/internal_packages/personal-level-indicators/assets/PLI-Level1@2x.png
new file mode 100755
index 0000000000..d609ebc9da
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/assets/PLI-Level1@2x.png differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/assets/PLI-Level2@2x.png b/packages/client-app/internal_packages/personal-level-indicators/assets/PLI-Level2@2x.png
new file mode 100755
index 0000000000..c28a510895
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/assets/PLI-Level2@2x.png differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/docco.css b/packages/client-app/internal_packages/personal-level-indicators/docs/docco.css
new file mode 100644
index 0000000000..b60f6fa3df
--- /dev/null
+++ b/packages/client-app/internal_packages/personal-level-indicators/docs/docco.css
@@ -0,0 +1,518 @@
+/*--------------------- Typography ----------------------------*/
+
+@font-face {
+ font-family: 'aller-light';
+ src: url('public/fonts/aller-light.eot');
+ src: url('public/fonts/aller-light.eot?#iefix') format('embedded-opentype'),
+ url('public/fonts/aller-light.woff') format('woff'),
+ url('public/fonts/aller-light.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'aller-bold';
+ src: url('public/fonts/aller-bold.eot');
+ src: url('public/fonts/aller-bold.eot?#iefix') format('embedded-opentype'),
+ url('public/fonts/aller-bold.woff') format('woff'),
+ url('public/fonts/aller-bold.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'roboto-black';
+ src: url('public/fonts/roboto-black.eot');
+ src: url('public/fonts/roboto-black.eot?#iefix') format('embedded-opentype'),
+ url('public/fonts/roboto-black.woff') format('woff'),
+ url('public/fonts/roboto-black.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+/*--------------------- Layout ----------------------------*/
+html { height: 100%; }
+body {
+ font-family: "aller-light";
+ font-size: 14px;
+ line-height: 18px;
+ color: #30404f;
+ margin: 0; padding: 0;
+ height:100%;
+}
+#container { min-height: 100%; }
+
+a {
+ color: #000;
+}
+
+b, strong {
+ font-weight: normal;
+ font-family: "aller-bold";
+}
+
+p {
+ margin: 15px 0 0px;
+}
+ .annotation ul, .annotation ol {
+ margin: 25px 0;
+ }
+ .annotation ul li, .annotation ol li {
+ font-size: 14px;
+ line-height: 18px;
+ margin: 10px 0;
+ }
+
+h1, h2, h3, h4, h5, h6 {
+ color: #112233;
+ line-height: 1em;
+ font-weight: normal;
+ font-family: "roboto-black";
+ text-transform: uppercase;
+ margin: 30px 0 15px 0;
+}
+
+h1 {
+ margin-top: 40px;
+}
+h2 {
+ font-size: 1.26em;
+}
+
+hr {
+ border: 0;
+ background: 1px #ddd;
+ height: 1px;
+ margin: 20px 0;
+}
+
+pre, tt, code {
+ font-size: 12px; line-height: 16px;
+ font-family: Menlo, Monaco, Consolas, "Lucida Console", monospace;
+ margin: 0; padding: 0;
+}
+ .annotation pre {
+ display: block;
+ margin: 0;
+ padding: 7px 10px;
+ background: #fcfcfc;
+ -moz-box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
+ -webkit-box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
+ box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
+ overflow-x: auto;
+ }
+ .annotation pre code {
+ border: 0;
+ padding: 0;
+ background: transparent;
+ }
+
+
+blockquote {
+ border-left: 5px solid #ccc;
+ margin: 0;
+ padding: 1px 0 1px 1em;
+}
+ .sections blockquote p {
+ font-family: Menlo, Consolas, Monaco, monospace;
+ font-size: 12px; line-height: 16px;
+ color: #999;
+ margin: 10px 0 0;
+ white-space: pre-wrap;
+ }
+
+ul.sections {
+ list-style: none;
+ padding:0 0 5px 0;;
+ margin:0;
+}
+
+/*
+ Force border-box so that % widths fit the parent
+ container without overlap because of margin/padding.
+
+ More Info : http://www.quirksmode.org/css/box.html
+*/
+ul.sections > li > div {
+ -moz-box-sizing: border-box; /* firefox */
+ -ms-box-sizing: border-box; /* ie */
+ -webkit-box-sizing: border-box; /* webkit */
+ -khtml-box-sizing: border-box; /* konqueror */
+ box-sizing: border-box; /* css3 */
+}
+
+
+/*---------------------- Jump Page -----------------------------*/
+#jump_to, #jump_page {
+ margin: 0;
+ background: white;
+ -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777;
+ -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px;
+ font: 16px Arial;
+ cursor: pointer;
+ text-align: right;
+ list-style: none;
+}
+
+#jump_to a {
+ text-decoration: none;
+}
+
+#jump_to a.large {
+ display: none;
+}
+#jump_to a.small {
+ font-size: 22px;
+ font-weight: bold;
+ color: #676767;
+}
+
+#jump_to, #jump_wrapper {
+ position: fixed;
+ right: 0; top: 0;
+ padding: 10px 15px;
+ margin:0;
+}
+
+#jump_wrapper {
+ display: none;
+ padding:0;
+}
+
+#jump_to:hover #jump_wrapper {
+ display: block;
+}
+
+#jump_page_wrapper{
+ position: fixed;
+ right: 0;
+ top: 0;
+ bottom: 0;
+}
+
+#jump_page {
+ padding: 5px 0 3px;
+ margin: 0 0 25px 25px;
+ max-height: 100%;
+ overflow: auto;
+}
+
+#jump_page .source {
+ display: block;
+ padding: 15px;
+ text-decoration: none;
+ border-top: 1px solid #eee;
+}
+
+#jump_page .source:hover {
+ background: #f5f5ff;
+}
+
+#jump_page .source:first-child {
+}
+
+/*---------------------- Low resolutions (> 320px) ---------------------*/
+@media only screen and (min-width: 320px) {
+ .pilwrap { display: none; }
+
+ ul.sections > li > div {
+ display: block;
+ padding:5px 10px 0 10px;
+ }
+
+ ul.sections > li > div.annotation ul, ul.sections > li > div.annotation ol {
+ padding-left: 30px;
+ }
+
+ ul.sections > li > div.content {
+ overflow-x:auto;
+ -webkit-box-shadow: inset 0 0 5px #e5e5ee;
+ box-shadow: inset 0 0 5px #e5e5ee;
+ border: 1px solid #dedede;
+ margin:5px 10px 5px 10px;
+ padding-bottom: 5px;
+ }
+
+ ul.sections > li > div.annotation pre {
+ margin: 7px 0 7px;
+ padding-left: 15px;
+ }
+
+ ul.sections > li > div.annotation p tt, .annotation code {
+ background: #f8f8ff;
+ border: 1px solid #dedede;
+ font-size: 12px;
+ padding: 0 0.2em;
+ }
+}
+
+/*---------------------- (> 481px) ---------------------*/
+@media only screen and (min-width: 481px) {
+ #container {
+ position: relative;
+ }
+ body {
+ background-color: #F5F5FF;
+ font-size: 15px;
+ line-height: 21px;
+ }
+ pre, tt, code {
+ line-height: 18px;
+ }
+ p, ul, ol {
+ margin: 0 0 15px;
+ }
+
+
+ #jump_to {
+ padding: 5px 10px;
+ }
+ #jump_wrapper {
+ padding: 0;
+ }
+ #jump_to, #jump_page {
+ font: 10px Arial;
+ text-transform: uppercase;
+ }
+ #jump_page .source {
+ padding: 5px 10px;
+ }
+ #jump_to a.large {
+ display: inline-block;
+ }
+ #jump_to a.small {
+ display: none;
+ }
+
+
+
+ #background {
+ position: absolute;
+ top: 0; bottom: 0;
+ width: 350px;
+ background: #fff;
+ border-right: 1px solid #e5e5ee;
+ z-index: -1;
+ }
+
+ ul.sections > li > div.annotation ul, ul.sections > li > div.annotation ol {
+ padding-left: 40px;
+ }
+
+ ul.sections > li {
+ white-space: nowrap;
+ }
+
+ ul.sections > li > div {
+ display: inline-block;
+ }
+
+ ul.sections > li > div.annotation {
+ max-width: 350px;
+ min-width: 350px;
+ min-height: 5px;
+ padding: 13px;
+ overflow-x: hidden;
+ white-space: normal;
+ vertical-align: top;
+ text-align: left;
+ }
+ ul.sections > li > div.annotation pre {
+ margin: 15px 0 15px;
+ padding-left: 15px;
+ }
+
+ ul.sections > li > div.content {
+ padding: 13px;
+ vertical-align: top;
+ border: none;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ }
+
+ .pilwrap {
+ position: relative;
+ display: inline;
+ }
+
+ .pilcrow {
+ font: 12px Arial;
+ text-decoration: none;
+ color: #454545;
+ position: absolute;
+ top: 3px; left: -20px;
+ padding: 1px 2px;
+ opacity: 0;
+ -webkit-transition: opacity 0.2s linear;
+ }
+ .for-h1 .pilcrow {
+ top: 47px;
+ }
+ .for-h2 .pilcrow, .for-h3 .pilcrow, .for-h4 .pilcrow {
+ top: 35px;
+ }
+
+ ul.sections > li > div.annotation:hover .pilcrow {
+ opacity: 1;
+ }
+}
+
+/*---------------------- (> 1025px) ---------------------*/
+@media only screen and (min-width: 1025px) {
+
+ body {
+ font-size: 16px;
+ line-height: 24px;
+ }
+
+ #background {
+ width: 525px;
+ }
+ ul.sections > li > div.annotation {
+ max-width: 525px;
+ min-width: 525px;
+ padding: 10px 25px 1px 50px;
+ }
+ ul.sections > li > div.content {
+ padding: 9px 15px 16px 25px;
+ }
+}
+
+/*---------------------- Syntax Highlighting -----------------------------*/
+
+td.linenos { background-color: #f0f0f0; padding-right: 10px; }
+span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; }
+/*
+
+github.com style (c) Vasily Polovnyov
+
+*/
+
+pre code {
+ display: block; padding: 0.5em;
+ color: #000;
+ background: #f8f8ff
+}
+
+pre .hljs-comment,
+pre .hljs-template_comment,
+pre .hljs-diff .hljs-header,
+pre .hljs-javadoc {
+ color: #408080;
+ font-style: italic
+}
+
+pre .hljs-keyword,
+pre .hljs-assignment,
+pre .hljs-literal,
+pre .hljs-css .hljs-rule .hljs-keyword,
+pre .hljs-winutils,
+pre .hljs-javascript .hljs-title,
+pre .hljs-lisp .hljs-title,
+pre .hljs-subst {
+ color: #954121;
+ /*font-weight: bold*/
+}
+
+pre .hljs-number,
+pre .hljs-hexcolor {
+ color: #40a070
+}
+
+pre .hljs-string,
+pre .hljs-tag .hljs-value,
+pre .hljs-phpdoc,
+pre .hljs-tex .hljs-formula {
+ color: #219161;
+}
+
+pre .hljs-title,
+pre .hljs-id {
+ color: #19469D;
+}
+pre .hljs-params {
+ color: #00F;
+}
+
+pre .hljs-javascript .hljs-title,
+pre .hljs-lisp .hljs-title,
+pre .hljs-subst {
+ font-weight: normal
+}
+
+pre .hljs-class .hljs-title,
+pre .hljs-haskell .hljs-label,
+pre .hljs-tex .hljs-command {
+ color: #458;
+ font-weight: bold
+}
+
+pre .hljs-tag,
+pre .hljs-tag .hljs-title,
+pre .hljs-rules .hljs-property,
+pre .hljs-django .hljs-tag .hljs-keyword {
+ color: #000080;
+ font-weight: normal
+}
+
+pre .hljs-attribute,
+pre .hljs-variable,
+pre .hljs-instancevar,
+pre .hljs-lisp .hljs-body {
+ color: #008080
+}
+
+pre .hljs-regexp {
+ color: #B68
+}
+
+pre .hljs-class {
+ color: #458;
+ font-weight: bold
+}
+
+pre .hljs-symbol,
+pre .hljs-ruby .hljs-symbol .hljs-string,
+pre .hljs-ruby .hljs-symbol .hljs-keyword,
+pre .hljs-ruby .hljs-symbol .hljs-keymethods,
+pre .hljs-lisp .hljs-keyword,
+pre .hljs-tex .hljs-special,
+pre .hljs-input_number {
+ color: #990073
+}
+
+pre .hljs-builtin,
+pre .hljs-constructor,
+pre .hljs-built_in,
+pre .hljs-lisp .hljs-title {
+ color: #0086b3
+}
+
+pre .hljs-preprocessor,
+pre .hljs-pi,
+pre .hljs-doctype,
+pre .hljs-shebang,
+pre .hljs-cdata {
+ color: #999;
+ font-weight: bold
+}
+
+pre .hljs-deletion {
+ background: #fdd
+}
+
+pre .hljs-addition {
+ background: #dfd
+}
+
+pre .hljs-diff .hljs-change {
+ background: #0086b3
+}
+
+pre .hljs-chunk {
+ color: #aaa
+}
+
+pre .hljs-tex .hljs-formula {
+ opacity: 0.5;
+}
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/personal-level-icon.html b/packages/client-app/internal_packages/personal-level-indicators/docs/personal-level-icon.html
new file mode 100644
index 0000000000..c4f86f658a
--- /dev/null
+++ b/packages/client-app/internal_packages/personal-level-indicators/docs/personal-level-icon.html
@@ -0,0 +1,171 @@
+
+
+
+
+ Personal Level Icon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Personal Level Icon
+
Show an icon for each thread to indicate whether you’re the only recipient,
+one of many recipients, or a member of a mailing list.
+
+
+
+
+
+
+
+
+
+
+
Access core components by requiring nylas-exports
.
+
+
+
+ {Utils, DraftStore, React} = require 'nylas-exports'
+
+
+
+
+
+
+
+
+
Access N1 React components by requiring nylas-component-kit
.
+
+
+
+ {RetinaImg} = require 'nylas-component-kit'
+
+class PersonalLevelIcon extends React .Component
+
+
+
+
+
+
+
+
+
Note: You should assign a new displayName to avoid naming
+conflicts when injecting your item
+
+
+
+ @displayName : 'PersonalLevelIcon'
+
+
+
+
+
+
+
+
+
In the constructor, we’re setting the component’s initial state.
+
+
+
+ constructor : (@props ) ->
+ @state =
+ level : @_calculateLevel (@props .thread)
+
+
+
+
+
+
+
+
+
React components’ render
methods return a virtual DOM element to render.
+The returned DOM fragment is a result of the component’s state
and
+props
. In that sense, render
methods are deterministic.
+
+
+
+ render : =>
+ React.createElement("div" , {"className" : "personal-level-icon" },
+ (@_renderIcon ())
+ )
+
+
+
+
+
+
+
+
+
Some application logic which is specific to this package to decide which
+character to render.
+
+
+
+ _renderIcon : =>
+ switch @state .level
+ when 0 then ""
+ when 1 then "\u3009"
+ when 2 then "\u300b"
+ when 3 then "\u21ba"
+
+
+
+
+
+
+
+
+
Some more application logic which is specific to this package to decide
+what level of personalness is related to the thread
.
+
+
+
+ _calculateLevel : (thread) =>
+ hasMe = (thread.participants.filter (p) -> p.isMe()) .length > 0
+ numOthers = thread .participants .length - hasMe
+ if not hasMe
+ return 0
+ if numOthers > 1
+ return 1
+ if numOthers is 1
+ return 2
+ else
+ return 3
+
+module .exports = PersonalLevelIcon
+
+
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-bold.eot b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-bold.eot
new file mode 100644
index 0000000000..1b32532a8e
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-bold.eot differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-bold.ttf b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-bold.ttf
new file mode 100644
index 0000000000..dc4cc9c27a
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-bold.ttf differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-bold.woff b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-bold.woff
new file mode 100644
index 0000000000..fa16fd0aba
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-bold.woff differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-light.eot b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-light.eot
new file mode 100644
index 0000000000..40bd654b5f
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-light.eot differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-light.ttf b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-light.ttf
new file mode 100644
index 0000000000..c2c72902a1
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-light.ttf differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-light.woff b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-light.woff
new file mode 100644
index 0000000000..81a09d18ec
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/aller-light.woff differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/roboto-black.eot b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/roboto-black.eot
new file mode 100755
index 0000000000..571ed49125
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/roboto-black.eot differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/roboto-black.ttf b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/roboto-black.ttf
new file mode 100755
index 0000000000..e0300b3ee3
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/roboto-black.ttf differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/roboto-black.woff b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/roboto-black.woff
new file mode 100755
index 0000000000..642e5b60f7
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/docs/public/fonts/roboto-black.woff differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/docs/public/stylesheets/normalize.css b/packages/client-app/internal_packages/personal-level-indicators/docs/public/stylesheets/normalize.css
new file mode 100644
index 0000000000..57b5d2679a
--- /dev/null
+++ b/packages/client-app/internal_packages/personal-level-indicators/docs/public/stylesheets/normalize.css
@@ -0,0 +1,375 @@
+/*! normalize.css v2.0.1 | MIT License | git.io/normalize */
+
+/* ==========================================================================
+ HTML5 display definitions
+ ========================================================================== */
+
+/*
+ * Corrects `block` display not defined in IE 8/9.
+ */
+
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+nav,
+section,
+summary {
+ display: block;
+}
+
+/*
+ * Corrects `inline-block` display not defined in IE 8/9.
+ */
+
+audio,
+canvas,
+video {
+ display: inline-block;
+}
+
+/*
+ * Prevents modern browsers from displaying `audio` without controls.
+ * Remove excess height in iOS 5 devices.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/*
+ * Addresses styling for `hidden` attribute not present in IE 8/9.
+ */
+
+[hidden] {
+ display: none;
+}
+
+/* ==========================================================================
+ Base
+ ========================================================================== */
+
+/*
+ * 1. Sets default font family to sans-serif.
+ * 2. Prevents iOS text size adjust after orientation change, without disabling
+ * user zoom.
+ */
+
+html {
+ font-family: sans-serif; /* 1 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+ -ms-text-size-adjust: 100%; /* 2 */
+}
+
+/*
+ * Removes default margin.
+ */
+
+body {
+ margin: 0;
+}
+
+/* ==========================================================================
+ Links
+ ========================================================================== */
+
+/*
+ * Addresses `outline` inconsistency between Chrome and other browsers.
+ */
+
+a:focus {
+ outline: thin dotted;
+}
+
+/*
+ * Improves readability when focused and also mouse hovered in all browsers.
+ */
+
+a:active,
+a:hover {
+ outline: 0;
+}
+
+/* ==========================================================================
+ Typography
+ ========================================================================== */
+
+/*
+ * Addresses `h1` font sizes within `section` and `article` in Firefox 4+,
+ * Safari 5, and Chrome.
+ */
+
+h1 {
+ font-size: 2em;
+}
+
+/*
+ * Addresses styling not present in IE 8/9, Safari 5, and Chrome.
+ */
+
+abbr[title] {
+ border-bottom: 1px dotted;
+}
+
+/*
+ * Addresses style set to `bolder` in Firefox 4+, Safari 5, and Chrome.
+ */
+
+b,
+strong {
+ font-weight: bold;
+}
+
+/*
+ * Addresses styling not present in Safari 5 and Chrome.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/*
+ * Addresses styling not present in IE 8/9.
+ */
+
+mark {
+ background: #ff0;
+ color: #000;
+}
+
+
+/*
+ * Corrects font family set oddly in Safari 5 and Chrome.
+ */
+
+code,
+kbd,
+pre,
+samp {
+ font-family: monospace, serif;
+ font-size: 1em;
+}
+
+/*
+ * Improves readability of pre-formatted text in all browsers.
+ */
+
+pre {
+ white-space: pre;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+/*
+ * Sets consistent quote types.
+ */
+
+q {
+ quotes: "\201C" "\201D" "\2018" "\2019";
+}
+
+/*
+ * Addresses inconsistent and variable font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/*
+ * Prevents `sub` and `sup` affecting `line-height` in all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sup {
+ top: -0.5em;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+/* ==========================================================================
+ Embedded content
+ ========================================================================== */
+
+/*
+ * Removes border when inside `a` element in IE 8/9.
+ */
+
+img {
+ border: 0;
+}
+
+/*
+ * Corrects overflow displayed oddly in IE 9.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* ==========================================================================
+ Figures
+ ========================================================================== */
+
+/*
+ * Addresses margin not present in IE 8/9 and Safari 5.
+ */
+
+figure {
+ margin: 0;
+}
+
+/* ==========================================================================
+ Forms
+ ========================================================================== */
+
+/*
+ * Define consistent border, margin, and padding.
+ */
+
+fieldset {
+ border: 1px solid #c0c0c0;
+ margin: 0 2px;
+ padding: 0.35em 0.625em 0.75em;
+}
+
+/*
+ * 1. Corrects color not being inherited in IE 8/9.
+ * 2. Remove padding so people aren't caught out if they zero out fieldsets.
+ */
+
+legend {
+ border: 0; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/*
+ * 1. Corrects font family not being inherited in all browsers.
+ * 2. Corrects font size not being inherited in all browsers.
+ * 3. Addresses margins set differently in Firefox 4+, Safari 5, and Chrome
+ */
+
+button,
+input,
+select,
+textarea {
+ font-family: inherit; /* 1 */
+ font-size: 100%; /* 2 */
+ margin: 0; /* 3 */
+}
+
+/*
+ * Addresses Firefox 4+ setting `line-height` on `input` using `!important` in
+ * the UA stylesheet.
+ */
+
+button,
+input {
+ line-height: normal;
+}
+
+/*
+ * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
+ * and `video` controls.
+ * 2. Corrects inability to style clickable `input` types in iOS.
+ * 3. Improves usability and consistency of cursor style between image-type
+ * `input` and others.
+ */
+
+button,
+html input[type="button"], /* 1 */
+input[type="reset"],
+input[type="submit"] {
+ -webkit-appearance: button; /* 2 */
+ cursor: pointer; /* 3 */
+}
+
+/*
+ * Re-set default cursor for disabled elements.
+ */
+
+button[disabled],
+input[disabled] {
+ cursor: default;
+}
+
+/*
+ * 1. Addresses box sizing set to `content-box` in IE 8/9.
+ * 2. Removes excess padding in IE 8/9.
+ */
+
+input[type="checkbox"],
+input[type="radio"] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/*
+ * 1. Addresses `appearance` set to `searchfield` in Safari 5 and Chrome.
+ * 2. Addresses `box-sizing` set to `border-box` in Safari 5 and Chrome
+ * (include `-moz` to future-proof).
+ */
+
+input[type="search"] {
+ -webkit-appearance: textfield; /* 1 */
+ -moz-box-sizing: content-box;
+ -webkit-box-sizing: content-box; /* 2 */
+ box-sizing: content-box;
+}
+
+/*
+ * Removes inner padding and search cancel button in Safari 5 and Chrome
+ * on OS X.
+ */
+
+input[type="search"]::-webkit-search-cancel-button,
+input[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+ * Removes inner padding and border in Firefox 4+.
+ */
+
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+
+/*
+ * 1. Removes default vertical scrollbar in IE 8/9.
+ * 2. Improves readability and alignment in all browsers.
+ */
+
+textarea {
+ overflow: auto; /* 1 */
+ vertical-align: top; /* 2 */
+}
+
+/* ==========================================================================
+ Tables
+ ========================================================================== */
+
+/*
+ * Remove most spacing between table cells.
+ */
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
diff --git a/packages/client-app/internal_packages/personal-level-indicators/examples-screencap-personal-level-icon.png b/packages/client-app/internal_packages/personal-level-indicators/examples-screencap-personal-level-icon.png
new file mode 100644
index 0000000000..e864b54b34
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/examples-screencap-personal-level-icon.png differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/icon.png b/packages/client-app/internal_packages/personal-level-indicators/icon.png
new file mode 100644
index 0000000000..c00bfa5cf6
Binary files /dev/null and b/packages/client-app/internal_packages/personal-level-indicators/icon.png differ
diff --git a/packages/client-app/internal_packages/personal-level-indicators/lib/main.es6 b/packages/client-app/internal_packages/personal-level-indicators/lib/main.es6
new file mode 100644
index 0000000000..69b546ae9d
--- /dev/null
+++ b/packages/client-app/internal_packages/personal-level-indicators/lib/main.es6
@@ -0,0 +1,32 @@
+import {ComponentRegistry} from 'nylas-exports'
+import PersonalLevelIcon from './personal-level-icon'
+
+/*
+All packages must export a basic object that has at least the following 3
+methods:
+
+1. `activate` - Actions to take once the package gets turned on.
+Pre-enabled packages get activated on N1 bootup. They can also be
+activated manually by a user.
+
+2. `deactivate` - Actions to take when a package gets turned off. This can
+happen when a user manually disables a package.
+
+3. `serialize` - A simple serializable object that gets saved to disk
+before N1 quits. This gets passed back into `activate` next time N1 boots
+up or your package is manually activated.
+*/
+
+export function activate() {
+ ComponentRegistry.register(PersonalLevelIcon, {
+ role: 'ThreadListIcon',
+ });
+}
+
+export function serialize() {
+ return {};
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(PersonalLevelIcon);
+}
diff --git a/packages/client-app/internal_packages/personal-level-indicators/lib/personal-level-icon.jsx b/packages/client-app/internal_packages/personal-level-indicators/lib/personal-level-icon.jsx
new file mode 100644
index 0000000000..e60726761a
--- /dev/null
+++ b/packages/client-app/internal_packages/personal-level-indicators/lib/personal-level-icon.jsx
@@ -0,0 +1,44 @@
+import {React} from 'nylas-exports';
+import {RetinaImg} from 'nylas-component-kit';
+
+const StaticEmptyIndicator = (
+
+);
+
+export default class PersonalLevelIcon extends React.Component {
+ // Note: You should assign a new displayName to avoid naming
+ // conflicts when injecting your item
+ static displayName = 'PersonalLevelIcon';
+
+ static propTypes = {
+ thread: React.PropTypes.object.isRequired,
+ };
+
+ renderIndicator(level) {
+ return (
+
+
+
+ )
+ }
+
+ // React components' `render` methods return a virtual DOM element to render.
+ // The returned DOM fragment is a result of the component's `state` and
+ // `props`. In that sense, `render` methods are deterministic.
+ render() {
+ const {thread} = this.props;
+ const me = thread.participants.find(p => p.isMe());
+
+ if (me && thread.participants.length === 2) {
+ return this.renderIndicator(2);
+ }
+ if (me && thread.participants.length > 2) {
+ return this.renderIndicator(1);
+ }
+
+ return StaticEmptyIndicator;
+ }
+}
diff --git a/packages/client-app/internal_packages/personal-level-indicators/package.json b/packages/client-app/internal_packages/personal-level-indicators/package.json
new file mode 100644
index 0000000000..0b0755bf07
--- /dev/null
+++ b/packages/client-app/internal_packages/personal-level-indicators/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "personal-level-indicators",
+ "main": "./lib/main",
+ "version": "0.1.0",
+ "isHiddenOnPluginsPage": true,
+
+ "title": "Personal Level Indicators",
+ "description": "Display chevrons beside threads that indicate whether you're a direct recipient or the only recipient on a thread.",
+ "icon": "./icon.png",
+ "isOptional": true,
+
+ "repository": {
+ "type": "git",
+ "url": ""
+ },
+ "engines": {
+ "nylas": "*"
+ },
+ "license": "GPL-3.0"
+}
diff --git a/packages/client-app/internal_packages/personal-level-indicators/stylesheets/main.less b/packages/client-app/internal_packages/personal-level-indicators/stylesheets/main.less
new file mode 100644
index 0000000000..232eeab702
--- /dev/null
+++ b/packages/client-app/internal_packages/personal-level-indicators/stylesheets/main.less
@@ -0,0 +1,19 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+div.personal-level-icon {
+ display: inline-block;
+ margin: 0 3px;
+ width: 12px;
+ img {
+ vertical-align: initial;
+ }
+}
+
+.list-item.focused, .list-item.selected {
+ div.personal-level-icon {
+ img {
+ -webkit-filter: brightness(600%) grayscale(100%);
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/phishing-detection/README.md b/packages/client-app/internal_packages/phishing-detection/README.md
new file mode 100644
index 0000000000..1082c814a9
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/README.md
@@ -0,0 +1,30 @@
+## Phishing Detection
+
+A sample package for Nylas Mail to detect simple phishing attempts. This package display a simple warning if
+a message's originating address is different from its return address. The warning looks like this:
+
+![screenshot](./screenshot.png)
+
+#### Install this plugin
+
+1. Download and run N1
+
+2. From the menu, select `Developer > Install a Package Manually...`
+ The dialog will default to this examples directory. Just choose the
+ package to install it!
+
+ > When you install packages, they're moved to `~/.nylas-mail/packages`,
+ > and N1 runs `apm install` on the command line to fetch dependencies
+ > listed in the package's `package.json`
+
+#### Who is this for?
+
+This package is our slimmest example package. It's annotated for developers who have no experience with React, Flux, Electron, or N1.
+
+#### To build documentation (the manual way)
+
+```
+cjsx-transform lib/main.cjsx > docs/main.coffee
+docco docs/main.coffee
+rm docs/main.coffee
+```
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/docco.css b/packages/client-app/internal_packages/phishing-detection/docs/docco.css
new file mode 100644
index 0000000000..b60f6fa3df
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/docs/docco.css
@@ -0,0 +1,518 @@
+/*--------------------- Typography ----------------------------*/
+
+@font-face {
+ font-family: 'aller-light';
+ src: url('public/fonts/aller-light.eot');
+ src: url('public/fonts/aller-light.eot?#iefix') format('embedded-opentype'),
+ url('public/fonts/aller-light.woff') format('woff'),
+ url('public/fonts/aller-light.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'aller-bold';
+ src: url('public/fonts/aller-bold.eot');
+ src: url('public/fonts/aller-bold.eot?#iefix') format('embedded-opentype'),
+ url('public/fonts/aller-bold.woff') format('woff'),
+ url('public/fonts/aller-bold.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'roboto-black';
+ src: url('public/fonts/roboto-black.eot');
+ src: url('public/fonts/roboto-black.eot?#iefix') format('embedded-opentype'),
+ url('public/fonts/roboto-black.woff') format('woff'),
+ url('public/fonts/roboto-black.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+/*--------------------- Layout ----------------------------*/
+html { height: 100%; }
+body {
+ font-family: "aller-light";
+ font-size: 14px;
+ line-height: 18px;
+ color: #30404f;
+ margin: 0; padding: 0;
+ height:100%;
+}
+#container { min-height: 100%; }
+
+a {
+ color: #000;
+}
+
+b, strong {
+ font-weight: normal;
+ font-family: "aller-bold";
+}
+
+p {
+ margin: 15px 0 0px;
+}
+ .annotation ul, .annotation ol {
+ margin: 25px 0;
+ }
+ .annotation ul li, .annotation ol li {
+ font-size: 14px;
+ line-height: 18px;
+ margin: 10px 0;
+ }
+
+h1, h2, h3, h4, h5, h6 {
+ color: #112233;
+ line-height: 1em;
+ font-weight: normal;
+ font-family: "roboto-black";
+ text-transform: uppercase;
+ margin: 30px 0 15px 0;
+}
+
+h1 {
+ margin-top: 40px;
+}
+h2 {
+ font-size: 1.26em;
+}
+
+hr {
+ border: 0;
+ background: 1px #ddd;
+ height: 1px;
+ margin: 20px 0;
+}
+
+pre, tt, code {
+ font-size: 12px; line-height: 16px;
+ font-family: Menlo, Monaco, Consolas, "Lucida Console", monospace;
+ margin: 0; padding: 0;
+}
+ .annotation pre {
+ display: block;
+ margin: 0;
+ padding: 7px 10px;
+ background: #fcfcfc;
+ -moz-box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
+ -webkit-box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
+ box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
+ overflow-x: auto;
+ }
+ .annotation pre code {
+ border: 0;
+ padding: 0;
+ background: transparent;
+ }
+
+
+blockquote {
+ border-left: 5px solid #ccc;
+ margin: 0;
+ padding: 1px 0 1px 1em;
+}
+ .sections blockquote p {
+ font-family: Menlo, Consolas, Monaco, monospace;
+ font-size: 12px; line-height: 16px;
+ color: #999;
+ margin: 10px 0 0;
+ white-space: pre-wrap;
+ }
+
+ul.sections {
+ list-style: none;
+ padding:0 0 5px 0;;
+ margin:0;
+}
+
+/*
+ Force border-box so that % widths fit the parent
+ container without overlap because of margin/padding.
+
+ More Info : http://www.quirksmode.org/css/box.html
+*/
+ul.sections > li > div {
+ -moz-box-sizing: border-box; /* firefox */
+ -ms-box-sizing: border-box; /* ie */
+ -webkit-box-sizing: border-box; /* webkit */
+ -khtml-box-sizing: border-box; /* konqueror */
+ box-sizing: border-box; /* css3 */
+}
+
+
+/*---------------------- Jump Page -----------------------------*/
+#jump_to, #jump_page {
+ margin: 0;
+ background: white;
+ -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777;
+ -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px;
+ font: 16px Arial;
+ cursor: pointer;
+ text-align: right;
+ list-style: none;
+}
+
+#jump_to a {
+ text-decoration: none;
+}
+
+#jump_to a.large {
+ display: none;
+}
+#jump_to a.small {
+ font-size: 22px;
+ font-weight: bold;
+ color: #676767;
+}
+
+#jump_to, #jump_wrapper {
+ position: fixed;
+ right: 0; top: 0;
+ padding: 10px 15px;
+ margin:0;
+}
+
+#jump_wrapper {
+ display: none;
+ padding:0;
+}
+
+#jump_to:hover #jump_wrapper {
+ display: block;
+}
+
+#jump_page_wrapper{
+ position: fixed;
+ right: 0;
+ top: 0;
+ bottom: 0;
+}
+
+#jump_page {
+ padding: 5px 0 3px;
+ margin: 0 0 25px 25px;
+ max-height: 100%;
+ overflow: auto;
+}
+
+#jump_page .source {
+ display: block;
+ padding: 15px;
+ text-decoration: none;
+ border-top: 1px solid #eee;
+}
+
+#jump_page .source:hover {
+ background: #f5f5ff;
+}
+
+#jump_page .source:first-child {
+}
+
+/*---------------------- Low resolutions (> 320px) ---------------------*/
+@media only screen and (min-width: 320px) {
+ .pilwrap { display: none; }
+
+ ul.sections > li > div {
+ display: block;
+ padding:5px 10px 0 10px;
+ }
+
+ ul.sections > li > div.annotation ul, ul.sections > li > div.annotation ol {
+ padding-left: 30px;
+ }
+
+ ul.sections > li > div.content {
+ overflow-x:auto;
+ -webkit-box-shadow: inset 0 0 5px #e5e5ee;
+ box-shadow: inset 0 0 5px #e5e5ee;
+ border: 1px solid #dedede;
+ margin:5px 10px 5px 10px;
+ padding-bottom: 5px;
+ }
+
+ ul.sections > li > div.annotation pre {
+ margin: 7px 0 7px;
+ padding-left: 15px;
+ }
+
+ ul.sections > li > div.annotation p tt, .annotation code {
+ background: #f8f8ff;
+ border: 1px solid #dedede;
+ font-size: 12px;
+ padding: 0 0.2em;
+ }
+}
+
+/*---------------------- (> 481px) ---------------------*/
+@media only screen and (min-width: 481px) {
+ #container {
+ position: relative;
+ }
+ body {
+ background-color: #F5F5FF;
+ font-size: 15px;
+ line-height: 21px;
+ }
+ pre, tt, code {
+ line-height: 18px;
+ }
+ p, ul, ol {
+ margin: 0 0 15px;
+ }
+
+
+ #jump_to {
+ padding: 5px 10px;
+ }
+ #jump_wrapper {
+ padding: 0;
+ }
+ #jump_to, #jump_page {
+ font: 10px Arial;
+ text-transform: uppercase;
+ }
+ #jump_page .source {
+ padding: 5px 10px;
+ }
+ #jump_to a.large {
+ display: inline-block;
+ }
+ #jump_to a.small {
+ display: none;
+ }
+
+
+
+ #background {
+ position: absolute;
+ top: 0; bottom: 0;
+ width: 350px;
+ background: #fff;
+ border-right: 1px solid #e5e5ee;
+ z-index: -1;
+ }
+
+ ul.sections > li > div.annotation ul, ul.sections > li > div.annotation ol {
+ padding-left: 40px;
+ }
+
+ ul.sections > li {
+ white-space: nowrap;
+ }
+
+ ul.sections > li > div {
+ display: inline-block;
+ }
+
+ ul.sections > li > div.annotation {
+ max-width: 350px;
+ min-width: 350px;
+ min-height: 5px;
+ padding: 13px;
+ overflow-x: hidden;
+ white-space: normal;
+ vertical-align: top;
+ text-align: left;
+ }
+ ul.sections > li > div.annotation pre {
+ margin: 15px 0 15px;
+ padding-left: 15px;
+ }
+
+ ul.sections > li > div.content {
+ padding: 13px;
+ vertical-align: top;
+ border: none;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ }
+
+ .pilwrap {
+ position: relative;
+ display: inline;
+ }
+
+ .pilcrow {
+ font: 12px Arial;
+ text-decoration: none;
+ color: #454545;
+ position: absolute;
+ top: 3px; left: -20px;
+ padding: 1px 2px;
+ opacity: 0;
+ -webkit-transition: opacity 0.2s linear;
+ }
+ .for-h1 .pilcrow {
+ top: 47px;
+ }
+ .for-h2 .pilcrow, .for-h3 .pilcrow, .for-h4 .pilcrow {
+ top: 35px;
+ }
+
+ ul.sections > li > div.annotation:hover .pilcrow {
+ opacity: 1;
+ }
+}
+
+/*---------------------- (> 1025px) ---------------------*/
+@media only screen and (min-width: 1025px) {
+
+ body {
+ font-size: 16px;
+ line-height: 24px;
+ }
+
+ #background {
+ width: 525px;
+ }
+ ul.sections > li > div.annotation {
+ max-width: 525px;
+ min-width: 525px;
+ padding: 10px 25px 1px 50px;
+ }
+ ul.sections > li > div.content {
+ padding: 9px 15px 16px 25px;
+ }
+}
+
+/*---------------------- Syntax Highlighting -----------------------------*/
+
+td.linenos { background-color: #f0f0f0; padding-right: 10px; }
+span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; }
+/*
+
+github.com style (c) Vasily Polovnyov
+
+*/
+
+pre code {
+ display: block; padding: 0.5em;
+ color: #000;
+ background: #f8f8ff
+}
+
+pre .hljs-comment,
+pre .hljs-template_comment,
+pre .hljs-diff .hljs-header,
+pre .hljs-javadoc {
+ color: #408080;
+ font-style: italic
+}
+
+pre .hljs-keyword,
+pre .hljs-assignment,
+pre .hljs-literal,
+pre .hljs-css .hljs-rule .hljs-keyword,
+pre .hljs-winutils,
+pre .hljs-javascript .hljs-title,
+pre .hljs-lisp .hljs-title,
+pre .hljs-subst {
+ color: #954121;
+ /*font-weight: bold*/
+}
+
+pre .hljs-number,
+pre .hljs-hexcolor {
+ color: #40a070
+}
+
+pre .hljs-string,
+pre .hljs-tag .hljs-value,
+pre .hljs-phpdoc,
+pre .hljs-tex .hljs-formula {
+ color: #219161;
+}
+
+pre .hljs-title,
+pre .hljs-id {
+ color: #19469D;
+}
+pre .hljs-params {
+ color: #00F;
+}
+
+pre .hljs-javascript .hljs-title,
+pre .hljs-lisp .hljs-title,
+pre .hljs-subst {
+ font-weight: normal
+}
+
+pre .hljs-class .hljs-title,
+pre .hljs-haskell .hljs-label,
+pre .hljs-tex .hljs-command {
+ color: #458;
+ font-weight: bold
+}
+
+pre .hljs-tag,
+pre .hljs-tag .hljs-title,
+pre .hljs-rules .hljs-property,
+pre .hljs-django .hljs-tag .hljs-keyword {
+ color: #000080;
+ font-weight: normal
+}
+
+pre .hljs-attribute,
+pre .hljs-variable,
+pre .hljs-instancevar,
+pre .hljs-lisp .hljs-body {
+ color: #008080
+}
+
+pre .hljs-regexp {
+ color: #B68
+}
+
+pre .hljs-class {
+ color: #458;
+ font-weight: bold
+}
+
+pre .hljs-symbol,
+pre .hljs-ruby .hljs-symbol .hljs-string,
+pre .hljs-ruby .hljs-symbol .hljs-keyword,
+pre .hljs-ruby .hljs-symbol .hljs-keymethods,
+pre .hljs-lisp .hljs-keyword,
+pre .hljs-tex .hljs-special,
+pre .hljs-input_number {
+ color: #990073
+}
+
+pre .hljs-builtin,
+pre .hljs-constructor,
+pre .hljs-built_in,
+pre .hljs-lisp .hljs-title {
+ color: #0086b3
+}
+
+pre .hljs-preprocessor,
+pre .hljs-pi,
+pre .hljs-doctype,
+pre .hljs-shebang,
+pre .hljs-cdata {
+ color: #999;
+ font-weight: bold
+}
+
+pre .hljs-deletion {
+ background: #fdd
+}
+
+pre .hljs-addition {
+ background: #dfd
+}
+
+pre .hljs-diff .hljs-change {
+ background: #0086b3
+}
+
+pre .hljs-chunk {
+ color: #aaa
+}
+
+pre .hljs-tex .hljs-formula {
+ opacity: 0.5;
+}
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/main.coffee b/packages/client-app/internal_packages/phishing-detection/docs/main.coffee
new file mode 100644
index 0000000000..40278749d1
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/docs/main.coffee
@@ -0,0 +1,92 @@
+# # Phishing Detection
+#
+# This is a simple package to notify N1 users if an email is a potential
+# phishing scam.
+
+# You can access N1 dependencies by requiring 'nylas-exports'
+{React,
+ # The ComponentRegistry manages all React components in N1.
+ ComponentRegistry,
+ # A `Store` is a Flux component which contains all business logic and data
+ # models to be consumed by React components to render markup.
+ MessageStore} = require 'nylas-exports'
+
+# Notice that this file is `main.cjsx` rather than `main.coffee`. We use the
+# `.cjsx` filetype because we use the CJSX DSL to describe markup for React to
+# render. Without the CJSX, we could just name this file `main.coffee` instead.
+class PhishingIndicator extends React.Component
+
+ # Adding a @displayName to a React component helps for debugging.
+ @displayName: 'PhishingIndicator'
+
+ # @propTypes is an object which validates the datatypes of properties that
+ # this React component can receive.
+ @propTypes:
+ thread: React.PropTypes.object.isRequired
+
+ # A React component's `render` method returns a virtual DOM element described
+ # in CJSX. `render` is deterministic: with the same input, it will always
+ # render the same output. Here, the input is provided by @isPhishingAttempt.
+ # `@state` and `@props` are popular inputs as well.
+ render: =>
+
+ # Our inputs for the virtual DOM to render come from @isPhishingAttempt.
+ [from, reply_to] = @isPhishingAttempt()
+
+ # We add some more application logic to decide how to render.
+ if from isnt null and reply_to isnt null
+ React.createElement("div", {"className": "phishingIndicator"},
+ React.createElement("b", null, "This message looks suspicious!"),
+ React.createElement("p", null, "It originates from ", (from), " but replies will go to ", (reply_to), ".")
+ )
+
+ # If you don't want a React component to render anything at all, then your
+ # `render` method should return `null` or `undefined`.
+ else
+ null
+
+ isPhishingAttempt: =>
+
+ # In this package, the MessageStore is the source of our data which will be
+ # the input for the `render` function. @isPhishingAttempt is performing some
+ # domain-specific application logic to prepare the data for `render`.
+ message = MessageStore.items()[0]
+
+ # This package's strategy to ascertain whether or not the email is a
+ # phishing attempt boils down to checking the `replyTo` attributes on
+ # `Message` models from `MessageStore`.
+ if message.replyTo? and message.replyTo.length != 0
+
+ # The `from` and `replyTo` attributes on `Message` models both refer to
+ # arrays of `Contact` models, which in turn have `email` attributes.
+ from = message.from[0].email
+ reply_to = message.replyTo[0].email
+
+ # This is our core logic for our whole package! If the `from` and
+ # `replyTo` emails are different, then we want to show a phishing warning.
+ return [from, reply_to] if reply_to isnt from
+
+ return [null, null]
+
+module.exports =
+
+ # Activate is called when the package is loaded. If your package previously
+ # saved state using `serialize` it is provided.
+ activate: (@state) ->
+
+ # This is a good time to tell the `ComponentRegistry` to insert our
+ # React component into the `'MessageListHeaders'` part of the application.
+ ComponentRegistry.register PhishingIndicator,
+ role: 'MessageListHeaders'
+
+ # Serialize is called when your package is about to be unmounted.
+ # You can return a state object that will be passed back to your package
+ # when it is re-activated.
+ serialize: ->
+
+ # This **optional** method is called when the window is shutting down,
+ # or when your package is being updated or disabled. If your package is
+ # watching any files, holding external resources, providing commands or
+ # subscribing to events, release them here.
+ deactivate: ->
+ ComponentRegistry.unregister(PhishingIndicator)
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/main.html b/packages/client-app/internal_packages/phishing-detection/docs/main.html
new file mode 100644
index 0000000000..bf41c4124b
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/docs/main.html
@@ -0,0 +1,342 @@
+
+
+
+
+ Phishing Detection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Phishing Detection
+
This is a simple package to notify N1 users if an email is a potential
+phishing scam.
+
+
+
+
+
+
+
+
+
+
+
You can access N1 dependencies by requiring ‘nylas-exports’
+
+
+
+
+
+
+
+
+
+
+
+
+
The ComponentRegistry manages all React components in N1.
+
+
+
+
+
+
+
+
+
+
+
+
+
A Store
is a Flux component which contains all business logic and data
+models to be consumed by React components to render markup.
+
+
+
+ MessageStore} = require 'nylas-exports'
+
+
+
+
+
+
+
+
+
Notice that this file is main.cjsx
rather than main.coffee
. We use the
+.cjsx
filetype because we use the CJSX DSL to describe markup for React to
+render. Without the CJSX, we could just name this file main.coffee
instead.
+
+
+
+ class PhishingIndicator extends React .Component
+
+
+
+
+
+
+
+
+
Adding a @displayName to a React component helps for debugging.
+
+
+
+ @displayName : 'PhishingIndicator'
+
+
+
+
+
+
+
+
+
@propTypes is an object which validates the datatypes of properties that
+this React component can receive.
+
+
+
+ @propTypes :
+ thread : React.PropTypes.object.isRequired
+
+
+
+
+
+
+
+
+
A React component’s render
method returns a virtual DOM element described
+in CJSX. render
is deterministic: with the same input, it will always
+render the same output. Here, the input is provided by @isPhishingAttempt.
+@state
and @props
are popular inputs as well.
+
+
+
+
+
+
+
+
+
+
+
+
+
Our inputs for the virtual DOM to render come from @isPhishingAttempt.
+
+
+
+ [from, reply_to] = @isPhishingAttempt ()
+
+
+
+
+
+
+
+
+
We add some more application logic to decide how to render.
+
+
+
+ if from isnt null and reply_to isnt null
+ React.createElement("div" , {"className" : "phishingIndicator" },
+ React.createElement("b" , null , "This message looks suspicious!" ),
+ React.createElement("p" , null , "It originates from " , (from), " but replies will go to " , (reply_to), "." )
+ )
+
+
+
+
+
+
+
+
+
If you don’t want a React component to render anything at all, then your
+render
method should return null
or undefined
.
+
+
+
+ else
+ null
+
+ isPhishingAttempt : =>
+
+
+
+
+
+
+
+
+
In this package, the MessageStore is the source of our data which will be
+the input for the render
function. @isPhishingAttempt is performing some
+domain-specific application logic to prepare the data for render
.
+
+
+
+ message = MessageStore.items()[0 ]
+
+
+
+
+
+
+
+
+
This package’s strategy to ascertain whether or not the email is a
+phishing attempt boils down to checking the replyTo
attributes on
+Message
models from MessageStore
.
+
+
+
+ if message.replyTo? and message.replyTo.length != 0
+
+
+
+
+
+
+
+
+
The from
and replyTo
attributes on Message
models both refer to
+arrays of Contact
models, which in turn have email
attributes.
+
+
+
+ from = message.from[0 ].email
+ reply_to = message.replyTo[0 ].email
+
+
+
+
+
+
+
+
+
This is our core logic for our whole package! If the from
and
+replyTo
emails are different, then we want to show a phishing warning.
+
+
+
+ if reply_to isnt from
+ return [from, reply_to]
+
+ return [null , null ];
+
+module .exports =
+
+
+
+
+
+
+
+
+
Activate is called when the package is loaded. If your package previously
+saved state using serialize
it is provided.
+
+
+
+
+
+
+
+
+
+
+
+
+
This is a good time to tell the ComponentRegistry
to insert our
+React component into the 'MessageListHeaders'
part of the application.
+
+
+
+ ComponentRegistry.register PhishingIndicator,
+ role : 'MessageListHeaders'
+
+
+
+
+
+
+
+
+
Serialize is called when your package is about to be unmounted.
+You can return a state object that will be passed back to your package
+when it is re-activated.
+
+
+
+
+
+
+
+
+
+
+
+
+
This optional method is called when the window is shutting down,
+or when your package is being updated or disabled. If your package is
+watching any files, holding external resources, providing commands or
+subscribing to events, release them here.
+
+
+
+ deactivate : ->
+ ComponentRegistry.unregister(PhishingIndicator)
+
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-bold.eot b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-bold.eot
new file mode 100644
index 0000000000..1b32532a8e
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-bold.eot differ
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-bold.ttf b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-bold.ttf
new file mode 100644
index 0000000000..dc4cc9c27a
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-bold.ttf differ
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-bold.woff b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-bold.woff
new file mode 100644
index 0000000000..fa16fd0aba
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-bold.woff differ
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-light.eot b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-light.eot
new file mode 100644
index 0000000000..40bd654b5f
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-light.eot differ
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-light.ttf b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-light.ttf
new file mode 100644
index 0000000000..c2c72902a1
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-light.ttf differ
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-light.woff b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-light.woff
new file mode 100644
index 0000000000..81a09d18ec
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/aller-light.woff differ
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/roboto-black.eot b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/roboto-black.eot
new file mode 100755
index 0000000000..571ed49125
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/roboto-black.eot differ
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/roboto-black.ttf b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/roboto-black.ttf
new file mode 100755
index 0000000000..e0300b3ee3
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/roboto-black.ttf differ
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/roboto-black.woff b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/roboto-black.woff
new file mode 100755
index 0000000000..642e5b60f7
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/docs/public/fonts/roboto-black.woff differ
diff --git a/packages/client-app/internal_packages/phishing-detection/docs/public/stylesheets/normalize.css b/packages/client-app/internal_packages/phishing-detection/docs/public/stylesheets/normalize.css
new file mode 100644
index 0000000000..57b5d2679a
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/docs/public/stylesheets/normalize.css
@@ -0,0 +1,375 @@
+/*! normalize.css v2.0.1 | MIT License | git.io/normalize */
+
+/* ==========================================================================
+ HTML5 display definitions
+ ========================================================================== */
+
+/*
+ * Corrects `block` display not defined in IE 8/9.
+ */
+
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+nav,
+section,
+summary {
+ display: block;
+}
+
+/*
+ * Corrects `inline-block` display not defined in IE 8/9.
+ */
+
+audio,
+canvas,
+video {
+ display: inline-block;
+}
+
+/*
+ * Prevents modern browsers from displaying `audio` without controls.
+ * Remove excess height in iOS 5 devices.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/*
+ * Addresses styling for `hidden` attribute not present in IE 8/9.
+ */
+
+[hidden] {
+ display: none;
+}
+
+/* ==========================================================================
+ Base
+ ========================================================================== */
+
+/*
+ * 1. Sets default font family to sans-serif.
+ * 2. Prevents iOS text size adjust after orientation change, without disabling
+ * user zoom.
+ */
+
+html {
+ font-family: sans-serif; /* 1 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+ -ms-text-size-adjust: 100%; /* 2 */
+}
+
+/*
+ * Removes default margin.
+ */
+
+body {
+ margin: 0;
+}
+
+/* ==========================================================================
+ Links
+ ========================================================================== */
+
+/*
+ * Addresses `outline` inconsistency between Chrome and other browsers.
+ */
+
+a:focus {
+ outline: thin dotted;
+}
+
+/*
+ * Improves readability when focused and also mouse hovered in all browsers.
+ */
+
+a:active,
+a:hover {
+ outline: 0;
+}
+
+/* ==========================================================================
+ Typography
+ ========================================================================== */
+
+/*
+ * Addresses `h1` font sizes within `section` and `article` in Firefox 4+,
+ * Safari 5, and Chrome.
+ */
+
+h1 {
+ font-size: 2em;
+}
+
+/*
+ * Addresses styling not present in IE 8/9, Safari 5, and Chrome.
+ */
+
+abbr[title] {
+ border-bottom: 1px dotted;
+}
+
+/*
+ * Addresses style set to `bolder` in Firefox 4+, Safari 5, and Chrome.
+ */
+
+b,
+strong {
+ font-weight: bold;
+}
+
+/*
+ * Addresses styling not present in Safari 5 and Chrome.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/*
+ * Addresses styling not present in IE 8/9.
+ */
+
+mark {
+ background: #ff0;
+ color: #000;
+}
+
+
+/*
+ * Corrects font family set oddly in Safari 5 and Chrome.
+ */
+
+code,
+kbd,
+pre,
+samp {
+ font-family: monospace, serif;
+ font-size: 1em;
+}
+
+/*
+ * Improves readability of pre-formatted text in all browsers.
+ */
+
+pre {
+ white-space: pre;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+/*
+ * Sets consistent quote types.
+ */
+
+q {
+ quotes: "\201C" "\201D" "\2018" "\2019";
+}
+
+/*
+ * Addresses inconsistent and variable font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/*
+ * Prevents `sub` and `sup` affecting `line-height` in all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sup {
+ top: -0.5em;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+/* ==========================================================================
+ Embedded content
+ ========================================================================== */
+
+/*
+ * Removes border when inside `a` element in IE 8/9.
+ */
+
+img {
+ border: 0;
+}
+
+/*
+ * Corrects overflow displayed oddly in IE 9.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* ==========================================================================
+ Figures
+ ========================================================================== */
+
+/*
+ * Addresses margin not present in IE 8/9 and Safari 5.
+ */
+
+figure {
+ margin: 0;
+}
+
+/* ==========================================================================
+ Forms
+ ========================================================================== */
+
+/*
+ * Define consistent border, margin, and padding.
+ */
+
+fieldset {
+ border: 1px solid #c0c0c0;
+ margin: 0 2px;
+ padding: 0.35em 0.625em 0.75em;
+}
+
+/*
+ * 1. Corrects color not being inherited in IE 8/9.
+ * 2. Remove padding so people aren't caught out if they zero out fieldsets.
+ */
+
+legend {
+ border: 0; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/*
+ * 1. Corrects font family not being inherited in all browsers.
+ * 2. Corrects font size not being inherited in all browsers.
+ * 3. Addresses margins set differently in Firefox 4+, Safari 5, and Chrome
+ */
+
+button,
+input,
+select,
+textarea {
+ font-family: inherit; /* 1 */
+ font-size: 100%; /* 2 */
+ margin: 0; /* 3 */
+}
+
+/*
+ * Addresses Firefox 4+ setting `line-height` on `input` using `!important` in
+ * the UA stylesheet.
+ */
+
+button,
+input {
+ line-height: normal;
+}
+
+/*
+ * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
+ * and `video` controls.
+ * 2. Corrects inability to style clickable `input` types in iOS.
+ * 3. Improves usability and consistency of cursor style between image-type
+ * `input` and others.
+ */
+
+button,
+html input[type="button"], /* 1 */
+input[type="reset"],
+input[type="submit"] {
+ -webkit-appearance: button; /* 2 */
+ cursor: pointer; /* 3 */
+}
+
+/*
+ * Re-set default cursor for disabled elements.
+ */
+
+button[disabled],
+input[disabled] {
+ cursor: default;
+}
+
+/*
+ * 1. Addresses box sizing set to `content-box` in IE 8/9.
+ * 2. Removes excess padding in IE 8/9.
+ */
+
+input[type="checkbox"],
+input[type="radio"] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/*
+ * 1. Addresses `appearance` set to `searchfield` in Safari 5 and Chrome.
+ * 2. Addresses `box-sizing` set to `border-box` in Safari 5 and Chrome
+ * (include `-moz` to future-proof).
+ */
+
+input[type="search"] {
+ -webkit-appearance: textfield; /* 1 */
+ -moz-box-sizing: content-box;
+ -webkit-box-sizing: content-box; /* 2 */
+ box-sizing: content-box;
+}
+
+/*
+ * Removes inner padding and search cancel button in Safari 5 and Chrome
+ * on OS X.
+ */
+
+input[type="search"]::-webkit-search-cancel-button,
+input[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+ * Removes inner padding and border in Firefox 4+.
+ */
+
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+
+/*
+ * 1. Removes default vertical scrollbar in IE 8/9.
+ * 2. Improves readability and alignment in all browsers.
+ */
+
+textarea {
+ overflow: auto; /* 1 */
+ vertical-align: top; /* 2 */
+}
+
+/* ==========================================================================
+ Tables
+ ========================================================================== */
+
+/*
+ * Remove most spacing between table cells.
+ */
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
diff --git a/packages/client-app/internal_packages/phishing-detection/icon.png b/packages/client-app/internal_packages/phishing-detection/icon.png
new file mode 100644
index 0000000000..a9c381077b
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/icon.png differ
diff --git a/packages/client-app/internal_packages/phishing-detection/lib/main.jsx b/packages/client-app/internal_packages/phishing-detection/lib/main.jsx
new file mode 100644
index 0000000000..b6294209ac
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/lib/main.jsx
@@ -0,0 +1,93 @@
+import {
+ React,
+ // The ComponentRegistry manages all React components in N1.
+ ComponentRegistry,
+ // A `Store` is a Flux component which contains all business logic and data
+ // models to be consumed by React components to render markup.
+ MessageStore,
+} from 'nylas-exports';
+
+const tld = require('tld');
+
+// Notice that this file is `main.cjsx` rather than `main.coffee`. We use the
+// `.cjsx` filetype because we use the CJSX DSL to describe markup for React to
+// render. Without the CJSX, we could just name this file `main.coffee` instead.
+class PhishingIndicator extends React.Component {
+
+ // Adding a displayName to a React component helps for debugging.
+ static displayName = 'PhishingIndicator';
+
+ constructor() {
+ super();
+ this.state = {
+ message: MessageStore.items()[0],
+ };
+ }
+ componentDidMount() {
+ this._unlisten = MessageStore.listen(this._onMessagesChanged);
+ }
+
+ componentWillUnmount() {
+ if (this._unlisten) {
+ this._unlisten();
+ }
+ }
+
+ _onMessagesChanged = () => {
+ this.setState({
+ message: MessageStore.items()[0],
+ });
+ }
+
+ // A React component's `render` method returns a virtual DOM element described
+ // in CJSX. `render` is deterministic: with the same input, it will always
+ // render the same output. Here, the input is provided by @isPhishingAttempt.
+ // `@state` and `@props` are popular inputs as well.
+ render() {
+ const {message} = this.state;
+ if (!message) {
+ return ( );
+ }
+
+ const {replyTo, from} = message;
+ if (!replyTo || !replyTo.length || !from || !from.length) {
+ return ( );
+ }
+
+ // This package's strategy to ascertain whether or not the email is a
+ // phishing attempt boils down to checking the `replyTo` attributes on
+ // `Message` models from `MessageStore`.
+ const fromEmail = from[0].email.toLowerCase();
+ const replyToEmail = replyTo[0].email.toLowerCase();
+ if (!fromEmail || !replyToEmail) {
+ return ( );
+ }
+
+ const fromDomain = tld.registered(fromEmail.split('@')[1] || '');
+ const replyToDomain = tld.registered(replyToEmail.split('@')[1] || '');
+ if (replyToDomain !== fromDomain) {
+ return (
+
+
This message looks suspicious!
+
{`It originates from ${fromEmail} but replies will go to ${replyToEmail}.`}
+
+ );
+ }
+
+ return ( );
+ }
+}
+
+export function activate() {
+ ComponentRegistry.register(PhishingIndicator, {
+ role: 'MessageListHeaders',
+ });
+}
+
+export function serialize() {
+
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(PhishingIndicator);
+}
diff --git a/packages/client-app/internal_packages/phishing-detection/package.json b/packages/client-app/internal_packages/phishing-detection/package.json
new file mode 100644
index 0000000000..11415730f7
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "phishing-detection",
+ "version": "0.2.1",
+ "main": "./lib/main",
+ "isHiddenOnPluginsPage": true,
+ "license": "GPL-3.0",
+
+ "title": "Phishing Detection",
+ "description": "Get warnings when an email specifies a reply-to address which is not the from address.",
+ "icon": "./icon.png",
+ "isOptional": true,
+
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "default": true,
+ "composer": true,
+ "thread-popout": true
+ }
+}
diff --git a/packages/client-app/internal_packages/phishing-detection/screenshot.png b/packages/client-app/internal_packages/phishing-detection/screenshot.png
new file mode 100644
index 0000000000..49cbd2473e
Binary files /dev/null and b/packages/client-app/internal_packages/phishing-detection/screenshot.png differ
diff --git a/packages/client-app/internal_packages/phishing-detection/spec/main-spec.jsx b/packages/client-app/internal_packages/phishing-detection/spec/main-spec.jsx
new file mode 100644
index 0000000000..f2a4bbd360
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/spec/main-spec.jsx
@@ -0,0 +1,5 @@
+describe("Phishing Detection Indicator", () => {
+ it("should exhibit some behavior", () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/packages/client-app/internal_packages/phishing-detection/stylesheets/index.less b/packages/client-app/internal_packages/phishing-detection/stylesheets/index.less
new file mode 100644
index 0000000000..2b33cfffad
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/stylesheets/index.less
@@ -0,0 +1,7 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+.phishing-indicator {
+ text-align: center;
+ background-color: white;
+}
diff --git a/packages/client-app/internal_packages/phishing-detection/stylesheets/phishing.less b/packages/client-app/internal_packages/phishing-detection/stylesheets/phishing.less
new file mode 100644
index 0000000000..0b6a2d9f82
--- /dev/null
+++ b/packages/client-app/internal_packages/phishing-detection/stylesheets/phishing.less
@@ -0,0 +1,18 @@
+.phishingIndicator {
+ display: block;
+ box-sizing: border-box;
+ -webkit-print-color-adjust: exact;
+ padding: 8px 12px;
+ margin-bottom: 5px;
+ border: 1px solid rgb(235, 204, 209);
+ border-radius: 4px;
+ color: rgb(169, 68, 66);
+ background-color: rgb(242, 222, 222);
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.phishingIndicator .description {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/packages/client-app/internal_packages/plugins/lib/main.jsx b/packages/client-app/internal_packages/plugins/lib/main.jsx
new file mode 100644
index 0000000000..c810d6f4b9
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/main.jsx
@@ -0,0 +1,16 @@
+import {PreferencesUIStore} from 'nylas-exports';
+import PluginsView from './preferences-plugins';
+
+export function activate() {
+ this.preferencesTab = new PreferencesUIStore.TabItem({
+ tabId: "Plugins",
+ displayName: "Plugins",
+ component: PluginsView,
+ });
+
+ PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
+}
+
+export function deactivate() {
+ PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab.sectionId)
+}
diff --git a/packages/client-app/internal_packages/plugins/lib/package-set.jsx b/packages/client-app/internal_packages/plugins/lib/package-set.jsx
new file mode 100644
index 0000000000..f86ae792cd
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/package-set.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+
+import Package from './package';
+
+
+class PackageSet extends React.Component {
+
+ static propTypes = {
+ title: React.PropTypes.string.isRequired,
+ packages: React.PropTypes.array,
+ emptyText: React.PropTypes.element,
+ showVersions: React.PropTypes.bool,
+ }
+
+ render() {
+ if (!this.props.packages) return false;
+
+ const packages = this.props.packages.map((pkg) =>
+
+ );
+ let count = ({this.props.packages.length})
+
+ if (packages.length === 0) {
+ count = [];
+ packages.push(
+ {this.props.emptyText || "No plugins to display."}
+ )
+ }
+
+ return (
+
+
{this.props.title} {count}
+ {packages}
+
+ );
+ }
+
+}
+
+export default PackageSet;
diff --git a/packages/client-app/internal_packages/plugins/lib/package.jsx b/packages/client-app/internal_packages/plugins/lib/package.jsx
new file mode 100644
index 0000000000..432ce2d753
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/package.jsx
@@ -0,0 +1,105 @@
+import React from 'react';
+
+import {Flexbox, RetinaImg, Switch} from 'nylas-component-kit';
+import PluginsActions from './plugins-actions';
+
+
+class Package extends React.Component {
+
+ static displayName = 'Package';
+
+ static propTypes = {
+ "package": React.PropTypes.object.isRequired,
+ "showVersions": React.PropTypes.bool,
+ }
+
+ _onDisablePackage = () => {
+ PluginsActions.disablePackage(this.props.package);
+ }
+
+ _onEnablePackage = () => {
+ PluginsActions.enablePackage(this.props.package);
+ }
+
+ _onUninstallPackage = () => {
+ PluginsActions.uninstallPackage(this.props.package);
+ }
+
+ _onUpdatePackage = () => {
+ PluginsActions.updatePackage(this.props.package);
+ }
+
+ _onInstallPackage = () => {
+ PluginsActions.installPackage(this.props.package);
+ }
+
+ _onShowPackage = () => {
+ PluginsActions.showPackage(this.props.package);
+ }
+
+ render() {
+ const actions = [];
+ const extras = [];
+ let icon = ( );
+ let uninstallButton = null;
+
+ if (this.props.package.icon) {
+ icon = ( );
+ } else if (this.props.package.theme) {
+ icon = ( );
+ }
+
+ if (this.props.package.installed) {
+ if (['user', 'dev', 'example'].indexOf(this.props.package.category) !== -1 && !this.props.package.theme) {
+ if (this.props.package.enabled) {
+ actions.push(Disable );
+ } else {
+ actions.push(Enable );
+ }
+ }
+ if (this.props.package.category === 'user') {
+ uninstallButton = Uninstall
+ }
+ if (this.props.package.category === 'dev') {
+ actions.push(Show...
);
+ }
+ } else if (this.props.package.installing) {
+ actions.push(Installing...
);
+ } else {
+ actions.push(Install
);
+ }
+
+ const {name, description, title, version} = this.props.package;
+
+ if (this.props.package.newerVersionAvailable) {
+ extras.push(
+
+ A newer version is available: {this.props.package.newerVersion}
+
Update
+
+ )
+ }
+
+ const versionLabel = this.props.showVersions ? `v${version}` : null;
+
+ return (
+
+
+
+
+
{title || name} {versionLabel}
+ {uninstallButton}
+
+
{description}
+
+ {actions}
+ {extras}
+
+ );
+ }
+
+}
+
+export default Package;
diff --git a/packages/client-app/internal_packages/plugins/lib/packages-store.jsx b/packages/client-app/internal_packages/plugins/lib/packages-store.jsx
new file mode 100644
index 0000000000..f6753b150c
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/packages-store.jsx
@@ -0,0 +1,358 @@
+import _ from 'underscore';
+import Reflux from 'reflux';
+import path from 'path';
+import fs from 'fs-plus';
+import {APMWrapper} from 'nylas-exports';
+import {ipcRenderer, shell, remote} from 'electron';
+
+import PluginsActions from './plugins-actions';
+
+const dialog = remote.dialog;
+
+
+const PackagesStore = Reflux.createStore({
+ init: function init() {
+ this._apm = new APMWrapper();
+
+ this._globalSearch = "";
+ this._installedSearch = "";
+ this._installing = {};
+ this._featured = {
+ themes: [],
+ packages: [],
+ };
+ this._newerVersions = [];
+ this._searchResults = null;
+ this._refreshFeatured();
+
+ this.listenTo(PluginsActions.refreshFeaturedPackages, this._refreshFeatured);
+ this.listenTo(PluginsActions.refreshInstalledPackages, this._refreshInstalled);
+
+ NylasEnv.commands.add(document.body,
+ 'application:create-package',
+ () => this._onCreatePackage()
+ );
+
+ NylasEnv.commands.add(document.body,
+ 'application:install-package',
+ () => this._onInstallPackage()
+ );
+
+ this.listenTo(PluginsActions.installNewPackage, this._onInstallPackage);
+ this.listenTo(PluginsActions.createPackage, this._onCreatePackage);
+ this.listenTo(PluginsActions.updatePackage, this._onUpdatePackage);
+ this.listenTo(PluginsActions.setGlobalSearchValue, this._onGlobalSearchChange);
+ this.listenTo(PluginsActions.setInstalledSearchValue, this._onInstalledSearchChange);
+
+ this.listenTo(PluginsActions.showPackage, (pkg) => {
+ const dir = NylasEnv.packages.resolvePackagePath(pkg.name);
+ if (dir) shell.showItemInFolder(dir);
+ });
+
+ this.listenTo(PluginsActions.installPackage, (pkg) => {
+ this._installing[pkg.name] = true;
+ this.trigger(this);
+ this._apm.install(pkg, (err) => {
+ if (err) {
+ delete this._installing[pkg.name];
+ this._displayMessage("Sorry, an error occurred", err.toString());
+ } else {
+ if (NylasEnv.packages.isPackageDisabled(pkg.name)) {
+ NylasEnv.packages.enablePackage(pkg.name);
+ }
+ }
+ this._onPackagesChanged();
+ });
+ });
+
+ this.listenTo(PluginsActions.uninstallPackage, (pkg) => {
+ if (NylasEnv.packages.isPackageLoaded(pkg.name)) {
+ NylasEnv.packages.disablePackage(pkg.name);
+ NylasEnv.packages.unloadPackage(pkg.name);
+ }
+ this._apm.uninstall(pkg, (err) => {
+ if (err) this._displayMessage("Sorry, an error occurred", err.toString())
+ this._onPackagesChanged();
+ })
+ });
+
+ this.listenTo(PluginsActions.enablePackage, (pkg) => {
+ if (NylasEnv.packages.isPackageDisabled(pkg.name)) {
+ NylasEnv.packages.enablePackage(pkg.name);
+ this._onPackagesChanged();
+ }
+ });
+
+ this.listenTo(PluginsActions.disablePackage, (pkg) => {
+ if (!NylasEnv.packages.isPackageDisabled(pkg.name)) {
+ NylasEnv.packages.disablePackage(pkg.name);
+ this._onPackagesChanged();
+ }
+ });
+
+ this._hasPrepared = false;
+ },
+
+ // Getters
+
+ installed: function installed() {
+ this._prepareIfFresh();
+ return this._addPackageStates(this._filter(this._installed, this._installedSearch));
+ },
+
+ installedSearchValue: function installedSearchValue() {
+ return this._installedSearch;
+ },
+
+ featured: function featured() {
+ this._prepareIfFresh();
+ return this._addPackageStates(this._featured);
+ },
+
+ searchResults: function searchResults() {
+ return this._addPackageStates(this._searchResults);
+ },
+
+ globalSearchValue: function globalSearchValue() {
+ return this._globalSearch;
+ },
+
+ // Action Handlers
+
+ _prepareIfFresh: function _prepareIfFresh() {
+ if (this._hasPrepared) return;
+ NylasEnv.packages.onDidActivatePackage(() => this._onPackagesChangedDebounced());
+ NylasEnv.packages.onDidDeactivatePackage(() => this._onPackagesChangedDebounced());
+ NylasEnv.packages.onDidLoadPackage(() => this._onPackagesChangedDebounced());
+ NylasEnv.packages.onDidUnloadPackage(() => this._onPackagesChangedDebounced());
+ this._onPackagesChanged();
+ this._hasPrepared = true;
+ },
+
+ _filter: function _filter(hash, search) {
+ const result = {}
+ const query = search.toLowerCase();
+ if (hash) {
+ Object.keys(hash).forEach((key) => {
+ result[key] = _.filter(hash[key], (p) =>
+ query.length === 0 || p.name.toLowerCase().indexOf(query) !== -1
+ );
+ });
+ }
+ return result;
+ },
+
+ _refreshFeatured: function _refreshFeatured() {
+ this._apm.getFeatured({themes: false})
+ .then((results) => {
+ this._featured.packages = results;
+ this.trigger();
+ })
+ .catch(() => {
+ // We may be offline
+ });
+ this._apm.getFeatured({themes: true})
+ .then((results) => {
+ this._featured.themes = results;
+ this.trigger();
+ })
+ .catch(() => {
+ // We may be offline
+ });
+ },
+
+ _refreshInstalled: function _refreshInstalled() {
+ this._onPackagesChanged();
+ },
+
+ _refreshSearch: function _refreshSearch() {
+ if (!this._globalSearch || this._globalSearch.length <= 0) return;
+
+ this._apm.search(this._globalSearch)
+ .then((results) => {
+ this._searchResults = {
+ packages: results.filter(({theme}) => !theme),
+ themes: results.filter(({theme}) => theme),
+ }
+ this.trigger();
+ })
+ .catch(() => {
+ // We may be offline
+ });
+ },
+
+ _refreshSearchThrottled: function _refreshSearchThrottled() {
+ _.debounce(this._refreshSearch, 400)
+ },
+
+ _onPackagesChanged: function _onPackagesChanged() {
+ this._apm.getInstalled()
+ .then((packages) => {
+ for (const category of ['dev', 'user']) {
+ packages[category].forEach((pkg) => {
+ pkg.category = category;
+ delete this._installing[pkg.name];
+ });
+ }
+
+ const available = NylasEnv.packages.getAvailablePackageMetadata();
+ const examples = available.filter(({isOptional, isHiddenOnPluginsPage}) =>
+ isOptional && !isHiddenOnPluginsPage);
+ packages.example = examples.map((pkg) =>
+ _.extend({}, pkg, {installed: true, category: 'example'})
+ );
+ this._installed = packages;
+ this.trigger();
+ });
+ },
+
+ _onPackagesChangedDebounced: function _onPackagesChangedDebounced() {
+ _.debounce(this._onPackagesChanged, 200);
+ },
+
+ _onInstalledSearchChange: function _onInstalledSearchChange(val) {
+ this._installedSearch = val;
+ this.trigger();
+ },
+
+ _onUpdatePackage: function _onUpdatePackage(pkg) {
+ this._apm.update(pkg, pkg.newerVersion);
+ },
+
+ _onInstallPackage: function _onInstallPackage() {
+ NylasEnv.showOpenDialog({
+ title: "Choose a Plugin Directory",
+ buttonLabel: 'Choose',
+ properties: ['openDirectory'],
+ },
+ (filenames) => {
+ if (!filenames || filenames.length === 0) return;
+ NylasEnv.packages.installPackageFromPath(filenames[0], (err, packageName) => {
+ if (err) {
+ this._displayMessage("Could not install plugin", err.message);
+ } else {
+ this._onPackagesChanged();
+ const msg = `${packageName} has been installed and enabled. No need to restart! If you don't see the plugin loaded, check the console for errors.`
+ this._displayMessage("Plugin installed! 🎉", msg);
+ }
+ });
+ });
+ },
+
+ _onCreatePackage: function _onCreatePackage() {
+ if (!NylasEnv.inDevMode()) {
+ const btn = dialog.showMessageBox({
+ type: 'warning',
+ message: "Run with debug flags?",
+ detail: `To develop plugins, you should run N1 with debug flags. This gives you better error messages, the debug version of React, and more. You can disable it at any time from the Developer menu.`,
+ buttons: ["OK", "Cancel"],
+ });
+ if (btn === 0) {
+ ipcRenderer.send('command', 'application:toggle-dev');
+ }
+ return;
+ }
+
+ const packagesDir = path.join(NylasEnv.getConfigDirPath(), 'dev', 'packages');
+ fs.makeTreeSync(packagesDir);
+
+ NylasEnv.showSaveDialog({
+ title: "Save New Package",
+ defaultPath: packagesDir,
+ properties: ['createDirectory'],
+ }, (packageDir) => {
+ if (!packageDir) return;
+
+ const packageName = path.basename(packageDir);
+
+ if (!packageDir.startsWith(packagesDir)) {
+ this._displayMessage('Invalid plugin location',
+ 'Sorry, you must create plugins in the packages folder.');
+ }
+
+ if (NylasEnv.packages.resolvePackagePath(packageName)) {
+ this._displayMessage('Invalid plugin name',
+ 'Sorry, you must give your plugin a unique name.');
+ }
+
+ if (packageName.indexOf(' ') !== -1) {
+ this._displayMessage('Invalid plugin name',
+ 'Sorry, plugin names cannot contain spaces.');
+ }
+
+ fs.mkdir(packageDir, (err) => {
+ if (err) {
+ this._displayMessage('Could not create plugin', err.toString());
+ return;
+ }
+ const {resourcePath} = NylasEnv.getLoadSettings();
+ const packageTemplatePath = path.join(resourcePath, 'static', 'package-template');
+ const packageJSON = {
+ name: packageName,
+ main: "./lib/main",
+ version: '0.1.0',
+ repository: {
+ type: 'git',
+ url: '',
+ },
+ engines: {
+ nylas: `>=${NylasEnv.getVersion().split('-')[0]}`,
+ },
+ windowTypes: {
+ 'default': true,
+ 'composer': true,
+ },
+ description: "Enter a description of your package!",
+ dependencies: {},
+ license: "MIT",
+ };
+
+ fs.copySync(packageTemplatePath, packageDir);
+ fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify(packageJSON, null, 2));
+ shell.showItemInFolder(packageDir);
+ _.defer(() => {
+ NylasEnv.packages.enablePackage(packageDir);
+ NylasEnv.packages.activatePackage(packageName);
+ });
+ });
+ });
+ },
+
+ _onGlobalSearchChange: function _onGlobalSearchChange(val) {
+ // Clear previous search results data if this is a new
+ // search beginning from "".
+ if (this._globalSearch.length === 0 && val.length > 0) {
+ this._searchResults = null;
+ }
+
+ this._globalSearch = val;
+ this._refreshSearchThrottled();
+ this.trigger();
+ },
+
+ _addPackageStates: function _addPackageStates(pkgs) {
+ const installedNames = _.flatten(_.values(this._installed)).map((pkg) => pkg.name);
+
+ _.flatten(_.values(pkgs)).forEach((pkg) => {
+ pkg.enabled = !NylasEnv.packages.isPackageDisabled(pkg.name);
+ pkg.installed = installedNames.indexOf(pkg.name) !== -1;
+ pkg.installing = this._installing[pkg.name];
+ pkg.newerVersionAvailable = this._newerVersions[pkg.name];
+ pkg.newerVersion = this._newerVersions[pkg.name];
+ });
+
+ return pkgs;
+ },
+
+ _displayMessage: function _displayMessage(title, message) {
+ dialog.showMessageBox({
+ type: 'warning',
+ message: title,
+ detail: message,
+ buttons: ["OK"],
+ });
+ },
+
+});
+
+export default PackagesStore;
diff --git a/packages/client-app/internal_packages/plugins/lib/plugins-actions.jsx b/packages/client-app/internal_packages/plugins/lib/plugins-actions.jsx
new file mode 100644
index 0000000000..35e0d44856
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/plugins-actions.jsx
@@ -0,0 +1,26 @@
+import Reflux from 'reflux';
+
+const Actions = Reflux.createActions([
+ 'selectTabIndex',
+ 'setInstalledSearchValue',
+ 'setGlobalSearchValue',
+
+ 'disablePackage',
+ 'enablePackage',
+ 'installPackage',
+ 'installNewPackage',
+ 'uninstallPackage',
+ 'createPackage',
+ 'reloadPackage',
+ 'showPackage',
+ 'updatePackage',
+
+ 'refreshFeaturedPackages',
+ 'refreshInstalledPackages',
+]);
+
+for (const key of Object.keys(Actions)) {
+ Actions[key].sync = true;
+}
+
+export default Actions;
diff --git a/packages/client-app/internal_packages/plugins/lib/plugins-tabs-view.jsx b/packages/client-app/internal_packages/plugins/lib/plugins-tabs-view.jsx
new file mode 100644
index 0000000000..4923509727
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/plugins-tabs-view.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import classNames from 'classnames';
+
+import Tabs from './tabs';
+import TabsStore from './tabs-store';
+import PluginsActions from './plugins-actions';
+
+
+class PluginsTabs extends React.Component {
+
+ static displayName = 'PluginsTabs';
+
+ static propTypes = {
+ onChange: React.PropTypes.Func,
+ };
+
+ static containerRequired = false;
+
+ static containerStyles = {
+ minWidth: 200,
+ maxWidth: 290,
+ };
+
+ constructor() {
+ super();
+ this.state = this._getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._unsubscribers = [];
+ this._unsubscribers.push(TabsStore.listen(this._onChange));
+ }
+
+ componentWillUnmount() {
+ this._unsubscribers.forEach(unsubscribe => unsubscribe());
+ }
+
+ _getStateFromStores() {
+ return {
+ tabIndex: TabsStore.tabIndex(),
+ };
+ }
+
+ _onChange = () => {
+ this.setState(this._getStateFromStores());
+ }
+
+ _renderItems() {
+ return Tabs.map(({name, key, icon}, idx) => {
+ const classes = classNames({
+ tab: true,
+ active: idx === this.state.tabIndex,
+ });
+ return ( PluginsActions.selectTabIndex(idx)}>{name} );
+ });
+ }
+
+ render() {
+ return (
+
+ {this._renderItems()}
+
+ );
+ }
+
+}
+
+export default PluginsTabs;
diff --git a/packages/client-app/internal_packages/plugins/lib/preferences-plugins.jsx b/packages/client-app/internal_packages/plugins/lib/preferences-plugins.jsx
new file mode 100644
index 0000000000..920433614e
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/preferences-plugins.jsx
@@ -0,0 +1,49 @@
+import React from 'react';
+
+import TabsStore from './tabs-store';
+import Tabs from './tabs';
+
+
+class PluginsView extends React.Component {
+
+ static displayName = 'PluginsView';
+
+ static containerStyles = {
+ minWidth: 500,
+ maxWidth: 99999,
+ }
+
+ constructor() {
+ super();
+ this.state = this._getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._unsubscribers = [];
+ this._unsubscribers.push(TabsStore.listen(this._onChange));
+ }
+
+ componentWillUnmount() {
+ this._unsubscribers.forEach(unsubscribe => unsubscribe());
+ }
+
+ _getStateFromStores() {
+ return {tabIndex: TabsStore.tabIndex()};
+ }
+
+ _onChange = () => {
+ this.setState(this._getStateFromStores());
+ }
+
+ render() {
+ const PluginsTabComponent = Tabs[this.state.tabIndex].component;
+ return (
+
+ );
+ }
+
+}
+
+export default PluginsView;
diff --git a/packages/client-app/internal_packages/plugins/lib/tab-explore.jsx b/packages/client-app/internal_packages/plugins/lib/tab-explore.jsx
new file mode 100644
index 0000000000..9d4c2bcd78
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/tab-explore.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+
+import PackageSet from './package-set';
+import PackagesStore from './packages-store';
+import PluginsActions from './plugins-actions';
+
+
+class TabExplore extends React.Component {
+
+ static displayName = 'TabExplore';
+
+ constructor() {
+ super();
+ this.state = this._getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._unsubscribers = [];
+ this._unsubscribers.push(PackagesStore.listen(this._onChange));
+
+ // Trigger a refresh of the featured packages
+ PluginsActions.refreshFeaturedPackages()
+ }
+
+ componentWillUnmount() {
+ this._unsubscribers.forEach(unsubscribe => unsubscribe());
+ }
+
+ _getStateFromStores() {
+ return {
+ featured: PackagesStore.featured(),
+ search: PackagesStore.globalSearchValue(),
+ searchResults: PackagesStore.searchResults(),
+ };
+ }
+
+ _onChange = () => {
+ this.setState(this._getStateFromStores());
+ }
+
+ _onSearchChange = (event) => {
+ PluginsActions.setGlobalSearchValue(event.target.value);
+ }
+
+ render() {
+ let collection = this.state.featured;
+ let collectionPrefix = "Featured ";
+ let emptyText = null;
+ if (this.state.search.length > 0) {
+ collectionPrefix = "Matching ";
+ if (this.state.searchResults) {
+ collection = this.state.searchResults;
+ emptyText = "No results found.";
+ } else {
+ collection = {
+ packages: [],
+ themes: [],
+ };
+ emptyText = "Loading results...";
+ }
+ }
+
+ return (
+
+ );
+ }
+
+}
+
+export default TabExplore;
diff --git a/packages/client-app/internal_packages/plugins/lib/tab-installed.jsx b/packages/client-app/internal_packages/plugins/lib/tab-installed.jsx
new file mode 100644
index 0000000000..00d41fd4ae
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/tab-installed.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import {ipcRenderer} from 'electron';
+import {Flexbox} from 'nylas-component-kit';
+
+import PackageSet from './package-set';
+import PackagesStore from './packages-store';
+import PluginsActions from './plugins-actions';
+
+
+class TabInstalled extends React.Component {
+
+ static displayName = 'TabInstalled';
+
+ constructor() {
+ super();
+ this.state = this._getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._unsubscribers = [];
+ this._unsubscribers.push(PackagesStore.listen(this._onChange));
+
+ PluginsActions.refreshInstalledPackages();
+ }
+
+ componentWillUnmount() {
+ this._unsubscribers.forEach(unsubscribe => unsubscribe());
+ }
+
+ _getStateFromStores() {
+ return {
+ packages: PackagesStore.installed(),
+ search: PackagesStore.installedSearchValue(),
+ };
+ }
+
+ _onChange = () => {
+ this.setState(this._getStateFromStores());
+ }
+
+ _onInstallPackage() {
+ PluginsActions.installNewPackage();
+ }
+
+ _onCreatePackage() {
+ PluginsActions.createPackage();
+ }
+
+ _onSearchChange = (event) => {
+ PluginsActions.setInstalledSearchValue(event.target.value);
+ }
+
+ _onEnableDevMode() {
+ ipcRenderer.send('command', 'application:toggle-dev');
+ }
+
+ render() {
+ let searchEmpty = null;
+ if (this.state.search.length > 0) {
+ searchEmpty = "No matching packages.";
+ }
+
+ let devPackages = []
+ let devEmpty = (Run with debug flags enabled to load ~/.nylas-mail/dev/packages. );
+ let devCTA = (Enable Debug Flags
);
+
+ if (NylasEnv.inDevMode()) {
+ devPackages = this.state.packages.dev || [];
+ devEmpty = (
+ {`You don't have any packages installed in ~/.nylas-mail/dev/packages. `}
+ These plugins are only loaded when you run the app with debug flags
+ enabled (via the Developer menu). Learn more about building
+ plugins with our docs .
+ );
+ devCTA = (Create New Plugin...
);
+ }
+
+ return (
+
+
+
+ Install Plugin...
+
+
+
{`You don't have any plugins installed in ~/.nylas-mail/packages.`}}
+ />
+
+
+
+ {devCTA}
+
+
+
+ );
+ }
+
+}
+
+export default TabInstalled;
diff --git a/packages/client-app/internal_packages/plugins/lib/tabs-store.jsx b/packages/client-app/internal_packages/plugins/lib/tabs-store.jsx
new file mode 100644
index 0000000000..c010dadba0
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/tabs-store.jsx
@@ -0,0 +1,28 @@
+import Reflux from 'reflux';
+
+import PluginsActions from './plugins-actions';
+
+
+const TabsStore = Reflux.createStore({
+
+ init: function init() {
+ this._tabIndex = 0;
+ this.listenTo(PluginsActions.selectTabIndex, this._onTabIndexChanged);
+ },
+
+ // Getters
+
+ tabIndex: function tabIndex() {
+ return this._tabIndex;
+ },
+
+ // Action Handlers
+
+ _onTabIndexChanged: function _onTabIndexChanged(idx) {
+ this._tabIndex = idx;
+ this.trigger(this);
+ },
+
+});
+
+export default TabsStore;
diff --git a/packages/client-app/internal_packages/plugins/lib/tabs.jsx b/packages/client-app/internal_packages/plugins/lib/tabs.jsx
new file mode 100644
index 0000000000..c93cd286fd
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/lib/tabs.jsx
@@ -0,0 +1,10 @@
+import TabInstalled from './tab-installed';
+
+const Tabs = [{
+ key: 'installed',
+ name: 'Installed',
+ icon: 'tbd',
+ component: TabInstalled,
+}]
+
+export default Tabs;
diff --git a/packages/client-app/internal_packages/plugins/package.json b/packages/client-app/internal_packages/plugins/package.json
new file mode 100755
index 0000000000..ad6b50a7b3
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "plugins",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Plugins",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/plugins/stylesheets/plugins.less b/packages/client-app/internal_packages/plugins/stylesheets/plugins.less
new file mode 100644
index 0000000000..002549259a
--- /dev/null
+++ b/packages/client-app/internal_packages/plugins/stylesheets/plugins.less
@@ -0,0 +1,129 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+.plugins-view-tabs {
+ color: @text-color-subtle;
+ list-style-type: none;
+ padding-left:0;
+ cursor: default;
+
+ li {
+ padding: @padding-large-vertical @padding-large-horizontal;
+ border-bottom: 1px solid @border-color-divider;
+
+ &.active {
+ background: @source-list-active-bg;
+ color: @source-list-active-color;
+ img.colorfill {
+ background: @source-list-active-color;
+ }
+ }
+ }
+}
+
+.plugins-view {
+ max-width: 800px;
+ margin: 0 auto;
+ .new-package {
+ margin-bottom: 50px;
+ }
+
+ .installed, .explore {
+ overflow-y: scroll;
+ padding-left: @padding-large-horizontal;
+ height: 100%;
+
+ .inner {
+ max-width: 800px;
+ .search-container {
+ margin: @padding-large-vertical 2px;
+ justify-content: space-between;
+ }
+ }
+
+ input {
+ box-sizing: border-box;
+ width: 30%;
+ }
+ .search {
+ padding-left: 0;
+ background-repeat: no-repeat;
+ background-image: url("../static/images/search/searchloupe@2x.png");
+ background-size: 15px 15px;
+ background-position: 7px 4px;
+ text-indent: 31px;
+ }
+ .empty {
+ color: @text-color-very-subtle;
+ margin-bottom: @padding-large-vertical * 2;
+ }
+ }
+
+ .package-set {
+ margin-top: 35px;
+ }
+ .package {
+ align-items: center;
+ background: @background-primary;
+ border: 1px solid @border-color-divider;
+ border-radius: @border-radius-large;
+ margin-top: @padding-large-vertical;
+ margin-bottom: @padding-large-vertical;
+ padding: @padding-large-vertical @padding-large-horizontal;
+
+ .icon-container {
+ width: 52px;
+ height: 52px;
+ border-radius: 6px;
+ background: linear-gradient(to bottom, @background-primary 0%, @background-secondary 100%);
+ box-shadow: 0 0.5px 0 rgba(0,0,0,0.15), 0 -0.5px 0 rgba(0,0,0,0.15), 0.5px 0 0 rgba(0,0,0,0.15), -0.5px 0 0 rgba(0,0,0,0.15), 0 0.5px 1px rgba(0, 0, 0, 0.15);
+ flex-shrink: 0;
+ margin-right: @padding-large-horizontal;
+ text-align: center;
+ line-height: 50px;
+ }
+ .info {
+ max-width: 380px;
+ cursor: default;
+
+ .title {
+ color: @text-color-heading;
+ font-size: @font-size-h4;
+ font-weight: @font-weight-normal;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .version {
+ font-size: @font-size-small;
+ font-weight: @font-weight-normal;
+ margin-left: 10px;
+ margin-top: 4px;
+ }
+ .uninstall-plugin {
+ color: @text-color-link;
+ margin-left: 10px;
+ margin-top: 4px;
+ }
+ .description {
+ padding-top:@padding-base-vertical;
+ color: @text-color-very-subtle;
+ font-size: @font-size-small;
+ }
+ }
+ .actions {
+ flex: 1;
+ text-align: right;
+ .btn {
+ margin-left:@padding-small-horizontal;
+ }
+ }
+ .update-info {
+ background: fade(@accent-primary, 10%);
+ line-height: @line-height-computed * 1.1;
+ .btn {
+ float: right;
+ }
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/preferences/lib/main.jsx b/packages/client-app/internal_packages/preferences/lib/main.jsx
new file mode 100644
index 0000000000..89c87bc6d1
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/main.jsx
@@ -0,0 +1,66 @@
+import {PreferencesUIStore,
+ WorkspaceStore,
+ ComponentRegistry} from 'nylas-exports';
+
+import PreferencesRoot from './preferences-root';
+import PreferencesGeneral from './tabs/preferences-general';
+import PreferencesAccounts from './tabs/preferences-accounts';
+import PreferencesAppearance from './tabs/preferences-appearance';
+import PreferencesKeymaps from './tabs/preferences-keymaps';
+// import PreferencesMailRules from './tabs/preferences-mail-rules';
+import PreferencesIdentity from './tabs/preferences-identity';
+
+export function activate() {
+ PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
+ tabId: 'General',
+ displayName: 'General',
+ component: PreferencesGeneral,
+ order: 1,
+ }))
+ PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
+ tabId: 'Accounts',
+ displayName: 'Accounts',
+ component: PreferencesAccounts,
+ order: 2,
+ }))
+ PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
+ tabId: 'Subscription',
+ displayName: 'Subscription',
+ component: PreferencesIdentity,
+ order: 3,
+ }))
+ PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
+ tabId: 'Appearance',
+ displayName: 'Appearance',
+ component: PreferencesAppearance,
+ order: 4,
+ }))
+ PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
+ tabId: 'Shortcuts',
+ displayName: 'Shortcuts',
+ component: PreferencesKeymaps,
+ order: 5,
+ }))
+ // PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
+ // tabId: 'Mail Rules',
+ // displayName: 'Mail Rules',
+ // component: PreferencesMailRules,
+ // order: 6,
+ // }))
+
+ WorkspaceStore.defineSheet('Preferences', {}, {
+ split: ['Preferences'],
+ list: ['Preferences'],
+ });
+
+ ComponentRegistry.register(PreferencesRoot, {
+ location: WorkspaceStore.Location.Preferences,
+ });
+}
+
+export function deactivate() {
+}
+
+export function serialize() {
+ return this.state;
+}
diff --git a/packages/client-app/internal_packages/preferences/lib/preferences-root.jsx b/packages/client-app/internal_packages/preferences/lib/preferences-root.jsx
new file mode 100644
index 0000000000..a52419291c
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/preferences-root.jsx
@@ -0,0 +1,111 @@
+/* eslint jsx-a11y/tabindex-no-positive: 0 */
+import React, {PropTypes} from 'react';
+import ReactDOM from 'react-dom';
+import {
+ Flexbox,
+ ScrollRegion,
+ KeyCommandsRegion,
+ ListensToFluxStore,
+ ConfigPropContainer,
+} from 'nylas-component-kit';
+import {PreferencesUIStore} from 'nylas-exports';
+import PreferencesTabsBar from './preferences-tabs-bar';
+
+
+class PreferencesRoot extends React.Component {
+ static displayName = 'PreferencesRoot';
+
+ static containerRequired = false;
+
+ static propTypes = {
+ tab: PropTypes.object,
+ tabs: PropTypes.object,
+ selection: PropTypes.object,
+ }
+
+ componentDidMount() {
+ ReactDOM.findDOMNode(this).focus();
+ this._focusContent();
+ }
+
+ componentDidUpdate(oldProps) {
+ if (oldProps.tab !== this.props.tab) {
+ const scrollRegion = document.querySelector(".preferences-content .scroll-region-content");
+ scrollRegion.scrollTop = 0;
+ this._focusContent();
+ }
+ }
+
+ _localHandlers() {
+ const stopPropagation = (e) => {
+ e.stopPropagation();
+ }
+ // This prevents some basic commands from propagating to the threads list and
+ // producing unexpected results
+
+ // TODO This is a partial/temporary solution and should go away when we do the
+ // Keymap/Commands/Menu refactor
+ return {
+ 'core:next-item': stopPropagation,
+ 'core:previous-item': stopPropagation,
+ 'core:select-up': stopPropagation,
+ 'core:select-down': stopPropagation,
+ 'core:select-item': stopPropagation,
+ 'core:messages-page-up': stopPropagation,
+ 'core:messages-page-down': stopPropagation,
+ 'core:list-page-up': stopPropagation,
+ 'core:list-page-down': stopPropagation,
+ 'core:remove-from-view': stopPropagation,
+ 'core:gmail-remove-from-view': stopPropagation,
+ 'core:remove-and-previous': stopPropagation,
+ 'core:remove-and-next': stopPropagation,
+ 'core:archive-item': stopPropagation,
+ 'core:delete-item': stopPropagation,
+ 'core:print-thread': stopPropagation,
+ }
+ }
+
+ // Focus the first thing with a tabindex when we update.
+ // inside the content area. This makes it way easier to interact with prefs.
+ _focusContent() {
+ const node = ReactDOM.findDOMNode(this.refs.content).querySelector('[tabindex]')
+ if (node) {
+ node.focus();
+ }
+ }
+
+ render() {
+ const {tab, selection, tabs} = this.props
+
+ return (
+
+
+
+
+
+ {tab ?
+ :
+ false
+ }
+
+
+
+
+ );
+ }
+
+}
+
+export default ListensToFluxStore(PreferencesRoot, {
+ stores: [PreferencesUIStore],
+ getStateFromStores() {
+ const tabs = PreferencesUIStore.tabs();
+ const selection = PreferencesUIStore.selection();
+ const tabId = selection.get('tabId');
+ const tab = tabs.find((s) => s.tabId === tabId);
+ return {tabs, selection, tab}
+ },
+});
diff --git a/packages/client-app/internal_packages/preferences/lib/preferences-tabs-bar.jsx b/packages/client-app/internal_packages/preferences/lib/preferences-tabs-bar.jsx
new file mode 100644
index 0000000000..c4fa9fb465
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/preferences-tabs-bar.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import fs from 'fs'
+import Immutable from 'immutable';
+import classNames from 'classnames';
+
+import {Flexbox, RetinaImg} from 'nylas-component-kit';
+import {Actions, PreferencesUIStore, Utils} from 'nylas-exports';
+
+
+class PreferencesTabItem extends React.Component {
+ static displayName = 'PreferencesTabItem';
+
+ static propTypes = {
+ selection: React.PropTypes.instanceOf(Immutable.Map).isRequired,
+ tabItem: React.PropTypes.instanceOf(PreferencesUIStore.TabItem).isRequired,
+ }
+
+ _onClick = () => {
+ Actions.switchPreferencesTab(this.props.tabItem.tabId);
+ }
+
+ _onClickAccount = (event, accountId) => {
+ Actions.switchPreferencesTab(this.props.tabItem.tabId, {accountId});
+ event.stopPropagation();
+ }
+
+ render() {
+ const {selection, tabItem} = this.props
+ const {tabId, displayName} = tabItem;
+ const classes = classNames({
+ item: true,
+ active: tabId === selection.get('tabId'),
+ });
+
+ let path = `icon-preferences-${displayName.toLowerCase().replace(" ", "-")}.png`
+ if (!fs.existsSync(Utils.imageNamed(path))) {
+ path = "icon-preferences-general.png";
+ }
+ const icon = (
+
+ );
+
+ return (
+
+ {icon}
+
{displayName}
+
+ );
+ }
+
+}
+
+
+class PreferencesTabsBar extends React.Component {
+ static displayName = 'PreferencesTabsBar';
+
+ static propTypes = {
+ tabs: React.PropTypes.instanceOf(Immutable.List).isRequired,
+ selection: React.PropTypes.instanceOf(Immutable.Map).isRequired,
+ }
+
+ renderTabs() {
+ return this.props.tabs.map((tabItem) =>
+
+ );
+ }
+
+ render() {
+ return (
+
+
+
+ {this.renderTabs()}
+
+
+
+ );
+ }
+
+}
+
+export default PreferencesTabsBar;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/config-schema-item.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/config-schema-item.jsx
new file mode 100644
index 0000000000..d0b3003d40
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/config-schema-item.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import _ from 'underscore';
+import _str from 'underscore.string';
+
+/*
+This component renders input controls for a subtree of the N1 config-schema
+and reads/writes current values using the `config` prop, which is expected to
+be an instance of the config provided by `ConfigPropContainer`.
+
+The config schema follows the JSON Schema standard: http://json-schema.org/
+*/
+class ConfigSchemaItem extends React.Component {
+
+ static displayName = 'ConfigSchemaItem';
+
+ static propTypes = {
+ config: React.PropTypes.object,
+ configSchema: React.PropTypes.object,
+ keyName: React.PropTypes.string,
+ keyPath: React.PropTypes.string,
+ };
+
+ _appliesToPlatform() {
+ if (!this.props.configSchema.platform) {
+ return true;
+ } else if (this.props.configSchema.platforms.indexOf(process.platform) !== -1) {
+ return true;
+ }
+ return false;
+ }
+
+ _onChangeChecked = (event) => {
+ this.props.config.toggle(this.props.keyPath);
+ event.target.blur();
+ }
+
+ _onChangeValue = (event) => {
+ this.props.config.set(this.props.keyPath, event.target.value);
+ event.target.blur();
+ }
+
+ render() {
+ if (!this._appliesToPlatform()) return false;
+
+ // In the future, we may add an option to reveal "advanced settings"
+ if (this.props.configSchema.advanced) return false;
+
+ if (this.props.configSchema.type === 'object') {
+ return (
+
+ {_str.humanize(this.props.keyName)}
+ {_.pairs(this.props.configSchema.properties).map(([key, value]) =>
+
+ )}
+
+ );
+ } else if (this.props.configSchema.enum) {
+ return (
+
+ {this.props.configSchema.title}:
+
+ {_.zip(this.props.configSchema.enum, this.props.configSchema.enumLabels).map(([value, label]) =>
+ {label}
+ )}
+
+
+ );
+ } else if (this.props.configSchema.type === 'boolean') {
+ return (
+
+
+ {this.props.configSchema.title}
+
+ );
+ }
+ return (
+
+ );
+ }
+
+}
+
+export default ConfigSchemaItem;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/keymaps/command-item.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/keymaps/command-item.jsx
new file mode 100644
index 0000000000..23078409df
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/keymaps/command-item.jsx
@@ -0,0 +1,166 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import _ from 'underscore';
+import { Flexbox } from 'nylas-component-kit';
+import fs from 'fs';
+
+import {keyAndModifiersForEvent} from './mousetrap-keybinding-helpers';
+
+export default class CommandKeybinding extends React.Component {
+ static propTypes = {
+ bindings: React.PropTypes.array,
+ label: React.PropTypes.string,
+ command: React.PropTypes.string,
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ editing: false,
+ }
+ }
+ componentDidUpdate() {
+ const {modifiers, keys, editing} = this.state;
+ if (editing) {
+ const finished = (((modifiers.length > 0) && (keys.length > 0)) || (keys.length >= 2));
+ if (finished) {
+ ReactDOM.findDOMNode(this).blur();
+ }
+ }
+ }
+
+ _formatKeystrokes(original) {
+ // On Windows, display cmd-shift-c
+ if (process.platform === "win32") return original;
+
+ // Replace "cmd" => ⌘, etc.
+ const modifiers = [
+ [/\+(?!$)/gi, ''],
+ [/command/gi, '⌘'],
+ [/meta/gi, '⌘'],
+ [/alt/gi, '⌥'],
+ [/shift/gi, '⇧'],
+ [/ctrl/gi, '^'],
+ [/mod/gi, (process.platform === 'darwin' ? '⌘' : '^')],
+ ];
+ let clean = original;
+ for (const [regexp, char] of modifiers) {
+ clean = clean.replace(regexp, char);
+ }
+
+ // ⌘⇧c => ⌘⇧C
+ if (clean !== original) {
+ clean = clean.toUpperCase();
+ }
+
+ // backspace => Backspace
+ if (original.length > 1 && clean === original) {
+ clean = clean[0].toUpperCase() + clean.slice(1);
+ }
+ return clean;
+ }
+
+ _renderKeystrokes = (keystrokes, idx) => {
+ const elements = [];
+ const splitKeystrokes = keystrokes.split(' ');
+ splitKeystrokes.forEach((keystroke, kidx) => {
+ elements.push({this._formatKeystrokes(keystroke)} );
+ if (kidx < splitKeystrokes.length - 1) {
+ elements.push( then );
+ }
+ });
+ return (
+ {elements}
+ );
+ }
+
+ _onEdit = () => {
+ this.setState({editing: true, editingBinding: null, keys: [], modifiers: []});
+ NylasEnv.keymaps.suspendAllKeymaps();
+ }
+
+ _onFinishedEditing = () => {
+ if (this.state.editingBinding) {
+ const keymapPath = NylasEnv.keymaps.getUserKeymapPath();
+ let keymaps = {};
+
+ try {
+ const exists = fs.existsSync(keymapPath);
+ if (exists) {
+ keymaps = JSON.parse(fs.readFileSync(keymapPath));
+ }
+ } catch (err) {
+ console.error(err);
+ }
+
+ keymaps[this.props.command] = this.state.editingBinding;
+
+ try {
+ fs.writeFileSync(keymapPath, JSON.stringify(keymaps, null, 2));
+ } catch (err) {
+ NylasEnv.showErrorDialog(`Nylas was unable to modify your keymaps at ${keymapPath}. ${err.toString()}`);
+ }
+ }
+ this.setState({editing: false, editingBinding: null});
+ NylasEnv.keymaps.resumeAllKeymaps();
+ }
+
+ _onKey = (event) => {
+ if (!this.state.editing) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const [eventKey, eventMods] = keyAndModifiersForEvent(event);
+ if (!eventKey || ['mod', 'meta', 'command', 'ctrl', 'alt', 'shift'].includes(eventKey)) {
+ return;
+ }
+
+ let {keys, modifiers} = this.state;
+ keys = keys.concat([eventKey]);
+ modifiers = _.uniq(modifiers.concat(eventMods));
+
+ let editingBinding = keys.join(' ');
+ if (modifiers.length > 0) {
+ editingBinding = [].concat(modifiers, keys).join('+');
+ editingBinding = editingBinding.replace(/(meta|command|ctrl)/g, 'mod');
+ }
+
+ this.setState({keys, modifiers, editingBinding});
+ }
+
+ render() {
+ const {editing, editingBinding} = this.state;
+ const bindings = editingBinding ? [editingBinding] : this.props.bindings;
+
+ let value = "None";
+ if (bindings.length > 0) {
+ value = _.uniq(bindings).map(this._renderKeystrokes);
+ }
+
+ let classnames = "shortcut";
+ if (editing) {
+ classnames += " editing";
+ }
+ return (
+
+
+ {this.props.label}
+
+
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/keymaps/displayed-keybindings.js b/packages/client-app/internal_packages/preferences/lib/tabs/keymaps/displayed-keybindings.js
new file mode 100644
index 0000000000..967565d3c1
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/keymaps/displayed-keybindings.js
@@ -0,0 +1,70 @@
+module.exports = [
+ {
+ title: 'Application',
+ items: [
+ ['application:new-message', 'New Message'],
+ ['core:focus-search', 'Search'],
+ ],
+ },
+ {
+ title: 'Actions',
+ items: [
+ ['core:reply', 'Reply'],
+ ['core:reply-all', 'Reply All'],
+ ['core:forward', 'Forward'],
+ ['core:archive-item', 'Archive'],
+ ['core:delete-item', 'Trash'],
+ ['core:remove-from-view', 'Remove from view'],
+ ['core:gmail-remove-from-view', 'Gmail Remove from view'],
+ ['core:star-item', 'Star'],
+ ['core:snooze-item', 'Snooze'],
+ ['core:change-category', 'Change Folder / Labels'],
+ ['core:mark-as-read', 'Mark as read'],
+ ['core:mark-as-unread', 'Mark as unread'],
+ ['core:mark-important', 'Mark as important (Gmail)'],
+ ['core:mark-unimportant', 'Mark as unimportant (Gmail)'],
+ ['core:remove-and-previous', 'Remove from view and previous'],
+ ['core:remove-and-next', 'Remove from view and next'],
+ ],
+ },
+ {
+ title: 'Composer',
+ items: [
+ ['composer:send-message', 'Send Message'],
+ ['composer:focus-to', 'Focus the To field'],
+ ['composer:show-and-focus-cc', 'Focus the Cc field'],
+ ['composer:show-and-focus-bcc', 'Focus the Bcc field'],
+ ],
+ },
+ {
+ title: 'Navigation',
+ items: [
+ ['core:pop-sheet', 'Return to conversation list'],
+ ['core:focus-item', 'Open selected conversation'],
+ ['core:previous-item', 'Move to newer conversation'],
+ ['core:next-item', 'Move to older conversation'],
+ ],
+ },
+ {
+ title: 'Selection',
+ items: [
+ ['core:select-item', 'Select conversation'],
+ ['multiselect-list:select-all', 'Select all conversations'],
+ ['multiselect-list:deselect-all', 'Deselect all conversations'],
+ ['thread-list:select-read', 'Select all read conversations'],
+ ['thread-list:select-unread', 'Select all unread conversations'],
+ ['thread-list:select-starred', 'Select all starred conversations'],
+ ['thread-list:select-unstarred', 'Select all unstarred conversations'],
+ ],
+ },
+ {
+ title: 'Jumping',
+ items: [
+ ['navigation:go-to-inbox', 'Go to "Inbox"'],
+ ['navigation:go-to-starred', 'Go to "Starred"'],
+ ['navigation:go-to-sent', 'Go to "Sent Mail"'],
+ ['navigation:go-to-drafts', 'Go to "Drafts"'],
+ ['navigation:go-to-all', 'Go to "All Mail"'],
+ ],
+ },
+]
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/keymaps/mousetrap-keybinding-helpers.js b/packages/client-app/internal_packages/preferences/lib/tabs/keymaps/mousetrap-keybinding-helpers.js
new file mode 100644
index 0000000000..87293bcaa7
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/keymaps/mousetrap-keybinding-helpers.js
@@ -0,0 +1,213 @@
+/* eslint-disable */
+/**
+ * mapping of special keycodes to their corresponding keys
+ *
+ * everything in this dictionary cannot use keypress events
+ * so it has to be here to map to the correct keycodes for
+ * keyup/keydown events
+ *
+ * @type {Object}
+ */
+var _MAP = {
+ '8': 'backspace',
+ '9': 'tab',
+ '13': 'enter',
+ '16': 'shift',
+ '17': 'ctrl',
+ '18': 'alt',
+ '20': 'capslock',
+ '27': 'esc',
+ '32': 'space',
+ '33': 'pageup',
+ '34': 'pagedown',
+ '35': 'end',
+ '36': 'home',
+ '37': 'left',
+ '38': 'up',
+ '39': 'right',
+ '40': 'down',
+ '45': 'ins',
+ '46': 'del',
+ '91': 'meta',
+ '93': 'meta',
+ '224': 'meta'
+};
+
+/**
+ * mapping for special characters so they can support
+ *
+ * this dictionary is only used incase you want to bind a
+ * keyup or keydown event to one of these keys
+ *
+ * @type {Object}
+ */
+var _KEYCODE_MAP = {
+ '106': '*',
+ '107': '+',
+ '109': '-',
+ '110': '.',
+ '111' : '/',
+ '186': ';',
+ '187': '=',
+ '188': ',',
+ '189': '-',
+ '190': '.',
+ '191': '/',
+ '192': '`',
+ '219': '[',
+ '220': '\\',
+ '221': ']',
+ '222': '\''
+};
+
+/**
+ * this is a mapping of keys that require shift on a US keypad
+ * back to the non shift equivelents
+ *
+ * this is so you can use keyup events with these keys
+ *
+ * note that this will only work reliably on US keyboards
+ *
+ * @type {Object}
+ */
+var _SHIFT_MAP = {
+ '~': '`',
+ '!': '1',
+ '@': '2',
+ '#': '3',
+ '$': '4',
+ '%': '5',
+ '^': '6',
+ '&': '7',
+ '*': '8',
+ '(': '9',
+ ')': '0',
+ '_': '-',
+ '+': '=',
+ ':': ';',
+ '\"': '\'',
+ '<': ',',
+ '>': '.',
+ '?': '/',
+ '|': '\\'
+};
+
+/**
+ * this is a list of special strings you can use to map
+ * to modifier keys when you specify your keyboard shortcuts
+ *
+ * @type {Object}
+ */
+var _SPECIAL_ALIASES = {
+ 'option': 'alt',
+ 'command': 'meta',
+ 'return': 'enter',
+ 'escape': 'esc',
+ 'plus': '+',
+ 'mod': /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'meta' : 'ctrl'
+};
+
+/**
+ * variable to store the flipped version of _MAP from above
+ * needed to check if we should use keypress or not when no action
+ * is specified
+ *
+ * @type {Object|undefined}
+ */
+var _REVERSE_SHIFT_MAP = {};
+for (var key of Object.keys(_SHIFT_MAP)) {
+ _REVERSE_SHIFT_MAP[_SHIFT_MAP[key]] = key;
+}
+
+/**
+ * loop through the f keys, f1 to f19 and add them to the map
+ * programatically
+ */
+for (var i = 1; i < 20; ++i) {
+ _MAP[111 + i] = 'f' + i;
+}
+
+/**
+ * loop through to map numbers on the numeric keypad
+ */
+for (i = 0; i <= 9; ++i) {
+ _MAP[i + 96] = i;
+}
+
+function characterFromEvent(e) {
+ // for keypress events we should return the character as is
+ if (e.type == 'keypress') {
+ var character = String.fromCharCode(e.which);
+
+ // if the shift key is not pressed then it is safe to assume
+ // that we want the character to be lowercase. this means if
+ // you accidentally have caps lock on then your key bindings
+ // will continue to work
+ //
+ // the only side effect that might not be desired is if you
+ // bind something like 'A' cause you want to trigger an
+ // event when capital A is pressed caps lock will no longer
+ // trigger the event. shift+a will though.
+ if (!e.shiftKey) {
+ character = character.toLowerCase();
+ }
+
+ return character;
+ }
+
+ // for non keypress events the special maps are needed
+ if (_MAP[e.which]) {
+ return _MAP[e.which];
+ }
+
+ if (_KEYCODE_MAP[`${e.which}`]) {
+ return _KEYCODE_MAP[`${e.which}`];
+ }
+
+ // if it is not in the special map
+
+ // with keydown and keyup events the character seems to always
+ // come in as an uppercase character whether you are pressing shift
+ // or not. we should make sure it is always lowercase for comparisons
+ return String.fromCharCode(e.which).toLowerCase();
+}
+
+/**
+ * takes a key event and figures out what the modifiers are
+ *
+ * @param {Event} e
+ * @returns {Array}
+ */
+function eventModifiers(e) {
+ var modifiers = [];
+
+ if (e.shiftKey) {
+ modifiers.push('shift');
+ }
+
+ if (e.altKey) {
+ modifiers.push('alt');
+ }
+
+ if (e.ctrlKey) {
+ modifiers.push('ctrl');
+ }
+
+ if (e.metaKey) {
+ modifiers.push('meta');
+ }
+
+ return modifiers;
+}
+
+function keyAndModifiersForEvent(e) {
+ var eventKey = characterFromEvent(e);
+ var eventMods = eventModifiers(e);
+ if (_REVERSE_SHIFT_MAP[eventKey] && (eventMods.indexOf('shift') !== -1)) {
+ eventKey = _REVERSE_SHIFT_MAP[eventKey];
+ eventMods = eventMods.filter((k) => k !== 'shift');
+ }
+ return [eventKey, eventMods];
+}
+
+module.exports = {characterFromEvent, eventModifiers, keyAndModifiersForEvent};
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-account-details.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-account-details.jsx
new file mode 100644
index 0000000000..13ecb5f1d9
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-account-details.jsx
@@ -0,0 +1,223 @@
+/* eslint global-require: 0 */
+import React, {Component, PropTypes} from 'react';
+import {EditableList, NewsletterSignup} from 'nylas-component-kit';
+import {RegExpUtils, Account} from 'nylas-exports';
+
+class PreferencesAccountDetails extends Component {
+
+ static propTypes = {
+ account: PropTypes.object,
+ onAccountUpdated: PropTypes.func.isRequired,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {account: props.account.clone()};
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.setState({account: nextProps.account.clone()});
+ }
+
+ componentWillUnmount() {
+ this._saveChanges();
+ }
+
+
+ // Helpers
+
+ /**
+ * @private Will transform any user input into alias format.
+ * It will ignore any text after an email, if one is entered.
+ * If no email is entered, it will use the account's email.
+ * It will treat the text before the email as the name for the alias.
+ * If no name is entered, it will use the account's name value.
+ * @param {string} str - The string the user entered on the alias input
+ * @param {object} [account=this.props.account] - The account object
+ */
+ _makeAlias(str, account = this.props.account) {
+ const emailRegex = RegExpUtils.emailRegex();
+ const match = emailRegex.exec(str);
+ if (!match) {
+ return `${str || account.name} <${account.emailAddress}>`;
+ }
+ const email = match[0];
+ let name = str.slice(0, Math.max(0, match.index - 1));
+ if (!name) {
+ name = account.name || 'No name provided';
+ }
+ name = name.trim();
+ // TODO Sanitize the name string
+ return `${name} <${email}>`;
+ }
+
+ _saveChanges = () => {
+ this.props.onAccountUpdated(this.props.account, this.state.account);
+ };
+
+ _setState = (updates, callback = () => {}) => {
+ const account = Object.assign(this.state.account.clone(), updates);
+ this.setState({account}, callback);
+ };
+
+ _setStateAndSave = (updates) => {
+ this._setState(updates, () => {
+ this._saveChanges();
+ });
+ };
+
+
+ // Handlers
+
+ _onAccountLabelUpdated = (event) => {
+ this._setState({label: event.target.value});
+ };
+
+ _onAccountAliasCreated = (newAlias) => {
+ const coercedAlias = this._makeAlias(newAlias);
+ const aliases = this.state.account.aliases.concat([coercedAlias]);
+ this._setStateAndSave({aliases})
+ };
+
+ _onAccountAliasUpdated = (newAlias, alias, idx) => {
+ const coercedAlias = this._makeAlias(newAlias);
+ const aliases = this.state.account.aliases.slice();
+ let defaultAlias = this.state.account.defaultAlias;
+ if (defaultAlias === alias) {
+ defaultAlias = coercedAlias;
+ }
+ aliases[idx] = coercedAlias;
+ this._setStateAndSave({aliases, defaultAlias});
+ };
+
+ _onAccountAliasRemoved = (alias, idx) => {
+ const aliases = this.state.account.aliases.slice();
+ let defaultAlias = this.state.account.defaultAlias;
+ if (defaultAlias === alias) {
+ defaultAlias = null;
+ }
+ aliases.splice(idx, 1);
+ this._setStateAndSave({aliases, defaultAlias});
+ };
+
+ _onDefaultAliasSelected = (event) => {
+ const defaultAlias = event.target.value === 'None' ? null : event.target.value;
+ this._setStateAndSave({defaultAlias});
+ };
+
+ _onReconnect = () => {
+ const ipc = require('electron').ipcRenderer;
+ ipc.send('command', 'application:add-account', {existingAccount: this.state.account, source: 'Reconnect from preferences'});
+ }
+
+ _onContactSupport = () => {
+ const {shell} = require("electron");
+ shell.openExternal("https://support.nylas.com/hc/en-us/requests/new");
+ }
+
+ // Renderers
+
+ _renderDefaultAliasSelector(account) {
+ const aliases = account.aliases;
+ const defaultAlias = account.defaultAlias || 'None';
+ if (aliases.length > 0) {
+ return (
+
+
Default for new messages:
+
+ {`${account.name} <${account.emailAddress}>`}
+ {aliases.map((alias, idx) => {alias} )}
+
+
+ );
+ }
+ return null;
+ }
+
+
+ _renderErrorDetail(message, buttonText, buttonAction) {
+ return ()
+ }
+
+ _renderSyncErrorDetails() {
+ const {account} = this.state;
+ if (account.hasSyncStateError()) {
+ switch (account.syncState) {
+ case Account.N1_Cloud_AUTH_FAILED:
+ return this._renderErrorDetail(
+ `Nylas Mail can no longer authenticate N1 Cloud Services with
+ ${account.emailAddress}. The password or authentication may
+ have changed.`,
+ "Reconnect",
+ this._onReconnect);
+ case Account.SYNC_STATE_AUTH_FAILED:
+ return this._renderErrorDetail(
+ `Nylas Mail can no longer authenticate with ${account.emailAddress}. The password or
+ authentication may have changed.`,
+ "Reconnect",
+ this._onReconnect);
+ default:
+ return this._renderErrorDetail(
+ `Nylas encountered an error while syncing mail for ${account.emailAddress}. Contact Nylas support for details.`,
+ "Contact support",
+ this._onContactSupport);
+ }
+ }
+ return null;
+ }
+
+ render() {
+ const {account} = this.state;
+ const aliasPlaceholder = this._makeAlias(
+ `alias@${account.emailAddress.split('@')[1]}`
+ );
+
+ return (
+
+ {this._renderSyncErrorDetails()}
+
Account Label
+
+
+
Account Settings
+
+
+ {account.provider === 'imap' ? 'Update Connection Settings...' : 'Re-authenticate...'}
+
+
+
Aliases
+
+
+ You may need to configure aliases with your
+ mail provider (Outlook, Gmail) before using them.
+
+
+
+
+ {this._renderDefaultAliasSelector(account)}
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default PreferencesAccountDetails;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-account-list.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-account-list.jsx
new file mode 100644
index 0000000000..2b387d2c8f
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-account-list.jsx
@@ -0,0 +1,80 @@
+import React, {Component, PropTypes} from 'react';
+import {RetinaImg, Flexbox, EditableList} from 'nylas-component-kit';
+import classnames from 'classnames';
+
+class PreferencesAccountList extends Component {
+
+ static propTypes = {
+ accounts: PropTypes.array,
+ selected: PropTypes.object,
+ onAddAccount: PropTypes.func.isRequired,
+ onReorderAccount: PropTypes.func.isRequired,
+ onSelectAccount: PropTypes.func.isRequired,
+ onRemoveAccount: PropTypes.func.isRequired,
+ };
+
+ _renderAccountStateIcon(account) {
+ if (account.syncState !== "running") {
+ return (
+
+
+
+ )
+ }
+ return null;
+ }
+
+ _renderAccount = (account) => {
+ const label = account.label;
+ const accountSub = `${account.name || 'No name provided'} <${account.emailAddress}>`;
+ const syncError = account.hasSyncStateError();
+
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
{accountSub} ({account.displayProvider()})
+
+
+
+ );
+ };
+
+ render() {
+ if (!this.props.accounts) {
+ return
;
+ }
+ return (
+
+ );
+ }
+
+}
+
+export default PreferencesAccountList;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-accounts.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-accounts.jsx
new file mode 100644
index 0000000000..3d67679a73
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-accounts.jsx
@@ -0,0 +1,93 @@
+import _ from 'underscore';
+import React from 'react';
+import {ipcRenderer} from 'electron';
+import {AccountStore, Actions} from 'nylas-exports';
+import PreferencesAccountList from './preferences-account-list';
+import PreferencesAccountDetails from './preferences-account-details';
+
+
+class PreferencesAccounts extends React.Component {
+ static displayName = 'PreferencesAccounts';
+
+ constructor() {
+ super();
+ this.state = this.getStateFromStores();
+ }
+
+ componentDidMount() {
+ this.unsubscribe = AccountStore.listen(this._onAccountsChanged)
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) {
+ this.unsubscribe();
+ }
+ }
+
+ getStateFromStores({selected} = {}) {
+ const accounts = AccountStore.accounts()
+ let selectedAccount;
+ if (selected) {
+ selectedAccount = _.findWhere(accounts, {id: selected.id})
+ }
+ // If selected was null or no longer exists in the AccountStore,
+ // just use the first account.
+ if (!selectedAccount) {
+ selectedAccount = accounts[0];
+ }
+ return {
+ accounts,
+ selected: selectedAccount,
+ };
+ }
+
+ _onAccountsChanged = () => {
+ this.setState(this.getStateFromStores(this.state));
+ }
+
+ // Update account list actions
+ _onAddAccount() {
+ ipcRenderer.send('command', 'application:add-account', {source: 'Preferences'});
+ }
+
+ _onReorderAccount(account, oldIdx, newIdx) {
+ Actions.reorderAccount(account.id, newIdx);
+ }
+
+ _onSelectAccount = (account) => {
+ this.setState({selected: account});
+ }
+
+ _onRemoveAccount(account) {
+ Actions.removeAccount(account.id);
+ }
+
+ // Update account actions
+ _onAccountUpdated(account, updates) {
+ Actions.updateAccount(account.id, updates);
+ }
+
+ render() {
+ return (
+
+ );
+ }
+
+}
+
+export default PreferencesAccounts;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-appearance.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-appearance.jsx
new file mode 100644
index 0000000000..8cc8f1b0dd
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-appearance.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import {RetinaImg, Flexbox} from 'nylas-component-kit';
+
+class AppearanceModeSwitch extends React.Component {
+
+ static displayName = 'AppearanceModeSwitch';
+
+ static propTypes = {
+ config: React.PropTypes.object.isRequired,
+ };
+
+ constructor(props) {
+ super();
+ this.state = {
+ value: props.config.get('core.workspace.mode'),
+ };
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.setState({
+ value: nextProps.config.get('core.workspace.mode'),
+ });
+ }
+
+ _onApplyChanges = () => {
+ NylasEnv.commands.dispatch(`application:select-${this.state.value}-mode`);
+ }
+
+ _renderModeOptions() {
+ return ['list', 'split'].map((mode) =>
+ this.setState({value: mode})}
+ />
+ );
+ }
+
+ render() {
+ const hasChanges = this.state.value !== this.props.config.get('core.workspace.mode');
+ let applyChangesClass = "btn";
+ if (!hasChanges) applyChangesClass += " btn-disabled";
+
+ return (
+
+
+ {this._renderModeOptions()}
+
+
Apply Layout
+
+ );
+ }
+
+}
+
+const AppearanceModeOption = function AppearanceModeOption(props) {
+ let classname = "appearance-mode";
+ if (props.active) classname += " active";
+
+ const label = {
+ list: 'Single Panel',
+ split: 'Two Panel',
+ }[props.mode];
+
+ return (
+
+ );
+}
+AppearanceModeOption.propTypes = {
+ mode: React.PropTypes.string.isRequired,
+ active: React.PropTypes.bool,
+ onClick: React.PropTypes.func,
+}
+
+class PreferencesAppearance extends React.Component {
+
+ static displayName = 'PreferencesAppearance';
+
+ static propTypes = {
+ config: React.PropTypes.object,
+ configSchema: React.PropTypes.object,
+ }
+
+ onClick = () => {
+ NylasEnv.commands.dispatch("window:launch-theme-picker");
+ }
+
+ render() {
+ return (
+
+
Change layout:
+
+
Change theme...
+
+ );
+ }
+}
+
+export default PreferencesAppearance;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-general.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-general.jsx
new file mode 100644
index 0000000000..2fc45aa88c
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-general.jsx
@@ -0,0 +1,90 @@
+/* eslint global-require: 0*/
+import React from 'react';
+
+import {Actions} from 'nylas-exports'
+import ConfigSchemaItem from './config-schema-item';
+import WorkspaceSection from './workspace-section';
+import SendingSection from './sending-section';
+class PreferencesGeneral extends React.Component {
+ static displayName = 'PreferencesGeneral'
+
+ static propTypes = {
+ config: React.PropTypes.object,
+ configSchema: React.PropTypes.object,
+ };
+
+ _reboot = () => {
+ const app = require('electron').remote.app;
+ app.relaunch()
+ app.quit()
+ }
+
+
+ _resetAccountsAndSettings = () => {
+ const rimraf = require('rimraf')
+ rimraf(NylasEnv.getConfigDirPath(), {disableGlob: true}, (err) => {
+ if (err) console.log(err)
+ else this._reboot()
+ })
+ }
+
+ _resetEmailCache = () => {
+ Actions.resetEmailCache()
+ }
+
+ render() {
+ return (
+
+
+
+
+
+
+
+ N1 desktop notifications on Linux require Zenity. You may need to install
+ it with your package manager (i.e., sudo apt-get install zenity
).
+
+
+
+
+
+
+
+
+
+
+
+
Local Data
+
Reset Email Cache
+
Reset Accounts and Settings
+
+
+ )
+ }
+}
+
+
+export default PreferencesGeneral;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-identity.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-identity.jsx
new file mode 100644
index 0000000000..2d6aed4caa
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-identity.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import {Actions, IdentityStore} from 'nylas-exports';
+import {OpenIdentityPageButton, BillingModal, RetinaImg} from 'nylas-component-kit';
+import {shell} from 'electron';
+
+class PreferencesIdentity extends React.Component {
+ static displayName = 'PreferencesIdentity';
+
+ constructor() {
+ super();
+ this.state = this._getStateFromStores();
+ }
+
+ componentDidMount() {
+ this.unsubscribe = IdentityStore.listen(() => {
+ this.setState(this._getStateFromStores());
+ });
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ _getStateFromStores() {
+ return {
+ identity: IdentityStore.identity() || {},
+ };
+ }
+
+ _onUpgrade = () => {
+ Actions.openModal({
+ component: (
+
+ ),
+ height: 575,
+ width: 412,
+ })
+ }
+
+ _renderBasic() {
+ const learnMore = () => shell.openExternal("https://nylas.com/nylas-pro")
+ return (
+
+
+ You are using Nylas Mail Basic . Upgrade to Nylas Mail Pro to unlock a more powerful email experience.
+
+
+
Upgrade to Nylas Mail Pro
+
Learn More
+
+
+ )
+ }
+
+ _renderPro() {
+ return (
+
+
+ Thank you for using Nylas Mail Pro
+
+
+
+
+
+ )
+ }
+
+ render() {
+ const {identity} = this.state;
+ const {firstname, lastname, email} = identity;
+
+ const logout = () => Actions.logoutNylasIdentity()
+
+ return (
+
+
+
+
+
+
+
+
+
{firstname} {lastname}
+
{email}
+
+
+
+
+ {this.state.identity.has_pro_access ? this._renderPro() : this._renderBasic()}
+
+
+
+ );
+ }
+}
+
+export default PreferencesIdentity;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-keymaps.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-keymaps.jsx
new file mode 100644
index 0000000000..329ab6c801
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-keymaps.jsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import path from 'path';
+import fs from 'fs';
+import { remote } from 'electron';
+import { Flexbox } from 'nylas-component-kit';
+
+import displayedKeybindings from './keymaps/displayed-keybindings';
+import CommandItem from './keymaps/command-item';
+
+class PreferencesKeymaps extends React.Component {
+
+ static displayName = 'PreferencesKeymaps';
+
+ static propTypes = {
+ config: React.PropTypes.object,
+ };
+
+ constructor() {
+ super();
+ this.state = {
+ templates: [],
+ bindings: this._getStateFromKeymaps(),
+ };
+ this._loadTemplates();
+ }
+
+ componentDidMount() {
+ this._disposable = NylasEnv.keymaps.onDidReloadKeymap(() => {
+ this.setState({bindings: this._getStateFromKeymaps()});
+ });
+ }
+
+ componentWillUnmount() {
+ this._disposable.dispose();
+ }
+
+ _getStateFromKeymaps() {
+ const bindings = {};
+ for (const section of displayedKeybindings) {
+ for (const [command] of section.items) {
+ bindings[command] = NylasEnv.keymaps.getBindingsForCommand(command) || [];
+ }
+ }
+ return bindings;
+ }
+
+ _loadTemplates() {
+ const templatesDir = path.join(NylasEnv.getLoadSettings().resourcePath, 'keymaps', 'templates');
+ fs.readdir(templatesDir, (err, files) => {
+ if (!files || !(files instanceof Array)) return;
+ let templates = files.filter((filename) => {
+ return path.extname(filename) === '.json';
+ });
+ templates = templates.map((filename) => {
+ return path.parse(filename).name;
+ });
+ this.setState({templates: templates});
+ });
+ }
+
+ _onShowUserKeymaps() {
+ const keymapsFile = NylasEnv.keymaps.getUserKeymapPath();
+ if (!fs.existsSync(keymapsFile)) {
+ fs.writeFileSync(keymapsFile, '{}');
+ }
+ remote.shell.showItemInFolder(keymapsFile);
+ }
+
+ _onDeleteUserKeymap() {
+ const chosen = remote.dialog.showMessageBox(NylasEnv.getCurrentWindow(), {
+ type: 'info',
+ message: "Are you sure?",
+ detail: "Delete your custom key bindings and reset to the template defaults?",
+ buttons: ['Cancel', 'Reset'],
+ });
+
+ if (chosen === 1) {
+ const keymapsFile = NylasEnv.keymaps.getUserKeymapPath();
+ fs.writeFileSync(keymapsFile, '{}');
+ }
+ }
+
+ _renderBindingsSection = (section) => {
+ return (
+
+ {section.title}
+ {
+ section.items.map(([command, label]) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+ }
+
+ render() {
+ return (
+
+
+
+ Shortcut set:
+
+ this.props.config.set('core.keymapTemplate', event.target.value)}
+ >
+ {this.state.templates.map((template) => {
+ return {template}
+ })}
+
+
+
+ Reset to Defaults
+
+
+ You can choose a shortcut set to use keyboard shortcuts of familiar email clients.
+ To edit a shortcut, click it in the list below and enter a replacement on the keyboard.
+
+ {displayedKeybindings.map(this._renderBindingsSection)}
+
+
+ Customization
+ You can manage your custom shortcuts directly by editing your shortcuts file.
+ Edit custom shortcuts
+
+
+ );
+ }
+
+}
+
+export default PreferencesKeymaps;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-mail-rules.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-mail-rules.jsx
new file mode 100644
index 0000000000..69ae61e559
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-mail-rules.jsx
@@ -0,0 +1,306 @@
+import React from 'react';
+import _ from 'underscore';
+
+import {Actions,
+ AccountStore,
+ MailRulesStore,
+ MailRulesTemplates,
+ TaskQueueStatusStore,
+ ReprocessMailRulesTask} from 'nylas-exports';
+
+import {Flexbox,
+ EditableList,
+ RetinaImg,
+ ScrollRegion,
+ ScenarioEditor} from 'nylas-component-kit';
+
+const {
+ ActionTemplatesForAccount,
+ ConditionTemplatesForAccount,
+} = MailRulesTemplates;
+
+
+class PreferencesMailRules extends React.Component {
+ static displayName = 'PreferencesMailRules';
+
+ constructor() {
+ super();
+ this.state = this._getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._unsubscribers = [];
+ this._unsubscribers.push(MailRulesStore.listen(this._onRulesChanged));
+ this._unsubscribers.push(TaskQueueStatusStore.listen(this._onTasksChanged));
+ }
+
+ componentWillUnmount() {
+ this._unsubscribers.forEach(unsubscribe => unsubscribe());
+ }
+
+ _getStateFromStores() {
+ const accounts = AccountStore.accounts();
+ const state = this.state || {};
+ let {currentAccount} = state;
+ if (!accounts.find(acct => acct === currentAccount)) {
+ currentAccount = accounts[0];
+ }
+ const rules = MailRulesStore.rulesForAccountId(currentAccount.id);
+ const selectedRule = this.state && this.state.selectedRule ? _.findWhere(rules, {id: this.state.selectedRule.id}) : rules[0];
+
+ return {
+ accounts: accounts,
+ currentAccount: currentAccount,
+ rules: rules,
+ selectedRule: selectedRule,
+ tasks: TaskQueueStatusStore.tasksMatching(ReprocessMailRulesTask, {}),
+ actionTemplates: ActionTemplatesForAccount(currentAccount),
+ conditionTemplates: ConditionTemplatesForAccount(currentAccount),
+ }
+ }
+
+ _onSelectAccount = (event) => {
+ const accountId = event.target.value;
+ const currentAccount = this.state.accounts.find(acct => acct.id === accountId);
+ this.setState({currentAccount: currentAccount}, () => {
+ this.setState(this._getStateFromStores())
+ });
+ }
+
+ _onReprocessRules = () => {
+ const needsMessageBodies = () => {
+ for (const rule of this.state.rules) {
+ for (const condition of rule.conditions) {
+ if (condition.templateKey === 'body') {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ if (needsMessageBodies()) {
+ NylasEnv.showErrorDialog("One or more of your mail rules requires the bodies of messages being processed. These rules can't be run on your entire mailbox.");
+ }
+
+ const task = new ReprocessMailRulesTask(this.state.currentAccount.id)
+ Actions.queueTask(task);
+ }
+
+ _onAddRule = () => {
+ Actions.addMailRule({accountId: this.state.currentAccount.id});
+ }
+
+ _onSelectRule = (rule) => {
+ this.setState({selectedRule: rule});
+ }
+
+ _onReorderRule = (rule, startIdx, endIdx) => {
+ Actions.reorderMailRule(rule.id, endIdx);
+ }
+
+ _onDeleteRule = (rule) => {
+ Actions.deleteMailRule(rule.id);
+ }
+
+ _onRuleNameEdited = (newName, rule) => {
+ Actions.updateMailRule(rule.id, {name: newName});
+ }
+
+ _onRuleConditionModeEdited = (event) => {
+ Actions.updateMailRule(this.state.selectedRule.id, {conditionMode: event.target.value});
+ }
+
+ _onRuleEnabled = () => {
+ Actions.updateMailRule(this.state.selectedRule.id, {disabled: false, disabledReason: null});
+ }
+
+ _onRulesChanged = () => {
+ const next = this._getStateFromStores();
+ const nextRules = next.rules;
+ const prevRules = this.state.rules ? this.state.rules : [];
+
+ const added = _.difference(nextRules, prevRules);
+ if (added.length === 1) {
+ next.selectedRule = added[0];
+ }
+
+ this.setState(next);
+ }
+
+ _onTasksChanged = () => {
+ this.setState({tasks: TaskQueueStatusStore.tasksMatching(ReprocessMailRulesTask, {})})
+ }
+
+ _renderAccountPicker() {
+ const options = this.state.accounts.map(account =>
+ {account.label}
+ );
+
+ return (
+
+ {options}
+
+ );
+ }
+
+ _renderMailRules() {
+ if (this.state.rules.length === 0) {
+ return (
+
+
+
No rules
+
+ Create a new rule
+
+
+ );
+ }
+ return (
+
+
+ {this._renderDetail()}
+
+ );
+ }
+
+ _renderListItemContent(rule) {
+ if (rule.disabled) {
+ return ({rule.name}
);
+ }
+ return rule.name;
+ }
+
+ _renderDetail() {
+ const rule = this.state.selectedRule;
+
+ if (rule) {
+ return (
+
+ {this._renderDetailDisabledNotice()}
+
+ If
+
+ Any
+ All
+
+ of the following conditions are met:
+ Actions.updateMailRule(rule.id, {conditions})}
+ className="well well-matchers"
+ />
+ Perform the following actions:
+ Actions.updateMailRule(rule.id, {actions})}
+ className="well well-actions"
+ />
+
+
+ );
+ }
+
+ return (
+
+
Create a rule or select one to get started
+
+ );
+ }
+
+ _renderDetailDisabledNotice() {
+ if (!this.state.selectedRule.disabled) return false;
+ return (
+
+
Enable
+ This rule has been disabled. Make sure the actions below are valid
+ and re-enable the rule.
+
({this.state.selectedRule.disabledReason})
+
+ );
+ }
+
+ _renderTasks() {
+ if (this.state.tasks.length === 0) return false;
+ return (
+
+ {this.state.tasks.map((task) => {
+ return (
+
+
+
+
+
+ {AccountStore.accountForId(task.accountId).emailAddress}
+ {` — ${Number(task.numberOfImpactedItems()).toLocaleString()} processed...`}
+
+
+ Actions.dequeueTask(task.id)}>
+ Cancel
+
+
+ );
+ })}
+
+ );
+ }
+
+ render() {
+ const processDisabled = _.any(this.state.tasks, (task) => {
+ return (task.accountId === this.state.currentAccount.id);
+ });
+
+ return (
+
+
+
+ Account:
+ {this._renderAccountPicker()}
+
+ Rules only apply to the selected account.
+
+ {this._renderMailRules()}
+
+
+
+
+ Process entire inbox
+
+
+ {this._renderTasks()}
+
+
+
+ By default, mail rules are only applied to new mail as it arrives.
+ Applying rules to your entire inbox may take a long time and
+ degrade performance.
+
+
+
+ );
+ }
+
+}
+
+export default PreferencesMailRules;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/sending-section.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/sending-section.jsx
new file mode 100644
index 0000000000..6a282668ae
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/sending-section.jsx
@@ -0,0 +1,61 @@
+import _ from 'underscore';
+import React from 'react';
+import {AccountStore, SendActionsStore} from 'nylas-exports';
+import {ListensToFluxStore} from 'nylas-component-kit';
+import ConfigSchemaItem from './config-schema-item';
+
+
+function getExtendedSendingSchema(configSchema) {
+ const accounts = AccountStore.accounts();
+ // const sendActions = SendActionsStore.sendActions()
+ const defaultAccountIdForSend = {
+ 'type': 'string',
+ 'title': 'Send new messages from',
+ 'default': 'selected-mailbox',
+ 'enum': ['selected-mailbox'].concat(accounts.map(acc => acc.id)),
+ 'enumLabels': ['Account of selected mailbox'].concat(accounts.map(acc => acc.me().toString())),
+ }
+ // TODO re-enable sending actions at some point
+ // const defaultSendType = {
+ // 'type': 'string',
+ // 'default': 'send',
+ // 'enum': sendActions.map(({configKey}) => configKey),
+ // 'enumLabels': sendActions.map(({title}) => title),
+ // 'title': "Default send behavior",
+ // }
+
+ _.extend(configSchema.properties.sending.properties, {
+ defaultAccountIdForSend,
+ });
+ return configSchema.properties.sending;
+}
+
+function SendingSection(props) {
+ const {config, sendingConfigSchema} = props
+
+ return (
+
+ );
+}
+
+SendingSection.displayName = 'SendingSection';
+SendingSection.propTypes = {
+ config: React.PropTypes.object,
+ configSchema: React.PropTypes.object,
+ sendingConfigSchema: React.PropTypes.object,
+}
+
+export default ListensToFluxStore(SendingSection, {
+ stores: [AccountStore, SendActionsStore],
+ getStateFromStores(props) {
+ const {configSchema} = props
+ return {
+ sendingConfigSchema: getExtendedSendingSchema(configSchema),
+ }
+ },
+});
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/update-channel-section.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/update-channel-section.jsx
new file mode 100644
index 0000000000..6c253a8dba
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/update-channel-section.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import {UpdateChannelStore} from 'nylas-exports';
+
+class UpdateChannelSection extends React.Component {
+
+ static displayName = 'UpdateChannelSection';
+
+ constructor(props) {
+ super(props);
+ this.state = this.getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._unsub = UpdateChannelStore.listen(() => {
+ this.setState(Object.assign(this.getStateFromStores(), {saving: false}));
+ });
+ UpdateChannelStore.refreshChannel();
+ }
+
+ componentWillUnmount() {
+ if (this._unsub) {
+ this._unsub();
+ }
+ }
+
+ getStateFromStores() {
+ return {
+ current: UpdateChannelStore.current(),
+ available: UpdateChannelStore.available(),
+ }
+ }
+
+ _onSelectedChannel = (event) => {
+ this.setState({saving: true});
+ UpdateChannelStore.setChannel(event.target.value);
+ }
+
+ render() {
+ const {current, available, saving} = this.state;
+
+ // HACK: Temporarily do not allow users to move on to the Salesforce channel.
+ // In the future we could implement this server-side via a "public" flag.
+ const allowedNames = ["stable", "nylas-mail", "beta"]
+
+ if (NylasEnv.config.get("salesforce")) {
+ allowedNames.push("salesforce");
+ }
+
+ const allowed = available.filter(c => {
+ return allowedNames.includes(c.name) || c.name === current.name
+ });
+
+ const displayNameForChannel = (channel) => {
+ if (channel.name === 'beta') {
+ return 'Beta (Unstable)';
+ } else if (channel.name === 'nylas-mail') {
+ return 'Nylas Mail (Stable)';
+ } else if (channel.name === 'stable') {
+ return 'Nylas Pro (Stable)';
+ }
+
+ return channel.name[0].toUpperCase() + channel.name.substr(1)
+ }
+
+ return (
+
+ Updates
+ Release channel:
+
+ {
+ allowed.map((channel) => {
+ return (
+ {displayNameForChannel(channel)}
+ );
+ })
+ }
+
+
+ Subscribe to different update channels to receive previews of new features.
+ Note that some update channels may be less stable!
+
+
+ );
+ }
+
+}
+
+export default UpdateChannelSection;
diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/workspace-section.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/workspace-section.jsx
new file mode 100644
index 0000000000..0262e06604
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/lib/tabs/workspace-section.jsx
@@ -0,0 +1,163 @@
+import React from 'react';
+import {DefaultClientHelper, SystemStartService} from 'nylas-exports';
+import ConfigSchemaItem from './config-schema-item';
+
+class DefaultMailClientItem extends React.Component {
+
+ constructor() {
+ super();
+ this.state = {defaultClient: false};
+ this._helper = new DefaultClientHelper();
+ if (this._helper.available()) {
+ this._helper.isRegisteredForURLScheme('mailto', (registered) => {
+ if (this._mounted) this.setState({defaultClient: registered});
+ });
+ }
+ }
+
+ componentDidMount() {
+ this._mounted = true;
+ }
+
+ componentWillUnmount() {
+ this._mounted = false;
+ }
+
+ toggleDefaultMailClient = (event) => {
+ if (this.state.defaultClient) {
+ this.setState({defaultClient: false});
+ this._helper.resetURLScheme('mailto');
+ } else {
+ this.setState({defaultClient: true});
+ this._helper.registerForURLScheme('mailto');
+ }
+ event.target.blur();
+ }
+
+ render() {
+ return (
+
+
+ Use Nylas Mail as default mail client
+
+ );
+ }
+
+}
+
+
+class LaunchSystemStartItem extends React.Component {
+
+ constructor() {
+ super();
+ this.state = {
+ available: false,
+ launchOnStart: false,
+ };
+ this._service = new SystemStartService();
+ }
+
+ componentDidMount() {
+ this._mounted = true;
+ this._service.checkAvailability().then((available) => {
+ if (this._mounted) {
+ this.setState({available});
+ }
+ if (!available || !this._mounted) return;
+ this._service.doesLaunchOnSystemStart().then((launchOnStart) => {
+ if (this._mounted) {
+ this.setState({launchOnStart});
+ }
+ });
+ });
+ }
+
+ componentWillUnmount() {
+ this._mounted = false;
+ }
+
+ _toggleLaunchOnStart = (event) => {
+ if (this.state.launchOnStart) {
+ this.setState({launchOnStart: false});
+ this._service.dontLaunchOnSystemStart();
+ } else {
+ this.setState({launchOnStart: true});
+ this._service.configureToLaunchOnSystemStart();
+ }
+ event.target.blur();
+ }
+
+ render() {
+ if (!this.state.available) return false;
+ return (
+
+
+ Launch on system start
+
+ );
+ }
+
+}
+
+const WorkspaceSection = (props) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "Launch on system start" only works in XDG-compliant desktop environments.
+ To enable the N1 icon in the system tray, you may need to install libappindicator1.
+ (i.e., <code>sudo apt-get install libappindicator1</code>)
+
+
+ );
+}
+
+WorkspaceSection.propTypes = {
+ config: React.PropTypes.object,
+ configSchema: React.PropTypes.object,
+}
+
+export default WorkspaceSection;
diff --git a/packages/client-app/internal_packages/preferences/package.json b/packages/client-app/internal_packages/preferences/package.json
new file mode 100644
index 0000000000..16cb6be578
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "preferences",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Nylas Preferences Window Component",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/preferences/spec/preferences-account-details-spec.jsx b/packages/client-app/internal_packages/preferences/spec/preferences-account-details-spec.jsx
new file mode 100644
index 0000000000..39f8a555b2
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/spec/preferences-account-details-spec.jsx
@@ -0,0 +1,166 @@
+import React from 'react';
+import {renderIntoDocument} from 'react-addons-test-utils';
+import {Account} from 'nylas-exports';
+
+import PreferencesAccountDetails from '../lib/tabs/preferences-account-details';
+
+
+const makeComponent = (props = {}) => {
+ return renderIntoDocument( );
+};
+
+const account = new Account({
+ id: 1,
+ clientId: 1,
+ name: 'someone',
+ emailAddress: 'someone@nylas.com',
+ aliases: [],
+ defaultAlias: null,
+})
+
+describe('PreferencesAccountDetails', function preferencesAccountDetails() {
+ beforeEach(() => {
+ this.account = account
+ this.onAccountUpdated = jasmine.createSpy('onAccountUpdated')
+ this.component = makeComponent({account, onAccountUpdated: this.onAccountUpdated})
+ spyOn(this.component, 'setState')
+ })
+
+ function assertAccountState(actual, expected) {
+ for (const key of Object.keys(expected)) {
+ expect(actual.account[key]).toEqual(expected[key]);
+ }
+ }
+
+ describe('_makeAlias', () => {
+ it('returns correct alias when empty string provided', () => {
+ const alias = this.component._makeAlias('', this.account)
+ expect(alias).toEqual('someone ')
+ });
+
+ it('returns correct alias when only the name provided', () => {
+ const alias = this.component._makeAlias('Chad', this.account)
+ expect(alias).toEqual('Chad ')
+ });
+
+ it('returns correct alias when email provided', () => {
+ const alias = this.component._makeAlias('keith@nylas.com', this.account)
+ expect(alias).toEqual('someone ')
+ });
+
+ it('returns correct alias if name and email provided', () => {
+ const alias = this.component._makeAlias('Donald donald@nylas.com', this.account)
+ expect(alias).toEqual('Donald ')
+ });
+
+ it('returns correct alias if alias provided', () => {
+ const alias = this.component._makeAlias('Donald ', this.account)
+ expect(alias).toEqual('Donald ')
+ });
+ });
+
+ describe('_setState', () => {
+ it('sets the correct state', () => {
+ this.component._setState({aliases: ['something']})
+ assertAccountState(this.component.setState.calls[0].args[0], {aliases: ['something']})
+ });
+ });
+
+ describe('_onDefaultAliasSelected', () => {
+ it('sets the default alias correctly when set to None', () => {
+ this.component._onDefaultAliasSelected({target: {value: 'None'}})
+ assertAccountState(this.component.setState.calls[0].args[0], {defaultAlias: null})
+ });
+
+ it('sets the default alias correctly when set to any value', () => {
+ this.component._onDefaultAliasSelected({target: {value: 'my alias'}})
+ assertAccountState(this.component.setState.calls[0].args[0], {defaultAlias: 'my alias'})
+ });
+ });
+
+ describe('alias handlers', () => {
+ beforeEach(() => {
+ this.currentAlias = 'juan '
+ this.newAlias = 'some ';
+ this.account.aliases = [
+ this.currentAlias,
+ ]
+ this.component = makeComponent({account: this.account, onAccountUpdated: this.onAccountUpdated})
+ spyOn(this.component, '_makeAlias').andCallFake((alias) => alias)
+ spyOn(this.component, 'setState')
+ })
+ describe('_onAccountAliasCreated', () => {
+ it('creates alias correctly', () => {
+ this.component._onAccountAliasCreated(this.newAlias)
+ assertAccountState(this.component.setState.calls[0].args[0],
+ {aliases: [this.currentAlias, this.newAlias]})
+ });
+ });
+
+ describe('_onAccountAliasUpdated', () => {
+ it('updates alias correctly when no default alias present', () => {
+ this.component._onAccountAliasUpdated(this.newAlias, this.currentAlias, 0)
+ assertAccountState(this.component.setState.calls[0].args[0],
+ {aliases: [this.newAlias]})
+ });
+
+ it('updates alias correctly when default alias present and it is being updated', () => {
+ this.account.defaultAlias = this.currentAlias
+ this.component = makeComponent({account: this.account, onAccountUpdated: this.onAccountUpdated})
+ spyOn(this.component, '_makeAlias').andCallFake((alias) => alias)
+ spyOn(this.component, 'setState')
+
+ this.component._onAccountAliasUpdated(this.newAlias, this.currentAlias, 0)
+ assertAccountState(this.component.setState.calls[0].args[0],
+ {aliases: [this.newAlias], defaultAlias: this.newAlias})
+ });
+
+ it('updates alias correctly when default alias present and it is not being updated', () => {
+ this.account.defaultAlias = this.currentAlias
+ this.account.aliases.push('otheralias')
+ this.component = makeComponent({account: this.account, onAccountUpdated: this.onAccountUpdated})
+ spyOn(this.component, '_makeAlias').andCallFake((alias) => alias)
+ spyOn(this.component, 'setState')
+
+ this.component._onAccountAliasUpdated(this.newAlias, 'otheralias', 1)
+ assertAccountState(
+ this.component.setState.calls[0].args[0],
+ {aliases: [this.currentAlias, this.newAlias], defaultAlias: this.currentAlias}
+ )
+ });
+ });
+
+
+ describe('_onAccountAliasRemoved', () => {
+ it('removes alias correctly when no default alias present', () => {
+ this.component._onAccountAliasRemoved(this.currentAlias, 0)
+ assertAccountState(this.component.setState.calls[0].args[0], {aliases: []})
+ });
+
+ it('removes alias correctly when default alias present and it is being removed', () => {
+ this.account.defaultAlias = this.currentAlias
+ this.component = makeComponent({account: this.account, onAccountUpdated: this.onAccountUpdated})
+ spyOn(this.component, '_makeAlias').andCallFake((alias) => alias)
+ spyOn(this.component, 'setState')
+
+ this.component._onAccountAliasRemoved(this.currentAlias, 0)
+ assertAccountState(this.component.setState.calls[0].args[0],
+ {aliases: [], defaultAlias: null})
+ });
+
+ it('removes alias correctly when default alias present and it is not being removed', () => {
+ this.account.defaultAlias = this.currentAlias
+ this.account.aliases.push('otheralias')
+ this.component = makeComponent({account: this.account, onAccountUpdated: this.onAccountUpdated})
+ spyOn(this.component, '_makeAlias').andCallFake((alias) => alias)
+ spyOn(this.component, 'setState')
+
+ this.component._onAccountAliasRemoved('otheralias', 1)
+ assertAccountState(
+ this.component.setState.calls[0].args[0],
+ {aliases: [this.currentAlias], defaultAlias: this.currentAlias}
+ )
+ });
+ });
+ });
+});
diff --git a/packages/client-app/internal_packages/preferences/stylesheets/preferences-accounts.less b/packages/client-app/internal_packages/preferences/stylesheets/preferences-accounts.less
new file mode 100644
index 0000000000..1dd570ac1b
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/stylesheets/preferences-accounts.less
@@ -0,0 +1,124 @@
+@import "ui-variables";
+
+// Preferences Specific
+.preferences-wrap {
+ .container-accounts {
+ width: 70%;
+ min-width: 420px;
+ margin: 0 auto;
+ .accounts-content {
+ display: flex;
+ justify-content: center;
+
+ .account-list {
+ display: flex;
+ flex-direction: column;
+ height: auto;
+ width: 400px;
+
+ .items-wrapper {
+ flex: 1;
+ }
+
+ .account {
+ padding: 10px;
+ border-bottom: 1px solid @border-color-divider;
+ }
+
+ .list-item:not(.selected) .sync-error {
+ color: @color-error;
+ }
+
+ .account-name {
+ font-size: @font-size-large;
+ cursor: default;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: middle;
+ }
+
+ .account-subtext {
+ font-size: @font-size-small;
+ cursor: default;
+ }
+
+ .btn-editable-list {
+ height: 37px;
+ width: 37px;
+ line-height: 37px;
+ font-size: 1em;
+ }
+ }
+
+ .account-details {
+ width: 400px;
+ padding: 20px;
+ padding-left: @spacing-standard * 2.25;
+ padding-right: @spacing-standard * 2.25;
+ background-color: @gray-lighter;
+ border-top: 1px solid @border-color-divider;
+ border-right: 1px solid @border-color-divider;
+ border-bottom: 1px solid @border-color-divider;
+
+ .key-commands-region {
+ height: inherit;
+ }
+
+ .items-wrapper {
+ height: 140px;
+ }
+
+ .account-error-detail {
+ display: flex;
+ flex-direction: column;
+ background: linear-gradient(to top, #ca2541 0%, #d55268 100%);
+
+ .action {
+ flex-shrink: 0;
+ background-color: rgba(0,0,0,0.15);
+ text-align: center;
+ padding: 3px @padding-base-horizontal;
+ color: @text-color-inverse
+ }
+ .action:hover {
+ background-color: rgba(255,255,255,0.15);
+ text-decoration:none;
+ }
+ .message {
+ flex-grow: 1;
+ padding: 3px @padding-base-horizontal;
+ color: @text-color-inverse
+ }
+ }
+
+ .newsletter {
+ padding-top: @padding-base-vertical * 2;
+ input[type=checkbox] { margin: 0; position: relative; top: 0; }
+ }
+
+ &>h3 {
+ font-size: 1.2em;
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ &>input {
+ font-size: 0.9em;
+ width: 100%;
+ }
+
+ .default-alias-selector {
+ padding-top: @padding-base-vertical * 3;
+ padding-bottom: @padding-base-vertical;
+
+ &>select {
+ font-size: 0.9em;
+ margin-left:0;
+ width: 100%;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/preferences/stylesheets/preferences-identity.less b/packages/client-app/internal_packages/preferences/stylesheets/preferences-identity.less
new file mode 100644
index 0000000000..5fe3e59e84
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/stylesheets/preferences-identity.less
@@ -0,0 +1,78 @@
+@import "ui-variables";
+
+@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } }
+
+.container-identity {
+ max-width: 887px;
+ min-width: 530px;
+ margin: auto;
+
+ .id-header {
+ color: @text-color-very-subtle;
+ margin-bottom: @padding-base-vertical * 2;
+ }
+
+ .refresh {
+ float: right;
+ color: @text-color-very-subtle;
+ margin-bottom: @padding-base-vertical * 2;
+ img { background-color: @text-color-very-subtle; }
+ }
+ .refresh.spinning img {
+ animation:spin 1.4s linear infinite;
+ }
+
+ .identity-content-box {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ color: @text-color-subtle;
+ border-radius: @border-radius-large;
+ border: 1px solid @border-color-primary;
+ background-color: @background-secondary;
+
+ .row {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ }
+
+ .padded {
+ display: block;
+ padding: 20px;
+ padding-left: 137px;
+ border-top: 1px solid @border-color-primary;
+ }
+
+ .btn {
+ width: 180px;
+ &.minor-width {
+ width: 120px;
+ }
+ text-align: center;
+ margin-right: @padding-base-horizontal;
+ margin-bottom: @padding-base-horizontal;
+ }
+ .identity-actions {
+ margin-top: @padding-small-vertical + 1;
+ }
+ .subscription-actions {
+ margin-top: 20px;
+ }
+
+ .info-row {
+ padding: 30px;
+ .logo {
+ margin-right: 30px;
+ }
+ .identity-info {
+ flex: 1;
+ line-height: 1.9em;
+ .name {
+ font-size: 1.2em;
+ }
+ }
+ }
+ }
+
+}
diff --git a/packages/client-app/internal_packages/preferences/stylesheets/preferences-mail-rules.less b/packages/client-app/internal_packages/preferences/stylesheets/preferences-mail-rules.less
new file mode 100644
index 0000000000..c16d2276d6
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/stylesheets/preferences-mail-rules.less
@@ -0,0 +1,129 @@
+@import "ui-variables";
+
+.container-mail-rules {
+ max-width: 800px;
+ margin: 0 auto;
+
+ .empty-list {
+ height: 376px;
+ width: inherit;
+ background-color: @background-secondary;
+ border: 1px solid @border-color-divider;
+ text-align: center;
+
+ .icon-mail-rules {
+ margin-top: 80px;
+ }
+
+ h2 {
+ color: @text-color-very-subtle;
+ }
+
+ .btn {
+ margin-top: 10px;
+ }
+ }
+
+ .rule-list {
+ position: relative;
+ height: inherit;
+ width: inherit;
+
+ .items-wrapper {
+ min-width:200px;
+ height: 350px;
+ }
+ .item-rule-disabled {
+ color: @color-error;
+ padding: 4px 10px;
+ border-bottom: 1px solid @border-color-divider;
+ }
+ .selected .item-rule-disabled {
+ color: @component-active-bg;
+ }
+ .btn-editable-list {
+ height: 37px;
+ width: 37px;
+ line-height: 37px;
+ font-size: 1em;
+ }
+ }
+ .rule-detail {
+ flex: 1;
+ cursor: default;
+ background-color: @background-secondary;
+ border: 1px solid @border-color-divider;
+ border-left: 0;
+
+ .disabled-reason {
+ padding: @padding-base-vertical * 2 @padding-base-vertical * 2;
+ background-color: fade(@background-color-error, 30%);
+ border-bottom: 1px solid @background-color-error;
+ margin-bottom: @padding-base-vertical;
+ .btn {
+ margin-left:@padding-base-horizontal * 2;
+ float:right;
+ }
+ }
+ .inner {
+ padding: @padding-base-vertical @padding-base-horizontal;
+ }
+ .no-selection {
+ color: @text-color-very-subtle;
+ text-align: center;
+ padding:100px;
+ }
+
+ .well {
+ background-color: @background-primary;
+ border: 1px solid @border-color-divider;
+ margin: @padding-base-vertical 0;
+ font-size:0.9em;
+
+ .well-row {
+ padding: @padding-base-vertical @padding-base-horizontal;
+ border-bottom: 1px solid @border-color-divider;
+ select, input {
+ margin:@padding-base-vertical / 4 @padding-base-horizontal / 2;
+ &:first-child {
+ margin-left: 0;
+ }
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ select {
+ max-width:170px;
+ }
+ input {
+ width:200px;
+ }
+ .actions {
+ white-space: nowrap;
+ vertical-align: middle;
+ .btn {
+ padding: 0;
+ border-radius: 100%;
+ text-align: center;
+ margin-left:10px;
+ margin-top:1px;
+ width:24px;
+ line-height: 24px;
+ height: 24px;
+ }
+ }
+ }
+ .well-row:last-child {
+ border-bottom: none;
+ }
+ }
+ }
+ .footer {
+ border-top:1px solid @border-color-divider;
+ background-color: @background-secondary;
+ padding: @padding-base-vertical*3 @padding-base-horizontal;
+ .btn {
+ margin-right: @padding-base-horizontal;
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/preferences/stylesheets/preferences.less b/packages/client-app/internal_packages/preferences/stylesheets/preferences.less
new file mode 100644
index 0000000000..5385d5e767
--- /dev/null
+++ b/packages/client-app/internal_packages/preferences/stylesheets/preferences.less
@@ -0,0 +1,284 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+// Preferences Specific
+
+.preferences-wrap {
+ input[type=checkbox] { margin: 0 7px 0 0; position: relative; top: -1px; }
+ input[type=radio] { margin: 0 7px 0 0; position: relative; top: -1px; }
+ select { margin: 4px 0 0 8px; }
+
+ height: 100%;
+ background-color: @background-primary;
+ color: @text-color;
+
+ h6 {
+ color: @text-color-very-subtle;
+ margin-top: 20px;
+ margin-bottom: 10px;
+ }
+
+ section:first-child h6:first-child {
+ margin-top: 0;
+ }
+
+ p {
+ color: @text-color-very-subtle;
+ font-size: @font-size-smaller;
+
+ a {
+ color: @text-color-very-subtle;
+ font-weight: bold;
+ text-decoration: underline;
+ }
+ }
+ *[contenteditable] {
+ p {
+ color: @text-color;
+ font-size: @font-size-base;
+ }
+ }
+
+ section {
+ padding-bottom: @padding-base-vertical;
+ .item {
+ padding-top: @padding-small-vertical;
+ padding-bottom: @padding-small-vertical;
+ }
+ }
+
+ .container-preference-tabs {
+ width: 100%;
+ background-color: @source-list-bg;
+ border-bottom: 1px solid @border-color-divider;
+
+ .preferences-tabs {
+ padding-top: 10px;
+ background-color: @source-list-bg;
+
+ .item {
+ cursor: default;
+ margin: 0 auto;
+ flex: 1.3;
+ min-width: 54px;
+ max-width: 120px;
+ text-align: center;
+
+ &:active {
+ img {
+ -webkit-filter: brightness(40%);
+ }
+ }
+
+ &.active {
+ background: darken(@background-primary, 10%);
+ border-radius: @border-radius-large @border-radius-large 0 0;
+ box-shadow: @shadow-border;
+ }
+
+ .tab-icon {
+ padding-top: 8px;
+ padding-bottom: 8px;
+ display: block;
+ margin: auto;
+ }
+
+ .name {
+ padding: @padding-base-vertical @padding-base-horizontal * 0.3 @padding-large-vertical @padding-base-horizontal * 0.3;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: @font-size-small;
+ line-height: @font-size-small;
+ }
+ }
+ }
+ }
+
+ .preferences-content {
+ flex: 8;
+ background-color: lighten(@background-secondary, 1%);
+ &>.scroll-region-content {
+ padding: @padding-large-vertical * 3 @padding-large-horizontal * 3;
+ }
+ .container-dropdown {
+ margin: 5px 0;
+ .dropdown {
+ padding-left: 10px;
+ }
+ }
+
+ }
+
+ .container-general {
+ width: 40%;
+ min-width: 400px;
+ margin: 0 auto;
+
+ .local-data {
+ .btn {
+ margin: 4px 0 0 8px;
+ border: 1px solid @dropdown-default-border-color;
+ box-shadow: none;
+ border-radius: 5px;
+ }
+ }
+ }
+
+ .container-appearance {
+ width: 400px;
+ margin: 0 auto;
+
+ .appearance-mode-switch {
+ max-width: 400px;
+ text-align: right;
+ margin: 10px 0;
+
+ .appearance-mode {
+ background-color: @background-off-primary;;
+ border-radius: 10px;
+ border: 1px solid @background-tertiary;
+ text-align: center;
+ flex: 1;
+ padding: 25px;
+ padding-bottom: 9px;
+ margin-right: 10px;
+ margin-bottom: 7px;
+ margin-top: 0;
+ img {
+ background-color: @background-tertiary;
+ }
+ div {
+ margin-top: 15px;
+ text-transform: capitalize;
+ cursor: default;
+ }
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ .appearance-mode.active {
+ border: 1px solid @component-active-color;
+ color: @component-active-color;
+ img { background-color: @component-active-color; }
+ }
+ }
+ }
+
+ .container-keymaps {
+ width: 40%;
+ min-width: 460px;
+ margin: 0 auto;
+
+ .col-left {
+ text-align: right;
+ flex: 1;
+ margin-right: 20px;
+ }
+ .col-right {
+ text-align: left;
+ flex: 1;
+ select {
+ width: 75%;
+ }
+ }
+
+ .shortcut-section-title {
+ border-bottom: 1px solid @border-color-divider;
+ margin: @padding-large-vertical * 1.5 0;
+ }
+
+ .shortcut {
+ padding: 3px 0;
+ color: @text-color-very-subtle;
+ .values {
+ font-family: monospace;
+ font-weight: 600;
+ color: @text-color;
+ display: inline-block;
+ padding-left: @padding-small-horizontal;
+ padding-right: @padding-small-horizontal;
+ cursor: text;
+
+ .shortcut-value {
+ .then {
+ font-size:0.9em;
+ color: @text-color-very-subtle;
+ }
+ &:after {
+ content: ", "
+ }
+ &:last-child:after {
+ content: "";
+ }
+ }
+ }
+
+ &.editing {
+ .values {
+ background: @input-bg;
+ color: @component-active-color;
+
+ border-radius: @border-radius-base;
+ outline: 1px solid @input-border-color;
+ }
+ }
+
+ }
+ }
+
+ .platform-note {
+ padding: @padding-base-vertical @padding-base-horizontal;
+ background: fade(@black, 4%);
+ border-left: 3px solid @color-info;
+ margin: @padding-base-vertical 0;
+ font-size: 0.95em;
+ &:before {
+ color: @color-info;
+ font-weight: 600;
+ content: "NOTE: ";
+ }
+ }
+ .platform-linux-only {
+ display: none;
+ }
+}
+
+body.platform-win32 {
+ .preferences-wrap {
+ .well {
+ border-radius: 0;
+ }
+ .container-appearance {
+ .appearance-mode {
+ border-radius: 0;
+ }
+ }
+ }
+}
+
+body.platform-linux {
+ .preferences-wrap {
+ .platform-linux-only {
+ display: block;
+ }
+ }
+}
+
+@media (-webkit-min-device-pixel-ratio: 2) {
+ .preferences-tabs {
+ .tab-icon {
+ padding-top: 15px !important;
+ }
+ }
+}
+
+@media (max-width: 600px) {
+ .preferences-tabs .item .name {
+ display:none;
+ }
+ .preferences-wrap .preferences-content > .scroll-region-content {
+ padding-left: @padding-large-horizontal * 1;
+ padding-right: @padding-large-horizontal * 1;
+ }
+}
diff --git a/packages/client-app/internal_packages/print/assets/nylas-print-logo.png b/packages/client-app/internal_packages/print/assets/nylas-print-logo.png
new file mode 100644
index 0000000000..43d3269c5e
Binary files /dev/null and b/packages/client-app/internal_packages/print/assets/nylas-print-logo.png differ
diff --git a/packages/client-app/internal_packages/print/lib/main.es6 b/packages/client-app/internal_packages/print/lib/main.es6
new file mode 100644
index 0000000000..9a768017a2
--- /dev/null
+++ b/packages/client-app/internal_packages/print/lib/main.es6
@@ -0,0 +1,14 @@
+import Printer from './printer';
+
+let printer = null;
+export function activate() {
+ printer = new Printer();
+}
+
+export function deactivate() {
+ if (printer) printer.deactivate();
+}
+
+export function serialize() {
+
+}
diff --git a/packages/client-app/internal_packages/print/lib/print-window.es6 b/packages/client-app/internal_packages/print/lib/print-window.es6
new file mode 100644
index 0000000000..a9351038ec
--- /dev/null
+++ b/packages/client-app/internal_packages/print/lib/print-window.es6
@@ -0,0 +1,71 @@
+import path from 'path';
+import fs from 'fs';
+import {remote} from 'electron';
+
+const {app, BrowserWindow} = remote;
+
+export default class PrintWindow {
+
+ constructor({subject, account, participants, styleTags, htmlContent, printMessages}) {
+ // This script will create the print prompt when loaded. We can also call
+ // print directly from this process, but inside print.js we can make sure to
+ // call window.print() after we've cleaned up the dom for printing
+ const scriptPath = path.join(__dirname, '..', 'static', 'print.js');
+ const stylesPath = path.join(__dirname, '..', 'static', 'print-styles.css');
+ const imgPath = path.join(__dirname, '..', 'assets', 'nylas-print-logo.png');
+ const participantsHtml = participants.map((part) => {
+ return (`${part.name} <${part.email}> `);
+ }).join('');
+
+ const content = (`
+
+
+
+ ${styleTags}
+
+
+
+
+ ${htmlContent}
+
+
+
+
+ `);
+
+ this.tmpFile = path.join(app.getPath('temp'), 'print.html');
+ this.browserWin = new BrowserWindow({
+ width: 800,
+ height: 600,
+ title: `Print - ${subject}`,
+ webPreferences: {
+ nodeIntegration: false,
+ },
+ });
+ fs.writeFileSync(this.tmpFile, content);
+ }
+
+ /**
+ * Load our temp html file. Once the file is loaded it will run print.js, and
+ * that script will pop out the print dialog.
+ */
+ load() {
+ this.browserWin.loadURL(`file://${this.tmpFile}`);
+ }
+}
diff --git a/packages/client-app/internal_packages/print/lib/printer.es6 b/packages/client-app/internal_packages/print/lib/printer.es6
new file mode 100644
index 0000000000..7b0abb8e90
--- /dev/null
+++ b/packages/client-app/internal_packages/print/lib/printer.es6
@@ -0,0 +1,43 @@
+import {AccountStore, Actions} from 'nylas-exports';
+import PrintWindow from './print-window';
+
+class Printer {
+
+ constructor() {
+ this.unsub = Actions.printThread.listen(this._printThread);
+ }
+
+ _printThread(thread, htmlContent) {
+ if (!thread) throw new Error('Printing: No thread active!');
+ const account = AccountStore.accountForId(thread.accountId)
+
+ // Get the tag present in the document
+ const styleTag = document.getElementsByTagName('nylas-styles')[0];
+ // These iframes should correspond to the message iframes when a thread is
+ // focused
+ const iframes = document.getElementsByTagName('iframe');
+ // Grab the html inside the iframes
+ const messagesHtml = [].slice.call(iframes).map((iframe) => {
+ return iframe.contentDocument.documentElement.innerHTML;
+ });
+
+ const win = new PrintWindow({
+ subject: thread.subject,
+ account: {
+ name: account.name,
+ email: account.emailAddress,
+ },
+ participants: thread.participants,
+ styleTags: styleTag.innerHTML,
+ htmlContent,
+ printMessages: JSON.stringify(messagesHtml),
+ });
+ win.load();
+ }
+
+ deactivate() {
+ this.unsub();
+ }
+}
+
+export default Printer;
diff --git a/packages/client-app/internal_packages/print/package.json b/packages/client-app/internal_packages/print/package.json
new file mode 100644
index 0000000000..094adb1bf6
--- /dev/null
+++ b/packages/client-app/internal_packages/print/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "print",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Print",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "default": true,
+ "thread-popout": true
+ }
+}
diff --git a/packages/client-app/internal_packages/print/static/print-styles.css b/packages/client-app/internal_packages/print/static/print-styles.css
new file mode 100644
index 0000000000..6fe0813d72
--- /dev/null
+++ b/packages/client-app/internal_packages/print/static/print-styles.css
@@ -0,0 +1,97 @@
+body {
+ overflow: auto !important;
+}
+#message-list {
+ background: transparent;
+}
+#print-button {
+ float:right;
+ margin-left: 10px;
+
+ /* From main button styles: */
+ padding: 0 0.8em;
+ border-radius: 3px;
+ display: inline-block;
+ height: 1.9em;
+ line-height: 1.9em;
+ font-size: 13.02px;
+ cursor: default;
+ color: #231f20;
+ position: relative;
+ color: #ffffff;
+ font-weight: 500;
+ background: linear-gradient(to bottom, #6bb1f9 0%, #0a80ff 100%);
+ box-shadow: none;
+ border: 1px solid #3878fa;
+}
+#print-header {
+ padding: 15px 20px 0 20px;
+}
+#print-header img {
+ zoom: 0.5;
+}
+#print-header .logo-wrapper {
+ display: flex;
+ align-items: center;
+ font-family: "Nylas-Pro", "Helvetica", "Lucidia Grande", sans-serif !important;
+}
+#print-header h1 {
+ font-size: 1.5em !important;
+ font-family: "Nylas-Pro", "Helvetica", "Lucidia Grande", sans-serif !important;
+}
+#print-header .account {
+ margin-left: auto;
+ font-size: 0.8em !important;
+}
+#print-header .participant {
+ font-size: 0.7em;
+ font-family: "Nylas-Pro", "Helvetica", "Lucidia Grande", sans-serif !important;
+}
+
+/* Elements to hide */
+.message-subject-wrap {
+ display: none !important;
+}
+.minified-bundle,.headers,.scrollbar-track,.message-icons-wrap,.header-toggle-control {
+ display: none !important;
+}
+.message-actions-wrap {
+ display: none;
+}
+.collapsed.message-item-wrap,.draft.message-item-wrap {
+ display: none !important;
+}
+.message-item-area>div {
+ display: none !important;
+}
+.quoted-text-control, .footer-reply-area-wrap {
+ display: none;
+}
+
+@media only print {
+ body,#message-list,.message-item-wrap,.message-item-white-wrap,
+ .message-item-area,.inbox-html-wrapper {
+ display: block !important;
+ width: auto !important;
+ height: auto !important;
+ overflow: visible !important;
+ }
+ #message-list {
+ min-height: initial;
+ }
+ #print-header {
+ padding: 0;
+ }
+ #print-header .account {
+ font-size: 0.7em;
+ }
+ .message-item-wrap {
+ display: block;
+ }
+ .message-item-area>span {
+ page-break-before: avoid;
+ }
+ .message-item-area>header {
+ page-break-after: avoid;
+ }
+}
diff --git a/packages/client-app/internal_packages/print/static/print.js b/packages/client-app/internal_packages/print/static/print.js
new file mode 100644
index 0000000000..46082d04e0
--- /dev/null
+++ b/packages/client-app/internal_packages/print/static/print.js
@@ -0,0 +1,44 @@
+(function() {
+ function rebuildMessages(messageNodes, messages) {
+ // Simply insert the message html inside the appropriate node
+ for (var idx = 0; idx < messageNodes.length; idx++) {
+ var msgNode = messageNodes[idx];
+ var msgHtml = messages[idx];
+ msgNode.innerHTML = msgHtml;
+ }
+ }
+
+ function removeClassFromNodes(nodeList, className) {
+ for (var idx = 0; idx < nodeList.length; idx++) {
+ var node = nodeList[idx];
+ var re = new RegExp('\\b' + className + '\\b', 'g');
+ node.className = node.className.replace(re, '');
+ }
+ }
+
+ function removeScrollClasses() {
+ var scrollRegions = document.querySelectorAll('.scroll-region');
+ var scrollContents = document.querySelectorAll('.scroll-region-content');
+ var scrollContentInners = document.querySelectorAll('.scroll-region-content-inner');
+ removeClassFromNodes(scrollRegions, 'scroll-region');
+ removeClassFromNodes(scrollContents, 'scroll-region-content');
+ removeClassFromNodes(scrollContentInners, 'scroll-region-content-inner');
+ }
+
+ function continueAndPrint() {
+ document.getElementById('print-button').style.display = 'none';
+ window.requestAnimationFrame(function() {
+ window.print();
+ // Close this print window after selecting to print
+ // This is really hackish but appears to be the only working solution
+ setTimeout(window.close, 500);
+ });
+ }
+
+ var messageNodes = document.querySelectorAll('.message-item-area>span');
+
+ removeScrollClasses();
+ rebuildMessages(messageNodes, window.printMessages);
+
+ window.continueAndPrint = continueAndPrint;
+})();
diff --git a/packages/client-app/internal_packages/remove-tracking-pixels/lib/main.es6 b/packages/client-app/internal_packages/remove-tracking-pixels/lib/main.es6
new file mode 100644
index 0000000000..186859e997
--- /dev/null
+++ b/packages/client-app/internal_packages/remove-tracking-pixels/lib/main.es6
@@ -0,0 +1,173 @@
+/* eslint no-cond-assign: 0 */
+
+import {
+ ExtensionRegistry,
+ MessageViewExtension,
+ ComposerExtension,
+ RegExpUtils,
+} from 'nylas-exports';
+
+const TrackingBlacklist = [{
+ name: 'Sidekick',
+ pattern: 't.signaux',
+ homepage: 'http://getsidekick.com',
+}, {
+ name: 'Sidekick',
+ pattern: 't.senal',
+ homepage: 'http://getsidekick.com',
+}, {
+ name: 'Sidekick',
+ pattern: 't.sidekickopen',
+ homepage: 'http://getsidekick.com',
+}, {
+ name: 'Sidekick',
+ pattern: 't.sigopn',
+ homepage: 'http://getsidekick.com',
+}, {
+ name: 'Banana Tag',
+ pattern: 'bl-1.com',
+ homepage: 'http://bananatag.com',
+}, {
+ name: 'Boomerang',
+ pattern: 'mailstat.us/tr',
+ homepage: 'http://boomeranggmail.com',
+}, {
+ name: 'Cirrus Inisght',
+ pattern: 'tracking.cirrusinsight.com',
+ homepage: 'http://cirrusinsight.com',
+}, {
+ name: 'Yesware',
+ pattern: 'app.yesware.com',
+ homepage: 'http://yesware.com',
+}, {
+ name: 'Yesware',
+ pattern: 't.yesware.com',
+ homepage: 'http://yesware.com',
+}, {
+ name: 'Streak',
+ pattern: 'mailfoogae.appspot.com',
+ homepage: 'http://streak.com',
+}, {
+ name: 'LaunchBit',
+ pattern: 'launchbit.com/taz-pixel',
+ homepage: 'http://launchbit.com',
+}, {
+ name: 'MailChimp',
+ pattern: 'list-manage.com/track',
+ homepage: 'http://mailchimp.com',
+}, {
+ name: 'Postmark',
+ pattern: 'cmail1.com/t',
+ homepage: 'http://postmarkapp.com',
+}, {
+ name: 'iContact',
+ pattern: 'click.icptrack.com/icp/',
+ homepage: 'http://icontact.com',
+}, {
+ name: 'Infusionsoft',
+ pattern: 'infusionsoft.com/app/emailOpened',
+ homepage: 'http://infusionsoft.com',
+}, {
+ name: 'Intercom',
+ pattern: 'via.intercom.io/o',
+ homepage: 'http://intercom.io',
+}, {
+ name: 'Mandrill',
+ pattern: 'mandrillapp.com/track',
+ homepage: 'http://mandrillapp.com',
+}, {
+ name: 'Hubspot',
+ pattern: 't.hsms06.com',
+ homepage: 'http://hubspot.com',
+}, {
+ name: 'RelateIQ',
+ pattern: 'app.relateiq.com/t.png',
+ homepage: 'http://relateiq.com',
+}, {
+ name: 'RJ Metrics',
+ pattern: 'go.rjmetrics.com',
+ homepage: 'http://rjmetrics.com',
+}, {
+ name: 'Mixpanel',
+ pattern: 'api.mixpanel.com/track',
+ homepage: 'http://mixpanel.com',
+}, {
+ name: 'Front App',
+ pattern: 'web.frontapp.com/api',
+ homepage: 'http://frontapp.com',
+}, {
+ name: 'Mailtrack.io',
+ pattern: 'mailtrack.io/trace',
+ homepage: 'http://mailtrack.io',
+}, {
+ name: 'Salesloft',
+ pattern: 'sdr.salesloft.com/email_trackers',
+ homepage: 'http://salesloft.com',
+}]
+
+export function rejectImagesInBody(body, callback) {
+ const spliceRegions = [];
+ const regex = RegExpUtils.imageTagRegex();
+
+ // Identify img tags that should be cut
+ let result = null;
+ while ((result = regex.exec(body)) !== null) {
+ if (callback(result[1])) {
+ spliceRegions.push({start: result.index, end: result.index + result[0].length})
+ }
+ }
+ // Remove them all, from the end of the string to the start
+ let updated = body;
+ spliceRegions.reverse().forEach(({start, end}) => {
+ updated = updated.substr(0, start) + updated.substr(end);
+ });
+
+ return updated;
+}
+
+export function removeTrackingPixels(message) {
+ const isFromMe = message.isFromMe();
+
+ message.body = rejectImagesInBody(message.body, (imageURL) => {
+ if (isFromMe) {
+ // If the image is sent by the user, remove all forms of tracking pixels.
+ // They could be viewing an email they sent with Salesloft, etc.
+ for (const item of TrackingBlacklist) {
+ if (imageURL.indexOf(item.pattern) >= 0) {
+ return true;
+ }
+ }
+ }
+
+ // Remove Nylas read receipt pixels for the current account. If this is a
+ // reply, our read receipt could still be in the body and could trigger
+ // additional opens. (isFromMe is not sufficient!)
+ if (imageURL.indexOf(`nylas.com/open/${message.accountId}`) >= 0) {
+ return true;
+ }
+ return false;
+ });
+}
+
+class TrackingPixelsMessageExtension extends MessageViewExtension {
+ static formatMessageBody = ({message}) => {
+ removeTrackingPixels(message);
+ }
+}
+
+class TrackingPixelsComposerExtension extends ComposerExtension {
+ static prepareNewDraft = ({draft}) => {
+ removeTrackingPixels(draft);
+ }
+}
+
+
+export function activate() {
+ ExtensionRegistry.MessageView.register(TrackingPixelsMessageExtension);
+ ExtensionRegistry.Composer.register(TrackingPixelsComposerExtension);
+}
+
+export function deactivate() {
+ ExtensionRegistry.MessageView.unregister(TrackingPixelsMessageExtension);
+ ExtensionRegistry.Composer.unregister(TrackingPixelsComposerExtension);
+}
diff --git a/packages/client-app/internal_packages/remove-tracking-pixels/package.json b/packages/client-app/internal_packages/remove-tracking-pixels/package.json
new file mode 100644
index 0000000000..e267faf011
--- /dev/null
+++ b/packages/client-app/internal_packages/remove-tracking-pixels/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "remove-tracking-pixels",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "license": "GPL-3.0",
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "default": true,
+ "thread-popout": true
+ }
+}
diff --git a/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/a-after.txt b/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/a-after.txt
new file mode 100644
index 0000000000..15028cd7a3
--- /dev/null
+++ b/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/a-after.txt
@@ -0,0 +1,28 @@
+Hey Ben,
+I've noticed that we don't yet have an SLA in place with Nylas. Are you the right
+person to be speaking with to make sure everything is set up on that end? If not,
+could you please put me in touch with them, so that we can get you guys set up
+correctly as soon as possible?
Thanks!
Gleb Polyakov
Head of
+Business Development and Growth
After Pixel
+
+Sent from Nylas Mail , the extensible, open source mail client.
+ On Apr 28 2016, at 2:14 pm, Ben Gotow (Careless) <careless@foundry376.com> wrote:
+
+
+ nother mailA Sent from Nylas Mail , the extensible, open source mail client.
+ On Apr 28 2016, at 1:46 pm, Ben Gotow (Careless) <careless@foundry376.com> wrote:
+
+
+ Hi Ben this is just a test. Sent from Nylas Mail , the extensible, open source mail client.
+ On Apr 26 2016, at 6:03 pm, Ben Gotow <bengotow@gmail.com> wrote:
+
+
+ To test this, send https://www.google.com/search?q=test@example.com to yourself from a client that allows plaintext or html editing.
+ Ben Gotow -----------------------------------http://www.foundry376.com/ bengotow@gmail.com 540-250-2334
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/a-before.txt b/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/a-before.txt
new file mode 100644
index 0000000000..a5ea939db6
--- /dev/null
+++ b/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/a-before.txt
@@ -0,0 +1,28 @@
+Hey Ben,
+I've noticed that we don't yet have an SLA in place with Nylas. Are you the right
+person to be speaking with to make sure everything is set up on that end? If not,
+could you please put me in touch with them, so that we can get you guys set up
+correctly as soon as possible?
Thanks!
Gleb Polyakov
Head of
+Business Development and Growth
After Pixel
+
+Sent from Nylas Mail , the extensible, open source mail client.
+ On Apr 28 2016, at 2:14 pm, Ben Gotow (Careless) <careless@foundry376.com> wrote:
+
+
+ nother mailA Sent from Nylas Mail , the extensible, open source mail client.
+ On Apr 28 2016, at 1:46 pm, Ben Gotow (Careless) <careless@foundry376.com> wrote:
+
+
+ Hi Ben this is just a test. Sent from Nylas Mail , the extensible, open source mail client.
+ On Apr 26 2016, at 6:03 pm, Ben Gotow <bengotow@gmail.com> wrote:
+
+
+ To test this, send https://www.google.com/search?q=test@example.com to yourself from a client that allows plaintext or html editing.
+ Ben Gotow -----------------------------------http://www.foundry376.com/ bengotow@gmail.com 540-250-2334
+
+
+
+
+
+
diff --git a/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/b-after.txt b/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/b-after.txt
new file mode 100644
index 0000000000..9170d928a0
--- /dev/null
+++ b/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/b-after.txt
@@ -0,0 +1,8 @@
+Hey Ben,
+This is the reply! This tracking pixel should not be removed.
+
+
+This is the email I sent!
+
+
+
\ No newline at end of file
diff --git a/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/b-before.txt b/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/b-before.txt
new file mode 100644
index 0000000000..21254075be
--- /dev/null
+++ b/packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/b-before.txt
@@ -0,0 +1,8 @@
+Hey Ben,
+This is the reply! This tracking pixel should not be removed.
+
+
+This is the email I sent!
+
+
+
diff --git a/packages/client-app/internal_packages/remove-tracking-pixels/spec/tracking-pixels-extension-spec.es6 b/packages/client-app/internal_packages/remove-tracking-pixels/spec/tracking-pixels-extension-spec.es6
new file mode 100644
index 0000000000..01bea3e731
--- /dev/null
+++ b/packages/client-app/internal_packages/remove-tracking-pixels/spec/tracking-pixels-extension-spec.es6
@@ -0,0 +1,35 @@
+/* eslint no-irregular-whitespace: 0 */
+import fs from 'fs';
+import {removeTrackingPixels} from '../lib/main';
+
+const readFixture = (name) => {
+ return fs.readFileSync(`${__dirname}/fixtures/${name}`).toString().trim()
+}
+
+describe("TrackingPixelsExtension", function trackingPixelsExtension() {
+ it("should splice all tracking pixels from emails I've sent", () => {
+ const before = readFixture('a-before.txt');
+ const expected = readFixture('a-after.txt');
+
+ const message = {
+ body: before,
+ accountId: '1234',
+ isFromMe: () => true,
+ }
+ removeTrackingPixels(message);
+ expect(message.body).toEqual(expected);
+ });
+
+ it("should always splice Nylas read receipts for the current account id ", () => {
+ const before = readFixture('b-before.txt');
+ const expected = readFixture('b-after.txt');
+
+ const message = {
+ body: before,
+ accountId: '1234',
+ isFromMe: () => false,
+ }
+ removeTrackingPixels(message);
+ expect(message.body).toEqual(expected);
+ });
+});
diff --git a/packages/client-app/internal_packages/screenshot-mode/assets/BLOKKNeue-Regular.otf b/packages/client-app/internal_packages/screenshot-mode/assets/BLOKKNeue-Regular.otf
new file mode 100644
index 0000000000..2fa2a064d4
Binary files /dev/null and b/packages/client-app/internal_packages/screenshot-mode/assets/BLOKKNeue-Regular.otf differ
diff --git a/packages/client-app/internal_packages/screenshot-mode/assets/font-override.css b/packages/client-app/internal_packages/screenshot-mode/assets/font-override.css
new file mode 100644
index 0000000000..79cd500740
--- /dev/null
+++ b/packages/client-app/internal_packages/screenshot-mode/assets/font-override.css
@@ -0,0 +1,34 @@
+@font-face {
+ font-family: 'Nylas-Pro';
+ font-style: normal;
+ font-weight: 200;
+ src: url('nylas://screenshot-mode/assets/BLOKKNeue-Regular.otf');
+}
+
+@font-face {
+ font-family: 'Nylas-Pro';
+ font-style: normal;
+ font-weight: 300;
+ src: url('nylas://screenshot-mode/assets/BLOKKNeue-Regular.otf');
+}
+
+@font-face {
+ font-family: 'Nylas-Pro';
+ font-style: normal;
+ font-weight: 400;
+ src: url('nylas://screenshot-mode/assets/BLOKKNeue-Regular.otf');
+}
+
+@font-face {
+ font-family: 'Nylas-Pro';
+ font-style: normal;
+ font-weight: 500;
+ src: url('nylas://screenshot-mode/assets/BLOKKNeue-Regular.otf');
+}
+
+@font-face {
+ font-family: 'Nylas-Pro';
+ font-style: normal;
+ font-weight: 600;
+ src: url('nylas://screenshot-mode/assets/BLOKKNeue-Regular.otf');
+}
diff --git a/packages/client-app/internal_packages/screenshot-mode/lib/main.coffee b/packages/client-app/internal_packages/screenshot-mode/lib/main.coffee
new file mode 100644
index 0000000000..1ff6be51b6
--- /dev/null
+++ b/packages/client-app/internal_packages/screenshot-mode/lib/main.coffee
@@ -0,0 +1,21 @@
+fs = require 'fs'
+
+style = null
+
+module.exports =
+ activate: ->
+ NylasEnv.commands.add document.body, "window:toggle-screenshot-mode", ->
+ if not style
+ style = document.createElement('style')
+ style.innerText = fs.readFileSync(path.join(__dirname, '..', 'assets','font-override.css')).toString()
+
+ if style.parentElement
+ document.body.removeChild(style)
+ else
+ document.body.appendChild(style)
+
+ deactivate: ->
+ if style and style.parentElement
+ document.body.removeChild(style)
+
+ serialize: ->
diff --git a/packages/client-app/internal_packages/screenshot-mode/package.json b/packages/client-app/internal_packages/screenshot-mode/package.json
new file mode 100755
index 0000000000..83110e3ae0
--- /dev/null
+++ b/packages/client-app/internal_packages/screenshot-mode/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "screenshot-mode",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Replaces all text with blocks for taking screenshots without PII",
+ "license": "Proprietary",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "all": true
+ }
+}
diff --git a/packages/client-app/internal_packages/search-index/lib/contact-search-indexer.es6 b/packages/client-app/internal_packages/search-index/lib/contact-search-indexer.es6
new file mode 100644
index 0000000000..85af31ac0e
--- /dev/null
+++ b/packages/client-app/internal_packages/search-index/lib/contact-search-indexer.es6
@@ -0,0 +1,38 @@
+import {
+ Contact,
+ ModelSearchIndexer,
+} from 'nylas-exports';
+
+
+const INDEX_VERSION = 1;
+
+class ContactSearchIndexer extends ModelSearchIndexer {
+
+ get MaxIndexSize() {
+ return 100000;
+ }
+
+ get ModelClass() {
+ return Contact;
+ }
+
+ get ConfigKey() {
+ return "contactSearchIndexVersion";
+ }
+
+ get IndexVersion() {
+ return INDEX_VERSION;
+ }
+
+ getIndexDataForModel(contact) {
+ return {
+ content: [
+ contact.name ? contact.name : '',
+ contact.email ? contact.email : '',
+ contact.email ? contact.email.replace('@', ' ') : '',
+ ].join(' '),
+ };
+ }
+}
+
+export default new ContactSearchIndexer()
diff --git a/packages/client-app/internal_packages/search-index/lib/event-search-indexer.es6 b/packages/client-app/internal_packages/search-index/lib/event-search-indexer.es6
new file mode 100644
index 0000000000..7353311dd9
--- /dev/null
+++ b/packages/client-app/internal_packages/search-index/lib/event-search-indexer.es6
@@ -0,0 +1,37 @@
+import {Event, ModelSearchIndexer} from 'nylas-exports'
+
+
+const INDEX_VERSION = 1
+
+class EventSearchIndexer extends ModelSearchIndexer {
+
+ get MaxIndexSize() {
+ return 5000;
+ }
+
+ get ConfigKey() {
+ return 'eventSearchIndexVersion';
+ }
+
+ get IndexVersion() {
+ return INDEX_VERSION;
+ }
+
+ get ModelClass() {
+ return Event;
+ }
+
+ getIndexDataForModel(event) {
+ const {title, description, location, participants} = event
+ return {
+ title,
+ location,
+ description,
+ participants: participants
+ .map((c) => `${c.name || ''} ${c.email || ''}`)
+ .join(' '),
+ }
+ }
+}
+
+export default new EventSearchIndexer()
diff --git a/packages/client-app/internal_packages/search-index/lib/main.es6 b/packages/client-app/internal_packages/search-index/lib/main.es6
new file mode 100644
index 0000000000..1da8909d95
--- /dev/null
+++ b/packages/client-app/internal_packages/search-index/lib/main.es6
@@ -0,0 +1,17 @@
+import ThreadSearchIndexStore from './thread-search-index-store'
+import ContactSearchIndexer from './contact-search-indexer'
+// import EventSearchIndexer from './event-search-indexer'
+
+
+export function activate() {
+ ThreadSearchIndexStore.activate()
+ ContactSearchIndexer.activate()
+ // TODO Calendar feature has been punted, we will disable this indexer for now
+ // EventSearchIndexer.activate(indexer)
+}
+
+export function deactivate() {
+ ThreadSearchIndexStore.deactivate()
+ ContactSearchIndexer.deactivate()
+ // EventSearchIndexer.deactivate()
+}
diff --git a/packages/client-app/internal_packages/search-index/lib/thread-search-index-store.es6 b/packages/client-app/internal_packages/search-index/lib/thread-search-index-store.es6
new file mode 100644
index 0000000000..132946a1ae
--- /dev/null
+++ b/packages/client-app/internal_packages/search-index/lib/thread-search-index-store.es6
@@ -0,0 +1,229 @@
+import _ from 'underscore'
+import {
+ Utils,
+ Thread,
+ AccountStore,
+ DatabaseStore,
+ SearchIndexScheduler,
+} from 'nylas-exports'
+
+const MAX_INDEX_SIZE = 100000
+const MESSAGE_BODY_LENGTH = 50000
+const INDEX_VERSION = 2
+
+class ThreadSearchIndexStore {
+
+ constructor() {
+ this.unsubscribers = []
+ this.indexer = SearchIndexScheduler;
+ this.threadsWaitingToBeIndexed = new Set();
+ }
+
+ activate() {
+ this.indexer.registerSearchableModel({
+ modelClass: Thread,
+ indexSize: MAX_INDEX_SIZE,
+ indexCallback: (model) => this.updateThreadIndex(model),
+ unindexCallback: (model) => this.unindexThread(model),
+ });
+
+ const date = Date.now();
+ console.log('Thread Search: Initializing thread search index...')
+
+ this.accountIds = _.pluck(AccountStore.accounts(), 'id')
+ this.initializeIndex()
+ .then(() => {
+ NylasEnv.config.set('threadSearchIndexVersion', INDEX_VERSION)
+ return Promise.resolve()
+ })
+ .then(() => {
+ console.log(`Thread Search: Index built successfully in ${((Date.now() - date) / 1000)}s`)
+ this.unsubscribers = [
+ AccountStore.listen(this.onAccountsChanged),
+ DatabaseStore.listen(this.onDataChanged),
+ ]
+ })
+ }
+
+ _isInvalidSize(size) {
+ return !size || size > MAX_INDEX_SIZE || size === 0;
+ }
+
+ /**
+ * We only want to build the entire index if:
+ * - It doesn't exist yet
+ * - It is too big
+ * - We bumped the index version
+ *
+ * Otherwise, we just want to index accounts that haven't been indexed yet.
+ * An account may not have been indexed if it is added and the app is closed
+ * before sync completes
+ */
+ initializeIndex() {
+ if (NylasEnv.config.get('threadSearchIndexVersion') !== INDEX_VERSION) {
+ return this.clearIndex()
+ .then(() => this.buildIndex(this.accountIds))
+ }
+
+ return this.buildIndex(this.accountIds);
+ }
+
+ /**
+ * When accounts change, we are only interested in knowing if an account has
+ * been added or removed
+ *
+ * - If an account has been added, we want to index its threads, but wait
+ * until that account has been successfully synced
+ *
+ * - If an account has been removed, we want to remove its threads from the
+ * index
+ *
+ * If the application is closed before sync is completed, the new account will
+ * be indexed via `initializeIndex`
+ */
+ onAccountsChanged = () => {
+ _.defer(() => {
+ const latestIds = _.pluck(AccountStore.accounts(), 'id')
+ if (_.isEqual(this.accountIds, latestIds)) {
+ return;
+ }
+ const date = Date.now()
+ console.log(`Thread Search: Updating thread search index for accounts ${latestIds}`)
+
+ const newIds = _.difference(latestIds, this.accountIds)
+ const removedIds = _.difference(this.accountIds, latestIds)
+ const promises = []
+ if (newIds.length > 0) {
+ promises.push(this.buildIndex(newIds))
+ }
+
+ if (removedIds.length > 0) {
+ promises.push(
+ Promise.all(removedIds.map(id => DatabaseStore.unindexModelsForAccount(id, Thread)))
+ )
+ }
+ this.accountIds = latestIds
+ Promise.all(promises)
+ .then(() => {
+ console.log(`Thread Search: Index updated successfully in ${((Date.now() - date) / 1000)}s`)
+ })
+ })
+ }
+
+ /**
+ * When a thread gets updated we will update the search index with the data
+ * from that thread if the account it belongs to is not being currently
+ * synced.
+ *
+ * When the account is successfully synced, its threads will be added to the
+ * index either via `onAccountsChanged` or via `initializeIndex` when the app
+ * starts
+ */
+ onDataChanged = (change) => {
+ if (change.objectClass !== Thread.name) {
+ return;
+ }
+ _.defer(async () => {
+ const {objects, type} = change
+ const threads = objects;
+
+ let promises = []
+ if (type === 'persist') {
+ const threadsToIndex = _.uniq(threads.filter(t => !this.threadsWaitingToBeIndexed.has(t.id)), false /* isSorted */, t => t.id);
+ const threadsIndexed = threads.filter(t => t.isSearchIndexed && this.threadsWaitingToBeIndexed.has(t.id));
+
+ for (const thread of threadsIndexed) {
+ this.threadsWaitingToBeIndexed.delete(thread.id);
+ }
+
+ if (threadsToIndex.length > 0) {
+ threadsToIndex.forEach(thread => {
+ // Mark already indexed threads as unindexed so that we re-index them
+ // with updates
+ thread.isSearchIndexed = false;
+ this.threadsWaitingToBeIndexed.add(thread.id);
+ })
+ await DatabaseStore.inTransaction(t => t.persistModels(threadsToIndex, {silent: true, affectsJoins: false}));
+ this.indexer.notifyHasIndexingToDo();
+ }
+ } else if (type === 'unpersist') {
+ promises = threads.map(thread => this.unindexThread(thread,
+ {isBeingUnpersisted: true}))
+ }
+ Promise.all(promises)
+ })
+ }
+
+ buildIndex = (accountIds) => {
+ if (!accountIds || accountIds.length === 0) { return Promise.resolve() }
+ this.indexer.notifyHasIndexingToDo();
+ return Promise.resolve()
+ }
+
+ clearIndex() {
+ return (
+ DatabaseStore.dropSearchIndex(Thread)
+ .then(() => DatabaseStore.createSearchIndex(Thread))
+ )
+ }
+
+ indexThread = (thread) => {
+ return (
+ this.getIndexData(thread)
+ .then((indexData) => (
+ DatabaseStore.indexModel(thread, indexData)
+ ))
+ )
+ }
+
+ updateThreadIndex = (thread) => {
+ return (
+ this.getIndexData(thread)
+ .then((indexData) => (
+ DatabaseStore.updateModelIndex(thread, indexData)
+ ))
+ )
+ }
+
+ unindexThread = (thread, opts) => {
+ return DatabaseStore.unindexModel(thread, opts)
+ }
+
+ getIndexData(thread) {
+ return thread.messages().then((messages) => {
+ return {
+ bodies: messages
+ .map(({body, snippet}) => (!_.isString(body) ? {snippet} : {body}))
+ .map(({body, snippet}) => (
+ snippet || Utils.extractTextFromHtml(body, {maxLength: MESSAGE_BODY_LENGTH}).replace(/(\s)+/g, ' ')
+ )).join(' '),
+ to: messages.map(({to, cc, bcc}) => (
+ _.uniq(to.concat(cc).concat(bcc).map(({name, email}) => `${name} ${email}`))
+ )).join(' '),
+ from: messages.map(({from}) => (
+ from.map(({name, email}) => `${name} ${email}`)
+ )).join(' '),
+ };
+ }).then(({bodies, to, from}) => {
+ const categories = (
+ thread.categories
+ .map(({displayName}) => displayName)
+ .join(' ')
+ )
+
+ return {
+ categories: categories,
+ to_: to,
+ from_: from,
+ body: bodies,
+ subject: thread.subject,
+ };
+ });
+ }
+
+ deactivate() {
+ this.unsubscribers.forEach(unsub => unsub())
+ }
+}
+
+export default new ThreadSearchIndexStore()
diff --git a/packages/client-app/internal_packages/search-index/package.json b/packages/client-app/internal_packages/search-index/package.json
new file mode 100644
index 0000000000..35c1a84ca4
--- /dev/null
+++ b/packages/client-app/internal_packages/search-index/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "search-index",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Keeps search index up to date",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "work": true
+ }
+}
diff --git a/packages/client-app/internal_packages/send-and-archive/images/composer-archive@2x.png b/packages/client-app/internal_packages/send-and-archive/images/composer-archive@2x.png
new file mode 100644
index 0000000000..f6a66f97af
Binary files /dev/null and b/packages/client-app/internal_packages/send-and-archive/images/composer-archive@2x.png differ
diff --git a/packages/client-app/internal_packages/send-and-archive/lib/main.es6 b/packages/client-app/internal_packages/send-and-archive/lib/main.es6
new file mode 100644
index 0000000000..7938ed75c3
--- /dev/null
+++ b/packages/client-app/internal_packages/send-and-archive/lib/main.es6
@@ -0,0 +1,11 @@
+import {ExtensionRegistry} from 'nylas-exports'
+import * as SendAndArchiveExtension from './send-and-archive-extension'
+
+
+export function activate() {
+ ExtensionRegistry.Composer.register(SendAndArchiveExtension)
+}
+
+export function deactivate() {
+ ExtensionRegistry.Composer.unregister(SendAndArchiveExtension)
+}
diff --git a/packages/client-app/internal_packages/send-and-archive/lib/send-and-archive-extension.es6 b/packages/client-app/internal_packages/send-and-archive/lib/send-and-archive-extension.es6
new file mode 100644
index 0000000000..07b4e9f6fd
--- /dev/null
+++ b/packages/client-app/internal_packages/send-and-archive/lib/send-and-archive-extension.es6
@@ -0,0 +1,30 @@
+import {
+ Actions,
+ Thread,
+ DatabaseStore,
+ TaskFactory,
+ SendDraftTask,
+} from 'nylas-exports'
+
+
+export const name = 'SendAndArchiveExtension'
+
+export function sendActions() {
+ return [{
+ title: 'Send and Archive',
+ iconUrl: 'nylas://send-and-archive/images/composer-archive@2x.png',
+ isAvailableForDraft({draft}) {
+ return draft.threadId != null
+ },
+ performSendAction({draft}) {
+ Actions.queueTask(new SendDraftTask(draft.clientId))
+ return DatabaseStore.modelify(Thread, [draft.threadId])
+ .then((threads) => {
+ Actions.archiveThreads({
+ source: "Send and Archive",
+ threads: threads,
+ })
+ })
+ },
+ }]
+}
diff --git a/packages/client-app/internal_packages/send-and-archive/package.json b/packages/client-app/internal_packages/send-and-archive/package.json
new file mode 100755
index 0000000000..201c0149dd
--- /dev/null
+++ b/packages/client-app/internal_packages/send-and-archive/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "send-and-archive",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Adds a send and archive option to the composer.",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "default": true,
+ "composer": true,
+ "thread-popout": true,
+ "work": true
+ }
+}
diff --git a/packages/client-app/internal_packages/send-and-archive/spec/send-and-archive-spec.coffee b/packages/client-app/internal_packages/send-and-archive/spec/send-and-archive-spec.coffee
new file mode 100644
index 0000000000..8b08590dd0
--- /dev/null
+++ b/packages/client-app/internal_packages/send-and-archive/spec/send-and-archive-spec.coffee
@@ -0,0 +1 @@
+describe "SendAndArchive", ->
diff --git a/packages/client-app/internal_packages/send-and-archive/styles/send-and-archive.less b/packages/client-app/internal_packages/send-and-archive/styles/send-and-archive.less
new file mode 100644
index 0000000000..7d57ba587b
--- /dev/null
+++ b/packages/client-app/internal_packages/send-and-archive/styles/send-and-archive.less
@@ -0,0 +1,2 @@
+.send-and-archive {
+}
diff --git a/packages/client-app/internal_packages/sync-health-checker/lib/main.es6 b/packages/client-app/internal_packages/sync-health-checker/lib/main.es6
new file mode 100644
index 0000000000..e7ca045d19
--- /dev/null
+++ b/packages/client-app/internal_packages/sync-health-checker/lib/main.es6
@@ -0,0 +1,9 @@
+import SyncHealthChecker from './sync-health-checker'
+
+export function activate() {
+ SyncHealthChecker.start()
+}
+
+export function deactivate() {
+ SyncHealthChecker.stop()
+}
diff --git a/packages/client-app/internal_packages/sync-health-checker/lib/sync-health-checker.es6 b/packages/client-app/internal_packages/sync-health-checker/lib/sync-health-checker.es6
new file mode 100644
index 0000000000..f152232b44
--- /dev/null
+++ b/packages/client-app/internal_packages/sync-health-checker/lib/sync-health-checker.es6
@@ -0,0 +1,114 @@
+import {ipcRenderer} from 'electron'
+import {IdentityStore, AccountStore, Actions, NylasAPI, NylasAPIRequest} from 'nylas-exports'
+
+const CHECK_HEALTH_INTERVAL = 5 * 60 * 1000;
+
+class SyncHealthChecker {
+ constructor() {
+ this._lastSyncActivity = null
+ this._interval = null
+ }
+
+ start() {
+ if (this._interval) {
+ console.warn('SyncHealthChecker has already been started')
+ } else {
+ this._interval = setInterval(this._checkSyncHealth, CHECK_HEALTH_INTERVAL)
+ }
+ }
+
+ stop() {
+ clearInterval(this._interval)
+ this._interval = null
+ }
+
+ // This is a separate function so the request can be manipulated in the specs
+ _buildRequest = () => {
+ return new NylasAPIRequest({
+ api: NylasAPI,
+ options: {
+ accountId: AccountStore.accounts()[0].id,
+ path: `/health`,
+ },
+ });
+ }
+
+ _checkSyncHealth = async () => {
+ try {
+ if (!IdentityStore.identity()) {
+ return
+ }
+ const request = this._buildRequest()
+ const response = await request.run()
+ this._lastSyncActivity = response
+ } catch (err) {
+ if (/ECONNREFUSED/i.test(err.toString())) {
+ this._onWorkerWindowUnavailable()
+ } else {
+ err.message = `Error checking sync health: ${err.message}`
+ NylasEnv.reportError(err)
+ }
+ }
+ }
+
+ _onWorkerWindowUnavailable() {
+ let extraData = {};
+
+ // Extract data that we want to report. We'll report the entire
+ // _lastSyncActivity object, but it'll probably be useful if we can segment
+ // by the data in the oldest or newest entry, so we report those as
+ // individual values too.
+ const lastActivityEntries = Object.entries(this._lastSyncActivity || {})
+ if (lastActivityEntries.length > 0) {
+ const times = lastActivityEntries.map((entry) => entry[1].time)
+ const now = Date.now()
+
+ const maxTime = Math.max(...times)
+ const mostRecentEntry = lastActivityEntries.find((entry) => entry[1].time === maxTime)
+ const [mostRecentActivityAccountId, {
+ activity: mostRecentActivity,
+ time: mostRecentActivityTime,
+ }] = mostRecentEntry;
+ const mostRecentDuration = now - mostRecentActivityTime
+
+ const minTime = Math.min(...times)
+ const leastRecentEntry = lastActivityEntries.find((entry) => entry[1].time === minTime)
+ const [leastRecentActivityAccountId, {
+ activity: leastRecentActivity,
+ time: leastRecentActivityTime,
+ }] = leastRecentEntry;
+ const leastRecentDuration = now - leastRecentActivityTime
+
+ extraData = {
+ mostRecentActivity,
+ mostRecentActivityTime,
+ mostRecentActivityAccountId,
+ mostRecentDuration,
+ leastRecentActivity,
+ leastRecentActivityTime,
+ leastRecentActivityAccountId,
+ leastRecentDuration,
+ }
+ }
+
+ NylasEnv.reportError(new Error('Worker window was unavailable'), {
+ // This information isn't as useful in Sentry, but include it here until
+ // the data is actually sent to Mixpanel. (See the TODO below)
+ lastActivityPerAccount: this._lastSyncActivity,
+ ...extraData,
+ })
+
+ // TODO: This doesn't make it to Mixpanel because our analytics process
+ // lives in the worker window. We should move analytics to the main process.
+ // https://phab.nylas.com/T8029
+ Actions.recordUserEvent('Worker Window Unavailable', {
+ lastActivityPerAccount: this._lastSyncActivity,
+ ...extraData,
+ })
+
+ console.log(`Detected worker window was unavailable. Restarting it.`, this._lastSyncActivity)
+ ipcRenderer.send('ensure-worker-window')
+ }
+}
+
+export default new SyncHealthChecker()
diff --git a/packages/client-app/internal_packages/sync-health-checker/package.json b/packages/client-app/internal_packages/sync-health-checker/package.json
new file mode 100644
index 0000000000..f4682568bd
--- /dev/null
+++ b/packages/client-app/internal_packages/sync-health-checker/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "sync-health-checker",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Periodically ping the sync process to ensure it's running",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/sync-health-checker/spec/sync-health-checker-spec.es6 b/packages/client-app/internal_packages/sync-health-checker/spec/sync-health-checker-spec.es6
new file mode 100644
index 0000000000..09dfb581a1
--- /dev/null
+++ b/packages/client-app/internal_packages/sync-health-checker/spec/sync-health-checker-spec.es6
@@ -0,0 +1,44 @@
+import {ipcRenderer} from 'electron'
+import SyncHealthChecker from '../lib/sync-health-checker'
+
+const requestWithErrorResponse = () => {
+ return {
+ run: async () => {
+ throw new Error('ECONNREFUSED');
+ },
+ }
+}
+
+const activityData = {account1: {time: 1490305104619, activity: ['activity']}}
+
+const requestWithDataResponse = () => {
+ return {
+ run: async () => {
+ return activityData
+ },
+ }
+}
+
+describe('SyncHealthChecker', () => {
+ describe('when the worker window is not available', () => {
+ beforeEach(() => {
+ spyOn(SyncHealthChecker, '_buildRequest').andCallFake(requestWithErrorResponse)
+ spyOn(ipcRenderer, 'send')
+ spyOn(NylasEnv, 'reportError')
+ })
+ it('attempts to restart it', async () => {
+ await SyncHealthChecker._checkSyncHealth();
+ expect(NylasEnv.reportError.calls.length).toEqual(1)
+ expect(ipcRenderer.send.calls[0].args[0]).toEqual('ensure-worker-window')
+ })
+ })
+ describe('when data is returned', () => {
+ beforeEach(() => {
+ spyOn(SyncHealthChecker, '_buildRequest').andCallFake(requestWithDataResponse)
+ })
+ it('stores the data', async () => {
+ await SyncHealthChecker._checkSyncHealth();
+ expect(SyncHealthChecker._lastSyncActivity).toEqual(activityData)
+ })
+ })
+})
diff --git a/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full-NewItems@1x.png b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full-NewItems@1x.png
new file mode 100644
index 0000000000..fe7fc2cd57
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full-NewItems@1x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full-NewItems@2x.png b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full-NewItems@2x.png
new file mode 100644
index 0000000000..ef3f094a38
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full-NewItems@2x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full@1x.png b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full@1x.png
new file mode 100644
index 0000000000..b0c649a071
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full@1x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full@2x.png b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full@2x.png
new file mode 100644
index 0000000000..763fa718a2
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Full@2x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Zero@1x.png b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Zero@1x.png
new file mode 100644
index 0000000000..05dfc6a763
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Zero@1x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Zero@2x.png b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Zero@2x.png
new file mode 100644
index 0000000000..0099752c6a
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/darwin/MenuItem-Inbox-Zero@2x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/linux/MenuItem-Inbox-Full-NewItems@2x.png b/packages/client-app/internal_packages/system-tray/assets/linux/MenuItem-Inbox-Full-NewItems@2x.png
new file mode 100644
index 0000000000..53efe1f6ef
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/linux/MenuItem-Inbox-Full-NewItems@2x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/linux/MenuItem-Inbox-Full@2x.png b/packages/client-app/internal_packages/system-tray/assets/linux/MenuItem-Inbox-Full@2x.png
new file mode 100644
index 0000000000..9d73b5f2de
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/linux/MenuItem-Inbox-Full@2x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/linux/MenuItem-Inbox-Zero@2x.png b/packages/client-app/internal_packages/system-tray/assets/linux/MenuItem-Inbox-Zero@2x.png
new file mode 100644
index 0000000000..9d73b5f2de
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/linux/MenuItem-Inbox-Zero@2x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/win32/MenuItem-Inbox-Full-NewItems@2x.png b/packages/client-app/internal_packages/system-tray/assets/win32/MenuItem-Inbox-Full-NewItems@2x.png
new file mode 100644
index 0000000000..d8511905bd
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/win32/MenuItem-Inbox-Full-NewItems@2x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/win32/MenuItem-Inbox-Full@2x.png b/packages/client-app/internal_packages/system-tray/assets/win32/MenuItem-Inbox-Full@2x.png
new file mode 100644
index 0000000000..725b50036e
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/win32/MenuItem-Inbox-Full@2x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/assets/win32/MenuItem-Inbox-Zero@2x.png b/packages/client-app/internal_packages/system-tray/assets/win32/MenuItem-Inbox-Zero@2x.png
new file mode 100644
index 0000000000..725b50036e
Binary files /dev/null and b/packages/client-app/internal_packages/system-tray/assets/win32/MenuItem-Inbox-Zero@2x.png differ
diff --git a/packages/client-app/internal_packages/system-tray/lib/main.es6 b/packages/client-app/internal_packages/system-tray/lib/main.es6
new file mode 100644
index 0000000000..04a5c07242
--- /dev/null
+++ b/packages/client-app/internal_packages/system-tray/lib/main.es6
@@ -0,0 +1,14 @@
+import SystemTrayIconStore from './system-tray-icon-store';
+
+export function activate() {
+ this.store = new SystemTrayIconStore();
+ this.store.activate();
+}
+
+export function deactivate() {
+ this.store.deactivate();
+}
+
+export function serialize() {
+
+}
diff --git a/packages/client-app/internal_packages/system-tray/lib/system-tray-icon-store.es6 b/packages/client-app/internal_packages/system-tray/lib/system-tray-icon-store.es6
new file mode 100644
index 0000000000..f730feb3c0
--- /dev/null
+++ b/packages/client-app/internal_packages/system-tray/lib/system-tray-icon-store.es6
@@ -0,0 +1,70 @@
+import path from 'path';
+import {ipcRenderer} from 'electron';
+import {BadgeStore} from 'nylas-exports';
+
+// Must be absolute real system path
+// https://github.com/atom/electron/issues/1299
+const {platform} = process
+const INBOX_ZERO_ICON = path.join(__dirname, '..', 'assets', platform, 'MenuItem-Inbox-Zero.png');
+const INBOX_UNREAD_ICON = path.join(__dirname, '..', 'assets', platform, 'MenuItem-Inbox-Full.png');
+const INBOX_UNREAD_ALT_ICON = path.join(__dirname, '..', 'assets', platform, 'MenuItem-Inbox-Full-NewItems.png');
+
+
+class SystemTrayIconStore {
+
+ static INBOX_ZERO_ICON = INBOX_ZERO_ICON;
+
+ static INBOX_UNREAD_ICON = INBOX_UNREAD_ICON;
+
+ static INBOX_UNREAD_ALT_ICON = INBOX_UNREAD_ALT_ICON;
+
+ constructor() {
+ this._windowBlurred = false;
+ this._unsubscribers = [];
+ }
+
+ activate() {
+ this._updateIcon();
+ this._unsubscribers.push(BadgeStore.listen(this._updateIcon));
+
+ window.addEventListener('browser-window-blur', this._onWindowBlur);
+ window.addEventListener('browser-window-focus', this._onWindowFocus);
+ this._unsubscribers.push(() => window.removeEventListener('browser-window-blur', this._onWindowBlur))
+ this._unsubscribers.push(() => window.removeEventListener('browser-window-focus', this._onWindowFocus))
+ }
+
+ _getIconImageData(isInboxZero, isWindowBlurred) {
+ if (isInboxZero) {
+ return {iconPath: INBOX_ZERO_ICON, isTemplateImg: true};
+ }
+ return isWindowBlurred ?
+ {iconPath: INBOX_UNREAD_ALT_ICON, isTemplateImg: false} :
+ {iconPath: INBOX_UNREAD_ICON, isTemplateImg: true};
+ }
+
+ _onWindowBlur = () => {
+ // Set state to blurred, but don't trigger a change. The icon should only be
+ // updated when the count changes
+ this._windowBlurred = true;
+ };
+
+ _onWindowFocus = () => {
+ // Make sure that as long as the window is focused we never use the alt icon
+ this._windowBlurred = false;
+ this._updateIcon();
+ };
+
+ _updateIcon = () => {
+ const unread = BadgeStore.unread();
+ const unreadString = (+unread).toLocaleString();
+ const isInboxZero = (BadgeStore.total() === 0);
+ const {iconPath, isTemplateImg} = this._getIconImageData(isInboxZero, this._windowBlurred);
+ ipcRenderer.send('update-system-tray', iconPath, unreadString, isTemplateImg);
+ };
+
+ deactivate() {
+ this._unsubscribers.forEach(unsub => unsub())
+ }
+}
+
+export default SystemTrayIconStore;
diff --git a/packages/client-app/internal_packages/system-tray/package.json b/packages/client-app/internal_packages/system-tray/package.json
new file mode 100644
index 0000000000..74b667744a
--- /dev/null
+++ b/packages/client-app/internal_packages/system-tray/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "system-tray",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Displays cross-platform system tray icon with unread count",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/system-tray/spec/system-tray-icon-store-spec.es6 b/packages/client-app/internal_packages/system-tray/spec/system-tray-icon-store-spec.es6
new file mode 100644
index 0000000000..fe891d35f3
--- /dev/null
+++ b/packages/client-app/internal_packages/system-tray/spec/system-tray-icon-store-spec.es6
@@ -0,0 +1,81 @@
+import {ipcRenderer} from 'electron';
+import {BadgeStore} from 'nylas-exports';
+import SystemTrayIconStore from '../lib/system-tray-icon-store';
+
+const {
+ INBOX_ZERO_ICON,
+ INBOX_UNREAD_ICON,
+ INBOX_UNREAD_ALT_ICON,
+} = SystemTrayIconStore;
+
+
+describe('SystemTrayIconStore', function systemTrayIconStore() {
+ beforeEach(() => {
+ spyOn(ipcRenderer, 'send')
+ this.iconStore = new SystemTrayIconStore()
+ });
+
+ function getCallData() {
+ const {args} = ipcRenderer.send.calls[0]
+ return {iconPath: args[1], isTemplateImg: args[3]}
+ }
+
+ describe('_getIconImageData', () => {
+ it('shows inbox zero icon when isInboxZero and window is focused', () => {
+ const {iconPath, isTemplateImg} = this.iconStore._getIconImageData(true, false)
+ expect(iconPath).toBe(INBOX_ZERO_ICON)
+ expect(isTemplateImg).toBe(true)
+ });
+
+ it('shows inbox zero icon when isInboxZero and window is blurred', () => {
+ const {iconPath, isTemplateImg} = this.iconStore._getIconImageData(true, true)
+ expect(iconPath).toBe(INBOX_ZERO_ICON)
+ expect(isTemplateImg).toBe(true)
+ });
+
+ it('shows inbox full icon when not isInboxZero and window is focused', () => {
+ const {iconPath, isTemplateImg} = this.iconStore._getIconImageData(false, false)
+ expect(iconPath).toBe(INBOX_UNREAD_ICON)
+ expect(isTemplateImg).toBe(true)
+ });
+
+ it('shows inbox full /alt/ icon when not isInboxZero and window is blurred', () => {
+ const {iconPath, isTemplateImg} = this.iconStore._getIconImageData(false, true)
+ expect(iconPath).toBe(INBOX_UNREAD_ALT_ICON)
+ expect(isTemplateImg).toBe(false)
+ });
+ });
+
+ describe('updating the icon based on focus and blur', () => {
+ it('always shows inbox full icon when the window gets focused', () => {
+ spyOn(BadgeStore, 'total').andReturn(1)
+ this.iconStore._onWindowFocus()
+ const {iconPath} = getCallData()
+ expect(iconPath).toBe(INBOX_UNREAD_ICON)
+ });
+
+ it('shows inbox full /alt/ icon ONLY when window is currently blurred and total count changes', () => {
+ this.iconStore._windowBlurred = false
+ this.iconStore._onWindowBlur()
+ expect(ipcRenderer.send).not.toHaveBeenCalled()
+
+ // BadgeStore triggers a change
+ spyOn(BadgeStore, 'total').andReturn(1)
+ this.iconStore._updateIcon()
+
+ const {iconPath} = getCallData()
+ expect(iconPath).toBe(INBOX_UNREAD_ALT_ICON)
+ });
+
+ it('does not show inbox full /alt/ icon when window is currently focused and total count changes', () => {
+ this.iconStore._windowBlurred = false
+
+ // BadgeStore triggers a change
+ spyOn(BadgeStore, 'total').andReturn(1)
+ this.iconStore._updateIcon()
+
+ const {iconPath} = getCallData()
+ expect(iconPath).toBe(INBOX_UNREAD_ICON)
+ });
+ });
+});
diff --git a/packages/client-app/internal_packages/theme-picker/lib/main.jsx b/packages/client-app/internal_packages/theme-picker/lib/main.jsx
new file mode 100644
index 0000000000..08c567a2bc
--- /dev/null
+++ b/packages/client-app/internal_packages/theme-picker/lib/main.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import {Actions, WorkspaceStore} from 'nylas-exports';
+
+import ThemePicker from './theme-picker';
+
+
+export function activate() {
+ this.disposable = NylasEnv.commands.add(document.body, "window:launch-theme-picker", () => {
+ WorkspaceStore.popToRootSheet();
+ Actions.openModal({
+ component: ( ),
+ height: 390,
+ width: 250,
+ });
+ });
+}
+
+export function deactivate() {
+ this.disposable.dispose();
+}
diff --git a/packages/client-app/internal_packages/theme-picker/lib/theme-option.jsx b/packages/client-app/internal_packages/theme-picker/lib/theme-option.jsx
new file mode 100644
index 0000000000..f42aa8ec3a
--- /dev/null
+++ b/packages/client-app/internal_packages/theme-picker/lib/theme-option.jsx
@@ -0,0 +1,112 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import fs from 'fs-plus';
+import path from 'path';
+
+import {EventedIFrame} from 'nylas-component-kit';
+import LessCompileCache from '../../../src/less-compile-cache'
+
+
+class ThemeOption extends React.Component {
+ static propTypes = {
+ theme: React.PropTypes.object.isRequired,
+ active: React.PropTypes.bool.isRequired,
+ onSelect: React.PropTypes.func.isRequired,
+ }
+
+ constructor(props) {
+ super(props);
+ this.lessCache = null;
+ }
+
+ componentDidMount() {
+ this._writeContent();
+ }
+
+ _getImportPaths() {
+ const themes = [this.props.theme];
+ // Pulls the theme package for Light as the base theme
+ for (const theme of NylasEnv.themes.getActiveThemes()) {
+ if (theme.name === NylasEnv.themes.baseThemeName()) {
+ themes.push(theme);
+ }
+ }
+ const themePaths = [];
+ for (const theme of themes) {
+ themePaths.push(theme.getStylesheetsPath());
+ }
+ return themePaths.filter((themePath) => fs.isDirectorySync(themePath));
+ }
+
+ _loadStylesheet(stylesheetPath) {
+ if (path.extname(stylesheetPath) === '.less') {
+ return this._loadLessStylesheet(stylesheetPath);
+ }
+ return fs.readFileSync(stylesheetPath, 'utf8');
+ }
+
+ _loadLessStylesheet(lessStylesheetPath) {
+ const {configDirPath, resourcePath} = NylasEnv.getLoadSettings();
+ if (this.lessCache) {
+ this.lessCache.setImportPaths(this._getImportPaths());
+ } else {
+ const importPaths = this._getImportPaths();
+ this.lessCache = new LessCompileCache({configDirPath, resourcePath, importPaths});
+ }
+ const themeVarPath = path.relative(`${resourcePath}/internal_packages/theme-picker/preview-styles`,
+ this.props.theme.getStylesheetsPath());
+ let varImports = `@import "../../../static/variables/ui-variables";`
+ if (fs.existsSync(`${this.props.theme.getStylesheetsPath()}/ui-variables.less`)) {
+ varImports += `@import "${themeVarPath}/ui-variables";`
+ }
+ if (fs.existsSync(`${this.props.theme.getStylesheetsPath()}/theme-colors.less`)) {
+ varImports += `@import "${themeVarPath}/theme-colors";`
+ }
+ const less = fs.readFileSync(lessStylesheetPath, 'utf8');
+ return this.lessCache.cssForFile(lessStylesheetPath, [varImports, less].join('\n'));
+ }
+
+ _writeContent() {
+ const domNode = ReactDOM.findDOMNode(this.refs.iframe);
+ const doc = domNode.contentDocument;
+ if (!doc) return;
+
+ const {resourcePath} = NylasEnv.getLoadSettings();
+ const css = ``
+ const html = `
+ ${css}
+
+
+
${this.props.theme.displayName}
+
+
+
+
+
+ `
+
+ doc.open();
+ doc.write(html);
+ doc.close();
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+
+export default ThemeOption;
diff --git a/packages/client-app/internal_packages/theme-picker/lib/theme-picker.jsx b/packages/client-app/internal_packages/theme-picker/lib/theme-picker.jsx
new file mode 100644
index 0000000000..bbc26aa79b
--- /dev/null
+++ b/packages/client-app/internal_packages/theme-picker/lib/theme-picker.jsx
@@ -0,0 +1,94 @@
+/* eslint jsx-a11y/tabindex-no-positive: 0 */
+import React from 'react';
+
+import {Flexbox, ScrollRegion} from 'nylas-component-kit';
+import ThemeOption from './theme-option';
+
+
+class ThemePicker extends React.Component {
+ static displayName = 'ThemePicker';
+
+ constructor(props) {
+ super(props);
+ this.themes = NylasEnv.themes;
+ this.state = this._getState();
+ }
+
+ componentDidMount() {
+ this.disposable = this.themes.onDidChangeActiveThemes(() => {
+ this.setState(this._getState());
+ });
+ }
+
+ componentWillUnmount() {
+ this.disposable.dispose();
+ }
+
+ _getState() {
+ return {
+ themes: this.themes.getLoadedThemes(),
+ activeTheme: this.themes.getActiveTheme().name,
+ }
+ }
+
+ _setActiveTheme(theme) {
+ const prevActiveTheme = this.state.activeTheme;
+ this.themes.setActiveTheme(theme);
+ this._rewriteIFrame(prevActiveTheme, theme);
+ }
+
+ _rewriteIFrame(prevActiveTheme, activeTheme) {
+ const prevActiveThemeDoc = document.querySelector(`.theme-preview-${prevActiveTheme.replace(/\./g, '-')}`).contentDocument;
+ const prevActiveElement = prevActiveThemeDoc.querySelector(".theme-option.active-true");
+ if (prevActiveElement) prevActiveElement.className = "theme-option active-false";
+ const activeThemeDoc = document.querySelector(`.theme-preview-${activeTheme.replace(/\./g, '-')}`).contentDocument;
+ const activeElement = activeThemeDoc.querySelector(".theme-option.active-false");
+ if (activeElement) activeElement.className = "theme-option active-true";
+ }
+
+ _renderThemeOptions() {
+ const internalThemes = ['ui-less-is-more', 'ui-ubuntu', 'ui-taiga', 'ui-darkside', 'ui-dark', 'ui-light'];
+ const sortedThemes = [].concat(this.state.themes);
+ sortedThemes.sort((a, b) => {
+ return (internalThemes.indexOf(a.name) - internalThemes.indexOf(b.name)) * -1;
+ });
+ return sortedThemes.map((theme) =>
+ this._setActiveTheme(theme.name)}
+ />
+ );
+ }
+
+ render() {
+ return (
+
+
+ Themes
+ Click any theme to apply:
+
+
+ {this._renderThemeOptions()}
+
+
+
+
+
+ );
+ }
+}
+
+export default ThemePicker;
diff --git a/packages/client-app/internal_packages/theme-picker/package.json b/packages/client-app/internal_packages/theme-picker/package.json
new file mode 100644
index 0000000000..9b78806f8b
--- /dev/null
+++ b/packages/client-app/internal_packages/theme-picker/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "theme-picker",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "View different themes and choose them easily",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/theme-picker/preview-styles/theme-option.less b/packages/client-app/internal_packages/theme-picker/preview-styles/theme-option.less
new file mode 100644
index 0000000000..9ce83253b0
--- /dev/null
+++ b/packages/client-app/internal_packages/theme-picker/preview-styles/theme-option.less
@@ -0,0 +1,99 @@
+@import "ui-variables";
+
+html,
+body {
+ margin: 0;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ -webkit-font-smoothing: antialiased;
+}
+
+.theme-option {
+ position: absolute;
+ top: 0;
+ margin-top: 4px;
+ margin-left: 5px;
+ width: 100px;
+ height: 60px;
+ background-color: @background-secondary;
+ color: @text-color;
+ border-radius: 5px;
+ text-align: center;
+ overflow: hidden;
+
+ &.active-true {
+ border: 1px solid #3187e1;
+ box-shadow: 0 0 4px #9ecaed;
+ }
+
+ &.active-false {
+ border: 1px solid darken(#f6f6f6, 10%);
+ }
+
+ .theme-name {
+ font-family: @font-family;
+ font-size: 12px;
+ font-weight: 600;
+ margin-top: 7px;
+ height: 18px;
+ overflow: hidden;
+ }
+
+ .swatches {
+ padding-left: 27px;
+ padding-right: 27px;
+ display: flex;
+ flex-direction: row;
+
+ .swatch {
+ flex: 1;
+ height: 10px;
+ width: 10px;
+ margin: 4px 2px 4px 2px;
+ border-radius: 2px;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ background-clip: border-box;
+ background-origin: border-box;
+
+ &.font-color {
+ background-color: @text-color;
+ }
+
+ &.active-color {
+ background-color: @component-active-color;
+ }
+
+ &.toolbar-color {
+ background-color: @toolbar-background-color;
+ }
+ }
+ }
+
+ .divider-black {
+ position: absolute;
+ bottom: 12px;
+ height: 1px;
+ width: 100%;
+ background-color: black;
+ opacity: 0.15;
+ }
+
+ .divider-white {
+ position: absolute;
+ z-index: 10;
+ bottom: 11px;
+ height: 1px;
+ width: 100%;
+ background-color: white;
+ opacity: 0.15;
+ }
+
+ .strip {
+ position: absolute;
+ bottom: 0;
+ height: 12px;
+ width: 100%;
+ background-color: @panel-background-color;
+ }
+}
diff --git a/packages/client-app/internal_packages/theme-picker/spec/theme-picker-spec.jsx b/packages/client-app/internal_packages/theme-picker/spec/theme-picker-spec.jsx
new file mode 100644
index 0000000000..7ec27ac79a
--- /dev/null
+++ b/packages/client-app/internal_packages/theme-picker/spec/theme-picker-spec.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ReactTestUtils from 'react-addons-test-utils';
+
+import ThemePackage from '../../../src/theme-package';
+import ThemePicker from '../lib/theme-picker';
+
+const {resourcePath} = NylasEnv.getLoadSettings();
+const light = new ThemePackage(`${resourcePath}/internal_packages/ui-light`);
+const dark = new ThemePackage(`${resourcePath}/internal_packages/ui-dark`);
+
+describe('ThemePicker', function themePicker() {
+ beforeEach(() => {
+ spyOn(NylasEnv.themes, 'getLoadedThemes').andReturn([light, dark]);
+ spyOn(NylasEnv.themes, 'getActiveTheme').andReturn(light);
+ this.component = ReactTestUtils.renderIntoDocument( );
+ });
+
+ it('changes the active theme when a theme is clicked', () => {
+ spyOn(ThemePicker.prototype, '_setActiveTheme').andCallThrough();
+ spyOn(ThemePicker.prototype, '_rewriteIFrame');
+ const themeOption = ReactDOM.findDOMNode(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'clickable-theme-option')[1]);
+ ReactTestUtils.Simulate.mouseDown(themeOption);
+ expect(ThemePicker.prototype._setActiveTheme).toHaveBeenCalled();
+ });
+});
diff --git a/packages/client-app/internal_packages/theme-picker/styles/theme-picker.less b/packages/client-app/internal_packages/theme-picker/styles/theme-picker.less
new file mode 100644
index 0000000000..52df50c00b
--- /dev/null
+++ b/packages/client-app/internal_packages/theme-picker/styles/theme-picker.less
@@ -0,0 +1,42 @@
+@import "ui-variables";
+
+.theme-picker {
+ text-align: center;
+ cursor: default;
+ h4 {
+ font-size: 14.5px;
+ margin-top: -10px;
+ margin-bottom: 5px;
+ }
+ .clickable-theme-option {
+ width: 115px;
+ height: 70px;
+ margin: 2px;
+ top: -20px;
+ iframe {
+ pointer-events: none;
+ position: relative;
+ z-index: 0;
+ }
+ }
+ .create-theme {
+ width: 100%;
+ text-align: center;
+ margin-top: 5px;
+ a {
+ text-decoration: none;
+ cursor: default;
+ }
+ }
+}
+
+@media (-webkit-min-device-pixel-ratio: 2) {
+ .theme-picker {
+ .theme-picker-x {
+ margin: 12px;
+ }
+ .clickable-theme-option {
+ top: -10px;
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/thread-list/assets/blank-bottom-left@2x.png b/packages/client-app/internal_packages/thread-list/assets/blank-bottom-left@2x.png
new file mode 100644
index 0000000000..86be23163c
Binary files /dev/null and b/packages/client-app/internal_packages/thread-list/assets/blank-bottom-left@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-list/assets/blank-bottom-right@2x.png b/packages/client-app/internal_packages/thread-list/assets/blank-bottom-right@2x.png
new file mode 100644
index 0000000000..f56d3f241f
Binary files /dev/null and b/packages/client-app/internal_packages/thread-list/assets/blank-bottom-right@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-list/assets/blank-top-left@2x.png b/packages/client-app/internal_packages/thread-list/assets/blank-top-left@2x.png
new file mode 100644
index 0000000000..3f23469a98
Binary files /dev/null and b/packages/client-app/internal_packages/thread-list/assets/blank-top-left@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-list/assets/blank-top-right@2x.png b/packages/client-app/internal_packages/thread-list/assets/blank-top-right@2x.png
new file mode 100644
index 0000000000..c9fb63c4d1
Binary files /dev/null and b/packages/client-app/internal_packages/thread-list/assets/blank-top-right@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-list/assets/graphic-stackable-card-filled.svg b/packages/client-app/internal_packages/thread-list/assets/graphic-stackable-card-filled.svg
new file mode 100644
index 0000000000..7dc5ad0810
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/assets/graphic-stackable-card-filled.svg
@@ -0,0 +1,10 @@
+
+
+
+ Rectangle 2
+ Created with Sketch.
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/client-app/internal_packages/thread-list/lib/category-removal-target-rulesets.es6 b/packages/client-app/internal_packages/thread-list/lib/category-removal-target-rulesets.es6
new file mode 100644
index 0000000000..6a34105363
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/category-removal-target-rulesets.es6
@@ -0,0 +1,86 @@
+import {AccountStore, CategoryStore} from 'nylas-exports';
+
+
+/**
+ * A RemovalTargetRuleset for categories is a map that represents the
+ * target/destination Category when removing threads from another given
+ * category, i.e., when removing them from their current CategoryPerspective.
+ * Rulesets are of the form:
+ *
+ * (categoryName) => function(accountId): Category
+ *
+ * Keys correspond to category names, e.g.`{'inbox', 'trash',...}`, which
+ * correspond to the name of the categories associated with a perspective
+ * Values are functions with the following signature:
+ *
+ * `function(accountId): Category`
+ *
+ * If a value is null instead of a function, it means that removing threads from
+ * that standard category has no effect, i.e. it is a no-op
+ *
+ * RemovalRulesets should also contain a special key `other`, that is meant to be used
+ * when a key cannot be found for a given Category name
+ *
+ * @typedef {Object} - RemovalTargetRuleset
+ * @property {(function|null)} target - Function that returns the target category
+*/
+const CategoryRemovalTargetRulesets = {
+
+ Default: {
+ // + Has no effect in Spam, Sent.
+ spam: null,
+ sent: null,
+
+ // + In inbox, move to [Archive or Trash]
+ inbox: (accountId) => {
+ const account = AccountStore.accountForId(accountId)
+ return account.defaultFinishedCategory()
+ },
+
+ // + In all/archive, move to trash.
+ all: (accountId) => CategoryStore.getTrashCategory(accountId),
+ archive: (accountId) => CategoryStore.getTrashCategory(accountId),
+
+ // TODO
+ // + In trash, it should delete permanently or do nothing.
+ trash: null,
+
+ // + In label or folder, move to [Archive or Trash]
+ other: (accountId) => {
+ const account = AccountStore.accountForId(accountId)
+ return account.defaultFinishedCategory()
+ },
+ },
+
+ Gmail: {
+ // + It has no effect in Spam, Sent, All Mail/Archive
+ all: null,
+ spam: null,
+ sent: null,
+ archive: null,
+
+ // + In inbox, move to [Archive or Trash].
+ inbox: (accountId) => {
+ const account = AccountStore.accountForId(accountId)
+ return account.defaultFinishedCategory()
+ },
+
+ // + In trash, move to Inbox
+ trash: (accountId) => CategoryStore.getInboxCategory(accountId),
+
+ // + In label, remove label
+ // + In folder, move to archive
+ other: (accountId) => {
+ const account = AccountStore.accountForId(accountId)
+ if (account.usesFolders()) {
+ // If we are removing threads from a folder, it means we are move the
+ // threads // somewhere. In this case, to the archive
+ return CategoryStore.getArchiveCategory(account)
+ }
+ // Otherwise, when removing a label, we don't want to move it anywhere
+ return null
+ },
+ },
+}
+
+export default CategoryRemovalTargetRulesets
diff --git a/packages/client-app/internal_packages/thread-list/lib/injects-toolbar-buttons.jsx b/packages/client-app/internal_packages/thread-list/lib/injects-toolbar-buttons.jsx
new file mode 100644
index 0000000000..acbf590cbb
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/injects-toolbar-buttons.jsx
@@ -0,0 +1,64 @@
+import React, {Component, PropTypes} from 'react'
+import {ListensToObservable, InjectedComponentSet} from 'nylas-component-kit'
+import ThreadListStore from './thread-list-store'
+
+
+export const ToolbarRole = 'ThreadActionsToolbarButton'
+
+
+function defaultObservable() {
+ return ThreadListStore.selectionObservable()
+}
+
+function InjectsToolbarButtons(ToolbarComponent, {getObservable, extraRoles = []}) {
+ const roles = [ToolbarRole].concat(extraRoles)
+
+ class ComposedComponent extends Component {
+ static displayName = ToolbarComponent.displayName;
+
+ static propTypes = {
+ items: PropTypes.array,
+ };
+
+ static containerRequired = false;
+
+ render() {
+ const {items} = this.props;
+ const {selection} = ThreadListStore.dataSource()
+
+ // Keep all of the exposed props from deprecated regions that now map to this one
+ const exposedProps = {
+ items,
+ selection,
+ thread: items[0],
+ }
+ const injectedButtons = (
+
+ )
+ return (
+
+ )
+ }
+ }
+
+ const getStateFromObservable = (items) => {
+ if (!items) {
+ return {items: []}
+ }
+ return {items}
+ }
+ return ListensToObservable(ComposedComponent, {
+ getObservable: getObservable || defaultObservable,
+ getStateFromObservable,
+ })
+}
+
+export default InjectsToolbarButtons
diff --git a/packages/client-app/internal_packages/thread-list/lib/main.es6 b/packages/client-app/internal_packages/thread-list/lib/main.es6
new file mode 100644
index 0000000000..836fdaddbe
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/main.es6
@@ -0,0 +1,81 @@
+import {ComponentRegistry, WorkspaceStore} from "nylas-exports";
+
+import ThreadList from './thread-list';
+import ThreadListToolbar from './thread-list-toolbar';
+import MessageListToolbar from './message-list-toolbar';
+import SelectedItemsStack from './selected-items-stack';
+
+import {
+ UpButton,
+ DownButton,
+ TrashButton,
+ ArchiveButton,
+ MarkAsSpamButton,
+ ToggleUnreadButton,
+ ToggleStarredButton,
+} from "./thread-toolbar-buttons";
+
+export function activate() {
+ ComponentRegistry.register(ThreadList, {
+ location: WorkspaceStore.Location.ThreadList,
+ });
+
+ ComponentRegistry.register(SelectedItemsStack, {
+ location: WorkspaceStore.Location.MessageList,
+ modes: ['split'],
+ });
+
+ // Toolbars
+ ComponentRegistry.register(ThreadListToolbar, {
+ location: WorkspaceStore.Location.ThreadList.Toolbar,
+ modes: ['list'],
+ });
+
+ ComponentRegistry.register(MessageListToolbar, {
+ location: WorkspaceStore.Location.MessageList.Toolbar,
+ });
+
+ ComponentRegistry.register(DownButton, {
+ location: WorkspaceStore.Location.MessageList.Toolbar,
+ modes: ['list'],
+ });
+
+ ComponentRegistry.register(UpButton, {
+ location: WorkspaceStore.Location.MessageList.Toolbar,
+ modes: ['list'],
+ });
+
+ ComponentRegistry.register(ArchiveButton, {
+ role: 'ThreadActionsToolbarButton',
+ });
+
+ ComponentRegistry.register(TrashButton, {
+ role: 'ThreadActionsToolbarButton',
+ });
+
+ ComponentRegistry.register(MarkAsSpamButton, {
+ role: 'ThreadActionsToolbarButton',
+ });
+
+ ComponentRegistry.register(ToggleStarredButton, {
+ role: 'ThreadActionsToolbarButton',
+ });
+
+ ComponentRegistry.register(ToggleUnreadButton, {
+ role: 'ThreadActionsToolbarButton',
+ });
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(ThreadList);
+ ComponentRegistry.unregister(SelectedItemsStack);
+ ComponentRegistry.unregister(ThreadListToolbar);
+ ComponentRegistry.unregister(MessageListToolbar);
+ ComponentRegistry.unregister(ArchiveButton);
+ ComponentRegistry.unregister(TrashButton);
+ ComponentRegistry.unregister(MarkAsSpamButton);
+ ComponentRegistry.unregister(ToggleUnreadButton);
+ ComponentRegistry.unregister(ToggleStarredButton);
+ ComponentRegistry.unregister(UpButton);
+ ComponentRegistry.unregister(DownButton);
+}
diff --git a/packages/client-app/internal_packages/thread-list/lib/message-list-toolbar.jsx b/packages/client-app/internal_packages/thread-list/lib/message-list-toolbar.jsx
new file mode 100644
index 0000000000..7b97a9ac81
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/message-list-toolbar.jsx
@@ -0,0 +1,49 @@
+import React, {PropTypes} from 'react'
+import ReactCSSTransitionGroup from 'react-addons-css-transition-group'
+import {Rx, FocusedContentStore} from 'nylas-exports'
+import ThreadListStore from './thread-list-store'
+import InjectsToolbarButtons, {ToolbarRole} from './injects-toolbar-buttons'
+
+
+function getObservable() {
+ return (
+ Rx.Observable.combineLatest(
+ Rx.Observable.fromStore(FocusedContentStore),
+ ThreadListStore.selectionObservable(),
+ (store, items) => ({focusedThread: store.focused('thread'), items})
+ )
+ .map(({focusedThread, items}) => {
+ if (focusedThread) {
+ return [focusedThread]
+ }
+ return items
+ })
+ )
+}
+
+const MessageListToolbar = ({items, injectedButtons}) => {
+ const shouldRender = items.length > 0
+
+ return (
+
+ {shouldRender ? injectedButtons : undefined}
+
+ )
+}
+MessageListToolbar.displayName = 'MessageListToolbar';
+MessageListToolbar.propTypes = {
+ items: PropTypes.array,
+ injectedButtons: PropTypes.element,
+};
+
+const toolbarProps = {
+ getObservable,
+ extraRoles: [`MessageList:${ToolbarRole}`],
+}
+
+export default InjectsToolbarButtons(MessageListToolbar, toolbarProps)
diff --git a/packages/client-app/internal_packages/thread-list/lib/selected-items-stack.jsx b/packages/client-app/internal_packages/thread-list/lib/selected-items-stack.jsx
new file mode 100644
index 0000000000..68cd4eb096
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/selected-items-stack.jsx
@@ -0,0 +1,73 @@
+import _ from 'underscore'
+import React, {Component, PropTypes} from 'react'
+import {ListensToObservable} from 'nylas-component-kit'
+import ThreadListStore from './thread-list-store'
+
+
+function getObservable() {
+ return (
+ ThreadListStore.selectionObservable()
+ .map(items => items.length)
+ )
+}
+
+function getStateFromObservable(selectionCount) {
+ if (!selectionCount) {
+ return {selectionCount: 0}
+ }
+ return {selectionCount}
+}
+
+class SelectedItemsStack extends Component {
+ static displayName = "SelectedItemsStack";
+
+ static propTypes = {
+ selectionCount: PropTypes.number,
+ };
+
+ static containerRequired = false;
+
+ onClearSelection = () => {
+ ThreadListStore.dataSource().selection.clear()
+ };
+
+ render() {
+ const {selectionCount} = this.props
+ if (selectionCount <= 1) {
+ return
+ }
+ const cardCount = Math.min(5, selectionCount)
+
+ return (
+
+
+
+ {_.times(cardCount, (idx) => {
+ let deg = idx * 0.9;
+
+ if (idx === 1) {
+ deg += 0.5
+ }
+ let transform = `rotate(${deg}deg)`
+ if (idx === cardCount - 1) {
+ transform += ' translate3d(2px, 3px, 0)'
+ }
+ const style = {
+ transform,
+ zIndex: 5 - idx,
+ }
+ return
+ })}
+
+
+
{selectionCount}
+
messages selected
+
Clear Selection
+
+
+
+ )
+ }
+}
+
+export default ListensToObservable(SelectedItemsStack, {getObservable, getStateFromObservable})
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-columns.cjsx b/packages/client-app/internal_packages/thread-list/lib/thread-list-columns.cjsx
new file mode 100644
index 0000000000..09a3735cd4
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-columns.cjsx
@@ -0,0 +1,200 @@
+_ = require 'underscore'
+React = require 'react'
+classNames = require 'classnames'
+moment = require 'moment'
+
+{ListTabular,
+ RetinaImg,
+ MailLabelSet,
+ MailImportantIcon,
+ InjectedComponent,
+ InjectedComponentSet} = require 'nylas-component-kit'
+
+{Thread, FocusedPerspectiveStore, Utils, DateUtils} = require 'nylas-exports'
+
+{ThreadArchiveQuickAction,
+ ThreadTrashQuickAction} = require './thread-list-quick-actions'
+
+ThreadListParticipants = require './thread-list-participants'
+ThreadListStore = require './thread-list-store'
+ThreadListIcon = require './thread-list-icon'
+
+# Get and format either last sent or last received timestamp depending on thread-list being viewed
+ThreadListTimestamp = ({thread}) ->
+ if FocusedPerspectiveStore.current().isSent()
+ rawTimestamp = thread.lastMessageSentTimestamp
+ else
+ rawTimestamp = thread.lastMessageReceivedTimestamp
+ timestamp = DateUtils.shortTimeString(rawTimestamp)
+ return {timestamp}
+ThreadListTimestamp.containerRequired = false
+
+subject = (subj) ->
+ if (subj ? "").trim().length is 0
+ return (No Subject)
+ else if subj.split(/([\uD800-\uDBFF][\uDC00-\uDFFF])/g).length > 1
+ subjComponents = []
+ subjParts = subj.split /([\uD800-\uDBFF][\uDC00-\uDFFF])/g
+ for part, idx in subjParts
+ if part.match /([\uD800-\uDBFF][\uDC00-\uDFFF])/g
+ subjComponents.push {part}
+ else
+ subjComponents.push {part}
+ return subjComponents
+ else
+ return subj
+
+getSnippet = (thread) ->
+ messages = thread.__messages || []
+ if (messages.length is 0)
+ return thread.snippet
+
+ return messages[messages.length - 1].snippet
+
+
+c1 = new ListTabular.Column
+ name: "★"
+ resolver: (thread) =>
+ [
+
+
+
+ ]
+
+c2 = new ListTabular.Column
+ name: "Participants"
+ width: 200
+ resolver: (thread) =>
+ hasDraft = (thread.__messages || []).find((m) => m.draft)
+ if hasDraft
+
+
+
+
+ else
+
+
+c3 = new ListTabular.Column
+ name: "Message"
+ flex: 4
+ resolver: (thread) =>
+ attachment = false
+ messages = thread.__messages || []
+
+ hasAttachments = thread.hasAttachments and messages.find (m) -> Utils.showIconForAttachments(m.files)
+ if hasAttachments
+ attachment =
+
+
+
+ {subject(thread.subject)}
+ {getSnippet(thread)}
+ {attachment}
+
+
+c4 = new ListTabular.Column
+ name: "Date"
+ resolver: (thread) =>
+ return (
+
+ )
+
+c5 = new ListTabular.Column
+ name: "HoverActions"
+ resolver: (thread) =>
+
+
+
+ ]}
+ matching={role: "ThreadListQuickAction"}
+ className="thread-injected-quick-actions"
+ exposedProps={thread: thread}
+ />
+
+
+cNarrow = new ListTabular.Column
+ name: "Item"
+ flex: 1
+ resolver: (thread) =>
+ pencil = false
+ attachment = false
+ messages = thread.__messages || []
+
+ hasAttachments = thread.hasAttachments and messages.find (m) -> Utils.showIconForAttachments(m.files)
+ if hasAttachments
+ attachment =
+
+ hasDraft = messages.find((m) => m.draft)
+ if hasDraft
+ pencil =
+
+ # TODO We are limiting the amount on injected icons in narrow mode to 1
+ # until we revisit the UI to accommodate more icons
+
+
+
+
+
+
+
+
+
+ {pencil}
+
+ {attachment}
+
+
+
{subject(thread.subject)}
+
+
{getSnippet(thread)}
+
+
+
+
+
+
+module.exports =
+ Narrow: [cNarrow]
+ Wide: [c1, c2, c3, c4, c5]
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-context-menu.es6 b/packages/client-app/internal_packages/thread-list/lib/thread-list-context-menu.es6
new file mode 100644
index 0000000000..625d0bb934
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-context-menu.es6
@@ -0,0 +1,179 @@
+/* eslint global-require: 0*/
+import _ from 'underscore'
+import {
+ Thread,
+ Actions,
+ Message,
+ TaskFactory,
+ DatabaseStore,
+ FocusedPerspectiveStore,
+} from 'nylas-exports'
+
+export default class ThreadListContextMenu {
+ constructor({threadIds = [], accountIds = []}) {
+ this.threadIds = threadIds
+ this.accountIds = accountIds
+ }
+
+ menuItemTemplate() {
+ return DatabaseStore.modelify(Thread, this.threadIds)
+ .then((threads) => {
+ this.threads = threads;
+
+ return Promise.all([
+ this.replyItem(),
+ this.replyAllItem(),
+ this.forwardItem(),
+ {type: 'separator'},
+ this.archiveItem(),
+ this.trashItem(),
+ this.markAsReadItem(),
+ this.starItem(),
+ // this.moveToOrLabelItem(),
+ // {type: 'separator'},
+ // this.extensionItems(),
+ ])
+ }).then((menuItems) => {
+ return _.filter(_.compact(menuItems), (item, index) => {
+ if ((index === 0 || index === menuItems.length - 1) && item.type === "separator") {
+ return false
+ }
+ return true
+ });
+ });
+ }
+
+ replyItem() {
+ if (this.threadIds.length !== 1) { return null }
+ return {
+ label: "Reply",
+ click: () => {
+ Actions.composeReply({
+ threadId: this.threadIds[0],
+ popout: true,
+ type: 'reply',
+ behavior: 'prefer-existing-if-pristine',
+ });
+ },
+ }
+ }
+
+ replyAllItem() {
+ if (this.threadIds.length !== 1) {
+ return null;
+ }
+
+ return DatabaseStore.findBy(Message, {threadId: this.threadIds[0]})
+ .order(Message.attributes.date.descending())
+ .limit(1)
+ .then((message) => {
+ if (message && message.canReplyAll()) {
+ return {
+ label: "Reply All",
+ click: () => {
+ Actions.composeReply({
+ threadId: this.threadIds[0],
+ popout: true,
+ type: 'reply-all',
+ behavior: 'prefer-existing-if-pristine',
+ });
+ },
+ }
+ }
+ return null;
+ })
+ }
+
+ forwardItem() {
+ if (this.threadIds.length !== 1) { return null }
+ return {
+ label: "Forward",
+ click: () => {
+ Actions.composeForward({threadId: this.threadIds[0], popout: true});
+ },
+ }
+ }
+
+ archiveItem() {
+ const perspective = FocusedPerspectiveStore.current()
+ const allowed = perspective.canArchiveThreads(this.threads)
+ if (!allowed) {
+ return null
+ }
+ return {
+ label: "Archive",
+ click: () => {
+ Actions.archiveThreads({
+ source: "Context Menu: Thread List",
+ threads: this.threads,
+ })
+ },
+ }
+ }
+
+ trashItem() {
+ const perspective = FocusedPerspectiveStore.current()
+ const allowed = perspective.canMoveThreadsTo(this.threads, 'trash')
+ if (!allowed) {
+ return null
+ }
+ return {
+ label: "Trash",
+ click: () => {
+ Actions.trashThreads({
+ source: "Context Menu: Thread List",
+ threads: this.threads,
+ })
+ },
+ }
+ }
+
+ markAsReadItem() {
+ const unread = _.every(this.threads, (t) => {
+ return _.isMatch(t, {unread: false})
+ });
+ const dir = unread ? "Unread" : "Read"
+
+ return {
+ label: `Mark as ${dir}`,
+ click: () => {
+ Actions.toggleUnreadThreads({
+ source: "Context Menu: Thread List",
+ threads: this.threads,
+ })
+ },
+ }
+ }
+
+ starItem() {
+ const starred = _.every(this.threads, (t) => {
+ return _.isMatch(t, {starred: false})
+ });
+
+ let dir = ""
+ let star = "Star"
+ if (!starred) {
+ dir = "Remove "
+ star = (this.threadIds.length > 1) ? "Stars" : "Star"
+ }
+
+
+ return {
+ label: `${dir}${star}`,
+ click: () => {
+ Actions.toggleStarredThreads({
+ source: "Context Menu: Thread List",
+ threads: this.threads,
+ })
+ },
+ }
+ }
+
+ displayMenu() {
+ const {remote} = require('electron')
+ this.menuItemTemplate().then((template) => {
+ remote.Menu.buildFromTemplate(template)
+ .popup(remote.getCurrentWindow());
+ });
+ }
+}
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-data-source.es6 b/packages/client-app/internal_packages/thread-list/lib/thread-list-data-source.es6
new file mode 100644
index 0000000000..2e4a346e2b
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-data-source.es6
@@ -0,0 +1,91 @@
+import {
+ Rx,
+ ObservableListDataSource,
+ DatabaseStore,
+ Message,
+ QueryResultSet,
+ QuerySubscription,
+} from 'nylas-exports';
+
+const _observableForThreadMessages = (id, initialModels) => {
+ const subscription = new QuerySubscription(DatabaseStore.findAll(Message, {threadId: id}), {
+ initialModels: initialModels,
+ emitResultSet: true,
+ });
+ return Rx.Observable.fromNamedQuerySubscription(`message-${id}`, subscription);
+};
+
+const _flatMapJoiningMessages = ($threadsResultSet) => {
+ // DatabaseView leverages `QuerySubscription` for threads /and/ for the
+ // messages on each thread, which are passed to out as `thread.__messages`.
+ let $messagesResultSets = {};
+
+ // 2. when we receive a set of threads, we check to see if we have message
+ // observables for each thread. If threads have been added to the result set,
+ // we make a single database query and load /all/ the message metadata for
+ // the new threads at once. (This is a performance optimization -it's about
+ // ~80msec faster than making 100 queries for 100 new thread ids separately.)
+ return $threadsResultSet.flatMapLatest((threadsResultSet) => {
+ const missingIds = threadsResultSet.ids().filter(id => !$messagesResultSets[id]);
+ let promise = null;
+ if (missingIds.length === 0) {
+ promise = Promise.resolve([threadsResultSet, []]);
+ } else {
+ promise = DatabaseStore.findAll(Message, {threadId: missingIds}).then((messages) => {
+ return Promise.resolve([threadsResultSet, messages]);
+ });
+ }
+ return Rx.Observable.fromPromise(promise);
+ })
+
+ // 3. when that finishes, we group the loaded messsages by threadId and create
+ // the missing observables. Creating a query subscription would normally load
+ // an initial result set. To avoid that, we just hand new subscriptions the
+ // results we loaded in #2.
+ .flatMapLatest(([threadsResultSet, messagesForNewThreads]) => {
+ const messagesGrouped = {};
+ for (const message of messagesForNewThreads) {
+ if (messagesGrouped[message.threadId] == null) { messagesGrouped[message.threadId] = []; }
+ messagesGrouped[message.threadId].push(message);
+ }
+
+ const oldSets = $messagesResultSets;
+ $messagesResultSets = {};
+
+ const sets = threadsResultSet.ids().map(id => {
+ $messagesResultSets[id] = oldSets[id] || _observableForThreadMessages(id, messagesGrouped[id]);
+ return $messagesResultSets[id];
+ });
+ sets.unshift(Rx.Observable.from([threadsResultSet]));
+
+ // 4. We use `combineLatest` to merge the message observables into a single
+ // stream (like Promise.all). When /any/ of them emit a new result set, we
+ // trigger.
+ return Rx.Observable.combineLatest(sets);
+ })
+
+ .flatMapLatest(([threadsResultSet, ...messagesResultSets]) => {
+ const threadsWithMessages = {};
+ threadsResultSet.models().forEach((thread, idx) => {
+ const clone = new thread.constructor(thread);
+ clone.__messages = messagesResultSets[idx] ? messagesResultSets[idx].models() : [];
+ clone.__messages = clone.__messages.filter((m) => !m.isHidden())
+ threadsWithMessages[clone.id] = clone;
+ });
+
+ return Rx.Observable.from([
+ QueryResultSet.setByApplyingModels(threadsResultSet, threadsWithMessages),
+ ]);
+ });
+};
+
+
+class ThreadListDataSource extends ObservableListDataSource {
+ constructor(subscription) {
+ let $resultSetObservable = Rx.Observable.fromNamedQuerySubscription('thread-list', subscription);
+ $resultSetObservable = _flatMapJoiningMessages($resultSetObservable);
+ super($resultSetObservable, subscription.replaceRange.bind(subscription));
+ }
+}
+
+export default ThreadListDataSource;
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-icon.cjsx b/packages/client-app/internal_packages/thread-list/lib/thread-list-icon.cjsx
new file mode 100644
index 0000000000..1847799a51
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-icon.cjsx
@@ -0,0 +1,66 @@
+_ = require 'underscore'
+React = require 'react'
+{DraftHelpers,
+ Actions,
+ Thread,
+ ChangeStarredTask,
+ ExtensionRegistry,
+ AccountStore} = require 'nylas-exports'
+
+class ThreadListIcon extends React.Component
+ @displayName: 'ThreadListIcon'
+ @propTypes:
+ thread: React.PropTypes.object
+
+ _extensionsIconClassNames: =>
+ return ExtensionRegistry.ThreadList.extensions()
+ .filter((ext) => ext.cssClassNamesForThreadListIcon?)
+ .reduce(((prev, ext) => prev + ' ' + ext.cssClassNamesForThreadListIcon(@props.thread)), '')
+ .trim()
+
+ _iconClassNames: =>
+ if !@props.thread
+ return 'thread-icon-star-on-hover'
+
+ extensionIconClassNames = @_extensionsIconClassNames()
+ if extensionIconClassNames.length > 0
+ return extensionIconClassNames
+
+ if @props.thread.starred
+ return 'thread-icon-star'
+
+ if @props.thread.unread
+ return 'thread-icon-unread thread-icon-star-on-hover'
+
+ msgs = @_nonDraftMessages()
+ last = msgs[msgs.length - 1]
+
+ if msgs.length > 1 and last.from[0]?.isMe()
+ if DraftHelpers.isForwardedMessage(last)
+ return 'thread-icon-forwarded thread-icon-star-on-hover'
+ else
+ return 'thread-icon-replied thread-icon-star-on-hover'
+
+ return 'thread-icon-none thread-icon-star-on-hover'
+
+ _nonDraftMessages: =>
+ msgs = @props.thread.__messages
+ return [] unless msgs and msgs instanceof Array
+ msgs = _.filter msgs, (m) -> m.serverId and not m.draft
+ return msgs
+
+ shouldComponentUpdate: (nextProps) =>
+ return false if nextProps.thread is @props.thread
+ true
+
+ render: =>
+
+
+ _onToggleStar: (event) =>
+ Actions.toggleStarredThreads(threads: [@props.thread], source: "Thread List Icon")
+ # Don't trigger the thread row click
+ event.stopPropagation()
+
+module.exports = ThreadListIcon
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-participants.cjsx b/packages/client-app/internal_packages/thread-list/lib/thread-list-participants.cjsx
new file mode 100644
index 0000000000..425286d926
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-participants.cjsx
@@ -0,0 +1,124 @@
+React = require 'react'
+{Utils} = require 'nylas-exports'
+_ = require 'underscore'
+
+class ThreadListParticipants extends React.Component
+ @displayName: 'ThreadListParticipants'
+
+ @propTypes:
+ thread: React.PropTypes.object.isRequired
+
+ shouldComponentUpdate: (nextProps) =>
+ return false if nextProps.thread is @props.thread
+ true
+
+ render: =>
+ items = @getTokens()
+
+ {@renderSpans(items)}
+
+
+ renderSpans: (items) =>
+ spans = []
+ accumulated = null
+ accumulatedUnread = false
+
+ flush = ->
+ if accumulated
+ spans.push {accumulated}
+ accumulated = null
+ accumulatedUnread = false
+
+ accumulate = (text, unread) ->
+ if accumulated and unread and accumulatedUnread isnt unread
+ flush()
+ if accumulated
+ accumulated += text
+ else
+ accumulated = text
+ accumulatedUnread = unread
+
+ for {spacer, contact, unread}, idx in items
+ if spacer
+ accumulate('...')
+ else
+ if contact.name.length > 0
+ if items.length > 1
+ short = contact.displayName(includeAccountLabel: false, compact: true)
+ else
+ short = contact.displayName(includeAccountLabel: false)
+ else
+ short = contact.email
+ if idx < items.length-1 and not items[idx+1].spacer
+ short += ", "
+ accumulate(short, unread)
+
+ messages = (@props.thread.__messages ? [])
+ if messages.length > 1
+ accumulate(" (#{messages.length})")
+
+ flush()
+
+ return spans
+
+ getTokensFromMessages: =>
+ messages = @props.thread.__messages
+ tokens = []
+
+ field = 'from'
+ if (messages.every (message) -> message.isFromMe())
+ field = 'to'
+
+ for message, idx in messages
+ if message.draft
+ continue
+
+ for contact in message[field]
+ if tokens.length is 0
+ tokens.push({ contact: contact, unread: message.unread })
+ else
+ lastToken = tokens[tokens.length - 1]
+ lastContact = lastToken.contact
+
+ sameEmail = Utils.emailIsEquivalent(lastContact.email, contact.email)
+ sameName = lastContact.name is contact.name
+ if sameEmail and sameName
+ lastToken.unread ||= message.unread
+ else
+ tokens.push({ contact: contact, unread: message.unread })
+
+ tokens
+
+ getTokensFromParticipants: =>
+ contacts = @props.thread.participants ? []
+ contacts = contacts.filter (contact) -> not contact.isMe()
+ contacts.map (contact) -> { contact: contact, unread: false }
+
+ getTokens: =>
+ if @props.thread.__messages instanceof Array
+ list = @getTokensFromMessages()
+ else
+ list = @getTokensFromParticipants()
+
+ # If no participants, we should at least add current user as sole participant
+ if list.length is 0 and @props.thread.participants?.length > 0
+ list.push({ contact: @props.thread.participants[0], unread: false })
+
+ # We only ever want to show three. Ben...Kevin... Marty
+ # But we want the *right* three.
+ if list.length > 3
+ listTrimmed = [
+ # Always include the first item
+ list[0],
+ { spacer: true },
+
+ # Always include last two items
+ list[list.length - 2],
+ list[list.length - 1]
+ ]
+ list = listTrimmed
+
+ list
+
+
+module.exports = ThreadListParticipants
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-quick-actions.cjsx b/packages/client-app/internal_packages/thread-list/lib/thread-list-quick-actions.cjsx
new file mode 100644
index 0000000000..41918866d8
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-quick-actions.cjsx
@@ -0,0 +1,62 @@
+React = require 'react'
+{Actions,
+ CategoryStore,
+ TaskFactory,
+ AccountStore,
+ FocusedPerspectiveStore} = require 'nylas-exports'
+
+class ThreadArchiveQuickAction extends React.Component
+ @displayName: 'ThreadArchiveQuickAction'
+ @propTypes:
+ thread: React.PropTypes.object
+
+ render: =>
+ allowed = FocusedPerspectiveStore.current().canArchiveThreads([@props.thread])
+ return unless allowed
+
+
+
+ shouldComponentUpdate: (newProps, newState) ->
+ newProps.thread.id isnt @props?.thread.id
+
+ _onArchive: (event) =>
+ # Don't trigger the thread row click
+ event.stopPropagation()
+ Actions.archiveThreads({
+ source: "Quick Actions: Thread List",
+ threads: [@props.thread],
+ })
+
+class ThreadTrashQuickAction extends React.Component
+ @displayName: 'ThreadTrashQuickAction'
+ @propTypes:
+ thread: React.PropTypes.object
+
+ render: =>
+ allowed = FocusedPerspectiveStore.current().canMoveThreadsTo([@props.thread], 'trash')
+ return unless allowed
+
+
+
+ shouldComponentUpdate: (newProps, newState) ->
+ newProps.thread.id isnt @props?.thread.id
+
+ _onRemove: (event) =>
+ Actions.trashThreads({
+ source: "Quick Actions: Thread List",
+ threads: [@props.thread],
+ })
+ # Don't trigger the thread row click
+ event.stopPropagation()
+
+module.exports = { ThreadArchiveQuickAction, ThreadTrashQuickAction }
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-scroll-tooltip.cjsx b/packages/client-app/internal_packages/thread-list/lib/thread-list-scroll-tooltip.cjsx
new file mode 100644
index 0000000000..2e625b823d
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-scroll-tooltip.cjsx
@@ -0,0 +1,35 @@
+React = require 'react'
+{Utils, DateUtils} = require 'nylas-exports'
+ThreadListStore = require './thread-list-store'
+
+class ThreadListScrollTooltip extends React.Component
+ @displayName: 'ThreadListScrollTooltip'
+ @propTypes:
+ viewportCenter: React.PropTypes.number.isRequired
+ totalHeight: React.PropTypes.number.isRequired
+
+ componentWillMount: =>
+ @setupForProps(@props)
+
+ componentWillReceiveProps: (newProps) =>
+ @setupForProps(newProps)
+
+ shouldComponentUpdate: (newProps, newState) =>
+ @state?.idx isnt newState.idx
+
+ setupForProps: (props) ->
+ idx = Math.floor(ThreadListStore.dataSource().count() / @props.totalHeight * @props.viewportCenter)
+ @setState
+ idx: idx
+ item: ThreadListStore.dataSource().get(idx)
+
+ render: ->
+ if @state.item
+ content = DateUtils.shortTimeString(@state.item.lastMessageReceivedTimestamp)
+ else
+ content = "Loading..."
+
+ {content}
+
+
+module.exports = ThreadListScrollTooltip
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-store.coffee b/packages/client-app/internal_packages/thread-list/lib/thread-list-store.coffee
new file mode 100644
index 0000000000..f16ed9d572
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-store.coffee
@@ -0,0 +1,88 @@
+_ = require 'underscore'
+NylasStore = require 'nylas-store'
+
+{Rx,
+ Thread,
+ Message,
+ Actions,
+ DatabaseStore,
+ WorkspaceStore,
+ FocusedContentStore,
+ TaskQueueStatusStore,
+ FocusedPerspectiveStore} = require 'nylas-exports'
+{ListTabular} = require 'nylas-component-kit'
+
+ThreadListDataSource = require('./thread-list-data-source').default
+
+class ThreadListStore extends NylasStore
+ constructor: ->
+ @listenTo FocusedPerspectiveStore, @_onPerspectiveChanged
+ @createListDataSource()
+
+ dataSource: =>
+ @_dataSource
+
+ createListDataSource: =>
+ @_dataSourceUnlisten?()
+ @_dataSource = null
+
+ threadsSubscription = FocusedPerspectiveStore.current().threads()
+ if threadsSubscription
+ @_dataSource = new ThreadListDataSource(threadsSubscription)
+ @_dataSourceUnlisten = @_dataSource.listen(@_onDataChanged, @)
+
+ else
+ @_dataSource = new ListTabular.DataSource.Empty()
+
+ @trigger(@)
+ Actions.setFocus(collection: 'thread', item: null)
+
+ selectionObservable: =>
+ return Rx.Observable.fromListSelection(@)
+
+ # Inbound Events
+
+ _onPerspectiveChanged: =>
+ @createListDataSource()
+
+ _onDataChanged: ({previous, next} = {}) =>
+ # This code keeps the focus and keyboard cursor in sync with the thread list.
+ # When the thread list changes, it looks to see if the focused thread is gone,
+ # or no longer matches the query criteria and advances the focus to the next
+ # thread.
+
+ # This means that removing a thread from view in any way causes selection
+ # to advance to the adjacent thread. Nice and declarative.
+
+ if previous and next
+ focused = FocusedContentStore.focused('thread')
+ keyboard = FocusedContentStore.keyboardCursor('thread')
+ viewModeAutofocuses = WorkspaceStore.layoutMode() is 'split' or WorkspaceStore.topSheet().root is true
+ matchers = next.query()?.matchers()
+
+ focusedIndex = if focused then previous.offsetOfId(focused.id) else -1
+ keyboardIndex = if keyboard then previous.offsetOfId(keyboard.id) else -1
+
+ nextItemFromIndex = (i) =>
+ if i > 0 and (next.modelAtOffset(i - 1)?.unread or i >= next.count())
+ nextIndex = i - 1
+ else
+ nextIndex = i
+
+ # May return null if no thread is loaded at the next index
+ next.modelAtOffset(nextIndex)
+
+ notInSet = (model) ->
+ if matchers
+ return model.matches(matchers) is false
+ else
+ return next.offsetOfId(model.id) is -1
+
+ if viewModeAutofocuses and focused and notInSet(focused)
+ Actions.setFocus(collection: 'thread', item: nextItemFromIndex(focusedIndex))
+
+ if keyboard and notInSet(keyboard)
+ Actions.setCursorPosition(collection: 'thread', item: nextItemFromIndex(keyboardIndex))
+
+
+module.exports = new ThreadListStore()
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-toolbar.jsx b/packages/client-app/internal_packages/thread-list/lib/thread-list-toolbar.jsx
new file mode 100644
index 0000000000..3b32af0a25
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-toolbar.jsx
@@ -0,0 +1,39 @@
+import React, {Component, PropTypes} from 'react'
+import {MultiselectToolbar} from 'nylas-component-kit'
+import InjectsToolbarButtons, {ToolbarRole} from './injects-toolbar-buttons'
+
+
+class ThreadListToolbar extends Component {
+ static displayName = 'ThreadListToolbar';
+
+ static propTypes = {
+ items: PropTypes.array,
+ selection: PropTypes.shape({
+ clear: PropTypes.func,
+ }),
+ injectedButtons: PropTypes.element,
+ };
+
+ onClearSelection = () => {
+ this.props.selection.clear()
+ };
+
+ render() {
+ const {injectedButtons, items} = this.props
+
+ return (
+
+ )
+ }
+}
+
+const toolbarProps = {
+ extraRoles: [`ThreadList:${ToolbarRole}`],
+}
+
+export default InjectsToolbarButtons(ThreadListToolbar, toolbarProps)
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list.cjsx b/packages/client-app/internal_packages/thread-list/lib/thread-list.cjsx
new file mode 100644
index 0000000000..3cd1cceb3d
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-list.cjsx
@@ -0,0 +1,379 @@
+_ = require 'underscore'
+React = require 'react'
+ReactDOM = require 'react-dom'
+classnames = require 'classnames'
+
+{MultiselectList,
+ FocusContainer,
+ EmptyListState,
+ FluxContainer
+ SyncingListState} = require 'nylas-component-kit'
+
+{Actions,
+ Utils,
+ Thread,
+ Category,
+ CanvasUtils,
+ TaskFactory,
+ ChangeStarredTask,
+ WorkspaceStore,
+ AccountStore,
+ CategoryStore,
+ ExtensionRegistry,
+ FocusedContentStore,
+ FocusedPerspectiveStore
+ FolderSyncProgressStore} = require 'nylas-exports'
+
+ThreadListColumns = require './thread-list-columns'
+ThreadListScrollTooltip = require './thread-list-scroll-tooltip'
+ThreadListStore = require './thread-list-store'
+ThreadListContextMenu = require('./thread-list-context-menu').default
+CategoryRemovalTargetRulesets = require('./category-removal-target-rulesets').default
+
+
+class ThreadList extends React.Component
+ @displayName: 'ThreadList'
+
+ @containerRequired: false
+ @containerStyles:
+ minWidth: 300
+ maxWidth: 3000
+
+ constructor: (@props) ->
+ @state =
+ style: 'unknown'
+ syncing: false
+
+ componentDidMount: =>
+ @_reportAppBootTime()
+ @unsub = FolderSyncProgressStore.listen(@_onSyncStatusChanged)
+ window.addEventListener('resize', @_onResize, true)
+ ReactDOM.findDOMNode(@).addEventListener('contextmenu', @_onShowContextMenu)
+ @_onResize()
+
+ shouldComponentUpdate: (nextProps, nextState) =>
+ return (
+ (not Utils.isEqualReact(@props, nextProps)) or
+ (not Utils.isEqualReact(@state, nextState))
+ )
+
+ componentWillUnmount: =>
+ @unsub()
+ window.removeEventListener('resize', @_onResize, true)
+ ReactDOM.findDOMNode(@).removeEventListener('contextmenu', @_onShowContextMenu)
+
+ _reportAppBootTime: =>
+ if NylasEnv.timer.isPending('app-boot')
+ Actions.recordPerfMetric({
+ action: 'app-boot',
+ actionTimeMs: NylasEnv.timer.stop('app-boot'),
+ maxValue: 60 * 1000,
+ })
+
+ _shift: ({offset, afterRunning}) =>
+ dataSource = ThreadListStore.dataSource()
+ focusedId = FocusedContentStore.focusedId('thread')
+ focusedIdx = Math.min(dataSource.count() - 1, Math.max(0, dataSource.indexOfId(focusedId) + offset))
+ item = dataSource.get(focusedIdx)
+ afterRunning()
+ Actions.setFocus(collection: 'thread', item: item)
+
+ _keymapHandlers: ->
+ 'core:remove-from-view': =>
+ @_onRemoveFromView()
+ 'core:gmail-remove-from-view': =>
+ @_onRemoveFromView(CategoryRemovalTargetRulesets.Gmail)
+ 'core:archive-item': @_onArchiveItem
+ 'core:delete-item': @_onDeleteItem
+ 'core:star-item': @_onStarItem
+ 'core:snooze-item': @_onSnoozeItem
+ 'core:mark-important': => @_onSetImportant(true)
+ 'core:mark-unimportant': => @_onSetImportant(false)
+ 'core:mark-as-unread': => @_onSetUnread(true)
+ 'core:mark-as-read': => @_onSetUnread(false)
+ 'core:report-as-spam': => @_onMarkAsSpam(false)
+ 'core:remove-and-previous': =>
+ @_shift(offset: -1, afterRunning: @_onRemoveFromView)
+ 'core:remove-and-next': =>
+ @_shift(offset: 1, afterRunning: @_onRemoveFromView)
+ 'thread-list:select-read': @_onSelectRead
+ 'thread-list:select-unread': @_onSelectUnread
+ 'thread-list:select-starred': @_onSelectStarred
+ 'thread-list:select-unstarred': @_onSelectUnstarred
+
+ _getFooter: ->
+ return null unless @state.syncing
+ return null if ThreadListStore.dataSource().count() <= 0
+ return
+
+ render: ->
+ if @state.style is 'wide'
+ columns = ThreadListColumns.Wide
+ itemHeight = 36
+ else
+ columns = ThreadListColumns.Narrow
+ itemHeight = 85
+
+ dataSource: ThreadListStore.dataSource() }>
+
+ Actions.popoutThread(thread)}
+ onDragStart={@_onDragStart}
+ onDragEnd={@_onDragEnd}
+ onComponentDidUpdate={@_onThreadListDidUpdate}
+ />
+
+
+
+ _onThreadListDidUpdate: =>
+ dataSource = ThreadListStore.dataSource()
+ threads = dataSource.itemsCurrentlyInView()
+ Actions.threadListDidUpdate(threads)
+
+ _threadPropsProvider: (item) ->
+ classes = classnames({
+ 'unread': item.unread
+ })
+ classes += ExtensionRegistry.ThreadList.extensions()
+ .filter((ext) => ext.cssClassNamesForThreadListItem?)
+ .reduce(((prev, ext) => prev + ' ' + ext.cssClassNamesForThreadListItem(item)), ' ')
+
+ props =
+ className: classes
+
+
+ # TODO this swiping logic needs some serious cleanup
+ props.shouldEnableSwipe = =>
+ perspective = FocusedPerspectiveStore.current()
+ tasks = perspective.tasksForRemovingItems([item], CategoryRemovalTargetRulesets.Default, "Swipe")
+ return tasks.length > 0
+
+ props.onSwipeRightClass = =>
+ perspective = FocusedPerspectiveStore.current()
+ tasks = perspective.tasksForRemovingItems([item], CategoryRemovalTargetRulesets.Default, "Swipe")
+ return null if tasks.length is 0
+
+ task = tasks[0]
+ name = if task instanceof ChangeStarredTask
+ 'unstar'
+ else if task.categoriesToAdd().length is 1
+ task.categoriesToAdd()[0].name
+ else
+ 'remove'
+
+ return "swipe-#{name}"
+
+ props.onSwipeRight = (callback) ->
+ perspective = FocusedPerspectiveStore.current()
+ tasks = perspective.tasksForRemovingItems([item], CategoryRemovalTargetRulesets.Default, "Swipe")
+ if tasks.length is 0
+ callback(false)
+ return
+ Actions.removeThreadsFromView({threads: [item], source: 'Swipe', ruleset: CategoryRemovalTargetRulesets.Default})
+ Actions.closePopover()
+ callback(true)
+
+ disabledPackages = NylasEnv.config.get('core.disabledPackages') ? []
+ if 'thread-snooze' in disabledPackages
+ return props
+
+ if FocusedPerspectiveStore.current().isInbox()
+ props.onSwipeLeftClass = 'swipe-snooze'
+ props.onSwipeCenter = =>
+ Actions.closePopover()
+ props.onSwipeLeft = (callback) =>
+ # TODO this should be grabbed from elsewhere
+ SnoozePopover = require('../../thread-snooze/lib/snooze-popover').default
+
+ element = document.querySelector("[data-item-id=\"#{item.id}\"]")
+ originRect = element.getBoundingClientRect()
+ Actions.openPopover(
+ ,
+ {originRect, direction: 'right', fallbackDirection: 'down'}
+ )
+
+ return props
+
+ _targetItemsForMouseEvent: (event) ->
+ itemThreadId = @refs.list.itemIdAtPoint(event.clientX, event.clientY)
+ unless itemThreadId
+ return null
+
+ dataSource = ThreadListStore.dataSource()
+ if itemThreadId in dataSource.selection.ids()
+ return {
+ threadIds: dataSource.selection.ids()
+ accountIds: _.uniq(_.pluck(dataSource.selection.items(), 'accountId'))
+ }
+ else
+ thread = dataSource.getById(itemThreadId)
+ return null unless thread
+ return {
+ threadIds: [thread.id]
+ accountIds: [thread.accountId]
+ }
+
+ _onSyncStatusChanged: =>
+ syncing = FocusedPerspectiveStore.current().hasSyncingCategories()
+ @setState({syncing})
+
+ _onShowContextMenu: (event) =>
+ data = @_targetItemsForMouseEvent(event)
+ if not data
+ event.preventDefault()
+ return
+ (new ThreadListContextMenu(data)).displayMenu()
+
+ _onDragStart: (event) =>
+ data = @_targetItemsForMouseEvent(event)
+ if not data
+ event.preventDefault()
+ return
+
+ event.dataTransfer.effectAllowed = "move"
+ event.dataTransfer.dragEffect = "move"
+
+ canvas = CanvasUtils.canvasWithThreadDragImage(data.threadIds.length)
+ event.dataTransfer.setDragImage(canvas, 10, 10)
+ event.dataTransfer.setData("nylas-threads-data", JSON.stringify(data))
+ event.dataTransfer.setData("nylas-accounts=#{data.accountIds.join(',')}", "1")
+ return
+
+ _onDragEnd: (event) =>
+
+ _onResize: (event) =>
+ current = @state.style
+ desired = if ReactDOM.findDOMNode(@).offsetWidth < 540 then 'narrow' else 'wide'
+ if current isnt desired
+ @setState(style: desired)
+
+ _threadsForKeyboardAction: ->
+ return null unless ThreadListStore.dataSource()
+ focused = FocusedContentStore.focused('thread')
+ if focused
+ return [focused]
+ else if ThreadListStore.dataSource().selection.count() > 0
+ return ThreadListStore.dataSource().selection.items()
+ else
+ return null
+
+ _onStarItem: =>
+ threads = @_threadsForKeyboardAction()
+ return unless threads
+ Actions.toggleStarredThreads({threads, source: "Keyboard Shortcut"})
+
+ _onSnoozeItem: =>
+ disabledPackages = NylasEnv.config.get('core.disabledPackages') ? []
+ if 'thread-snooze' in disabledPackages
+ return
+
+ threads = @_threadsForKeyboardAction()
+ return unless threads
+ # TODO this should be grabbed from elsewhere
+ SnoozePopover = require('../../thread-snooze/lib/snooze-popover').default
+
+ element = document.querySelector(".snooze-button.btn.btn-toolbar")
+ return unless element
+ originRect = element.getBoundingClientRect()
+ Actions.openPopover(
+ ,
+ {originRect, direction: 'down'}
+ )
+
+ _onSetImportant: (important) =>
+ threads = @_threadsForKeyboardAction()
+ return unless threads
+ return unless NylasEnv.config.get('core.workspace.showImportant')
+
+ if important
+ tasks = TaskFactory.tasksForApplyingCategories
+ source: "Keyboard Shortcut"
+ threads: threads
+ categoriesToRemove: (accountId) -> []
+ categoriesToAdd: (accountId) ->
+ [CategoryStore.getStandardCategory(accountId, 'important')]
+
+ else
+ tasks = TaskFactory.tasksForApplyingCategories
+ source: "Keyboard Shortcut"
+ threads: threads
+ categoriesToRemove: (accountId) ->
+ important = CategoryStore.getStandardCategory(accountId, 'important')
+ return [important] if important
+ return []
+
+ Actions.queueTasks(tasks)
+
+ _onSetUnread: (unread) =>
+ threads = @_threadsForKeyboardAction()
+ return unless threads
+ Actions.setUnreadThreads({threads, unread, source: "Keyboard Shortcut"})
+ Actions.popSheet()
+
+ _onMarkAsSpam: =>
+ threads = @_threadsForKeyboardAction()
+ return unless threads
+ Actions.markAsSpamThreads({
+ source: "Keyboard Shortcut",
+ threads: threads,
+ })
+
+ _onRemoveFromView: (ruleset = CategoryRemovalTargetRulesets.Default) =>
+ threads = @_threadsForKeyboardAction()
+ if not threads
+ return
+ Actions.removeThreadsFromView({threads, ruleset, source: "Keyboard Shortcut"})
+ Actions.popSheet()
+
+ _onArchiveItem: =>
+ threads = @_threadsForKeyboardAction()
+ if not threads
+ return
+ Actions.archiveThreads({threads, source: "Keyboard Shortcut"})
+ Actions.popSheet()
+
+ _onDeleteItem: =>
+ threads = @_threadsForKeyboardAction()
+ if threads
+ Actions.trashThreads({
+ source: "Keyboard Shortcut",
+ threads: threads,
+ })
+ Actions.popSheet()
+
+ _onSelectRead: =>
+ dataSource = ThreadListStore.dataSource()
+ items = dataSource.itemsCurrentlyInViewMatching (item) -> not item.unread
+ @refs.list.handler().onSelect(items)
+
+ _onSelectUnread: =>
+ dataSource = ThreadListStore.dataSource()
+ items = dataSource.itemsCurrentlyInViewMatching (item) -> item.unread
+ @refs.list.handler().onSelect(items)
+
+ _onSelectStarred: =>
+ dataSource = ThreadListStore.dataSource()
+ items = dataSource.itemsCurrentlyInViewMatching (item) -> item.starred
+ @refs.list.handler().onSelect(items)
+
+ _onSelectUnstarred: =>
+ dataSource = ThreadListStore.dataSource()
+ items = dataSource.itemsCurrentlyInViewMatching (item) -> not item.starred
+ @refs.list.handler().onSelect(items)
+
+module.exports = ThreadList
diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-toolbar-buttons.jsx b/packages/client-app/internal_packages/thread-list/lib/thread-toolbar-buttons.jsx
new file mode 100644
index 0000000000..21ae0f1662
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/lib/thread-toolbar-buttons.jsx
@@ -0,0 +1,320 @@
+import React from "react";
+import classNames from 'classnames';
+import {RetinaImg} from 'nylas-component-kit';
+import {
+ Actions,
+ TaskFactory,
+ AccountStore,
+ CategoryStore,
+ FocusedContentStore,
+ FocusedPerspectiveStore,
+} from "nylas-exports";
+
+import ThreadListStore from './thread-list-store';
+
+
+export class ArchiveButton extends React.Component {
+ static displayName = 'ArchiveButton';
+ static containerRequired = false;
+
+ static propTypes = {
+ items: React.PropTypes.array.isRequired,
+ }
+
+ _onArchive = (event) => {
+ Actions.archiveThreads({
+ threads: this.props.items,
+ source: "Toolbar Button: Thread List",
+ })
+ Actions.popSheet();
+ event.stopPropagation();
+ return;
+ }
+
+ render() {
+ const allowed = FocusedPerspectiveStore.current().canArchiveThreads(this.props.items);
+ if (!allowed) {
+ return ;
+ }
+
+ return (
+
+
+
+ )
+ }
+}
+
+export class TrashButton extends React.Component {
+ static displayName = 'TrashButton'
+ static containerRequired = false;
+
+ static propTypes = {
+ items: React.PropTypes.array.isRequired,
+ }
+
+ _onRemove = (event) => {
+ Actions.trashThreads({threads: this.props.items, source: "Toolbar Button: Thread List"});
+ Actions.popSheet();
+ event.stopPropagation();
+ return;
+ }
+
+ render() {
+ const allowed = FocusedPerspectiveStore.current().canMoveThreadsTo(this.props.items, 'trash')
+ if (!allowed) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+ }
+}
+
+export class MarkAsSpamButton extends React.Component {
+ static displayName = 'MarkAsSpamButton';
+ static containerRequired = false;
+
+ static propTypes = {
+ items: React.PropTypes.array.isRequired,
+ }
+
+ _allInSpam() {
+ return this.props.items.every(item => item.categories.map(c => c.name).includes('spam'));
+ }
+
+ _onNotSpam = (event) => {
+ const tasks = TaskFactory.tasksForApplyingCategories({
+ source: "Toolbar Button: Thread List",
+ threads: this.props.items,
+ categoriesToAdd: (accountId) => {
+ const account = AccountStore.accountForId(accountId)
+ return account.usesFolders() ? [CategoryStore.getInboxCategory(accountId)] : [];
+ },
+ categoriesToRemove: (accountId) => {
+ return [CategoryStore.getSpamCategory(accountId)];
+ },
+ })
+ Actions.queueTasks(tasks);
+ Actions.popSheet();
+ event.stopPropagation();
+ return;
+ }
+
+ _onMarkAsSpam = (event) => {
+ Actions.markAsSpamThreads({threads: this.props.items, source: "Toolbar Button: Thread List"});
+ Actions.popSheet();
+ event.stopPropagation();
+ return;
+ }
+
+ render() {
+ if (this._allInSpam()) {
+ return (
+
+
+
+ )
+ }
+
+ const allowed = FocusedPerspectiveStore.current().canMoveThreadsTo(this.props.items, 'spam');
+ if (!allowed) {
+ return ;
+ }
+ return (
+
+
+
+ );
+ }
+}
+
+export class ToggleStarredButton extends React.Component {
+ static displayName = 'ToggleStarredButton';
+ static containerRequired = false;
+
+ static propTypes = {
+ items: React.PropTypes.array.isRequired,
+ };
+
+ _onStar = (event) => {
+ Actions.toggleStarredThreads({threads: this.props.items, source: "Toolbar Button: Thread List"});
+ event.stopPropagation();
+ return;
+ }
+
+ render() {
+ const postClickStarredState = this.props.items.every((t) => t.starred === false);
+ const title = postClickStarredState ? "Star" : "Unstar";
+ const imageName = postClickStarredState ? "toolbar-star.png" : "toolbar-star-selected.png"
+
+ return (
+
+
+
+ );
+ }
+}
+
+export class ToggleUnreadButton extends React.Component {
+ static displayName = 'ToggleUnreadButton';
+ static containerRequired = false;
+
+ static propTypes = {
+ items: React.PropTypes.array.isRequired,
+ }
+
+ _onClick = (event) => {
+ Actions.toggleUnreadThreads({threads: this.props.items, source: "Toolbar Button: Thread List"});
+ Actions.popSheet();
+ event.stopPropagation();
+ return;
+ }
+
+ render() {
+ const postClickUnreadState = this.props.items.every(t => t.unread === false);
+ const fragment = postClickUnreadState ? "unread" : "read";
+
+ return (
+
+
+
+ );
+ }
+}
+
+class ThreadArrowButton extends React.Component {
+ static propTypes = {
+ getStateFromStores: React.PropTypes.func,
+ direction: React.PropTypes.string,
+ command: React.PropTypes.string,
+ title: React.PropTypes.string,
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = this.props.getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._unsubscribe = ThreadListStore.listen(this._onStoreChange);
+ this._unsubscribe_focus = FocusedContentStore.listen(this._onStoreChange);
+ }
+
+ componentWillUnmount() {
+ this._unsubscribe();
+ this._unsubscribe_focus();
+ }
+
+ _onClick = () => {
+ if (this.state.disabled) {
+ return;
+ }
+ NylasEnv.commands.dispatch(this.props.command);
+ return;
+ }
+
+ _onStoreChange = () => {
+ this.setState(this.props.getStateFromStores());
+ }
+
+ render() {
+ const {direction, title} = this.props;
+ const classes = classNames({
+ "btn-icon": true,
+ "message-toolbar-arrow": true,
+ "disabled": this.state.disabled,
+ });
+
+ return (
+
+
+
+ );
+ }
+}
+
+export const DownButton = () => {
+ const getStateFromStores = () => {
+ const selectedId = FocusedContentStore.focusedId('thread');
+ const lastIndex = ThreadListStore.dataSource().count() - 1
+ const lastItem = ThreadListStore.dataSource().get(lastIndex);
+ return {
+ disabled: (lastItem && lastItem.id === selectedId),
+ };
+ }
+
+ return (
+
+ );
+}
+DownButton.displayName = 'DownButton';
+DownButton.containerRequired = false;
+
+export const UpButton = () => {
+ const getStateFromStores = () => {
+ const selectedId = FocusedContentStore.focusedId('thread');
+ const item = ThreadListStore.dataSource().get(0)
+ return {
+ disabled: (item && item.id === selectedId),
+ };
+ }
+
+ return (
+
+ );
+}
+UpButton.displayName = 'UpButton';
+UpButton.containerRequired = false;
diff --git a/packages/client-app/internal_packages/thread-list/package.json b/packages/client-app/internal_packages/thread-list/package.json
new file mode 100755
index 0000000000..c37b0f4e98
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "thread-list",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "View threads using React",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/thread-list/spec/category-removal-target-rulesets-spec.es6 b/packages/client-app/internal_packages/thread-list/spec/category-removal-target-rulesets-spec.es6
new file mode 100644
index 0000000000..fcceae8beb
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/spec/category-removal-target-rulesets-spec.es6
@@ -0,0 +1,29 @@
+import {AccountStore, CategoryStore} from 'nylas-exports'
+import CategoryRemovalTargetRulesets from '../lib/category-removal-target-rulesets'
+const {Gmail} = CategoryRemovalTargetRulesets;
+
+describe('CategoryRemovalTargetRulesets', function categoryRemovalTargetRulesets() {
+ describe('Gmail', () => {
+ it('is a no op in archive, all, spam and sent', () => {
+ expect(Gmail.all).toBe(null)
+ expect(Gmail.sent).toBe(null)
+ expect(Gmail.spam).toBe(null)
+ expect(Gmail.archive).toBe(null)
+ });
+
+ describe('default', () => {
+ it('moves to archive if account uses folders', () => {
+ const account = {usesFolders: () => true}
+ spyOn(AccountStore, 'accountForId').andReturn(account)
+ spyOn(CategoryStore, 'getArchiveCategory').andReturn('archive')
+ expect(Gmail.other('a1')).toEqual('archive')
+ });
+
+ it('moves to nowhere if account uses labels', () => {
+ const account = {usesFolders: () => false}
+ spyOn(AccountStore, 'accountForId').andReturn(account)
+ expect(Gmail.other('a1')).toBe(null)
+ });
+ });
+ });
+});
diff --git a/packages/client-app/internal_packages/thread-list/spec/thread-list-column-spec.coffee b/packages/client-app/internal_packages/thread-list/spec/thread-list-column-spec.coffee
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/client-app/internal_packages/thread-list/spec/thread-list-participants-spec.cjsx b/packages/client-app/internal_packages/thread-list/spec/thread-list-participants-spec.cjsx
new file mode 100644
index 0000000000..bec3690b6a
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/spec/thread-list-participants-spec.cjsx
@@ -0,0 +1,306 @@
+React = require "react"
+ReactTestUtils = require('react-addons-test-utils')
+
+_ = require 'underscore'
+{AccountStore, Thread, Contact, Message} = require 'nylas-exports'
+ThreadListParticipants = require '../lib/thread-list-participants'
+
+describe "ThreadListParticipants", ->
+
+ beforeEach ->
+ @account = AccountStore.accounts()[0]
+
+ it "renders into the document", ->
+ @participants = ReactTestUtils.renderIntoDocument(
+
+ )
+ expect(ReactTestUtils.isCompositeComponentWithType(@participants, ThreadListParticipants)).toBe true
+
+ it "renders unread contacts with .unread-true", ->
+ ben = new Contact(email: 'ben@nylas.com', name: 'ben')
+ ben.unread = true
+ thread = new Thread()
+ thread.__messages = [new Message(from: [ben], unread:true)]
+
+ @participants = ReactTestUtils.renderIntoDocument(
+
+ )
+ unread = ReactTestUtils.scryRenderedDOMComponentsWithClass(@participants, 'unread-true')
+ expect(unread.length).toBe(1)
+
+ describe "getTokens", ->
+ beforeEach ->
+ @ben = new Contact(email: 'ben@nylas.com', name: 'ben')
+ @evan = new Contact(email: 'evan@nylas.com', name: 'evan')
+ @evanAgain = new Contact(email: 'evan@nylas.com', name: 'evan')
+ @michael = new Contact(email: 'michael@nylas.com', name: 'michael')
+ @kavya = new Contact(email: 'kavya@nylas.com', name: 'kavya')
+ @phab1 = new Contact(email: 'no-reply@phab.nylas.com', name: 'Ben')
+ @phab2 = new Contact(email: 'no-reply@phab.nylas.com', name: 'MG')
+
+ describe "when thread.messages is available", ->
+ it "correctly produces items for display in a wide range of scenarios", ->
+ scenarios = [{
+ name: 'single read email'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ ]
+ out: [{contact: @ben, unread: false}]
+ },{
+ name: 'single read email and draft'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(from: [@ben], draft: true),
+ ]
+ out: [{contact: @ben, unread: false}]
+ },{
+ name: 'single unread email'
+ in: [
+ new Message(unread: true, from: [@evan]),
+ ]
+ out: [{contact: @evan, unread: true}]
+ },{
+ name: 'single unread response'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: true, from: [@evan]),
+ ]
+ out: [{contact: @ben, unread: false}, {contact: @evan, unread: true}]
+ },{
+ name: 'two unread responses'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: true, from: [@evan]),
+ new Message(unread: true, from: [@kavya]),
+ ]
+ out: [{contact: @ben, unread: false},
+ {contact: @evan, unread: true},
+ {contact: @kavya, unread: true}]
+ },{
+ name: 'two unread responses (repeated participants)'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: true, from: [@evan]),
+ new Message(unread: true, from: [@evanAgain]),
+ ]
+ out: [{contact: @ben, unread: false}, {contact: @evan, unread: true}]
+ },{
+ name: 'three unread responses (repeated participants)'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: true, from: [@evan]),
+ new Message(unread: true, from: [@michael]),
+ new Message(unread: true, from: [@evanAgain]),
+ ]
+ out: [{contact: @ben, unread: false},
+ {spacer: true},
+ {contact: @michael, unread: true},
+ {contact: @evanAgain, unread: true}]
+ },{
+ name: 'three unread responses'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: true, from: [@evan]),
+ new Message(unread: true, from: [@michael]),
+ new Message(unread: true, from: [@kavya]),
+ ]
+ out: [{contact: @ben, unread: false},
+ {spacer: true},
+ {contact: @michael, unread: true},
+ {contact: @kavya, unread: true}]
+ },{
+ name: 'ends with two emails from the same person, second one is unread'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: false, from: [@evan]),
+ new Message(unread: false, from: [@kavya]),
+ new Message(unread: true, from: [@kavya]),
+ ]
+ out: [{contact: @ben, unread: false},
+ {contact: @evan, unread: false},
+ {contact: @kavya, unread: true}]
+ },{
+ name: 'three unread responses to long thread'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: false, from: [@evan]),
+ new Message(unread: false, from: [@michael]),
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: true, from: [@evanAgain]),
+ new Message(unread: true, from: [@michael]),
+ new Message(unread: true, from: [@evanAgain]),
+ ]
+ out: [{contact: @ben, unread: false},
+ {spacer: true},
+ {contact: @michael, unread: true},
+ {contact: @evanAgain, unread: true}]
+ },{
+ name: 'single unread responses to long thread'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: false, from: [@evan]),
+ new Message(unread: false, from: [@michael]),
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: true, from: [@evanAgain]),
+ ]
+ out: [{contact: @ben, unread: false},
+ {spacer: true},
+ {contact: @ben, unread: false},
+ {contact: @evanAgain, unread: true}]
+ },{
+ name: 'long read thread'
+ in: [
+ new Message(unread: false, from: [@ben]),
+ new Message(unread: false, from: [@evan]),
+ new Message(unread: false, from: [@michael]),
+ new Message(unread: false, from: [@ben]),
+ ]
+ out: [{contact: @ben, unread: false},
+ {spacer: true},
+ {contact: @michael, unread: false},
+ {contact: @ben, unread: false}]
+ },{
+ name: 'thread with different participants with the same email address'
+ in: [
+ new Message(unread: false, from: [@phab1]),
+ new Message(unread: false, from: [@phab2])
+ ]
+ out: [{contact: @phab1, unread: false},
+ {contact: @phab2, unread: false}]
+ }]
+
+ for scenario in scenarios
+ thread = new Thread()
+ thread.__messages = scenario.in
+ participants = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ expect(participants.getTokens()).toEqual(scenario.out)
+
+ # Slightly misuse jasmine to get the output we want to show
+ if (!_.isEqual(participants.getTokens(), scenario.out))
+ expect(scenario.name).toBe('correct')
+
+ describe "when getTokens() called and current user is only sender", ->
+ beforeEach ->
+ @me = @account.me()
+ @ben = new Contact(email: 'ben@nylas.com', name: 'ben')
+ @evan = new Contact(email: 'evan@nylas.com', name: 'evan')
+ @evanCapitalized = new Contact(email: 'EVAN@nylas.com', name: 'evan')
+ @michael = new Contact(email: 'michael@nylas.com', name: 'michael')
+ @kavya = new Contact(email: 'kavya@nylas.com', name: 'kavya')
+
+ getTokens = (threadMessages) ->
+ thread = new Thread()
+ thread.__messages = threadMessages
+ participants = ReactTestUtils.renderIntoDocument(
+
+ )
+ participants.getTokens()
+
+ it "shows only recipients for emails sent from me to different recipients", ->
+ input = [new Message(unread: false, from: [@me], to: [@ben])
+ new Message(unread: false, from: [@me], to: [@evan])
+ new Message(unread: false, from: [@me], to: [@ben])]
+ actualOut = getTokens(input)
+ expectedOut = [{contact: @ben, unread: false}
+ {contact: @evan, unread: false}
+ {contact: @ben, unread: false}]
+ expect(actualOut).toEqual expectedOut
+
+ it "is case insensitive", ->
+ input = [new Message(unread: false, from: [@me], to: [@evan])
+ new Message(unread: false, from: [@me], to: [@evanCapitalized])]
+ actualOut = getTokens(input)
+ expectedOut = [{contact: @evan, unread: false}]
+ expect(actualOut).toEqual expectedOut
+
+ it "shows only first, spacer, second to last, and last recipients if recipients count > 3", ->
+ input = [new Message(unread: false, from: [@me], to: [@ben])
+ new Message(unread: false, from: [@me], to: [@evan])
+ new Message(unread: false, from: [@me], to: [@michael])
+ new Message(unread: false, from: [@me], to: [@kavya])]
+ actualOut = getTokens(input)
+ expectedOut = [{contact: @ben, unread: false}
+ {spacer: true}
+ {contact: @michael, unread: false}
+ {contact: @kavya, unread: false}]
+ expect(actualOut).toEqual expectedOut
+
+ it "shows correct recipients even if only one email", ->
+ input = [new Message(unread: false, from: [@me], to: [@ben, @evan, @michael, @kavya])]
+ actualOut = getTokens(input)
+ expectedOut = [{contact: @ben, unread: false}
+ {spacer: true}
+ {contact: @michael, unread: false}
+ {contact: @kavya, unread: false}]
+ expect(actualOut).toEqual expectedOut
+
+ it "shows only one recipient if the sender only sent to one recipient", ->
+ input = [new Message(unread: false, from: [@me], to: [@evan])
+ new Message(unread: false, from: [@me], to: [@evan])
+ new Message(unread: false, from: [@me], to: [@evan])
+ new Message(unread: false, from: [@me], to: [@evan])]
+ actualOut = getTokens(input)
+ expectedOut = [{contact: @evan, unread: false}]
+ expect(actualOut).toEqual expectedOut
+
+ it "shows only the recipient for one sent email", ->
+ input = [new Message(unread: false, from: [@me], to: [@evan])]
+ actualOut = getTokens(input)
+ expectedOut = [{contact: @evan, unread: false}]
+ expect(actualOut).toEqual expectedOut
+
+ it "shows unread email as well", ->
+ input = [new Message(unread: false, from: [@me], to: [@evan])
+ new Message(unread: false, from: [@me], to: [@ben])
+ new Message(unread: true, from: [@me], to: [@kavya])
+ new Message(unread: true, from: [@me], to: [@michael])]
+ actualOut = getTokens(input)
+ expectedOut = [{contact: @evan, unread: false},
+ {spacer: true},
+ {contact: @kavya, unread: true},
+ {contact: @michael, unread: true}]
+ expect(actualOut).toEqual expectedOut
+
+ describe "when thread.messages is not available", ->
+ it "correctly produces items for display in a wide range of scenarios", ->
+ me = @account.me()
+ scenarios = [{
+ name: 'one participant'
+ in: [@ben]
+ out: [{contact: @ben, unread: false}]
+ },{
+ name: 'one participant (me)'
+ in: [me]
+ out: [{contact: me, unread: false}]
+ },{
+ name: 'two participants'
+ in: [@evan, @ben]
+ out: [{contact: @evan, unread: false}, {contact: @ben, unread: false}]
+ },{
+ name: 'two participants (me)'
+ in: [@ben, me]
+ out: [{contact: @ben, unread: false}]
+ },{
+ name: 'lots of participants'
+ in: [@ben, @evan, @michael, @kavya]
+ out: [{contact: @ben, unread: false},
+ {spacer: true},
+ {contact: @michael, unread: false},
+ {contact: @kavya, unread: false}]
+ }]
+
+ for scenario in scenarios
+ thread = new Thread()
+ thread.participants = scenario.in
+ participants = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ expect(participants.getTokens()).toEqual(scenario.out)
+
+ # Slightly misuse jasmine to get the output we want to show
+ if (!_.isEqual(participants.getTokens(), scenario.out))
+ expect(scenario.name).toBe('correct')
diff --git a/packages/client-app/internal_packages/thread-list/spec/thread-list-spec.cjsx b/packages/client-app/internal_packages/thread-list/spec/thread-list-spec.cjsx
new file mode 100644
index 0000000000..6c0122ae11
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/spec/thread-list-spec.cjsx
@@ -0,0 +1,282 @@
+
+
+
+
+
+return
+
+
+
+
+
+
+moment = require "moment"
+_ = require 'underscore'
+React = require "react"
+ReactTestUtils = require('react-addons-test-utils')
+ReactTestUtils = _.extend ReactTestUtils, require "jasmine-react-helpers"
+
+{Thread,
+ Actions,
+ Account,
+ DatabaseStore,
+ WorkspaceStore,
+ NylasTestUtils,
+ AccountStore,
+ ComponentRegistry} = require "nylas-exports"
+{ListTabular} = require 'nylas-component-kit'
+
+
+ThreadStore = require "../lib/thread-store"
+ThreadList = require "../lib/thread-list"
+
+test_threads = -> [
+ (new Thread).fromJSON({
+ "id": "111",
+ "object": "thread",
+ "created_at": null,
+ "updated_at": null,
+ "account_id": TEST_ACCOUNT_ID,
+ "snippet": "snippet 111",
+ "subject": "Subject 111",
+ "tags": [
+ {
+ "id": "unseen",
+ "created_at": null,
+ "updated_at": null,
+ "name": "unseen"
+ },
+ {
+ "id": "all",
+ "created_at": null,
+ "updated_at": null,
+ "name": "all"
+ },
+ {
+ "id": "inbox",
+ "created_at": null,
+ "updated_at": null,
+ "name": "inbox"
+ },
+ {
+ "id": "unread",
+ "created_at": null,
+ "updated_at": null,
+ "name": "unread"
+ },
+ {
+ "id": "attachment",
+ "created_at": null,
+ "updated_at": null,
+ "name": "attachment"
+ }
+ ],
+ "participants": [
+ {
+ "created_at": null,
+ "updated_at": null,
+ "name": "User One",
+ "email": "user1@nylas.com"
+ },
+ {
+ "created_at": null,
+ "updated_at": null,
+ "name": "User Two",
+ "email": "user2@nylas.com"
+ }
+ ],
+ "last_message_received_timestamp": 1415742036
+ }),
+ (new Thread).fromJSON({
+ "id": "222",
+ "object": "thread",
+ "created_at": null,
+ "updated_at": null,
+ "account_id": TEST_ACCOUNT_ID,
+ "snippet": "snippet 222",
+ "subject": "Subject 222",
+ "tags": [
+ {
+ "id": "unread",
+ "created_at": null,
+ "updated_at": null,
+ "name": "unread"
+ },
+ {
+ "id": "all",
+ "created_at": null,
+ "updated_at": null,
+ "name": "all"
+ },
+ {
+ "id": "unseen",
+ "created_at": null,
+ "updated_at": null,
+ "name": "unseen"
+ },
+ {
+ "id": "inbox",
+ "created_at": null,
+ "updated_at": null,
+ "name": "inbox"
+ }
+ ],
+ "participants": [
+ {
+ "created_at": null,
+ "updated_at": null,
+ "name": "User One",
+ "email": "user1@nylas.com"
+ },
+ {
+ "created_at": null,
+ "updated_at": null,
+ "name": "User Three",
+ "email": "user3@nylas.com"
+ }
+ ],
+ "last_message_received_timestamp": 1415741913
+ }),
+ (new Thread).fromJSON({
+ "id": "333",
+ "object": "thread",
+ "created_at": null,
+ "updated_at": null,
+ "account_id": TEST_ACCOUNT_ID,
+ "snippet": "snippet 333",
+ "subject": "Subject 333",
+ "tags": [
+ {
+ "id": "inbox",
+ "created_at": null,
+ "updated_at": null,
+ "name": "inbox"
+ },
+ {
+ "id": "all",
+ "created_at": null,
+ "updated_at": null,
+ "name": "all"
+ },
+ {
+ "id": "unseen",
+ "created_at": null,
+ "updated_at": null,
+ "name": "unseen"
+ }
+ ],
+ "participants": [
+ {
+ "created_at": null,
+ "updated_at": null,
+ "name": "User One",
+ "email": "user1@nylas.com"
+ },
+ {
+ "created_at": null,
+ "updated_at": null,
+ "name": "User Four",
+ "email": "user4@nylas.com"
+ }
+ ],
+ "last_message_received_timestamp": 1415741837
+ })
+]
+
+
+cjsxSubjectResolver = (thread) ->
+
+ Subject {thread.id}
+ Snippet
+
+
+describe "ThreadList", ->
+
+ Foo = React.createClass({render: -> {@props.children}
})
+ c1 = new ListTabular.Column
+ name: "Name"
+ flex: 1
+ resolver: (thread) -> "#{thread.id} Test Name"
+ c2 = new ListTabular.Column
+ name: "Subject"
+ flex: 3
+ resolver: cjsxSubjectResolver
+ c3 = new ListTabular.Column
+ name: "Date"
+ resolver: (thread) -> {thread.id}
+
+ columns = [c1,c2,c3]
+
+ beforeEach ->
+ NylasTestUtils.loadKeymap("internal_packages/thread-list/keymaps/thread-list")
+ spyOn(ThreadStore, "_onAccountChanged")
+ spyOn(DatabaseStore, "findAll").andCallFake ->
+ new Promise (resolve, reject) -> resolve(test_threads())
+ ReactTestUtils.spyOnClass(ThreadList, "_prepareColumns").andCallFake ->
+ @_columns = columns
+
+ ThreadStore._resetInstanceVars()
+
+ @thread_list = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "renders into the document", ->
+ expect(ReactTestUtils.isCompositeComponentWithType(@thread_list,
+ ThreadList)).toBe true
+
+ it "has the expected columns", ->
+ expect(@thread_list._columns).toEqual columns
+
+ it "by default has zero children", ->
+ items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list, ListTabular.Item)
+ expect(items.length).toBe 0
+
+ describe "when the workspace is in list mode", ->
+ beforeEach ->
+ spyOn(WorkspaceStore, "layoutMode").andReturn "list"
+ @thread_list.setState focusedId: "t111"
+
+ it "allows reply only when the sheet type is 'Thread'", ->
+ spyOn(WorkspaceStore, "sheet").andCallFake -> {type: "Thread"}
+ spyOn(Actions, "composeReply")
+ @thread_list._onReply()
+ expect(Actions.composeReply).toHaveBeenCalled()
+ expect(@thread_list._actionInVisualScope()).toBe true
+
+ it "doesn't reply only when the sheet type isnt 'Thread'", ->
+ spyOn(WorkspaceStore, "sheet").andCallFake -> {type: "Root"}
+ spyOn(Actions, "composeReply")
+ @thread_list._onReply()
+ expect(Actions.composeReply).not.toHaveBeenCalled()
+ expect(@thread_list._actionInVisualScope()).toBe false
+
+ describe "when the workspace is in split mode", ->
+ beforeEach ->
+ spyOn(WorkspaceStore, "layoutMode").andReturn "split"
+ @thread_list.setState focusedId: "t111"
+
+ it "allows reply and reply-all regardless of sheet type", ->
+ spyOn(WorkspaceStore, "sheet").andCallFake -> {type: "anything"}
+ spyOn(Actions, "composeReply")
+ @thread_list._onReply()
+ expect(Actions.composeReply).toHaveBeenCalled()
+ expect(@thread_list._actionInVisualScope()).toBe true
+
+ describe "Populated thread list", ->
+ beforeEach ->
+ view =
+ loaded: -> true
+ get: (i) -> test_threads()[i]
+ count: -> test_threads().length
+ setRetainedRange: ->
+ ThreadStore._view = view
+ ThreadStore._focusedId = null
+ ThreadStore.trigger(ThreadStore)
+ @thread_list_node = ReactDOM.findDOMNode(@thread_list)
+ spyOn(@thread_list, "setState").andCallThrough()
+
+ it "renders all of the thread list items", ->
+ advanceClock(100)
+ items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list, ListTabular.Item)
+ expect(items.length).toBe(test_threads().length)
diff --git a/packages/client-app/internal_packages/thread-list/spec/thread-toolbar-buttons-spec.cjsx b/packages/client-app/internal_packages/thread-list/spec/thread-toolbar-buttons-spec.cjsx
new file mode 100644
index 0000000000..1ee0def343
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/spec/thread-toolbar-buttons-spec.cjsx
@@ -0,0 +1,111 @@
+React = require "react"
+ReactDOM = require "react-dom"
+ReactTestUtils = require 'react-addons-test-utils'
+{
+ Thread,
+ FocusedContentStore,
+ Actions,
+ CategoryStore,
+ ChangeUnreadTask,
+ MailboxPerspective
+} = require "nylas-exports"
+{ToggleStarredButton, ToggleUnreadButton, MarkAsSpamButton} = require '../lib/thread-toolbar-buttons'
+
+test_thread = (new Thread).fromJSON({
+ "id" : "thread_12345"
+ "account_id": TEST_ACCOUNT_ID
+ "subject" : "Subject 12345"
+ "starred": false
+})
+
+test_thread_starred = (new Thread).fromJSON({
+ "id" : "thread_starred_12345"
+ "account_id": TEST_ACCOUNT_ID
+ "subject" : "Subject 12345"
+ "starred": true
+})
+
+describe "ThreadToolbarButtons", ->
+ beforeEach ->
+ spyOn Actions, "queueTask"
+ spyOn Actions, "queueTasks"
+ spyOn Actions, "toggleStarredThreads"
+ spyOn Actions, "toggleUnreadThreads"
+
+ describe "Starring", ->
+ it "stars a thread if the star button is clicked and thread is unstarred", ->
+ starButton = ReactTestUtils.renderIntoDocument()
+
+ ReactTestUtils.Simulate.click ReactDOM.findDOMNode(starButton)
+
+ expect(Actions.toggleStarredThreads.mostRecentCall.args[0].threads).toEqual([test_thread])
+
+ it "unstars a thread if the star button is clicked and thread is starred", ->
+ starButton = ReactTestUtils.renderIntoDocument()
+
+ ReactTestUtils.Simulate.click ReactDOM.findDOMNode(starButton)
+
+ expect(Actions.toggleStarredThreads.mostRecentCall.args[0].threads).toEqual([test_thread_starred])
+
+ describe "Marking as unread", ->
+ thread = null
+ markUnreadBtn = null
+
+ beforeEach ->
+ thread = new Thread(id: "thread-id-lol-123", accountId: TEST_ACCOUNT_ID, unread: false)
+ markUnreadBtn = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "queues a task to change unread status to true", ->
+ ReactTestUtils.Simulate.click ReactDOM.findDOMNode(markUnreadBtn).childNodes[0]
+ expect(Actions.toggleUnreadThreads.mostRecentCall.args[0].threads).toEqual([thread])
+
+ it "returns to the thread list", ->
+ spyOn Actions, "popSheet"
+ ReactTestUtils.Simulate.click ReactDOM.findDOMNode(markUnreadBtn).childNodes[0]
+
+ expect(Actions.popSheet).toHaveBeenCalled()
+
+ describe "Marking as spam", ->
+ thread = null
+ markSpamButton = null
+
+ describe "when the thread is already in spam", ->
+ beforeEach ->
+ thread = new Thread({
+ id: "thread-id-lol-123",
+ accountId: TEST_ACCOUNT_ID,
+ categories: [{name: 'spam'}]
+ })
+ markSpamButton = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "queues a task to remove spam", ->
+ spyOn(CategoryStore, 'getSpamCategory').andReturn(thread.categories[0])
+ ReactTestUtils.Simulate.click(ReactDOM.findDOMNode(markSpamButton))
+ {labelsToAdd, labelsToRemove} = Actions.queueTasks.mostRecentCall.args[0][0]
+ expect(labelsToAdd).toEqual([])
+ expect(labelsToRemove).toEqual([thread.categories[0]])
+
+ describe "when the thread can be moved to spam", ->
+ beforeEach ->
+ spyOn(MailboxPerspective.prototype, 'canMoveThreadsTo').andReturn(true)
+ thread = new Thread(id: "thread-id-lol-123", accountId: TEST_ACCOUNT_ID, categories: [])
+ markSpamButton = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it "queues a task to mark as spam", ->
+ spyOn(Actions, 'markAsSpamThreads')
+ ReactTestUtils.Simulate.click(ReactDOM.findDOMNode(markSpamButton))
+ expect(Actions.markAsSpamThreads).toHaveBeenCalledWith({
+ threads: [thread],
+ source: 'Toolbar Button: Thread List'
+ })
+
+ it "returns to the thread list", ->
+ spyOn(Actions, 'popSheet')
+ ReactTestUtils.Simulate.click(ReactDOM.findDOMNode(markSpamButton))
+ expect(Actions.popSheet).toHaveBeenCalled()
diff --git a/packages/client-app/internal_packages/thread-list/stylesheets/selected-items-stack.less b/packages/client-app/internal_packages/thread-list/stylesheets/selected-items-stack.less
new file mode 100644
index 0000000000..23d0742766
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/stylesheets/selected-items-stack.less
@@ -0,0 +1,50 @@
+@import "ui-variables";
+@img-path: "../internal_packages/thread-list/assets/graphic-stackable-card-filled.svg";
+
+.selected-items-stack {
+ display: flex;
+ align-self: center;
+ align-items: center;
+ height: 100%;
+
+ .selected-items-stack-content {
+ display: flex;
+ position: relative;
+ align-items: center;
+ justify-content: center;
+ width: 198px;
+ height: 268px;
+
+ .stack {
+ .card {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 198px;
+ height: 268px;
+ background: url(@img-path);
+ background-size: 198px 268px;
+ }
+ }
+
+ .count-info {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ z-index: 6;
+
+ .count {
+ font-size: 4em;
+ font-weight: 200;
+ color: @text-color-very-subtle;
+ }
+ .count-message {
+ padding-top: @padding-base-vertical;
+ color: @text-color-very-subtle;
+ }
+ .clear.btn {
+ margin-top: @padding-large-vertical * 2;
+ }
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/thread-list/stylesheets/thread-list.less b/packages/client-app/internal_packages/thread-list/stylesheets/thread-list.less
new file mode 100644
index 0000000000..d376c60b06
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-list/stylesheets/thread-list.less
@@ -0,0 +1,510 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+@scrollbar-margin: 8px;
+
+// MIXINS
+
+.inverseContent() {
+ // Note: these styles are also applied below
+ // subpixel antialiasing looks awful against dark background colors
+ -webkit-font-smoothing: antialiased;
+
+ color: @text-color-inverse;
+
+ .participants {
+ .unread-true {
+ font-weight: @font-weight-normal;
+ }
+ }
+ .subject {
+ font-weight: @font-weight-normal;
+ }
+
+ .thread-icon, .draft-icon, .mail-important-icon {
+ -webkit-filter: brightness(600%) grayscale(100%);
+ }
+
+ .mail-label {
+ // Note - these !important styles override values set by a style tag
+ // since the color of the label is detemined programatically.
+ background: fade(@text-color-inverse, 20%) !important;
+ box-shadow: none !important;
+ -webkit-filter: brightness(600%) grayscale(100%);
+ }
+
+}
+
+// STYLES
+
+*:focus, input:focus {
+ outline:none;
+}
+
+.thread-list, .draft-list {
+ .list-container, .scroll-region {
+ width:100%;
+ height:100%;
+ -webkit-font-smoothing: subpixel-antialiased;
+ }
+
+ .swipe-backing {
+ background-color: darken(@background-primary, 10%);
+ &::after {
+ color: fade(white, 90%);
+ padding-top: 45px;
+ text-align: center;
+ font-weight: 400;
+ font-size: @font-size-small;
+ position: absolute;
+ top: 0;
+ transform: translateX(0%);
+ width: 80px;
+ bottom: 0;
+ opacity: 0.8;
+ transition: opacity linear 150ms;
+ background-repeat: no-repeat;
+ background-position: 50% 35%;
+ background-size: 24px 24px;
+ }
+
+ &.swipe-trash {
+ transition: background-color linear 150ms;
+ background-color: mix(#ed304b, @background-primary, 75%);
+ &::after {
+ transition: left linear 150ms, transform linear 150ms;
+ content: "Trash";
+ left: 0;
+ background-image: url(../static/images/swipe/icon-swipe-trash@2x.png);
+ }
+ &.confirmed {
+ background-color: #ed304b;
+ &::after {
+ left: 100%;
+ transform: translateX(-100%);
+ opacity: 1;
+ }
+ }
+ }
+ &.swipe-archive,&.swipe-all {
+ transition: background-color linear 150ms;
+ background-color: mix(#6cd420, @background-primary, 75%);
+ &::after {
+ transition: left linear 150ms, transform linear 150ms;
+ content: "Archive";
+ left: 0;
+ background-image: url(../static/images/swipe/icon-swipe-archive@2x.png);
+ }
+ &.confirmed {
+ background-color: #6cd420;
+ &::after {
+ left: 100%;
+ transform: translateX(-100%);
+ opacity: 1;
+ }
+ }
+ }
+ &.swipe-snooze {
+ transition: background-color linear 150ms;
+ background-color: mix(#8d6be3, @background-primary, 75%);
+ &::after {
+ transition: right linear 150ms, transform linear 150ms;
+ content: "Snooze";
+ right: 0;
+ background-image: url(../static/images/swipe/icon-swipe-snooze@2x.png);
+ }
+ &.confirmed {
+ background-color: #8d6be3;
+ &::after {
+ right: 100%;
+ transform: translateX(100%);
+ opacity: 1;
+ }
+ }
+ }
+ }
+
+ .list-item {
+ background-color: darken(@background-primary, 2%);
+ border-bottom: 1px solid fade(@list-border, 60%);
+ line-height: 36px;
+ }
+
+ .mail-important-icon {
+ margin-left:6px;
+ padding: 12px;
+ vertical-align: initial;
+ &:not(.active) {
+ visibility: hidden;
+ }
+ }
+
+ .message-count {
+ color: @text-color-inverse;
+ background: @background-tertiary;
+ padding: 4px 6px 2px 6px;
+ margin-left: 1em;
+ }
+
+ .draft-icon {
+ margin-left:10px;
+ flex-shrink: 0;
+ object-fit: contain;
+ }
+
+ .participants {
+ font-size: @font-size-small;
+ text-overflow: ellipsis;
+ text-align: left;
+ overflow: hidden;
+
+ &.no-recipients {
+ color: @text-color-very-subtle;
+ }
+ }
+
+ .details {
+ display:flex;
+ align-items: center;
+ overflow: hidden;
+
+ .subject {
+ font-size: @font-size-small;
+ font-weight: @font-weight-normal;
+ padding-right: @padding-base-horizontal;
+ text-overflow: ellipsis;
+ overflow: hidden;
+
+ // Shrink, but only after snippet has shrunk
+ flex-shrink:0.1;
+ }
+ .snippet {
+ font-size: @font-size-small;
+ font-weight: @font-weight-normal;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ opacity: 0.62;
+ flex: 1;
+ }
+ .thread-icon {
+ margin-right:@padding-base-horizontal;
+ margin-left:@padding-base-horizontal;
+ }
+ }
+
+ .list-column-State {
+ display: flex;
+ align-items: center;
+ }
+ .list-column-Date {
+ text-align: right;
+ }
+
+ .timestamp {
+ font-size: @font-size-small;
+ font-weight: @font-weight-normal;
+ min-width:70px;
+ margin-right: @scrollbar-margin;
+ opacity: 0.62;
+ }
+
+ .unread:not(.focused):not(.selected) {
+ background-color: @background-primary;
+ &:hover {
+ background: darken(@background-primary, 2%);
+ }
+ .snippet {
+ color: @text-color-subtle;
+ }
+ }
+
+ .unread:not(.focused):not(.selected):not(.next-is-selected) {
+ border-bottom: 1px solid @list-border;
+ }
+
+ .unread:not(.focused) {
+ // Never show any unread styles when the thread is focused.
+ // It will be marked as read and the delay from focus=>read
+ // is noticeable.
+ .subject {
+ font-weight: @font-weight-semi-bold;
+ .emoji {
+ font-weight: @font-weight-normal;
+ }
+ }
+ .participants {
+ .unread-true {
+ font-weight: @font-weight-semi-bold;
+ }
+ }
+ }
+
+ .focused {
+ .inverseContent;
+ }
+
+ .thread-injected-icons {
+ vertical-align: top;
+ }
+ .thread-injected-mail-labels {
+ margin-right: 6px;
+ }
+ .thread-icon {
+ width:25px;
+ height:24px;
+ flex-shrink:0;
+ background-size: 15px;
+ display:inline-block;
+ background-repeat: no-repeat;
+ background-position:center;
+
+ &.thread-icon-attachment {
+ background-image:url(../static/images/thread-list/icon-attachment-@2x.png);
+ margin-right:0;
+ margin-left:0;
+ }
+ &.thread-icon-unread {
+ background-image:url(../static/images/thread-list/icon-unread-@2x.png);
+ }
+ &.thread-icon-replied {
+ background-image:url(../static/images/thread-list/icon-replied-@2x.png);
+ }
+ &.thread-icon-forwarded {
+ background-image:url(../static/images/thread-list/icon-forwarded-@2x.png);
+ }
+ &.thread-icon-star {
+ background-size: 16px;
+ background-image:url(../static/images/thread-list/icon-star-@2x.png);
+ }
+ }
+ .star-button {
+ font-size: 16px;
+ .fa-star {
+ color: rgb(239, 209, 0);
+ &:hover {
+ cursor: pointer;
+ color: rgb(220,220,220);
+ }
+ }
+ .fa-star-o {
+ color: rgb(220,220,220);
+ &:hover {
+ cursor: pointer;
+ color: rgb(239, 209, 0);
+ }
+ }
+ }
+}
+
+
+// quick actions
+@archive-img: "../static/images/thread-list-quick-actions/ic-quick-button-archive@2x.png";
+@trash-img: "../static/images/thread-list-quick-actions/ic-quick-button-trash@2x.png";
+@snooze-img: "../static/images/thread-list-quick-actions/ic-quickaction-snooze@2x.png";
+
+.thread-list .list-item .list-column-HoverActions {
+ display:none;
+ .action {
+ display: inline-block;
+ background-size: 100%;
+ zoom: 0.5;
+ width: 81px;
+ height: 51px;
+ margin: 9px 16px 0 16px;
+ }
+ .action.action-archive {
+ background: url(@archive-img) center no-repeat, @background-gradient;
+ }
+ .action.action-trash {
+ background: url(@trash-img) center no-repeat, @background-gradient;
+ }
+ .action.action-snooze {
+ background: url(@snooze-img) center no-repeat, @background-gradient;
+ }
+}
+body.platform-win32 {
+ .thread-list .list-item .list-column-HoverActions {
+ .action {
+ border: 0;
+ margin: 9px 0 0 0;
+ }
+ }
+}
+.thread-list .list-item:hover .list-column-HoverActions {
+ width: 0;
+ padding: 0;
+ display:block;
+ overflow: visible;
+ height:100%;
+
+ .inner {
+ position:relative;
+ width:300px;
+ height:100%;
+ left: -300px;
+ .thread-injected-quick-actions {
+ margin-right: 10px;
+ }
+ }
+}
+
+.thread-list .list-item:hover .list-column-HoverActions .inner {
+ background-image: -webkit-linear-gradient(left, fade(darken(@list-bg, 5%), 0%) 0%, darken(@list-bg, 5%) 50%, darken(@list-bg, 5%) 100%);
+}
+
+.thread-list .list-item.selected:hover .list-column-HoverActions .inner {
+ background-image: -webkit-linear-gradient(left, fade(@list-selected-bg, 0%) 0%, @list-selected-bg 50%, @list-selected-bg 100%);
+}
+
+.thread-list .list-item.focused:hover .list-column-HoverActions .inner {
+ background-image: -webkit-linear-gradient(left, fade(@list-focused-bg, 0%) 0%, @list-focused-bg 50%, @list-focused-bg 100%);
+ .action {
+ -webkit-filter: invert(100%) brightness(300%);
+ }
+ .action.action-archive {
+ background: url(@archive-img) center no-repeat;
+ }
+ .action.action-trash {
+ background: url(@trash-img) center no-repeat;
+ }
+ .action.action-snooze {
+ background: url(@snooze-img) center no-repeat;
+ }
+}
+
+
+// stars
+
+.thread-list .thread-icon-star:hover
+{
+ background-image:url(../static/images/thread-list/icon-star-@2x.png);
+ background-size: 16px;
+ -webkit-filter: brightness(90%);
+}
+.thread-list .list-item:hover .thread-icon-none:hover {
+ background-image:url(../static/images/thread-list/icon-star-action-hover-@2x.png);
+ background-size: 16px;
+}
+.thread-list .list-item:hover .thread-icon-none {
+ background-image:url(../static/images/thread-list/icon-star-hover-@2x.png);
+ background-size: 16px;
+}
+.thread-list .list-item:hover .mail-important-icon.enabled {
+ visibility: inherit;
+}
+.thread-list .thread-icon-star-on-hover:hover {
+ background-image:url(../static/images/thread-list/icon-star-hover-@2x.png);
+ background-size: 16px;
+}
+
+.thread-list-narrow {
+ .icons-column {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 25px;
+ margin-right: 5px;
+
+ .thread-injected-icons {
+ align-items: center;
+ }
+ }
+ .thread-info-column {
+ flex: 1;
+ align-self: center;
+ overflow: hidden;
+
+ .participants-wrapper {
+ display: flex;
+ align-items: center;
+ min-height: 24px;
+ }
+ }
+ .list-column {
+ display:block;
+ }
+ .list-tabular-item {
+ line-height: 21px;
+ }
+ .timestamp {
+ order: 100;
+ min-width: 0;
+ }
+ .participants {
+ font-size: @font-size-base;
+ }
+
+ .mail-important-icon {
+ margin-left:1px;
+ float:left;
+ padding: 12px;
+ vertical-align: initial;
+ }
+
+ .subject {
+ font-size: @font-size-base;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: left;
+ margin-right: @scrollbar-margin;
+ }
+ .snippet-and-labels {
+ margin-right: @scrollbar-margin;
+ display: flex;
+ align-items: baseline;
+ overflow: hidden;
+
+ .mail-label {
+ font-size: 0.8em;
+ line-height: 17px;
+ }
+
+ .snippet {
+ font-size: @font-size-small;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ opacity: 0.7;
+ text-align: left;
+ min-height: 21px;
+ margin-right:4px;
+ }
+ }
+}
+
+// selection looks like focus in split mode
+
+.thread-list.handler-split {
+ .list-item {
+ &.selected {
+ background: @list-focused-bg;
+ color: @list-focused-color;
+ .inverseContent;
+ }
+ }
+ .list-item.selected:hover .list-column-HoverActions .inner {
+ background-image: -webkit-linear-gradient(left, fade(@list-focused-bg, 0%) 0%, @list-focused-bg 50%, @list-focused-bg 100%);
+ .action {
+ -webkit-filter: invert(100%) brightness(300%);
+ }
+ .action.action-archive {
+ background: url(@archive-img) center no-repeat;
+ }
+ .action.action-trash {
+ background: url(@trash-img) center no-repeat;
+ }
+ .action.action-snooze {
+ background: url(@snooze-img) center no-repeat;
+ }
+ }
+}
+body.is-blurred {
+ .thread-list.handler-split {
+ .list-item {
+ &.selected {
+ background: fadeout(desaturate(@list-focused-bg, 100%), 65%);
+ border-bottom: 1px solid fadeout(desaturate(@list-focused-border, 100%), 65%);
+ color: @text-color;
+ }
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/thread-search/README.md b/packages/client-app/internal_packages/thread-search/README.md
new file mode 100755
index 0000000000..a781a1fc64
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/README.md
@@ -0,0 +1 @@
+# React version of thread list
diff --git a/packages/client-app/internal_packages/thread-search/lib/main.es6 b/packages/client-app/internal_packages/thread-search/lib/main.es6
new file mode 100644
index 0000000000..92d5b5af33
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/lib/main.es6
@@ -0,0 +1,16 @@
+import {ComponentRegistry, WorkspaceStore} from 'nylas-exports'
+import ThreadSearchBar from './thread-search-bar'
+
+export const configDefaults = {
+ showOnRightSide: false,
+}
+
+export function activate() {
+ ComponentRegistry.register(ThreadSearchBar, {
+ location: WorkspaceStore.Location.ThreadList.Toolbar,
+ })
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(ThreadSearchBar)
+}
diff --git a/packages/client-app/internal_packages/thread-search/lib/search-actions.es6 b/packages/client-app/internal_packages/thread-search/lib/search-actions.es6
new file mode 100644
index 0000000000..a8cf903159
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/lib/search-actions.es6
@@ -0,0 +1,14 @@
+import Reflux from 'reflux';
+
+const SearchActions = Reflux.createActions([
+ "querySubmitted",
+ "queryChanged",
+ "searchBlurred",
+ "searchCompleted",
+]);
+
+for (const key of Object.keys(SearchActions)) {
+ SearchActions[key].sync = true;
+}
+
+export default SearchActions
diff --git a/packages/client-app/internal_packages/thread-search/lib/search-mailbox-perspective.es6 b/packages/client-app/internal_packages/thread-search/lib/search-mailbox-perspective.es6
new file mode 100644
index 0000000000..a1307d097e
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/lib/search-mailbox-perspective.es6
@@ -0,0 +1,64 @@
+import _ from 'underscore'
+import {AccountStore, CategoryStore, TaskFactory, MailboxPerspective} from 'nylas-exports'
+import SearchQuerySubscription from './search-query-subscription'
+
+class SearchMailboxPerspective extends MailboxPerspective {
+
+ constructor(sourcePerspective, searchQuery) {
+ super(sourcePerspective.accountIds)
+ if (!_.isString(searchQuery)) {
+ throw new Error("SearchMailboxPerspective: Expected a `string` search query")
+ }
+
+ this.searchQuery = searchQuery;
+ if (sourcePerspective instanceof SearchMailboxPerspective) {
+ this.sourcePerspective = sourcePerspective.sourcePerspective;
+ } else {
+ this.sourcePerspective = sourcePerspective;
+ }
+
+ this.name = `Searching ${this.sourcePerspective.name}`
+ }
+
+ _folderScope() {
+ // When the inbox is focused we don't specify a folder scope. If the user
+ // wants to search just the inbox then they have to specify it explicitly.
+ if (this.sourcePerspective.isInbox()) {
+ return '';
+ }
+ const folderQuery = this.sourcePerspective.categories().map((c) => c.displayName).join('" OR in:"');
+ return `AND (in:"${folderQuery}")`;
+ }
+
+ emptyMessage() {
+ return "No search results available"
+ }
+
+ isEqual(other) {
+ return super.isEqual(other) && other.searchQuery === this.searchQuery
+ }
+
+ threads() {
+ return new SearchQuerySubscription(`(${this.searchQuery}) ${this._folderScope()}`, this.accountIds)
+ }
+
+ canReceiveThreadsFromAccountIds() {
+ return false
+ }
+
+ tasksForRemovingItems(threads) {
+ return TaskFactory.tasksForApplyingCategories({
+ source: "Removing from Search Results",
+ threads: threads,
+ categoriesToAdd: (accountId) => {
+ const account = AccountStore.accountForId(accountId)
+ return [account.defaultFinishedCategory()]
+ },
+ categoriesToRemove: (accountId) => {
+ return [CategoryStore.getInboxCategory(accountId)]
+ },
+ })
+ }
+}
+
+export default SearchMailboxPerspective;
diff --git a/packages/client-app/internal_packages/thread-search/lib/search-query-subscription.es6 b/packages/client-app/internal_packages/thread-search/lib/search-query-subscription.es6
new file mode 100644
index 0000000000..4312e86604
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/lib/search-query-subscription.es6
@@ -0,0 +1,217 @@
+import _ from 'underscore'
+import {
+ Actions,
+ NylasAPI,
+ Thread,
+ DatabaseStore,
+ SearchQueryParser,
+ ComponentRegistry,
+ NylasLongConnection,
+ FocusedContentStore,
+ MutableQuerySubscription,
+} from 'nylas-exports'
+import SearchActions from './search-actions'
+
+const {LongConnectionStatus} = NylasAPI
+
+class SearchQuerySubscription extends MutableQuerySubscription {
+
+ constructor(searchQuery, accountIds) {
+ super(null, {emitResultSet: true})
+ this._searchQuery = searchQuery
+ this._accountIds = accountIds
+
+ this.resetData()
+
+ this._connections = []
+ this._unsubscribers = [
+ FocusedContentStore.listen(this.onFocusedContentChanged),
+ ]
+ this._extDisposables = []
+
+ _.defer(() => this.performSearch())
+ }
+
+ replaceRange = () => {
+ // TODO
+ }
+
+ resetData() {
+ this._searchStartedAt = null
+ this._localResultsReceivedAt = null
+ this._remoteResultsReceivedAt = null
+ this._remoteResultsCount = 0
+ this._localResultsCount = 0
+ this._firstThreadSelectedAt = null
+ this._lastFocusedThread = null
+ this._focusedThreadCount = 0
+ }
+
+ performSearch() {
+ this._searchStartedAt = Date.now()
+
+ this.performLocalSearch()
+ this.performRemoteSearch()
+ this.performExtensionSearch()
+ }
+
+ performLocalSearch() {
+ let dbQuery = DatabaseStore.findAll(Thread).distinct()
+ if (this._accountIds.length === 1) {
+ dbQuery = dbQuery.where({accountId: this._accountIds[0]})
+ }
+ try {
+ const parsedQuery = SearchQueryParser.parse(this._searchQuery);
+ console.info('Successfully parsed and codegened search query', parsedQuery);
+ dbQuery = dbQuery.structuredSearch(parsedQuery);
+ } catch (e) {
+ console.info('Failed to parse local search query, falling back to generic query', e);
+ dbQuery = dbQuery.search(this._searchQuery);
+ }
+ dbQuery = dbQuery
+ .order(Thread.attributes.lastMessageReceivedTimestamp.descending())
+ .limit(100)
+
+ console.info('dbQuery.sql() =', dbQuery.sql());
+
+ dbQuery.then((results) => {
+ if (!this._localResultsReceivedAt) {
+ this._localResultsReceivedAt = Date.now()
+ }
+ this._localResultsCount += results.length
+ // Even if we don't have any results now we might sync additional messages
+ // from the provider which will cause new results to appear later.
+ this.replaceQuery(dbQuery)
+ })
+ }
+
+ _addThreadIdsToSearch(ids = []) {
+ const currentResults = this._set && this._set.ids().length > 0;
+ let searchIds = ids;
+ if (currentResults) {
+ const currentResultIds = this._set.ids()
+ searchIds = _.uniq(currentResultIds.concat(ids))
+ }
+ const dbQuery = (
+ DatabaseStore.findAll(Thread)
+ .where({id: searchIds})
+ .order(Thread.attributes.lastMessageReceivedTimestamp.descending())
+ )
+ this.replaceQuery(dbQuery)
+ }
+
+ performRemoteSearch() {
+ const accountsSearched = new Set()
+ const allAccountsSearched = () => accountsSearched.size === this._accountIds.length
+ this._connections = this._accountIds.map((accountId) => {
+ const conn = new NylasLongConnection({
+ accountId,
+ api: NylasAPI,
+ path: `/threads/search/streaming?q=${encodeURIComponent(this._searchQuery)}`,
+ onResults: (results) => {
+ if (!this._remoteResultsReceivedAt) {
+ this._remoteResultsReceivedAt = Date.now();
+ }
+ const threads = results[0];
+ this._remoteResultsCount += threads.length;
+ },
+ onStatusChanged: (status) => {
+ const hasClosed = [
+ LongConnectionStatus.Closed,
+ LongConnectionStatus.Ended,
+ ].includes(status)
+
+ if (hasClosed) {
+ accountsSearched.add(accountId)
+ if (allAccountsSearched()) {
+ SearchActions.searchCompleted()
+ }
+ }
+ },
+ })
+
+ return conn.start()
+ })
+ }
+
+ performExtensionSearch() {
+ const searchExtensions = ComponentRegistry.findComponentsMatching({
+ role: "SearchBarResults",
+ })
+
+ this._extDisposables = searchExtensions.map((ext) => {
+ return ext.observeThreadIdsForQuery(this._searchQuery)
+ .subscribe((ids = []) => {
+ const allIds = _.compact(_.flatten(ids))
+ if (allIds.length === 0) return;
+ this._addThreadIdsToSearch(allIds)
+ })
+ })
+ }
+
+ // We want to keep track of how many threads from the search results were
+ // focused
+ onFocusedContentChanged = () => {
+ const thread = FocusedContentStore.focused('thread')
+ const shouldRecordChange = (
+ thread &&
+ (this._lastFocusedThread || {}).id !== thread.id
+ )
+ if (shouldRecordChange) {
+ if (this._focusedThreadCount === 0) {
+ this._firstThreadSelectedAt = Date.now()
+ }
+ this._focusedThreadCount += 1
+ this._lastFocusedThread = thread
+ }
+ }
+
+ reportSearchMetrics() {
+ if (!this._searchStartedAt) {
+ return;
+ }
+
+ let timeToLocalResultsMs = null
+ let timeToFirstRemoteResultsMs = null;
+ let timeToFirstThreadSelectedMs = null;
+ const timeInsideSearchMs = Date.now() - this._searchStartedAt
+ const numThreadsSelected = this._focusedThreadCount
+ const numLocalResults = this._localResultsCount
+ const numRemoteResults = this._remoteResultsCount
+
+ if (this._firstThreadSelectedAt) {
+ timeToFirstThreadSelectedMs = this._firstThreadSelectedAt - this._searchStartedAt
+ }
+ if (this._localResultsReceivedAt) {
+ timeToLocalResultsMs = this._localResultsReceivedAt - this._searchStartedAt
+ }
+ if (this._remoteResultsReceivedAt) {
+ timeToFirstRemoteResultsMs = this._remoteResultsReceivedAt - this._searchStartedAt
+ }
+
+ Actions.recordPerfMetric({
+ action: 'search-performed',
+ actionTimeMs: timeToLocalResultsMs,
+ numLocalResults,
+ numRemoteResults,
+ numThreadsSelected,
+ clippedData: [
+ {key: 'timeToLocalResultsMs', val: timeToLocalResultsMs},
+ {key: 'timeToFirstThreadSelectedMs', val: timeToFirstThreadSelectedMs},
+ {key: 'timeInsideSearchMs', val: timeInsideSearchMs, maxValue: 60 * 1000},
+ {key: 'timeToFirstRemoteResultsMs', val: timeToFirstRemoteResultsMs, maxValue: 10 * 1000},
+ ],
+ })
+ this.resetData()
+ }
+
+ // This function is called when the user leaves the SearchPerspective
+ onLastCallbackRemoved() {
+ this.reportSearchMetrics();
+ this._connections.forEach((conn) => conn.end())
+ this._unsubscribers.forEach((unsub) => unsub())
+ this._extDisposables.forEach((disposable) => disposable.dispose())
+ }
+}
+
+export default SearchQuerySubscription
diff --git a/packages/client-app/internal_packages/thread-search/lib/search-store.es6 b/packages/client-app/internal_packages/thread-search/lib/search-store.es6
new file mode 100644
index 0000000000..39fb6b706f
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/lib/search-store.es6
@@ -0,0 +1,220 @@
+import NylasStore from 'nylas-store';
+import {
+ Thread,
+ Actions,
+ ContactStore,
+ AccountStore,
+ DatabaseStore,
+ ComponentRegistry,
+ FocusedPerspectiveStore,
+ SearchQueryParser,
+} from 'nylas-exports';
+
+import SearchActions from './search-actions';
+import SearchMailboxPerspective from './search-mailbox-perspective';
+
+// Stores should closely match the needs of a particular part of the front end.
+// For example, we might create a "MessageStore" that observes this store
+// for changes in selectedThread, "DatabaseStore" for changes to the underlying database,
+// and vends up the array used for that view.
+
+class SearchStore extends NylasStore {
+ constructor() {
+ super();
+
+ this._searchQuery = FocusedPerspectiveStore.current().searchQuery || "";
+ this._searchSuggestionsVersion = 1;
+ this._isSearching = false;
+ this._extensionData = []
+ this._clearResults();
+
+ this.listenTo(FocusedPerspectiveStore, this._onPerspectiveChanged);
+ this.listenTo(SearchActions.querySubmitted, this._onQuerySubmitted);
+ this.listenTo(SearchActions.queryChanged, this._onQueryChanged);
+ this.listenTo(SearchActions.searchBlurred, this._onSearchBlurred);
+ this.listenTo(SearchActions.searchCompleted, this._onSearchCompleted);
+ }
+
+ query() {
+ return this._searchQuery;
+ }
+
+ queryPopulated() {
+ return this._searchQuery && this._searchQuery.trim().length > 0;
+ }
+
+ suggestions() {
+ return this._suggestions;
+ }
+
+ isSearching() {
+ return this._isSearching;
+ }
+
+ _onSearchCompleted = () => {
+ this._isSearching = false;
+ this.trigger();
+ }
+
+ _onPerspectiveChanged = () => {
+ this._searchQuery = FocusedPerspectiveStore.current().searchQuery || "";
+ this.trigger();
+ }
+
+ _onQueryChanged = (query) => {
+ this._searchQuery = query;
+ if (this._searchQuery.length <= 1) {
+ this.trigger()
+ return
+ }
+ this._compileResults();
+ setTimeout(() => this._rebuildResults(), 0);
+ }
+
+ _onQuerySubmitted = (query) => {
+ this._searchQuery = query;
+ const current = FocusedPerspectiveStore.current();
+
+ if (this.queryPopulated()) {
+ this._isSearching = true;
+ if (this._perspectiveBeforeSearch == null) {
+ this._perspectiveBeforeSearch = current;
+ }
+ const next = new SearchMailboxPerspective(current, this._searchQuery.trim());
+ Actions.focusMailboxPerspective(next);
+ } else if (current instanceof SearchMailboxPerspective) {
+ if (this._perspectiveBeforeSearch) {
+ Actions.focusMailboxPerspective(this._perspectiveBeforeSearch);
+ this._perspectiveBeforeSearch = null;
+ } else {
+ Actions.focusDefaultMailboxPerspectiveForAccounts(AccountStore.accounts());
+ }
+ }
+
+ this._clearResults();
+ }
+
+ _onSearchBlurred = () => {
+ this._clearResults();
+ }
+
+ _clearResults() {
+ this._searchSuggestionsVersion = 1;
+ this._threadResults = [];
+ this._contactResults = [];
+ this._suggestions = [];
+ this.trigger();
+ }
+
+ _rebuildResults() {
+ if (!this.queryPopulated()) {
+ this._clearResults();
+ return;
+ }
+ this._searchSuggestionsVersion += 1;
+ const searchExtensions = ComponentRegistry.findComponentsMatching({
+ role: "SearchBarResults",
+ })
+
+ Promise.map(searchExtensions, (ext) => {
+ return Promise.props({
+ label: ext.searchLabel(),
+ suggestions: ext.fetchSearchSuggestions(this._searchQuery),
+ })
+ }).then((extensionData = []) => {
+ this._extensionData = extensionData;
+ this._compileResults();
+ })
+
+ this._fetchThreadResults();
+ this._fetchContactResults();
+ }
+
+ _fetchContactResults() {
+ const version = this._searchSuggestionsVersion;
+ ContactStore.searchContacts(this._searchQuery, {limit: 10}).then(contacts => {
+ if (version !== this._searchSuggestionsVersion) {
+ return;
+ }
+ this._contactResults = contacts;
+ this._compileResults();
+ });
+ }
+
+ _fetchThreadResults() {
+ if (this._fetchingThreadResultsVersion) { return; }
+ this._fetchingThreadResultsVersion = this._searchSuggestionsVersion;
+
+ const {accountIds} = FocusedPerspectiveStore.current();
+ let dbQuery = DatabaseStore.findAll(Thread).distinct()
+ if (Array.isArray(accountIds) && accountIds.length === 1) {
+ dbQuery = dbQuery.where({accountId: accountIds[0]})
+ }
+
+ try {
+ const parsedQuery = SearchQueryParser.parse(this._searchQuery);
+ // console.info('Successfully parsed and codegened search query', parsedQuery);
+ dbQuery = dbQuery.structuredSearch(parsedQuery);
+ } catch (e) {
+ // console.info('Failed to parse local search query, falling back to generic query', e);
+ dbQuery = dbQuery.search(this._searchQuery);
+ }
+ dbQuery = dbQuery
+ .order(Thread.attributes.lastMessageReceivedTimestamp.descending())
+
+ // console.info(dbQuery.sql());
+
+ dbQuery.background().then(results => {
+ // We've fetched the latest thread results - display them!
+ if (this._searchSuggestionsVersion === this._fetchingThreadResultsVersion) {
+ this._fetchingThreadResultsVersion = null;
+ this._threadResults = results;
+ this._compileResults();
+ // We're behind and need to re-run the search for the latest results
+ } else if (this._searchSuggestionsVersion > this._fetchingThreadResultsVersion) {
+ this._fetchingThreadResultsVersion = null;
+ this._fetchThreadResults();
+ } else {
+ this._fetchingThreadResultsVersion = null;
+ }
+ }
+ );
+ }
+
+ _compileResults() {
+ this._suggestions = [];
+
+ this._suggestions.push({
+ label: `Message Contains: ${this._searchQuery}`,
+ value: this._searchQuery,
+ });
+
+ if (this._threadResults.length) {
+ this._suggestions.push({divider: 'Threads'});
+ for (const thread of this._threadResults) {
+ this._suggestions.push({thread});
+ }
+ }
+
+ if (this._contactResults.length) {
+ this._suggestions.push({divider: 'People'});
+ for (const contact of this._contactResults) {
+ this._suggestions.push({
+ contact: contact,
+ value: contact.email,
+ });
+ }
+ }
+
+ if (this._extensionData.length) {
+ for (const {label, suggestions} of this._extensionData) {
+ this._suggestions.push({divider: label});
+ this._suggestions = this._suggestions.concat(suggestions)
+ }
+ }
+
+ this.trigger();
+ }
+}
+
+export default new SearchStore();
diff --git a/packages/client-app/internal_packages/thread-search/lib/thread-search-bar.jsx b/packages/client-app/internal_packages/thread-search/lib/thread-search-bar.jsx
new file mode 100644
index 0000000000..82af375002
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/lib/thread-search-bar.jsx
@@ -0,0 +1,97 @@
+import React, {Component, PropTypes} from 'react'
+import {Menu, SearchBar, ListensToFluxStore} from 'nylas-component-kit'
+import {FocusedPerspectiveStore} from 'nylas-exports'
+import SearchStore from './search-store'
+import SearchActions from './search-actions'
+
+
+class ThreadSearchBar extends Component {
+ static displayName = 'ThreadSearchBar';
+
+ static propTypes = {
+ query: PropTypes.string,
+ isSearching: PropTypes.bool,
+ suggestions: PropTypes.array,
+ perspective: PropTypes.object,
+ }
+
+ _onSelectSuggestion = (suggestion) => {
+ if (suggestion.thread) {
+ SearchActions.querySubmitted(`"${suggestion.thread.subject}"`)
+ } else {
+ SearchActions.querySubmitted(suggestion.value);
+ }
+ }
+
+ _onSearchQueryChanged = (query) => {
+ SearchActions.queryChanged(query);
+ if (query === '') {
+ this._onClearSearchQuery();
+ }
+ }
+
+ _onSubmitSearchQuery = (query) => {
+ SearchActions.querySubmitted(query);
+ }
+
+ _onClearSearchQuery = () => {
+ SearchActions.querySubmitted('');
+ }
+
+ _onClearSearchSuggestions = () => {
+ SearchActions.searchBlurred()
+ }
+
+ _renderSuggestion = (suggestion) => {
+ if (suggestion.contact) {
+ return ;
+ }
+ if (suggestion.thread) {
+ return suggestion.thread.subject;
+ }
+ if (suggestion.customElement) {
+ return suggestion.customElement
+ }
+ return suggestion.label;
+ }
+
+ _placeholder = () => {
+ if (this.props.perspective.isInbox()) {
+ return 'Search all email';
+ }
+ return `Search ${this.props.perspective.name}`;
+ }
+
+ render() {
+ const {query, isSearching, suggestions} = this.props;
+
+ return (
+ suggestion.label || (suggestion.contact || {}).id || (suggestion.thread || {}).id}
+ suggestionRenderer={this._renderSuggestion}
+ onSelectSuggestion={this._onSelectSuggestion}
+ onSubmitSearchQuery={this._onSubmitSearchQuery}
+ onSearchQueryChanged={this._onSearchQueryChanged}
+ onClearSearchQuery={this._onClearSearchQuery}
+ onClearSearchSuggestions={this._onClearSearchSuggestions}
+ />
+ )
+ }
+}
+
+export default ListensToFluxStore(ThreadSearchBar, {
+ stores: [SearchStore, FocusedPerspectiveStore],
+ getStateFromStores() {
+ return {
+ query: SearchStore.query(),
+ suggestions: SearchStore.suggestions(),
+ isSearching: SearchStore.isSearching(),
+ perspective: FocusedPerspectiveStore.current(),
+ };
+ },
+})
diff --git a/packages/client-app/internal_packages/thread-search/package.json b/packages/client-app/internal_packages/thread-search/package.json
new file mode 100755
index 0000000000..e9a70d7445
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "thread-search",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Search for threads",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/thread-search/spec/search-bar-spec.cjsx b/packages/client-app/internal_packages/thread-search/spec/search-bar-spec.cjsx
new file mode 100644
index 0000000000..f02dc70dfd
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/spec/search-bar-spec.cjsx
@@ -0,0 +1,26 @@
+React = require 'react'
+ReactDOM = require 'react-dom'
+ReactTestUtils = require('react-addons-test-utils')
+
+ThreadSearchBar = require('../lib/thread-search-bar').default
+SearchActions = require('../lib/search-actions').default
+
+describe 'ThreadSearchBar', ->
+ beforeEach ->
+ spyOn(NylasEnv, "isMainWindow").andReturn true
+ @searchBar = ReactTestUtils.renderIntoDocument( )
+ @input = ReactDOM.findDOMNode(@searchBar).querySelector("input")
+
+ it 'supports search queries with a colon character', ->
+ spyOn(SearchActions, "queryChanged")
+ test = "::Hello: World::"
+ ReactTestUtils.Simulate.change @input, target: value: test
+ expect(SearchActions.queryChanged).toHaveBeenCalledWith(test)
+
+ it 'preserves capitalization on searches', ->
+ test = "HeLlO wOrLd"
+ ReactTestUtils.Simulate.change @input, target: value: test
+ waitsFor =>
+ @input.value.length > 0
+ runs =>
+ expect(@input.value).toBe(test)
diff --git a/packages/client-app/internal_packages/thread-search/stylesheets/thread-search-bar.less b/packages/client-app/internal_packages/thread-search/stylesheets/thread-search-bar.less
new file mode 100644
index 0000000000..51cf90a214
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-search/stylesheets/thread-search-bar.less
@@ -0,0 +1,11 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+.nylas-search-bar.thread-search-bar {
+ position: relative;
+ order: -100;
+ overflow: visible;
+ z-index: 100;
+ width: 450px;
+ margin-top: (38px - 23px) / 2;
+}
diff --git a/packages/client-app/internal_packages/thread-snooze/README.md b/packages/client-app/internal_packages/thread-snooze/README.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/client-app/internal_packages/thread-snooze/assets/ic-snooze-modal@2x.png b/packages/client-app/internal_packages/thread-snooze/assets/ic-snooze-modal@2x.png
new file mode 100644
index 0000000000..57e8a5e367
Binary files /dev/null and b/packages/client-app/internal_packages/thread-snooze/assets/ic-snooze-modal@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-later@2x.png b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-later@2x.png
new file mode 100644
index 0000000000..98294be7ba
Binary files /dev/null and b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-later@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-month@2x.png b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-month@2x.png
new file mode 100644
index 0000000000..cc5a0b03cf
Binary files /dev/null and b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-month@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-tomorrow@2x.png b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-tomorrow@2x.png
new file mode 100644
index 0000000000..754bfcb281
Binary files /dev/null and b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-tomorrow@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-tonight@2x.png b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-tonight@2x.png
new file mode 100644
index 0000000000..b13a9d14ce
Binary files /dev/null and b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-tonight@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-week@2x.png b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-week@2x.png
new file mode 100644
index 0000000000..16eb18dfa6
Binary files /dev/null and b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-week@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-weekend@2x.png b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-weekend@2x.png
new file mode 100644
index 0000000000..d964cb58a4
Binary files /dev/null and b/packages/client-app/internal_packages/thread-snooze/assets/ic-snoozepopover-weekend@2x.png differ
diff --git a/packages/client-app/internal_packages/thread-snooze/lib/main.es6 b/packages/client-app/internal_packages/thread-snooze/lib/main.es6
new file mode 100644
index 0000000000..cc85981b5b
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/lib/main.es6
@@ -0,0 +1,32 @@
+import {ComponentRegistry} from 'nylas-exports';
+import {HasTutorialTip} from 'nylas-component-kit';
+
+import {ToolbarSnooze, QuickActionSnooze} from './snooze-buttons';
+import SnoozeMailLabel from './snooze-mail-label'
+import SnoozeStore from './snooze-store'
+
+
+export function activate() {
+ this.snoozeStore = new SnoozeStore()
+
+ const ToolbarSnoozeWithTutorialTip = HasTutorialTip(ToolbarSnooze, {
+ title: "Handle it later!",
+ instructions: "Snooze this email and it'll return to your inbox later. Click here or swipe across the thread in your inbox to snooze.",
+ });
+
+ this.snoozeStore.activate()
+ ComponentRegistry.register(ToolbarSnoozeWithTutorialTip, {role: 'ThreadActionsToolbarButton'});
+ ComponentRegistry.register(QuickActionSnooze, {role: 'ThreadListQuickAction'});
+ ComponentRegistry.register(SnoozeMailLabel, {role: 'Thread:MailLabel'});
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(ToolbarSnooze);
+ ComponentRegistry.unregister(QuickActionSnooze);
+ ComponentRegistry.unregister(SnoozeMailLabel);
+ this.snoozeStore.deactivate()
+}
+
+export function serialize() {
+
+}
diff --git a/packages/client-app/internal_packages/thread-snooze/lib/snooze-actions.es6 b/packages/client-app/internal_packages/thread-snooze/lib/snooze-actions.es6
new file mode 100644
index 0000000000..7ebdc50115
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/lib/snooze-actions.es6
@@ -0,0 +1,11 @@
+import Reflux from 'reflux';
+
+const SnoozeActions = Reflux.createActions([
+ 'snoozeThreads',
+])
+
+for (const key of Object.keys(SnoozeActions)) {
+ SnoozeActions[key].sync = true
+}
+
+export default SnoozeActions
diff --git a/packages/client-app/internal_packages/thread-snooze/lib/snooze-buttons.jsx b/packages/client-app/internal_packages/thread-snooze/lib/snooze-buttons.jsx
new file mode 100644
index 0000000000..b8bf85feef
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/lib/snooze-buttons.jsx
@@ -0,0 +1,115 @@
+import React, {Component, PropTypes} from 'react';
+import ReactDOM from 'react-dom';
+import {Actions, FocusedPerspectiveStore} from 'nylas-exports';
+import {RetinaImg} from 'nylas-component-kit';
+import SnoozePopover from './snooze-popover';
+
+
+class SnoozeButton extends Component {
+
+ static propTypes = {
+ className: PropTypes.string,
+ threads: PropTypes.array,
+ direction: PropTypes.string,
+ shouldRenderIconImg: PropTypes.bool,
+ getBoundingClientRect: PropTypes.func,
+ };
+
+ static defaultProps = {
+ className: 'btn btn-toolbar',
+ direction: 'down',
+ shouldRenderIconImg: true,
+ getBoundingClientRect: (inst) => ReactDOM.findDOMNode(inst).getBoundingClientRect(),
+ };
+
+ onClick = (event) => {
+ event.stopPropagation()
+ const {threads, direction, getBoundingClientRect} = this.props
+ const buttonRect = getBoundingClientRect(this)
+ Actions.openPopover(
+ ,
+ {originRect: buttonRect, direction: direction}
+ )
+ };
+
+ render() {
+ if (!FocusedPerspectiveStore.current().isInbox()) {
+ return ;
+ }
+ return (
+
+ {this.props.shouldRenderIconImg ?
+ :
+ null
+ }
+
+ );
+ }
+}
+
+
+export class QuickActionSnooze extends Component {
+ static displayName = 'QuickActionSnooze';
+
+ static propTypes = {
+ thread: PropTypes.object,
+ };
+
+ static containerRequired = false;
+
+ getBoundingClientRect = () => {
+ // Grab the parent node because of the zoom applied to this button. If we
+ // took this element directly, we'd have to divide everything by 2
+ const element = ReactDOM.findDOMNode(this).parentNode;
+ const {height, width, top, bottom, left, right} = element.getBoundingClientRect()
+
+ // The parent node is a bit too much to the left, lets adjust this.
+ return {height, width, top, bottom, right, left: left + 5}
+ };
+
+ render() {
+ if (!FocusedPerspectiveStore.current().isInbox()) {
+ return ;
+ }
+ return (
+
+ );
+ }
+}
+
+
+export class ToolbarSnooze extends Component {
+ static displayName = 'ToolbarSnooze';
+
+ static propTypes = {
+ items: PropTypes.array,
+ };
+
+ static containerRequired = false;
+
+ render() {
+ if (!FocusedPerspectiveStore.current().isInbox()) {
+ return ;
+ }
+ return (
+
+ );
+ }
+}
diff --git a/packages/client-app/internal_packages/thread-snooze/lib/snooze-constants.es6 b/packages/client-app/internal_packages/thread-snooze/lib/snooze-constants.es6
new file mode 100644
index 0000000000..3382ac9563
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/lib/snooze-constants.es6
@@ -0,0 +1,5 @@
+import plugin from '../package.json'
+
+export const PLUGIN_ID = plugin.name;
+export const PLUGIN_NAME = "Snooze Plugin"
+export const SNOOZE_CATEGORY_NAME = "N1-Snoozed"
diff --git a/packages/client-app/internal_packages/thread-snooze/lib/snooze-mail-label.jsx b/packages/client-app/internal_packages/thread-snooze/lib/snooze-mail-label.jsx
new file mode 100644
index 0000000000..e1e8cd7b09
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/lib/snooze-mail-label.jsx
@@ -0,0 +1,58 @@
+import _ from 'underscore';
+import React, {Component, PropTypes} from 'react';
+import {FocusedPerspectiveStore} from 'nylas-exports';
+import {RetinaImg, MailLabel} from 'nylas-component-kit';
+import {SNOOZE_CATEGORY_NAME, PLUGIN_ID} from './snooze-constants';
+import SnoozeUtils from './snooze-utils';
+
+
+class SnoozeMailLabel extends Component {
+ static displayName = 'SnoozeMailLabel';
+
+ static propTypes = {
+ thread: PropTypes.object,
+ };
+
+ static containerRequired = false;
+
+ render() {
+ const current = FocusedPerspectiveStore.current()
+ const isSnoozedPerspective = (
+ current.categories().length > 0 &&
+ current.categories()[0].displayName === SNOOZE_CATEGORY_NAME
+ )
+
+ if (!isSnoozedPerspective) {
+ return false
+ }
+
+ const {thread} = this.props;
+ if (_.findWhere(thread.categories, {displayName: SNOOZE_CATEGORY_NAME})) {
+ const metadata = thread.metadataForPluginId(PLUGIN_ID);
+ if (metadata) {
+ // TODO this is such a hack
+ const {snoozeDate} = metadata;
+ const message = SnoozeUtils.snoozedUntilMessage(snoozeDate).replace('Snoozed', '')
+ const content = (
+
+
+ {message}
+
+ )
+ const label = {
+ displayName: content,
+ isLockedCategory: () => true,
+ hue: () => 259,
+ }
+ return ;
+ }
+ return
+ }
+ return
+ }
+}
+
+export default SnoozeMailLabel;
diff --git a/packages/client-app/internal_packages/thread-snooze/lib/snooze-popover.jsx b/packages/client-app/internal_packages/thread-snooze/lib/snooze-popover.jsx
new file mode 100644
index 0000000000..79cf25fa01
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/lib/snooze-popover.jsx
@@ -0,0 +1,128 @@
+import _ from 'underscore';
+import React, {Component, PropTypes} from 'react';
+import {DateUtils, Actions} from 'nylas-exports'
+import {RetinaImg, DateInput} from 'nylas-component-kit';
+import SnoozeActions from './snooze-actions'
+
+const {DATE_FORMAT_LONG} = DateUtils
+
+
+const SnoozeOptions = [
+ [
+ 'Later today',
+ 'Tonight',
+ 'Tomorrow',
+ ],
+ [
+ 'This weekend',
+ 'Next week',
+ 'Next month',
+ ],
+]
+
+const SnoozeDatesFactory = {
+ 'Later today': DateUtils.laterToday,
+ 'Tonight': DateUtils.tonight,
+ 'Tomorrow': DateUtils.tomorrow,
+ 'This weekend': DateUtils.thisWeekend,
+ 'Next week': DateUtils.nextWeek,
+ 'Next month': DateUtils.nextMonth,
+}
+
+const SnoozeIconNames = {
+ 'Later today': 'later',
+ 'Tonight': 'tonight',
+ 'Tomorrow': 'tomorrow',
+ 'This weekend': 'weekend',
+ 'Next week': 'week',
+ 'Next month': 'month',
+}
+
+
+class SnoozePopover extends Component {
+ static displayName = 'SnoozePopover';
+
+ static propTypes = {
+ threads: PropTypes.array.isRequired,
+ swipeCallback: PropTypes.func,
+ };
+
+ static defaultProps = {
+ swipeCallback: () => {},
+ };
+
+ constructor() {
+ super();
+ this.didSnooze = false;
+ }
+
+ componentWillUnmount() {
+ this.props.swipeCallback(this.didSnooze);
+ }
+
+ onSnooze(date, itemLabel) {
+ const utcDate = date.utc();
+ const formatted = DateUtils.format(utcDate);
+ SnoozeActions.snoozeThreads(this.props.threads, formatted, itemLabel);
+ this.didSnooze = true;
+ Actions.closePopover();
+
+ // if we're looking at a thread, go back to the main view.
+ // has no effect otherwise.
+ Actions.popSheet();
+ }
+
+ onSelectCustomDate = (date, inputValue) => {
+ if (date) {
+ this.onSnooze(date, "Custom");
+ } else {
+ NylasEnv.showErrorDialog(`Sorry, we can't parse ${inputValue} as a valid date.`);
+ }
+ };
+
+ renderItem = (itemLabel) => {
+ const date = SnoozeDatesFactory[itemLabel]();
+ const iconName = SnoozeIconNames[itemLabel];
+ const iconPath = `nylas://thread-snooze/assets/ic-snoozepopover-${iconName}@2x.png`;
+ return (
+ this.onSnooze(date, itemLabel)}
+ >
+
+ {itemLabel}
+
+ )
+ };
+
+ renderRow = (options, idx) => {
+ const items = _.map(options, this.renderItem);
+ return (
+
+ {items}
+
+ );
+ };
+
+ render() {
+ const rows = SnoozeOptions.map(this.renderRow);
+
+ return (
+
+ {rows}
+
+
+ );
+ }
+
+}
+
+export default SnoozePopover;
diff --git a/packages/client-app/internal_packages/thread-snooze/lib/snooze-store.jsx b/packages/client-app/internal_packages/thread-snooze/lib/snooze-store.jsx
new file mode 100644
index 0000000000..fdd324070d
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/lib/snooze-store.jsx
@@ -0,0 +1,118 @@
+import _ from 'underscore';
+import {FeatureUsageStore, Actions, AccountStore,
+ DatabaseStore, Message, CategoryStore} from 'nylas-exports';
+import SnoozeUtils from './snooze-utils'
+import {PLUGIN_ID, PLUGIN_NAME} from './snooze-constants';
+import SnoozeActions from './snooze-actions';
+
+class SnoozeStore {
+
+ constructor(pluginId = PLUGIN_ID, pluginName = PLUGIN_NAME) {
+ this.pluginId = pluginId
+ this.pluginName = pluginName
+ this.accountIds = _.pluck(AccountStore.accounts(), 'id')
+ this.snoozeCategoriesPromise = SnoozeUtils.getSnoozeCategoriesByAccount(AccountStore.accounts())
+ }
+
+ activate() {
+ this.unsubscribers = [
+ AccountStore.listen(this.onAccountsChanged),
+ SnoozeActions.snoozeThreads.listen(this.onSnoozeThreads),
+ ]
+ }
+
+ recordSnoozeEvent(threads, snoozeDate, label) {
+ try {
+ const timeInSec = Math.round(((new Date(snoozeDate)).valueOf() - Date.now()) / 1000);
+ Actions.recordUserEvent("Threads Snoozed", {
+ timeInSec: timeInSec,
+ timeInLog10Sec: Math.log10(timeInSec),
+ label: label,
+ numItems: threads.length,
+ });
+ } catch (e) {
+ // Do nothing
+ }
+ }
+
+ groupUpdatedThreads = (threads, snoozeCategoriesByAccount) => {
+ const getSnoozeCategory = (accId) => snoozeCategoriesByAccount[accId]
+ const {getInboxCategory} = CategoryStore
+ const threadsByAccountId = {}
+
+ threads.forEach((thread) => {
+ const accId = thread.accountId
+ if (!threadsByAccountId[accId]) {
+ threadsByAccountId[accId] = {
+ threads: [thread],
+ snoozeCategoryId: getSnoozeCategory(accId).serverId,
+ returnCategoryId: getInboxCategory(accId).serverId,
+ }
+ } else {
+ threadsByAccountId[accId].threads.push(thread);
+ }
+ });
+ return Promise.resolve(threadsByAccountId);
+ };
+
+ onAccountsChanged = () => {
+ const nextIds = _.pluck(AccountStore.accounts(), 'id')
+ const isSameAccountIds = (
+ this.accountIds.length === nextIds.length &&
+ this.accountIds.length === _.intersection(this.accountIds, nextIds).length
+ )
+ if (!isSameAccountIds) {
+ this.accountIds = nextIds
+ this.snoozeCategoriesPromise = SnoozeUtils.getSnoozeCategoriesByAccount(AccountStore.accounts())
+ }
+ };
+
+ onSnoozeThreads = (threads, snoozeDate, label) => {
+ const lexicon = {
+ displayName: "Snooze",
+ usedUpHeader: "All Snoozes used",
+ iconUrl: "nylas://thread-snooze/assets/ic-snooze-modal@2x.png",
+ }
+
+ FeatureUsageStore.asyncUseFeature('snooze', {lexicon})
+ .then(() => {
+ this.recordSnoozeEvent(threads, snoozeDate, label)
+ return SnoozeUtils.moveThreadsToSnooze(threads, this.snoozeCategoriesPromise, snoozeDate)
+ })
+ .then((updatedThreads) => {
+ return this.snoozeCategoriesPromise
+ .then(snoozeCategories => this.groupUpdatedThreads(updatedThreads, snoozeCategories))
+ })
+ .then((updatedThreadsByAccountId) => {
+ _.each(updatedThreadsByAccountId, (update) => {
+ const {snoozeCategoryId, returnCategoryId} = update;
+
+ // Get messages for those threads and metadata for those.
+ DatabaseStore.findAll(Message, {threadId: update.threads.map(t => t.id)}).then((messages) => {
+ for (const message of messages) {
+ const header = message.messageIdHeader;
+ const stableId = message.id;
+ Actions.setMetadata(message, this.pluginId,
+ {expiration: snoozeDate, header, stableId, snoozeCategoryId, returnCategoryId})
+ }
+ });
+ });
+ })
+ .catch((error) => {
+ if (error instanceof FeatureUsageStore.NoProAccess) {
+ return
+ }
+ SnoozeUtils.moveThreadsFromSnooze(threads, this.snoozeCategoriesPromise)
+ Actions.closePopover();
+ NylasEnv.reportError(error);
+ NylasEnv.showErrorDialog(`Sorry, we were unable to save your snooze settings. ${error.message}`);
+ return
+ });
+ };
+
+ deactivate() {
+ this.unsubscribers.forEach(unsub => unsub())
+ }
+}
+
+export default SnoozeStore;
diff --git a/packages/client-app/internal_packages/thread-snooze/lib/snooze-utils.es6 b/packages/client-app/internal_packages/thread-snooze/lib/snooze-utils.es6
new file mode 100644
index 0000000000..52ee0f5f82
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/lib/snooze-utils.es6
@@ -0,0 +1,128 @@
+import moment from 'moment';
+import _ from 'underscore';
+import {
+ Actions,
+ Thread,
+ Category,
+ DateUtils,
+ TaskFactory,
+ AccountStore,
+ CategoryStore,
+ DatabaseStore,
+ SyncbackCategoryTask,
+ TaskQueueStatusStore,
+ FolderSyncProgressStore,
+} from 'nylas-exports';
+import {SNOOZE_CATEGORY_NAME} from './snooze-constants'
+
+const {DATE_FORMAT_SHORT} = DateUtils
+
+
+const SnoozeUtils = {
+
+ snoozedUntilMessage(snoozeDate, now = moment()) {
+ let message = 'Snoozed'
+ if (snoozeDate) {
+ let dateFormat = DATE_FORMAT_SHORT
+ const date = moment(snoozeDate)
+ const hourDifference = moment.duration(date.diff(now)).asHours()
+
+ if (hourDifference < 24) {
+ dateFormat = dateFormat.replace('MMM D, ', '');
+ }
+ if (date.minutes() === 0) {
+ dateFormat = dateFormat.replace(':mm', '');
+ }
+
+ message += ` until ${DateUtils.format(date, dateFormat)}`;
+ }
+ return message;
+ },
+
+ createSnoozeCategory(accountId, name = SNOOZE_CATEGORY_NAME) {
+ const category = new Category({
+ displayName: name,
+ accountId: accountId,
+ })
+ const task = new SyncbackCategoryTask({category})
+
+ Actions.queueTask(task)
+ return TaskQueueStatusStore.waitForPerformRemote(task).then(() => {
+ return DatabaseStore.findBy(Category, {clientId: category.clientId})
+ .then((updatedCat) => {
+ if (updatedCat && updatedCat.isSavedRemotely()) {
+ return Promise.resolve(updatedCat)
+ }
+ return Promise.reject(new Error('Could not create Snooze category'))
+ })
+ })
+ },
+
+ getSnoozeCategory(accountId, categoryName = SNOOZE_CATEGORY_NAME) {
+ return FolderSyncProgressStore.whenCategoryListSynced(accountId)
+ .then(() => {
+ const allCategories = CategoryStore.categories(accountId)
+ const category = _.findWhere(allCategories, {displayName: categoryName})
+ if (category) {
+ return Promise.resolve(category);
+ }
+ return SnoozeUtils.createSnoozeCategory(accountId, categoryName)
+ })
+ },
+
+ getSnoozeCategoriesByAccount(accounts = AccountStore.accounts()) {
+ const snoozeCategoriesByAccountId = {}
+ accounts.forEach(({id}) => {
+ if (snoozeCategoriesByAccountId[id] != null) return;
+ snoozeCategoriesByAccountId[id] = SnoozeUtils.getSnoozeCategory(id)
+ })
+ return Promise.props(snoozeCategoriesByAccountId)
+ },
+
+ moveThreads(threads, {snooze, getSnoozeCategory, getInboxCategory, description} = {}) {
+ const tasks = TaskFactory.tasksForApplyingCategories({
+ source: "Snooze Move",
+ threads,
+ categoriesToRemove: snooze ? getInboxCategory : getSnoozeCategory,
+ categoriesToAdd: snooze ? getSnoozeCategory : getInboxCategory,
+ taskDescription: description,
+ })
+
+ Actions.queueTasks(tasks)
+ const promises = tasks.map(task => TaskQueueStatusStore.waitForPerformRemote(task))
+ // Resolve with the updated threads
+ return (
+ Promise.all(promises).then(() => {
+ return DatabaseStore.modelify(Thread, _.pluck(threads, 'clientId'))
+ })
+ )
+ },
+
+ moveThreadsToSnooze(threads, snoozeCategoriesByAccountPromise, snoozeDate) {
+ return snoozeCategoriesByAccountPromise
+ .then((snoozeCategoriesByAccountId) => {
+ const getSnoozeCategory = (accId) => [snoozeCategoriesByAccountId[accId]]
+ const getInboxCategory = (accId) => [CategoryStore.getInboxCategory(accId)]
+ const description = SnoozeUtils.snoozedUntilMessage(snoozeDate)
+ return SnoozeUtils.moveThreads(
+ threads,
+ {snooze: true, getSnoozeCategory, getInboxCategory, description}
+ )
+ })
+ },
+
+ moveThreadsFromSnooze(threads, snoozeCategoriesByAccountPromise) {
+ return snoozeCategoriesByAccountPromise
+ .then((snoozeCategoriesByAccountId) => {
+ const getSnoozeCategory = (accId) => [snoozeCategoriesByAccountId[accId]]
+ const getInboxCategory = (accId) => [CategoryStore.getInboxCategory(accId)]
+ const description = 'Unsnoozed';
+ return SnoozeUtils.moveThreads(
+ threads,
+ {snooze: false, getSnoozeCategory, getInboxCategory, description}
+ )
+ })
+ },
+}
+
+export default SnoozeUtils
diff --git a/packages/client-app/internal_packages/thread-snooze/package.json b/packages/client-app/internal_packages/thread-snooze/package.json
new file mode 100644
index 0000000000..a2fcabf2e4
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "thread-snooze",
+ "version": "1.0.0",
+ "title": "Thread Snooze",
+ "description": "Snooze mail!",
+ "main": "lib/main",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "github.com/nylas/nylas-mail"
+ },
+ "engines": {
+ "nylas": "*"
+ },
+ "license": "GPL-3.0"
+}
diff --git a/packages/client-app/internal_packages/thread-snooze/spec/snooze-store-spec.es6 b/packages/client-app/internal_packages/thread-snooze/spec/snooze-store-spec.es6
new file mode 100644
index 0000000000..f06dd193d2
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/spec/snooze-store-spec.es6
@@ -0,0 +1,184 @@
+import {
+ AccountStore,
+ CategoryStore,
+ NylasAPIHelpers,
+ Thread,
+ Actions,
+ Category,
+} from 'nylas-exports'
+import SnoozeUtils from '../lib/snooze-utils'
+import SnoozeStore from '../lib/snooze-store'
+
+
+xdescribe('SnoozeStore', function snoozeStore() {
+ beforeEach(() => {
+ this.store = new SnoozeStore('plug-id', 'plug-name')
+ this.name = 'Snooze folder'
+ this.accounts = [{id: 123}, {id: 321}]
+
+ this.snoozeCatsByAccount = {
+ 123: new Category({accountId: 123, displayName: this.name, serverId: 'sn-1'}),
+ 321: new Category({accountId: 321, displayName: this.name, serverId: 'sn-2'}),
+ }
+ this.inboxCatsByAccount = {
+ 123: new Category({accountId: 123, name: 'inbox', serverId: 'in-1'}),
+ 321: new Category({accountId: 321, name: 'inbox', serverId: 'in-2'}),
+ }
+ this.threads = [
+ new Thread({accountId: 123, serverId: 's-1'}),
+ new Thread({accountId: 123, serverId: 's-2'}),
+ new Thread({accountId: 321, serverId: 's-3'}),
+ ]
+ this.updatedThreadsByAccountId = {
+ 123: {
+ threads: [this.threads[0], this.threads[1]],
+ snoozeCategoryId: 'sn-1',
+ returnCategoryId: 'in-1',
+ },
+ 321: {
+ threads: [this.threads[2]],
+ snoozeCategoryId: 'sn-2',
+ returnCategoryId: 'in-2',
+ },
+ }
+ this.store.snoozeCategoriesPromise = Promise.resolve()
+ spyOn(this.store, 'recordSnoozeEvent')
+ spyOn(this.store, 'groupUpdatedThreads').andReturn(Promise.resolve(this.updatedThreadsByAccountId))
+
+ spyOn(AccountStore, 'accountsForItems').andReturn(this.accounts)
+ spyOn(NylasAPIHelpers, 'authPlugin').andReturn(Promise.resolve())
+ spyOn(SnoozeUtils, 'moveThreadsToSnooze').andReturn(Promise.resolve(this.threads))
+ spyOn(SnoozeUtils, 'moveThreadsFromSnooze')
+ spyOn(Actions, 'setMetadata')
+ spyOn(Actions, 'closePopover')
+ spyOn(NylasEnv, 'reportError')
+ spyOn(NylasEnv, 'showErrorDialog')
+ })
+
+ describe('groupUpdatedThreads', () => {
+ it('groups the threads correctly by account id, with their snooze and inbox categories', () => {
+ spyOn(CategoryStore, 'getInboxCategory').andCallFake(accId => this.inboxCatsByAccount[accId])
+
+ waitsForPromise(() => {
+ return this.store.groupUpdatedThreads(this.threads, this.snoozeCatsByAccount)
+ .then((result) => {
+ expect(result['123']).toEqual({
+ threads: [this.threads[0], this.threads[1]],
+ snoozeCategoryId: 'sn-1',
+ returnCategoryId: 'in-1',
+ })
+ expect(result['321']).toEqual({
+ threads: [this.threads[2]],
+ snoozeCategoryId: 'sn-2',
+ returnCategoryId: 'in-2',
+ })
+ })
+ })
+ });
+ });
+
+ describe('onAccountsChanged', () => {
+ it('updates categories promise if an account has been added', () => {
+ const nextAccounts = [
+ {id: 'ac1'},
+ {id: 'ac2'},
+ {id: 'ac3'},
+ ]
+ this.store.accountIds = ['ac1', 'ac2']
+ spyOn(SnoozeUtils, 'getSnoozeCategoriesByAccount')
+ spyOn(AccountStore, 'accounts').andReturn(nextAccounts)
+ this.store.onAccountsChanged()
+ expect(SnoozeUtils.getSnoozeCategoriesByAccount).toHaveBeenCalledWith(nextAccounts)
+ });
+
+ it('updates categories promise if an account has been removed', () => {
+ const nextAccounts = [
+ {id: 'ac1'},
+ {id: 'ac3'},
+ ]
+ this.store.accountIds = ['ac1', 'ac2', 'ac3']
+ spyOn(SnoozeUtils, 'getSnoozeCategoriesByAccount')
+ spyOn(AccountStore, 'accounts').andReturn(nextAccounts)
+ this.store.onAccountsChanged()
+ expect(SnoozeUtils.getSnoozeCategoriesByAccount).toHaveBeenCalledWith(nextAccounts)
+ });
+
+ it('updates categories promise if an account is added and another removed', () => {
+ const nextAccounts = [
+ {id: 'ac1'},
+ {id: 'ac3'},
+ ]
+ this.store.accountIds = ['ac1', 'ac2']
+ spyOn(SnoozeUtils, 'getSnoozeCategoriesByAccount')
+ spyOn(AccountStore, 'accounts').andReturn(nextAccounts)
+ this.store.onAccountsChanged()
+ expect(SnoozeUtils.getSnoozeCategoriesByAccount).toHaveBeenCalledWith(nextAccounts)
+ });
+
+ it('does not update categories promise if accounts have not changed', () => {
+ const nextAccounts = [
+ {id: 'ac1'},
+ {id: 'ac2'},
+ ]
+ this.store.accountIds = ['ac1', 'ac2']
+ spyOn(SnoozeUtils, 'getSnoozeCategoriesByAccount')
+ spyOn(AccountStore, 'accounts').andReturn(nextAccounts)
+ this.store.onAccountsChanged()
+ expect(SnoozeUtils.getSnoozeCategoriesByAccount).not.toHaveBeenCalled()
+ });
+ });
+
+ describe('onSnoozeThreads', () => {
+ it('auths plugin against all present accounts', () => {
+ waitsForPromise(() => {
+ return this.store.onSnoozeThreads(this.threads, 'date', 'label')
+ .then(() => {
+ expect(NylasAPIHelpers.authPlugin).toHaveBeenCalled()
+ expect(NylasAPIHelpers.authPlugin.calls[0].args[2]).toEqual(this.accounts[0])
+ expect(NylasAPIHelpers.authPlugin.calls[1].args[2]).toEqual(this.accounts[1])
+ })
+ })
+ });
+
+ it('calls Actions.setMetadata with the correct metadata', () => {
+ waitsForPromise(() => {
+ return this.store.onSnoozeThreads(this.threads, 'date', 'label')
+ .then(() => {
+ expect(Actions.setMetadata).toHaveBeenCalled()
+ expect(Actions.setMetadata.calls[0].args).toEqual([
+ this.updatedThreadsByAccountId['123'].threads,
+ 'plug-id',
+ {
+ snoozeDate: 'date',
+ snoozeCategoryId: 'sn-1',
+ returnCategoryId: 'in-1',
+ },
+ ])
+ expect(Actions.setMetadata.calls[1].args).toEqual([
+ this.updatedThreadsByAccountId['321'].threads,
+ 'plug-id',
+ {
+ snoozeDate: 'date',
+ snoozeCategoryId: 'sn-2',
+ returnCategoryId: 'in-2',
+ },
+ ])
+ })
+ })
+ });
+
+ it('displays dialog on error', () => {
+ jasmine.unspy(SnoozeUtils, 'moveThreadsToSnooze')
+ spyOn(SnoozeUtils, 'moveThreadsToSnooze').andReturn(Promise.reject(new Error('Oh no!')))
+
+ waitsForPromise(() => {
+ return this.store.onSnoozeThreads(this.threads, 'date', 'label')
+ .finally(() => {
+ expect(SnoozeUtils.moveThreadsFromSnooze).toHaveBeenCalled()
+ expect(NylasEnv.reportError).toHaveBeenCalled()
+ expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
+ })
+ })
+ });
+ });
+})
diff --git a/packages/client-app/internal_packages/thread-snooze/spec/snooze-utils-spec.es6 b/packages/client-app/internal_packages/thread-snooze/spec/snooze-utils-spec.es6
new file mode 100644
index 0000000000..859e1e48aa
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/spec/snooze-utils-spec.es6
@@ -0,0 +1,224 @@
+import moment from 'moment'
+import {
+ Actions,
+ TaskQueueStatusStore,
+ TaskFactory,
+ DatabaseStore,
+ Category,
+ Thread,
+ CategoryStore,
+ FolderSyncProgressStore,
+} from 'nylas-exports'
+import SnoozeUtils from '../lib/snooze-utils'
+
+xdescribe('Snooze Utils', function snoozeUtils() {
+ beforeEach(() => {
+ this.name = 'Snoozed Folder'
+ this.accId = 123
+ spyOn(FolderSyncProgressStore, 'whenCategoryListSynced').andReturn(Promise.resolve())
+ })
+
+ describe('snoozedUntilMessage', () => {
+ it('returns correct message if no snooze date provided', () => {
+ expect(SnoozeUtils.snoozedUntilMessage()).toEqual('Snoozed')
+ });
+
+ describe('when less than 24 hours from now', () => {
+ it('returns correct message if snoozeDate is on the hour of the clock', () => {
+ const now9AM = window.testNowMoment().hour(9).minute(0)
+ const tomorrowAt8 = moment(now9AM).add(1, 'day').hour(8)
+ const result = SnoozeUtils.snoozedUntilMessage(tomorrowAt8, now9AM)
+ expect(result).toEqual('Snoozed until 8 AM')
+ });
+
+ it('returns correct message if snoozeDate otherwise', () => {
+ const now9AM = window.testNowMoment().hour(9).minute(0)
+ const snooze10AM = moment(now9AM).hour(10).minute(5)
+ const result = SnoozeUtils.snoozedUntilMessage(snooze10AM, now9AM)
+ expect(result).toEqual('Snoozed until 10:05 AM')
+ });
+ });
+
+ describe('when more than 24 hourse from now', () => {
+ it('returns correct message if snoozeDate is on the hour of the clock', () => {
+ // Jan 1
+ const now9AM = window.testNowMoment().month(0).date(1).hour(9).minute(0)
+ const tomorrowAt10 = moment(now9AM).add(1, 'day').hour(10)
+ const result = SnoozeUtils.snoozedUntilMessage(tomorrowAt10, now9AM)
+ expect(result).toEqual('Snoozed until Jan 2, 10 AM')
+ });
+
+ it('returns correct message if snoozeDate otherwise', () => {
+ // Jan 1
+ const now9AM = window.testNowMoment().month(0).date(1).hour(9).minute(0)
+ const tomorrowAt930 = moment(now9AM).add(1, 'day').minute(30)
+ const result = SnoozeUtils.snoozedUntilMessage(tomorrowAt930, now9AM)
+ expect(result).toEqual('Snoozed until Jan 2, 9:30 AM')
+ });
+ });
+ });
+
+ describe('createSnoozeCategory', () => {
+ beforeEach(() => {
+ this.category = new Category({
+ displayName: this.name,
+ accountId: this.accId,
+ clientId: 321,
+ serverId: 321,
+ })
+ spyOn(Actions, 'queueTask')
+ spyOn(TaskQueueStatusStore, 'waitForPerformRemote').andReturn(Promise.resolve())
+ spyOn(DatabaseStore, 'findBy').andReturn(Promise.resolve(this.category))
+ })
+
+ it('creates category with correct snooze name', () => {
+ SnoozeUtils.createSnoozeCategory(this.accId, this.name)
+ expect(Actions.queueTask).toHaveBeenCalled()
+ const task = Actions.queueTask.calls[0].args[0]
+ expect(task.category.displayName).toEqual(this.name)
+ expect(task.category.accountId).toEqual(this.accId)
+ });
+
+ it('resolves with the updated category that has been saved to the server', () => {
+ waitsForPromise(() => {
+ return SnoozeUtils.createSnoozeCategory(this.accId, this.name).then((result) => {
+ expect(DatabaseStore.findBy).toHaveBeenCalled()
+ expect(result).toBe(this.category)
+ })
+ })
+ });
+
+ it('rejects if the category could not be found in the database', () => {
+ this.category.serverId = null
+ jasmine.unspy(DatabaseStore, 'findBy')
+ spyOn(DatabaseStore, 'findBy').andReturn(Promise.resolve(this.category))
+ waitsForPromise(() => {
+ return SnoozeUtils.createSnoozeCategory(this.accId, this.name)
+ .then(() => {
+ throw new Error('SnoozeUtils.createSnoozeCategory should not resolve in this case!')
+ })
+ .catch((error) => {
+ expect(DatabaseStore.findBy).toHaveBeenCalled()
+ expect(error.message).toEqual('Could not create Snooze category')
+ })
+ })
+ });
+
+ it('rejects if the category could not be saved to the server', () => {
+ jasmine.unspy(DatabaseStore, 'findBy')
+ spyOn(DatabaseStore, 'findBy').andReturn(Promise.resolve(undefined))
+ waitsForPromise(() => {
+ return SnoozeUtils.createSnoozeCategory(this.accId, this.name)
+ .then(() => {
+ throw new Error('SnoozeUtils.createSnoozeCategory should not resolve in this case!')
+ })
+ .catch((error) => {
+ expect(DatabaseStore.findBy).toHaveBeenCalled()
+ expect(error.message).toEqual('Could not create Snooze category')
+ })
+ })
+ });
+ });
+
+ describe('getSnoozeCategory', () => {
+ it('resolves category if it exists in the category store', () => {
+ const categories = [
+ new Category({accountId: this.accId, name: 'inbox'}),
+ new Category({accountId: this.accId, displayName: this.name}),
+ ]
+ spyOn(CategoryStore, 'categories').andReturn(categories)
+ spyOn(SnoozeUtils, 'createSnoozeCategory')
+
+ waitsForPromise(() => {
+ return SnoozeUtils.getSnoozeCategory(this.accountId, this.name)
+ .then((result) => {
+ expect(SnoozeUtils.createSnoozeCategory).not.toHaveBeenCalled()
+ expect(result).toBe(categories[1])
+ })
+ })
+ });
+
+ it('creates category if it does not exist', () => {
+ const categories = [
+ new Category({accountId: this.accId, name: 'inbox'}),
+ ]
+ const snoozeCat = new Category({accountId: this.accId, displayName: this.name})
+ spyOn(CategoryStore, 'categories').andReturn(categories)
+ spyOn(SnoozeUtils, 'createSnoozeCategory').andReturn(Promise.resolve(snoozeCat))
+
+ waitsForPromise(() => {
+ return SnoozeUtils.getSnoozeCategory(this.accId, this.name)
+ .then((result) => {
+ expect(SnoozeUtils.createSnoozeCategory).toHaveBeenCalledWith(this.accId, this.name)
+ expect(result).toBe(snoozeCat)
+ })
+ })
+ });
+ });
+
+ describe('moveThreads', () => {
+ beforeEach(() => {
+ this.description = 'Snoozin';
+ this.snoozeCatsByAccount = {
+ 123: new Category({accountId: 123, displayName: this.name, serverId: 'sr-1'}),
+ 321: new Category({accountId: 321, displayName: this.name, serverId: 'sr-2'}),
+ }
+ this.inboxCatsByAccount = {
+ 123: new Category({accountId: 123, name: 'inbox', serverId: 'sr-3'}),
+ 321: new Category({accountId: 321, name: 'inbox', serverId: 'sr-4'}),
+ }
+ this.threads = [
+ new Thread({accountId: 123}),
+ new Thread({accountId: 123}),
+ new Thread({accountId: 321}),
+ ]
+ this.getInboxCat = (accId) => [this.inboxCatsByAccount[accId]]
+ this.getSnoozeCat = (accId) => [this.snoozeCatsByAccount[accId]]
+
+ spyOn(DatabaseStore, 'modelify').andReturn(Promise.resolve(this.threads))
+ spyOn(TaskFactory, 'tasksForApplyingCategories').andReturn([])
+ spyOn(TaskQueueStatusStore, 'waitForPerformRemote').andReturn(Promise.resolve())
+ spyOn(Actions, 'queueTasks')
+ })
+
+ it('creates the tasks to move threads correctly when snoozing', () => {
+ const snooze = true
+ const description = this.description
+
+ waitsForPromise(() => {
+ return SnoozeUtils.moveThreads(this.threads, {snooze, description, getInboxCategory: this.getInboxCat, getSnoozeCategory: this.getSnoozeCat})
+ .then(() => {
+ expect(TaskFactory.tasksForApplyingCategories).toHaveBeenCalled()
+ expect(Actions.queueTasks).toHaveBeenCalled()
+ const {threads, categoriesToAdd, categoriesToRemove, taskDescription} = TaskFactory.tasksForApplyingCategories.calls[0].args[0]
+ expect(threads).toBe(this.threads)
+ expect(categoriesToRemove('123')[0]).toBe(this.inboxCatsByAccount['123'])
+ expect(categoriesToRemove('321')[0]).toBe(this.inboxCatsByAccount['321'])
+ expect(categoriesToAdd('123')[0]).toBe(this.snoozeCatsByAccount['123'])
+ expect(categoriesToAdd('321')[0]).toBe(this.snoozeCatsByAccount['321'])
+ expect(taskDescription).toEqual(description)
+ })
+ })
+ });
+
+ it('creates the tasks to move threads correctly when unsnoozing', () => {
+ const snooze = false
+ const description = this.description
+
+ waitsForPromise(() => {
+ return SnoozeUtils.moveThreads(this.threads, {snooze, description, getInboxCategory: this.getInboxCat, getSnoozeCategory: this.getSnoozeCat})
+ .then(() => {
+ expect(TaskFactory.tasksForApplyingCategories).toHaveBeenCalled()
+ expect(Actions.queueTasks).toHaveBeenCalled()
+ const {threads, categoriesToAdd, categoriesToRemove, taskDescription} = TaskFactory.tasksForApplyingCategories.calls[0].args[0]
+ expect(threads).toBe(this.threads)
+ expect(categoriesToAdd('123')[0]).toBe(this.inboxCatsByAccount['123'])
+ expect(categoriesToAdd('321')[0]).toBe(this.inboxCatsByAccount['321'])
+ expect(categoriesToRemove('123')[0]).toBe(this.snoozeCatsByAccount['123'])
+ expect(categoriesToRemove('321')[0]).toBe(this.snoozeCatsByAccount['321'])
+ expect(taskDescription).toEqual(description)
+ })
+ })
+ });
+ });
+});
diff --git a/packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-feature-used-modal.less b/packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-feature-used-modal.less
new file mode 100644
index 0000000000..3b0381edf0
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-feature-used-modal.less
@@ -0,0 +1,20 @@
+@import "ui-variables";
+
+.feature-usage-modal.snooze {
+ @snooze-color: #8e6ce3;
+ .feature-header {
+ @from: @snooze-color;
+ @to: lighten(@snooze-color, 10%);
+ background: linear-gradient(to top, @from, @to);
+ }
+ .feature-name {
+ color: @snooze-color;
+ }
+ .pro-description {
+ li {
+ &:before {
+ color: @snooze-color;
+ }
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-mail-label.less b/packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-mail-label.less
new file mode 100644
index 0000000000..bc8c9728d3
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-mail-label.less
@@ -0,0 +1,11 @@
+@snooze-color: #472B82;
+
+.snooze-mail-label {
+ display: flex;
+ align-items: center;
+
+ img {
+ background-color: @snooze-color;
+ margin-right: 5px;
+ }
+}
diff --git a/packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-popover.less b/packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-popover.less
new file mode 100644
index 0000000000..633e9f5f40
--- /dev/null
+++ b/packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-popover.less
@@ -0,0 +1,60 @@
+@import "ui-variables";
+
+@snooze-quickaction-img: "../static/images/thread-list-quick-actions/ic-quickaction-snooze@2x.png";
+
+.thread-list .list-item .list-column-HoverActions .action.action-snooze {
+ background: url(@snooze-quickaction-img) center no-repeat, @background-gradient;
+}
+
+.snooze-button {
+ order: -103;
+}
+
+.snooze-popover {
+ color: fadeout(@btn-default-text-color, 20%);
+ display: flex;
+ flex-direction: column;
+
+ .snooze-row {
+ display: flex;
+
+ .snooze-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ padding: 15px 0;
+ cursor: default;
+ width: 105px;
+ line-height: initial;
+ text-align: initial;
+
+ img { background-color: fadeout(@btn-default-text-color, 20%); }
+ &:hover {
+ background-color: darken(@btn-default-bg-color, 5%);
+ color: fadeout(@btn-default-text-color, 10%);
+ img { background-color: fadeout(@btn-default-text-color, 10%); }
+ }
+ &:active {
+ background-color: darken(@btn-default-bg-color, 8%);
+ color: fadeout(@btn-default-text-color, 0%);
+ img { background-color: fadeout(@btn-default-text-color, 0%); }
+ }
+ &+.snooze-item {
+ border-left: 1px solid @border-color-divider;
+ }
+ }
+ &+.snooze-row {
+ border-top: 1px solid @border-color-divider;
+ }
+ }
+
+ .snooze-input {
+ border-top: 1px solid @border-color-divider;
+ padding: @padding-large-vertical @padding-large-horizontal;
+
+ input {
+ margin-bottom: 3px;
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-dark/LICENSE.md b/packages/client-app/internal_packages/ui-dark/LICENSE.md
new file mode 100644
index 0000000000..4d231b4563
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-dark/LICENSE.md
@@ -0,0 +1,20 @@
+Copyright (c) 2014 GitHub Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/client-app/internal_packages/ui-dark/README.md b/packages/client-app/internal_packages/ui-dark/README.md
new file mode 100644
index 0000000000..cd03b320bc
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-dark/README.md
@@ -0,0 +1,7 @@
+# N1 Dark UI theme
+
+Default dark UI theme for N1.
+
+This theme is installed by default with N1 and can be activated by going to
+the _Themes_ section in the Settings view (`cmd-,`) and selecting it from the
+_UI Themes_ drop-down menu.
diff --git a/packages/client-app/internal_packages/ui-dark/package.json b/packages/client-app/internal_packages/ui-dark/package.json
new file mode 100644
index 0000000000..ca145587c0
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-dark/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "ui-dark",
+ "displayName": "Dark",
+ "theme": "ui",
+ "version": "0.1.0",
+ "description": "The Dark N1 Client Theme",
+ "license": "GPL-3.0",
+ "styleSheets": ["email-frame"],
+ "engines": {
+ "nylas": "*"
+ },
+ "private": true
+}
diff --git a/packages/client-app/internal_packages/ui-dark/styles/email-frame.less b/packages/client-app/internal_packages/ui-dark/styles/email-frame.less
new file mode 100644
index 0000000000..a3bffa0ee4
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-dark/styles/email-frame.less
@@ -0,0 +1,9 @@
+.ignore-in-parent-frame {
+ body {
+ -webkit-filter: invert() hue-rotate(180deg);
+ color: #111;
+ }
+ img {
+ -webkit-filter: invert() hue-rotate(180deg);
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-dark/styles/ui-variables.less b/packages/client-app/internal_packages/ui-dark/styles/ui-variables.less
new file mode 100644
index 0000000000..73489a96e9
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-dark/styles/ui-variables.less
@@ -0,0 +1,65 @@
+@gray-base: #ffffff;
+@gray-darker: darken(@gray-base, 13.5%);
+@gray-dark: darken(@gray-base, 20%);
+@gray: darken(@gray-base, 33.5%);
+@gray-light: darken(@gray-base, 46.7%);
+@gray-lighter: darken(@gray-base, 92.5%);
+@white: #0a0b0c;
+
+@accent-primary: #5e6a77;
+@accent-primary-dark: #5e6a77;
+
+@background-primary: #333539;
+@background-off-primary: #282828;
+@background-secondary: #333539;
+@background-tertiary: #333539;
+
+@border-color-primary: darken(@background-primary, 1%);
+@border-color-secondary: darken(@background-secondary, 1%);
+@border-color-tertiary: darken(@background-tertiary, 1%);
+@border-color-divider: @border-color-secondary;
+
+@text-color: #c0c6cb;
+@text-color-subtle: fadeout(@text-color, 20%);
+@text-color-very-subtle: fadeout(@text-color, 40%);
+@text-color-inverse: white;
+@text-color-inverse-subtle: fadeout(@text-color-inverse, 20%);
+@text-color-inverse-very-subtle: fadeout(@text-color-inverse, 50%);
+@text-color-heading: #FFF;
+
+@btn-default-bg-color: lighten(@background-primary, 5%);
+@dropdown-default-bg-color: #404040;
+
+@input-bg: #242424;
+@input-border: @border-color-divider;
+
+@list-bg: #333;
+@list-border: #383838;
+@list-selected-color: @text-color-inverse;
+@list-focused-color: @text-color;
+
+@toolbar-background-color: @background-secondary;
+@panel-background-color: #282b30;
+
+.sheet-toolbar {
+ border-bottom: none;
+ box-shadow: 0 0 0.5px @border-color-primary, 0 1px 1.5px @border-color-primary, 0 0 3px @border-color-primary;
+}
+
+.thread-icon:not(.thread-icon-unread):not(.thread-icon-star) {
+ -webkit-filter: invert(100%);
+}
+img.content-dark {
+ -webkit-filter: invert(100%);
+}
+img.content-light {
+ -webkit-filter: invert(100%);
+}
+
+.popover {
+ border: 1px solid @border-color-primary;
+}
+
+.mail-label {
+ -webkit-filter: contrast(110%) brightness(85%);
+}
\ No newline at end of file
diff --git a/packages/client-app/internal_packages/ui-darkside/LICENSE b/packages/client-app/internal_packages/ui-darkside/LICENSE
new file mode 100644
index 0000000000..326fc3d9ef
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Jamie Wilson
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/packages/client-app/internal_packages/ui-darkside/README.md b/packages/client-app/internal_packages/ui-darkside/README.md
new file mode 100644
index 0000000000..52042e1b49
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/README.md
@@ -0,0 +1,64 @@
+# Darkside
+**An dark sidebar theme for [Nylas Mail](https://nylas.com/n1). Created by [Jamie Wilson](http://jamiewilson.io)**
+
+## Activation
+Darkside comes [pre-installed](https://github.com/nylas/nylas-mail/tree/master/internal_packages/ui-darkside) with N1. To change themes, go to `Nylas Mail > Change Theme…` in the menu bar, then select `Darkside`. Learn more at [support.nylas.com](https://support.nylas.com/hc/en-us/articles/217557858-How-do-I-change-my-theme-).
+
+## Customization
+In order to customize Darkside, you'll need to manually install it.
+
+#### 1. Download the `ui-darkside` folder.
+
+> **Download Option 1:**
+> [Download just the 'ui-darkside' folder](https://kinolien.github.io/gitzip/?download=https://github.com/nylas/nylas-mail/tree/master/internal_packages/ui-darkside) thanks to the service [gitzip by @kinolien](https://kinolien.github.io/gitzip/).
+
+
+> **Download Option 2:**
+> [Download the entire N1 repo](https://github.com/nylas/nylas-mail/archive/master.zip) or `git clone https://github.com/nylas/nylas-mail.git`. Then grab the folder from `N1/internal_packages/ui-darkside`.
+
+#### 2. Manual Install
+
+> To manually install a theme, go to `Nylas Mail > Install Theme…` in the menu bar. Select the `ui-darkside` folder you just downloaded. This will copy the folder into your N1 packages directory so you can delete the orginal download if you want to.
+
+#### 3. Customize
+
+> **Open the theme directory**
+> If you're on a Mac, you can find the theme files at `~/.nylas-mail/packages`. To get there quickly, use the key command Cmd + Shift + G and enter `~/.nylas-mail/packages`.
+
+> **Change package.json**
+> In order to avoid conflicts between your custom theme and the pre-installed version, change `name` and `displayName` in `package.json` to:
+
+ "name": "ui-darkside-custom",
+ "displayName": "Darkside Custom",
+
+> **Edit LESS files**
+> Open the `darkside-variables.less` file. To change colors, just comment out the default `@sidebar` and `@accent` variables and uncomment another theme or simply replace with your own colors.
+
+```sass
+// Default
+@sidebar: #313042;
+@accent: #F18260;
+
+// Luna
+// @sidebar: #202C46;
+// @accent: #39DFF8;
+
+// Zond
+// @sidebar: #333333;
+// @accent: #F6D49C;
+
+// Gemini
+// @sidebar: #00203C;
+// @accent: #F6B312;
+
+// Mercury
+// @sidebar: #555;
+// @accent: #999;
+
+// Apollo
+// @sidebar: #3A1E15;
+// @accent: #F6AA1C;
+```
+
+### Feedback
+If you have questions or suggestions, please submit an issue. If you need to, you can email me at [jamie@jamiewilson.io](mailto:jamie@jamiewilson?subject=Re: Darkside).
diff --git a/packages/client-app/internal_packages/ui-darkside/index.less b/packages/client-app/internal_packages/ui-darkside/index.less
new file mode 100644
index 0000000000..e7f1d3d767
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/index.less
@@ -0,0 +1,14 @@
+@import "styles/darkside-variables";
+@import "styles/darkside-sidebar";
+@import "styles/darkside-toolbars";
+@import "styles/darkside-window-controls";
+@import "styles/darkside-threadlist";
+@import "styles/darkside-inputs";
+@import "styles/darkside-thread-icons";
+@import "styles/darkside-swiping";
+@import "styles/darkside-labels";
+@import "styles/darkside-message-list";
+@import "styles/darkside-composer";
+@import "styles/darkside-preferences";
+@import "styles/darkside-notifications";
+@import "styles/darkside-drafts";
diff --git a/packages/client-app/internal_packages/ui-darkside/package.json b/packages/client-app/internal_packages/ui-darkside/package.json
new file mode 100644
index 0000000000..afff44c628
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "ui-darkside",
+ "displayName": "Darkside",
+ "theme": "ui",
+ "version": "1.0.0",
+ "description": "A customizable, dark sidebar theme for Nylas Mail.",
+ "license": "MIT",
+ "engines": {
+ "nylas": "*"
+ },
+ "private": true
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-composer.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-composer.less
new file mode 100644
index 0000000000..8791d64f28
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-composer.less
@@ -0,0 +1,61 @@
+@import "darkside-variables";
+
+.tokenizing-field .token.invalid,
+.tokenizing-field .token.invalid:hover,
+.tokenizing-field .token.invalid.selected,
+.tokenizing-field .token.invalid.dragging {
+ color: @sidebar;
+ background: none;
+ border: none;
+ box-shadow: inset 0 0 0 1px @invalid;
+}
+
+// Darken composer action bar to contrast from background
+.composer-inner-wrap .composer-action-bar-wrap,
+.composer-full-window .composer-inner-wrap .composer-action-bar-wrap {
+ background: darken(@messagelist-bg, 1%);
+ box-shadow: none;
+ border-radius: 0;
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+}
+
+// Replacing focused state with theme accent
+#message-list .message-item-wrap .message-item-white-wrap.composer-outer-wrap {
+ background: white;
+ &.focused {
+ box-shadow: 0 0 0 1px @accent;
+ }
+}
+
+.message-item-white-wrap.composer-outer-wrap .composer-participant-field .dropdown-component .signature-button-dropdown .only-item {
+ background: white;
+}
+
+// make action bar at bottom of composer a bit darker than background
+#message-list .message-item-wrap .message-item-white-wrap.composer-outer-wrap {
+ & .composer-action-bar-wrap { background: transparent; }
+ &.focused .composer-action-bar-wrap { background: darken(@messagelist-bg, 1%); }
+}
+
+.composer-inner-wrap .composer-action-bar-wrap .composer-action-bar-content {
+ padding: 20px;
+ max-width: 100%;
+}
+
+// ============================
+// Attachements
+// ============================
+
+.file-wrap.file-image-wrap .file-preview .file-name-container {
+ background: fade(@sidebar, 20%);
+ min-height: 0;
+ & .file-name {
+ left: 0;
+ right: 0;
+ bottom: 0;
+ color: white;
+ background: fade(@sidebar, 80%);
+ padding: 5px 15px;
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-drafts.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-drafts.less
new file mode 100644
index 0000000000..9ad40f6b63
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-drafts.less
@@ -0,0 +1,37 @@
+@import "darkside-variables";
+
+// Make corresponding toolbar match threadlist background
+.draft-list,
+.toolbar-DraftList {
+ background: @messagelist-bg;
+}
+
+.draft-list .list-container .list-item.selected,
+.draft-list .list-tabular .list-tabular-item.keyboard-cursor {
+ background: white;
+}
+
+.draft-list .list-tabular .list-tabular-item .checkmark .inner {
+ background-color: white;
+ border-color: tint(@sidebar, 75%);
+}
+
+.list-tabular .list-tabular-item.selected .checkmark .inner {
+ background-color: @accent;
+ background-image: url('data:image/svg+xml;utf8, ');
+ background-size: 8px 6px;
+}
+
+// Make draft-list items slightly darker on hover
+// Using !important so multiple selection actions
+.draft-list .list-tabular .list-tabular-item:hover {
+ background: tint(@sidebar, 90%) !important;
+}
+
+// Center vertically regardless of list item height
+.draft-list .sending-progress {
+ align-self: center;
+ background-color: #f5f5f5;
+ border: none;
+ margin-top: 0;
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-inputs.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-inputs.less
new file mode 100644
index 0000000000..b2b0780bcb
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-inputs.less
@@ -0,0 +1,23 @@
+@import "darkside-variables";
+
+textarea:focus,
+input[type="text"]:focus,
+input[type="email"]:focus,
+.search-bar .menu .header-container input:focus {
+ border-color: @accent;
+ box-shadow: 0 0 1.5px @accent;
+}
+
+.search-bar {
+ margin: 7.5px;
+ width: 100%;
+}
+
+.search-container .content-container {
+ margin-top: 5px !important;
+}
+
+.menu .item.selected, .menu .item:active,
+.search-container .content-container .item.selected {
+ background: @accent;
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-labels.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-labels.less
new file mode 100644
index 0000000000..6422bdbe37
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-labels.less
@@ -0,0 +1,10 @@
+@import "darkside-variables";
+
+// Make labels white on accent color when message is selected
+.thread-list .focused .mail-label, .draft-list .focused .mail-label,
+.thread-list.handler-split .list-item.selected .mail-label {
+ background: @accent !important;
+ color: white !important;
+ box-shadow: none !important;
+ -webkit-filter: none !important;
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-message-list.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-message-list.less
new file mode 100644
index 0000000000..b336659a76
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-message-list.less
@@ -0,0 +1,90 @@
+@import "darkside-variables";
+
+#message-list {
+ background: @messagelist-bg;
+}
+
+// Make toolbars match panels
+.column-MessageList,
+.toolbar-MessageList,
+.column-MessageListSidebar,
+.toolbar-MessageListSidebar {
+ height: 100%;
+ background: @messagelist-bg;
+ border-left: 1px solid @border-color;
+}
+
+// Message List top and bottom spacing
+#message-list .messages-wrap .scroll-region-content-inner {
+ padding: 20px;
+ padding-bottom: 40vh;
+}
+
+// Reset padding
+#message-list .message-header,
+#message-list .message-item-wrap.collapsed .message-item-white-wrap,
+#message-list .message-item-wrap.collapsed .message-item-area {
+ padding: 0;
+}
+
+// Make padding uniform
+#message-list .message-item-area,
+#message-list .footer-reply-area-wrap .footer-reply-area {
+ padding: 20px !important;
+}
+
+#message-list .message-item-wrap.collapsed .message-item-area .collapsed-attachment {
+ padding: 10px;
+}
+
+// Adjusting position of thread participants toggle
+#message-list .header-toggle-control {
+ top: 6px !important;
+ left: -11px !important;
+ display: flex !important;
+ justify-content: center;
+ align-items: center;
+}
+
+// Reducing size and overriding invalid -webkit-mask-repeat- property
+#message-list .header-toggle-control img {
+ zoom: 0.35 !important;
+ -webkit-mask-repeat: no-repeat !important;
+}
+
+.message-participants.to-participants .collapsed-participants,
+.message-participants .expanded-participants .participant-type {
+ margin-top: 0;
+}
+
+.message-participants .from-label,
+.message-participants .to-label,
+.message-participants .cc-label,
+.message-participants .bcc-label {
+ margin-right: 6px;
+}
+
+#message-list .message-item-wrap .message-item-white-wrap,
+#message-list .minified-bundle .msg-line,
+#message-list .minified-bundle .num-messages,
+#message-list .footer-reply-area-wrap, {
+ box-shadow: inset 0 0 0 1px @border-color;
+ border: none;
+}
+
+#message-list .minified-bundle + .message-item-wrap,
+#message-list .message-item-wrap.collapsed + .minified-bundle,
+#message-list .minified-bundle .num-messages,
+#message-list .minified-bundle .msg-lines {
+ margin-top: 0;
+}
+
+// Collapsed Messages Pill Label
+#message-list .minified-bundle .num-messages {
+ padding: 3px;
+}
+
+// remove margin for last message before reply
+#message-list .message-item-wrap.before-reply-area {
+ margin-bottom: 0;
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-notifications.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-notifications.less
new file mode 100644
index 0000000000..35468111d5
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-notifications.less
@@ -0,0 +1,20 @@
+@import "darkside-variables";
+
+.notifications {
+ background-color: @sidebar;
+}
+
+.notifications .notification{
+ background-color: @accent;
+ border: none;
+}
+
+.sidebar-activity {
+ background: darken(@sidebar, 5%);
+ color: @active-sidebar-text;
+ box-shadow: none;
+}
+
+.sidebar-activity .item {
+ border: none;
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-preferences.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-preferences.less
new file mode 100644
index 0000000000..00c324efec
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-preferences.less
@@ -0,0 +1,10 @@
+@import "darkside-variables";
+
+.preferences-sidebar,
+.preferences-content {
+ background: @messagelist-bg;
+}
+
+.preferences-wrap .preferences-content > .scroll-region-content {
+ padding-bottom: 100px;
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-sidebar.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-sidebar.less
new file mode 100644
index 0000000000..a5d6a1d99b
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-sidebar.less
@@ -0,0 +1,204 @@
+@import "darkside-variables";
+
+// ============================
+// Sidebar Base
+// ============================
+
+// Make sidebar and corresponding toolbar match
+.column-RootSidebar,
+.account-sidebar,
+.toolbar-RootSidebar {
+ height: 100%;
+ background-color: @sidebar;
+ // If NOT Retina display, subpixel-antialias fonts instead
+ @media
+ not screen and (-webkit-min-device-pixel-ratio: 1.3),
+ not screen and (-o-min-device-pixel-ratio: 13/10),
+ not screen and (min-resolution: 120dpi) {
+ -webkit-font-smoothing: subpixel-antialiased !important;
+ }
+}
+
+.notifications {
+ box-shadow: none;
+}
+
+
+// Refactored this to make sure all items
+// in sidebar always align left with each other
+.account-sidebar {
+ // make absolute elements (like compose button)
+ // relate to the sidear, not the column
+ position: relative;
+ margin: @sidebar-margin;
+}
+
+.nylas-outline-view {
+ margin-bottom: @sidebar-margin;
+}
+
+// Section headers
+.account-sidebar .heading {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ color: fade(@sidebar-text, 50%);
+ margin-bottom: 10px;
+ padding: 0;
+}
+
+// Down arrow icon
+.account-switcher {
+ height: 14px;
+ width: 16px;
+ top: 0;
+ right: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ opacity: 0.5;
+ transition: opacity 200ms;
+ &:hover {
+ opacity: 1;
+ }
+}
+
+// Down arrow icon
+.account-switcher img {
+ zoom: 1 !important;
+ max-width: 10px;
+ max-height: 6px;
+ transform: none;
+ background-image: none;
+ background-color: @sidebar-text;
+ -webkit-mask-repeat: no-repeat;
+ -webkit-mask-image: url('data:image/svg+xml;utf8, ');
+}
+
+.account-sidebar .item,
+.account-sidebar .item.selected {
+ color: @sidebar-text;
+ font-size: 13px;
+ font-weight: 400;
+ padding-right: 0;
+}
+
+.account-sidebar .item.selected {
+ background: transparent;
+ color: @active-sidebar-text;
+}
+
+// Item expansion icon wrapper
+.disclosure-triangle {
+ display: flex;
+ align-items: center;
+ padding: 0;
+ width: 15px;
+}
+
+// Item expansion icon
+.disclosure-triangle div {
+ border-left-color: fade(@sidebar-text, 50%);
+ border-top-width: 3px;
+ border-bottom-width: 3px;
+ border-left-width: 5px;
+ transform-origin: 2px;
+}
+
+//====================================================
+// Sidebar Icons
+//====================================================
+
+.account-sidebar .item img.content-mask,
+.account-sidebar .add-item-button img, {
+ background-color: @sidebar-text;
+}
+
+.account-sidebar .item.selected img.content-mask {
+ background-color: @active-sidebar-text;
+}
+
+.nylas-outline-view .item-container.dropping {
+ background: transparent;
+}
+
+.nylas-outline-view .item-container.dropping .item {
+ color: @accent;
+}
+
+.nylas-outline-view .item-container.dropping .item img.content-mask {
+ background-color: @accent;
+}
+
+.nylas-outline-view .heading .add-item-button img {
+ background: fade(@sidebar-text, 50%);
+}
+
+//====================================================
+// Sidebar Count Badges
+//====================================================
+
+.nylas-outline-view .item .item-count-box.alt-count {
+ background: @accent;
+ color: @sidebar;
+}
+
+.nylas-outline-view .item .item-count-box {
+ color: @accent;
+ box-shadow: inset 0 0 0 1px fade(@accent, 50%);
+}
+
+//====================================================
+// Scrollbar Base & Sidebar Scrollbar
+//====================================================
+
+.scrollbar-track {
+ background: transparent;
+ border-left: none;
+ width: 10px;
+}
+
+// transitioning background instead of opacity
+// so the location tooltip isn't affected
+.scrollbar-track .scrollbar-handle {
+ background: fade(@sidebar, 20%);
+ border: none !important;
+ cursor: -webkit-grab;
+ transition: background 300ms;
+}
+
+.scrollbar-track.dragging .scrollbar-handle {
+ background: fade(@sidebar, 50%);
+ cursor: -webkit-grabbing;
+}
+
+@keyframes slideInRight {
+ from { opacity: 0; transform: translateX(-50px); }
+ to { opacity: 1; transform: translateX(-15px); }
+}
+
+.scrollbar-track .scrollbar-handle .tooltip .scroll-tooltip {
+ transform-origin: center right;
+ animation: slideInRight 300ms;
+}
+
+.flexbox-handle-horizontal div {
+ box-shadow: none;
+}
+
+// Removing overlap of scrollbar and handle
+.column-ThreadList .flexbox-handle-horizontal.flexbox-handle-right {
+ right: -8px;
+ padding: 0;
+}
+
+// we now offset the margin on scrollbar
+// in sidebar since it's position: relative
+.account-sidebar .scrollbar-track {
+ margin-right: -@sidebar-margin;
+}
+
+// and lighten the handle background
+.account-sidebar .scrollbar-track .scrollbar-handle {
+ background: fade(lighten(@sidebar, 50%), 40%);
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-swiping.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-swiping.less
new file mode 100644
index 0000000000..69d50d26b2
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-swiping.less
@@ -0,0 +1,27 @@
+@import "darkside-variables";
+
+.thread-list .swipe-backing.swipe-all,
+.thread-list .swipe-backing.swipe-archive,
+.draft-list .swipe-backing.swipe-all,
+.draft-list .swipe-backing.swipe-archive {
+ background: @swipe-archive;
+ &.confirmed {
+ background: saturate(@swipe-archive, 10%);
+ }
+}
+
+.thread-list .swipe-backing.swipe-snooze,
+.draft-list .swipe-backing.swipe-snooze {
+ background: @swipe-snooze;
+ &.confirmed {
+ background: saturate(@swipe-snooze, 10%);
+ }
+}
+
+.thread-list .swipe-backing.swipe-trash,
+.draft-list .swipe-backing.swipe-trash {
+ background: @swipe-trash;
+ &.confirmed {
+ background: saturate(@swipe-trash, 10%);
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-thread-icons.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-thread-icons.less
new file mode 100644
index 0000000000..068b7d4193
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-thread-icons.less
@@ -0,0 +1,78 @@
+@import "darkside-variables";
+
+// Remove inverted color effects
+.thread-list .focused .thread-icon,
+.draft-list .focused .thread-icon,
+.thread-list .focused .draft-icon,
+.draft-list .focused .draft-icon,
+.thread-list .focused .mail-important-icon,
+.draft-list .focused .mail-important-icon,
+.thread-list.handler-split .list-item.selected .thread-icon,
+.thread-list.handler-split .list-item.selected .draft-icon,
+.thread-list.handler-split .list-item.selected .mail-important-icon {
+ -webkit-filter: none !important;
+}
+
+// Base settings for replacing backgrounds with -webkit-filters for easier color changes
+.thread-list .thread-icon {
+ -webkit-mask-repeat: no-repeat;
+ -webkit-mask-size: 12px;
+ -webkit-mask-position: center;
+}
+
+// Change color of unread dot icon
+.thread-list .thread-icon.thread-icon-unread {
+ background-image: none;
+ background-color: @accent;
+ -webkit-mask-image: url(../static/images/thread-list/icon-unread-@2x.png);
+}
+
+// replace undread icon with star icon on thread item hover
+.thread-list .list-item:hover .thread-icon.thread-icon-unread {
+ background-color: tint(@sidebar);
+ -webkit-mask-image: url(../static/images/thread-list/icon-star-@2x.png);
+ &:hover { background-color: tint(@sidebar, 20%); }
+}
+
+// Replace outlined star icon with solid one
+.thread-list .thread-icon.thread-icon-star,
+.thread-list .thread-icon-star-on-hover:hover {
+ background-color: tint(@sidebar);
+ -webkit-mask-image: url(../static/images/thread-list/icon-star-@2x.png);
+}
+
+// for Read messages, use the solid star on item hover as well
+.thread-list .list-item:hover .thread-icon-none {
+ background-image: none;
+ background-color: tint(@sidebar);
+ -webkit-mask-image: url(../static/images/thread-list/icon-star-@2x.png);
+}
+
+// Make the star a bit darker on direct hover
+.thread-list .list-item:hover .thread-icon-none:hover {
+ background-image: none;
+ background-color: tint(@sidebar, 20%);
+ -webkit-mask-image: url(../static/images/thread-list/icon-star-@2x.png);
+}
+
+.thread-icon.thread-icon-attachment {
+ opacity: 0.5;
+ background-size: 12px;
+}
+
+// The gradient behind threadlist hover icons (Snooze, Arvhive Delete)
+.thread-list .list-item:hover .list-column-HoverActions .inner,
+.thread-list .list-item.focused:hover .list-column-HoverActions .inner,
+.thread-list .list-item.selected:hover .list-column-HoverActions .inner,
+.thread-list.handler-split .list-item.selected:hover .list-column-HoverActions .inner {
+ background-image: -webkit-linear-gradient(left, fade(@messagelist-bg, 0%) 0%, @messagelist-bg 40%, @messagelist-bg 100%);
+}
+
+.thread-list .list-item.focused:hover .list-column-HoverActions .inner .action,
+.thread-list.handler-split .list-item.selected:hover .list-column-HoverActions .inner .action {
+ -webkit-filter: none;
+}
+
+.thread-list .list-item.focused:hover .list-column-HoverActions .inner .action.action-trash {
+ background: url("../static/images/thread-list-quick-actions/ic-quick-button-trash@2x.png") center no-repeat, linear-gradient(to top, rgba(241, 241, 241, 0.75) 0%, rgba(253, 253, 253, 0.75) 100%);
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-threadlist.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-threadlist.less
new file mode 100644
index 0000000000..2fb63b1422
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-threadlist.less
@@ -0,0 +1,47 @@
+@import "darkside-variables";
+
+// Make corresponding toolbar match threadlist background
+.column-ThreadList,
+.toolbar-ThreadList {
+ height: 100%;
+ background: @threadlist-bg;
+ border-bottom: 1px solid @border-color;
+}
+
+// jackiehluo -> Hide search bar when buttons appear in list mode
+.toolbar-ThreadList .selection-bar .inner {
+ background: @threadlist-bg;
+}
+
+.list-tabular .list-tabular-item {
+ background-color: @threadlist-bg;
+ border-bottom: 1px solid @border-color !important;
+}
+
+// Using !important so multiple selection actions
+.list-tabular .list-tabular-item:hover {
+ background: tint(@sidebar, 95%) !important;
+}
+
+.list-container .list-item.focused,
+.list-container .list-item.selected,
+.thread-list.handler-split .list-item.selected {
+ background: tint(@accent, 90%);
+ color: @active-thread-text;
+}
+
+body.is-blurred .list-container .list-item.focused,
+body.is-blurred .list-container .list-item.selected,
+body.is-blurred .thread-list.handler-split .list-item.selected {
+ background: tint(@sidebar, 90%);
+ color: @active-thread-text;
+}
+
+.list-tabular .list-tabular-item.keyboard-cursor {
+ border-left-color: @accent;
+ background: tint(@accent, 90%);
+}
+
+body.is-blurred .list-tabular .list-tabular-item.keyboard-cursor {
+ border-left-color: tint(@sidebar, 70%);
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-toolbars.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-toolbars.less
new file mode 100644
index 0000000000..57fbc60535
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-toolbars.less
@@ -0,0 +1,68 @@
+@import "darkside-variables";
+
+.sheet-toolbar {
+ border: none;
+}
+
+.sheet-toolbar-container {
+ background: transparent;
+ box-shadow: none;
+ border: none;
+}
+
+.sheet-toolbar .selection-bar .absolute {
+ left: 0;
+ right: 0;
+ border-left: none;
+ border-right: none;
+ background: none;
+}
+
+// Match left and right alignment across all toolbars
+.toolbar-RootSidebar,
+.toolbar-MessageList,
+.toolbar-MessageListSidebar,
+.toolbar-Center,
+.toolbar-Preferences {
+ height: 100%;
+ padding-left: @sidebar-margin;
+ padding-right: @sidebar-margin;
+}
+
+// Slightly darker toolbar for Prefs, Single Panel Messages, and Popout
+.toolbar-Preferences,
+.layout-mode-list .toolbar-MessageList,
+.sheet-toolbar-container.mode-popout {
+ background: transparent;
+ background-color: tint(@sidebar, 90%);
+ border: none;
+}
+
+// jackiehluo -> (themes): Fixes Windows button UI issues in #1649
+body.platform-win32 .sheet-toolbar-container .btn-toolbar:hover {
+ background: none;
+}
+
+// Centering vertially without magic numbers
+.layout-mode-popout .toolbar-window-controls {
+ margin-top: 0;
+}
+
+.sheet-toolbar .item-container .window-title {
+ position: static;
+ // compensate for width of .toolbar-window-controls
+ transform: translateX(-25px);
+}
+
+.sheet-toolbar .btn-toolbar {
+ box-shadow: 0 0 0 1px @border-color;
+}
+
+// Let toolbar define outer padding/margin
+.sheet-toolbar .btn-toolbar:only-of-type {
+ margin-right: 0;
+}
+
+.btn-toolbar.mode-toggle.mode-false img.content-mask {
+ background-color: @accent;
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-variables.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-variables.less
new file mode 100644
index 0000000000..1d3fc7beef
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-variables.less
@@ -0,0 +1,39 @@
+// Default
+@sidebar: #313042;
+@accent: #F18260;
+
+// Luna
+// @sidebar: #202C46;
+// @accent: #39DFF8;
+
+// Zond
+// @sidebar: #333333;
+// @accent: #F6D49C;
+
+// Gemini
+// @sidebar: #00203C;
+// @accent: #F6B312;
+
+// Mercury
+// @sidebar: #555;
+// @accent: #999;
+
+// Apollo
+// @sidebar: #3A1E15;
+// @accent: #F6AA1C;
+
+@threadlist-bg: #FFFFFF;
+@messagelist-bg: tint(@sidebar, 95%);
+@active-thread-text: @sidebar;
+@sidebar-text: desaturate(lighten(@sidebar, 40%), 75%);
+@active-sidebar-text: #FFFFFF;
+@border-color: fade(@sidebar, 10%);
+@danger: #FF5F56;
+@minimize: #FBD852;
+@maximize: #8DD07D;
+@swipe-archive: #8DD07D;
+@swipe-snooze: #FBD852;
+@swipe-trash: @danger;
+@invalid: @danger;
+
+@sidebar-margin: 15px;
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/darkside-window-controls.less b/packages/client-app/internal_packages/ui-darkside/styles/darkside-window-controls.less
new file mode 100644
index 0000000000..5f580b09c8
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/darkside-window-controls.less
@@ -0,0 +1,79 @@
+@import "darkside-variables";
+
+.toolbar-window-controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 0;
+ min-width: 50px;
+ width: 50px;
+}
+
+.toolbar-window-controls button {
+ background-color: @sidebar-text;
+ background-image: none !important;
+ float: none;
+ opacity: 0.5;
+ margin: 0;
+ transform: scaleY(0.5);
+ border-radius: 2px;
+ transition-duration: 150ms;
+ transition-property: border-radius, opacity, transform;
+}
+
+.toolbar-window-controls:hover button {
+ opacity: 1;
+ border-radius: 50%;
+ transform: scaleY(1);
+}
+
+.toolbar-window-controls .close {
+ background-color: @danger;
+}
+
+.toolbar-window-controls .minimize {
+ background-color: @minimize;
+}
+
+.toolbar-window-controls .maximize {
+ background-color: @maximize;
+}
+
+.is-blurred {
+ .toolbar-window-controls .close,
+ .toolbar-window-controls .minimize,
+ .toolbar-window-controls .maximize {
+ background-color: fade(@sidebar-text, 50%);
+ }
+}
+
+// Compose Button Overrides
+.sheet-toolbar .btn.btn-toolbar.item-compose {
+ background: transparent;
+ box-shadow: none;
+ opacity: 0.5;
+ padding: 0;
+ margin: 0;
+ height: 100%;
+ transition: opacity 200ms;
+ &:hover {
+ opacity: 1;
+ }
+}
+
+// Compose button icon color
+.sheet-toolbar .btn.btn-toolbar.item-compose img.content-mask {
+ background-color: @sidebar-text;
+}
+
+// Activity List
+.toolbar-activity {
+ margin-right: 8px;
+}
+
+.activity-list-container {
+ .disclosure-triangle div {
+ margin-left: 4px;
+ margin-top: -2px;
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-darkside/styles/theme-colors.less b/packages/client-app/internal_packages/ui-darkside/styles/theme-colors.less
new file mode 100644
index 0000000000..4b5ccf70d0
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-darkside/styles/theme-colors.less
@@ -0,0 +1,4 @@
+@import "darkside-variables";
+
+@component-active-color: @accent;
+@panel-background-color: @sidebar;
diff --git a/packages/client-app/internal_packages/ui-less-is-more/LICENSE b/packages/client-app/internal_packages/ui-less-is-more/LICENSE
new file mode 100644
index 0000000000..8fd3a02e20
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-less-is-more/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 Alexander Adkins
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/client-app/internal_packages/ui-less-is-more/README.md b/packages/client-app/internal_packages/ui-less-is-more/README.md
new file mode 100644
index 0000000000..27c5e799b7
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-less-is-more/README.md
@@ -0,0 +1,7 @@
+# N1 Less Is More UI theme
+
+Less Is More UI theme for N1.
+
+This theme is installed by default with N1 and can be activated by going to
+the _Themes_ section in the Settings view (`cmd-,`) and selecting it from the
+_UI Themes_ drop-down menu.
diff --git a/packages/client-app/internal_packages/ui-less-is-more/images/default.jpg b/packages/client-app/internal_packages/ui-less-is-more/images/default.jpg
new file mode 100644
index 0000000000..dac1c3958d
Binary files /dev/null and b/packages/client-app/internal_packages/ui-less-is-more/images/default.jpg differ
diff --git a/packages/client-app/internal_packages/ui-less-is-more/index.less b/packages/client-app/internal_packages/ui-less-is-more/index.less
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/client-app/internal_packages/ui-less-is-more/package.json b/packages/client-app/internal_packages/ui-less-is-more/package.json
new file mode 100644
index 0000000000..4d6b4dbc79
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-less-is-more/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "less-is-more",
+ "displayName": "Less Is More",
+ "theme": "ui-less-is-more",
+ "version": "1.0.7",
+ "description": "A minimal approach to email in Nylas Mail",
+ "license": "MIT",
+ "engines": {
+ "nylas": "*"
+ },
+ "styleSheets": ["less-is-more"],
+ "private": true
+}
diff --git a/packages/client-app/internal_packages/ui-less-is-more/styles/less-is-more.less b/packages/client-app/internal_packages/ui-less-is-more/styles/less-is-more.less
new file mode 100644
index 0000000000..dd8b958441
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-less-is-more/styles/less-is-more.less
@@ -0,0 +1,616 @@
+//====================================================
+// Less Is More Index
+//====================================================
+// Theme Variables
+// Window Controls
+// Sheet Toolbars
+// Sidebar & Account Switcher
+// Sidebar Count Badges
+// Scrollbars & Resize Handles
+// Thread List
+// Message List
+// Message List Sidebar
+// Swiping
+// Preferences
+// Form Inputs & Search Bar
+// Menu Dropdowns
+// Notifications
+// Drafts
+// Composer
+
+
+//====================================================
+// Theme Variables
+//====================================================
+
+@less-background: #FFFFFF; //white
+@less-text: #566C75; //gray
+@less-highlight: #FAFAFA; //lightest-gray
+@less-divider: #DDDDDD; //lighter-gray
+@minimize: #FBD852; //yellow
+@maximize: #8DD07D; //green
+@close: #FF5F56; //red
+@swipe-archive: @maximize; //green
+@swipe-snooze: @minimize; //yellow
+@swipe-trash: @close; //red
+@invalid: @close; //red
+@sidebar-text: lighten(@less-text, 20%); //light-gray
+
+
+//====================================================
+// Window Controls
+//====================================================
+
+// Padding and Color for Account Sidebar, Message List, Message Sidebar,
+// Preference Sidebar and Draft List.
+.column-RootSidebar,
+.column-MessageListSidebar,
+.preferences-sidebar,
+.column-DraftList {
+ padding: 5em 0 2em 2em;
+ background: @less-background;
+}
+
+// Message List padding
+.column-MessageList {
+ padding: 3em 2em;
+}
+
+
+// Window Control Button transforms
+.toolbar-window-controls button {
+ background-color: @sidebar-text;
+ background-image: none !important;
+ width: 12px;
+ height: 12px;
+ float: none;
+ opacity: 0.5;
+ transform: scaleY(0.5);
+ border-radius: 0;
+ transition-duration: 150ms;
+ transition-property: border-radius, opacity, transform;
+}
+
+// Window Control Button transforms on hover
+.toolbar-window-controls:hover button {
+ opacity: 1;
+ border-radius: 50%;
+ transform: scaleY(1);
+}
+
+// Window Control close Button color
+.toolbar-window-controls .close {
+ background-color: @close;
+}
+
+// Window Control minimize Button color
+.toolbar-window-controls .minimize {
+ background-color: @minimize;
+}
+
+// Window Control maximize Button color
+.toolbar-window-controls .maximize {
+ background-color: @maximize;
+}
+
+// Remove underline and dropshadow on compose button
+.sheet-toolbar .btn-toolbar {
+ background: transparent;
+ border: none;
+ box-shadow: none;
+}
+
+
+//====================================================
+// Sheet Toolbars
+//====================================================
+
+// Create white background mask on message list sidebar toolbar
+.toolbar-MessageListSidebar,
+.sheet-toolbar-container {
+ background-color: @less-background;
+}
+
+// Create divider line for message list sidebar toolbar
+.toolbar-MessageListSidebar {
+ border-left: 1px solid @less-divider;
+ height: 40px;
+ margin-left: -0.5px;
+}
+
+// Make top toolbar mask our searchbar with white background
+.sheet-toolbar-container [data-column='0'] .item-container {
+ background-color: @less-background;
+}
+
+// Correctly position and remove border on the top toolbar
+.sheet-toolbar {
+ border: none;
+ height: 0;
+ min-height: 0;
+ .selection-bar .absolute {
+ position: absolute;
+ left: 0;
+ right: 0;
+ border-left: none;
+ border-right: none;
+ }
+}
+
+
+//====================================================
+// Sidebar & Account Switcher
+//====================================================
+
+// Change default account sidebar color from default gray to white
+.column-RootSidebar,
+.account-sidebar {
+ background-color: @less-background;
+}
+
+// Account sidebar label controls
+.account-sidebar .item {
+ color: @sidebar-text;
+ font-size: 14px;
+ font-weight: 400;
+ padding-left: 20px;
+ margin-bottom: 6px;
+}
+
+// Account sidebar headings overrides
+.account-sidebar .heading {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ color: @sidebar-text;
+ margin-bottom: 12px;
+}
+
+// Keep account sidebar icons from flashing on click
+.account-sidebar .item img.content-mask,
+.account-sidebar .add-item-button img {
+ background-color: transparent;
+ -webkit-mask-image: none;
+}
+
+// Account sidebar selected label overrides
+.account-sidebar {
+ // Fix padding jump
+ .item .name {
+ padding-left: 0;
+ margin-left: -10px;
+ }
+ // Change label color and font weight
+ .item.selected {
+ background: transparent;
+ color: @less-text;
+ font-weight: 600;
+ }
+}
+
+// Account sidebar label triangle bullet overrides
+.disclosure-triangle {
+ padding-top: 7px;
+ & div {
+ border-left-color: @sidebar-text;
+ border-top-width: 3px;
+ border-bottom-width: 3px;
+ border-left-width: 5px;
+ transform-origin: 2px;
+ }
+}
+
+// Remove default nylas icon images
+.nylas-outline-view .item .icon img {
+ display: none;
+}
+
+// Account sidebar add folder icon color overrides
+.nylas-outline-view .heading .add-item-button img {
+ background: @sidebar-text;
+}
+
+
+//====================================================
+// Sidebar Count Badges
+//====================================================
+
+// Sidebar unread email count color overrides
+.nylas-outline-view .item .item-count-box.alt-count {
+ background: @less-text;
+ color: @less-background;
+}
+
+
+//====================================================
+// Scrollbars & Resize Handles
+//====================================================
+
+.scrollbar-track {
+ background-color: transparent;
+ width: 10px;
+ border-left: none;
+}
+
+.flexbox-handle-horizontal div {
+ border-right: none;
+ box-shadow: none;
+}
+
+// Position scrollbar on message list on divider
+#message-list .scrollbar-track {
+ margin-right: -2em;
+}
+
+
+//====================================================
+// Thread List
+//====================================================
+
+// Thread list overrides
+.column-ThreadList,
+.list-container .list-item,
+.list-container .list-item:hover {
+ cursor: pointer !important;
+ box-sizing: border-box;
+ border: 0 !important;
+ background-color: @less-background;
+ color: @less-text;
+}
+
+// Thread list padding overrides
+.column-ThreadList {
+ padding: 5em 2em 1em;
+}
+
+// Selected thread list items overrides
+body.is-blurred .list-container .list-item.focused,
+body.is-blurred .list-container .list-item.selected,
+body.is-blurred .thread-list.handler-split .list-item.selected {
+ background-color: @less-highlight;
+ color: darken(@less-text, 50%);
+ font-weight: bold;
+}
+
+// Thread list turns gray on hover
+.list-container .list-item.selected,
+.list-container .list-item:hover {
+ background: @less-highlight;
+ color: @less-text;
+}
+
+// Remove gradient on thread list during quick action hover
+.thread-list .list-item.selected:hover .list-column-HoverActions .inner,
+.thread-list .list-item:hover .list-column-HoverActions .inner {
+ background-image: -webkit-linear-gradient(left, fade(@less-highlight, 0%) 0%, @less-highlight 40%, @less-highlight 100%);
+}
+
+// Remove box-shadow on thread list quick action buttons
+.thread-injected-quick-actions .btn {
+ box-shadow: none;
+}
+
+// Remove gradients quick action buttons
+.thread-list .list-item .list-column-HoverActions .action.action-trash {
+ background: url("../static/images/thread-list-quick-actions/ic-quick-button-trash@2x.png")
+ center no-repeat, @less-highlight;
+}
+
+// Remove gradients quick action buttons
+.thread-list .list-item .list-column-HoverActions .action.action-archive {
+ background: url("../static/images/thread-list-quick-actions/ic-quick-button-archive@2x.png")
+ center no-repeat, @less-highlight;
+}
+
+// Remove gradients quick action buttons
+.thread-list .list-item .list-column-HoverActions .action.action-snooze {
+ background: url("../static/images/thread-list-quick-actions/ic-quickaction-snooze@2x.png")
+ center no-repeat, @less-highlight;
+}
+
+// Change default color of star to gray
+.thread-list .thread-icon.thread-icon-star, .draft-list .thread-icon.thread-icon-star {
+ -webkit-filter: grayscale(100%);
+}
+
+
+//====================================================
+// Message List
+//====================================================
+
+// Theme message list
+#message-list {
+ background-color: @less-background;
+}
+
+// Theme collapsed message item
+#message-list .message-item-wrap .message-item-white-wrap {
+ box-shadow: none;
+ border-radius: 0;
+}
+
+// Theme message list composer footer
+#message-list .footer-reply-area-wrap {
+ box-shadow: none;
+ border-radius: 0;
+ border-top: none;
+ background: @less-highlight;
+}
+
+// Draft message background color
+#message-list .message-item-wrap .message-item-white-wrap.composer-outer-wrap,
+#message-list .message-item-wrap .message-item-white-wrap.composer-outer-wrap .composer-action-bar-wrap {
+ background-color: @less-highlight;
+ border-top: none;
+ box-shadow: none;
+ border-radius: 0;
+}
+
+// Draft message background color on focus
+#message-list .message-item-wrap .message-item-white-wrap.composer-outer-wrap.focused {
+ background-color: @less-background;
+ box-shadow: none;
+ border: 1px solid @less-divider;
+ border-radius: 0;
+}
+
+// Draft message background action bar theme on focus
+#message-list .message-item-wrap .message-item-white-wrap.composer-outer-wrap.focused .composer-action-bar-wrap {
+ background-color: @less-background;
+}
+
+
+//====================================================
+// Message List Sidebar
+//====================================================
+
+// Re-center message list in sidebar with padding
+.column-MessageListSidebar {
+ padding: 5em 1em;
+}
+
+.sidebar-participant-picker {
+ padding-bottom: 50px;
+}
+
+// Remove border line surrounding on message list sidebar
+.sidebar-section {
+ border: none;
+ border-radius: 0;
+}
+
+// Message list sidebar headings to match account sidebar headings
+.sidebar-section h2 {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 3px;
+ color: @sidebar-text;
+ border-bottom: none;
+}
+
+// Theme related threads tabs
+.related-threads {
+ background: transparent;
+ border-top: none;
+ border-radius: 0;
+ overflow: visible;
+}
+
+// Theme related threads tabs items
+.related-threads .related-thread {
+ border-top: none;
+ background-color: @less-highlight;
+ color: @less-text;
+ margin-bottom: 8px;
+ padding: 15px 10px;
+}
+
+// Theme related threads "Show More" label overrides
+.related-threads .toggle {
+ border-top: none;
+ color: @less-text;
+}
+
+
+//====================================================
+// Swiping
+//====================================================
+
+// Adjust color of archive swipe to green
+.thread-list .swipe-backing.swipe-all,
+.thread-list .swipe-backing.swipe-archive,
+.draft-list .swipe-backing.swipe-all,
+.draft-list .swipe-backing.swipe-archive {
+ background: @swipe-archive;
+ &.confirmed {
+ background: saturate(@swipe-archive, 10%);
+ }
+}
+
+// Adjust color of snooze swipe to yellow
+.thread-list .swipe-backing.swipe-snooze,
+.draft-list .swipe-backing.swipe-snooze {
+ background: @swipe-snooze;
+ &.confirmed {
+ background: saturate(@swipe-snooze, 10%);
+ }
+}
+
+// Adjust color of trash swipe to red
+.thread-list .swipe-backing.swipe-trash,
+.draft-list .swipe-backing.swipe-trash {
+ background: @swipe-trash;
+ &.confirmed {
+ background: saturate(@swipe-trash, 10%);
+ }
+}
+
+
+//====================================================
+// Preferences
+//====================================================
+
+// Extra padding and color adjust needed for preferences top panel
+.preferences-wrap .container-preference-tabs .preferences-tabs {
+ padding-top: 40px;
+ background-color: @less-background;
+}
+
+// Padding for bottom of preferences panel
+.preferences-wrap .preferences-content > .scroll-region-content {
+ padding-bottom: 100px;
+}
+
+
+//====================================================
+// Form Inputs & Search Bar
+//====================================================
+
+// Input style overrides
+input[type="text"],
+input[type="email"],
+input[type="date"],
+input[type="datetime"],
+input[type="datetime-local"],
+input[type="month"],
+input[type="number"],
+input[type="password"],
+input[type="range"],
+input[type="search"],
+input[type="tel"],
+input[type="time"],
+input[type="url"] {
+ border-radius: 0;
+ border: none !important;
+}
+
+// Input style overrides on hover
+textarea:focus,
+input[type="text"]:focus,
+input[type="email"]:focus,
+.search-bar .menu .header-container input:focus {
+ border: none !important;
+ border-radius: 0;
+ border-bottom: 2px solid @less-text !important;
+ box-shadow: none;
+}
+
+// Search bar overrides
+.search-bar {
+ background-color: transparent;
+ width: 400px;
+ margin-right: 7.5px;
+}
+
+// Remove box-shadow on search bar
+body.is-blurred .search-bar .menu .header-container input,
+body.is-blurred .sheet-toolbar-container .btn.btn-toolbar,
+.search-bar .menu .header-container input {
+ box-shadow: none;
+}
+
+
+//====================================================
+// Notifications
+//====================================================
+
+.notifications-sticky .notifications-sticky-item {
+ background-color: @close;
+ line-height: 50px;
+ border: none;
+}
+
+.sidebar-activity {
+ background: @less-background;
+ color: @less-text;
+ box-shadow: none;
+}
+
+.sidebar-activity .item {
+ border: none;
+}
+
+
+//====================================================
+// Composer
+//====================================================
+
+// make top of composer window uniform in color
+.sheet-toolbar-container,
+body.is-blurred .sheet-toolbar-container {
+ background-color: @less-background;
+ background-image: none;
+ box-shadow: none;
+}
+
+// make bottom of composer window uniform in color
+.composer-full-window .composer-inner-wrap .composer-action-bar-wrap {
+ background: @less-background;
+ border-top: none;
+ box-shadow: none;
+ padding-bottom: .8em;
+}
+
+// Border at bottom of composer subject field
+.composer-inner-wrap .compose-subject-wrap {
+ border-bottom: 1px solid @sidebar-text;
+}
+
+.tokenizing-field .token.invalid {
+ border: 1px solid lighten(@close,25%);
+}
+
+.tokenizing-field .token.selected,
+.tokenizing-field .token.dragging {
+ background: @less-text;
+ box-shadow: none;
+ border: none;
+}
+
+.tokenizing-field .token.invalid.selected,
+.tokenizing-field .token.invalid.dragging {
+ background: lighten(@close,25%);
+}
+
+.tokenizing-field .tokenizing-field-input input[type="text"],
+.tokenizing-field .tokenizing-field-input input[type="text"]:focus {
+ border-bottom: none !important;
+}
+
+textarea:focus,
+input[type="text"]:focus,
+input[type="email"]:focus {
+ border: 1px solid @less-text;
+ box-shadow: none;
+ padding-left: 0;
+ padding-right: 0;
+ min-width: 30px;
+}
+
+.button-dropdown .primary-item, .button-dropdown .only-item {
+ box-shadow: none;
+}
+
+.button-dropdown.btn-emphasis .primary-item,
+.button-dropdown.btn-emphasis .secondary-picker,
+.button-dropdown.btn-emphasis .only-item
+.button-dropdown.btn-emphasis .primary-item:active,
+.button-dropdown.btn-emphasis .secondary-picker:active,
+.button-dropdown.btn-emphasis .only-item:active,
+.button-dropdown.bordered .primary-item,
+.button-dropdown:hover .primary-item,
+.button-dropdown.bordered .only-item,
+.button-dropdown:hover .only-item,
+.button-dropdown .secondary-picker,
+.btn.btn-emphasis {
+ background-color: lighten(@less-text, 30%);
+ background: lighten(@less-text, 30%);
+ box-shadow: none;
+ border: 1px solid lighten(@less-text, 25%);
+}
+
+.button-dropdown .primary-item img.content-mask,
+.button-dropdown .only-item img.content-mask,
+.button-dropdown .secondary-picker img.content-mask {
+ background-color: @less-background;
+}
diff --git a/packages/client-app/internal_packages/ui-less-is-more/styles/theme-colors.less b/packages/client-app/internal_packages/ui-less-is-more/styles/theme-colors.less
new file mode 100644
index 0000000000..ed351e097f
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-less-is-more/styles/theme-colors.less
@@ -0,0 +1,7 @@
+@import "less-is-more";
+
+@background-secondary: @less-background;
+@text-color: @less-text;
+@component-active-color: @less-background;
+@toolbar-background-color: @less-background;
+@panel-background-color: @less-background;
diff --git a/packages/client-app/internal_packages/ui-light/package.json b/packages/client-app/internal_packages/ui-light/package.json
new file mode 100644
index 0000000000..703193acb2
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-light/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "ui-light",
+ "displayName": "Light",
+ "theme": "ui",
+ "version": "0.1.0",
+ "description": "The N1 Client Theme",
+ "license": "GPL-3.0",
+ "engines": {
+ "nylas": "*"
+ },
+ "private": true
+}
diff --git a/packages/client-app/internal_packages/ui-light/styles/ui-variables.less b/packages/client-app/internal_packages/ui-light/styles/ui-variables.less
new file mode 100644
index 0000000000..7281b9e923
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-light/styles/ui-variables.less
@@ -0,0 +1 @@
+@background-primary: #ffffff;
diff --git a/packages/client-app/internal_packages/ui-taiga/LICENSE b/packages/client-app/internal_packages/ui-taiga/LICENSE
new file mode 100644
index 0000000000..19bfa964de
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Noah Buscher
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/packages/client-app/internal_packages/ui-taiga/README.md b/packages/client-app/internal_packages/ui-taiga/README.md
new file mode 100644
index 0000000000..80df74b07b
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/README.md
@@ -0,0 +1,13 @@
+# Taiga
+
+Taiga is a clean, simple, Mailbox-inspired theme for N1 that allows you to focus on what matters most: your emails.
+
+![](./preview.jpg)
+
+## Installing
+
+1. [Download](https://nylas.com/n1) Nylas Mail email client if you have not yet
+2. [Grab](https://github.com/noahbuscher/N1-Taiga/releases) the latest release of Taiga
+3. Open `N1>Preferences>General>Select theme` and select `Install new theme...` from the dropdown
+
+Profit! :money_with_wings:
diff --git a/packages/client-app/internal_packages/ui-taiga/package.json b/packages/client-app/internal_packages/ui-taiga/package.json
new file mode 100644
index 0000000000..4f6865cb7c
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "ui-taiga",
+ "displayName": "Taiga",
+ "theme": "ui",
+ "version": "0.2.8",
+ "description": "A clean, Mailbox-inspired theme for Nylas Mail.",
+ "license": "GPL-3.0",
+ "engines": {
+ "nylas": "*"
+ },
+ "styleSheets": ["controls", "email-frame", "sidebar", "threads", "notifications"],
+ "private": true
+}
diff --git a/packages/client-app/internal_packages/ui-taiga/preview.jpg b/packages/client-app/internal_packages/ui-taiga/preview.jpg
new file mode 100644
index 0000000000..4d4e0a89f3
Binary files /dev/null and b/packages/client-app/internal_packages/ui-taiga/preview.jpg differ
diff --git a/packages/client-app/internal_packages/ui-taiga/styles/controls.less b/packages/client-app/internal_packages/ui-taiga/styles/controls.less
new file mode 100644
index 0000000000..123fc4eb0b
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/styles/controls.less
@@ -0,0 +1,134 @@
+@import "variables";
+
+.header-container {
+ margin-right: 10px;
+}
+
+/**
+ * Buttons
+ */
+.btn-toolbar, .token, .actions>.btn, .new-package>.btn, .appearance-mode-switch>.btn {
+ box-shadow: none !important;
+ background: @white !important;
+ border: 0;
+ border-radius: @base-border-radius !important;
+ &.item-compose {
+ border: 1px solid @taiga-light;
+ }
+}
+
+.composer-action-bar-content {
+ .btn-toolbar {
+ border: 0 !important;
+ background: transparent !important;
+ }
+}
+
+body.platform-win32 {
+ .sheet-toolbar-container {
+ .btn-toolbar {
+ border: 0 !important;
+ }
+ }
+}
+
+.btn.btn-emphasis {
+ background-color: @taiga-accent !important;
+ border-color: @taiga-accent !important;
+ color: @white !important;
+
+ img.content-mask {
+ background-color: @white !important;
+ }
+}
+
+.button-dropdown.bordered {
+ .primary-item {
+ box-shadow: none !important;
+ background: @white !important;
+ border: 1px solid @taiga-light !important;
+ border-top-left-radius: @base-border-radius !important;
+ border-bottom-left-radius: @base-border-radius !important;
+ img {
+ position: relative;
+ top: -2px;
+ }
+ }
+
+ .secondary-picker {
+ box-shadow: none !important;
+ background: @white !important;
+ border: 1px solid @taiga-light !important;
+ border-top-right-radius: @base-border-radius !important;
+ border-bottom-right-radius: @base-border-radius !important;
+ margin-left: -1px !important;
+ }
+
+ .secondary-items {
+ .item {
+ color: @taiga-light !important;
+ .search-match {
+ background: @white !important;
+ }
+ .button-dropdown {
+ img {
+ background: @taiga-light !important;
+ }
+ }
+ }
+ }
+}
+
+.sheet-toolbar .btn-toolbar {
+ height: 2em !important;
+ line-height: 1 !important;
+ margin-top: 6px !important;
+}
+
+/**
+ * Feedback button
+ */
+.btn-feedback {
+ background: @taiga-accent !important;
+ border: none !important;
+}
+
+
+/**
+ * Dropdown
+ */
+.menu {
+ .item.selected {
+ .primary {
+ color: @white !important;
+ }
+ .secondary {
+ color: @taiga-lighter !important;
+ }
+ }
+ &.search-container {
+ .item {
+ background: @white !important;
+ }
+ .item.selected {
+ background: @taiga-light !important;
+ color: @white !important;
+ }
+ }
+}
+
+/**
+ * Plugin page
+ */
+.package {
+ border-radius: @base-border-radius;
+}
+
+/**
+ * Prefs page
+ */
+.appearance-mode {
+ &.active {
+ background-color: lighten(@taiga-light, 30%) !important;
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-taiga/styles/email-frame.less b/packages/client-app/internal_packages/ui-taiga/styles/email-frame.less
new file mode 100644
index 0000000000..ecf8d688fd
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/styles/email-frame.less
@@ -0,0 +1,10 @@
+@import "variables";
+
+.ignore-in-parent-frame {
+ body {
+ color: @taiga-dark;
+ }
+ img {
+ color: @taiga-dark;
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-taiga/styles/notifications.less b/packages/client-app/internal_packages/ui-taiga/styles/notifications.less
new file mode 100644
index 0000000000..243cacdf34
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/styles/notifications.less
@@ -0,0 +1,11 @@
+@import "variables";
+
+.notification {
+ color: @white !important;
+ background: @taiga-accent !important;
+
+ .action {
+ color: @white !important;
+ background: darken(@taiga-accent, 20%);
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-taiga/styles/sidebar.less b/packages/client-app/internal_packages/ui-taiga/styles/sidebar.less
new file mode 100644
index 0000000000..18c06280b6
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/styles/sidebar.less
@@ -0,0 +1,71 @@
+@import "../../../static/variables/ui-variables";
+@import "variables";
+
+#account-switcher .primary-item .name {
+ color: @taiga-dark;
+}
+
+.account-sidebar-sections {
+ background-color: @white !important;
+
+ section {
+ &:first-child .heading {
+ padding-right: 40px;
+ }
+
+ .heading {
+ padding-bottom: 5px;
+ .text {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+ .item-container {
+ margin: 0 10px 0 0 !important;
+
+ .disclosure-triangle {
+ display: flex;
+ align-items: center;
+ width: 15px;
+ div {
+ border-left-color: @border-color-primary;
+ border-top-width: 3px;
+ border-bottom-width: 3px;
+ border-left-width: 5px;
+ transform-origin: 2px;
+ }
+ }
+
+ .item {
+ padding: 0 10px !important;
+ color: @taiga-light !important;
+ cursor: pointer !important;
+
+ .item-count-box {
+ background: transparent !important;
+ color: @taiga-light !important;
+ box-shadow: 0 0.5px 0 @taiga-light, 0 -0.5px 0 @taiga-light, 0.5px 0 0 @taiga-light, -0.5px 0 0 @taiga-light !important;
+ }
+
+ &.selected {
+ background: @taiga-accent !important;
+ border-radius: @base-border-radius;
+ color: @white !important;
+
+ .item-count-box {
+ background: transparent !important;
+ color: @white !important;
+ box-shadow: 0 0.5px 0 @white, 0 -0.5px 0 @white, 0.5px 0 0 @white, -0.5px 0 0 @white !important;
+ }
+
+ .icon {
+ img {
+ background: @white !important;
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-taiga/styles/theme-colors.less b/packages/client-app/internal_packages/ui-taiga/styles/theme-colors.less
new file mode 100644
index 0000000000..0d669f694f
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/styles/theme-colors.less
@@ -0,0 +1,2 @@
+@component-active-color: #5dade1;
+@toolbar-background-color: #ddedf4;
\ No newline at end of file
diff --git a/packages/client-app/internal_packages/ui-taiga/styles/threads.less b/packages/client-app/internal_packages/ui-taiga/styles/threads.less
new file mode 100644
index 0000000000..b32b991f0e
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/styles/threads.less
@@ -0,0 +1,67 @@
+@import "variables";
+
+.list-tabular .list-column.list-column-Item {
+ margin-left: -20px;
+ padding-left: 30px;
+}
+
+.thread-list {
+ .list-container {
+ .list-item {
+ &.focused:hover .list-column-HoverActions .inner {
+ color: @taiga-dark !important;
+ background-image: linear-gradient(90deg, fadeout(@taiga-lighter, 100%) 0%, darken(@taiga-lighter, 10%) 100%);
+ .action {
+ -webkit-filter: none;
+ }
+ .thread-icon {
+ &:not(.thread-icon-star) {
+ opacity: 0.7;
+ }
+ }
+ }
+ &.focused {
+ border-bottom: 0;
+ .thread-icon, .mail-important-icon, .draft-icon {
+ -webkit-filter: none;
+ }
+ }
+ &:hover {
+ .thread-icon {
+ visibility: inherit;
+ }
+ }
+ .list-column {
+ border-bottom: 0 !important;
+ }
+ }
+ .scroll-region-content .scroll-region-content-inner .list-rows {
+ .list-item {
+ cursor: pointer !important;
+ box-sizing: border-box;
+ background-color: @white !important;
+ color: @taiga-dark !important;
+ &.focused {
+ color: @taiga-dark !important;
+ background-color: darken(@taiga-lighter, 5%) !important;
+ }
+ &.selected {
+ color: @taiga-dark !important;
+ background-color: darken(@taiga-lighter, 5%) !important;
+ }
+ }
+ }
+ }
+ .thread-icon {
+ background-image: url(../static/images/thread-list/icon-star-hover-@2x.png);
+ &:not(.thread-icon-star) {
+ visibility: hidden;
+ }
+ }
+}
+
+.is-blurred {
+ .thread-list .list-container .list-item.focused {
+ border-bottom: 0;
+ }
+}
diff --git a/packages/client-app/internal_packages/ui-taiga/styles/ui-variables.less b/packages/client-app/internal_packages/ui-taiga/styles/ui-variables.less
new file mode 100644
index 0000000000..3000547743
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/styles/ui-variables.less
@@ -0,0 +1,19 @@
+@import "variables";
+
+@accent-primary: @taiga-light;
+@accent-primary-dark: darken(@taiga-light, 20%);
+
+@background-secondary: @white;
+@text-color: @taiga-dark;
+@text-color-subtle: lighten(@taiga-dark, 20%);
+@text-color-very-subtle: @taiga-light;
+@text-color-inverse: @taiga-light;
+@text-color-inverse-subtle: darken(@taiga-light, 30%);
+@text-color-inverse-very-subtle: darken(@taiga-light, 20%);
+
+@panel-background-color: @white;
+@toolbar-background-color: @white;
+
+@btn-default-bg-color: @white;
+@btn-default-text-color: @taiga-light;
+@background-gradient: none;
diff --git a/packages/client-app/internal_packages/ui-taiga/styles/variables.less b/packages/client-app/internal_packages/ui-taiga/styles/variables.less
new file mode 100644
index 0000000000..0fce409c3d
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-taiga/styles/variables.less
@@ -0,0 +1,13 @@
+/**
+ * Colors
+ */
+@taiga-light: darken(#A3ACB1, 10%);
+@taiga-lighter: #F0F7FA;
+@taiga-dark: darken(#727C83, 4%);
+@taiga-accent: #5DADE1;
+@white: #ffffff;
+
+/**
+ * Borders
+ */
+@base-border-radius: 4px;
diff --git a/packages/client-app/internal_packages/ui-ubuntu/README.md b/packages/client-app/internal_packages/ui-ubuntu/README.md
new file mode 100644
index 0000000000..7a46ce0d7c
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-ubuntu/README.md
@@ -0,0 +1,8 @@
+# Ubuntu Theme for Nylas Mail #
+
+![img](https://raw.githubusercontent.com/ahmedlhanafy/Ubuntu-Ui-Theme-for-Nylas-N1/master/Screenshot.png)
+
+## Installation: ##
+
+* Download the zip folder and extract it.
+* Update N1 to the latest version go to Preferences -> General -> Select theme -> Install a theme and then select the extracted folder.
diff --git a/packages/client-app/internal_packages/ui-ubuntu/index.less b/packages/client-app/internal_packages/ui-ubuntu/index.less
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/client-app/internal_packages/ui-ubuntu/package.json b/packages/client-app/internal_packages/ui-ubuntu/package.json
new file mode 100644
index 0000000000..3aca5de958
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-ubuntu/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "ui-ubuntu",
+ "displayName": "Ubuntu",
+ "theme": "ui",
+ "version": "0.1.0",
+ "description": "The Ubuntu theme for N1.",
+ "license": "Proprietary",
+ "engines": {
+ "nylas": "*"
+ },
+ "private": true
+}
diff --git a/packages/client-app/internal_packages/ui-ubuntu/styles/theme-colors.less b/packages/client-app/internal_packages/ui-ubuntu/styles/theme-colors.less
new file mode 100644
index 0000000000..d22874d12f
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-ubuntu/styles/theme-colors.less
@@ -0,0 +1,2 @@
+@component-active-color: #f07746;
+@toolbar-background-color: #41403b;
diff --git a/packages/client-app/internal_packages/ui-ubuntu/styles/ui-variables.less b/packages/client-app/internal_packages/ui-ubuntu/styles/ui-variables.less
new file mode 100644
index 0000000000..c9793c10f8
--- /dev/null
+++ b/packages/client-app/internal_packages/ui-ubuntu/styles/ui-variables.less
@@ -0,0 +1,57 @@
+@import "../../../static/variables/ui-variables";
+
+@accent-primary: #f07746;
+@accent-primary-dark: darken(#f07746, 1%);
+
+@border-color-secondary: lighten(@background-secondary, 10%);
+
+@toolbar-background-color: #41403b;
+@light: rgb(246, 246, 246);
+
+.sheet-toolbar .btn-toolbar img.content-mask {
+ background-color: @light;
+}
+
+.sheet-toolbar-container {
+ background-image: -webkit-linear-gradient(top,@toolbar-background-color, darken(@toolbar-background-color,5%));
+ box-shadow: none;
+ .btn {
+ background: lighten(@toolbar-background-color,4%);
+ }
+ .btn.btn-toolbar {
+ color: @light;
+ }
+ .toolbar-activity .activity-toolbar-icon {
+ background: @light;
+ }
+}
+
+.sheet-toolbar .item-back img.content-mask{
+ background-color: @light;
+}
+
+.sheet-toolbar .item-back .item-back-title{
+ color: @light;
+}
+
+.sheet-toolbar .selection-bar {
+ .absolute {
+ border-color: lighten(@toolbar-background-color, 5%);
+ background-color: darken(@toolbar-background-color, 8%);
+
+ .inner {
+ .centered {
+ color: @light;
+ }
+ }
+ }
+}
+
+.btn-icon img.content-mask {
+ background-color:@light;
+ color: @light;
+}
+
+.btn-icon img.content-mask:hover {
+ background-color:@accent-primary;
+}
diff --git a/packages/client-app/internal_packages/undo-redo/lib/main.es6 b/packages/client-app/internal_packages/undo-redo/lib/main.es6
new file mode 100644
index 0000000000..b6e9f58cb6
--- /dev/null
+++ b/packages/client-app/internal_packages/undo-redo/lib/main.es6
@@ -0,0 +1,25 @@
+import {ComponentRegistry, WorkspaceStore} from 'nylas-exports'
+import UndoRedoThreadListToast from './undo-redo-thread-list-toast'
+import UndoSendStore from './undo-send-store';
+import UndoSendToast from './undo-send-toast';
+
+
+export function activate() {
+ UndoSendStore.activate()
+ ComponentRegistry.register(UndoSendToast, {
+ location: WorkspaceStore.Sheet.Global.Footer,
+ });
+ if (NylasEnv.isMainWindow()) {
+ ComponentRegistry.register(UndoRedoThreadListToast, {
+ location: WorkspaceStore.Location.ThreadList,
+ })
+ }
+}
+
+export function deactivate() {
+ UndoSendStore.deactivate()
+ ComponentRegistry.unregister(UndoSendToast);
+ if (NylasEnv.isMainWindow()) {
+ ComponentRegistry.unregister(UndoRedoThreadListToast)
+ }
+}
diff --git a/packages/client-app/internal_packages/undo-redo/lib/undo-redo-thread-list-toast.jsx b/packages/client-app/internal_packages/undo-redo/lib/undo-redo-thread-list-toast.jsx
new file mode 100644
index 0000000000..af828aaae6
--- /dev/null
+++ b/packages/client-app/internal_packages/undo-redo/lib/undo-redo-thread-list-toast.jsx
@@ -0,0 +1,38 @@
+import React, {PropTypes} from 'react'
+import {UndoRedoStore} from 'nylas-exports'
+import {UndoToast, ListensToFluxStore} from 'nylas-component-kit'
+
+
+function onUndo() {
+ NylasEnv.commands.dispatch('core:undo')
+}
+
+function UndoRedoThreadListToast(props) {
+ const {tasks} = props
+ return (
+ t.description()).join(', ')}
+ />
+ )
+}
+
+UndoRedoThreadListToast.displayName = 'UndoRedoThreadListToast'
+UndoRedoThreadListToast.containerRequired = false
+UndoRedoThreadListToast.propTypes = {
+ tasks: PropTypes.array,
+}
+
+export default ListensToFluxStore(UndoRedoThreadListToast, {
+ stores: [UndoRedoStore],
+ getStateFromStores() {
+ const tasks = UndoRedoStore.getMostRecent()
+ return {
+ tasks,
+ visible: tasks && tasks.length > 0,
+ }
+ },
+})
diff --git a/packages/client-app/internal_packages/undo-redo/lib/undo-send-store.es6 b/packages/client-app/internal_packages/undo-redo/lib/undo-send-store.es6
new file mode 100644
index 0000000000..27c480ab7e
--- /dev/null
+++ b/packages/client-app/internal_packages/undo-redo/lib/undo-send-store.es6
@@ -0,0 +1,49 @@
+import NylasStore from 'nylas-store'
+import {Actions} from 'nylas-exports'
+
+
+class UndoSendStore extends NylasStore {
+
+ activate() {
+ this._showUndoSend = false
+ this._sendActionTaskId = null
+ this._unlisteners = [
+ Actions.willPerformSendAction.listen(this._onWillPerformSendAction),
+ Actions.didPerformSendAction.listen(this._onDidPerformSendAction),
+ Actions.didCancelSendAction.listen(this._onDidCancelSendAction),
+ ]
+ }
+
+ shouldShowUndoSend() {
+ return this._showUndoSend
+ }
+
+ sendActionTaskId() {
+ return this._sendActionTaskId
+ }
+
+ _onWillPerformSendAction = ({taskId}) => {
+ this._showUndoSend = true
+ this._sendActionTaskId = taskId
+ this.trigger()
+ }
+
+ _onDidPerformSendAction = () => {
+ this._showUndoSend = false
+ this._sendActionTaskId = null
+ this.trigger()
+ }
+
+ _onDidCancelSendAction = () => {
+ this._showUndoSend = false
+ this._sendActionTaskId = null
+ this.trigger()
+ }
+
+ deactivate() {
+ this._unlisteners.forEach((unsub) => unsub())
+ }
+}
+
+export default new UndoSendStore()
+
diff --git a/packages/client-app/internal_packages/undo-redo/lib/undo-send-toast.jsx b/packages/client-app/internal_packages/undo-redo/lib/undo-send-toast.jsx
new file mode 100644
index 0000000000..d62ff83c59
--- /dev/null
+++ b/packages/client-app/internal_packages/undo-redo/lib/undo-send-toast.jsx
@@ -0,0 +1,45 @@
+import React, {PropTypes} from 'react'
+import {Actions} from 'nylas-exports'
+import {KeyCommandsRegion, UndoToast, ListensToFluxStore} from 'nylas-component-kit'
+import UndoSendStore from './undo-send-store'
+
+
+function UndoSendToast(props) {
+ const {visible, sendActionTaskId} = props
+ return (
+ {
+ if (!visible) { return }
+ event.preventDefault();
+ event.stopPropagation();
+ Actions.dequeueTask(sendActionTaskId)
+ },
+ }}
+ >
+ Actions.dequeueTask(sendActionTaskId)}
+ />
+
+ )
+}
+UndoSendToast.displayName = 'UndoSendToast'
+UndoSendToast.propTypes = {
+ visible: PropTypes.bool,
+ sendActionTaskId: PropTypes.string,
+}
+
+export default ListensToFluxStore(UndoSendToast, {
+ stores: [UndoSendStore],
+ getStateFromStores() {
+ return {
+ visible: UndoSendStore.shouldShowUndoSend(),
+ sendActionTaskId: UndoSendStore.sendActionTaskId(),
+ }
+ },
+})
+
diff --git a/packages/client-app/internal_packages/undo-redo/package.json b/packages/client-app/internal_packages/undo-redo/package.json
new file mode 100644
index 0000000000..8b9c74446a
--- /dev/null
+++ b/packages/client-app/internal_packages/undo-redo/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "undo-redo",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Undo modal button",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "default": true,
+ "thread-popout": true
+ }
+}
diff --git a/packages/client-app/internal_packages/unread-notifications/lib/main.es6 b/packages/client-app/internal_packages/unread-notifications/lib/main.es6
new file mode 100644
index 0000000000..e99e284480
--- /dev/null
+++ b/packages/client-app/internal_packages/unread-notifications/lib/main.es6
@@ -0,0 +1,208 @@
+import _ from 'underscore'
+import {
+ Thread,
+ Actions,
+ SoundRegistry,
+ NativeNotifications,
+ DatabaseStore,
+} from 'nylas-exports';
+
+export class Notifier {
+ constructor() {
+ this.unlisteners = [];
+ this.unlisteners.push(Actions.onNewMailDeltas.listen(this._onNewMailReceived, this));
+ this.activationTime = Date.now();
+ this.unnotifiedQueue = [];
+ this.hasScheduledNotify = false;
+
+ this.activeNotifications = {};
+ this.unlisteners.push(DatabaseStore.listen(this._onDatabaseUpdated, this));
+ }
+
+ unlisten() {
+ for (const unlisten of this.unlisteners) {
+ unlisten();
+ }
+ }
+
+ _onDatabaseUpdated({objectClass, objects}) {
+ if (objectClass === 'Thread') {
+ objects
+ .filter((thread) => !thread.unread)
+ .forEach((thread) => this._onThreadIsRead(thread));
+ }
+ }
+
+ _onThreadIsRead({id: threadId}) {
+ if (threadId in this.activeNotifications) {
+ this.activeNotifications[threadId].forEach((n) => n.close());
+ delete this.activeNotifications[threadId];
+ }
+ }
+
+ _notifyAll() {
+ NativeNotifications.displayNotification({
+ title: `${this.unnotifiedQueue.length} Unread Messages`,
+ tag: 'unread-update',
+ });
+ this.unnotifiedQueue = [];
+ }
+
+ _notifyOne({message, thread}) {
+ const from = (message.from[0]) ? message.from[0].displayName() : "Unknown";
+ const title = from;
+ let subtitle = null;
+ let body = null;
+ if (message.subject && message.subject.length > 0) {
+ subtitle = message.subject;
+ body = message.snippet;
+ } else {
+ subtitle = message.snippet
+ body = null
+ }
+
+ const notification = NativeNotifications.displayNotification({
+ title: title,
+ subtitle: subtitle,
+ body: body,
+ canReply: true,
+ tag: 'unread-update',
+ onActivate: ({response, activationType}) => {
+ if ((activationType === 'replied') && response && _.isString(response)) {
+ Actions.sendQuickReply({thread, message}, response);
+ } else {
+ NylasEnv.displayWindow()
+ }
+
+ if (!thread) {
+ NylasEnv.showErrorDialog(`Can't find that thread`)
+ return
+ }
+ Actions.ensureCategoryIsFocused('inbox', thread.accountId);
+ Actions.setFocus({collection: 'thread', item: thread});
+ },
+ });
+
+ if (!this.activeNotifications[thread.id]) {
+ this.activeNotifications[thread.id] = [notification];
+ } else {
+ this.activeNotifications[thread.id].push(notification);
+ }
+ }
+
+ _notifyMessages() {
+ if (this.unnotifiedQueue.length >= 5) {
+ this._notifyAll()
+ } else if (this.unnotifiedQueue.length > 0) {
+ this._notifyOne(this.unnotifiedQueue.shift());
+ }
+
+ this.hasScheduledNotify = false;
+ if (this.unnotifiedQueue.length > 0) {
+ setTimeout(() => this._notifyMessages(), 2000);
+ this.hasScheduledNotify = true;
+ }
+ }
+
+ // https://phab.nylas.com/D2188
+ _onNewMessagesMissingThreads(messages) {
+ setTimeout(() => {
+ const threads = {}
+ for (const {threadId} of messages) {
+ threads[threadId] = threads[threadId] || DatabaseStore.find(Thread, threadId);
+ }
+ Promise.props(threads).then((resolvedThreads) => {
+ const resolved = messages.filter((msg) => resolvedThreads[msg.threadId]);
+ if (resolved.length > 0) {
+ this._onNewMailReceived({message: resolved, thread: _.values(resolvedThreads)});
+ }
+ });
+ }, 10000);
+ }
+
+ _onNewMailReceived(incoming) {
+ return new Promise((resolve) => {
+ if (NylasEnv.config.get('core.notifications.enabled') === false) {
+ resolve();
+ return;
+ }
+
+ const incomingMessages = incoming.message || [];
+ const incomingThreads = incoming.thread || [];
+
+ // Filter for new messages that are not sent by the current user
+ const newUnread = incomingMessages.filter((msg) => {
+ const isUnread = msg.unread === true;
+ const isNew = msg.date && msg.date.valueOf() >= this.activationTime;
+ const isFromMe = msg.isFromMe();
+ return isUnread && isNew && !isFromMe;
+ });
+
+ if (newUnread.length === 0) {
+ resolve();
+ return;
+ }
+
+ // For each message, find it's corresponding thread. First, look to see
+ // if it's already in the `incoming` payload (sent via delta sync
+ // at the same time as the message.) If it's not, try loading it from
+ // the local cache.
+
+ // Note we may receive multiple unread msgs for the same thread.
+ // Using a map and ?= to avoid repeating work.
+ const threads = {}
+ for (const {threadId} of newUnread) {
+ threads[threadId] = threads[threadId] || _.findWhere(incomingThreads, {id: threadId})
+ threads[threadId] = threads[threadId] || DatabaseStore.find(Thread, threadId);
+ }
+
+ Promise.props(threads).then((resolvedThreads) => {
+ // Filter new unread messages to just the ones in the inbox
+ const newUnreadInInbox = newUnread.filter((msg) =>
+ resolvedThreads[msg.threadId] && resolvedThreads[msg.threadId].categoryNamed('inbox')
+ )
+
+ // Filter messages that we can't decide whether to display or not
+ // since no associated Thread object has arrived yet.
+ const newUnreadMissingThreads = newUnread.filter((msg) => !resolvedThreads[msg.threadId])
+
+ if (newUnreadMissingThreads.length > 0) {
+ this._onNewMessagesMissingThreads(newUnreadMissingThreads);
+ }
+
+ if (newUnreadInInbox.length === 0) {
+ resolve();
+ return;
+ }
+
+ for (const msg of newUnreadInInbox) {
+ this.unnotifiedQueue.push({message: msg, thread: resolvedThreads[msg.threadId]});
+ }
+ if (!this.hasScheduledNotify) {
+ if (NylasEnv.config.get("core.notifications.sounds")) {
+ this._playNewMailSound = this._playNewMailSound || _.debounce(() => SoundRegistry.playSound('new-mail'), 5000, true);
+ this._playNewMailSound();
+ }
+ this._notifyMessages();
+ }
+
+ resolve();
+ });
+ });
+ }
+}
+
+export const config = {
+ enabled: {
+ 'type': 'boolean',
+ 'default': true,
+ },
+};
+
+export function activate() {
+ this.notifier = new Notifier();
+}
+
+export function deactivate() {
+ this.notifier.unlisten();
+}
diff --git a/packages/client-app/internal_packages/unread-notifications/package.json b/packages/client-app/internal_packages/unread-notifications/package.json
new file mode 100755
index 0000000000..1e59cd329e
--- /dev/null
+++ b/packages/client-app/internal_packages/unread-notifications/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "unread-notifications",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Fires notifications when new mail is received",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ }
+}
diff --git a/packages/client-app/internal_packages/unread-notifications/spec/main-spec.es6 b/packages/client-app/internal_packages/unread-notifications/spec/main-spec.es6
new file mode 100644
index 0000000000..60fc7a44f9
--- /dev/null
+++ b/packages/client-app/internal_packages/unread-notifications/spec/main-spec.es6
@@ -0,0 +1,417 @@
+import Contact from '../../../src/flux/models/contact'
+import Message from '../../../src/flux/models/message'
+import Thread from '../../../src/flux/models/thread'
+import Category from '../../../src/flux/models/category'
+import CategoryStore from '../../../src/flux/stores/category-store'
+import DatabaseStore from '../../../src/flux/stores/database-store'
+import AccountStore from '../../../src/flux/stores/account-store'
+import SoundRegistry from '../../../src/registries/sound-registry'
+import NativeNotifications from '../../../src/native-notifications'
+import {Notifier} from '../lib/main'
+
+xdescribe("UnreadNotifications", function UnreadNotifications() {
+ beforeEach(() => {
+ this.notifier = new Notifier();
+
+ const inbox = new Category({id: "l1", name: "inbox", displayName: "Inbox"})
+ const archive = new Category({id: "l2", name: "archive", displayName: "Archive"})
+
+ spyOn(CategoryStore, "getStandardCategory").andReturn(inbox);
+
+ const account = AccountStore.accounts()[0];
+
+ this.threadA = new Thread({
+ id: 'A',
+ categories: [inbox],
+ });
+ this.threadB = new Thread({
+ id: 'B',
+ categories: [archive],
+ });
+
+ this.msg1 = new Message({
+ unread: true,
+ date: new Date(),
+ from: [new Contact({name: 'Ben', email: 'benthis.example.com'})],
+ subject: "Hello World",
+ threadId: "A",
+ });
+ this.msgNoSender = new Message({
+ unread: true,
+ date: new Date(),
+ from: [],
+ subject: "Hello World",
+ threadId: "A",
+ });
+ this.msg2 = new Message({
+ unread: true,
+ date: new Date(),
+ from: [new Contact({name: 'Mark', email: 'markthis.example.com'})],
+ subject: "Hello World 2",
+ threadId: "A",
+ });
+ this.msg3 = new Message({
+ unread: true,
+ date: new Date(),
+ from: [new Contact({name: 'Ben', email: 'benthis.example.com'})],
+ subject: "Hello World 3",
+ threadId: "A",
+ });
+ this.msg4 = new Message({
+ unread: true,
+ date: new Date(),
+ from: [new Contact({name: 'Ben', email: 'benthis.example.com'})],
+ subject: "Hello World 4",
+ threadId: "A",
+ });
+ this.msg5 = new Message({
+ unread: true,
+ date: new Date(),
+ from: [new Contact({name: 'Ben', email: 'benthis.example.com'})],
+ subject: "Hello World 5",
+ threadId: "A",
+ });
+ this.msgUnreadButArchived = new Message({
+ unread: true,
+ date: new Date(),
+ from: [new Contact({name: 'Mark', email: 'markthis.example.com'})],
+ subject: "Hello World 2",
+ threadId: "B",
+ });
+ this.msgRead = new Message({
+ unread: false,
+ date: new Date(),
+ from: [new Contact({name: 'Mark', email: 'markthis.example.com'})],
+ subject: "Hello World Read Already",
+ threadId: "A",
+ });
+ this.msgOld = new Message({
+ unread: true,
+ date: new Date(2000, 1, 1),
+ from: [new Contact({name: 'Mark', email: 'markthis.example.com'})],
+ subject: "Hello World Old",
+ threadId: "A",
+ });
+ this.msgFromMe = new Message({
+ unread: true,
+ date: new Date(),
+ from: [account.me()],
+ subject: "A Sent Mail!",
+ threadId: "A",
+ });
+
+ spyOn(DatabaseStore, 'find').andCallFake((klass, id) => {
+ if (id === 'A') {
+ return Promise.resolve(this.threadA);
+ }
+ if (id === 'B') {
+ return Promise.resolve(this.threadB);
+ }
+ return Promise.resolve(null);
+ });
+
+ this.notification = jasmine.createSpyObj('notification', ['close']);
+ spyOn(NativeNotifications, 'displayNotification').andReturn(this.notification);
+
+ spyOn(Promise, 'props').andCallFake((dict) => {
+ const dictOut = {};
+ for (const key of Object.keys(dict)) {
+ const val = dict[key];
+ if (val.value !== undefined) {
+ dictOut[key] = val.value();
+ } else {
+ dictOut[key] = val;
+ }
+ }
+ return Promise.resolve(dictOut);
+ });
+ });
+
+ afterEach(() => {
+ this.notifier.unlisten();
+ })
+
+ it("should create a Notification if there is one unread message", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msgRead, this.msg1]})
+ .then(() => {
+ advanceClock(2000)
+ expect(NativeNotifications.displayNotification).toHaveBeenCalled()
+ const options = NativeNotifications.displayNotification.mostRecentCall.args[0]
+ delete options.onActivate;
+ expect(options).toEqual({
+ title: 'Ben',
+ subtitle: 'Hello World',
+ body: undefined,
+ canReply: true,
+ tag: 'unread-update',
+ });
+ });
+ });
+ });
+
+ it("should create multiple Notifications if there is more than one but less than five unread messages", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msg1, this.msg2, this.msg3]})
+ .then(() => {
+ // Need to call advance clock twice because we call setTimeout twice
+ advanceClock(2000)
+ advanceClock(2000)
+ expect(NativeNotifications.displayNotification.callCount).toEqual(3)
+ });
+ });
+ });
+
+ it("should create Notifications in the order of messages received", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msg1, this.msg2]})
+ .then(() => {
+ advanceClock(2000);
+ return this.notifier._onNewMailReceived({message: [this.msg3, this.msg4]});
+ })
+ .then(() => {
+ advanceClock(2000);
+ advanceClock(2000);
+ expect(NativeNotifications.displayNotification.callCount).toEqual(4);
+ const subjects = NativeNotifications.displayNotification.calls.map((call) => {
+ return call.args[0].subtitle;
+ });
+ const expected = [this.msg1, this.msg2, this.msg3, this.msg4]
+ .map((msg) => msg.subject);
+ expect(subjects).toEqual(expected);
+ });
+ });
+ });
+
+ it("should create a Notification if there are five or more unread messages", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({
+ message: [this.msg1, this.msg2, this.msg3, this.msg4, this.msg5]})
+ .then(() => {
+ advanceClock(2000)
+ expect(NativeNotifications.displayNotification).toHaveBeenCalled()
+ expect(NativeNotifications.displayNotification.mostRecentCall.args).toEqual([{
+ title: '5 Unread Messages',
+ tag: 'unread-update',
+ }])
+ });
+ });
+ });
+
+ it("should create a Notification correctly, even if new mail has no sender", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msgNoSender]})
+ .then(() => {
+ expect(NativeNotifications.displayNotification).toHaveBeenCalled()
+
+ const options = NativeNotifications.displayNotification.mostRecentCall.args[0]
+ delete options.onActivate;
+ expect(options).toEqual({
+ title: 'Unknown',
+ subtitle: 'Hello World',
+ body: undefined,
+ canReply: true,
+ tag: 'unread-update',
+ })
+ });
+ });
+ });
+
+ it("should not create a Notification if there are no new messages", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: []})
+ .then(() => {
+ expect(NativeNotifications.displayNotification).not.toHaveBeenCalled()
+ });
+ });
+
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({})
+ .then(() => {
+ expect(NativeNotifications.displayNotification).not.toHaveBeenCalled()
+ });
+ });
+ });
+
+ it("should not notify about unread messages that are outside the inbox", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msgUnreadButArchived, this.msg1]})
+ .then(() => {
+ expect(NativeNotifications.displayNotification).toHaveBeenCalled()
+ const options = NativeNotifications.displayNotification.mostRecentCall.args[0]
+ delete options.onActivate;
+ expect(options).toEqual({
+ title: 'Ben',
+ subtitle: 'Hello World',
+ body: undefined,
+ canReply: true,
+ tag: 'unread-update',
+ })
+ });
+ });
+ });
+
+ it("should not create a Notification if the new messages are read", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msgRead]})
+ .then(() => {
+ expect(NativeNotifications.displayNotification).not.toHaveBeenCalled()
+ });
+ });
+ });
+
+ it("should not create a Notification if the new messages are actually old ones", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msgOld]})
+ .then(() => {
+ expect(NativeNotifications.displayNotification).not.toHaveBeenCalled()
+ });
+ });
+ });
+
+ it("should not create a Notification if the new message is one I sent", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msgFromMe]})
+ .then(() => {
+ expect(NativeNotifications.displayNotification).not.toHaveBeenCalled()
+ });
+ });
+ });
+
+ it("clears notifications when a thread is read", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msg1]})
+ .then(() => {
+ expect(NativeNotifications.displayNotification).toHaveBeenCalled();
+ expect(this.notification.close).not.toHaveBeenCalled();
+ this.notifier._onThreadIsRead(this.threadA);
+ expect(this.notification.close).toHaveBeenCalled();
+ });
+ });
+ });
+
+ it("detects changes that may be a thread being read", () => {
+ const unreadThread = { unread: true };
+ const readThread = { unread: false };
+ spyOn(this.notifier, '_onThreadIsRead');
+ this.notifier._onDatabaseUpdated({ objectClass: 'Thread', objects: [unreadThread, readThread]});
+ expect(this.notifier._onThreadIsRead.calls.length).toEqual(1);
+ expect(this.notifier._onThreadIsRead).toHaveBeenCalledWith(readThread);
+ });
+
+ it("should play a sound when it gets new mail", () => {
+ spyOn(NylasEnv.config, "get").andCallFake((config) => {
+ if (config === "core.notifications.enabled") return true
+ if (config === "core.notifications.sounds") return true
+ return undefined;
+ });
+
+ spyOn(SoundRegistry, "playSound");
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msg1]})
+ .then(() => {
+ expect(NylasEnv.config.get.calls[1].args[0]).toBe("core.notifications.sounds");
+ expect(SoundRegistry.playSound).toHaveBeenCalledWith("new-mail");
+ });
+ });
+ });
+
+ it("should not play a sound if the config is off", () => {
+ spyOn(NylasEnv.config, "get").andCallFake((config) => {
+ if (config === "core.notifications.enabled") return true;
+ if (config === "core.notifications.sounds") return false;
+ return undefined;
+ });
+ spyOn(SoundRegistry, "playSound")
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msg1]})
+ .then(() => {
+ expect(NylasEnv.config.get.calls[1].args[0]).toBe("core.notifications.sounds");
+ expect(SoundRegistry.playSound).not.toHaveBeenCalled()
+ });
+ });
+ });
+
+ it("should not play a sound if other notiications are still in flight", () => {
+ spyOn(NylasEnv.config, "get").andCallFake((config) => {
+ if (config === "core.notifications.enabled") return true;
+ if (config === "core.notifications.sounds") return true;
+ return undefined;
+ });
+ waitsForPromise(() => {
+ spyOn(SoundRegistry, "playSound")
+ return this.notifier._onNewMailReceived({message: [this.msg1, this.msg2]}).then(() => {
+ expect(SoundRegistry.playSound).toHaveBeenCalled();
+ SoundRegistry.playSound.reset();
+ return this.notifier._onNewMailReceived({message: [this.msg3]}).then(() => {
+ expect(SoundRegistry.playSound).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe("when the message has no matching thread", () => {
+ beforeEach(() => {
+ this.msgNoThread = new Message({
+ unread: true,
+ date: new Date(),
+ from: [new Contact({name: 'Ben', email: 'benthis.example.com'})],
+ subject: "Hello World",
+ threadId: "missing",
+ });
+ });
+
+ it("should not create a Notification, since it cannot be determined whether the message is in the Inbox", () => {
+ waitsForPromise(() => {
+ return this.notifier._onNewMailReceived({message: [this.msgNoThread]})
+ .then(() => {
+ advanceClock(2000)
+ expect(NativeNotifications.displayNotification).not.toHaveBeenCalled()
+ });
+ });
+ });
+
+ it("should call _onNewMessagesMissingThreads to try displaying a notification again in 10 seconds", () => {
+ waitsForPromise(() => {
+ spyOn(this.notifier, '_onNewMessagesMissingThreads')
+ return this.notifier._onNewMailReceived({message: [this.msgNoThread]})
+ .then(() => {
+ advanceClock(2000)
+ expect(this.notifier._onNewMessagesMissingThreads).toHaveBeenCalledWith([this.msgNoThread])
+ });
+ });
+ });
+ });
+
+ describe("_onNewMessagesMissingThreads", () => {
+ beforeEach(() => {
+ this.msgNoThread = new Message({
+ unread: true,
+ date: new Date(),
+ from: [new Contact({name: 'Ben', email: 'benthis.example.com'})],
+ subject: "Hello World",
+ threadId: "missing",
+ });
+ spyOn(this.notifier, '_onNewMailReceived')
+ this.notifier._onNewMessagesMissingThreads([this.msgNoThread])
+ advanceClock(2000)
+ });
+
+ it("should wait 10 seconds and then re-query for threads", () => {
+ expect(DatabaseStore.find).not.toHaveBeenCalled()
+ this.msgNoThread.threadId = "A"
+ advanceClock(10000)
+ expect(DatabaseStore.find).toHaveBeenCalled()
+ advanceClock()
+ expect(this.notifier._onNewMailReceived).toHaveBeenCalledWith({message: [this.msgNoThread], thread: [this.threadA]})
+ });
+
+ it("should do nothing if the threads still can't be found", () => {
+ expect(DatabaseStore.find).not.toHaveBeenCalled()
+ advanceClock(10000)
+ expect(DatabaseStore.find).toHaveBeenCalled()
+ advanceClock()
+ expect(this.notifier._onNewMailReceived).not.toHaveBeenCalled()
+ });
+ });
+});
diff --git a/packages/client-app/internal_packages/verify-install-location/lib/main.es6 b/packages/client-app/internal_packages/verify-install-location/lib/main.es6
new file mode 100644
index 0000000000..85ac9542cd
--- /dev/null
+++ b/packages/client-app/internal_packages/verify-install-location/lib/main.es6
@@ -0,0 +1,80 @@
+import {ipcRenderer, remote} from 'electron'
+
+/**
+ * We want to make sure that people have installed the app in a
+ * reasonable location.
+ *
+ * On the Mac, you can accidentally run the app from the DMG. If you do
+ * this, it will no longer auto-update. It's also common for Mac users to
+ * leave their app in the /Downloads folder (which frequently gets
+ * erased!).
+ */
+
+function onDialogActionTaken(numAsks) {
+ return (buttonIndex) => {
+ if (numAsks >= 1) {
+ if (buttonIndex === 1) {
+ NylasEnv.config.set("asksAboutAppMove", 5)
+ } else {
+ NylasEnv.config.set("asksAboutAppMove", numAsks + 1)
+ }
+ } else {
+ NylasEnv.config.set("asksAboutAppMove", numAsks + 1)
+ }
+ }
+}
+
+export function activate() {
+ if (NylasEnv.inDevMode() || NylasEnv.inSpecMode()) { return; }
+
+ if (process.platform !== "darwin") { return; }
+
+ const appRe = /Applications/gi;
+ if (appRe.test(process.argv[0])) { return; }
+
+ // If we're in Volumes, that means we've launched from the DMG. This
+ // is unsupported. We should optimistically move.
+ const volTest = /Volumes/gi;
+ if (volTest.test(process.argv[0])) {
+ ipcRenderer.send("move-to-applications");
+ return;
+ }
+
+ const numAsks = NylasEnv.config.get("asksAboutAppMove") || 0
+ if (numAsks <= 0) {
+ NylasEnv.config.set("asksAboutAppMove", 1)
+ return;
+ }
+
+ NylasEnv.config.set("asksAboutAppMove", numAsks + 1)
+ if (numAsks >= 5) return;
+
+ let buttons;
+ if (numAsks >= 1) {
+ buttons = [
+ "Okay",
+ "Don't ask again",
+ ]
+ } else {
+ buttons = [
+ "Okay",
+ ]
+ }
+
+ const msg = `We recommend that you move Nylas Mail to your Applications folder to get updates correctly and keep this folder uncluttered.`
+
+ const CANCEL_ID = 0;
+
+ remote.dialog.showMessageBox({
+ type: "warning",
+ buttons: buttons,
+ title: "A Better Place to Install Nylas Mail",
+ message: "Please move Nylas Mail to your Applications folder",
+ detail: msg,
+ defaultId: 0,
+ cancelId: CANCEL_ID,
+ }, onDialogActionTaken(numAsks))
+}
+
+export function deactivate() {
+}
diff --git a/packages/client-app/internal_packages/verify-install-location/package.json b/packages/client-app/internal_packages/verify-install-location/package.json
new file mode 100644
index 0000000000..d5ddf58e98
--- /dev/null
+++ b/packages/client-app/internal_packages/verify-install-location/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "verify-install-location",
+ "main": "./lib/main",
+ "version": "0.0.1",
+ "description": "Verifies the install location for N1",
+ "license": "GPL-3.0",
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "default": true
+ }
+}
diff --git a/packages/client-app/internal_packages/worker-ui/lib/developer-bar-curl-item.cjsx b/packages/client-app/internal_packages/worker-ui/lib/developer-bar-curl-item.cjsx
new file mode 100644
index 0000000000..848ea3b0fe
--- /dev/null
+++ b/packages/client-app/internal_packages/worker-ui/lib/developer-bar-curl-item.cjsx
@@ -0,0 +1,47 @@
+classNames = require 'classnames'
+React = require 'react'
+
+class DeveloperBarCurlItem extends React.Component
+ @displayName: 'DeveloperBarCurlItem'
+
+ render: =>
+ classes = classNames
+ "item": true
+ "error-code": @_isError()
+
+
{@props.item.statusCode}{@_errorMessage()}
+
{@props.item.startMoment.format("HH:mm:ss")}
+
Run
+
Copy
+ {@props.item.command}
+
+
+ shouldComponentUpdate: (nextProps) =>
+ return @props.item isnt nextProps.item
+
+ _onCopyCommand: =>
+ clipboard = require('electron').clipboard
+ clipboard.writeText(@props.item.commandWithAuth)
+
+ _isError: ->
+ return false if @props.item.statusCode is "pending"
+ return not (parseInt(@props.item.statusCode) <= 399)
+
+ _errorMessage: ->
+ if (@props.item.errorMessage ? "").length > 0
+ return " | #{@props.item.errorMessage}"
+ else
+ return ""
+
+ _onRunCommand: =>
+ curlFile = "#{NylasEnv.getConfigDirPath()}/curl.command"
+ fs = require 'fs-plus'
+ if fs.existsSync(curlFile)
+ fs.unlinkSync(curlFile)
+ fs.writeFileSync(curlFile, @props.item.commandWithAuth)
+ fs.chmodSync(curlFile, '777')
+ {shell} = require 'electron'
+ shell.openItem(curlFile)
+
+
+module.exports = DeveloperBarCurlItem
diff --git a/packages/client-app/internal_packages/worker-ui/lib/developer-bar-long-poll-item.cjsx b/packages/client-app/internal_packages/worker-ui/lib/developer-bar-long-poll-item.cjsx
new file mode 100644
index 0000000000..76e9c3673d
--- /dev/null
+++ b/packages/client-app/internal_packages/worker-ui/lib/developer-bar-long-poll-item.cjsx
@@ -0,0 +1,44 @@
+React = require 'react'
+moment = require 'moment'
+{DateUtils, Utils} = require 'nylas-exports'
+
+class DeveloperBarLongPollItem extends React.Component
+ @displayName: 'DeveloperBarLongPollItem'
+
+ constructor: (@props) ->
+ @state = expanded: false
+
+ shouldComponentUpdate: (nextProps, nextState) =>
+ return not Utils.isEqualReact(nextProps, @props) or not Utils.isEqualReact(nextState, @state)
+
+ render: =>
+ if @state.expanded
+ payload = JSON.stringify(@props.item, null, 2)
+ else
+ payload = []
+
+ itemId = @props.item.id
+ itemVersion = @props.item.version || @props.item.attributes?.version
+ itemId += " (version #{itemVersion})" if itemVersion
+
+ timeFormat = DateUtils.getTimeFormat { seconds: true }
+ timestamp = moment(@props.item.timestamp).format(timeFormat)
+
+ classname = "item"
+ right = @props.item.cursor
+
+ if @props.ignoredBecause
+ classname += " ignored"
+ right = @props.ignoredBecause + " - " + right
+
+ @setState expanded: not @state?.expanded}>
+
{right}
+ {" #{timestamp}: #{@props.item.event} #{@props.item.object} #{itemId}"}
+
e.stopPropagation() }>
+ {payload}
+
+
+
+
+
+module.exports = DeveloperBarLongPollItem
diff --git a/packages/client-app/internal_packages/worker-ui/lib/developer-bar-store.coffee b/packages/client-app/internal_packages/worker-ui/lib/developer-bar-store.coffee
new file mode 100644
index 0000000000..1c6df8beb9
--- /dev/null
+++ b/packages/client-app/internal_packages/worker-ui/lib/developer-bar-store.coffee
@@ -0,0 +1,140 @@
+NylasStore = require 'nylas-store'
+{Rx, Actions, DatabaseStore, ProviderSyncbackRequest, DeltaConnectionStore} = require 'nylas-exports'
+qs = require 'querystring'
+_ = require 'underscore'
+moment = require 'moment'
+
+class DeveloperBarCurlRequest
+ constructor: ({@id, request, statusCode, error}) ->
+ url = request.url
+ urlWithAuth = url
+ if request.auth and (request.auth.user || request.auth.pass)
+ urlWithAuth = url.replace('://', "://#{request.auth.user ? ""}:#{request.auth.pass ? ""}@")
+
+ if request.qs
+ url += "?#{qs.stringify(request.qs)}"
+ urlWithAuth += "?#{qs.stringify(request.qs)}"
+
+ postBody = ""
+ postBody = JSON.stringify(request.body).replace(/'/g, '\\u0027') if request.body
+
+ data = ""
+ data = "-d '#{postBody}'" unless request.method == 'GET'
+
+ headers = ""
+ if request.headers
+ for k,v of request.headers
+ headers += "-H \"#{k}: #{v}\" "
+
+ # When constructed during _onWillMakeAPIRequest(), `request` has not been
+ # processed by node-request yet. Therefore, it will not have Content-Type
+ # set in the request headers.
+ if (request.json and not request._json and
+ request.headers and
+ 'content-type' not in request.headers and
+ 'Content-Type' not in request.headers)
+ headers += '-H "Content-Type: application\/json" '
+
+ if request.auth?.bearer
+ tok = request.auth.bearer.replace("!", "\\!")
+ headers += "-H \"Authorization: Bearer #{tok}\" "
+
+ baseCommand = "curl -X #{request.method} #{headers}#{data}"
+ @command = baseCommand + " \"#{url}\""
+ @commandWithAuth = baseCommand + " \"#{urlWithAuth}\""
+ @statusCode = statusCode ? error?.code ? "pending"
+ @errorMessage = error?.message ? error
+ @startMoment = moment(request.startTime)
+ @
+
+class DeveloperBarStore extends NylasStore
+ constructor: ->
+ @_setStoreDefaults()
+ @_registerListeners()
+
+ ########### PUBLIC #####################################################
+
+ curlHistory: -> @_curlHistory
+
+ longPollStates: -> @_longPollStates
+
+ longPollHistory: -> @_longPollHistory
+
+ providerSyncbackRequests: -> @_providerSyncbackRequests
+
+ ########### PRIVATE ####################################################
+
+ triggerThrottled: ->
+ @_triggerThrottled ?= _.throttle(@trigger, 150)
+ @_triggerThrottled()
+
+ _setStoreDefaults: ->
+ @_curlHistoryIds = []
+ @_curlHistory = []
+ @_longPollHistory = []
+ @_longPollStates = {}
+ @_providerSyncbackRequests = []
+
+ _registerListeners: ->
+ query = DatabaseStore.findAll(ProviderSyncbackRequest)
+ .order(ProviderSyncbackRequest.attributes.id.descending())
+ .limit(100)
+ Rx.Observable.fromQuery(query).subscribe(@_onSyncbackRequestChange)
+ @listenTo DeltaConnectionStore, @_onDeltaConnectionStatusChanged
+ @listenTo Actions.willMakeAPIRequest, @_onWillMakeAPIRequest
+ @listenTo Actions.didMakeAPIRequest, @_onDidMakeAPIRequest
+ @listenTo Actions.longPollReceivedRawDeltas, @_onLongPollDeltas
+ @listenTo Actions.longPollProcessedDeltas, @_onLongPollProcessedDeltas
+ @listenTo Actions.clearDeveloperConsole, @_onClear
+
+ _onClear: ->
+ @_curlHistoryIds = []
+ @_curlHistory = []
+ @_longPollHistory = []
+ @trigger(@)
+
+ _onSyncbackRequestChange: (reqs = []) =>
+ @_providerSyncbackRequests = reqs
+ @trigger()
+
+ _onDeltaConnectionStatusChanged: ->
+ @_longPollStates = {}
+ _.forEach DeltaConnectionStore.getDeltaConnectionStates(), (state, accountId) =>
+ @_longPollStates[accountId] = state.status
+ @trigger()
+
+ _onLongPollDeltas: (deltas) ->
+ # Add a local timestamp to deltas so we can display it
+ now = new Date()
+ delta.timestamp = now for delta in deltas
+
+ # Incoming deltas are [oldest...newest]. Append them to the beginning
+ # of our internal history which is [newest...oldest]
+ @_longPollHistory.unshift([].concat(deltas).reverse()...)
+ if @_longPollHistory.length > 200
+ @_longPollHistory.length = 200
+ @triggerThrottled(@)
+
+ _onLongPollProcessedDeltas: ->
+ @triggerThrottled(@)
+
+ _onWillMakeAPIRequest: ({requestId, request}) =>
+ item = new DeveloperBarCurlRequest({id: requestId, request})
+
+ @_curlHistory.unshift(item)
+ @_curlHistoryIds.unshift(requestId)
+ if @_curlHistory.length > 200
+ @_curlHistory.pop()
+ @_curlHistoryIds.pop()
+
+ @triggerThrottled(@)
+
+ _onDidMakeAPIRequest: ({requestId, request, statusCode, error}) =>
+ idx = @_curlHistoryIds.indexOf(requestId)
+ return if idx is -1 # Could be more than 200 requests ago
+
+ item = new DeveloperBarCurlRequest({id: requestId, request, statusCode, error})
+ @_curlHistory[idx] = item
+ @triggerThrottled(@)
+
+module.exports = new DeveloperBarStore()
diff --git a/packages/client-app/internal_packages/worker-ui/lib/developer-bar-task.cjsx b/packages/client-app/internal_packages/worker-ui/lib/developer-bar-task.cjsx
new file mode 100644
index 0000000000..f11f201475
--- /dev/null
+++ b/packages/client-app/internal_packages/worker-ui/lib/developer-bar-task.cjsx
@@ -0,0 +1,67 @@
+React = require 'react'
+classNames = require 'classnames'
+_ = require 'underscore'
+{Utils} = require 'nylas-exports'
+
+class DeveloperBarTask extends React.Component
+ @displayName: 'DeveloperBarTask'
+
+ constructor: (@props) ->
+ @state =
+ expanded: false
+
+ render: =>
+ details = false
+ if @state.expanded
+ # This could be a potentially large amount of JSON.
+ # Do not render unless it's actually being displayed!
+ details = {JSON.stringify(@props.task.toJSON(), null, 2)}
+
+ @setState(expanded: not @state.expanded)}>
+
+ {@_taskSummary()}
+
+ {details}
+
+
+ shouldComponentUpdate: (nextProps, nextState) =>
+ return not Utils.isEqualReact(nextProps, @props) or not Utils.isEqualReact(nextState, @state)
+
+ _taskSummary: =>
+ qs = @props.task.queueState
+ errType = ""
+ errCode = ""
+ errMessage = ""
+ if qs.localError?
+ localError = qs.localError
+ errType = localError.constructor.name
+ errMessage = localError.message ? JSON.stringify(localError)
+ else if qs.remoteError?
+ remoteError = qs.remoteError
+ errType = remoteError.constructor.name
+ errCode = remoteError.statusCode ? ""
+ errMessage = remoteError.body?.message ? remoteError?.message ? JSON.stringify(remoteError)
+
+ id = @props.task.id[-4..-1]
+
+ if qs.status
+ status = "#{qs.status} (#{qs.debugStatus})"
+ else
+ status = "#{qs.debugStatus}"
+
+ return "#{@props.task.constructor.name} (ID: #{id}) #{status} #{errType} #{errCode} #{errMessage}"
+
+ _classNames: =>
+ qs = @props.task.queueState ? {}
+ classNames
+ "task": true
+ "task-queued": @props.type is "queued"
+ "task-completed": @props.type is "completed"
+ "task-expanded": @state.expanded
+ "task-local-error": qs.localError
+ "task-remote-error": qs.remoteError
+ "task-is-processing": qs.isProcessing
+ "task-success": qs.localComplete and qs.remoteComplete
+
+
+module.exports = DeveloperBarTask
diff --git a/packages/client-app/internal_packages/worker-ui/lib/developer-bar.cjsx b/packages/client-app/internal_packages/worker-ui/lib/developer-bar.cjsx
new file mode 100644
index 0000000000..4ed966562a
--- /dev/null
+++ b/packages/client-app/internal_packages/worker-ui/lib/developer-bar.cjsx
@@ -0,0 +1,165 @@
+_ = require 'underscore'
+React = require 'react'
+{DatabaseStore,
+ AccountStore,
+ TaskQueue,
+ Actions,
+ Contact,
+ Utils,
+ Message} = require 'nylas-exports'
+{InjectedComponentSet} = require 'nylas-component-kit'
+
+DeveloperBarStore = require './developer-bar-store'
+DeveloperBarTask = require './developer-bar-task'
+DeveloperBarCurlItem = require './developer-bar-curl-item'
+DeveloperBarLongPollItem = require './developer-bar-long-poll-item'
+
+
+class DeveloperBar extends React.Component
+ @displayName: "DeveloperBar"
+
+ @containerRequired: false
+
+ constructor: (@props) ->
+ @state = _.extend @_getStateFromStores(),
+ section: 'curl'
+ filter: ''
+
+ componentDidMount: =>
+ @taskQueueUnsubscribe = TaskQueue.listen @_onChange
+ @activityStoreUnsubscribe = DeveloperBarStore.listen @_onChange
+
+ componentWillUnmount: =>
+ @taskQueueUnsubscribe() if @taskQueueUnsubscribe
+ @activityStoreUnsubscribe() if @activityStoreUnsubscribe
+
+ render: =>
+
+
+
+
@_onExpandSection('queue')}>
+ Client Tasks ({@state.queue?.length})
+
+
+
+
@_onExpandSection('providerSyncbackRequests')}>
+ Provider Syncback Requests
+
+
+
+
@_onExpandSection('long-polling')}>
+ {@_renderDeltaStates()}
+ Cloud Deltas
+
+
+
+
@_onExpandSection('curl')}>
+ Requests: {@state.curlHistory.length}
+
+
+
+
@_onExpandSection('local-sync')}>
+ Local Sync Engine
+
+
+
+ {@_sectionContent()}
+
+
+
+ _renderDeltaStates: =>
+ _.map @state.longPollStates, (status, accountId) =>
+
+
+ _sectionContent: =>
+ expandedDiv =
+
+ matchingFilter = (item) =>
+ return true if @state.filter is ''
+ return JSON.stringify(item).indexOf(@state.filter) >= 0
+
+ if @state.section == 'curl'
+ itemDivs = @state.curlHistory.filter(matchingFilter).map (item) ->
+
+ expandedDiv = {itemDivs}
+
+ else if @state.section == 'long-polling'
+ itemDivs = @state.longPollHistory.filter(matchingFilter).map (item) ->
+
+ expandedDiv = {itemDivs}
+
+ else if @state.section == 'local-sync'
+ expandedDiv =
+
+
+
+ else if @state.section == 'providerSyncbackRequests'
+ reqs = @state.providerSyncbackRequests.map (req) =>
+ {req.type}: {req.status} - {JSON.stringify(req.props)}
+ expandedDiv = {reqs}
+
+ else if @state.section == 'queue'
+ queue = @state.queue.filter(matchingFilter)
+ queueDivs = for i in [@state.queue.length - 1..0] by -1
+ task = @state.queue[i]
+ # We need to pass the task separately because we want to update
+ # when just that variable changes. Otherwise, since the `task`
+ # pointer doesn't change, the `DeveloperBarTask` doesn't know to
+ # update.
+ status = @state.queue[i].queueState.status
+
+
+ queueCompleted = @state.completed.filter(matchingFilter)
+ queueCompletedDivs = for i in [@state.completed.length - 1..0] by -1
+ task = @state.completed[i]
+
+
+ expandedDiv =
+
+
Remove Queued Tasks
+
+ {queueDivs}
+
+ {queueCompletedDivs}
+
+
+
+ expandedDiv
+
+ _onChange: =>
+ @setState(@_getStateFromStores())
+
+ _onClear: =>
+ Actions.clearDeveloperConsole()
+
+ _onFilter: (ev) =>
+ @setState(filter: ev.target.value)
+
+ _onDequeueAll: =>
+ Actions.dequeueAllTasks()
+
+ _onExpandSection: (section) =>
+ @setState(@_getStateFromStores())
+ @setState(section: section)
+
+ _getStateFromStores: =>
+ queue: Utils.deepClone(TaskQueue._queue)
+ completed: TaskQueue._completed
+ curlHistory: DeveloperBarStore.curlHistory()
+ longPollHistory: DeveloperBarStore.longPollHistory()
+ longPollStates: DeveloperBarStore.longPollStates()
+ providerSyncbackRequests: DeveloperBarStore.providerSyncbackRequests()
+
+
+module.exports = DeveloperBar
diff --git a/packages/client-app/internal_packages/worker-ui/lib/main.cjsx b/packages/client-app/internal_packages/worker-ui/lib/main.cjsx
new file mode 100644
index 0000000000..e338c245d6
--- /dev/null
+++ b/packages/client-app/internal_packages/worker-ui/lib/main.cjsx
@@ -0,0 +1,16 @@
+React = require 'react'
+{ComponentRegistry, WorkspaceStore} = require 'nylas-exports'
+DeveloperBar = require './developer-bar'
+
+module.exports =
+ item: null
+
+ activate: (@state={}) ->
+ WorkspaceStore.defineSheet 'Main', {root: true},
+ popout: ['Center']
+
+ ComponentRegistry.register DeveloperBar,
+ location: WorkspaceStore.Location.Center
+
+ deactivate: ->
+ ComponentRegistry.unregister DeveloperBar
diff --git a/packages/client-app/internal_packages/worker-ui/package.json b/packages/client-app/internal_packages/worker-ui/package.json
new file mode 100755
index 0000000000..b19194efb3
--- /dev/null
+++ b/packages/client-app/internal_packages/worker-ui/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "worker-ui",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Interface for the worker window",
+ "license": "GPL-3.0",
+ "private": true,
+ "engines": {
+ "nylas": "*"
+ },
+ "windowTypes": {
+ "work": true
+ }
+}
diff --git a/packages/client-app/internal_packages/worker-ui/stylesheets/worker-ui.less b/packages/client-app/internal_packages/worker-ui/stylesheets/worker-ui.less
new file mode 100755
index 0000000000..6759755740
--- /dev/null
+++ b/packages/client-app/internal_packages/worker-ui/stylesheets/worker-ui.less
@@ -0,0 +1,228 @@
+@import "ui-variables";
+
+.developer-bar {
+ -webkit-font-smoothing: auto;
+ background-color: rgba(80,80,80,1);
+ border-top:1px solid rgba(0,0,0,0.7);
+ color:white;
+ font-size:12px;
+ display:flex;
+ flex-direction:column;
+ height:100%;
+
+ .controls {
+ z-index:2;
+ background-color: rgba(80,80,80,1);
+ position: relative;
+ min-height:30px;
+ -webkit-app-region: drag;
+ .btn-container {
+ -webkit-app-region: no-drag;
+ }
+ }
+
+ .footer {
+ padding:2px;
+ input.filter {
+ margin-left: 4px;
+ padding: 2px;
+ color:black;
+ vertical-align: middle;
+ width: 400px;
+ }
+ }
+ .section-content {
+ position: relative;
+ z-index: 1;
+ }
+ .queue-buttons {
+ position: relative;
+ z-index: 1;
+ }
+ .btn {
+ padding: 5px;
+ font-size: 13px;
+ line-height: 15px;
+ height: 25px;
+ background: rgba(60,60,60,1);
+ color: white;
+ }
+
+ .btn:hover {
+ background: rgba(40,40,40,1);
+ }
+
+ .fa-caret-square-o-down,
+ .fa-caret-square-o-up {
+ display:inline-block;
+ width:20px;
+ height:20px;
+ float:left;
+ margin:7px;
+ margin-bottom:0;
+ font-size:18px;
+ }
+
+ .btn-container {
+ padding:3px;
+ }
+
+ .delta-state-wrap {
+ display: inline-block;
+ }
+
+ .activity-status-bubble {
+ border-radius:6px;
+ display:inline-block;
+ margin-right:5px;
+ margin-top:-2px;
+ width:11px;
+ height:11px;
+ vertical-align: middle;
+
+ &.state-connecting {
+ background-color:#aff2a7;
+ }
+ &.state-connected {
+ background-color:#94E864;
+ }
+ &.state-none,
+ &.state-closed,
+ &.state-ended, {
+ background-color:gray;
+ }
+ }
+
+ .expanded-section {
+ clear:both;
+ flex: 1;
+ border-top:1px solid black;
+ padding-top:8px;
+ padding-bottom:8px;
+ overflow-y: scroll;
+ background-color: rgba(0,0,0,0.5);
+ font-family: monospace;
+ -webkit-user-select:auto;
+
+ &.queue {
+ padding: 0;
+ .btn { float:right; z-index: 10; }
+ hr {
+ margin: 1em 0;
+ }
+ }
+ .item {
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &.curl-history {
+ .item {
+ padding-left:8px;
+ padding-right:8px;
+ padding-bottom:3px;
+ }
+ .timestamp {
+ color: rgba(255,255,255,0.5);
+ }
+ .error-code {
+ background-color:#740000;
+ }
+ .item.status-code-500,
+ .item.status-code-501,
+ .item.status-code-502,
+ .item.status-code-503,
+ .item.status-code-504,
+ .item.status-code-400,
+ .item.status-code-404,
+ .item.status-code-409 {
+ background-color:#740000;
+ }
+ .code {
+ float:right;
+ clear:right;
+ opacity: 0.5;
+ }
+ a {
+ padding-right:4px;
+ border-bottom: 0;
+ }
+ a:hover {
+ border-bottom: 0;
+ text-decoration: none;
+ background-color: #003845;
+ color: white;
+ }
+ }
+
+
+ &.long-polling {
+ .item {
+ padding-left:8px;
+ padding-right:8px;
+ padding-bottom:3px;
+
+ .cursor {
+ float:right;
+ clear:right;
+ opacity: 0.5;
+ }
+
+ &:hover {
+ cursor: pointer;
+ background-color: rgba(255,255,255,0.2);
+ }
+
+ .payload {
+ white-space: pre;
+ color: burlywood;
+ }
+ }
+ .item.ignored {
+ opacity: 0.5;
+ }
+ }
+ }
+
+ .task {
+ padding: 0.5em 1em 0.5em 1.5em;
+ margin: 2px 0;
+
+ &:hover {
+ cursor: pointer;
+ background-color: rgba(255,255,255,0.2);
+ }
+
+ position: relative;
+ &:before {
+ content: " ";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 10px;
+ height: 100%;
+ background: @background-color-pending;
+ }
+
+ &.task-queued{
+ &.task-is-processing:before {
+ background: @background-color-info;
+ }
+ }
+
+ &.task-completed{
+ &.task-local-error:before, &.task-remote-error:before {
+ background: @background-color-error;
+ }
+ &.task-completed.task-success:before {
+ background: @background-color-success;
+ }
+ }
+
+ .task-details { display: none; }
+ &.task-expanded{
+ .task-details { display: block; white-space: pre; }
+ }
+
+ }
+}
diff --git a/packages/client-app/keymaps/README.m b/packages/client-app/keymaps/README.m
new file mode 100644
index 0000000000..9afb691fdb
--- /dev/null
+++ b/packages/client-app/keymaps/README.m
@@ -0,0 +1,17 @@
+# This is the core set of universal, cross-platform keymaps. This is
+# extended in the following places:
+#
+# 1. keymaps/base.cson - (This file) Core, universal keymaps across all platforms
+# 2. keymaps/base-darwin.cson - Any universal mac-only keymaps
+# 3. keymaps/base-win32.cson - Any universal windows-only keymaps
+# 4. keymaps/base-darwin.cson - Any universal linux-only keymaps
+# 5. keymaps/templates/Gmail.cson - Gmail key bindings for all platforms
+# 6. keymaps/templates/Outlook.cson - Outlook key bindings for all platforms
+# 7. keymaps/templates/Apple Mail.cson - Mac Mail key bindings for all platforms
+# 8. some/package/keymaps/package.cson - Keymaps for a specific package
+# 9. ~/.nylas/keymap.cson - Custom user-specific overrides
+#
+# NOTE: We have a special N1 extension called `mod` that automatically
+# uses `cmd` on mac and `ctrl` on windows and linux. This covers most
+# cross-platform cases. For truely platform-specific features, use the
+# platform keymap extensions.
diff --git a/packages/client-app/keymaps/base-darwin.json b/packages/client-app/keymaps/base-darwin.json
new file mode 100644
index 0000000000..09626a958b
--- /dev/null
+++ b/packages/client-app/keymaps/base-darwin.json
@@ -0,0 +1,10 @@
+{
+ "application:minimize": "command+m",
+ "application:hide": "command+h",
+ "application:hide-other-applications": "command+alt+h",
+ "application:zoom": "alt+command+ctrl+m",
+
+ "window:toggle-full-screen": "command+ctrl+f",
+ "window:reload": "mod+alt+l",
+ "window:toggle-dev-tools": "meta+alt+i"
+}
diff --git a/packages/client-app/keymaps/base-linux.json b/packages/client-app/keymaps/base-linux.json
new file mode 100644
index 0000000000..fd90e0d739
--- /dev/null
+++ b/packages/client-app/keymaps/base-linux.json
@@ -0,0 +1,7 @@
+{
+ "core:copy": "ctrl+insert",
+ "core:paste": "shift+insert",
+ "window:toggle-full-screen": "f11",
+ "window:reload": "mod+alt+l",
+ "window:toggle-dev-tools": "mod+alt+i"
+}
diff --git a/packages/client-app/keymaps/base-win32.json b/packages/client-app/keymaps/base-win32.json
new file mode 100644
index 0000000000..904495c7df
--- /dev/null
+++ b/packages/client-app/keymaps/base-win32.json
@@ -0,0 +1,5 @@
+{
+ "window:toggle-full-screen": "f11",
+ "window:reload": "ctrl+shift+r",
+ "window:toggle-dev-tools": "ctrl+shift+i"
+}
diff --git a/packages/client-app/keymaps/base.json b/packages/client-app/keymaps/base.json
new file mode 100644
index 0000000000..3bd2395eb2
--- /dev/null
+++ b/packages/client-app/keymaps/base.json
@@ -0,0 +1,61 @@
+{
+ "core:undo": "mod+z",
+ "core:redo": ["mod+shift+z", "mod+y"],
+ "core:cut": "mod+x",
+ "core:copy": "mod+c",
+ "core:paste": "mod+v",
+ "core:paste-and-match-style": "mod+alt+shift+v",
+ "core:select-all": "mod+a",
+ "core:previous-item": "up",
+ "core:next-item": "down",
+ "core:move-left": "left",
+ "core:move-right": "right",
+ "core:select-up": "shift+up",
+ "core:select-down": "shift+down",
+ "core:select-left": "shift+left",
+ "core:select-right": "shift+right",
+
+ "application:open-preferences": "mod+,",
+ "application:quit": "mod+q",
+
+ "window:close": "mod+w",
+
+ "core:snooze-item": "z",
+ "core:print-thread": "mod+p",
+ "core:focus-item": "enter",
+ "core:remove-from-view": ["backspace", "del"],
+ "core:pop-sheet": "escape",
+ "core:show-keybindings": "?",
+
+ "core:messages-page-up": "pageup",
+ "core:messages-page-down": "pagedown",
+ "core:list-page-up": "shift+pageup",
+ "core:list-page-down": "shift+pagedown",
+
+ "window:select-account-0": "mod+1",
+ "window:select-account-1": "mod+2",
+ "window:select-account-2": "mod+3",
+ "window:select-account-3": "mod+4",
+ "window:select-account-4": "mod+5",
+ "window:select-account-5": "mod+6",
+ "window:select-account-6": "mod+7",
+ "window:select-account-7": "mod+8",
+ "window:select-account-8": "mod+9",
+
+ "core:find-in-thread": "mod+f",
+ "core:find-in-thread-next": "mod+g",
+ "core:find-in-thread-previous": "mod+shift+g",
+
+ "contenteditable:set-right-to-left": "mod+,",
+ "contenteditable:underline": "mod+u",
+ "contenteditable:bold": "mod+b",
+ "contenteditable:italic": "mod+i",
+ "contenteditable:insert-link": "mod+k",
+ "contenteditable:numbered-list": "mod+shift+7",
+ "contenteditable:bulleted-list": "mod+shift+8",
+ "contenteditable:quote": "mod+shift+9",
+ "contenteditable:outdent": "mod+[",
+ "contenteditable:indent": "mod+]",
+ "contenteditable:next-selection": "mod+\"",
+ "contenteditable:open-spelling-suggestions": "mod+m"
+}
diff --git a/packages/client-app/keymaps/templates/Apple Mail.json b/packages/client-app/keymaps/templates/Apple Mail.json
new file mode 100644
index 0000000000..38f50f6bd4
--- /dev/null
+++ b/packages/client-app/keymaps/templates/Apple Mail.json
@@ -0,0 +1,30 @@
+{
+ "application:new-message": "mod+n",
+
+ "navigation:go-to-inbox": "command+ctrl+1",
+ "navigation:go-to-starred": "command+ctrl+2",
+ "navigation:go-to-sent": "command+ctrl+3",
+ "navigation:go-to-drafts": "command+ctrl+4",
+ "navigation:go-to-all": "command+ctrl+5",
+ "navigation:go-to-contacts": "command+ctrl+6",
+ "navigation:go-to-tasks": "command+ctrl+7",
+ "navigation:go-to-label": "command+ctrl+8",
+
+ "multiselect-list:select-all": "command+a",
+
+ "core:previous-item": "command+[",
+ "core:select-up": "shift+command+[",
+ "core:next-item": "command+]",
+ "core:select-down": "shift+command+]",
+
+ "core:reply": "mod+r",
+ "core:reply-all": "mod+shift+r",
+ "core:forward": "mod+shift+f",
+ "core:report-as-spam": "mod+shift+j",
+ "core:mark-as-unread": "mod+shift+u",
+ "core:star-item": "mod+shift+l",
+ "core:focus-search": "mod+alt+f",
+ "core:archive-item": "command+ctrl+a",
+
+ "composer:send-message": "mod+shift+d"
+}
diff --git a/packages/client-app/keymaps/templates/Gmail.json b/packages/client-app/keymaps/templates/Gmail.json
new file mode 100644
index 0000000000..9f69938959
--- /dev/null
+++ b/packages/client-app/keymaps/templates/Gmail.json
@@ -0,0 +1,61 @@
+{
+ "navigation:go-to-inbox": "g i",
+ "navigation:go-to-starred": "g s",
+ "navigation:go-to-sent": "g t",
+ "navigation:go-to-drafts": "g d",
+ "navigation:go-to-all": "g a",
+ "navigation:go-to-contacts": "g c",
+ "navigation:go-to-tasks": "g k",
+ "navigation:go-to-label": "g l",
+
+ "multiselect-list:select-all": "* a",
+ "multiselect-list:deselect-all": "* n",
+
+ "thread-list:select-read": "* r",
+ "thread-list:select-unread": "* u",
+ "thread-list:select-starred": "* s",
+ "thread-list:select-unstarred": "* t",
+
+ "core:pop-sheet": "u",
+ "core:previous-item": "k",
+ "core:select-up": "shift+k",
+ "core:next-item": "j",
+ "core:select-down": "shift+j",
+ "core:focus-item": "o",
+ "core:select-item": "x",
+ "core:undo": "mod+z",
+
+ "message-list:previous-message": "p",
+ "message-list:next-message": "n",
+ "message-list:expand-all": ";",
+ "message-list:collapse-all": ":",
+
+ "application:new-message": ["c", "d", "mod+n"],
+ "application:more-actions": ".",
+ "application:open-help": "?",
+
+ "core:mute-conversation": "m",
+ "core:focus-search": "/",
+ "core:change-category": ["l", "v"],
+ "core:focus-toolbar": ",",
+ "core:star-item": "s",
+ "core:gmail-remove-from-view": "y",
+ "core:archive-item": "e",
+ "core:report-as-spam": "!",
+ "core:delete-item": "#",
+
+ "core:reply": ["r", "mod+r"],
+ "core:reply-new-window": "shift+r",
+ "core:reply-all": ["a", "mod+shift+r"],
+ "core:reply-all-new-window": "shift+a",
+ "core:forward": ["f", "mod+shift+f"],
+ "core:forward-new-window": "shift+f",
+
+ "core:remove-and-previous": ["}", "]"],
+ "core:remove-and-next": ["{", "["],
+
+ "core:mark-as-read": "shift+i",
+ "core:mark-as-unread": ["shift+u", "_"],
+ "core:mark-important": ["+", "="],
+ "core:mark-unimportant": "-"
+}
diff --git a/packages/client-app/keymaps/templates/Inbox by Gmail.json b/packages/client-app/keymaps/templates/Inbox by Gmail.json
new file mode 100644
index 0000000000..274e8b78e3
--- /dev/null
+++ b/packages/client-app/keymaps/templates/Inbox by Gmail.json
@@ -0,0 +1,36 @@
+{
+ "application:new-message": ["c", "mod+n"],
+
+ "navigation:go-to-inbox": "i",
+ "multiselect-list:select-all": "shift+x",
+
+ "core:pop-sheet": "u",
+ "core:previous-item": "k",
+ "core:select-up": "shift+k",
+ "core:next-item": "j",
+ "core:select-down": "shift+j",
+ "core:focus-item": "o",
+
+ "message-list:previous-message": "p",
+ "message-list:next-message": "n",
+
+ "core:mute-conversation": "m",
+ "core:focus-search": "/",
+ "core:change-category": ".",
+ "core:select-item": "x",
+ "core:gmail-remove-from-view": "y",
+ "core:archive-item": "e",
+ "core:report-as-spam": "!",
+ "core:delete-item": "#",
+
+ "core:reply": ["r", "mod+r"],
+ "core:reply-new-window": "shift+r",
+ "core:reply-all": ["a", "mod+shift+r"],
+ "core:reply-all-new-window": "shift+a",
+ "core:forward": ["f", "mod+shift+f"],
+ "core:forward-new-window": "shift+f",
+
+ "core:remove-and-previous": ["}", "]"],
+ "core:remove-and-next": ["{", "["],
+ "core:undo": "mod+z"
+}
diff --git a/packages/client-app/keymaps/templates/Outlook.json b/packages/client-app/keymaps/templates/Outlook.json
new file mode 100644
index 0000000000..f6b6b263d9
--- /dev/null
+++ b/packages/client-app/keymaps/templates/Outlook.json
@@ -0,0 +1,17 @@
+{
+ "core:change-category": "mod+shift+v",
+ "core:focus-search": ["f3", "mod+e"],
+ "core:forward": "mod+f",
+ "core:delete-item": "mod+d",
+ "core:undo": "alt+backspace",
+ "composer:send-message": "alt+s",
+ "core:reply": "mod+r",
+ "core:reply-all": "mod+shift+r",
+ "application:new-message": ["mod+n", "mod+shift+m"],
+ "send": "mod+enter",
+ "core:find-in-thread": "f4",
+ "core:find-in-thread-next": "shift+f4",
+ "core:find-in-thread-previous": "ctrl+shift+f4",
+
+ "multiselect-list:select-all": "ctrl+a"
+}
diff --git a/packages/client-app/menus/darwin.json b/packages/client-app/menus/darwin.json
new file mode 100644
index 0000000000..c6b4fe489a
--- /dev/null
+++ b/packages/client-app/menus/darwin.json
@@ -0,0 +1,122 @@
+{
+ "menu": [
+ {
+ "label": "Nylas Mail",
+ "submenu": [
+ { "label": "About Nylas Mail", "command": "application:about" },
+ { "type": "separator" },
+ { "label": "Preferences", "command": "application:open-preferences" },
+ { "label": "Change Theme...", "command": "window:launch-theme-picker" },
+ { "label": "Install Theme...", "command": "application:install-package" },
+ { "type": "separator" },
+ { "label": "Add Account...", "command": "application:add-account", "args": {"source": "Menu"}},
+ { "label": "VERSION", "enabled": false },
+ { "label": "Restart and Install Update", "command": "application:install-update", "visible": false},
+ { "label": "Check for Update", "command": "application:check-for-update", "visible": false},
+ { "label": "Downloading Update", "enabled": false, "visible": false},
+ { "type": "separator" },
+ { "type": "separator" },
+ { "label": "Services", "submenu": [] },
+ { "type": "separator" },
+ { "label": "Hide Nylas Mail", "command": "application:hide" },
+ { "label": "Hide Others", "command": "application:hide-other-applications" },
+ { "label": "Show All", "command": "application:unhide-all-applications" },
+ { "type": "separator" },
+ { "label": "Quit", "command": "application:quit" }
+ ]
+ },
+ {
+ "label": "File",
+ "submenu": [
+ { "label": "New Message", "command": "application:new-message" },
+ { "type": "separator" },
+ { "label": "Close Window", "command": "window:close" },
+ { "type": "separator" },
+ { "label": "Print Current Thread", "command": "core:print-thread" }
+ ]
+ },
+
+ {
+ "label": "Edit",
+ "submenu": [
+ { "label": "Undo", "command": "core:undo" },
+ { "label": "Redo", "command": "core:redo" },
+ { "type": "separator" },
+ { "label": "Cut", "command": "core:cut" },
+ { "label": "Copy", "command": "core:copy" },
+ { "label": "Paste", "command": "core:paste" },
+ { "label": "Paste and Match Style", "command": "core:paste-and-match-style" },
+ { "label": "Select All", "command": "core:select-all" },
+ { "type": "separator" },
+ { "label": "Find", "submenu": [
+ { "label": "Find in Thread...", "command": "core:find-in-thread" },
+ { "label": "Find Next", "command": "core:find-in-thread-next" },
+ { "label": "Find Previous", "command": "core:find-in-thread-previous" }
+ ] }
+ ]
+ },
+
+ {
+ "label": "View",
+ "submenu": [
+ { "type": "separator", "id": "mailbox-navigation"},
+ { "label": "Go to Inbox", "command": "navigation:go-to-inbox" },
+ { "label": "Go to Starred", "command": "navigation:go-to-starred" },
+ { "label": "Go to Sent", "command": "navigation:go-to-sent" },
+ { "label": "Go to Drafts", "command": "navigation:go-to-drafts" },
+ { "label": "Go to All mail", "command": "navigation:go-to-all" },
+ { "type": "separator" },
+ { "label": "Enter Full Screen", "command": "window:toggle-full-screen" },
+ { "label": "Exit Full Screen", "command": "window:toggle-full-screen", "visible": false }
+ ]
+ },
+
+ {
+ "label": "Thread",
+ "submenu": [
+ { "label": "Reply", "command": "core:reply" },
+ { "label": "Reply All", "command": "core:reply-all" },
+ { "label": "Forward", "command": "core:forward" },
+ { "type": "separator" },
+ { "label": "Star", "command": "core:star-item" },
+ { "type": "separator", "id": "thread-actions" },
+ { "label": "Remove from view", "command": "core:remove-from-view" },
+ { "type": "separator", "id": "view-actions" }
+ ]
+ },
+
+ {
+ "label": "Developer",
+ "submenu": [
+ { "label": "Run with Debug Flags", "type": "checkbox", "command": "application:toggle-dev" },
+ { "type": "separator" },
+ { "label": "Reload", "command": "window:reload" },
+ { "label": "Toggle Developer Tools", "command": "window:toggle-dev-tools" },
+ { "label": "Toggle Component Regions", "command": "window:toggle-component-regions" },
+ { "label": "Toggle Screenshot Mode", "command": "window:toggle-screenshot-mode" },
+ { "type": "separator" },
+ { "label": "Create a Plugin...", "command": "application:create-package" },
+ { "label": "Install a Plugin...", "command": "application:install-package" },
+ { "type": "separator" },
+ { "label": "Open Detailed Logs", "command": "window:open-errorlogger-logs" }
+ ]
+ },
+ {
+ "label": "Window",
+ "submenu": [
+ { "label": "Minimize", "command": "application:minimize" },
+ { "label": "Zoom", "command": "application:zoom" },
+ { "type": "separator", "id": "window-list-separator" },
+ { "type": "separator" },
+ { "label": "Bring All to Front", "command": "application:bring-all-windows-to-front" }
+ ]
+ },
+
+ {
+ "label": "Help",
+ "submenu": [
+ { "label": "Nylas Mail Help", "command": "application:view-help" }
+ ]
+ }
+ ]
+}
diff --git a/packages/client-app/menus/linux.json b/packages/client-app/menus/linux.json
new file mode 100644
index 0000000000..9c74b4fa9d
--- /dev/null
+++ b/packages/client-app/menus/linux.json
@@ -0,0 +1,102 @@
+{
+ "menu": [
+ {
+ "label": "&File",
+ "submenu": [
+ { "label": "&New Message...", "command": "application:new-message" },
+ { "type": "separator" },
+ { "label": "Add Account...", "command": "application:add-account", "args": {"source": "Menu"}},
+ { "label": "Clos&e Window", "command": "window:close" },
+ { "type": "separator" },
+ { "label": "Print Current Thread...", "command": "core:print-thread" },
+ { "type": "separator" },
+ { "label": "Quit", "command": "application:quit" }
+ ]
+ },
+
+ {
+ "label": "&Edit",
+ "submenu": [
+ { "label": "&Undo", "command": "core:undo" },
+ { "label": "&Redo", "command": "core:redo" },
+ { "type": "separator" },
+ { "label": "&Cut", "command": "core:cut" },
+ { "label": "C&opy", "command": "core:copy" },
+ { "label": "&Paste", "command": "core:paste" },
+ { "label": "Paste and Match Style", "command": "core:paste-and-match-style" },
+ { "label": "Select &All", "command": "core:select-all" },
+ { "type": "separator" },
+ { "label": "Find", "submenu": [
+ { "label": "Find in Thread...", "command": "core:find-in-thread" },
+ { "label": "Find Next", "command": "core:find-in-thread-next" },
+ { "label": "Find Previous", "command": "core:find-in-thread-previous" }
+ ]},
+ { "type": "separator" },
+ { "label": "Preferences", "command": "application:open-preferences" },
+ { "label": "Change Theme...", "command": "window:launch-theme-picker" },
+ { "label": "Install Theme...", "command": "application:install-package" }
+ ]
+ },
+
+ {
+ "label": "&View",
+ "submenu": [
+ { "type": "separator", "id": "mailbox-navigation"},
+ { "label": "Go to Inbox", "command": "navigation:go-to-inbox" },
+ { "label": "Go to Starred", "command": "navigation:go-to-starred" },
+ { "label": "Go to Sent", "command": "navigation:go-to-sent" },
+ { "label": "Go to Drafts", "command": "navigation:go-to-drafts" },
+ { "label": "Go to All mail", "command": "navigation:go-to-all" },
+ { "type": "separator" },
+ { "label": "Enter Full Screen", "command": "window:toggle-full-screen" },
+ { "label": "Exit Full Screen", "command": "window:toggle-full-screen", "visible": false }
+ ]
+ },
+
+ {
+ "label": "Thread",
+ "submenu": [
+ { "label": "Reply", "command": "core:reply" },
+ { "label": "Reply All", "command": "core:reply-all" },
+ { "label": "Forward", "command": "core:forward" },
+ { "type": "separator" },
+ { "label": "Star", "command": "core:star-item" },
+ { "type": "separator", "id": "thread-actions" },
+ { "label": "Remove from view", "command": "core:remove-from-view" },
+ { "type": "separator", "id": "view-actions" }
+ ]
+ },
+ {
+ "label": "Developer",
+ "submenu": [
+ { "label": "Run with &Debug Flags", "type": "checkbox", "command": "application:toggle-dev" },
+ { "type": "separator" },
+ { "label": "Reload", "command": "window:reload" },
+ { "label": "Toggle Developer &Tools", "command": "window:toggle-dev-tools" },
+ { "label": "Toggle Component Regions", "command": "window:toggle-component-regions" },
+ { "label": "Toggle Screenshot Mode", "command": "window:toggle-screenshot-mode" },
+ { "type": "separator" },
+ { "label": "Create a Plugin...", "command": "application:create-package" },
+ { "label": "Install a Plugin...", "command": "application:install-package" },
+ { "type": "separator" },
+ { "label": "Open Detailed Logs", "command": "window:open-errorlogger-logs" }
+ ]
+ },
+ {
+ "label": "Window",
+ "submenu": [
+ { "label": "Minimize", "command": "application:minimize" },
+ { "label": "Zoom", "command": "application:zoom" },
+ { "type": "separator", "id": "window-list-separator" }
+ ]
+ },
+ {
+ "label": "&Help",
+ "submenu": [
+ { "label": "VERSION", "enabled": false },
+ { "type": "separator" },
+ { "label": "Nylas Mail Help", "command": "application:view-help" }
+ ]
+ }
+ ]
+}
diff --git a/packages/client-app/menus/win32.json b/packages/client-app/menus/win32.json
new file mode 100644
index 0000000000..b740a66f57
--- /dev/null
+++ b/packages/client-app/menus/win32.json
@@ -0,0 +1,95 @@
+{
+ "menu": [
+ {
+ "label": "&Edit",
+ "submenu": [
+ { "label": "&Undo", "command": "core:undo" },
+ { "label": "&Redo", "command": "core:redo" },
+ { "type": "separator" },
+ { "label": "Cu&t", "command": "core:cut" },
+ { "label": "&Copy", "command": "core:copy" },
+ { "label": "&Paste", "command": "core:paste" },
+ { "label": "Paste and Match Style", "command": "core:paste-and-match-style" },
+ { "label": "Select &All", "command": "core:select-all" },
+ { "type": "separator" },
+ { "label": "Find", "submenu": [
+ { "label": "Find in Thread...", "command": "core:find-in-thread" },
+ { "label": "Find Next", "command": "core:find-in-thread-next" },
+ { "label": "Find Previous", "command": "core:find-in-thread-previous" }
+ ] }
+ ]
+ },
+
+ {
+ "label": "&View",
+ "submenu": [
+ { "type": "separator", "id": "mailbox-navigation"},
+ { "label": "Go to Inbox", "command": "navigation:go-to-inbox" },
+ { "label": "Go to Starred", "command": "navigation:go-to-starred" },
+ { "label": "Go to Sent", "command": "navigation:go-to-sent" },
+ { "label": "Go to Drafts", "command": "navigation:go-to-drafts" },
+ { "label": "Go to All mail", "command": "navigation:go-to-all" },
+ { "type": "separator" },
+ { "label": "Enter Full Screen", "command": "window:toggle-full-screen" },
+ { "label": "Exit Full Screen", "command": "window:toggle-full-screen", "visible": false }
+ ]
+ },
+
+ {
+ "label": "Thread",
+ "submenu": [
+ { "label": "Reply", "command": "core:reply" },
+ { "label": "Reply All", "command": "core:reply-all" },
+ { "label": "Forward", "command": "core:forward" },
+ { "type": "separator" },
+ { "label": "Star", "command": "core:star-item" },
+ { "type": "separator", "id": "thread-actions" },
+ { "label": "Remove from view", "command": "core:remove-from-view" },
+ { "type": "separator", "id": "view-actions" }
+ ]
+ },
+ {
+ "label": "Developer",
+ "submenu": [
+ { "label": "Run with &Debug Flags", "type": "checkbox", "command": "application:toggle-dev" },
+ { "type": "separator" },
+ { "label": "&Reload", "command": "window:reload" },
+ { "label": "Toggle Developer &Tools", "command": "window:toggle-dev-tools" },
+ { "label": "Toggle Component Regions", "command": "window:toggle-component-regions" },
+ { "label": "Toggle Screenshot Mode", "command": "window:toggle-screenshot-mode" },
+ { "type": "separator" },
+ { "label": "Create a Plugin...", "command": "application:create-package" },
+ { "label": "Install a Plugin...", "command": "application:install-package" },
+ { "type": "separator" },
+ { "label": "Open Detailed Logs", "command": "window:open-errorlogger-logs" }
+ ]
+ },
+ {
+ "label": "Window",
+ "submenu": [
+ { "label": "Minimize", "command": "application:minimize" },
+ { "label": "Zoom", "command": "application:zoom" },
+ { "type": "separator", "id": "window-list-separator" }
+ ]
+ },
+ { "type": "separator" },
+ {
+ "label": "&Help...",
+ "command": "application:view-help"
+ },
+ { "type": "separator" },
+ { "label": "Preferences", "command": "application:open-preferences" },
+ { "label": "Add Account...", "command": "application:add-account", "args": {"source": "Menu"}},
+ { "label": "Change Theme...", "command": "window:launch-theme-picker" },
+ { "label": "Install Theme...", "command": "application:install-package" },
+ { "type": "separator" },
+ { "label": "VERSION", "enabled": false },
+ { "label": "Restart and Install Update", "command": "application:install-update", "visible": false},
+ { "label": "Check for Update", "command": "application:check-for-update", "visible": false},
+ { "label": "Downloading Update", "enabled": false, "visible": false},
+ { "type": "separator" },
+ { "label": "Print Current Thread", "command": "core:print-thread" },
+ { "type": "separator" },
+ { "label": "E&xit", "command": "application:quit" }
+ ]
+}
diff --git a/packages/client-app/package.json b/packages/client-app/package.json
new file mode 100644
index 0000000000..6ec559df94
--- /dev/null
+++ b/packages/client-app/package.json
@@ -0,0 +1,141 @@
+{
+ "name": "nylas-mail",
+ "productName": "Nylas Mail",
+ "version": "2.0.15",
+ "description": "The best email app for people and teams at work",
+ "license": "GPL-3.0",
+ "main": "./src/browser/main.js",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/nylas/nylas-mail.git"
+ },
+ "bugs": {
+ "url": "https://github.com/nylas/nylas-mail/issues"
+ },
+ "dependencies": {
+ "analytics-node": "2.x.x",
+ "async": "^0.9",
+ "babel-core": "6.22.0",
+ "babel-preset-electron": "1.4.15",
+ "babel-preset-react": "6.22.0",
+ "babel-regenerator-runtime": "6.5.0",
+ "base64-stream": "0.1.3",
+ "better-sqlite3": "bengotow/better-sqlite3#a888061ad334c76d2db4c06554c90785cc6e7cce",
+ "bluebird": "3.4.x",
+ "chromium-net-errors": "1.0.3",
+ "chrono-node": "^1.1.2",
+ "classnames": "1.2.1",
+ "clearbit": "^1.2",
+ "coffee-react": "^5.0.0",
+ "coffee-script": "1.10.0",
+ "coffeestack": "^1.1",
+ "color": "^0.7.3",
+ "debug": "github:emorikawa/debug#nylas",
+ "electron": "1.4.15",
+ "electron-spellchecker": "1.0.1",
+ "emissary": "^1.3.1",
+ "emoji-data": "^0.2.0",
+ "encoding": "0.1.12",
+ "enzyme": "2.7.1",
+ "esdoc": "^0.5.2",
+ "esdoc-es7-plugin": "0.0.3",
+ "event-kit": "^1.0.2",
+ "fs-plus": "^2.3.2",
+ "getmac": "1.x.x",
+ "googleapis": "9.0.0",
+ "guid": "0.0.10",
+ "hapi": "16.1.0",
+ "hapi-auth-basic": "^4.2.0",
+ "hapi-boom-decorators": "2.2.2",
+ "hapi-plugin-websocket": "^0.9.2",
+ "hapi-swagger": "7.6.0",
+ "he": "1.1.0",
+ "iconv": "2.2.1",
+ "immutable": "3.7.5",
+ "inert": "4.0.0",
+ "is-online": "6.1.0",
+ "isomorphic-core": "0.x.x",
+ "jasmine-json": "~0.0",
+ "jasmine-react-helpers": "^0.2",
+ "jasmine-reporters": "1.x.x",
+ "jasmine-tagged": "^1.1.2",
+ "joi": "8.4.2",
+ "jsx-transform": "^2.3.0",
+ "juice": "^1.4",
+ "kbpgp": "^2.0.52",
+ "keytar": "3.0.0",
+ "less-cache": "0.21",
+ "lru-cache": "^4.0.1",
+ "marked": "^0.3",
+ "mimelib": "0.2.19",
+ "mkdirp": "^0.5",
+ "moment": "2.12.0",
+ "moment-round": "^1.0.1",
+ "moment-timezone": "0.5.4",
+ "mousetrap": "^1.5.3",
+ "nock": "^2",
+ "node-emoji": "^1.2.1",
+ "node-uuid": "^1.4",
+ "nslog": "^3",
+ "optimist": "0.4.0",
+ "papaparse": "^4.1.2",
+ "pathwatcher": "~6.2",
+ "pick-react-known-prop": "0.x.x",
+ "promise-queue": "2.1.1",
+ "property-accessors": "^1",
+ "proxyquire": "1.3.1",
+ "q": "^1.0.1",
+ "raven": "1.1.4",
+ "react": "15.4.2",
+ "react-addons-css-transition-group": "15.4.2",
+ "react-addons-perf": "15.4.2",
+ "react-addons-test-utils": "15.4.2",
+ "react-dom": "15.4.2",
+ "reflux": "0.1.13",
+ "request": "2.79.x",
+ "request-progress": "^0.3",
+ "rimraf": "2.5.2",
+ "runas": "^3.1",
+ "rx-lite": "4.0.8",
+ "rx-lite-testing": "^4.0.7",
+ "sanitize-html": "1.9.0",
+ "season": "^5.1",
+ "semver": "^4.2",
+ "sequelize": "nylas/sequelize#nylas-3.40.0",
+ "simplemde": "jstejada/simplemde-markdown-editor#input-style-support",
+ "source-map-support": "^0.3.2",
+ "sqlite3": "https://github.com/bengotow/node-sqlite3/archive/bengotow/usleep-v3.1.4.tar.gz",
+ "temp": "^0.8",
+ "tld": "^0.0.2",
+ "underscore": "1.8.x",
+ "underscore.string": "^3.0",
+ "vision": "4.1.0",
+ "windows-shortcuts": "emorikawa/windows-shortcuts#b0a0fc7"
+ },
+ "devDependencies": {
+ "donna": "^1.0.15",
+ "gitbook": "^3.2.2",
+ "gitbook-cli": "^2.3.0",
+ "gitbook-plugin-anchors": "^0.7.1",
+ "gitbook-plugin-editlink": "^1.0.2",
+ "gitbook-plugin-favicon": "0.0.2",
+ "gitbook-plugin-github": "^2.0.0",
+ "gitbook-plugin-theme-api": "^1.1.2",
+ "handlebars": "4.0.6",
+ "joanna": "0.0.8",
+ "meta-marked": "0.4.2",
+ "tello": "1.0.6"
+ },
+ "optionalDependencies": {
+ "node-mac-notifier": "0.0.13"
+ },
+ "packageDependencies": {},
+ "scripts": {
+ "test": "electron . --test --enable-logging",
+ "test-window": "electron . --test=window --enable-logging",
+ "test-junit": "electron . --test --enable-logging --junit-xml=junitxml",
+ "start": "electron . --dev --enable-logging",
+ "lint": "script/grunt lint",
+ "build": "script/grunt build"
+ }
+}
diff --git a/packages/client-app/script/grunt b/packages/client-app/script/grunt
new file mode 100755
index 0000000000..ddf19bd517
--- /dev/null
+++ b/packages/client-app/script/grunt
@@ -0,0 +1,17 @@
+#!/usr/bin/env node
+var cp = require('./utils/child-process-wrapper.js');
+var fs = require('fs');
+var path = require('path');
+
+// node build/node_modules/.bin/grunt "$@"
+var gruntPath = path.resolve(__dirname, '..', 'build', 'node_modules', '.bin', 'grunt') + (process.platform === 'win32' ? '.cmd' : '');
+
+if (!fs.existsSync(gruntPath)) {
+ console.error('Grunt command does not exist at: ' + gruntPath);
+ console.error('Run script/bootstrap to install Grunt');
+ process.exit(1);
+}
+
+var args = ['--gruntfile', path.resolve('build', 'Gruntfile.js')];
+args = args.concat(process.argv.slice(2));
+cp.safeSpawn(gruntPath, args, process.exit);
diff --git a/packages/client-app/script/grunt.cmd b/packages/client-app/script/grunt.cmd
new file mode 100644
index 0000000000..854fceb037
--- /dev/null
+++ b/packages/client-app/script/grunt.cmd
@@ -0,0 +1,5 @@
+@IF EXIST "%~dp0\node.exe" (
+ "%~dp0\node.exe" "%~dp0\grunt" %*
+) ELSE (
+ node "%~dp0\grunt" %*
+)
diff --git a/packages/client-app/script/mkdeb b/packages/client-app/script/mkdeb
new file mode 100755
index 0000000000..fe9b005314
--- /dev/null
+++ b/packages/client-app/script/mkdeb
@@ -0,0 +1,65 @@
+#!/bin/bash
+# mkdeb version arch control-file-path desktop-file-path icon-file-path sources-file-path deb-file-path
+
+set -e
+
+SCRIPT=`readlink -f "$0"`
+ROOT=`readlink -f $(dirname $SCRIPT)/..`
+cd $ROOT
+
+VERSION="$1"
+ARCH="$2"
+ICON_FILE="$3"
+LINUX_ASSETS_DIRECTORY="$4"
+APP_CONTENTS_DIRECTORY="$5"
+OUTPUT_PATH="$6"
+
+FILE_MODE=755
+
+TARGET_ROOT="`mktemp -d`"
+chmod $FILE_MODE "$TARGET_ROOT"
+TARGET="$TARGET_ROOT/nylas-$VERSION-$ARCH"
+
+mkdir -m $FILE_MODE -p "$TARGET/usr"
+mkdir -m $FILE_MODE -p "$TARGET/usr/share"
+cp -r "$APP_CONTENTS_DIRECTORY" "$TARGET/usr/share/nylas-mail"
+
+mkdir -m $FILE_MODE -p "$TARGET/DEBIAN"
+cp "$OUTPUT_PATH/control" "$TARGET/DEBIAN/control"
+
+cp "$LINUX_ASSETS_DIRECTORY/debian/postinst" "$TARGET/DEBIAN/postinst"
+cp "$LINUX_ASSETS_DIRECTORY/debian/postrm" "$TARGET/DEBIAN/postrm"
+
+mkdir -m $FILE_MODE -p "$TARGET/usr/bin"
+ln -s "../share/nylas-mail/nylas" "$TARGET/usr/bin/nylas-mail"
+chmod +x "$TARGET/usr/bin/nylas-mail"
+
+mkdir -m $FILE_MODE -p "$TARGET/usr/share/applications"
+cp "$OUTPUT_PATH/nylas-mail.desktop" "$TARGET/usr/share/applications"
+
+mkdir -m $FILE_MODE -p "$TARGET/usr/share/pixmaps"
+cp "$ICON_FILE" "$TARGET/usr/share/pixmaps/nylas-mail.png"
+
+mkdir -m $FILE_MODE -p "$TARGET/usr/share/icons/hicolor"
+for i in 256 128 64 32 16; do
+ mkdir -p "$TARGET/usr/share/icons/hicolor/${i}x${i}/apps"
+ cp "$LINUX_ASSETS_DIRECTORY/icons/${i}.png" "$TARGET/usr/share/icons/hicolor/${i}x${i}/apps/nylas-mail.png"
+done
+
+# Copy generated LICENSE.md to /usr/share/doc/nylas-mail/copyright
+mkdir -m $FILE_MODE -p "$TARGET/usr/share/doc/nylas-mail"
+cp "$TARGET/usr/share/nylas-mail/LICENSE" "$TARGET/usr/share/doc/nylas-mail/copyright"
+
+# Add lintian overrides
+mkdir -m $FILE_MODE -p "$TARGET/usr/share/lintian/overrides"
+cp "$ROOT/build/resources/linux/debian/lintian-overrides" "$TARGET/usr/share/lintian/overrides/nylas-mail"
+
+# Remove group write from all files
+chmod -R g-w "$TARGET";
+
+# Remove executable bit from .node files
+find "$TARGET" -type f -name "*.node" -exec chmod a-x {} \;
+
+fakeroot dpkg-deb -b "$TARGET"
+mv "$TARGET_ROOT/nylas-$VERSION-$ARCH.deb" "$OUTPUT_PATH"
+rm -rf "$TARGET_ROOT"
diff --git a/packages/client-app/script/mkrpm b/packages/client-app/script/mkrpm
new file mode 100755
index 0000000000..c542b9e6fa
--- /dev/null
+++ b/packages/client-app/script/mkrpm
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+set -e
+
+BUILD_DIRECTORY="$1"
+APP_CONTENTS_DIRECTORY="$2"
+LINUX_ASSETS_DIRECTORY="$3"
+
+RPM_BUILD_ROOT=~/rpmbuild
+ARCH=`uname -m`
+
+# Work around for `uname -m` returning i686 when rpmbuild uses i386 instead
+if [ "$ARCH" = "i686" ]; then
+ ARCH="i386"
+fi
+
+# rpmdev-setuptree
+mkdir -p $RPM_BUILD_ROOT/BUILD
+mkdir -p $RPM_BUILD_ROOT/SPECS
+mkdir -p $RPM_BUILD_ROOT/RPMS
+
+cp -r "$APP_CONTENTS_DIRECTORY/"* "$RPM_BUILD_ROOT/BUILD"
+cp -r "$LINUX_ASSETS_DIRECTORY/icons" "$RPM_BUILD_ROOT/BUILD"
+cp "$BUILD_DIRECTORY/nylas.spec" "$RPM_BUILD_ROOT/SPECS"
+cp "$BUILD_DIRECTORY/nylas-mail.desktop" "$RPM_BUILD_ROOT/BUILD"
+
+rpmbuild -ba "$BUILD_DIRECTORY/nylas.spec"
+cp $RPM_BUILD_ROOT/RPMS/$ARCH/nylas-*.rpm "$BUILD_DIRECTORY"
+
+rm -rf "$RPM_BUILD_ROOT"
diff --git a/packages/client-app/script/publish-docs b/packages/client-app/script/publish-docs
new file mode 100755
index 0000000000..33383596f4
--- /dev/null
+++ b/packages/client-app/script/publish-docs
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+set -e
+
+# Builds docs and moves the output to gh-pages branch (overwrites)
+mkdir -p _docs_output
+script/grunt docs
+./node_modules/.bin/gitbook --gitbook=latest build . ./_docs_output --log=debug --debug
+rm -r docs_src/classes
+git checkout gh-pages --quiet
+cp -rf _docs_output/* .
+# rm -r _docs_output
+
+git add .
+git status -s
+printf "\nDocs updated! \n\n"
+git commit -m 'Update Docs'
+git push origin gh-pages
+git checkout master
diff --git a/packages/client-app/script/utils/child-process-wrapper.js b/packages/client-app/script/utils/child-process-wrapper.js
new file mode 100644
index 0000000000..095d431d07
--- /dev/null
+++ b/packages/client-app/script/utils/child-process-wrapper.js
@@ -0,0 +1,46 @@
+var childProcess = require('child_process');
+
+// Exit the process if the command failed and only call the callback if the
+// command succeed, output of the command would also be piped.
+exports.safeExec = function(command, options, callback) {
+ if (!callback) {
+ callback = options;
+ options = {};
+ }
+ if (!options)
+ options = {};
+
+ // This needed to be increased for `apm test` runs that generate many failures
+ // The default is 200KB.
+ options.maxBuffer = 1024 * 1024;
+
+ options.stdio = "inherit"
+ var child = childProcess.exec(command, options, function(error, stdout, stderr) {
+ if (error && !options.ignoreStderr)
+ process.exit(error.code || 1);
+ else
+ callback(null);
+ });
+ child.stderr.pipe(process.stderr);
+ if (!options.ignoreStdout)
+ child.stdout.pipe(process.stdout);
+}
+
+// Same with safeExec but call child_process.spawn instead.
+exports.safeSpawn = function(command, args, options, callback) {
+ if (!callback) {
+ callback = options;
+ options = {};
+ }
+ options.stdio = "inherit"
+ var child = childProcess.spawn(command, args, options);
+ child.on('error', function(error) {
+ console.error('Command \'' + command + '\' failed: ' + error.message);
+ });
+ child.on('exit', function(code) {
+ if (code != 0)
+ process.exit(code);
+ else
+ callback(null);
+ });
+}
diff --git a/packages/client-app/spec/action-bridge-spec.coffee b/packages/client-app/spec/action-bridge-spec.coffee
new file mode 100644
index 0000000000..c962efa8fb
--- /dev/null
+++ b/packages/client-app/spec/action-bridge-spec.coffee
@@ -0,0 +1,101 @@
+Reflux = require 'reflux'
+Actions = require('../src/flux/actions').default
+Message = require('../src/flux/models/message').default
+DatabaseStore = require('../src/flux/stores/database-store').default
+AccountStore = require('../src/flux/stores/account-store').default
+ActionBridge = require('../src/flux/action-bridge').default
+_ = require 'underscore'
+
+ipc =
+ on: ->
+ send: ->
+
+describe "ActionBridge", ->
+
+ describe "in the work window", ->
+ beforeEach ->
+ spyOn(NylasEnv, "getWindowType").andReturn "default"
+ spyOn(NylasEnv, "isWorkWindow").andReturn true
+ @bridge = new ActionBridge(ipc)
+
+ it "should have the role Role.WORK", ->
+ expect(@bridge.role).toBe(ActionBridge.Role.WORK)
+
+ it "should rebroadcast global actions", ->
+ spyOn(@bridge, 'onRebroadcast')
+ testAction = Actions[Actions.globalActions[0]]
+ testAction('bla')
+ expect(@bridge.onRebroadcast).toHaveBeenCalled()
+
+ it "should rebroadcast when the DatabaseStore triggers", ->
+ spyOn(@bridge, 'onRebroadcast')
+ DatabaseStore.trigger({})
+ expect(@bridge.onRebroadcast).toHaveBeenCalled()
+
+ it "should not rebroadcast mainWindow actions since it is the main window", ->
+ spyOn(@bridge, 'onRebroadcast')
+ testAction = Actions.didMakeAPIRequest
+ testAction('bla')
+ expect(@bridge.onRebroadcast).not.toHaveBeenCalled()
+
+ it "should not rebroadcast window actions", ->
+ spyOn(@bridge, 'onRebroadcast')
+ testAction = Actions[Actions.windowActions[0]]
+ testAction('bla')
+ expect(@bridge.onRebroadcast).not.toHaveBeenCalled()
+
+ describe "in another window", ->
+ beforeEach ->
+ spyOn(NylasEnv, "getWindowType").andReturn "popout"
+ spyOn(NylasEnv, "isWorkWindow").andReturn false
+ @bridge = new ActionBridge(ipc)
+ @message = new Message
+ id: 'test-id'
+ accountId: TEST_ACCOUNT_ID
+
+ it "should have the role Role.SECONDARY", ->
+ expect(@bridge.role).toBe(ActionBridge.Role.SECONDARY)
+
+ it "should rebroadcast global actions", ->
+ spyOn(@bridge, 'onRebroadcast')
+ testAction = Actions[Actions.globalActions[0]]
+ testAction('bla')
+ expect(@bridge.onRebroadcast).toHaveBeenCalled()
+
+ it "should rebroadcast mainWindow actions", ->
+ spyOn(@bridge, 'onRebroadcast')
+ testAction = Actions.didMakeAPIRequest
+ testAction('bla')
+ expect(@bridge.onRebroadcast).toHaveBeenCalled()
+
+ it "should not rebroadcast window actions", ->
+ spyOn(@bridge, 'onRebroadcast')
+ testAction = Actions[Actions.windowActions[0]]
+ testAction('bla')
+ expect(@bridge.onRebroadcast).not.toHaveBeenCalled()
+
+ describe "onRebroadcast", ->
+ beforeEach ->
+ spyOn(NylasEnv, "getWindowType").andReturn "popout"
+ spyOn(NylasEnv, "isMainWindow").andReturn false
+ @bridge = new ActionBridge(ipc)
+
+ describe "when called with TargetWindows.ALL", ->
+ it "should broadcast the action over IPC to all windows", ->
+ spyOn(ipc, 'send')
+ Actions.onNewMailDeltas.firing = false
+ @bridge.onRebroadcast(ActionBridge.TargetWindows.ALL, 'onNewMailDeltas', [{oldModel: '1', newModel: 2}])
+ expect(ipc.send).toHaveBeenCalledWith('action-bridge-rebroadcast-to-all', 'popout', 'onNewMailDeltas', '[{"oldModel":"1","newModel":2}]')
+
+ describe "when called with TargetWindows.WORK", ->
+ it "should broadcast the action over IPC to the main window only", ->
+ spyOn(ipc, 'send')
+ Actions.onNewMailDeltas.firing = false
+ @bridge.onRebroadcast(ActionBridge.TargetWindows.WORK, 'onNewMailDeltas', [{oldModel: '1', newModel: 2}])
+ expect(ipc.send).toHaveBeenCalledWith('action-bridge-rebroadcast-to-work', 'popout', 'onNewMailDeltas', '[{"oldModel":"1","newModel":2}]')
+
+ it "should not do anything if the current invocation of the Action was triggered by itself", ->
+ spyOn(ipc, 'send')
+ Actions.onNewMailDeltas.firing = true
+ @bridge.onRebroadcast(ActionBridge.TargetWindows.ALL, 'onNewMailDeltas', [{oldModel: '1', newModel: 2}])
+ expect(ipc.send).not.toHaveBeenCalled()
diff --git a/packages/client-app/spec/async-test-spec.es6 b/packages/client-app/spec/async-test-spec.es6
new file mode 100644
index 0000000000..4bb93a8015
--- /dev/null
+++ b/packages/client-app/spec/async-test-spec.es6
@@ -0,0 +1,27 @@
+const foo = () => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ console.log("---------------------------------- RESOLVING")
+ resolve()
+ }, 100)
+ })
+}
+
+xdescribe("test spec", function testSpec() {
+ // it("has 1 failure", () => {
+ // expect(false).toBe(true)
+ // });
+
+ it("is async", () => {
+ const p = foo().then(() => {
+ console.log("THEN")
+ expect(true).toBe(true)
+ })
+ advanceClock(200);
+ return p
+ });
+
+ // it("has another failure", () => {
+ // expect(false).toBe(true)
+ // });
+});
diff --git a/packages/client-app/spec/auto-update-manager-spec.coffee b/packages/client-app/spec/auto-update-manager-spec.coffee
new file mode 100644
index 0000000000..4fde97c313
--- /dev/null
+++ b/packages/client-app/spec/auto-update-manager-spec.coffee
@@ -0,0 +1,66 @@
+AutoUpdateManager = require('../src/browser/auto-update-manager').default
+url = require 'url'
+
+describe "AutoUpdateManager", ->
+ beforeEach ->
+ @nylasIdentityId = null
+ @accounts = [{email_address: 'ben@nylas.com'},{email_address: 'mark@nylas.com'}]
+ @specMode = true
+ @databaseReader =
+ getJSONBlob: => {
+ id: @nylasIdentityId
+ }
+ @config =
+ set: jasmine.createSpy('config.set')
+ get: (key) =>
+ if key is 'nylas.accounts'
+ return @accounts
+ if key is 'env'
+ return 'production'
+ onDidChange: (key, callback) =>
+ callback()
+
+ describe "with attached commit version", ->
+ it "correctly sets the feedURL", ->
+ m = new AutoUpdateManager("3.222.1-abc", @config, @specMode, @databaseReader)
+ spyOn(m, "setupAutoUpdater")
+
+ {query} = url.parse(m.feedURL, true)
+ expect(query.arch).toBe process.arch
+ expect(query.platform).toBe process.platform
+ expect(query.version).toBe "3.222.1-abc"
+
+ describe "with no attached commit", ->
+ it "correctly sets the feedURL", ->
+ m = new AutoUpdateManager("3.222.1", @config, @specMode, @databaseReader)
+ spyOn(m, "setupAutoUpdater")
+ {query} = url.parse(m.feedURL, true)
+ expect(query.arch).toBe process.arch
+ expect(query.platform).toBe process.platform
+ expect(query.version).toBe "3.222.1"
+
+ describe "when an update identity is not present", ->
+ it "should use anonymous", ->
+ m = new AutoUpdateManager("3.222.1", @config, @specMode, @databaseReader)
+ spyOn(m, "setupAutoUpdater")
+ {query} = url.parse(m.feedURL, true)
+ expect(query.id).toEqual('anonymous')
+
+ describe "when an update identity is already set", ->
+ it "should send it and not save any changes", ->
+ @nylasIdentityId = "test-nylas-id"
+ m = new AutoUpdateManager("3.222.1", @config, @specMode, @databaseReader)
+ spyOn(m, "setupAutoUpdater")
+ {query} = url.parse(m.feedURL, true)
+ expect(query.id).toEqual(@nylasIdentityId)
+
+ describe "when an update identity is added", ->
+ it "should update the feed URL", ->
+ m = new AutoUpdateManager("3.222.1", @config, @specMode, @databaseReader)
+ spyOn(m, "setupAutoUpdater")
+ {query} = url.parse(m.feedURL, true)
+ expect(query.id).toEqual('anonymous')
+ @nylasIdentityId = '1'
+ m.updateFeedURL()
+ {query} = url.parse(m.feedURL, true)
+ expect(query.id).toEqual(@nylasIdentityId)
diff --git a/packages/client-app/spec/buffered-process-spec.coffee b/packages/client-app/spec/buffered-process-spec.coffee
new file mode 100644
index 0000000000..8794fc22b3
--- /dev/null
+++ b/packages/client-app/spec/buffered-process-spec.coffee
@@ -0,0 +1,75 @@
+ChildProcess = require 'child_process'
+path = require 'path'
+BufferedProcess = require '../src/buffered-process'
+
+describe "BufferedProcess", ->
+ describe "when a bad command is specified", ->
+ [oldOnError] = []
+ beforeEach ->
+ oldOnError = window.onerror
+ window.onerror = jasmine.createSpy()
+
+ afterEach ->
+ window.onerror = oldOnError
+
+ describe "when there is an error handler specified", ->
+ it "calls the error handler and does not throw an exception", ->
+ p = new BufferedProcess
+ command: 'bad-command-nope'
+ args: ['nothing']
+ options: {}
+
+ errorSpy = jasmine.createSpy().andCallFake (error) -> error.handle()
+ p.onWillThrowError(errorSpy)
+
+ waitsFor -> errorSpy.callCount > 0
+
+ runs ->
+ expect(window.onerror).not.toHaveBeenCalled()
+ expect(errorSpy).toHaveBeenCalled()
+ expect(errorSpy.mostRecentCall.args[0].error.message).toContain 'spawn bad-command-nope ENOENT'
+
+ # describe "when there is not an error handler specified", ->
+ # it "calls the error handler and does not throw an exception", ->
+ # spyOn(process, "nextTick").andCallFake (fn) -> fn()
+ #
+ # try
+ # p = new BufferedProcess
+ # command: 'bad-command-nope'
+ # args: ['nothing']
+ # options: {stdout: 'ignore'}
+ #
+ # catch error
+ # expect(error.message).toContain 'Failed to spawn command `bad-command-nope`'
+ # expect(error.name).toBe 'BufferedProcessError'
+
+ describe "on Windows", ->
+ originalPlatform = null
+
+ beforeEach ->
+ # Prevent any commands from actually running and affecting the host
+ originalSpawn = ChildProcess.spawn
+ spyOn(ChildProcess, 'spawn').andCallFake ->
+ # Just spawn something that won't actually modify the host
+ if originalPlatform is 'win32'
+ originalSpawn('dir')
+ else
+ originalSpawn('ls')
+
+ originalPlatform = process.platform
+ Object.defineProperty process, 'platform', value: 'win32'
+
+ afterEach ->
+ Object.defineProperty process, 'platform', value: originalPlatform
+
+ describe "when the explorer command is spawned on Windows", ->
+ it "doesn't quote arguments of the form /root,C...", ->
+ new BufferedProcess({command: 'explorer.exe', args: ['/root,C:\\foo']})
+ expect(ChildProcess.spawn.argsForCall[0][1][2]).toBe '"explorer.exe /root,C:\\foo"'
+
+ it "spawns the command using a cmd.exe wrapper", ->
+ new BufferedProcess({command: 'dir'})
+ expect(path.basename(ChildProcess.spawn.argsForCall[0][0])).toBe 'cmd.exe'
+ expect(ChildProcess.spawn.argsForCall[0][1][0]).toBe '/s'
+ expect(ChildProcess.spawn.argsForCall[0][1][1]).toBe '/c'
+ expect(ChildProcess.spawn.argsForCall[0][1][2]).toBe '"dir"'
diff --git a/packages/client-app/spec/components/blockquote-manager-spec.es6 b/packages/client-app/spec/components/blockquote-manager-spec.es6
new file mode 100644
index 0000000000..6f59fc8690
--- /dev/null
+++ b/packages/client-app/spec/components/blockquote-manager-spec.es6
@@ -0,0 +1,126 @@
+import { DOMUtils } from 'nylas-exports';
+import BlockquoteManager from '../../src/components/contenteditable/blockquote-manager';
+
+describe("BlockquoteManager", function BlockquoteManagerSpecs() {
+ const outdentCases = [`
+ |
+ `,
+ `
+
+ |
+
+ `,
+ `
+
+ \n
+ |
+ `,
+ `
+
+
+
+ |
+ `,
+ `
+
+ `,
+ `
+
+
+ |
+
+ `,
+ `
+
+ yo
+
+
+
+
+ |test
+
+ `,
+ ]
+
+ const backspaceCases = [`
+ yo|
+ `,
+ `
+
+ yo
+ |
+
+ `,
+ `
+
+
+ |
+ `,
+ `
+
+
+ yo
+ |
+ `,
+ `
+
+ `,
+ `
+
+ yo
+ |
+
+ `,
+ `
+
+ yo
+
+
+ yo
+
+ |test
+
+ `,
+ ]
+
+ const setupContext = (testCase) => {
+ const context = document.createElement("blockquote");
+ context.innerHTML = testCase;
+ const {node, index} = DOMUtils.findCharacter(context, "|");
+ if (!node) {
+ throw new Error("Couldn't find where to set Selection");
+ }
+ const mockSelection = {
+ isCollapsed: true,
+ anchorNode: node,
+ anchorOffset: index,
+ };
+ return mockSelection;
+ };
+
+ outdentCases.forEach(testCase =>
+ it(`outdents\n${testCase}`, () => {
+ const mockSelection = setupContext(testCase);
+ const editor = {currentSelection() { return mockSelection; }};
+ expect(BlockquoteManager._isInBlockquote(editor)).toBe(true);
+ return expect(BlockquoteManager._isAtStartOfLine(editor)).toBe(true);
+ })
+ );
+
+ return backspaceCases.forEach(testCase =>
+ it(`backspaces (does NOT outdent)\n${testCase}`, () => {
+ const mockSelection = setupContext(testCase);
+ const editor = {currentSelection() { return mockSelection; }};
+ expect(BlockquoteManager._isInBlockquote(editor)).toBe(true);
+ return expect(BlockquoteManager._isAtStartOfLine(editor)).toBe(false);
+ })
+ );
+});
diff --git a/packages/client-app/spec/components/clipboard-service-spec.coffee b/packages/client-app/spec/components/clipboard-service-spec.coffee
new file mode 100644
index 0000000000..20d8ee0f33
--- /dev/null
+++ b/packages/client-app/spec/components/clipboard-service-spec.coffee
@@ -0,0 +1,125 @@
+ClipboardService = require('../../src/components/contenteditable/clipboard-service').default
+{InlineStyleTransformer, SanitizeTransformer} = require 'nylas-exports'
+fs = require 'fs'
+
+describe "ClipboardService", ->
+ beforeEach ->
+ @onFilePaste = jasmine.createSpy('onFilePaste')
+ @setInnerState = jasmine.createSpy('setInnerState')
+ @clipboardService = new ClipboardService
+ data: {props: {@onFilePaste}}
+ methods: {@setInnerState}
+
+ spyOn(document, 'execCommand')
+
+ describe "when both html and plain text parts are present", ->
+ beforeEach ->
+ @mockEvent =
+ preventDefault: jasmine.createSpy('preventDefault')
+ clipboardData:
+ getData: (mimetype) ->
+ return 'This is text ' if mimetype is 'text/html'
+ return 'This is plain text' if mimetype is 'text/plain'
+ return null
+ items: [{
+ kind: 'string'
+ type: 'text/html'
+ getAsString: -> 'This is text '
+ },{
+ kind: 'string'
+ type: 'text/plain'
+ getAsString: -> 'This is plain text'
+ }]
+
+ it "should choose to insert the HTML representation", ->
+ spyOn(@clipboardService, '_sanitizeHTMLInput').andCallFake (input) =>
+ Promise.resolve(input)
+
+ runs ->
+ @clipboardService.onPaste(@mockEvent)
+ waitsFor ->
+ document.execCommand.callCount > 0
+ runs ->
+ [command, a, html] = document.execCommand.mostRecentCall.args
+ expect(command).toEqual('insertHTML')
+ expect(html).toEqual('This is text ')
+
+ describe "when only plain text is present", ->
+ beforeEach ->
+ @mockEvent =
+ preventDefault: jasmine.createSpy('preventDefault')
+ clipboardData:
+ getData: (mimetype) ->
+ return 'This is plain text\nAnother line Hello World' if mimetype is 'text/plain'
+ return null
+ items: [{
+ kind: 'string'
+ type: 'text/plain'
+ getAsString: -> 'This is plain text\nAnother line Hello World'
+ }]
+
+ it "should convert the plain text to HTML and call insertHTML", ->
+ runs ->
+ @clipboardService.onPaste(@mockEvent)
+ waitsFor ->
+ document.execCommand.callCount > 0
+ runs ->
+ [command, a, html] = document.execCommand.mostRecentCall.args
+ expect(command).toEqual('insertHTML')
+ expect(html).toEqual('This is plain text Another line Hello World')
+
+ describe "HTML sanitization", ->
+ beforeEach ->
+ spyOn(InlineStyleTransformer, 'run').andCallThrough()
+ spyOn(SanitizeTransformer, 'run').andCallThrough()
+
+ it "should inline CSS styles and run the standard permissive HTML sanitizer", ->
+ input = "HTML HERE"
+ waitsForPromise =>
+ @clipboardService._sanitizeHTMLInput(input)
+ .then =>
+ expect(InlineStyleTransformer.run).toHaveBeenCalledWith(input)
+ expect(SanitizeTransformer.run).toHaveBeenCalledWith(input, SanitizeTransformer.Preset.Permissive)
+
+ it "should replace two or more s in a row", ->
+ tests = [{
+ in: "Hello\n\n\nWorld"
+ out: "Hello World"
+ },{
+ in: "Hello World"
+ out: "Hello World"
+ }]
+ for test in tests
+ waitsForPromise =>
+ @clipboardService._sanitizeHTMLInput(test.in).then (out) ->
+ expect(out).toBe(test.out)
+
+
+ it "should remove all leading and trailing s from the text", ->
+ tests = [{
+ in: " Hello World"
+ out: "Hello World"
+ },{
+ in: " Hello "
+ out: "Hello"
+ }]
+ for test in tests
+ waitsForPromise =>
+ @clipboardService._sanitizeHTMLInput(test.in).then (out) ->
+ expect(out).toBe(test.out)
+
+ # Unfortunately, it doesn't seem we can do real IPC (to `juice` in the main process)
+ # so these tests are non-functional.
+ xdescribe "real-world examples", ->
+ it "should produce the correct output", ->
+ scenarios = []
+ fixtures = path.resolve('./spec/fixtures/paste')
+ for filename in fs.readdirSync(fixtures)
+ if filename[-8..-1] is '-in.html'
+ scenarios.push
+ in: fs.readFileSync(path.join(fixtures, filename)).toString()
+ out: fs.readFileSync(path.join(fixtures, "#{filename[0..-9]}-out.html")).toString()
+
+ scenarios.forEach (scenario) =>
+ @clipboardService._sanitizeHTMLInput(scenario.in).then (out) ->
+ expect(out).toBe(scenario.out)
diff --git a/packages/client-app/spec/components/contenteditable-component-spec.cjsx b/packages/client-app/spec/components/contenteditable-component-spec.cjsx
new file mode 100644
index 0000000000..872f86094b
--- /dev/null
+++ b/packages/client-app/spec/components/contenteditable-component-spec.cjsx
@@ -0,0 +1,84 @@
+# This tests the basic Contenteditable component. For various modules of
+# the contenteditable (such as selection, tooltip, quoting, etc) see the
+# related test files.
+#
+_ = require "underscore"
+fs = require 'fs'
+React = require "react"
+ReactDOM = require 'react-dom'
+ReactTestUtils = require('react-addons-test-utils')
+Contenteditable = require "../../src/components/contenteditable/contenteditable",
+
+describe "Contenteditable", ->
+ beforeEach ->
+ @onChange = jasmine.createSpy('onChange')
+ html = 'Test HTML '
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+ @editableNode = ReactDOM.findDOMNode(@component).querySelector('[contenteditable]')
+
+ describe "render", ->
+ it 'should render into the document', ->
+ expect(ReactTestUtils.isCompositeComponentWithType @component, Contenteditable).toBe true
+
+ it "should include a content-editable div", ->
+ expect(@editableNode).toBeDefined()
+
+ describe "when the html is changed", ->
+ beforeEach ->
+ @changedHtmlWithoutQuote = 'Changed NEW 1 HTML '
+
+ @performEdit = (newHTML, component = @component) =>
+ @editableNode.innerHTML = newHTML
+
+ it "should fire `props.onChange`", ->
+ runs =>
+ @performEdit('Test New HTML ')
+ waitsFor =>
+ @onChange.calls.length > 0
+ runs =>
+ expect(@onChange).toHaveBeenCalled()
+
+ # One day we may make this more efficient. For now we aggressively
+ # re-render because of the manual cursor positioning.
+ it "should fire if the html is the same", ->
+ expect(@onChange.callCount).toBe(0)
+ runs =>
+ @performEdit(@changedHtmlWithoutQuote)
+ @performEdit(@changedHtmlWithoutQuote)
+ waitsFor =>
+ @onChange.callCount > 0
+ runs =>
+ expect(@onChange).toHaveBeenCalled()
+
+ describe "pasting", ->
+ beforeEach ->
+
+ describe "when a file item is present", ->
+ beforeEach ->
+ @mockEvent =
+ preventDefault: jasmine.createSpy('preventDefault')
+ clipboardData:
+ items: [{
+ kind: 'file'
+ type: 'image/png'
+ getAsFile: -> new Blob(['12341352312411'], {type : 'image/png'})
+ }]
+
+ it "should save the image to a temporary file and call `onFilePaste`", ->
+ onPaste = jasmine.createSpy('onPaste')
+ @component = ReactTestUtils.renderIntoDocument(
+
+ )
+ @editableNode = ReactDOM.findDOMNode(@component).querySelector('[contenteditable]')
+ runs ->
+ ReactTestUtils.Simulate.paste(@editableNode, @mockEvent)
+ waitsFor ->
+ onPaste.callCount > 0
+ runs ->
+ path = require('path')
+ file = onPaste.mostRecentCall.args[0]
+ expect(path.basename(file)).toEqual('Pasted File.png')
+ contents = fs.readFileSync(file)
+ expect(contents.toString()).toEqual('12341352312411')
diff --git a/packages/client-app/spec/components/date-input-spec.jsx b/packages/client-app/spec/components/date-input-spec.jsx
new file mode 100644
index 0000000000..032bc675fe
--- /dev/null
+++ b/packages/client-app/spec/components/date-input-spec.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {
+ Simulate,
+ findRenderedDOMComponentWithClass,
+} from 'react-addons-test-utils';
+
+import {DateUtils} from 'nylas-exports'
+import DateInput from '../../src/components/date-input';
+import {renderIntoDocument} from '../nylas-test-utils'
+
+const {findDOMNode} = ReactDOM;
+
+const makeInput = (props = {}) => {
+ const input = renderIntoDocument( );
+ if (props.initialState) {
+ input.setState(props.initialState)
+ }
+ return input
+};
+
+describe('DateInput', function dateInput() {
+ describe('onInputKeyDown', () => {
+ it('should submit the input if Enter or Escape pressed', () => {
+ const onDateSubmitted = jasmine.createSpy('onDateSubmitted')
+ const component = makeInput({onDateSubmitted: onDateSubmitted})
+ const inputNode = ReactDOM.findDOMNode(component).querySelector('input')
+ const stopPropagation = jasmine.createSpy('stopPropagation')
+ const keys = ['Enter', 'Return']
+ inputNode.value = 'tomorrow'
+ spyOn(DateUtils, 'futureDateFromString').andReturn('someday')
+
+ keys.forEach((key) => {
+ Simulate.keyDown(inputNode, {key, stopPropagation})
+ expect(stopPropagation).toHaveBeenCalled()
+ expect(onDateSubmitted).toHaveBeenCalledWith('someday', 'tomorrow')
+ stopPropagation.reset()
+ onDateSubmitted.reset()
+ })
+ });
+ });
+
+ describe('render', () => {
+ beforeEach(() => {
+ spyOn(DateUtils, 'format').andReturn('formatted')
+ });
+
+ it('should render a date interpretation if a date has been inputted', () => {
+ const component = makeInput({initialState: {inputDate: 'something!'}})
+ spyOn(component, 'setState')
+ const dateInterpretation = findDOMNode(findRenderedDOMComponentWithClass(component, 'date-interpretation'))
+
+ expect(dateInterpretation.textContent).toEqual('formatted')
+ });
+
+ it('should not render a date interpretation if no input date available', () => {
+ const component = makeInput({initialState: {inputDate: null}})
+ spyOn(component, 'setState')
+ expect(() => {
+ findRenderedDOMComponentWithClass(component, 'date-interpretation')
+ }).toThrow()
+ });
+ });
+});
diff --git a/packages/client-app/spec/components/date-picker-popover-spec.jsx b/packages/client-app/spec/components/date-picker-popover-spec.jsx
new file mode 100644
index 0000000000..c03d6a4774
--- /dev/null
+++ b/packages/client-app/spec/components/date-picker-popover-spec.jsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import {mount} from 'enzyme'
+import {DateUtils} from 'nylas-exports'
+import {DatePickerPopover} from 'nylas-component-kit'
+
+
+const makePopover = (props = {}) => {
+ return mount(
+ my header}
+ onSelectDate={() => {}}
+ {...props}
+ />
+ );
+};
+
+describe('DatePickerPopover', function sendLaterPopover() {
+ beforeEach(() => {
+ spyOn(DateUtils, 'format').andReturn('formatted')
+ });
+
+ describe('selectDate', () => {
+ it('calls props.onSelectDate', () => {
+ const onSelectDate = jasmine.createSpy('onSelectDate')
+ const popover = makePopover({onSelectDate})
+ popover.instance().selectDate({utc: () => 'utc'}, 'Custom')
+
+ expect(onSelectDate).toHaveBeenCalledWith('formatted', 'Custom')
+ });
+ });
+
+ describe('onSelectMenuOption', () => {
+
+ });
+
+ describe('onCustomDateSelected', () => {
+ it('selects date', () => {
+ const popover = makePopover()
+ const instance = popover.instance()
+ spyOn(instance, 'selectDate')
+ instance.onCustomDateSelected('date', 'abc')
+ expect(instance.selectDate).toHaveBeenCalledWith('date', 'Custom')
+ });
+
+ it('throws error if date is invalid', () => {
+ spyOn(NylasEnv, 'showErrorDialog')
+ const popover = makePopover()
+ popover.instance().onCustomDateSelected(null, 'abc')
+ expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
+ });
+ });
+
+ describe('render', () => {
+ it('renders the provided dateOptions', () => {
+ const popover = makePopover({
+ dateOptions: {
+ 'label 1-': () => {},
+ 'label 2-': () => {},
+ },
+ })
+ const items = popover.find('.item')
+ expect(items.at(0).text()).toEqual('label 1-formatted')
+ expect(items.at(1).text()).toEqual('label 2-formatted')
+ });
+
+ it('renders header components', () => {
+ const popover = makePopover()
+ expect(popover.find('.header').text()).toEqual('my header')
+ })
+
+ it('renders footer components', () => {
+ const popover = makePopover({
+ footer: footer ,
+ })
+ expect(popover.find('.footer').text()).toEqual('footer')
+ expect(popover.find('.date-input-section').exists()).toBe(true)
+ });
+ });
+});
+
diff --git a/packages/client-app/spec/components/editable-list-spec.jsx b/packages/client-app/spec/components/editable-list-spec.jsx
new file mode 100644
index 0000000000..ad6b1cffb7
--- /dev/null
+++ b/packages/client-app/spec/components/editable-list-spec.jsx
@@ -0,0 +1,287 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {
+ findRenderedDOMComponentWithClass,
+ scryRenderedDOMComponentsWithClass,
+ Simulate,
+} from 'react-addons-test-utils';
+
+import EditableList from '../../src/components/editable-list';
+import {renderIntoDocument, simulateCommand} from '../nylas-test-utils'
+
+const {findDOMNode} = ReactDOM;
+
+const makeList = (items = [], props = {}) => {
+ const list = renderIntoDocument( );
+ if (props.initialState) {
+ list.setState(props.initialState)
+ }
+ return list
+};
+
+describe('EditableList', function editableList() {
+ describe('_onItemClick', () => {
+ it('calls onSelectItem', () => {
+ const onSelectItem = jasmine.createSpy('onSelectItem');
+ const list = makeList(['1', '2'], {onSelectItem});
+ const item = scryRenderedDOMComponentsWithClass(list, 'editable-item')[0];
+
+ Simulate.click(item);
+
+ expect(onSelectItem).toHaveBeenCalledWith('1', 0);
+ });
+ });
+
+ describe('_onItemEdit', () => {
+ it('enters editing mode when double click', () => {
+ const list = makeList(['1', '2']);
+ spyOn(list, 'setState');
+ const item = scryRenderedDOMComponentsWithClass(list, 'editable-item')[0];
+
+ Simulate.doubleClick(item);
+
+ expect(list.setState).toHaveBeenCalledWith({editingIndex: 0});
+ });
+
+ it('enters editing mode when edit icon clicked', () => {
+ const list = makeList(['1', '2']);
+ spyOn(list, 'setState');
+ const editIcon = scryRenderedDOMComponentsWithClass(list, 'edit-icon')[0];
+
+ Simulate.click(editIcon);
+
+ expect(list.setState).toHaveBeenCalledWith({editingIndex: 0});
+ });
+ });
+
+ describe('core:previous-item / core:next-item', () => {
+ it('calls onSelectItem', () => {
+ const onSelectItem = jasmine.createSpy('onSelectItem');
+ const list = makeList(['1', '2'], {selected: '1', onSelectItem});
+ const innerList = findRenderedDOMComponentWithClass(list, 'items-wrapper');
+
+ simulateCommand(innerList, 'core:next-item')
+
+ expect(onSelectItem).toHaveBeenCalledWith('2', 1);
+ });
+
+ it('does not select an item when at the bottom of the list and moves down', () => {
+ const onSelectItem = jasmine.createSpy('onSelectItem');
+ const list = makeList(['1', '2'], {selected: '2', onSelectItem});
+ const innerList = findRenderedDOMComponentWithClass(list, 'items-wrapper');
+
+ simulateCommand(innerList, 'core:next-item')
+
+ expect(onSelectItem).not.toHaveBeenCalled();
+ });
+
+ it('does not select an item when at the top of the list and moves up', () => {
+ const onSelectItem = jasmine.createSpy('onSelectItem');
+ const list = makeList(['1', '2'], {selected: '1', onSelectItem});
+ const innerList = findRenderedDOMComponentWithClass(list, 'items-wrapper');
+
+ simulateCommand(innerList, 'core:previous-item')
+
+ expect(onSelectItem).not.toHaveBeenCalled();
+ });
+
+ it('does not clear the selection when esc pressed but prop does not allow it', () => {
+ const onSelectItem = jasmine.createSpy('onSelectItem');
+ const list = makeList(['1', '2'], {selected: '1', allowEmptySelection: false, onSelectItem});
+ const innerList = findRenderedDOMComponentWithClass(list, 'items-wrapper');
+
+ Simulate.keyDown(innerList, {key: 'Escape'});
+
+ expect(onSelectItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('_onCreateInputKeyDown', () => {
+ it('calls onItemCreated', () => {
+ const onItemCreated = jasmine.createSpy('onItemCreated');
+ const list = makeList(['1', '2'], {initialState: {creatingItem: true}, onItemCreated});
+ const createItem = findRenderedDOMComponentWithClass(list, 'create-item-input');
+ const input = createItem.querySelector('input');
+ findDOMNode(input).value = 'New Item';
+
+ Simulate.keyDown(input, {key: 'Enter'});
+
+ expect(onItemCreated).toHaveBeenCalledWith('New Item');
+ });
+
+ it('does not call onItemCreated when no value entered', () => {
+ const onItemCreated = jasmine.createSpy('onItemCreated');
+ const list = makeList(['1', '2'], {initialState: {creatingItem: true}, onItemCreated});
+ const createItem = findRenderedDOMComponentWithClass(list, 'create-item-input');
+ const input = createItem.querySelector('input');
+ findDOMNode(input).value = '';
+
+ Simulate.keyDown(input, {key: 'Enter'});
+
+ expect(onItemCreated).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('_onCreateItem', () => {
+ it('should call prop callback when provided', () => {
+ const onCreateItem = jasmine.createSpy('onCreateItem');
+ const list = makeList(['1', '2'], {onCreateItem});
+
+ list._onCreateItem();
+ expect(onCreateItem).toHaveBeenCalled();
+ });
+
+ it('should set state for creating item when no callback provided', () => {
+ const list = makeList(['1', '2']);
+ spyOn(list, 'setState');
+ list._onCreateItem();
+ expect(list.setState).toHaveBeenCalledWith({creatingItem: true});
+ });
+ });
+
+ describe('_onDeleteItem', () => {
+ let onSelectItem;
+ let onDeleteItem;
+ beforeEach(() => {
+ onSelectItem = jasmine.createSpy('onSelectItem');
+ onDeleteItem = jasmine.createSpy('onDeleteItem');
+ })
+ it('deletes the item from the list', () => {
+ const list = makeList(['1', '2'], {selected: '2', onDeleteItem, onSelectItem});
+ const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
+
+ Simulate.click(button);
+ expect(onDeleteItem).toHaveBeenCalledWith('2', 1);
+ })
+ it('sets the selected item to the one above if it exists', () => {
+ const list = makeList(['1', '2'], {selected: '2', onDeleteItem, onSelectItem});
+ const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
+
+ Simulate.click(button);
+ expect(onSelectItem).toHaveBeenCalledWith('1', 0)
+ })
+ it('sets the selected item to the one below if it is at the top', () => {
+ const list = makeList(['1', '2'], {selected: '1', onDeleteItem, onSelectItem});
+ const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
+
+ Simulate.click(button);
+ expect(onSelectItem).toHaveBeenCalledWith('2', 1)
+ })
+ it('sets the selected item to nothing when you delete the last item', () => {
+ const list = makeList(['1'], {selected: '1', onDeleteItem, onSelectItem});
+ const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
+
+ Simulate.click(button);
+ expect(onSelectItem).not.toHaveBeenCalled()
+ })
+ })
+ describe('_renderItem', () => {
+ const makeItem = (item, idx, state = {}, handlers = {}) => {
+ const list = makeList([], {initialState: state});
+ return renderIntoDocument(
+ list._renderItem(item, idx, state, handlers)
+ );
+ };
+
+ it('binds correct click callbacks', () => {
+ const onClick = jasmine.createSpy('onClick');
+ const onEdit = jasmine.createSpy('onEdit');
+ const item = makeItem('item 1', 0, {}, {onClick, onEdit});
+
+ Simulate.click(item);
+ expect(onClick.calls[0].args[1]).toEqual('item 1');
+ expect(onClick.calls[0].args[2]).toEqual(0);
+
+ Simulate.doubleClick(item);
+ expect(onEdit.calls[0].args[1]).toEqual('item 1');
+ expect(onEdit.calls[0].args[2]).toEqual(0);
+ });
+
+ it('renders correctly when item is selected', () => {
+ const item = findDOMNode(makeItem('item 1', 0, {selected: 'item 1'}));
+ expect(item.className.indexOf('selected')).not.toEqual(-1);
+ });
+
+ it('renders correctly when item is string', () => {
+ const item = findDOMNode(makeItem('item 1', 0));
+ expect(item.className.indexOf('selected')).toEqual(-1);
+ expect(item.className.indexOf('editable-item')).not.toEqual(-1);
+ expect(item.innerText).toEqual('item 1');
+ });
+
+ it('renders correctly when item is component', () => {
+ const item = findDOMNode(makeItem(
, 0));
+ expect(item.className.indexOf('selected')).toEqual(-1);
+ expect(item.className.indexOf('editable-item')).toEqual(-1);
+ expect(item.childNodes[0].tagName).toEqual('DIV');
+ });
+
+ it('renders correctly when item is in editing state', () => {
+ const onInputBlur = jasmine.createSpy('onInputBlur');
+ const onInputFocus = jasmine.createSpy('onInputFocus');
+ const onInputKeyDown = jasmine.createSpy('onInputKeyDown');
+
+ const item = makeItem('item 1', 0, {editingIndex: 0}, {onInputBlur, onInputFocus, onInputKeyDown});
+ const input = item.querySelector('input')
+
+ Simulate.focus(input);
+ Simulate.keyDown(input);
+ Simulate.blur(input);
+
+ expect(onInputFocus).toHaveBeenCalled();
+ expect(onInputBlur).toHaveBeenCalled();
+ expect(onInputKeyDown.calls[0].args[1]).toEqual('item 1');
+ expect(onInputKeyDown.calls[0].args[2]).toEqual(0);
+
+ expect(findDOMNode(input).tagName).toEqual('INPUT');
+ });
+ });
+
+ describe('render', () => {
+ it('renders list of items', () => {
+ const items = ['1', '2', '3'];
+ const list = makeList(items);
+ const innerList = findDOMNode(
+ findRenderedDOMComponentWithClass(list, 'scroll-region-content-inner')
+ );
+ expect(() => {
+ findRenderedDOMComponentWithClass(list, 'create-item-input');
+ }).toThrow();
+
+ expect(innerList.childNodes.length).toEqual(3);
+ items.forEach((item, idx) => expect(innerList.childNodes[idx].textContent).toEqual(item));
+ });
+
+ it('renders create input as an item when creating', () => {
+ const items = ['1', '2', '3'];
+ const list = makeList(items, {initialState: {creatingItem: true}});
+ const createItem = findRenderedDOMComponentWithClass(list, 'create-item-input');
+ expect(createItem).toBeDefined();
+ });
+
+ it('renders add button', () => {
+ const list = makeList();
+ const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[0];
+
+ expect(findDOMNode(button).textContent).toEqual('+');
+ });
+
+ it('renders delete button', () => {
+ const list = makeList(['1', '2'], {selected: '2'});
+ const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
+
+ expect(findDOMNode(button).textContent).toEqual('—');
+ });
+
+ it('disables the delete button when no item is selected', () => {
+ const onSelectItem = jasmine.createSpy('onSelectItem');
+ const onDeleteItem = jasmine.createSpy('onDeleteItem');
+ const list = makeList(['1', '2'], {selected: null, onDeleteItem, onSelectItem});
+ const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
+
+ Simulate.click(button);
+
+ expect(onDeleteItem).not.toHaveBeenCalledWith('2', 1);
+ });
+ });
+});
diff --git a/packages/client-app/spec/components/editable-table-spec.jsx b/packages/client-app/spec/components/editable-table-spec.jsx
new file mode 100644
index 0000000000..0abf2dcd1b
--- /dev/null
+++ b/packages/client-app/spec/components/editable-table-spec.jsx
@@ -0,0 +1,150 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import {mount, shallow} from 'enzyme'
+import {SelectableTable, EditableTableCell, EditableTable} from 'nylas-component-kit'
+import {selection, cellProps, tableProps, testDataSource} from '../fixtures/table-data'
+
+
+describe('EditableTable Components', function describeBlock() {
+ describe('EditableTableCell', () => {
+ function renderCell(props) {
+ // This node is used so that React does not issue DOM tree warnings when running
+ // the tests
+ const table = document.createElement('table')
+ table.innerHTML = ' '
+ const cellRootNode = table.querySelector('tr')
+ return mount(
+ ,
+ {attachTo: cellRootNode}
+ )
+ }
+
+ describe('onInputBlur', () => {
+ it('should call onCellEdited if value is different from current value', () => {
+ const onCellEdited = jasmine.createSpy('onCellEdited')
+ const event = {
+ target: {value: 'new-val'},
+ }
+ const cell = renderCell({onCellEdited, isHeader: false}).instance()
+ cell.onInputBlur(event)
+ expect(onCellEdited).toHaveBeenCalledWith({
+ rowIdx: 0, colIdx: 0, value: 'new-val', isHeader: false,
+ })
+ });
+
+ it('should not call onCellEdited otherwise', () => {
+ const onCellEdited = jasmine.createSpy('onCellEdited')
+ const event = {
+ target: {value: 1},
+ }
+ const cell = renderCell({onCellEdited}).instance()
+ cell.onInputBlur(event)
+ expect(onCellEdited).not.toHaveBeenCalled()
+ });
+ });
+
+ describe('onInputKeyDown', () => {
+ it('calls onAddRow if Enter pressed and cell is in last row', () => {
+ const onAddRow = jasmine.createSpy('onAddRow')
+ const event = {
+ key: 'Enter',
+ stopPropagation: jasmine.createSpy('stopPropagation'),
+ }
+ const cell = renderCell({rowIdx: 2, onAddRow}).instance()
+ cell.onInputKeyDown(event)
+ expect(event.stopPropagation).toHaveBeenCalled()
+ expect(onAddRow).toHaveBeenCalled()
+ });
+
+ it('stops event propagation and blurs input if Escape pressed', () => {
+ const focusSpy = jasmine.createSpy('focusSpy')
+ spyOn(ReactDOM, 'findDOMNode').andReturn({
+ focus: focusSpy,
+ })
+ const event = {
+ key: 'Escape',
+ stopPropagation: jasmine.createSpy('stopPropagation'),
+ }
+ const cell = renderCell().instance()
+ cell.onInputKeyDown(event)
+ expect(event.stopPropagation).toHaveBeenCalled()
+ expect(focusSpy).toHaveBeenCalled()
+ });
+ });
+
+ it('renders a SelectableTableCell with the correct props', () => {
+ const cell = renderCell()
+ expect(cell.prop('tableDataSource')).toBe(testDataSource)
+ expect(cell.prop('selection')).toBe(selection)
+ expect(cell.prop('rowIdx')).toBe(0)
+ expect(cell.prop('colIdx')).toBe(0)
+ });
+
+ it('renders the InputRenderer as the child of the SelectableTableCell with the correct props', () => {
+ const InputRenderer = () =>
+ const inputProps = {p1: 'p1'}
+ const input = renderCell({
+ rowIdx: 2,
+ colIdx: 2,
+ inputProps,
+ InputRenderer,
+ }).childAt(0).childAt(0)
+ expect(input.type()).toBe(InputRenderer)
+ expect(input.prop('rowIdx')).toBe(2)
+ expect(input.prop('colIdx')).toBe(2)
+ expect(input.prop('p1')).toBe('p1')
+ expect(input.prop('defaultValue')).toBe(9)
+ expect(input.prop('tableDataSource')).toBe(testDataSource)
+ });
+ });
+
+ describe('EditableTable', () => {
+ function renderTable(props) {
+ return shallow(
+
+ )
+ }
+
+ it('renders column buttons if onAddColumn and onRemoveColumn are provided', () => {
+ const onAddColumn = () => {}
+ const onRemoveColumn = () => {}
+ const table = renderTable({onAddColumn, onRemoveColumn})
+ expect(table.hasClass('editable-table-container')).toBe(true)
+ expect(table.find('.btn').length).toBe(2)
+ });
+
+ it('renders only a SelectableTable if column callbacks are not provided', () => {
+ const table = renderTable()
+ expect(table.find('.btn').length).toBe(0)
+ });
+
+ it('renders with the correct props', () => {
+ const onAddRow = () => {}
+ const onCellEdited = () => {}
+ const inputProps = {}
+ const InputRenderer = () =>
+ const other = 'other'
+ const table = renderTable({
+ onAddRow,
+ onCellEdited,
+ inputProps,
+ InputRenderer,
+ other,
+ }).find(SelectableTable)
+ expect(table.prop('extraProps').onAddRow).toBe(onAddRow)
+ expect(table.prop('extraProps').onCellEdited).toBe(onCellEdited)
+ expect(table.prop('extraProps').inputProps).toBe(inputProps)
+ expect(table.prop('extraProps').InputRenderer).toBe(InputRenderer)
+ expect(table.prop('other')).toEqual('other')
+ expect(table.prop('CellRenderer')).toBe(EditableTableCell)
+ expect(table.hasClass('editable-table')).toBe(true)
+ });
+ });
+});
+
diff --git a/packages/client-app/spec/components/evented-iframe-spec.cjsx b/packages/client-app/spec/components/evented-iframe-spec.cjsx
new file mode 100644
index 0000000000..3689abfaa5
--- /dev/null
+++ b/packages/client-app/spec/components/evented-iframe-spec.cjsx
@@ -0,0 +1,77 @@
+React = require "react"
+ReactTestUtils = require('react-addons-test-utils')
+EventedIFrame = require '../../src/components/evented-iframe'
+
+describe 'EventedIFrame', ->
+ describe 'link clicking behavior', ->
+
+ beforeEach ->
+ @frame = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ @setAttributeSpy = jasmine.createSpy('setAttribute')
+ @preventDefaultSpy = jasmine.createSpy('preventDefault')
+ @openLinkSpy = jasmine.createSpy("openLink")
+
+ @oldOpenLink = NylasEnv.windowEventHandler.openLink
+ NylasEnv.windowEventHandler.openLink = @openLinkSpy
+
+ @fakeEvent = (href) =>
+ stopPropagation: ->
+ preventDefault: @preventDefaultSpy
+ target:
+ getAttribute: (attr) -> return href
+ setAttribute: @setAttributeSpy
+
+ afterEach ->
+ NylasEnv.windowEventHandler.openLink = @oldOpenLink
+
+ it 'works for acceptable link types', ->
+ hrefs = [
+ "http://nylas.com"
+ "https://www.nylas.com"
+ "mailto:evan@nylas.com"
+ "tel:8585311718"
+ "custom:www.nylas.com"
+ ]
+ for href, i in hrefs
+ @frame._onIFrameClick(@fakeEvent(href))
+ expect(@setAttributeSpy).not.toHaveBeenCalled()
+ expect(@openLinkSpy).toHaveBeenCalled()
+ target = @openLinkSpy.calls[i].args[0].target
+ targetHref = @openLinkSpy.calls[i].args[0].href
+ expect(target).not.toBeDefined()
+ expect(targetHref).toBe href
+
+ it 'corrects relative uris', ->
+ hrefs = [
+ "nylas.com"
+ "www.nylas.com"
+ ]
+ for href, i in hrefs
+ @frame._onIFrameClick(@fakeEvent(href))
+ expect(@setAttributeSpy).toHaveBeenCalled()
+ modifiedHref = @setAttributeSpy.calls[i].args[1]
+ expect(modifiedHref).toBe "http://#{href}"
+
+ it 'corrects protocol-relative uris', ->
+ hrefs = [
+ "//nylas.com"
+ "//www.nylas.com"
+ ]
+ for href, i in hrefs
+ @frame._onIFrameClick(@fakeEvent(href))
+ expect(@setAttributeSpy).toHaveBeenCalled()
+ modifiedHref = @setAttributeSpy.calls[i].args[1]
+ expect(modifiedHref).toBe "https:#{href}"
+
+ it 'disallows malicious uris', ->
+ hrefs = [
+ "file://usr/bin/bad"
+ ]
+ for href in hrefs
+ @frame._onIFrameClick(@fakeEvent(href))
+ expect(@preventDefaultSpy).toHaveBeenCalled()
+ expect(@openLinkSpy).not.toHaveBeenCalled()
+
diff --git a/packages/client-app/spec/components/fixed-popover-spec.jsx b/packages/client-app/spec/components/fixed-popover-spec.jsx
new file mode 100644
index 0000000000..bdde302751
--- /dev/null
+++ b/packages/client-app/spec/components/fixed-popover-spec.jsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import FixedPopover from '../../src/components/fixed-popover';
+import {renderIntoDocument} from '../nylas-test-utils'
+
+
+const {Directions: {Up, Down, Left, Right}} = FixedPopover
+
+const makePopover = (props = {}) => {
+ const originRect = props.originRect ? props.originRect : {};
+ const popover = renderIntoDocument(
+
+ );
+ if (props.initialState) {
+ popover.setState(props.initialState)
+ }
+ return popover
+};
+
+describe('FixedPopover', function fixedPopover() {
+ describe('computeAdjustedOffsetAndDirection', () => {
+ beforeEach(() => {
+ this.popover = makePopover()
+ this.PADDING = 10
+ this.windowDimensions = {
+ height: 500,
+ width: 500,
+ }
+ });
+
+ const compute = (direction, {fallback, top, left, bottom, right}) => {
+ return this.popover.computeAdjustedOffsetAndDirection({
+ direction,
+ windowDimensions: this.windowDimensions,
+ currentRect: {
+ top,
+ left,
+ bottom,
+ right,
+ },
+ fallback,
+ offsetPadding: this.PADDING,
+ })
+ }
+
+ it('returns null when no overflows present', () => {
+ const res = compute(Up, {top: 10, left: 10, right: 20, bottom: 20})
+ expect(res).toBe(null)
+ });
+
+ describe('when overflowing on 1 side of the window', () => {
+ it('returns fallback direction when it is specified', () => {
+ const {offset, direction} = compute(Up, {fallback: Left, top: -10, left: 10, right: 20, bottom: 10})
+ expect(offset).toEqual({})
+ expect(direction).toEqual(Left)
+ });
+
+ it('inverts direction if is Up and overflows on the top', () => {
+ const {offset, direction} = compute(Up, {top: -10, left: 10, right: 20, bottom: 10})
+ expect(offset).toEqual({})
+ expect(direction).toEqual(Down)
+ });
+
+ it('inverts direction if is Down and overflows on the bottom', () => {
+ const {offset, direction} = compute(Down, {top: 490, left: 10, right: 20, bottom: 510})
+ expect(offset).toEqual({})
+ expect(direction).toEqual(Up)
+ });
+
+ it('inverts direction if is Right and overflows on the right', () => {
+ const {offset, direction} = compute(Right, {top: 10, left: 490, right: 510, bottom: 20})
+ expect(offset).toEqual({})
+ expect(direction).toEqual(Left)
+ });
+
+ it('inverts direction if is Left and overflows on the left', () => {
+ const {offset, direction} = compute(Left, {top: 10, left: -10, right: 10, bottom: 20})
+ expect(offset).toEqual({})
+ expect(direction).toEqual(Right)
+ });
+
+ [Up, Down, Left, Right].forEach((dir) => {
+ if (dir === Up || dir === Down) {
+ it('moves left if its overflowing on the right', () => {
+ const {offset, direction} = compute(dir, {top: 10, left: 490, right: 510, bottom: 20})
+ expect(offset).toEqual({x: -20})
+ expect(direction).toEqual(dir)
+ });
+
+ it('moves right if overflows on the left', () => {
+ const {offset, direction} = compute(dir, {top: 10, left: -10, right: 10, bottom: 20})
+ expect(offset).toEqual({x: 20})
+ expect(direction).toEqual(dir)
+ });
+ }
+
+ if (dir === Left || dir === Right) {
+ it('moves up if its overflowing on the bottom', () => {
+ const {offset, direction} = compute(dir, {top: 490, left: 10, right: 20, bottom: 510})
+ expect(offset).toEqual({y: -20})
+ expect(direction).toEqual(dir)
+ });
+
+ it('moves down if overflows on the top', () => {
+ const {offset, direction} = compute(dir, {top: -10, left: 10, right: 20, bottom: 10})
+ expect(offset).toEqual({y: 20})
+ expect(direction).toEqual(dir)
+ });
+ }
+ })
+ })
+
+ describe('when overflowing on 2 sides of the window', () => {
+ describe('when direction is up', () => {
+ it('computes correctly when it overflows up and right', () => {
+ const {offset, direction} = compute(Up, {top: -10, left: 10, right: 510, bottom: 10})
+ expect(offset).toEqual({x: -20})
+ expect(direction).toEqual(Down)
+ });
+
+ it('computes correctly when it overflows up and left', () => {
+ const {offset, direction} = compute(Up, {top: -10, left: -10, right: 10, bottom: 10})
+ expect(offset).toEqual({x: 20})
+ expect(direction).toEqual(Down)
+ });
+ });
+
+ describe('when direction is right', () => {
+ it('computes correctly when it overflows right and up', () => {
+ const {offset, direction} = compute(Right, {top: -10, left: 490, right: 510, bottom: 10})
+ expect(offset).toEqual({y: 20})
+ expect(direction).toEqual(Left)
+ });
+
+ it('computes correctly when it overflows right and down', () => {
+ const {offset, direction} = compute(Right, {top: 490, left: 490, right: 510, bottom: 510})
+ expect(offset).toEqual({y: -20})
+ expect(direction).toEqual(Left)
+ });
+ });
+
+ describe('when direction is left', () => {
+ it('computes correctly when it overflows left and up', () => {
+ const {offset, direction} = compute(Left, {top: -10, left: -10, right: 10, bottom: 10})
+ expect(offset).toEqual({y: 20})
+ expect(direction).toEqual(Right)
+ });
+
+ it('computes correctly when it overflows left and down', () => {
+ const {offset, direction} = compute(Left, {top: 490, left: -10, right: 10, bottom: 510})
+ expect(offset).toEqual({y: -20})
+ expect(direction).toEqual(Right)
+ });
+ });
+
+ describe('when direction is down', () => {
+ it('computes correctly when it overflows down and left', () => {
+ const {offset, direction} = compute(Down, {top: 490, left: -10, right: 10, bottom: 510})
+ expect(offset).toEqual({x: 20})
+ expect(direction).toEqual(Up)
+ });
+
+ it('computes correctly when it overflows down and right', () => {
+ const {offset, direction} = compute(Down, {top: 490, left: 490, right: 510, bottom: 510})
+ expect(offset).toEqual({x: -20})
+ expect(direction).toEqual(Up)
+ });
+ });
+ });
+ });
+
+ describe('computePopoverStyles', () => {
+ // TODO
+ });
+});
diff --git a/packages/client-app/spec/components/injected-component-set-spec.jsx b/packages/client-app/spec/components/injected-component-set-spec.jsx
new file mode 100644
index 0000000000..91f7b5bba9
--- /dev/null
+++ b/packages/client-app/spec/components/injected-component-set-spec.jsx
@@ -0,0 +1,51 @@
+/* eslint react/prefer-es6-class: "off" */
+/* eslint react/prefer-stateless-function: "off" */
+
+import {React, ComponentRegistry, NylasTestUtils} from 'nylas-exports';
+import {InjectedComponentSet} from 'nylas-component-kit';
+
+const {renderIntoDocument} = NylasTestUtils;
+
+const reactStub = (displayName) => {
+ return React.createClass({
+ displayName,
+ render() { return
},
+ });
+};
+
+
+describe('InjectedComponentSet', function injectedComponentSet() {
+ describe('render', () => {
+ beforeEach(() => {
+ const components = [reactStub('comp1'), reactStub('comp2')];
+ spyOn(ComponentRegistry, 'findComponentsMatching').andReturn(components);
+ });
+
+ it('calls `onComponentsDidRender` when all child comps have actually been rendered to the dom', () => {
+ let rendered;
+ const onComponentsDidRender = () => {
+ rendered = true;
+ };
+ runs(() => {
+ renderIntoDocument(
+
+ );
+ });
+
+ waitsFor(
+ () => { return rendered; },
+ '`onComponentsDidMount` should be called',
+ 100
+ );
+
+ runs(() => {
+ expect(rendered).toBe(true);
+ expect(document.querySelectorAll('.comp1').length).toEqual(1);
+ expect(document.querySelectorAll('.comp2').length).toEqual(1);
+ });
+ });
+ });
+});
diff --git a/packages/client-app/spec/components/multiselect-dropdown-spec.jsx b/packages/client-app/spec/components/multiselect-dropdown-spec.jsx
new file mode 100644
index 0000000000..24a2a10bb1
--- /dev/null
+++ b/packages/client-app/spec/components/multiselect-dropdown-spec.jsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import {
+ scryRenderedDOMComponentsWithClass,
+ Simulate,
+} from 'react-addons-test-utils';
+
+import MultiselectDropdown from '../../src/components/multiselect-dropdown'
+import {renderIntoDocument} from '../nylas-test-utils'
+
+const makeDropdown = (items = [], props = {}) => {
+ return renderIntoDocument( )
+}
+describe('MultiselectDropdown', function multiSelectedDropdown() {
+ describe('_onItemClick', () => {
+ it('calls onToggleItem function', () => {
+ const onToggleItem = jasmine.createSpy('onToggleItem')
+ const itemChecked = jasmine.createSpy('itemChecked')
+ const itemKey = (i) => i
+ const dropdown = makeDropdown(["annie@nylas.com", "anniecook@ostby.com"], {onToggleItem, itemChecked, itemKey})
+ dropdown.setState({selectingItems: true})
+ const item = scryRenderedDOMComponentsWithClass(dropdown, 'item')[0]
+ Simulate.mouseDown(item)
+ expect(onToggleItem).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/client-app/spec/components/multiselect-list-interaction-handler-spec.coffee b/packages/client-app/spec/components/multiselect-list-interaction-handler-spec.coffee
new file mode 100644
index 0000000000..89c84c7b0d
--- /dev/null
+++ b/packages/client-app/spec/components/multiselect-list-interaction-handler-spec.coffee
@@ -0,0 +1,115 @@
+MultiselectListInteractionHandler = require '../../src/components/multiselect-list-interaction-handler'
+WorkspaceStore = require '../../src/flux/stores/workspace-store'
+FocusedContentStore = require '../../src/flux/stores/focused-content-store'
+Thread = require('../../src/flux/models/thread').default
+Actions = require('../../src/flux/actions').default
+_ = require 'underscore'
+
+describe "MultiselectListInteractionHandler", ->
+ beforeEach ->
+ @item = new Thread(id:'123')
+ @itemFocus = new Thread({id: 'focus'})
+ @itemKeyboardFocus = new Thread({id: 'keyboard-focus'})
+ @itemAfterFocus = new Thread(id:'after-focus')
+ @itemAfterKeyboardFocus = new Thread(id:'after-keyboard-focus')
+
+ data = [@item, @itemFocus, @itemAfterFocus, @itemKeyboardFocus, @itemAfterKeyboardFocus]
+
+ @onFocusItem = jasmine.createSpy('onFocusItem')
+ @onSetCursorPosition = jasmine.createSpy('onSetCursorPosition')
+ @dataSource =
+ selection:
+ toggle: jasmine.createSpy('toggle')
+ expandTo: jasmine.createSpy('expandTo')
+ walk: jasmine.createSpy('walk')
+ get: (idx) ->
+ data[idx]
+ getById: (id) ->
+ _.find data, (item) -> item.id is id
+ indexOfId: (id) ->
+ _.findIndex data, (item) -> item.id is id
+ count: -> data.length
+
+ @props =
+ dataSource: @dataSource
+ keyboardCursorId: 'keyboard-focus'
+ focusedId: 'focus'
+ onFocusItem: @onFocusItem
+ onSetCursorPosition: @onSetCursorPosition
+
+ @collection = 'threads'
+ @isRootSheet = true
+ @handler = new MultiselectListInteractionHandler(@props)
+
+ spyOn(WorkspaceStore, 'topSheet').andCallFake => {root: @isRootSheet}
+
+ it "should never show focus", ->
+ expect(@handler.shouldShowFocus()).toEqual(false)
+
+ it "should always show the keyboard cursor", ->
+ expect(@handler.shouldShowKeyboardCursor()).toEqual(true)
+
+ it "should always show checkmarks", ->
+ expect(@handler.shouldShowCheckmarks()).toEqual(true)
+
+ describe "onClick", ->
+ it "should focus list items", ->
+ @handler.onClick(@item)
+ expect(@onFocusItem).toHaveBeenCalledWith(@item)
+
+ describe "onMetaClick", ->
+ it "shoud toggle selection", ->
+ @handler.onMetaClick(@item)
+ expect(@dataSource.selection.toggle).toHaveBeenCalledWith(@item)
+
+ it "should focus the keyboard on the clicked item", ->
+ @handler.onMetaClick(@item)
+ expect(@onSetCursorPosition).toHaveBeenCalledWith(@item)
+
+ describe "onShiftClick", ->
+ it "should expand selection", ->
+ @handler.onShiftClick(@item)
+ expect(@dataSource.selection.expandTo).toHaveBeenCalledWith(@item)
+
+ it "should focus the keyboard on the clicked item", ->
+ @handler.onShiftClick(@item)
+ expect(@onSetCursorPosition).toHaveBeenCalledWith(@item)
+
+ describe "onEnter", ->
+ it "should focus the item with the current keyboard selection", ->
+ @handler.onEnter()
+ expect(@onFocusItem).toHaveBeenCalledWith(@itemKeyboardFocus)
+
+ describe "onSelectKeyboardItem (x key on keyboard)", ->
+ describe "on the root view", ->
+ it "should toggle the selection of the keyboard item", ->
+ @isRootSheet = true
+ @handler.onSelectKeyboardItem()
+ expect(@dataSource.selection.toggle).toHaveBeenCalledWith(@itemKeyboardFocus)
+
+ describe "on the thread view", ->
+ it "should toggle the selection of the focused item", ->
+ @isRootSheet = false
+ @handler.onSelectKeyboardItem()
+ expect(@dataSource.selection.toggle).toHaveBeenCalledWith(@itemFocus)
+
+ describe "onShift", ->
+ describe "on the root view", ->
+ beforeEach ->
+ @isRootSheet = true
+
+ it "should shift the keyboard item", ->
+ @handler.onShift(1, {})
+ expect(@onSetCursorPosition).toHaveBeenCalledWith(@itemAfterKeyboardFocus)
+
+ it "should walk selection if the select option is passed", ->
+ @handler.onShift(1, select: true)
+ expect(@dataSource.selection.walk).toHaveBeenCalledWith({current: @itemKeyboardFocus, next: @itemAfterKeyboardFocus})
+
+ describe "on the thread view", ->
+ beforeEach ->
+ @isRootSheet = false
+
+ it "should shift the focused item", ->
+ @handler.onShift(1, {})
+ expect(@onFocusItem).toHaveBeenCalledWith(@itemAfterFocus)
diff --git a/packages/client-app/spec/components/multiselect-split-interaction-handler-spec.coffee b/packages/client-app/spec/components/multiselect-split-interaction-handler-spec.coffee
new file mode 100644
index 0000000000..4a25a12717
--- /dev/null
+++ b/packages/client-app/spec/components/multiselect-split-interaction-handler-spec.coffee
@@ -0,0 +1,154 @@
+MultiselectSplitInteractionHandler = require '../../src/components/multiselect-split-interaction-handler'
+WorkspaceStore = require '../../src/flux/stores/workspace-store'
+FocusedContentStore = require '../../src/flux/stores/focused-content-store'
+Thread = require('../../src/flux/models/thread').default
+Actions = require('../../src/flux/actions').default
+_ = require 'underscore'
+
+describe "MultiselectSplitInteractionHandler", ->
+ beforeEach ->
+ @item = new Thread(id:'123')
+ @itemFocus = new Thread({id: 'focus'})
+ @itemKeyboardFocus = new Thread({id: 'keyboard-focus'})
+ @itemAfterFocus = new Thread(id:'after-focus')
+ @itemAfterKeyboardFocus = new Thread(id:'after-keyboard-focus')
+
+ data = [@item, @itemFocus, @itemAfterFocus, @itemKeyboardFocus, @itemAfterKeyboardFocus]
+
+ @onFocusItem = jasmine.createSpy('onFocusItem')
+ @onSetCursorPosition = jasmine.createSpy('onSetCursorPosition')
+ @selection = []
+ @dataSource =
+ selection:
+ toggle: jasmine.createSpy('toggle')
+ expandTo: jasmine.createSpy('expandTo')
+ add: jasmine.createSpy('add')
+ walk: jasmine.createSpy('walk')
+ clear: jasmine.createSpy('clear')
+ count: => @selection.length
+ items: => @selection
+ top: => @selection[-1]
+
+ get: (idx) ->
+ data[idx]
+ getById: (id) ->
+ _.find data, (item) -> item.id is id
+ indexOfId: (id) ->
+ _.findIndex data, (item) -> item.id is id
+ count: -> data.length
+
+ @props =
+ dataSource: @dataSource
+ keyboardCursorId: 'keyboard-focus'
+ focused: @itemFocus
+ focusedId: 'focus'
+ onFocusItem: @onFocusItem
+ onSetCursorPosition: @onSetCursorPosition
+
+ @collection = 'threads'
+ @isRootSheet = true
+ @handler = new MultiselectSplitInteractionHandler(@props)
+
+ spyOn(WorkspaceStore, 'topSheet').andCallFake => {root: @isRootSheet}
+
+ it "should always show focus", ->
+ expect(@handler.shouldShowFocus()).toEqual(true)
+
+ it "should show the keyboard cursor when multiple items are selected", ->
+ @selection = []
+ expect(@handler.shouldShowKeyboardCursor()).toEqual(false)
+ @selection = [@item]
+ expect(@handler.shouldShowKeyboardCursor()).toEqual(false)
+ @selection = [@item, @itemFocus]
+ expect(@handler.shouldShowKeyboardCursor()).toEqual(true)
+
+ describe "onClick", ->
+ it "should focus the list item and indicate it was focused via click", ->
+ @handler.onClick(@item)
+ expect(@onFocusItem).toHaveBeenCalledWith(@item)
+
+ describe "onMetaClick", ->
+ describe "when there is currently a focused item", ->
+ it "should turn the focused item into the first selected item", ->
+ @handler.onMetaClick(@item)
+ expect(@dataSource.selection.add).toHaveBeenCalledWith(@itemFocus)
+
+ it "should clear the focus", ->
+ @handler.onMetaClick(@item)
+ expect(@onFocusItem).toHaveBeenCalledWith(null)
+
+ it "should toggle selection", ->
+ @handler.onMetaClick(@item)
+ expect(@dataSource.selection.toggle).toHaveBeenCalledWith(@item)
+
+ it "should call _checkSelectionAndFocusConsistency", ->
+ spyOn(@handler, '_checkSelectionAndFocusConsistency')
+ @handler.onMetaClick(@item)
+ expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
+
+ describe "onShiftClick", ->
+ describe "when there is currently a focused item", ->
+
+ it "should turn the focused item into the first selected item", ->
+ @handler.onMetaClick(@item)
+ expect(@dataSource.selection.add).toHaveBeenCalledWith(@itemFocus)
+
+ it "should clear the focus", ->
+ @handler.onMetaClick(@item)
+ expect(@onFocusItem).toHaveBeenCalledWith(null)
+
+ it "should expand selection", ->
+ @handler.onShiftClick(@item)
+ expect(@dataSource.selection.expandTo).toHaveBeenCalledWith(@item)
+
+ it "should call _checkSelectionAndFocusConsistency", ->
+ spyOn(@handler, '_checkSelectionAndFocusConsistency')
+ @handler.onMetaClick(@item)
+ expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
+
+ describe "onEnter", ->
+
+ describe "onSelect (x key on keyboard)", ->
+ it "should call _checkSelectionAndFocusConsistency", ->
+ spyOn(@handler, '_checkSelectionAndFocusConsistency')
+ @handler.onMetaClick(@item)
+ expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
+
+ describe "onShift", ->
+ it "should call _checkSelectionAndFocusConsistency", ->
+ spyOn(@handler, '_checkSelectionAndFocusConsistency')
+ @handler.onMetaClick(@item)
+ expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
+
+ describe "when the select option is passed", ->
+ it "should turn the existing focused item into a selected item", ->
+ @handler.onShift(1, {select: true})
+ expect(@dataSource.selection.add).toHaveBeenCalledWith(@itemFocus)
+
+ it "should walk the selection to the shift target", ->
+ @handler.onShift(1, {select: true})
+ expect(@dataSource.selection.walk).toHaveBeenCalledWith({current: @itemFocus, next: @itemAfterFocus})
+
+ describe "when one or more items is selected", ->
+ it "should move the keyboard cursor", ->
+ @selection = [@itemFocus, @itemAfterFocus, @itemKeyboardFocus]
+ @handler.onShift(1, {})
+ expect(@onSetCursorPosition).toHaveBeenCalledWith(@itemAfterKeyboardFocus)
+
+ describe "when no items are selected", ->
+ it "should move the focus", ->
+ @handler.onShift(1, {})
+ expect(@onFocusItem).toHaveBeenCalledWith(@itemAfterFocus)
+
+
+ describe "_checkSelectionAndFocusConsistency", ->
+ describe "when only one item is selected", ->
+ beforeEach ->
+ @selection = [@item]
+ @props.focused = null
+ @handler = new MultiselectSplitInteractionHandler(@props)
+
+ it "should clear the selection and make the item focused", ->
+ @handler._checkSelectionAndFocusConsistency()
+ expect(@dataSource.selection.clear).toHaveBeenCalled()
+ expect(@onFocusItem).toHaveBeenCalledWith(@item)
diff --git a/packages/client-app/spec/components/nylas-calendar/calendar-toggles-spec.jsx b/packages/client-app/spec/components/nylas-calendar/calendar-toggles-spec.jsx
new file mode 100644
index 0000000000..6c57f6d2e4
--- /dev/null
+++ b/packages/client-app/spec/components/nylas-calendar/calendar-toggles-spec.jsx
@@ -0,0 +1,21 @@
+import React from 'react'
+import ReactTestUtils from 'react-addons-test-utils'
+import {NylasCalendar} from 'nylas-component-kit'
+
+import { now } from './test-utils'
+import TestDataSource from './test-data-source'
+import CalendarToggles from '../../../src/components/nylas-calendar/calendar-toggles'
+
+describe("Nylas Calendar Toggles", function calendarPickerSpec() {
+ beforeEach(() => {
+ this.dataSource = new TestDataSource();
+ this.calendar = ReactTestUtils.renderIntoDocument(
+
+ );
+ this.toggles = ReactTestUtils.findRenderedComponentWithType(this.calendar, CalendarToggles);
+ });
+});
diff --git a/packages/client-app/spec/components/nylas-calendar/fixtures/events.es6 b/packages/client-app/spec/components/nylas-calendar/fixtures/events.es6
new file mode 100644
index 0000000000..aa02162f90
--- /dev/null
+++ b/packages/client-app/spec/components/nylas-calendar/fixtures/events.es6
@@ -0,0 +1,132 @@
+import moment from 'moment-timezone'
+import {Event} from 'nylas-exports'
+import {TZ, TEST_CALENDAR} from '../test-utils'
+
+// All day
+// All day overlap
+//
+// Simple single event
+// Event that spans a day
+// Overlapping events
+
+let gen = 0
+
+const genEvent = ({start, end, object = "timespan"}) => {
+ gen += 1;
+
+ let when = {}
+ if (object === "timespan") {
+ when = {
+ object: "timespan",
+ end_time: moment.tz(end, TZ).unix(),
+ start_time: moment.tz(start, TZ).unix(),
+ }
+ }
+ if (object === "datespan") {
+ when = {
+ object: "datespan",
+ end_date: end,
+ start_date: start,
+ }
+ }
+
+ return new Event().fromJSON({
+ id: `server-${gen}`,
+ calendar_id: TEST_CALENDAR,
+ account_id: window.TEST_ACCOUNT_ID,
+ description: `description ${gen}`,
+ location: `location ${gen}`,
+ owner: `${window._TEST_ACCOUNT_NAME} <${window.TEST_ACCOUNT_EMAIL}>`,
+ participants: [{
+ email: window.TEST_ACCOUNT_EMAIL,
+ name: window.TEST_ACCOUNT_NAME,
+ status: "yes",
+ }],
+ read_only: "false",
+ title: `Title ${gen}`,
+ busy: true,
+ when,
+ status: "confirmed",
+ })
+}
+
+// NOTE:
+// DST Started 2016-03-13 01:59 and immediately jumps to 03:00.
+// DST Ended 2016-11-06 01:59 and immediately jumps to 01:00 again!
+//
+// See: http://momentjs.com/timezone/docs/#/using-timezones/parsing-ambiguous-inputs/
+
+// All times are in "America/Los_Angeles"
+export const numAllDayEvents = 6
+export const numStandardEvents = 9
+export const numByDay = {
+ 1457769600: 2,
+ 1457856000: 7,
+}
+export const eventOverlapForSunday = {
+ "server-2": {
+ concurrentEvents: 2,
+ order: 1,
+ },
+ "server-3": {
+ concurrentEvents: 2,
+ order: 2,
+ },
+ "server-6": {
+ concurrentEvents: 1,
+ order: 1,
+ },
+ "server-7": {
+ concurrentEvents: 1,
+ order: 1,
+ },
+ "server-8": {
+ concurrentEvents: 2,
+ order: 1,
+ },
+ "server-9": {
+ concurrentEvents: 2,
+ order: 2,
+ },
+ "server-10": {
+ concurrentEvents: 2,
+ order: 1,
+ },
+}
+export const events = [
+ // Single event
+ genEvent({start: "2016-03-12 12:00", end: "2016-03-12 13:00"}),
+
+ // DST start spanning event. 6 hours when it should be 7!
+ genEvent({start: "2016-03-12 23:00", end: "2016-03-13 06:00"}),
+
+ // DST start invalid event. Does not exist!
+ genEvent({start: "2016-03-13 02:15", end: "2016-03-13 02:45"}),
+
+ // DST end spanning event. 8 hours when it shoudl be 7!
+ genEvent({start: "2016-11-05 23:00", end: "2016-11-06 06:00"}),
+
+ // DST end ambiguous event. This timespan happens twice!
+ genEvent({start: "2016-11-06 01:15", end: "2016-11-06 01:45"}),
+
+ // Adjacent events
+ genEvent({start: "2016-03-13 12:00", end: "2016-03-13 13:00"}),
+ genEvent({start: "2016-03-13 13:00", end: "2016-03-13 14:00"}),
+
+ // Overlapping events
+ genEvent({start: "2016-03-13 14:30", end: "2016-03-13 15:30"}),
+ genEvent({start: "2016-03-13 15:00", end: "2016-03-13 16:00"}),
+ genEvent({start: "2016-03-13 15:30", end: "2016-03-13 16:30"}),
+
+ // All day timespan event
+ genEvent({start: "2016-03-15 00:00", end: "2016-03-16 00:00"}),
+
+ // All day datespan
+ genEvent({start: "2016-03-17", end: "2016-03-18", object: "datespan"}),
+
+ // Overlapping all day
+ genEvent({start: "2016-03-19", end: "2016-03-20", object: "datespan"}),
+ genEvent({start: "2016-03-19 00:00", end: "2016-03-20 00:00"}),
+ genEvent({start: "2016-03-19 12:00", end: "2016-03-20 12:00"}),
+ genEvent({start: "2016-03-20 00:00", end: "2016-03-21 00:00"}),
+]
diff --git a/packages/client-app/spec/components/nylas-calendar/test-data-source.es6 b/packages/client-app/spec/components/nylas-calendar/test-data-source.es6
new file mode 100644
index 0000000000..93ba9399f0
--- /dev/null
+++ b/packages/client-app/spec/components/nylas-calendar/test-data-source.es6
@@ -0,0 +1,17 @@
+// import Rx from 'rx-lite-testing'
+import {CalendarDataSource} from 'nylas-exports'
+import {events} from './fixtures/events'
+
+export default class TestDataSource extends CalendarDataSource {
+ buildObservable({startTime, endTime}) {
+ this.endTime = endTime;
+ this.startTime = startTime;
+ return this
+ }
+
+ subscribe(onNext) {
+ onNext({events})
+ this.unsubscribe = jasmine.createSpy("unusbscribe");
+ return {dispose: this.unsubscribe}
+ }
+}
diff --git a/packages/client-app/spec/components/nylas-calendar/test-utils.es6 b/packages/client-app/spec/components/nylas-calendar/test-utils.es6
new file mode 100644
index 0000000000..e467b3fe7b
--- /dev/null
+++ b/packages/client-app/spec/components/nylas-calendar/test-utils.es6
@@ -0,0 +1,14 @@
+import moment from 'moment-timezone'
+
+export const TZ = window.TEST_TIME_ZONE;
+export const TEST_CALENDAR = "TEST_CALENDAR";
+
+export const now = () => window.testNowMoment();
+
+export const NOW_WEEK_START = moment.tz("2016-03-13 00:00", TZ);
+export const NOW_BUFFER_START = moment.tz("2016-03-06 00:00", TZ);
+export const NOW_BUFFER_END = moment.tz("2016-03-26 23:59:59", TZ);
+
+// Makes test failure output easier to read.
+export const u2h = (unixTime) => moment.unix(unixTime).format("LLL z")
+export const m2h = (m) => m.format("LLL z")
diff --git a/packages/client-app/spec/components/nylas-calendar/week-view-extended-spec.jsx b/packages/client-app/spec/components/nylas-calendar/week-view-extended-spec.jsx
new file mode 100644
index 0000000000..a996968449
--- /dev/null
+++ b/packages/client-app/spec/components/nylas-calendar/week-view-extended-spec.jsx
@@ -0,0 +1,5 @@
+// import {events} from './fixtures/events'
+// import {NylasCalendar} from 'nylas-component-kit'
+//
+// describe('Extended Nylas Calendar Week View', function extendedNylasCalendarWeekView() {
+// });
diff --git a/packages/client-app/spec/components/nylas-calendar/week-view-spec.jsx b/packages/client-app/spec/components/nylas-calendar/week-view-spec.jsx
new file mode 100644
index 0000000000..1e15bf8905
--- /dev/null
+++ b/packages/client-app/spec/components/nylas-calendar/week-view-spec.jsx
@@ -0,0 +1,188 @@
+import _ from 'underscore'
+import moment from 'moment'
+import React from 'react'
+import ReactTestUtils from 'react-addons-test-utils'
+import {NylasCalendar} from 'nylas-component-kit'
+
+import {
+ now,
+ NOW_WEEK_START,
+ NOW_BUFFER_START,
+ NOW_BUFFER_END,
+} from './test-utils'
+
+import TestDataSource from './test-data-source'
+import {
+ numByDay,
+ numAllDayEvents,
+ numStandardEvents,
+ eventOverlapForSunday,
+} from './fixtures/events'
+
+import WeekView from '../../../src/components/nylas-calendar/week-view'
+
+describe("Nylas Calendar Week View", function weekViewSpec() {
+ beforeEach(() => {
+ spyOn(WeekView.prototype, "_now").andReturn(now());
+
+ this.onCalendarMouseDown = jasmine.createSpy("onCalendarMouseDown")
+ this.dataSource = new TestDataSource();
+ this.calendar = ReactTestUtils.renderIntoDocument(
+
+ );
+ this.weekView = ReactTestUtils.findRenderedComponentWithType(this.calendar, WeekView);
+ });
+
+ it("renders a calendar", () => {
+ const cal = ReactTestUtils.findRenderedComponentWithType(this.calendar, NylasCalendar)
+ expect(cal instanceof NylasCalendar).toBe(true)
+ });
+
+ it("sets the correct moment", () => {
+ expect(this.calendar.state.currentMoment.valueOf()).toBe(now().valueOf())
+ });
+
+ it("defaulted to WeekView", () => {
+ expect(this.calendar.state.currentView).toBe("week");
+ expect(this.weekView instanceof WeekView).toBe(true);
+ });
+
+ it("initializes the component", () => {
+ expect(this.weekView.todayYear).toBe(now().year());
+ expect(this.weekView.todayDayOfYear).toBe(now().dayOfYear());
+ });
+
+ it("initializes the data source & state with the correct times", () => {
+ expect(this.dataSource.startTime).toBe(NOW_BUFFER_START.unix());
+ expect(this.dataSource.endTime).toBe(NOW_BUFFER_END.unix());
+ expect(this.weekView.state.startMoment.unix()).toBe(NOW_BUFFER_START.unix());
+ expect(this.weekView.state.endMoment.unix()).toBe(NOW_BUFFER_END.unix());
+ expect(this.weekView._scrollTime).toBe(NOW_WEEK_START.unix())
+ });
+
+ it("has the correct days in buffer", () => {
+ const days = this.weekView._daysInView();
+ expect(days.length).toBe(21);
+ expect(days[0].dayOfYear()).toBe(66)
+ expect(days[days.length - 1].dayOfYear()).toBe(86)
+ });
+
+ it("shows the correct current week", () => {
+ expect(this.weekView._currentWeekText()).toBe("March 13 - March 19 2016")
+ });
+
+ it("goes to next week on click", () => {
+ const nextBtn = this.weekView.refs.headerControls.refs.onNextAction
+ expect(this.weekView.state.startMoment.unix()).toBe(NOW_BUFFER_START.unix());
+ expect(this.weekView._scrollTime).toBe(NOW_WEEK_START.unix())
+
+ ReactTestUtils.Simulate.click(nextBtn);
+
+ expect((this.weekView.state.startMoment).unix())
+ .toBe(moment(NOW_BUFFER_START).add(1, 'week').unix());
+
+ expect(this.weekView._scrollTime)
+ .toBe(moment(NOW_WEEK_START).add(1, 'week').unix());
+ });
+
+ it("goes to the previous week on click", () => {
+ const prevBtn = this.weekView.refs.headerControls.refs.onPreviousAction
+ expect(this.weekView.state.startMoment.unix()).toBe(NOW_BUFFER_START.unix());
+ expect(this.weekView._scrollTime).toBe(NOW_WEEK_START.unix())
+
+ ReactTestUtils.Simulate.click(prevBtn);
+
+ expect((this.weekView.state.startMoment).unix())
+ .toBe(moment(NOW_BUFFER_START).subtract(1, 'week').unix());
+
+ expect(this.weekView._scrollTime)
+ .toBe(moment(NOW_WEEK_START).subtract(1, 'week').unix());
+ });
+
+ it("goes to 'today' when the 'today' btn is pressed", () => {
+ const todayBtn = this.weekView.refs.todayBtn;
+ const nextBtn = this.weekView.refs.headerControls.refs.onNextAction
+ ReactTestUtils.Simulate.click(nextBtn);
+ ReactTestUtils.Simulate.click(todayBtn)
+
+ expect(this.weekView.state.startMoment.unix()).toBe(NOW_BUFFER_START.unix());
+ expect(this.weekView._scrollTime).toBe(NOW_WEEK_START.unix())
+ });
+
+ it("sets the interval height properly", () => {
+ expect(this.weekView.state.intervalHeight).toBe(21)
+ });
+
+ it("properly segments the events by day", () => {
+ const days = this.weekView._daysInView();
+ const eventsByDay = this.weekView._eventsByDay(days);
+
+ // See fixtures/events
+ expect(eventsByDay.allDay.length).toBe(numAllDayEvents);
+ for (const day of Object.keys(numByDay)) {
+ expect(eventsByDay[day].length).toBe(numByDay[day])
+ }
+ });
+
+ it("correctly stacks all day events", () => {
+ const height = this.weekView.refs.weekViewAllDayEvents.props.height;
+ // This means it's 3-high
+ expect(height).toBe(64);
+ });
+
+ it("correctly sets up the event overlap for a day", () => {
+ const days = this.weekView._daysInView();
+ const eventsByDay = this.weekView._eventsByDay(days);
+ const eventOverlap = this.weekView._eventOverlap(eventsByDay['1457856000']);
+ expect(eventOverlap).toEqual(eventOverlapForSunday)
+ });
+
+ it("renders the events onto the grid", () => {
+ const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.weekView);
+
+ const events = $("calendar-event");
+ const standardEvents = $("calendar-event vertical");
+ const allDayEvents = $("calendar-event horizontal");
+
+ expect(events.length).toBe(numStandardEvents + numAllDayEvents)
+ expect(standardEvents.length).toBe(numStandardEvents)
+ expect(allDayEvents.length).toBe(numAllDayEvents)
+ });
+
+ it("finds the correct data from mouse events", () => {
+ const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.weekView);
+
+ const eventContainer = this.weekView.refs.calendarEventContainer;
+
+ // Unfortunately, _dataFromMouseEvent requires the component to both
+ // be mounted and have size. To truly test this we'd have to load the
+ // integratino test environment. For now, we test that the event makes
+ // its way back to passed in callback handlers
+ const mouseData = {
+ x: 100,
+ y: 100,
+ width: 100,
+ height: 100,
+ time: now(),
+ }
+ spyOn(eventContainer, "_dataFromMouseEvent").andReturn(mouseData)
+
+ const eventEl = $("calendar-event vertical")[0];
+ ReactTestUtils.Simulate.mouseDown(eventEl, {x: 100, y: 100});
+
+ const mouseEvent = eventContainer._dataFromMouseEvent.calls[0].args[0];
+ expect(mouseEvent.x).toBe(100)
+ expect(mouseEvent.y).toBe(100)
+
+ const mouseDataOut = this.onCalendarMouseDown.calls[0].args[0]
+ expect(mouseDataOut.x).toEqual(mouseData.x)
+ expect(mouseDataOut.y).toEqual(mouseData.y)
+ expect(mouseDataOut.width).toEqual(mouseData.width)
+ expect(mouseDataOut.height).toEqual(mouseData.height)
+ expect(mouseDataOut.time.unix()).toEqual(mouseData.time.unix())
+ });
+});
diff --git a/packages/client-app/spec/components/participants-text-field-spec.jsx b/packages/client-app/spec/components/participants-text-field-spec.jsx
new file mode 100644
index 0000000000..2d66a9ab90
--- /dev/null
+++ b/packages/client-app/spec/components/participants-text-field-spec.jsx
@@ -0,0 +1,183 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { ContactStore, Contact } from 'nylas-exports';
+
+import { ParticipantsTextField } from 'nylas-component-kit';
+
+const participant1 = new Contact({
+ id: 'local-1',
+ email: 'ben@nylas.com',
+});
+const participant2 = new Contact({
+ id: 'local-2',
+ email: 'ben@example.com',
+ name: 'Ben Gotow',
+});
+const participant3 = new Contact({
+ id: 'local-3',
+ email: 'evan@nylas.com',
+ name: 'Evan Morikawa',
+});
+
+xdescribe('ParticipantsTextField', function ParticipantsTextFieldSpecs() {
+ beforeEach(() => {
+ spyOn(NylasEnv, "isMainWindow").andReturn(true)
+ this.propChange = jasmine.createSpy('change')
+
+ this.fieldName = 'to';
+ this.participants = {
+ to: [participant1, participant2],
+ cc: [participant3],
+ bcc: [],
+ };
+
+ this.renderedField = mount(
+
+ )
+ this.renderedInput = this.renderedField.find('input')
+
+ this.expectInputToYield = (input, expected) => {
+ const reviver = function reviver(k, v) {
+ if (k === "id" || k === "client_id" || k === "server_id" || k === "object") { return undefined; }
+ return v;
+ };
+ runs(() => {
+ this.renderedInput.simulate('change', {target: {value: input}});
+ advanceClock(100);
+ return this.renderedInput.simulate('keyDown', {key: 'Enter', keyCode: 9});
+ });
+ waitsFor(() => {
+ return this.propChange.calls.length > 0;
+ });
+ runs(() => {
+ let found = this.propChange.mostRecentCall.args[0];
+ found = JSON.parse(JSON.stringify(found), reviver);
+ expect(found).toEqual(JSON.parse(JSON.stringify(expected), reviver));
+
+ // This advance clock needs to be here because our waitsFor latch
+ // catches the first time that propChange gets called. More stuff
+ // may happen after this and we need to advance the clock to
+ // "clear" all of that. If we don't do this it throws errors about
+ // `setState` being called on unmounted components :(
+ return advanceClock(100);
+ });
+ };
+ });
+
+ it('renders into the document', () => {
+ expect(this.renderedField.find(ParticipantsTextField).length).toBe(1)
+ });
+
+ describe("inserting participant text", () => {
+ it("should fire onChange with an updated participants hash", () => {
+ this.expectInputToYield('abc@abc.com', {
+ to: [participant1, participant2, new Contact({name: 'abc@abc.com', email: 'abc@abc.com'})],
+ cc: [participant3],
+ bcc: [],
+ });
+ });
+
+ it("should remove added participants from other fields", () => {
+ this.expectInputToYield(participant3.email, {
+ to: [participant1, participant2, new Contact({name: participant3.email, email: participant3.email})],
+ cc: [],
+ bcc: [],
+ });
+ });
+
+ it("should use the name of an existing contact in the ContactStore if possible", () => {
+ spyOn(ContactStore, 'searchContacts').andCallFake((val) => {
+ if (val === participant3.name) {
+ return Promise.resolve([participant3]);
+ }
+ return Promise.resolve([]);
+ });
+
+ this.expectInputToYield(participant3.name, {
+ to: [participant1, participant2, participant3],
+ cc: [],
+ bcc: [],
+ });
+ });
+
+ it("should use the plain email if that's what's entered", () => {
+ spyOn(ContactStore, 'searchContacts').andCallFake((val) => {
+ if (val === participant3.name) {
+ return Promise.resolve([participant3]);
+ }
+ return Promise.resolve([]);
+ });
+
+ this.expectInputToYield(participant3.email, {
+ to: [participant1, participant2, new Contact({email: "evan@nylas.com"})],
+ cc: [],
+ bcc: [],
+ });
+ });
+
+ it("should not have the same contact auto-picked multiple times", () => {
+ spyOn(ContactStore, 'searchContacts').andCallFake((val) => {
+ if (val === participant2.name) {
+ return Promise.resolve([participant2]);
+ }
+ return Promise.resolve([])
+ });
+
+ this.expectInputToYield(participant2.name, {
+ to: [participant1, participant2, new Contact({email: participant2.name, name: participant2.name})],
+ cc: [participant3],
+ bcc: [],
+ });
+ });
+
+ describe("when text contains Name (Email) formatted data", () => {
+ it("should correctly parse it into named Contact objects", () => {
+ const newContact1 = new Contact({id: "b1", name: 'Ben Imposter', email: 'imposter@nylas.com'});
+ const newContact2 = new Contact({name: 'Nylas Team', email: 'feedback@nylas.com'});
+
+ const inputs = [
+ "Ben Imposter , Nylas Team ",
+ "\n\nbla\nBen Imposter (imposter@nylas.com), Nylas Team (feedback@nylas.com)",
+ "Hello world! I like cheese. \rBen Imposter (imposter@nylas.com)\nNylas Team (feedback@nylas.com)",
+ "Ben ImposterNylas Team (feedback@nylas.com)",
+ ];
+
+ for (const input of inputs) {
+ this.expectInputToYield(input, {
+ to: [participant1, participant2, newContact1, newContact2],
+ cc: [participant3],
+ bcc: [],
+ });
+ }
+ });
+ });
+
+ describe("when text contains emails mixed with garbage text", () => {
+ it("should still parse out emails into Contact objects", () => {
+ const newContact1 = new Contact({id: 'gm', name: 'garbage-man@nylas.com', email: 'garbage-man@nylas.com'});
+ const newContact2 = new Contact({id: 'rm', name: 'recycling-guy@nylas.com', email: 'recycling-guy@nylas.com'});
+
+ const inputs = [
+ "Hello world I real. \n asd. garbage-man@nylas.com—he's cool Also 'recycling-guy@nylas.com'!",
+ "garbage-man@nylas.com1WHOA I REALLY HATE DATA,recycling-guy@nylas.com",
+ "nils.com garbage-man@nylas.com @nylas.com nope@.com nope! recycling-guy@nylas.com HOLLA AT recycling-guy@nylas.",
+ ];
+
+ for (const input of inputs) {
+ this.expectInputToYield(input, {
+ to: [participant1, participant2, newContact1, newContact2],
+ cc: [participant3],
+ bcc: [],
+ });
+ }
+ });
+ });
+ });
+});
diff --git a/packages/client-app/spec/components/selectable-table-spec.jsx b/packages/client-app/spec/components/selectable-table-spec.jsx
new file mode 100644
index 0000000000..7f9b2606e3
--- /dev/null
+++ b/packages/client-app/spec/components/selectable-table-spec.jsx
@@ -0,0 +1,257 @@
+import React from 'react'
+import {mount, shallow} from 'enzyme'
+import {Table, SelectableTableCell, SelectableTableRow, SelectableTable} from 'nylas-component-kit'
+import {selection, cellProps, rowProps, tableProps, testDataSource} from '../fixtures/table-data'
+
+
+describe('SelectableTable Components', function describeBlock() {
+ describe('SelectableTableCell', () => {
+ function renderCell(props) {
+ return shallow(
+
+ )
+ }
+
+ describe('shouldComponentUpdate', () => {
+ it('should update if selection status for cell has changed', () => {
+ const nextSelection = {colIdx: 0, rowIdx: 2}
+ const cell = renderCell()
+ const nextProps = {...cellProps, selection: nextSelection}
+ const shouldUpdate = cell.instance().shouldComponentUpdate(nextProps)
+ expect(shouldUpdate).toBe(true)
+ });
+
+ it('should update if data for cell has changed', () => {
+ const nextRows = testDataSource.rows().slice()
+ nextRows[0] = ['something else', 2]
+ const nextDataSource = testDataSource.setRows(nextRows)
+ const cell = renderCell()
+ const nextProps = {...cellProps, tableDataSource: nextDataSource}
+ const shouldUpdate = cell.instance().shouldComponentUpdate(nextProps)
+ expect(shouldUpdate).toBe(true)
+ });
+
+ it('should not update otherwise', () => {
+ const nextRows = testDataSource.rows().slice()
+ nextRows[0] = nextRows[0].slice()
+ const nextDataSource = testDataSource.setRows(nextRows)
+ const nextSelection = {...selection}
+ const cell = renderCell()
+ const nextProps = {...cellProps, selection: nextSelection, tableDataSource: nextDataSource}
+ const shouldUpdate = cell.instance().shouldComponentUpdate(nextProps)
+ expect(shouldUpdate).toBe(false)
+ });
+ });
+
+ describe('isSelected', () => {
+ it('returns true if selection matches props', () => {
+ const cell = renderCell()
+ expect(cell.instance().isSelected(cellProps)).toBe(true)
+ });
+
+ it('returns false otherwise', () => {
+ const cell = renderCell()
+ expect(cell.instance().isSelected({
+ ...cellProps,
+ selection: {rowIdx: 1, colIdx: 2},
+ })).toBe(false)
+ });
+ });
+
+ describe('isSelectedUsingKey', () => {
+ it('returns true if cell was selected using the provided key', () => {
+ const cell = renderCell({selection: {...selection, key: 'Enter'}})
+ expect(cell.instance().isSelectedUsingKey('Enter')).toBe(true)
+ });
+
+ it('returns false if cell was not selected using the provided key', () => {
+ const cell = renderCell()
+ expect(cell.instance().isSelectedUsingKey('Enter')).toBe(false)
+ });
+ });
+
+ describe('isInLastRow', () => {
+ it('returns true if cell is in last row', () => {
+ const cell = renderCell({rowIdx: 2})
+ expect(cell.instance().isInLastRow()).toBe(true)
+ });
+
+ it('returns true if cell is not in last row', () => {
+ const cell = renderCell()
+ expect(cell.instance().isInLastRow()).toBe(false)
+ });
+ });
+
+ it('renders with the appropriate className when selected', () => {
+ const cell = renderCell()
+ expect(cell.hasClass('selected')).toBe(true)
+ });
+
+ it('renders with the appropriate className when not selected', () => {
+ const cell = renderCell({rowIdx: 2, colIdx: 1})
+ expect(cell.hasClass('selected')).toBe(false)
+ });
+
+ it('renders any extra classNames', () => {
+ const cell = renderCell({className: 'my-cell'})
+ expect(cell.hasClass('my-cell')).toBe(true)
+ });
+ });
+
+ describe('SelectableTableRow', () => {
+ function renderRow(props) {
+ return shallow(
+
+ )
+ }
+
+ describe('shouldComponentUpdate', () => {
+ it('should update if the row data has changed', () => {
+ const nextRows = testDataSource.rows().slice()
+ nextRows[0] = ['new', 'row']
+ const nextDataSource = testDataSource.setRows(nextRows)
+ const row = renderRow()
+ const shouldUpdate = row.instance().shouldComponentUpdate({...rowProps, tableDataSource: nextDataSource})
+ expect(shouldUpdate).toBe(true)
+ });
+
+ it('should update if selection status for row has changed', () => {
+ const nextSelection = {rowIdx: 2, colIdx: 0}
+ const row = renderRow()
+ const shouldUpdate = row.instance().shouldComponentUpdate({...rowProps, selection: nextSelection})
+ expect(shouldUpdate).toBe(true)
+ });
+
+ it('should update even if row is still selected but selected cell has changed', () => {
+ const nextSelection = {rowIdx: 1, colIdx: 1}
+ const row = renderRow()
+ const shouldUpdate = row.instance().shouldComponentUpdate({...rowProps, selection: nextSelection})
+ expect(shouldUpdate).toBe(true)
+ });
+
+ it('should not update otherwise', () => {
+ const nextRows = testDataSource.rows().slice()
+ const nextDataSource = testDataSource.setRows(nextRows)
+ const nextSelection = {...selection}
+ const row = renderRow()
+ const nextProps = {...rowProps, selection: nextSelection, tableDataSource: nextDataSource}
+ const shouldUpdate = row.instance().shouldComponentUpdate(nextProps)
+ expect(shouldUpdate).toBe(false)
+ });
+ });
+
+ describe('isSelected', () => {
+ it('returns true when selection matches props', () => {
+ const row = renderRow()
+ expect(row.instance().isSelected({
+ selection: {rowIdx: 1},
+ rowIdx: 1,
+ })).toBe(true)
+ });
+
+ it('returns false otherwise', () => {
+ const row = renderRow()
+ expect(row.instance().isSelected({
+ selection: {rowIdx: 2},
+ rowIdx: 1,
+ })).toBe(false)
+ });
+ });
+
+ it('renders with the appropriate className when selected', () => {
+ const row = renderRow()
+ expect(row.hasClass('selected')).toBe(true)
+ });
+
+ it('renders with the appropriate className when not selected', () => {
+ const row = renderRow({selection: {rowIdx: 2, colIdx: 0}})
+ expect(row.hasClass('selected')).toBe(false)
+ });
+
+ it('renders any extra classNames', () => {
+ const row = renderRow({className: 'my-row'})
+ expect(row.hasClass('my-row')).toBe(true)
+ });
+ });
+
+ describe('SelectableTable', () => {
+ function renderTable(props) {
+ return mount(
+
+ )
+ }
+
+ describe('onTab', () => {
+ it('shifts selection to the next row if last column is selected', () => {
+ const onShiftSelection = jasmine.createSpy('onShiftSelection')
+ const table = renderTable({selection: {colIdx: 2, rowIdx: 1}, onShiftSelection})
+ table.instance().onTab({key: 'Tab'})
+ expect(onShiftSelection).toHaveBeenCalledWith({
+ row: 1, col: -2, key: 'Tab',
+ })
+ });
+
+ it('shifts selection to the next col otherwise', () => {
+ const onShiftSelection = jasmine.createSpy('onShiftSelection')
+ const table = renderTable({selection: {colIdx: 0, rowIdx: 1}, onShiftSelection})
+ table.instance().onTab({key: 'Tab'})
+ expect(onShiftSelection).toHaveBeenCalledWith({
+ col: 1, key: 'Tab',
+ })
+ });
+ });
+
+ describe('onShiftTab', () => {
+ it('shifts selection to the previous row if first column is selected', () => {
+ const onShiftSelection = jasmine.createSpy('onShiftSelection')
+ const table = renderTable({selection: {colIdx: 0, rowIdx: 2}, onShiftSelection})
+ table.instance().onShiftTab({key: 'Tab'})
+ expect(onShiftSelection).toHaveBeenCalledWith({
+ row: -1, col: 2, key: 'Tab',
+ })
+ });
+
+ it('shifts selection to the previous col otherwise', () => {
+ const onShiftSelection = jasmine.createSpy('onShiftSelection')
+ const table = renderTable({selection: {colIdx: 1, rowIdx: 1}, onShiftSelection})
+ table.instance().onShiftTab({key: 'Tab'})
+ expect(onShiftSelection).toHaveBeenCalledWith({
+ col: -1, key: 'Tab',
+ })
+ });
+ });
+
+ it('renders with the correct props', () => {
+ const RowRenderer = () =>
+ const CellRenderer = () =>
+ const onSetSelection = () => {}
+ const onShiftSelection = () => {}
+ const extraProps = {p1: 'p1'}
+ const table = renderTable({
+ extraProps,
+ onSetSelection,
+ onShiftSelection,
+ RowRenderer,
+ CellRenderer,
+ }).find(Table)
+ expect(table.prop('extraProps')).toEqual({
+ p1: 'p1',
+ selection,
+ onSetSelection,
+ onShiftSelection,
+ })
+ expect(table.prop('tableDataSource')).toBe(testDataSource)
+ expect(table.prop('RowRenderer')).toBe(RowRenderer)
+ expect(table.prop('CellRenderer')).toBe(CellRenderer)
+ });
+ });
+});
diff --git a/packages/client-app/spec/components/table/table-data-source-spec.jsx b/packages/client-app/spec/components/table/table-data-source-spec.jsx
new file mode 100644
index 0000000000..0790cb92b8
--- /dev/null
+++ b/packages/client-app/spec/components/table/table-data-source-spec.jsx
@@ -0,0 +1,199 @@
+import {
+ testData,
+ testDataSource,
+ testDataSourceEmpty,
+ testDataSourceUneven,
+} from '../../fixtures/table-data'
+
+
+describe('TableDataSource', function describeBlock() {
+ describe('colAt', () => {
+ it('returns the correct value for column', () => {
+ expect(testDataSource.colAt(1)).toEqual('col2')
+ });
+
+ it('returns null if col does not exist', () => {
+ expect(testDataSource.colAt(3)).toBe(null)
+ });
+ });
+
+ describe('rowAt', () => {
+ it('returns correct row', () => {
+ expect(testDataSource.rowAt(1)).toEqual([4, 5, 6])
+ });
+
+ it('returns columns if rowIdx is null', () => {
+ expect(testDataSource.rowAt(null)).toEqual(['col1', 'col2', 'col3'])
+ });
+
+ it('returns null if row does not exist', () => {
+ expect(testDataSource.rowAt(3)).toBe(null)
+ });
+ });
+
+ describe('cellAt', () => {
+ it('returns correct cell', () => {
+ expect(testDataSource.cellAt({rowIdx: 1, colIdx: 1})).toEqual(5)
+ });
+
+ it('returns correct col if rowIdx is null', () => {
+ expect(testDataSource.cellAt({rowIdx: null, colIdx: 1})).toEqual('col2')
+ });
+
+ it('returns null if cell does not exist', () => {
+ expect(testDataSource.cellAt({rowIdx: 3, colIdx: 1})).toBe(null)
+ expect(testDataSource.cellAt({rowIdx: 1, colIdx: 3})).toBe(null)
+ });
+ });
+
+ describe('isEmpty', () => {
+ it('throws if no args passed', () => {
+ expect(() => testDataSource.isEmpty()).toThrow()
+ });
+
+ it('throws if row does not exist', () => {
+ expect(() => testDataSource.isEmpty({rowIdx: 100})).toThrow()
+ });
+
+ it('throws if col does not exist', () => {
+ expect(() => testDataSource.isEmpty({colIdx: 100})).toThrow()
+ });
+
+ it('returns correct value when checking cell', () => {
+ expect(testDataSourceEmpty.isEmpty({rowIdx: 2, colIdx: 1})).toBe(true)
+ expect(testDataSourceEmpty.isEmpty({rowIdx: 3, colIdx: 1})).toBe(true)
+ expect(testDataSourceEmpty.isEmpty({rowIdx: 0, colIdx: 0})).toBe(false)
+ });
+
+ it('returns correct value when checking col', () => {
+ expect(testDataSourceEmpty.isEmpty({colIdx: 2})).toBe(true)
+ expect(testDataSourceEmpty.isEmpty({colIdx: 0})).toBe(false)
+ });
+
+ it('returns correct value when checking row', () => {
+ expect(testDataSourceEmpty.isEmpty({rowIdx: 2})).toBe(true)
+ expect(testDataSourceEmpty.isEmpty({rowIdx: 3})).toBe(true)
+ expect(testDataSourceEmpty.isEmpty({rowIdx: 1})).toBe(false)
+ });
+ });
+
+ describe('rows', () => {
+ it('returns all rows', () => {
+ expect(testDataSource.rows()).toBe(testData.rows)
+ });
+ });
+
+ describe('columns', () => {
+ it('returns all columns', () => {
+ expect(testDataSource.columns()).toBe(testData.columns)
+ });
+ });
+
+ describe('addColumn', () => {
+ it('pushes a new column to the data source\'s columns', () => {
+ const res = testDataSource.addColumn()
+ expect(res.columns()).toEqual(['col1', 'col2', 'col3', null])
+ });
+
+ it('pushes a new column to every row', () => {
+ const res = testDataSource.addColumn()
+ expect(res.rows()).toEqual([
+ [1, 2, 3, null],
+ [4, 5, 6, null],
+ [7, 8, 9, null],
+ ])
+ });
+ });
+
+ describe('removeLastColumn', () => {
+ it('removes last column from the data source\'s columns', () => {
+ const res = testDataSource.removeLastColumn()
+ expect(res.columns()).toEqual(['col1', 'col2'])
+ });
+
+ it('removes last column from every row', () => {
+ const res = testDataSource.removeLastColumn()
+ expect(res.rows()).toEqual([
+ [1, 2],
+ [4, 5],
+ [7, 8],
+ ])
+ });
+
+ it('removes the last column only from every row with that column', () => {
+ const res = testDataSourceUneven.removeLastColumn()
+ expect(res.rows()).toEqual([
+ [1, 2],
+ [4, 5],
+ [7, 8],
+ ])
+ })
+ });
+
+ describe('addRow', () => {
+ it('pushes an empty row with correct number of columns', () => {
+ const res = testDataSource.addRow()
+ expect(res.rows()).toEqual([
+ [1, 2, 3],
+ [4, 5, 6],
+ [7, 8, 9],
+ [null, null, null],
+ ])
+ });
+ });
+
+ describe('removeRow', () => {
+ it('removes last row', () => {
+ const res = testDataSource.removeRow()
+ expect(res.rows()).toEqual([
+ [1, 2, 3],
+ [4, 5, 6],
+ ])
+ });
+ });
+
+ describe('updateCell', () => {
+ it('updates cell value correctly when updating a cell that is /not/ a header', () => {
+ const res = testDataSource.updateCell({
+ rowIdx: 0, colIdx: 0, isHeader: false, value: 'new-val',
+ })
+ expect(res.columns()).toBe(testDataSource.columns())
+ expect(res.rows()).toEqual([
+ ['new-val', 2, 3],
+ [4, 5, 6],
+ [7, 8, 9],
+ ])
+
+ // If a row doesn't change, it should be the same row
+ testDataSource.rows().slice(1).forEach((row, rowIdx) => expect(row).toBe(testDataSource.rowAt(rowIdx + 1)))
+ });
+
+ it('updates cell value correctly when updating a cell that /is/ a header', () => {
+ const res = testDataSource.updateCell({
+ rowIdx: null, colIdx: 0, isHeader: true, value: 'new-val',
+ })
+ expect(res.columns()).toEqual(['new-val', 'col2', 'col3'])
+ expect(res.rows()).toBe(testDataSource.rows())
+
+ // If a row doesn't change, it should be the same row
+ testDataSource.rows().forEach((row, rowIdx) => expect(row).toBe(testDataSource.rowAt(rowIdx)))
+ });
+ });
+
+ describe('clear', () => {
+ it('clears all data correcltly', () => {
+ const res = testDataSource.clear()
+ expect(res.toJSON()).toEqual({
+ columns: [],
+ rows: [[]],
+ })
+ });
+ });
+
+ describe('toJSON', () => {
+ it('returns correct json object from data source', () => {
+ const res = testDataSource.toJSON()
+ expect(res).toEqual(testData)
+ });
+ });
+});
diff --git a/packages/client-app/spec/components/table/table-spec.jsx b/packages/client-app/spec/components/table/table-spec.jsx
new file mode 100644
index 0000000000..17a3146169
--- /dev/null
+++ b/packages/client-app/spec/components/table/table-spec.jsx
@@ -0,0 +1,195 @@
+import React from 'react'
+import {shallow} from 'enzyme'
+import {Table, TableRow, TableCell, LazyRenderedList} from 'nylas-component-kit'
+import {testDataSource} from '../../fixtures/table-data'
+
+
+describe('Table Components', function describeBlock() {
+ describe('TableCell', () => {
+ it('renders children correctly', () => {
+ const element = shallow(Cell )
+ expect(element.text()).toEqual('Cell')
+ });
+
+ it('renders a th when is header', () => {
+ const element = shallow( )
+ expect(element.type()).toEqual('th')
+ });
+
+ it('renders a td when is not header', () => {
+ const element = shallow( )
+ expect(element.type()).toEqual('td')
+ });
+
+ it('renders extra classNames', () => {
+ const element = shallow( )
+ expect(element.hasClass('my-cell')).toBe(true)
+ });
+
+ it('passes additional props to cell', () => {
+ const handler = () => {}
+ const element = shallow( )
+ expect(element.prop('onClick')).toBe(handler)
+ });
+ });
+
+ describe('TableRow', () => {
+ function renderRow(props = {}) {
+ return shallow(
+
+ )
+ }
+
+ it('renders extra classNames', () => {
+ const row = renderRow({className: 'my-row'})
+ expect(row.hasClass('my-row')).toBe(true)
+ });
+
+ it('renders correct className when row is header', () => {
+ const row = renderRow({isHeader: true})
+ expect(row.hasClass('table-row-header')).toBe(true)
+ });
+
+ it('renders cells correctly given the tableDataSource', () => {
+ const row = renderRow()
+ expect(row.children().length).toBe(3)
+ row.children().forEach((cell, idx) => {
+ expect(cell.type()).toBe(TableCell)
+ expect(cell.childAt(0).text()).toEqual(`${idx + 1}`)
+ })
+ });
+
+ it('renders cells correctly if row is header', () => {
+ const row = renderRow({isHeader: true, rowIdx: null})
+ expect(row.children().length).toBe(3)
+ row.children().forEach((cell, idx) => {
+ expect(cell.type()).toBe(TableCell)
+ expect(cell.childAt(0).text()).toEqual(`col${idx + 1}`)
+ })
+ });
+
+ it('renders an empty first cell if displayNumbers is specified and is header', () => {
+ const row = renderRow({displayNumbers: true, isHeader: true, rowIdx: null})
+ const cell = row.childAt(0)
+ expect(row.children().length).toBe(4)
+ expect(cell.type()).toBe(TableCell)
+ expect(cell.hasClass('numbered-cell')).toBe(true)
+ expect(cell.childAt(0).text()).toEqual('')
+ });
+
+ it('renders first cell with row number if displayNumbers specified', () => {
+ const row = renderRow({displayNumbers: true})
+ expect(row.children().length).toBe(4)
+
+ const cell = row.childAt(0)
+ expect(cell.type()).toBe(TableCell)
+ expect(cell.hasClass('numbered-cell')).toBe(true)
+ expect(cell.childAt(0).text()).toEqual('1')
+ });
+
+ it('renders cell correctly given the CellRenderer', () => {
+ const CellRenderer = (props) =>
+ const row = renderRow({CellRenderer})
+ expect(row.children().length).toBe(3)
+ row.children().forEach((cell) => {
+ expect(cell.type()).toBe(CellRenderer)
+ })
+ });
+
+ it('passes correct props to children cells', () => {
+ const extraProps = {prop1: 'prop1'}
+ const row = renderRow({extraProps})
+ expect(row.children().length).toBe(3)
+ row.children().forEach((cell, idx) => {
+ expect(cell.type()).toBe(TableCell)
+ expect(cell.prop('rowIdx')).toEqual(0)
+ expect(cell.prop('colIdx')).toEqual(idx)
+ expect(cell.prop('prop1')).toEqual('prop1')
+ expect(cell.prop('tableDataSource')).toBe(testDataSource)
+ })
+ });
+ });
+
+ describe('Table', () => {
+ function renderTable(props = {}) {
+ return shallow()
+ }
+
+ it('renders extra classNames', () => {
+ const table = renderTable({className: 'my-table'})
+ expect(table.hasClass('nylas-table')).toBe(true)
+ expect(table.hasClass('my-table')).toBe(true)
+ });
+
+ describe('renderHeader', () => {
+ it('renders nothing if displayHeader is not specified', () => {
+ const table = renderTable({displayHeader: false})
+ expect(table.find('thead').length).toBe(0)
+ });
+
+ it('renders header row with the given RowRenderer', () => {
+ const RowRenderer = (props) =>
+ const table = renderTable({displayHeader: true, RowRenderer})
+ const header = table.find('thead').childAt(0)
+ expect(header.type()).toBe(RowRenderer)
+ });
+
+ it('passes correct props to header row', () => {
+ const table = renderTable({displayHeader: true, displayNumbers: true, extraProps: {p1: 'p1'}})
+ const header = table.find('thead').childAt(0)
+ expect(header.type()).toBe(TableRow)
+ expect(header.prop('rowIdx')).toBe(null)
+ expect(header.prop('tableDataSource')).toBe(testDataSource)
+ expect(header.prop('displayNumbers')).toBe(true)
+ expect(header.prop('isHeader')).toBe(true)
+ expect(header.prop('p1')).toEqual('p1')
+ expect(header.prop('extraProps')).toEqual({isHeader: true, p1: 'p1'})
+ });
+ });
+
+ describe('renderBody', () => {
+ it('renders a lazy list with correct rows when header should not be displayed', () => {
+ const table = renderTable()
+ const body = table.find(LazyRenderedList)
+ expect(body.prop('items')).toEqual(testDataSource.rows())
+ expect(body.prop('BufferTag')).toEqual('tr')
+ expect(body.prop('RootRenderer')).toEqual('tbody')
+ });
+ });
+
+ describe('renderRow', () => {
+ it('renders row with the given RowRenderer', () => {
+ const RowRenderer = (props) =>
+ const table = renderTable({RowRenderer})
+ const Renderer = table.instance().renderRow
+ const row = shallow( )
+ expect(row.type()).toBe(RowRenderer)
+ });
+
+ it('passes the correct props to the row when displayHeader is true', () => {
+ const CellRenderer = (props) =>
+ const extraProps = {p1: 'p1'}
+ const table = renderTable({displayHeader: true, displayNumbers: true, extraProps, CellRenderer})
+ const Renderer = table.instance().renderRow
+ const row = shallow( )
+ expect(row.prop('p1')).toEqual('p1')
+ expect(row.prop('rowIdx')).toBe(5)
+ expect(row.prop('displayNumbers')).toBe(true)
+ expect(row.prop('tableDataSource')).toBe(testDataSource)
+ expect(row.prop('extraProps')).toBe(extraProps)
+ expect(row.prop('CellRenderer')).toBe(CellRenderer)
+ });
+
+ it('passes the correct props to the row when displayHeader is false', () => {
+ const table = renderTable({displayHeader: false})
+ const Renderer = table.instance().renderRow
+ const row = shallow( )
+ expect(row.prop('rowIdx')).toBe(5)
+ });
+ });
+ });
+});
diff --git a/packages/client-app/spec/components/tokenizing-text-field-spec.cjsx b/packages/client-app/spec/components/tokenizing-text-field-spec.cjsx
new file mode 100644
index 0000000000..6378feec30
--- /dev/null
+++ b/packages/client-app/spec/components/tokenizing-text-field-spec.cjsx
@@ -0,0 +1,386 @@
+_ = require 'underscore'
+React = require 'react'
+ReactDOM = require 'react-dom'
+{mount} = require 'enzyme'
+
+
+{NylasTestUtils,
+ Account,
+ AccountStore,
+ Contact,
+} = require 'nylas-exports'
+{TokenizingTextField, Menu} = require 'nylas-component-kit'
+
+CustomToken = React.createClass
+ render: ->
+ {@props.token.email}
+
+CustomSuggestion = React.createClass
+ render: ->
+ {@props.item.email}
+
+participant1 = new Contact
+ id: '1'
+ email: 'ben@nylas.com'
+ isSearchIndexed: false
+participant2 = new Contact
+ id: '2'
+ email: 'burgers@nylas.com'
+ name: 'Nylas Burger Basket'
+ isSearchIndexed: false
+participant3 = new Contact
+ id: '3'
+ email: 'evan@nylas.com'
+ name: 'Evan'
+ isSearchIndexed: false
+participant4 = new Contact
+ id: '4'
+ email: 'tester@elsewhere.com',
+ name: 'Tester'
+ isSearchIndexed: false
+participant5 = new Contact
+ id: '5'
+ email: 'michael@elsewhere.com',
+ name: 'Michael'
+ isSearchIndexed: false
+
+describe 'TokenizingTextField', ->
+ beforeEach ->
+ @completions = []
+ @propAdd = jasmine.createSpy 'add'
+ @propEdit = jasmine.createSpy 'edit'
+ @propRemove = jasmine.createSpy 'remove'
+ @propEmptied = jasmine.createSpy 'emptied'
+ @propTokenKey = jasmine.createSpy("tokenKey").andCallFake (p) -> p.email
+ @propTokenIsValid = jasmine.createSpy("tokenIsValid").andReturn(true)
+ @propTokenRenderer = CustomToken
+ @propOnTokenAction = jasmine.createSpy 'tokenAction'
+ @propCompletionNode = (p) ->
+ @propCompletionsForInput = (input) => @completions
+
+ spyOn(@, 'propCompletionNode').andCallThrough()
+ spyOn(@, 'propCompletionsForInput').andCallThrough()
+
+ @tokens = [participant1, participant2, participant3]
+
+ @rebuildRenderedField = (tokens) =>
+ tokens ?= @tokens
+ @renderedField = mount(
+
+ )
+ @renderedInput = @renderedField.find('input')
+ return @renderedField
+
+ @rebuildRenderedField()
+
+ it 'renders into the document', ->
+ expect(@renderedField.find(TokenizingTextField).length).toBe(1)
+
+ it 'should render an input field', ->
+ expect(@renderedInput).toBeDefined()
+
+ it 'shows the tokens provided by the tokenRenderer', ->
+ expect(@renderedField.find(CustomToken).length).toBe(@tokens.length)
+
+ it 'shows the tokens in the correct order', ->
+ @renderedTokens = @renderedField.find(CustomToken)
+ for i in [0..@tokens.length-1]
+ expect(@renderedTokens.at(i).props().token).toBe(@tokens[i])
+
+ describe "prop: tokenIsValid", ->
+ it "should be evaluated for each token when it's provided", ->
+ @propTokenIsValid = jasmine.createSpy("tokenIsValid").andCallFake (p) =>
+ if p is participant2 then true else false
+
+ @rebuildRenderedField()
+ @tokens = @renderedField.find(TokenizingTextField.Token)
+ expect(@tokens.at(0).props().valid).toBe(false)
+ expect(@tokens.at(1).props().valid).toBe(true)
+ expect(@tokens.at(2).props().valid).toBe(false)
+
+ it "should default to true when not provided", ->
+ @propTokenIsValid = null
+ @rebuildRenderedField()
+ @tokens = @renderedField.find(TokenizingTextField.Token)
+ expect(@tokens.at(0).props().valid).toBe(true)
+ expect(@tokens.at(1).props().valid).toBe(true)
+ expect(@tokens.at(2).props().valid).toBe(true)
+
+ describe "when the user drags and drops a token between two fields", ->
+ it "should work properly", ->
+ participant2.clientId = '123'
+
+ tokensA = [participant1, participant2, participant3]
+ fieldA = @rebuildRenderedField(tokensA)
+
+ tokensB = []
+ fieldB = @rebuildRenderedField(tokensB)
+
+ tokenIndexToDrag = 1
+ token = fieldA.find('.token').at(tokenIndexToDrag)
+
+ dragStartEventData = {}
+ dragStartEvent =
+ dataTransfer:
+ setData: (type, val) ->
+ dragStartEventData[type] = val
+ token.simulate('dragStart', dragStartEvent)
+
+ expect(dragStartEventData).toEqual({
+ 'nylas-token-items': '[{"client_id":"123","server_id":"2","name":"Nylas Burger Basket","email":"burgers@nylas.com","thirdPartyData":{},"is_search_indexed":false,"id":"2","__constructorName":"Contact"}]'
+ 'text/plain': 'Nylas Burger Basket '
+ })
+
+ dropEvent =
+ dataTransfer:
+ types: Object.keys(dragStartEventData)
+ getData: (type) -> dragStartEventData[type]
+
+ fieldB.ref('field-drop-target').simulate('drop', dropEvent)
+
+ expect(@propAdd).toHaveBeenCalledWith([tokensA[tokenIndexToDrag]])
+
+ describe "When the user selects a token", ->
+ beforeEach ->
+ token = @renderedField.find('.token').first()
+ token.simulate('click')
+
+ it "should set the selectedKeys state", ->
+ expect(@renderedField.state().selectedKeys).toEqual([participant1.email])
+
+ it "should return the appropriate token object", ->
+ expect(@propTokenKey).toHaveBeenCalledWith(participant1)
+ expect(@renderedField.find('.token.selected').length).toEqual(1)
+
+ describe "when focused", ->
+ it 'should receive the `focused` class', ->
+ expect(@renderedField.find('.focused').length).toBe(0)
+ @renderedInput.simulate('focus')
+ expect(@renderedField.find('.focused').length).toBe(1)
+
+ describe "when the user types in the input", ->
+ it 'should fetch completions for the text', ->
+ @renderedInput.simulate('change', {target: {value: 'abc'}})
+ advanceClock(1000)
+ expect(@propCompletionsForInput.calls[0].args[0]).toBe('abc')
+
+ it 'should fetch completions on focus', ->
+ @renderedField.setState({inputValue: "abc"})
+ @renderedInput.simulate('focus')
+ advanceClock(1000)
+ expect(@propCompletionsForInput.calls[0].args[0]).toBe('abc')
+
+ it 'should display the completions', ->
+ @completions = [participant4, participant5]
+ @renderedInput.simulate('change', {target: {value: 'abc'}})
+
+ components = @renderedField.find(CustomSuggestion)
+ expect(components.length).toBe(2)
+ expect(components.at(0).props().item).toBe(participant4)
+ expect(components.at(1).props().item).toBe(participant5)
+
+ it 'should not display items with keys matching items already in the token field', ->
+ @completions = [participant2, participant4, participant1]
+ @renderedInput.simulate('change', {target: {value: 'abc'}})
+
+ components = @renderedField.find(CustomSuggestion)
+ expect(components.length).toBe(1)
+ expect(components.at(0).props().item).toBe(participant4)
+
+ it 'should highlight the first completion', ->
+ @completions = [participant4, participant5]
+ @renderedInput.simulate('change', {target: {value: 'abc'}})
+ components = @renderedField.find(Menu.Item)
+ menuItem = components.first()
+ expect(menuItem.props().selected).toBe true
+
+ it 'select the clicked element', ->
+ @completions = [participant4, participant5]
+ @renderedInput.simulate('change', {target: {value: 'abc'}})
+ components = @renderedField.find(Menu.Item)
+ menuItem = components.first()
+ menuItem.simulate('mouseDown')
+ expect(@propAdd).toHaveBeenCalledWith([participant4])
+
+ it "doesn't sumbmit if it looks like an email but has no space at the end", ->
+ @renderedInput.simulate('change', {target: {value: 'abc@foo.com'}})
+ advanceClock(10)
+ expect(@propCompletionsForInput.calls[0].args[0]).toBe('abc@foo.com')
+ expect(@propAdd).not.toHaveBeenCalled()
+
+ it "allows spaces if what's currently being entered doesn't look like an email", ->
+ @renderedInput.simulate('change', {target: {value: 'ab'}})
+ advanceClock(10)
+ @renderedInput.simulate('change', {target: {value: 'ab '}})
+ advanceClock(10)
+ @renderedInput.simulate('change', {target: {value: 'ab c'}})
+ advanceClock(10)
+ expect(@propCompletionsForInput.calls[2].args[0]).toBe('ab c')
+ expect(@propAdd).not.toHaveBeenCalled()
+
+ [{key:'Enter', keyCode:13}, {key:',', keyCode: 188}].forEach ({key, keyCode}) ->
+ describe "when the user presses #{key}", ->
+ describe "and there is an completion available", ->
+ it "should call add with the first completion", ->
+ @completions = [participant4]
+ @renderedInput.simulate('change', {target: {value: 'abc'}})
+ @renderedInput.simulate('keyDown', {key: key, keyCode: keyCode})
+ expect(@propAdd).toHaveBeenCalledWith([participant4])
+
+ describe "and there is NO completion available", ->
+ it 'should call add, allowing the parent to (optionally) turn the text into a token', ->
+ @completions = []
+ @renderedInput.simulate('change', {target: {value: 'abc'}})
+ @renderedInput.simulate('keyDown', {key: key, keyCode: keyCode})
+ expect(@propAdd).toHaveBeenCalledWith('abc', {})
+
+ describe "when the user presses tab", ->
+ beforeEach ->
+ @tabDownEvent =
+ key: "Tab"
+ keyCode: 9
+ preventDefault: jasmine.createSpy('preventDefault')
+ stopPropagation: jasmine.createSpy('stopPropagation')
+
+ describe "and there is an completion available", ->
+ it "should call add with the first completion", ->
+ @completions = [participant4]
+ @renderedInput.simulate('change', {target: {value: 'abc'}})
+ @renderedInput.simulate('keyDown', @tabDownEvent)
+ expect(@propAdd).toHaveBeenCalledWith([participant4])
+ expect(@tabDownEvent.preventDefault).toHaveBeenCalled()
+ expect(@tabDownEvent.stopPropagation).toHaveBeenCalled()
+
+ it "shouldn't handle the event in the input is empty", ->
+ # We ignore on empty input values
+ @renderedInput.simulate('change', {target: {value: ' '}})
+ @renderedInput.simulate('keyDown', @tabDownEvent)
+ expect(@propAdd).not.toHaveBeenCalled()
+
+ it "should NOT stop the propagation if the input is empty.", ->
+ # This is to allow tabs to propagate up to controls that might want
+ # to change the focus later.
+ @renderedInput.simulate('change', {target: {value: ' '}})
+ @renderedInput.simulate('keyDown', @tabDownEvent)
+ expect(@propAdd).not.toHaveBeenCalled()
+ expect(@tabDownEvent.stopPropagation).not.toHaveBeenCalled()
+
+ it "should add the raw input value if there are no completions", ->
+ @completions = []
+ @renderedInput.simulate('change', {target: {value: 'abc'}})
+ @renderedInput.simulate('keyDown', @tabDownEvent)
+ expect(@propAdd).toHaveBeenCalledWith('abc', {})
+ expect(@tabDownEvent.preventDefault).toHaveBeenCalled()
+ expect(@tabDownEvent.stopPropagation).toHaveBeenCalled()
+
+ describe "when blurred", ->
+ it 'should do nothing if the relatedTarget is null meaning the app has been blurred', ->
+ @renderedInput.simulate('focus')
+ @renderedInput.simulate('change', {target: {value: 'text'}})
+ @renderedInput.simulate('blur', {relatedTarget: null})
+ expect(@propAdd).not.toHaveBeenCalled()
+ expect(@renderedField.find('.focused').length).toBe(1)
+
+ it 'should call add, allowing the parent component to (optionally) turn the entered text into a token', ->
+ @renderedInput.simulate('focus')
+ @renderedInput.simulate('change', {target: {value: 'text'}})
+ @renderedInput.simulate('blur', {relatedTarget: document.body})
+ expect(@propAdd).toHaveBeenCalledWith('text', {})
+
+ it 'should clear the entered text', ->
+ @renderedInput.simulate('focus')
+ @renderedInput.simulate('change', {target: {value: 'text'}})
+ @renderedInput.simulate('blur', {relatedTarget: document.body})
+ expect(@renderedInput.props().value).toBe('')
+
+ it 'should no longer have the `focused` class', ->
+ @renderedInput.simulate('focus')
+ expect(@renderedField.find('.focused').length).toBe(1)
+ @renderedInput.simulate('blur', {relatedTarget: document.body})
+ expect(@renderedField.find('.focused').length).toBe(0)
+
+ describe "cut", ->
+ it "removes the selected tokens", ->
+ @renderedField.setState({selectedKeys: [participant1.email]})
+ @renderedInput.simulate('cut')
+ expect(@propRemove).toHaveBeenCalledWith([participant1])
+ expect(@renderedField.find('.token.selected').length).toEqual(0)
+ expect(@propEmptied).not.toHaveBeenCalled()
+
+ describe "backspace", ->
+ describe "when no token is selected", ->
+ it "selects the last token first and doesn't remove", ->
+ @renderedInput.simulate('keyDown', {key: 'Backspace', keyCode: 8})
+ expect(@renderedField.find('.token.selected').length).toEqual(1)
+ expect(@propRemove).not.toHaveBeenCalled()
+ expect(@propEmptied).not.toHaveBeenCalled()
+
+ describe "when a token is selected", ->
+ it "removes that token and deselects", ->
+ @renderedField.setState({selectedKeys: [participant1.email]})
+ expect(@renderedField.find('.token.selected').length).toEqual(1)
+ @renderedInput.simulate('keyDown', {key: 'Backspace', keyCode: 8})
+ expect(@propRemove).toHaveBeenCalledWith([participant1])
+ expect(@renderedField.find('.token.selected').length).toEqual(0)
+ expect(@propEmptied).not.toHaveBeenCalled()
+
+ describe "when there are no tokens left", ->
+ it "fires onEmptied", ->
+ @renderedField.setProps({tokens: []})
+ expect(@renderedField.find('.token').length).toEqual(0)
+ @renderedInput.simulate('keyDown', {key: 'Backspace', keyCode: 8})
+ expect(@propEmptied).toHaveBeenCalled()
+
+describe "TokenizingTextField.Token", ->
+ describe "when an onEdit prop has been provided", ->
+ beforeEach ->
+ @propEdit = jasmine.createSpy('onEdit')
+ @propClick = jasmine.createSpy('onClick')
+ @token = mount(React.createElement(TokenizingTextField.Token, {
+ selected: false,
+ valid: true,
+ item: participant1,
+ onClick: @propClick,
+ onEdited: @propEdit,
+ onDragStart: jasmine.createSpy('onDragStart'),
+ }))
+
+ it "should enter editing mode", ->
+ expect(@token.state().editing).toBe(false)
+ @token.simulate('doubleClick', {})
+ expect(@token.state().editing).toBe(true)
+
+ it "should call onEdit to commit the new token value when the edit field is blurred", ->
+ expect(@token.state().editing).toBe(false)
+ @token.simulate('doubleClick', {})
+ tokenEditInput = @token.find('input')
+ tokenEditInput.simulate('change', {target: {value: 'new tag content'}})
+ tokenEditInput.simulate('blur')
+ expect(@propEdit).toHaveBeenCalledWith(participant1, 'new tag content')
+
+ describe "when no onEdit prop has been provided", ->
+ it "should not enter editing mode", ->
+ @token = mount(React.createElement(TokenizingTextField.Token, {
+ selected: false,
+ valid: true,
+ item: participant1,
+ onClick: jasmine.createSpy('onClick'),
+ onDragStart: jasmine.createSpy('onDragStart'),
+ onEdited: null,
+ }))
+ expect(@token.state().editing).toBe(false)
+ @token.simulate('doubleClick', {})
+ expect(@token.state().editing).toBe(false)
diff --git a/packages/client-app/spec/database-object-registry-spec.es6 b/packages/client-app/spec/database-object-registry-spec.es6
new file mode 100644
index 0000000000..0fdb785dd3
--- /dev/null
+++ b/packages/client-app/spec/database-object-registry-spec.es6
@@ -0,0 +1,40 @@
+/* eslint quote-props: 0 */
+import _ from 'underscore';
+import Model from '../src/flux/models/model';
+import Attributes from '../src/flux/attributes';
+import DatabaseObjectRegistry from '../src/registries/database-object-registry';
+
+class GoodTest extends Model {
+ static attributes = _.extend({}, Model.attributes, {
+ "foo": Attributes.String({
+ modelKey: 'foo',
+ jsonKey: 'foo',
+ }),
+ });
+}
+
+describe('DatabaseObjectRegistry', function DatabaseObjectRegistrySpecs() {
+ beforeEach(() => DatabaseObjectRegistry.unregister("GoodTest"));
+
+ it("can register constructors", () => {
+ const testFn = () => GoodTest;
+ expect(() => DatabaseObjectRegistry.register("GoodTest", testFn)).not.toThrow();
+ expect(DatabaseObjectRegistry.get("GoodTest")).toBe(GoodTest);
+ });
+
+ it("Tests if a constructor is in the registry", () => {
+ DatabaseObjectRegistry.register("GoodTest", () => GoodTest);
+ expect(DatabaseObjectRegistry.isInRegistry("GoodTest")).toBe(true);
+ });
+
+ it("deserializes the objects for a constructor", () => {
+ DatabaseObjectRegistry.register("GoodTest", () => GoodTest);
+ const obj = DatabaseObjectRegistry.deserialize("GoodTest", {foo: "bar"});
+ expect(obj instanceof GoodTest).toBe(true);
+ expect(obj.foo).toBe("bar");
+ });
+
+ it("throws an error if the object can't be deserialized", () =>
+ expect(() => DatabaseObjectRegistry.deserialize("GoodTest", {foo: "bar"})).toThrow()
+ );
+});
diff --git a/packages/client-app/spec/default-client-helper-spec.coffee b/packages/client-app/spec/default-client-helper-spec.coffee
new file mode 100644
index 0000000000..0ac6555202
--- /dev/null
+++ b/packages/client-app/spec/default-client-helper-spec.coffee
@@ -0,0 +1,215 @@
+_ = require 'underscore'
+proxyquire = require 'proxyquire'
+
+stubDefaultsJSON = null
+execHitory = []
+
+ChildProcess =
+ exec: (command, callback) ->
+ execHitory.push(arguments)
+ callback(null, '', null)
+
+fs =
+ exists: (path, callback) ->
+ callback(true)
+ readFile: (path, callback) ->
+ callback(null, JSON.stringify(stubDefaultsJSON))
+ readFileSync: (path) ->
+ JSON.stringify(stubDefaultsJSON)
+ writeFileSync: (path) ->
+ null
+ unlink: (path, callback) ->
+ callback(null) if callback
+
+DefaultClientHelper = proxyquire "../src/default-client-helper",
+ "child_process": ChildProcess
+ "fs": fs
+
+describe "DefaultClientHelper", ->
+ beforeEach ->
+ stubDefaultsJSON = [
+ {
+ LSHandlerRoleAll: 'com.apple.dt.xcode',
+ LSHandlerURLScheme: 'xcdoc'
+ },
+ {
+ LSHandlerRoleAll: 'com.fournova.tower',
+ LSHandlerURLScheme: 'github-mac'
+ },
+ {
+ LSHandlerRoleAll: 'com.fournova.tower',
+ LSHandlerURLScheme: 'sourcetree'
+ },
+ {
+ LSHandlerRoleAll: 'com.google.chrome',
+ LSHandlerURLScheme: 'http'
+ },
+ {
+ LSHandlerRoleAll: 'com.google.chrome',
+ LSHandlerURLScheme: 'https'
+ },
+ {
+ LSHandlerContentType: 'public.html',
+ LSHandlerRoleViewer: 'com.google.chrome'
+ },
+ {
+ LSHandlerContentType: 'public.url',
+ LSHandlerRoleViewer: 'com.google.chrome'
+ },
+ {
+ LSHandlerContentType: 'com.apple.ical.backup',
+ LSHandlerRoleAll: 'com.apple.ical'
+ },
+ {
+ LSHandlerContentTag: 'icalevent',
+ LSHandlerContentTagClass: 'public.filename-extension',
+ LSHandlerRoleAll: 'com.apple.ical'
+ },
+ {
+ LSHandlerContentTag: 'icaltodo',
+ LSHandlerContentTagClass: 'public.filename-extension',
+ LSHandlerRoleAll: 'com.apple.reminders'
+ },
+ {
+ LSHandlerRoleAll: 'com.apple.ical',
+ LSHandlerURLScheme: 'webcal'
+ },
+ {
+ LSHandlerContentTag: 'coffee',
+ LSHandlerContentTagClass: 'public.filename-extension',
+ LSHandlerRoleAll: 'com.sublimetext.2'
+ },
+ {
+ LSHandlerRoleAll: 'com.apple.facetime',
+ LSHandlerURLScheme: 'facetime'
+ },
+ {
+ LSHandlerRoleAll: 'com.apple.dt.xcode',
+ LSHandlerURLScheme: 'xcdevice'
+ },
+ {
+ LSHandlerContentType: 'public.png',
+ LSHandlerRoleAll: 'com.macromedia.fireworks'
+ },
+ {
+ LSHandlerRoleAll: 'com.apple.dt.xcode',
+ LSHandlerURLScheme: 'xcbot'
+ },
+ {
+ LSHandlerRoleAll: 'com.microsoft.rdc.mac',
+ LSHandlerURLScheme: 'rdp'
+ },
+ {
+ LSHandlerContentTag: 'rdp',
+ LSHandlerContentTagClass: 'public.filename-extension',
+ LSHandlerRoleAll: 'com.microsoft.rdc.mac'
+ },
+ {
+ LSHandlerContentType: 'public.json',
+ LSHandlerRoleAll: 'com.sublimetext.2'
+ },
+ {
+ LSHandlerContentTag: 'cson',
+ LSHandlerContentTagClass: 'public.filename-extension',
+ LSHandlerRoleAll: 'com.sublimetext.2'
+ },
+ {
+ LSHandlerRoleAll: 'com.apple.mail',
+ LSHandlerURLScheme: 'mailto'
+ }
+ ]
+
+
+ describe "DefaultClientHelperMac", ->
+ beforeEach ->
+ execHitory = []
+ @helper = new DefaultClientHelper.Mac()
+
+ describe "available", ->
+ it "should return true", ->
+ expect(@helper.available()).toEqual(true)
+
+ describe "readDefaults", ->
+
+ describe "writeDefaults", ->
+ it "should `lsregister` to reload defaults after saving them", ->
+ callback = jasmine.createSpy('callback')
+ @helper.writeDefaults(stubDefaultsJSON, callback)
+ callback.callCount is 1
+ command = execHitory[2][0]
+ expect(command).toBe("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -kill -r -domain local -domain system -domain user")
+
+ describe "isRegisteredForURLScheme", ->
+ it "should require a callback is provided", ->
+ expect( -> @helper.isRegisteredForURLScheme('mailto')).toThrow()
+
+ it "should return true if a matching `LSHandlerURLScheme` record exists for the bundle identifier", ->
+ spyOn(@helper, 'readDefaults').andCallFake (callback) ->
+ callback([{
+ "LSHandlerRoleAll": "com.apple.dt.xcode",
+ "LSHandlerURLScheme": "xcdoc"
+ }, {
+ "LSHandlerContentTag": "cson",
+ "LSHandlerContentTagClass": "public.filename-extension",
+ "LSHandlerRoleAll": "com.sublimetext.2"
+ }, {
+ "LSHandlerRoleAll": "com.nylas.nylas-mail",
+ "LSHandlerURLScheme": "mailto"
+ }])
+ @helper.isRegisteredForURLScheme 'mailto', (registered) ->
+ expect(registered).toBe(true)
+
+ it "should return false when other records exist for the bundle identifier but do not match", ->
+ spyOn(@helper, 'readDefaults').andCallFake (callback) ->
+ callback([{
+ LSHandlerRoleAll: "com.apple.dt.xcode",
+ LSHandlerURLScheme: "xcdoc"
+ },{
+ LSHandlerContentTag: "cson",
+ LSHandlerContentTagClass: "public.filename-extension",
+ LSHandlerRoleAll: "com.sublimetext.2"
+ },{
+ LSHandlerRoleAll: "com.nylas.nylas-mail",
+ LSHandlerURLScheme: "atom"
+ }])
+ @helper.isRegisteredForURLScheme 'mailto', (registered) ->
+ expect(registered).toBe(false)
+
+ it "should return false if another bundle identifier is registered for the `LSHandlerURLScheme`", ->
+ spyOn(@helper, 'readDefaults').andCallFake (callback) ->
+ callback([{
+ LSHandlerRoleAll: "com.apple.dt.xcode",
+ LSHandlerURLScheme: "xcdoc"
+ },{
+ LSHandlerContentTag: "cson",
+ LSHandlerContentTagClass: "public.filename-extension",
+ LSHandlerRoleAll: "com.sublimetext.2"
+ },{
+ LSHandlerRoleAll: "com.apple.mail",
+ LSHandlerURLScheme: "mailto"
+ }])
+ @helper.isRegisteredForURLScheme 'mailto', (registered) ->
+ expect(registered).toBe(false)
+
+ describe "registerForURLScheme", ->
+ it "should remove any existing records for the `LSHandlerURLScheme`", ->
+ @helper.registerForURLScheme 'mailto', =>
+ @helper.readDefaults (values) ->
+ expect(JSON.stringify(values).indexOf('com.apple.mail')).toBe(-1)
+
+ it "should add a record for the `LSHandlerURLScheme` and the app's bundle identifier", ->
+ @helper.registerForURLScheme 'mailto', =>
+ @helper.readDefaults (defaults) ->
+ match = _.find defaults, (d) ->
+ d.LSHandlerURLScheme is 'mailto' and d.LSHandlerRoleAll is 'com.nylas.nylas-mail'
+ expect(match).not.toBe(null)
+
+ it "should write the new defaults", ->
+ spyOn(@helper, 'readDefaults').andCallFake (callback) ->
+ callback([{
+ LSHandlerRoleAll: "com.apple.dt.xcode",
+ LSHandlerURLScheme: "xcdoc"
+ }])
+ spyOn(@helper, 'writeDefaults')
+ @helper.registerForURLScheme('mailto')
+ expect(@helper.writeDefaults).toHaveBeenCalled()
diff --git a/packages/client-app/spec/fixtures/css.css b/packages/client-app/spec/fixtures/css.css
new file mode 100644
index 0000000000..d5ae97e682
--- /dev/null
+++ b/packages/client-app/spec/fixtures/css.css
@@ -0,0 +1,5 @@
+body {
+ font-size: 1234px;
+ width: 110%;
+ font-weight: bold !important;
+}
diff --git a/packages/client-app/spec/fixtures/db-test-model.coffee b/packages/client-app/spec/fixtures/db-test-model.coffee
new file mode 100644
index 0000000000..55d3de1231
--- /dev/null
+++ b/packages/client-app/spec/fixtures/db-test-model.coffee
@@ -0,0 +1,116 @@
+Model = require '../../src/flux/models/model'
+Category = require('../../src/flux/models/category').default
+Attributes = require('../../src/flux/attributes').default
+
+class TestModel extends Model
+ @attributes =
+ 'id': Attributes.String
+ queryable: true
+ modelKey: 'id'
+
+ 'clientId': Attributes.String
+ queryable: true
+ modelKey: 'clientId'
+ jsonKey: 'client_id'
+
+ 'serverId': Attributes.ServerId
+ queryable: true
+ modelKey: 'serverId'
+ jsonKey: 'server_id'
+
+TestModel.configureBasic = ->
+ TestModel.additionalSQLiteConfig = undefined
+ TestModel.attributes =
+ 'id': Attributes.String
+ queryable: true
+ modelKey: 'id'
+ 'clientId': Attributes.String
+ queryable: true
+ modelKey: 'clientId'
+ jsonKey: 'client_id'
+ 'serverId': Attributes.ServerId
+ queryable: true
+ modelKey: 'serverId'
+ jsonKey: 'server_id'
+
+TestModel.configureWithAllAttributes = ->
+ TestModel.additionalSQLiteConfig = undefined
+ TestModel.attributes =
+ 'datetime': Attributes.DateTime
+ queryable: true
+ modelKey: 'datetime'
+ 'string': Attributes.String
+ queryable: true
+ modelKey: 'string'
+ jsonKey: 'string-json-key'
+ 'boolean': Attributes.Boolean
+ queryable: true
+ modelKey: 'boolean'
+ 'number': Attributes.Number
+ queryable: true
+ modelKey: 'number'
+ 'other': Attributes.String
+ modelKey: 'other'
+
+TestModel.configureWithCollectionAttribute = ->
+ TestModel.additionalSQLiteConfig = undefined
+ TestModel.attributes =
+ 'id': Attributes.String
+ queryable: true
+ modelKey: 'id'
+ 'clientId': Attributes.String
+ queryable: true
+ modelKey: 'clientId'
+ jsonKey: 'client_id'
+ 'serverId': Attributes.ServerId
+ queryable: true
+ modelKey: 'serverId'
+ jsonKey: 'server_id'
+ 'other': Attributes.String
+ queryable: true,
+ modelKey: 'other'
+ 'categories': Attributes.Collection
+ queryable: true,
+ modelKey: 'categories'
+ itemClass: Category,
+ joinOnField: 'id',
+ joinQueryableBy: ['other'],
+
+TestModel.configureWithJoinedDataAttribute = ->
+ TestModel.additionalSQLiteConfig = undefined
+ TestModel.attributes =
+ 'id': Attributes.String
+ queryable: true
+ modelKey: 'id'
+ 'clientId': Attributes.String
+ queryable: true
+ modelKey: 'clientId'
+ jsonKey: 'client_id'
+ 'serverId': Attributes.ServerId
+ queryable: true
+ modelKey: 'serverId'
+ jsonKey: 'server_id'
+ 'body': Attributes.JoinedData
+ modelTable: 'TestModelBody'
+ modelKey: 'body'
+
+
+TestModel.configureWithAdditionalSQLiteConfig = ->
+ TestModel.attributes =
+ 'id': Attributes.String
+ queryable: true
+ modelKey: 'id'
+ 'clientId': Attributes.String
+ modelKey: 'clientId'
+ jsonKey: 'client_id'
+ 'serverId': Attributes.ServerId
+ modelKey: 'serverId'
+ jsonKey: 'server_id'
+ 'body': Attributes.JoinedData
+ modelTable: 'TestModelBody'
+ modelKey: 'body'
+ TestModel.additionalSQLiteConfig =
+ setup: ->
+ ['CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_received_timestamp DESC, account_id, id)']
+
+module.exports = TestModel
diff --git a/packages/client-app/spec/fixtures/emails/correct_sig.txt b/packages/client-app/spec/fixtures/emails/correct_sig.txt
new file mode 100755
index 0000000000..4bb8a2a763
--- /dev/null
+++ b/packages/client-app/spec/fixtures/emails/correct_sig.txt
@@ -0,0 +1,4 @@
+this is an email with a correct -- signature.
+
+--
+rick
diff --git a/packages/client-app/spec/fixtures/emails/email_1.html b/packages/client-app/spec/fixtures/emails/email_1.html
new file mode 100644
index 0000000000..6791591af9
--- /dev/null
+++ b/packages/client-app/spec/fixtures/emails/email_1.html
@@ -0,0 +1,1730 @@
+
+
+
+
+
+
+Hi Jeff,
+
+Quick update on the event bugs:
+- I fixed the bug where events would be incorrectly marked as read-only.
+- We expose RRULEs as valid JSON now.
+
+We're currently testing the fixes, they should ship early next week.
+
+Concerning timezones, an event should always be associated with a timezone. Having a NULL value instead is a bug on our end. I will be working on fixing this on Monday and will let you know when it's fixed.
+
+Thanks for your detailed bug reports,
+
+Karim
+From: Kavya Joshi < kavya@nylas.com >
+
+
+
+
+
+
+
+
+
Hi Jeff,
+
+
+
+
The events are incorrectly marked as read only because of a bug in how we determine the organizer of an event; read-writable permissions are only granted to the organizer as per the Exchange ActiveSync protocol. Karim's working
+ on fixing the bug, it will be rolled out shortly and we'll keep you posted.
+
+
+
With respect to the rrule returned by the API - absolutely; we will change the representation and let you know when that's done too.
+
+
+
With respect to your question about when the timezone would be null for calendars - we're looking into it and will get back to you.
+
+
+
Thanks!
+
Kavya
+
+
+
+
+
+
+
From: Jeff Meister <jeff@esper.com >
+Sent: Wednesday, May 27, 2015 3:30 PM
+To: Kavya Joshi
+Cc: Jennie Lees; Andrew Lee; Mackenzie Dallas; support; Karim Hamidou; Christine Spang
+Subject: Re: Esper <-> Nilas
+
+
+
+
+
+
+
+
Hi Kavya,
+
+
+I just did some more testing with the Meg account, and everything related to recurring events worked correctly for me. I was about to try with one of our real Formation 8 accounts, but I ran into an issue syncing data back to O365 through Nylas: even non-recurring
+ events that I create or modify on the O365 side become read only in Nylas, so I'm unable to update them through your API. I remember we had this problem before and you guys fixed it, so maybe this was reintroduced during your recurring event changes, since
+ those events should be read only? Hopefully this isn't a complicated fix, let me know if I can provide more info.
+
+
+
The stuff below is not as important, just wanted to mention it:
+
+
+
+I noticed a couple things about the recurrence data in Nylas. First, the rrule is given as a single-quote-delimited string array packed inside a JSON string. We're currently dealing with this by taking the rrule string, replacing ' with ", then parsing the
+ now-valid JSON array. This could fail if there are other quote characters in the rule... could your API return the rrule simply as a JSON array containing double-quoted JSON strings, or would that break existing things? Second, I see that a recurrence entry
+ comes with a timezone, which is great because Google needs one, but my Meg calendars are always showing null for the timezone. Christine explained in the past that there are difficulties in getting timezones for Exchange calendars... do you know in what cases
+ this field will be non-null? For now, we require our users to specify their calendar timezone during onboarding, and we just use that zone every time.
+
+
+Thanks again,
+
+Jeff
+
+