================================================
FILE: packages/client-app/internal_packages/keybase/lib/keybase-search.cjsx
================================================
{Utils,
React,
ReactDOM,
Actions,
RegExpUtils,
IdentityStore,
AccountStore} = 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'
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 =
{ profiles }
{ badSearch }
================================================
FILE: packages/client-app/internal_packages/keybase/lib/keybase-user.cjsx
================================================
{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 }
================================================
FILE: packages/client-app/internal_packages/keybase/lib/keybase.coffee
================================================
_ = 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()
================================================
FILE: packages/client-app/internal_packages/keybase/lib/main.es6
================================================
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 {};
}
================================================
FILE: packages/client-app/internal_packages/keybase/lib/modal-key-recommender.cjsx
================================================
{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 }
================================================
FILE: packages/client-app/internal_packages/keybase/lib/passphrase-popover.cjsx
================================================
{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()
================================================
FILE: packages/client-app/internal_packages/keybase/lib/pgp-key-store.cjsx
================================================
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()
================================================
FILE: packages/client-app/internal_packages/keybase/lib/preferences-keybase.cjsx
================================================
{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
================================================
FILE: packages/client-app/internal_packages/keybase/lib/private-key-popover.cjsx
================================================
{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()
================================================
FILE: packages/client-app/internal_packages/keybase/lib/recipient-key-chip.cjsx
================================================
{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
================================================
FILE: packages/client-app/internal_packages/keybase/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/keybase/spec/decrypt-buttons-spec.cjsx
================================================
{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()
================================================
FILE: packages/client-app/internal_packages/keybase/spec/encrypt-button-spec.cjsx
================================================
{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))
================================================
FILE: packages/client-app/internal_packages/keybase/spec/keybase-profile-spec.cjsx
================================================
{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
================================================
FILE: packages/client-app/internal_packages/keybase/spec/keybase-search-spec.cjsx
================================================
{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
================================================
FILE: packages/client-app/internal_packages/keybase/spec/keybase-spec.coffee
================================================
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")
)
================================================
FILE: packages/client-app/internal_packages/keybase/spec/main-spec.coffee
================================================
{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)
================================================
FILE: packages/client-app/internal_packages/keybase/spec/pgp-key-store-spec.cjsx
================================================
{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)
================================================
FILE: packages/client-app/internal_packages/keybase/spec/recipient-key-chip-spec.cjsx
================================================
{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()
================================================
FILE: packages/client-app/internal_packages/keybase/stylesheets/main.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/link-tracking/README.md
================================================
## Open Tracking
Adds tracking pixels to messages and tracks whether they have been opened.
================================================
FILE: packages/client-app/internal_packages/link-tracking/lib/link-tracking-button.jsx
================================================
// import {DraftStore, React, Actions, NylasAPI, DatabaseStore, Message, Rx} from 'nylas-exports'
import {React, APIError, NylasAPI} from 'nylas-exports'
import {MetadataComposerToggleButton} from 'nylas-component-kit'
import {PLUGIN_ID, PLUGIN_NAME} from './link-tracking-constants'
export default class LinkTrackingButton extends React.Component {
static displayName = 'LinkTrackingButton';
static propTypes = {
draft: React.PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired,
};
shouldComponentUpdate(nextProps) {
return (nextProps.draft.metadataForPluginId(PLUGIN_ID) !== this.props.draft.metadataForPluginId(PLUGIN_ID));
}
_title(enabled) {
const dir = enabled ? "Disable" : "Enable";
return `${dir} link tracking`
}
_errorMessage(error) {
if (error instanceof APIError && NylasAPI.TimeoutErrorCodes.includes(error.statusCode)) {
return `Link tracking does not work offline. Please re-enable when you come back online.`
}
return `Unfortunately, link tracking servers are currently not available. Please try again later. Error: ${error.message}`
}
render() {
return (
)
}
}
LinkTrackingButton.containerRequired = false;
================================================
FILE: packages/client-app/internal_packages/link-tracking/lib/link-tracking-composer-extension.es6
================================================
import {ComposerExtension, RegExpUtils} from 'nylas-exports';
import {PLUGIN_ID, PLUGIN_URL} from './link-tracking-constants'
function forEachATagInBody(draftBodyRootNode, callback) {
const treeWalker = document.createTreeWalker(draftBodyRootNode, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node) => {
if (node.classList.contains('gmail_quote')) {
return NodeFilter.FILTER_REJECT; // skips the entire subtree
}
return (node.hasAttribute('href')) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
})
while (treeWalker.nextNode()) {
callback(treeWalker.currentNode);
}
}
/**
* This replaces all links with a new url that redirects through our
* cloud-api servers (see cloud-api/routes/link-tracking)
*
* This redirect link href is NOT complete at this stage. It requires
* substantial post processing just before send. This happens in iso-core
* since sending can happen immediately or later in cloud-workers.
*
* See isomorphic-core tracking-utils.es6
*
* We don't have a Message Id yet since this is still a draft. We generate
* and replace `MESSAGE_ID` later with the correct one.
*
* We also need to add individualized recipients to each tracking pixel
* for each message sent to each person.
*
* We finally need to put the original url back for the message that ends
* up in the users's sent folder. This ensures the sender doesn't trip
* their own link tracks.
*/
export default class LinkTrackingComposerExtension extends ComposerExtension {
static applyTransformsForSending({draftBodyRootNode, draft}) {
const metadata = draft.metadataForPluginId(PLUGIN_ID);
if (metadata) {
const messageUid = draft.clientId;
const links = [];
forEachATagInBody(draftBodyRootNode, (el) => {
const url = el.getAttribute('href');
if (!RegExpUtils.urlRegex().test(url)) {
return;
}
const encoded = encodeURIComponent(url);
const redirectUrl = `${PLUGIN_URL}/link/MESSAGE_ID/${links.length}?redirect=${encoded}`;
links.push({
url,
click_count: 0,
click_data: [],
redirect_url: redirectUrl,
});
el.setAttribute('href', redirectUrl);
});
// save the link info to draft metadata
metadata.uid = messageUid;
metadata.links = links;
draft.applyPluginMetadata(PLUGIN_ID, metadata);
}
}
static unapplyTransformsForSending({draftBodyRootNode}) {
forEachATagInBody(draftBodyRootNode, (el) => {
const url = el.getAttribute('href');
if (url.indexOf(PLUGIN_URL) !== -1) {
const userURLEncoded = url.split('?redirect=')[1];
el.setAttribute('href', decodeURIComponent(userURLEncoded));
}
});
}
}
================================================
FILE: packages/client-app/internal_packages/link-tracking/lib/link-tracking-constants.es6
================================================
import plugin from '../package.json'
export const PLUGIN_NAME = plugin.title
export const PLUGIN_ID = plugin.name;
export const PLUGIN_URL = plugin.serverUrl[NylasEnv.config.get("env")];
================================================
FILE: packages/client-app/internal_packages/link-tracking/lib/link-tracking-message-extension.jsx
================================================
import {React, MessageViewExtension, Actions} from 'nylas-exports'
import LinkTrackingMessagePopover from './link-tracking-message-popover'
import {PLUGIN_ID} from './link-tracking-constants'
export default class LinkTrackingMessageExtension extends MessageViewExtension {
static renderedMessageBodyIntoDocument({document, message, iframe}) {
const metadata = message.metadataForPluginId(PLUGIN_ID) || {};
if ((metadata.links || []).length === 0) { return }
const links = {}
for (const link of metadata.links) {
links[link.url] = link
links[link.redirect_url] = link
}
const trackedLinksWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node) => {
if ((node.nodeName === 'A') && links[node.getAttribute('href')]) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
},
});
while (trackedLinksWalker.nextNode()) {
const node = trackedLinksWalker.currentNode;
const nodeHref = node.getAttribute('href');
const originalHref = links[nodeHref].url;
const dotNode = document.createElement('img');
dotNode.className = 'link-tracking-dot';
dotNode.style = 'margin-bottom: 0.75em; margin-left: 1px; margin-right: 1px; vertical-align: text-bottom; width: 6px;';
if (links[nodeHref].click_count > 0) {
dotNode.title = `${links[nodeHref].click_count} click${links[nodeHref].click_count === 1 ? "" : "s"} (${originalHref})`;
dotNode.src = 'nylas://link-tracking/assets/ic-tracking-visited@2x.png';
dotNode.style = 'margin-bottom: 0.75em; margin-left: 1px; margin-right: 1px; vertical-align: text-bottom; width: 6px; cursor: pointer;'
dotNode.onmousedown = () => {
const dotRect = dotNode.getBoundingClientRect();
const iframeRect = iframe.getBoundingClientRect();
const rect = {
top: dotRect.top + iframeRect.top,
bottom: dotRect.bottom + iframeRect.top,
left: dotRect.left + iframeRect.left,
right: dotRect.right + iframeRect.left,
width: dotRect.width,
height: dotRect.height,
};
Actions.openPopover(
,
{
originRect: rect,
direction: 'down',
}
);
}
} else {
dotNode.title = `This link has not been clicked (${originalHref})`;
dotNode.src = 'nylas://link-tracking/assets/ic-tracking-unvisited@2x.png';
}
node.href = originalHref;
node.title = originalHref;
node.parentNode.insertBefore(dotNode, node.nextSibling);
}
}
}
================================================
FILE: packages/client-app/internal_packages/link-tracking/lib/link-tracking-message-popover.jsx
================================================
import React from 'react';
import {DateUtils} from 'nylas-exports';
import {Flexbox} from 'nylas-component-kit';
import ActivityListStore from '../../activity-list/lib/activity-list-store';
class LinkTrackingMessagePopover extends React.Component {
static displayName = 'LinkTrackingMessagePopover';
static propTypes = {
message: React.PropTypes.object,
linkMetadata: React.PropTypes.object,
};
renderClickActions() {
const clicks = this.props.linkMetadata.click_data;
return clicks.map((click) => {
const recipients = this.props.message.to.concat(this.props.message.cc, this.props.message.bcc);
const recipient = ActivityListStore.getRecipient(click.recipient, recipients);
const date = new Date(0);
date.setUTCSeconds(click.timestamp);
return (
{recipient ? recipient.displayName() : "Someone"}
{DateUtils.shortTimeString(date)}
);
});
}
render() {
return (
Clicked by:
{this.renderClickActions()}
);
}
}
export default LinkTrackingMessagePopover;
================================================
FILE: packages/client-app/internal_packages/link-tracking/lib/main.es6
================================================
import {
ComponentRegistry,
ExtensionRegistry,
} from 'nylas-exports';
import {HasTutorialTip} from 'nylas-component-kit';
import LinkTrackingButton from './link-tracking-button';
import LinkTrackingComposerExtension from './link-tracking-composer-extension';
import LinkTrackingMessageExtension from './link-tracking-message-extension';
const LinkTrackingButtonWithTutorialTip = HasTutorialTip(LinkTrackingButton, {
title: "Track links in this email",
instructions: "When link tracking is turned on, Nylas Mail will notify you when recipients click links in this email.",
});
export function activate() {
ComponentRegistry.register(LinkTrackingButtonWithTutorialTip, {
role: 'Composer:ActionButton',
});
ExtensionRegistry.Composer.register(LinkTrackingComposerExtension);
ExtensionRegistry.MessageView.register(LinkTrackingMessageExtension);
}
export function serialize() {}
export function deactivate() {
ComponentRegistry.unregister(LinkTrackingButtonWithTutorialTip);
ExtensionRegistry.Composer.unregister(LinkTrackingComposerExtension);
ExtensionRegistry.MessageView.unregister(LinkTrackingMessageExtension);
}
================================================
FILE: packages/client-app/internal_packages/link-tracking/package.json
================================================
{
"name": "link-tracking",
"main": "./lib/main",
"version": "0.1.0",
"serverUrl": {
"local": "https://local-n1.nylas.com",
"development": "https://local-n1.nylas.com",
"staging": "https://n1-staging.nylas.com",
"production": "https://n1.nylas.com"
},
"title": "Link Tracking",
"description": "Track when links in an email have been clicked by recipients.",
"icon": "./icon.png",
"isOptional": true,
"supportedEnvs": ["local", "development", "staging", "production"],
"repository": {
"type": "git",
"url": ""
},
"engines": {
"nylas": "*"
},
"windowTypes": {
"default": true,
"composer": true,
"thread-popout": true
},
"dependencies": {},
"license": "GPL-3.0"
}
================================================
FILE: packages/client-app/internal_packages/link-tracking/spec/link-tracking-composer-extension-spec.es6
================================================
import {Message} from 'nylas-exports';
import LinkTrackingComposerExtension from '../lib/link-tracking-composer-extension'
import {PLUGIN_ID, PLUGIN_URL} from '../lib/link-tracking-constants';
const beforeBody = `TEST_BODY
test
asdad
adsasd
stillhere
http://www.stillhere.com
twstasdad `;
const afterBodyFactory = (accountId, messageUid) => `TEST_BODY
test
asdad
adsasd
stillhere
http://www.stillhere.com
twstasdad `;
const nodeForHTML = (html) => {
const fragment = document.createDocumentFragment();
const node = document.createElement('root');
fragment.appendChild(node);
node.innerHTML = html;
return node;
}
xdescribe('Link tracking composer extension', function linkTrackingComposerExtension() {
describe("applyTransformsForSending", () => {
beforeEach(() => {
this.draft = new Message({accountId: "test"});
this.draft.body = beforeBody;
this.draftBodyRootNode = nodeForHTML(this.draft.body);
});
it("takes no action if there is no metadata", () => {
LinkTrackingComposerExtension.applyTransformsForSending({
draftBodyRootNode: this.draftBodyRootNode,
draft: this.draft,
});
const afterBody = this.draftBodyRootNode.innerHTML;
expect(afterBody).toEqual(beforeBody);
});
describe("With properly formatted metadata and correct params", () => {
beforeEach(() => {
this.metadata = {tracked: true};
this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
});
it("replaces links in the unquoted portion of the body", () => {
LinkTrackingComposerExtension.applyTransformsForSending({
draftBodyRootNode: this.draftBodyRootNode,
draft: this.draft,
});
const metadata = this.draft.metadataForPluginId(PLUGIN_ID);
const afterBody = this.draftBodyRootNode.innerHTML;
expect(afterBody).toEqual(afterBodyFactory(this.draft.accountId, metadata.uid));
});
it("sets a uid and list of links on the metadata", () => {
LinkTrackingComposerExtension.applyTransformsForSending({
draftBodyRootNode: this.draftBodyRootNode,
draft: this.draft,
});
const metadata = this.draft.metadataForPluginId(PLUGIN_ID);
expect(metadata.uid).not.toBeUndefined();
expect(metadata.links).not.toBeUndefined();
expect(metadata.links.length).toEqual(2);
for (const link of metadata.links) {
expect(link.click_count).toEqual(0);
}
});
});
});
describe("unapplyTransformsForSending", () => {
beforeEach(() => {
this.metadata = {tracked: true, uid: '123'};
this.draft = new Message({accountId: "test"});
this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
});
it("takes no action if there are no tracked links in the body", () => {
this.draft.body = beforeBody;
this.draftBodyRootNode = nodeForHTML(this.draft.body);
LinkTrackingComposerExtension.unapplyTransformsForSending({
draftBodyRootNode: this.draftBodyRootNode,
draft: this.draft,
});
const afterBody = this.draftBodyRootNode.innerHTML;
expect(afterBody).toEqual(beforeBody);
});
it("replaces tracked links with the original links, restoring the body exactly", () => {
this.draft.body = afterBodyFactory(this.draft.accountId, this.metadata.uid);
this.draftBodyRootNode = nodeForHTML(this.draft.body);
LinkTrackingComposerExtension.unapplyTransformsForSending({
draftBodyRootNode: this.draftBodyRootNode,
draft: this.draft,
});
const afterBody = this.draftBodyRootNode.innerHTML;
expect(afterBody).toEqual(beforeBody);
});
});
});
================================================
FILE: packages/client-app/internal_packages/link-tracking/stylesheets/main.less
================================================
@import "ui-variables";
@import "ui-mixins";
.link-tracking-icon img.content-mask {
background-color: #AAA;
vertical-align: text-bottom;
}
.link-tracking-icon img.content-mask.clicked {
background-color: #CCC;
}
.link-tracking-icon .link-click-count {
display: inline-block;
position: relative;
left: -16px;
text-align: center;
color: #3187e1;
font-size: 12px;
font-weight: bold;
}
.link-tracking-icon {
width: 16px;
margin-right: 4px;
}
.link-tracking-panel {
background: #DDF6FF;
border: 1px solid #ACD;
padding: 5px;
border-radius: 5px;
}
.link-tracking-panel h4{
text-align: center;
margin-top: 0;
}
.link-tracking-panel table{
width: 100%;
}
.link-tracking-panel td {
border-bottom: 1px solid #D5EAF5;
border-top: 1px solid #D5EAF5;
padding: 0 10px;
text-align: left;
}
.link-tracking-message-popover {
width: 200px;
max-height: 134px;
.link-tracking-header {
padding: @padding-base-vertical @padding-base-horizontal 0 @padding-base-horizontal;
text-align: center;
color: @text-color-subtle;
font-weight: 600;
}
.click-history-container {
max-height: 112px;
padding: 0 @padding-base-horizontal @padding-base-vertical @padding-base-horizontal;
overflow: auto;
.click-action {
color: @text-color-subtle;
.recipient {
text-overflow: ellipsis;
overflow: hidden;
}
.spacer {
flex: 1 1 0;
}
.timestamp {
color: @text-color-very-subtle;
flex-shrink: 0;
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/main-calendar/README.md
================================================
# composer package
================================================
FILE: packages/client-app/internal_packages/main-calendar/lib/calendar-wrapper.jsx
================================================
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 (
)
}
}
================================================
FILE: packages/client-app/internal_packages/main-calendar/lib/event-description-frame.jsx
================================================
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 (
);
}
}
================================================
FILE: packages/client-app/internal_packages/main-calendar/lib/main.jsx
================================================
// 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);
}
================================================
FILE: packages/client-app/internal_packages/main-calendar/lib/quick-event-button.jsx
================================================
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 (
+
);
}
}
================================================
FILE: packages/client-app/internal_packages/main-calendar/lib/quick-event-popover.jsx
================================================
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}
)
}
}
================================================
FILE: packages/client-app/internal_packages/main-calendar/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/main-calendar/stylesheets/main-calendar.less
================================================
// 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;
}
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-actions.es6
================================================
import Reflux from 'reflux';
const ActionNames = [
'temporarilyEnableImages',
'permanentlyEnableImages',
];
const Actions = Reflux.createActions(ActionNames);
ActionNames.forEach((name) => {
Actions[name].sync = true;
});
export default Actions;
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-extension.es6
================================================
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}#`;
});
}
}
}
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-header.jsx
================================================
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 (
);
}
}
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/lib/autoload-images-store.es6
================================================
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();
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/lib/main.es6
================================================
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);
}
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/spec/autoload-images-extension-spec.es6
================================================
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);
});
});
});
});
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/spec/fixtures/linkedin-in.html
================================================
Published by your network
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.
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/spec/fixtures/linkedin-out.html
================================================
Published by your network
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.
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/spec/fixtures/marketing-email-in.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/spec/fixtures/marketing-email-out.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/spec/fixtures/no-image-extensions-in.html
================================================
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
© 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.
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/spec/fixtures/no-image-extensions-out.html
================================================
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
© 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.
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/spec/fixtures/table-body-in.html
================================================
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/spec/fixtures/table-body-out.html
================================================
================================================
FILE: packages/client-app/internal_packages/message-autoload-images/stylesheets/message-autoload-images.less
================================================
@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%);
}
}
================================================
FILE: packages/client-app/internal_packages/message-list/lib/autolinker.es6
================================================
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');
}
}
================================================
FILE: packages/client-app/internal_packages/message-list/lib/autoscale-images.es6
================================================
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);
}
}
================================================
FILE: packages/client-app/internal_packages/message-list/lib/email-frame-styles-store.coffee
================================================
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()
================================================
FILE: packages/client-app/internal_packages/message-list/lib/email-frame.jsx
================================================
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 (
);
}
}
================================================
FILE: packages/client-app/internal_packages/message-list/lib/find-in-thread.jsx
================================================
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 (
)
}
}
================================================
FILE: packages/client-app/internal_packages/message-list/lib/inline-image-listeners.es6
================================================
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);
}
}
================================================
FILE: packages/client-app/internal_packages/message-list/lib/main.cjsx
================================================
{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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/message-controls.cjsx
================================================
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.popup(remote.getCurrentWindow())
_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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/message-item-body.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/message-item-container.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/message-item.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/message-list-hidden-messages-toggle.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/message-list/lib/message-list.cjsx
================================================
_ = 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
_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)
================================================
FILE: packages/client-app/internal_packages/message-list/lib/message-participants.cjsx
================================================
_ = 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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/message-timestamp.jsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/sidebar-participant-picker.jsx
================================================
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()}
)
}
}
================================================
FILE: packages/client-app/internal_packages/message-list/lib/sidebar-plugin-container.cjsx
================================================
_ = 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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/thread-archive-button.cjsx
================================================
{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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/thread-star-button.cjsx
================================================
_ = 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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/thread-toggle-unread-button.cjsx
================================================
{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
================================================
FILE: packages/client-app/internal_packages/message-list/lib/thread-trash-button.cjsx
================================================
_ = 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
================================================
FILE: packages/client-app/internal_packages/message-list/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/both-email-and-url-in.html
================================================
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.
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/both-email-and-url-out.html
================================================
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.
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/gmail-in.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/gmail-out.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/linkedin-in.html
================================================
"
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.
"
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/linkedin-out.html
================================================
"
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.
"
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/medium-post-in.html
================================================
Reported on GitHub:
https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.3fela2o72
Geez they have messy URLs.
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/medium-post-out.html
================================================
Reported on GitHub:
https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.3fela2o72
Geez they have messy URLs.
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/nylas-url-in.html
================================================
Hello world
nylas is cool.
nylas://plugins?test=stuff
nylas:plugins?test=stuff
nylas://plugins?test=stuff
Don't you like nylas?
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/nylas-url-out.html
================================================
Hello world
nylas is cool.
nylas://plugins?test=stuff
nylas:plugins?test=stuff
nylas://plugins?test=stuff
Don't you like nylas?
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/plaintext-in.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/plaintext-out.html
================================================
http://apple.com/
550.555.1234
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/readme-in.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/readme-out.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangeemails-in.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangeemails-out.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangephones-in.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/strangephones-out.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/twitter-in.html
================================================
Reported on GitHub:
https://twitter.com/SF_emergency/status/714901408298893317
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/twitter-out.html
================================================
Reported on GitHub:
https://twitter.com/SF_emergency/status/714901408298893317
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/url-with-port-in.html
================================================
HTTP links with port in them don't link correctly either,
e.g. http://example.com:8080/path/ only links http://example.com.
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-fixtures/url-with-port-out.html
================================================
HTTP links with port in them don't link correctly either,
e.g. http://example.com:8080/path/ only links http://example.com .
================================================
FILE: packages/client-app/internal_packages/message-list/spec/autolinker-spec.es6
================================================
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);
});
});
});
================================================
FILE: packages/client-app/internal_packages/message-list/spec/message-item-body-spec.cjsx
================================================
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)
================================================
FILE: packages/client-app/internal_packages/message-list/spec/message-item-container-spec.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/message-item-spec.cjsx
================================================
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)
================================================
FILE: packages/client-app/internal_packages/message-list/spec/message-list-spec.cjsx
================================================
_ = 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'}
]
================================================
FILE: packages/client-app/internal_packages/message-list/spec/message-participants-spec.cjsx
================================================
_ = 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
================================================
FILE: packages/client-app/internal_packages/message-list/spec/message-timestamp-spec.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/message-list/stylesheets/find-in-thread.less
================================================
@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;
}
}
}
================================================
FILE: packages/client-app/internal_packages/message-list/stylesheets/message-list.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/message-view-on-github/README.md
================================================
# 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`
================================================
FILE: packages/client-app/internal_packages/message-view-on-github/keymaps/github.json
================================================
{
"github:open": "mod-G"
}
================================================
FILE: packages/client-app/internal_packages/message-view-on-github/lib/github-store.es6
================================================
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();
================================================
FILE: packages/client-app/internal_packages/message-view-on-github/lib/main.jsx
================================================
/*
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);
}
================================================
FILE: packages/client-app/internal_packages/message-view-on-github/lib/view-on-github-button.jsx
================================================
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 (
)
}
}
================================================
FILE: packages/client-app/internal_packages/message-view-on-github/package.json
================================================
{
"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": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/message-view-on-github/stylesheets/github.less
================================================
.btn.btn-toolbar.btn-view-on-github {
&:only-of-type {
margin-right: 0;
}
}
================================================
FILE: packages/client-app/internal_packages/mode-switch/lib/main.es6
================================================
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);
}
================================================
FILE: packages/client-app/internal_packages/mode-switch/lib/mode-toggle.cjsx
================================================
{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
================================================
FILE: packages/client-app/internal_packages/mode-switch/package.json
================================================
{
"name": "mode-switch",
"version": "0.0.1",
"description": "Mode switch",
"main": "./lib/main",
"license": "GPL-3.0",
"engines": {
"nylas": "*"
},
"private": true
}
================================================
FILE: packages/client-app/internal_packages/mode-switch/stylesheets/mode-switch.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/lib/items/account-error-notif.jsx
================================================
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 (
)
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/lib/items/default-client-notif.jsx
================================================
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 (
)
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/lib/items/dev-mode-notif.jsx
================================================
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 (
)
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/lib/items/disabled-mail-rules-notif.jsx
================================================
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 (
)
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/lib/items/offline-notification.jsx
================================================
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(),
}
},
})
================================================
FILE: packages/client-app/internal_packages/notifications/lib/main.es6
================================================
/* 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 DevModeNotification from "./items/dev-mode-notif";
import DisabledMailRulesNotification from "./items/disabled-mail-rules-notif";
import OfflineNotification from "./items/offline-notification";
const notifications = [
AccountErrorNotification,
DefaultClientNotification,
DevModeNotification,
DisabledMailRulesNotification,
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)
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/lib/notif-wrapper.jsx
================================================
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 (
)
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/lib/sidebar/activity-sidebar.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/notifications/lib/sidebar/initial-sync-activity.jsx
================================================
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}
)
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/lib/sidebar/sync-activity.jsx
================================================
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}
)
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/lib/sidebar/syncback-activity.jsx
================================================
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}
)
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/package.json
================================================
{
"name": "notifications",
"version": "0.1.0",
"main": "./lib/main",
"description": "Notifications",
"license": "GPL-3.0",
"private": true,
"engines": {
"nylas": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/spec/account-error-notif-spec.jsx
================================================
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);
});
});
});
================================================
FILE: packages/client-app/internal_packages/notifications/spec/default-client-notif-spec.jsx
================================================
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);
});
})
});
================================================
FILE: packages/client-app/internal_packages/notifications/spec/dev-mode-notif-spec.jsx
================================================
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);
})
})
});
================================================
FILE: packages/client-app/internal_packages/notifications/spec/disabled-mail-rules-notif-spec.jsx
================================================
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);
})
})
})
================================================
FILE: packages/client-app/internal_packages/notifications/spec/priority-spec.jsx
================================================
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);
})
})
});
================================================
FILE: packages/client-app/internal_packages/notifications/stylesheets/notifications.less
================================================
@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;
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/notifications/stylesheets/styles.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-fonts/lib/main.es6
================================================
export function activate() {}
export function deactivate() {}
export function serialize() {}
================================================
FILE: packages/client-app/internal_packages/nylas-private-fonts/package.json
================================================
{
"name": "nylas-private-fonts",
"version": "0.1.0",
"main": "./lib/main",
"description": "Nylas Fonts",
"license": "Proprietary",
"private": true,
"windowTypes": {
"all": true
},
"engines": {
"nylas": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-fonts/stylesheets/nylas-fonts.less
================================================
// ----- Font Families -----
@font-face {
font-family: 'Nylas-Pro';
font-style: normal;
font-weight: 200;
src: url('nylas://nylas-private-fonts/fonts/Nylas-Pro-Thin.otf');
}
@font-face {
font-family: 'Nylas-Pro';
font-style: normal;
font-weight: 300;
src: url('nylas://nylas-private-fonts/fonts/Nylas-Pro-Blond.otf');
}
@font-face {
font-family: 'Nylas-Pro';
font-style: normal;
font-weight: 400;
src: url('nylas://nylas-private-fonts/fonts/Nylas-Pro-Normal.otf');
}
@font-face {
font-family: 'Nylas-Pro';
font-style: normal;
font-weight: 500;
src: url('nylas://nylas-private-fonts/fonts/Nylas-Pro-Medium.otf');
}
@font-face {
font-family: 'Nylas-Pro';
font-style: normal;
font-weight: 600;
src: url('nylas://nylas-private-fonts/fonts/Nylas-Pro-SemiBold.otf');
}
// Pro-SemiBold doesn't render emoji properly. Override the emjoi unicode
// block so that it uses the "Normal" weight even at font-weight:600.
@font-face {
font-family: 'Nylas-Pro';
font-style: normal;
font-weight: 600;
src: url('nylas://nylas-private-fonts/fonts/Nylas-Pro-Normal.otf'), Helvetica, sans-serif;
unicode-range: U+1F300-1F5FF, U+1F600-1F64F, U+1F680-1F6FF, U+2600-26FF;
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/.gitignore
================================================
.arcconfig
.arclint
arclib
*.swp
*~
.DS_Store
Thumbs.db
.project
.svn
.nvm-version
node_modules
npm-debug.log
debug.log
/tags
/electron/
docs/output
docs/includes
spec/fixtures/evil-files/
/_site
/.sass-cache
.integration-test-config
.idea/
spec-saved-state.json
!spec/fixtures/packages/package-with-incompatible-native-module/node_modules
#emacs
*~
*#
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/README.md
================================================
# Nylas Mail Salesforce Integration
See [+N1 Salesforce](https://paper.dropbox.com/doc/N1-Salesforce-tIXHxx0fSDJSnxdxAx1rS) on Paper
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/keymaps/salesforce.json
================================================
{
"salesforce:show-relate-thread-popover": "mod+l"
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/composer/contact-search-results.jsx
================================================
import React from 'react'
import {Menu} from 'nylas-component-kit'
import {Contact, DatabaseStore} from 'nylas-exports'
import SalesforceIcon from '../shared-components/salesforce-icon'
import SalesforceObject from '../models/salesforce-object'
export function ContactSearchResult({token}) {
return (
)
}
ContactSearchResult.propTypes = {
token: React.PropTypes.instanceOf(Contact),
}
/**
* Registers as "ContactSearchResults"
*/
export default class ContactSearchResults extends React.Component {
static displayName = "ContactSearchResults"
static containerRequired = false
static propTypes = {
token: React.PropTypes.instanceOf(SalesforceObject),
}
/**
* Finds Salesforce contacts and replaces any pre-found and sorted
* nylasContacts with the corresponding Salesforce contact.
*/
static findAdditionalContacts(search, nylasContacts) {
return DatabaseStore.findAll(SalesforceObject)
.search(search).then((results) => {
const sfContacts = results.filter(c => c.type === "Contact")
.map(o => {
const c = new Contact({name: o.name, email: o.identifier})
c.customComponent = ContactSearchResult
return c;
});
const sfEmails = {}
sfContacts.forEach((c, i) => {
sfEmails[c.email.toLowerCase()] = i
});
const combinedContacts = []
nylasContacts.forEach((c) => {
const i = sfEmails[c.email.toLowerCase()]
if (i >= 0) {
combinedContacts.push(sfContacts.splice(i, 1)[0])
} else {
combinedContacts.push(c)
}
})
return combinedContacts.concat(sfContacts);
})
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/composer/participant-decorator.jsx
================================================
import React from 'react'
import {Rx, DatabaseStore} from 'nylas-exports'
import SalesforceIcon from '../shared-components/salesforce-icon'
import SalesforceObject from '../models/salesforce-object'
export default class ParticipantDecorator extends React.Component {
static displayName = "ParticipantDecorator"
static containerRequired = false
static propTypes = {
contact: React.PropTypes.object,
collapsed: React.PropTypes.bool,
}
constructor(props) {
super(props);
this.state = { sfContacts: [] }
}
componentWillMount() {
this._setupObserver(this.props)
}
componentWillReceiveProps(nextProps) {
this._setupObserver(nextProps)
}
componentWillUnmount() {
this._disposable.dispose();
}
_setupObserver(props) {
if (this._disposable) this._disposable.dispose();
const email = (props.contact.email || "").toLowerCase().trim()
if (email.length === 0) return;
const query = DatabaseStore.findAll(SalesforceObject)
.where({type: "Contact", identifier: email})
this._disposable = Rx.Observable.fromQuery(query)
.subscribe((sfContacts = []) => {
this.setState({sfContacts})
})
}
render() {
if (this.props.collapsed) return false;
if (this.state.sfContacts.length === 0) return false
return
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/composer/salesforce-composer-picker.jsx
================================================
import React from 'react'
import {Popover, RetinaImg} from 'nylas-component-kit'
import SalesforceObjectPicker from '../form/salesforce-object-picker'
// TODO: Add to composer
class SalesforceComposerPicker extends React.Component {
static displayName = "SalesforceComposerPicker"
// Inline composers will have threadIds.
// Popout composers will not and the threadId will be null.
static propTypes= {
threadId: React.PropTypes.string,
draftClientId: React.PropTypes.string.isRequired,
}
static containerStyles = {
order: 2,
}
_defaultObjectType() {
return "Opportunity"
}
_pickerId() {
return `${this.props.draftClientId}-Picker`
}
_renderPicker() {
const button = (
)
return (
Sync with Salesforce {this._defaultObjectType()}
)
}
render() {
return this._renderPicker()
}
}
export default SalesforceComposerPicker
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/contact/salesforce-contact-info.jsx
================================================
import _ from 'underscore'
import React from 'react'
import {FocusedContentStore} from 'nylas-exports'
import SalesforceIcon from '../shared-components/salesforce-icon'
import * as dataHelpers from '../salesforce-object-helpers'
import SalesforceActions from '../salesforce-actions'
import OpenInSalesforceBtn from '../shared-components/open-in-salesforce-btn'
import SalesforceRelatedObjectCache from '../salesforce-related-object-cache'
class SalesforceContactInfo extends React.Component {
static displayName = "SalesforceContactInfo";
static containerStyles = {
order: 97,
}
static propTypes = {
contact: React.PropTypes.object.isRequired,
}
constructor(props) {
super(props)
this.state = { leads: [], contacts: [] }
}
componentDidMount() {
this._fetchLead(this.props)
this._disposable = SalesforceRelatedObjectCache.observeDirectlyRelatedSObjectsByEmail(this.props.contact.email).subscribe((sObjectsById = {}) => {
const objsByType = _.groupBy(_.values(sObjectsById), "type");
this.setState({
leads: objsByType.Lead || [],
contacts: objsByType.Contact || [],
})
})
}
componentWillReceiveProps(nextProps = {}) {
this._fetchLead(nextProps)
}
componentWillUnmount() {
this._disposable.dispose()
}
/**
* We don't initial sync Leads because there are usually too many of
* them (Millions). If a user inspects a contact, then we'll fetch leads
* on demand. If we find a lead, it'll save to the Database which will
* cause the SalesforceRelatedObjectCache to trigger for our observable.
*/
_fetchLead(props) {
const email = props.contact.email.toLowerCase().trim()
return dataHelpers.loadBasicObjectsByField({
objectType: "Lead",
where: {Email: email},
}).then(dataHelpers.upsertBasicObjects)
}
_requestNew(objectType, objectInitialData = {}) {
const thread = FocusedContentStore.focused('thread')
return SalesforceActions.openObjectForm({
objectType: objectType,
objectInitialData: objectInitialData,
contextData: {
nylasObjectId: thread.id,
nylasObjectType: "Thread",
focusedNylasContactData: {
id: this.props.contact.id,
name: this.props.contact.name,
email: this.props.contact.email,
},
},
})
}
_requestEdit(object) {
const thread = FocusedContentStore.focused('thread')
SalesforceActions.openObjectForm({
objectId: object.id,
objectType: object.type,
objectInitialData: object,
contextData: {
nylasObjectId: thread.id,
nylasObjectType: "Thread",
},
})
}
_renderObjectCreators() {
const headers = []
if (this.state.leads.length === 0) {
headers.push(this._renderCreateObj("Lead"))
if (this.state.contacts.length === 0) {
headers.push(this._renderCreateObj("Contact"))
}
}
if (headers.length === 0) { return false; }
return {headers}
}
_hasRelatedObjects() {
return (this.state.leads.length > 0 || this.state.contacts.length > 0)
}
_renderRelatedObjects() {
if (!this._hasRelatedObjects()) { return false; }
return (
{[this._renderRelatedSFObjects("Lead", this.state.leads),
this._renderRelatedSFObjects("Contact", this.state.contacts)]}
)
}
_renderRelatedSFObjects(objectType, sfObjects = []) {
const objDoms = []
sfObjects.forEach((object) => {
const reqEdit = _.debounce(() => this._requestEdit(object), 1000, true);
const objDom = (
)
objDoms.push(objDom)
if (objectType === "Lead") {
objDoms.push(this._renderConvertLead(object))
}
});
return objDoms;
}
_renderCreateObj(objType) {
const reqNew = () => this._requestNew(objType)
return (
Create {objType} from {this.props.contact.firstName()}
)
}
_renderConvertLead(lead) {
if (this.state.contacts.length > 0) { return false; }
const convert = _.debounce(() =>
this._requestNew("Contact", {
Name: lead.name,
Email: lead.identifier,
}), 1000, true);
return (
Convert Lead to Contact
)
}
render() {
if (!this.props.contact) return false;
if (this.props.contact.isMe()) return false;
let h2 = false;
if (this._hasRelatedObjects()) {
h2 = Salesforce
}
return (
{h2}
{this._renderRelatedObjects()}
{this._renderObjectCreators()}
)
}
}
export default SalesforceContactInfo
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/fetch-empty-schema-for-type.es6
================================================
import {DatabaseStore} from 'nylas-exports';
import SalesforceAPI from '../salesforce-api'
import SalesforceSchema from '../models/salesforce-schema';
import SalesforceActions from '../salesforce-actions';
import SalesforceObjectPicker from './salesforce-object-picker'
import SalesforceSchemaAdapter from './salesforce-schema-adapter';
/**
* Given a Salesforce object type, we resolve a GeneratedForm schema.
*/
class FetchEmptySchemaForType {
run(objectType) {
return Promise.resolve(objectType)
.then(this._loadSchemaFromDatabase)
.then(this._addCustomFormTypes)
.then(this._verifySchemaValidity)
.then(({genFormSchema, isValid}) => {
if (isValid) return genFormSchema;
return Promise.resolve(objectType)
.then(this._describeLayouts)
.then(this._fetchDefaultLayout)
.then(SalesforceSchemaAdapter.convertFullEditLayout.bind(SalesforceSchemaAdapter))
.then(this._saveGenFormSchema)
})
// We allow all errors to propagate up so they can be caught by the
// caller and displayed to the user.
}
_loadSchemaFromDatabase = (objectType) => {
return DatabaseStore.findBy(SalesforceSchema, {objectType})
.order(SalesforceSchema.attributes.createdAt.descending())
.limit(1)
}
_addCustomFormTypes(formSchema = {}) {
const fieldsets = formSchema.fieldsets || []
for (const fieldset of fieldsets) {
const formItems = fieldset.formItems || []
for (const formItem of formItems) {
if (formItem.type === "reference") {
formItem.customComponent = SalesforceObjectPicker
}
}
}
return formSchema
}
_verifySchemaValidity = (genFormSchema = {}) => {
if (!(genFormSchema instanceof SalesforceSchema)) {
return {genFormSchema, isValid: false}
}
const noData = (genFormSchema.fieldsets || []).length === 0;
const fieldError = this._hasInvalidFields(genFormSchema);
if (noData || fieldError) {
console.warn("The schema in the DB is malformed!", genFormSchema, {noData, fieldError});
return DatabaseStore.inTransaction(t => t.unpersistModel(genFormSchema))
.then(() => {
return {genFormSchema, isValid: false}
})
}
return {genFormSchema, isValid: true}
}
_hasInvalidFields = (genFormSchema) => {
const fieldsets = genFormSchema.fieldsets || []
if (fieldsets.length === 0) return "no fieldsets";
for (const fieldset of fieldsets) {
if (!fieldset.id) return "no fieldset id";
const formItems = fieldset.formItems || []
if (formItems.length === 0) return "empty form items";
for (const formItem of formItems) {
if (!formItem.id) return "formItem with no Id";
if (formItem.type !== "EmptySpace" && !formItem.name) {
return "formItem has no name";
}
if (formItem.type === "reference") {
/**
* We enfore the Id format since we use that format to
* pre-populate fields from existing objects in the
* SalesforceObjectPicker.
*/
if (formItem.referenceTo.length === 0) return "empty referenceTo";
if (formItem.referenceType === "hasMany") {
if (!/.+Ids$/.test(formItem.name)) {
return `Invalid hasMany name: ${formItem.name}`
}
} else if (formItem.referenceType === "hasManyThrough") {
if (!/.+Ids$/.test(formItem.name)) {
return `Invalid hasManyThrough name: ${formItem.name}`
}
if (!formItem.referenceThrough) return "No referenceThough";
if (!formItem.referenceThroughSelfKey) return "No SelfKey";
if (!formItem.referenceThroughForeignKey) return "No ForeignKey";
} else {
if (!/.+Id$/.test(formItem.name)) {
return `Invalid belongsTo name: ${formItem.name}`
}
}
}
}
}
return false;
}
_describeLayouts = (objectType) => {
return SalesforceAPI.makeRequest({
path: `/sobjects/${objectType}/describe/layouts`,
}).then((layoutDescription) => {
return {layoutDescription, objectType}
})
}
// The /describe endpoint returns a list of `recordTypeMappings` that
// may include one or more layouts. In many cases there will only be 1
// layout and 1 default to choose from. We can immediately return the
// layout in this case.
//
// In other cases we will need to separately fetch the raw layout from
// the API
_fetchDefaultLayout = ({layoutDescription, objectType}) => {
try {
const rawLayout = SalesforceSchemaAdapter.defaultLayout(layoutDescription);
if (rawLayout) return {rawLayout, objectType}
const path = SalesforceSchemaAdapter.pathForDefaultLayout(layoutDescription)
return SalesforceAPI.makeRequest({path: path})
.then((rl) => { return {rawLayout: rl, objectType} })
} catch (error) {
error.reportedToSentry = true;
SalesforceActions.reportError(error, {objectType, layoutDescription});
throw error;
}
}
_saveGenFormSchema = (genFormSchemaJSON) => {
const genFormSchema = new SalesforceSchema(genFormSchemaJSON);
const schema = this._addCustomFormTypes(genFormSchema)
return DatabaseStore.inTransaction(t => {
return t.persistModel(schema).then(() => schema);
});
}
}
export default new FetchEmptySchemaForType()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/form-data-helpers.es6
================================================
import _ from 'underscore';
import {Utils} from 'nylas-exports'
import SalesforceObject from '../models/salesforce-object'
import PendingSalesforceObject from './pending-salesforce-object'
export function formItemEach(formData, eachFn) {
if (!_.isFunction(eachFn)) { return; }
const fieldsets = formData.fieldsets || []
for (const fieldset of fieldsets) {
const formItems = fieldset.formItems || []
for (const formItem of formItems) {
eachFn(formItem);
}
}
}
/**
* Many forms will want to initialize new forms. When we do this we need
* to pass along serialized data of the current form state to a new
* window. Form values can be full of PendingSalesforceObjects and
* SalesforceObjects that we'll need to serialize properly.
*/
export function serializeRawFormValue(value) {
if (value === null || value === undefined) return value;
if (typeof value === "string") return value;
if (value instanceof SalesforceObject) return value.id
if (value instanceof PendingSalesforceObject) return value.toJSON();
if (value.pendingSalesforceObject) return value;
if (value.id) return value.id;
if (_.isArray(value)) return _.compact(value.map(serializeRawFormValue))
return value
}
/**
* A Salesforce REQUIRED_FIELD_MISSING API error has the following
* schema:
*
* rawError = [
* {
* errorCode: "REQUIRED_FIELD_MISSING",
* fields: ["AccountId", "LastName"]
* }
* ]
*/
export function validateForm(formData) {
const validationErrors = {}
let valid = true;
formItemEach(formData, (formItem) => {
if (formItem.required &&
((formItem.value === null || formItem.value === undefined) ||
formItem.value.length === 0)) {
valid = false;
validationErrors[formItem.id] = {
id: formItem.id,
message: "This is a required field",
}
}
})
if (valid) return Promise.resolve();
return Promise.reject(validationErrors)
}
/**
* A frontend form validation error has the following schema:
*
* validationErrors = {
* "local-123": {
* id: "local-123",
* message: "This is a required field",
* }
* "some-form-item-id": {
* id: "some-form-item-id",
* message: "Some error message",
* }
* }
*/
export function formDataWithValidationErrors(_formData, validationErrors = {}) {
const formData = Utils.deepClone(_formData);
formData.errors.formItemErrors = validationErrors;
return formData
}
export function cloneFormWithoutErrors(formData) {
const newFormData = Utils.deepClone(formData);
newFormData.errors = {};
return newFormData;
}
export function mergeSalesforceError(formData, error) {
if (error.errorCode !== "REQUIRED_FIELD_MISSING") {
return formData;
}
formData.errors.formItemErrors = {};
formItemEach(formData, (formItem) => {
if (!error.fields.includes(formItem.name)) return;
formData.errors.formItemErrors[formItem.id] = {
id: formItem.id,
message: "This is a required field",
};
})
return formData
}
// Merges errors with formData and returns a new shallow of formData
// See the generated form error data schema in:
// src/components/generated-form
export function formDataWithAPIErrors(_formData, error = {}) {
let formData = Utils.deepClone(_formData);
// Came from Edgehill API
if (error.errorCode === "REQUIRED_FIELD_MISSING") {
formData.errors = {};
formData = mergeSalesforceError(formData, error.body[0]);
return formData;
}
const msg = error.message || "Unknown error with the Salesforce API"
if (error.name === "APIError") {
formData.errors = {
formError: { message: msg },
formItemErrors: {},
};
} else {
console.log("An unexpected error occurred", error);
formData.errors = {
formError: { message: (error.message || "An unexpected error occurred") },
formItemErrors: {},
};
}
return formData;
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/generated-form-to-salesforce-adapter.es6
================================================
/**
* Converts the schema of a GeneratedForm to a data format that the
* Salesforce object creation API understands
*
* https://www.salesforce.com/us/developer/docs/api_rest/
* See:
* Using REST Resources > Using REST API Resources > Working with Records
* > Create a Record
*/
import _ from "underscore"
import SalesforceObject from '../models/salesforce-object'
class GeneratedFormToSalesforceAdapter {
static extract(formData) {
const relatedObjectsData = {};
const formPostData = {};
const fieldSets = formData.fieldsets || []
fieldSets.forEach((fieldset) => {
const formItems = fieldset.formItems || []
formItems.forEach((formItem) => {
if (formItem.type === "EmptySpace") { return; }
if (!formItem.name) {
console.error(formItem);
throw new Error("This formItem doesnt have a name");
}
if (formItem.type === "reference") {
if (_.isString(formItem.value)) {
console.error(formItem);
throw new Error("Invalid value for reference type")
}
const objIds = (formItem.value || []).filter((obj) => {
return obj instanceof SalesforceObject
}).map(obj => obj.id)
if (formItem.referenceType === "hasMany") {
relatedObjectsData[formItem.name] = objIds;
} else if (formItem.referenceType === "hasManyThrough") {
if (!formItem.referenceThrough) {
console.error(formItem);
throw new Error("Must specify referenceThrough")
}
relatedObjectsData[formItem.name] = objIds;
} else {
// This is a standards Salesforce "reference" type. In the
// Nylas language it is a "belongsTo" `referenceType`
let value = objIds[0]
if (value === null || value === undefined) value = "";
formPostData[formItem.name] = value;
}
} else {
formPostData[formItem.name] = formItem.value;
}
})
})
return {
formPostData,
relatedObjectsData,
};
}
}
export default GeneratedFormToSalesforceAdapter
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/pending-salesforce-object.es6
================================================
import {Utils} from 'nylas-exports'
/*
There are many places we want to have a SalesforceObject for which we
don't yet have the full data.
An example is when we're created a linked SalesforceObject in a
SalesforceForm. It may take a user a long time to create that object. In
the meantime, we stub in a PendingSalesforceObject to indicate that such
an activity is in progress.
*/
export default class PendingSalesforceObject {
constructor({id, type, name}) {
this.id = id || Utils.generateTempId();
this.type = type;
this.name = name;
}
toJSON() {
return {
id: this.id,
type: this.type,
name: this.name,
pendingSalesforceObject: true,
}
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/remove-controls.jsx
================================================
import React from 'react'
import _str from 'underscore.string'
import {Actions} from 'nylas-exports'
import OpenInSalesforceBtn from '../shared-components/open-in-salesforce-btn'
import DestroySalesforceObjectTask from '../tasks/destroy-salesforce-object-task'
export default class RemoveControls extends React.Component {
static propTypes = {
objectId: React.PropTypes.string,
objectType: React.PropTypes.string,
}
constructor(props) {
super(props);
this.state = {confirmDelete: false}
}
_renderOpenInSalesforce() {
return [
,
| ,
]
}
_deleteObject = () => {
Actions.recordUserEvent("Salesforce Object Delete Submitted", {
sObjectId: this.props.objectId,
sObjectType: this.props.objectType,
});
const task = new DestroySalesforceObjectTask({
sObjectId: this.props.objectId,
sObjectType: this.props.objectType,
})
Actions.queueTask(task);
setTimeout(() => { NylasEnv.close() }, 20)
}
render() {
const confirm = () => this.setState({confirmDelete: true});
const cancel = () => this.setState({confirmDelete: false});
let confirmControl
let confirmControlClass = ""
if (this.state.confirmDelete) {
confirmControlClass = "confirm-control"
confirmControl = (
Are you sure? This will permanently delete on force.com.
Yes delete
|
No cancel
)
} else {
const objectName = _str.titleize(_str.humanize(this.props.objectType))
confirmControl = (
Delete {objectName}
)
}
return (
{this._renderOpenInSalesforce()}
{confirmControl}
)
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/salesforce-object-form.jsx
================================================
import React from 'react'
import _ from 'underscore'
import _str from 'underscore.string'
import {Utils, Actions} from 'nylas-exports'
import {Spinner, GeneratedForm} from 'nylas-component-kit'
import SmartFields from './smart-fields';
import RemoveControls from './remove-controls'
import * as dataHelpers from '../salesforce-object-helpers'
import SalesforceActions from '../salesforce-actions'
import * as formDataHelpers from './form-data-helpers'
import SalesforceObjectPicker from './salesforce-object-picker'
import FetchEmptySchemaForType from './fetch-empty-schema-for-type'
import SyncbackSalesforceObjectTask from '../tasks/syncback-salesforce-object-task';
import GeneratedFormToSalesforceAdapter from './generated-form-to-salesforce-adapter';
class SalesforceObjectForm extends React.Component {
static displayName = "SalesforceObjectForm"
static containerRequired = false
static propTypes = {
/**
* The Salesforce Object ID. This is only given if we're editing an
* existing object. If it's blank that means we're creating a new
* object.
*/
objectId: React.PropTypes.string,
// The type of Salesforce Object
objectType: React.PropTypes.string.isRequired,
/**
* When the object form is created, it's passed in contextData. We use
* this data to help us intelligently fill out the form. We also pass
* the contextData onto the form so any downstream objects get created
* accordingly.
*/
contextData: SalesforceObjectPicker.propTypes.contextData,
// Any initial data we get passed when creating this object.
objectInitialData: React.PropTypes.object,
}
static defaultProps = {
contextData: {},
objectInitialData: {},
}
constructor(props) {
super(props);
this.formId = props.contextData.formId || Utils.generateTempId()
this.state = {
formData: null,
submitting: false,
formLoadingErrorMsg: null,
}
}
componentWillMount() {
this._usubs = [
SalesforceActions.deleteSuccess.listen(this._onDeleteSuccess),
SalesforceActions.syncbackFailed.listen(this._onSyncbackFailed),
SalesforceActions.syncbackSuccess.listen(this._onSyncbackSuccess),
]
NylasEnv.onBeforeUnload(this._onBeforeUnload);
this._initializeNewFormData().then(formData => {
this.setState({formData})
})
}
componentWillUnmount() {
for (const usub of this._usubs) { usub() }
return NylasEnv.removeUnloadCallback(this._onBeforeUnload);
}
_initializeNewFormData() {
return FetchEmptySchemaForType.run(this.props.objectType)
.then((emptySchema) => {
const initialSchema = this._addContextData(emptySchema)
return Promise.props({
initialSchema: initialSchema,
objectInitialData: this._initialData(),
}).then(SmartFields.fillForm)
})
.catch(this._handleFormLoadingErrors);
}
_addContextData(emptySchema) {
return Object.assign({}, emptySchema, {
formType: this.props.objectId ? "update" : "new",
contextData: Object.assign({}, this.props.contextData, {
formId: this.formId,
objectId: this.props.objectId,
objectType: this.props.objectType,
}),
})
}
_initialData = () => {
if (!this.props.objectId) return this.props.objectInitialData;
return this._loadFullObject().then(object => {
return Object.assign({}, this.props.objectInitialData,
(object.rawData || {}))
})
}
_loadFullObject = () => {
return dataHelpers.loadFullObject(this.props).then(object => {
if (!object) {
const err = new Error();
err.formErrorMessage = `The ${this._objectName()} you attempted to access with ID ${this.props.objectId} has been deleted. The user who deleted this record may be able to recover it from the Salesforce.com Recycle Bin. Deleted data is stored in the Recycle Bin for 15 days.`
throw err
}
return object
})
}
_handleFormLoadingErrors = (error) => {
let msg = `Unable to load the form for ${this._objectName()}`
if (error.formErrorMessage) msg = error.formErrorMessage;
if (!error.reportedToSentry) {
SalesforceActions.reportError(error, this.props);
}
return this.setState({formLoadingErrorMsg: msg});
}
_onSubmit = () => {
const formData = formDataHelpers.cloneFormWithoutErrors(this.state.formData);
this.setState({submitting: true, formData: formData})
const {formPostData, relatedObjectsData} = GeneratedFormToSalesforceAdapter.extract(formData);
this._submittedName = formPostData.Name || formPostData.Email
this._action = this.props.objectId ? "Edit" : "Create";
Actions.recordUserEvent(`Salesforce Object ${this._action} Submitted`, {
sObjectId: this.props.objectId,
sObjectType: this.props.objectType,
sObjectName: this._submittedName,
});
formDataHelpers.validateForm(formData).then(() => {
const t = new SyncbackSalesforceObjectTask({
objectId: this.props.objectId,
objectType: this.props.objectType,
contextData: formData.contextData,
formPostData: formPostData,
relatedObjectsData: relatedObjectsData,
});
Actions.queueTask(t);
}).catch((validationErrors = {}) => {
const newData = formDataHelpers.formDataWithValidationErrors(formData, validationErrors);
Actions.recordUserEvent(`Salesforce Object ${this._action} Errored`, {
errorType: "LocalValidationError",
errorCode: "LOCAL_FORM_VALIDATION_ERROR",
errorMessage: this._localErrorsForAnalytics(validationErrors),
sObjectId: this.props.objectId,
sObjectType: this.props.objectType,
sObjectName: this._submittedName,
});
this.setState({formData: newData, submitting: false})
// Don't rethrow
})
}
_localErrorsForAnalytics(validationErrors = {}) {
return _.uniq(_.values(validationErrors).map(({message}) => message))
.sort().join(", ")
}
_remoteErrorsForAnalytics(apiError = {}) {
const msg = (apiError.body || [])[0] || apiError.message
return [`${apiError.errorCode}: ${msg}`]
}
_onDeleteSuccess = ({objectId}) => {
if (this.props.objectId === objectId) { NylasEnv.close() }
}
_onSyncbackFailed = ({contextData, error}) => {
if (contextData.formId !== this.formId) return;
if (!this.state.formData) return;
Actions.recordUserEvent(`Salesforce Object ${this._action} Errored`, {
errorType: error.constructor.name,
errorCode: error.errorCode,
errorMessage: error.message,
sObjectId: this.props.objectId,
sObjectType: this.props.objectType,
sObjectName: this._submittedName,
});
this.setState({
submitting: false,
formData: formDataHelpers.formDataWithAPIErrors(this.state.formData, error),
})
}
_onSyncbackSuccess = ({contextData} = {}) => {
if (contextData.formId !== this.formId) return;
this._closingDueToObjectSuccess = true;
Actions.recordUserEvent(`Salesforce Object ${this._action} Succeeded`, {
sObjectId: this.props.objectId,
sObjectType: this.props.objectType,
sObjectName: this._submittedName,
});
setTimeout(() => { NylasEnv.close(); }, 20)
}
_onBeforeUnload = () => {
SalesforceActions.salesforceWindowClosing({
contextData: this.state.formData.contextData,
closingDueToObjectSuccess: this._closingDueToObjectSuccess,
});
return true;
}
_objectName() {
return _str.titleize(_str.humanize(this.props.objectType))
}
render() {
if (!this.state.formData) {
return (
)
}
if (this.state.formLoadingErrorMsg) {
return (
{this.state.formLoadingErrorMsg}
)
}
return (
this.setState({formData})}
/>
)
}
}
export default SalesforceObjectForm
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/salesforce-object-picker.jsx
================================================
import _ from 'underscore'
import React from 'react'
import titleize from 'underscore.string/titleize'
import {Actions, Utils, DatabaseStore} from 'nylas-exports'
import {FormItem, BoldedSearchResult, TokenizingTextField} from 'nylas-component-kit'
import SalesforceIcon from '../shared-components/salesforce-icon'
import SalesforceObject from '../models/salesforce-object'
import {loadBasicObject} from '../salesforce-object-helpers';
import SalesforceActions from '../salesforce-actions'
import PendingSalesforceObject from './pending-salesforce-object'
import * as formDataHelpers from './form-data-helpers'
const MAX_RESULTS = 100;
/*
This creates a selectable dropdown that lets you choose and create new
Salesforce objects.
It behaves like a standard formItem with a `value` prop and `onChange`. The value is always an array of SalesforceObject or PendingSalesforceObjects
*/
class SalesforceObjectPicker extends React.Component {
static displayName = "SalesforceObjectPicker"
static extendedPropTypes = {
// Zero or more SalesforceObject's or PendingSalesforceObjects to turn
// into `tokens` in the TokenizingTextField
value: React.PropTypes.arrayOf(
React.PropTypes.oneOfType([
React.PropTypes.instanceOf(SalesforceObject),
React.PropTypes.instanceOf(PendingSalesforceObject),
])
),
/**
* This is extra environment data about the form we're creating. It
* includes data about the thread that we're creating this object in
* context with, or with extra contact details used to help enrich the
* form.
*/
contextData: React.PropTypes.shape({
/**
* The unique ID of the form the picker is in. This is used when
* creating "PendingSalesforceObject" that are linked to forms. When
* those forms close, we can know what PendingSalesforceObjects to
* remove.
*/
formId: React.PropTypes.string,
/**
* The Salesforce object this form is about.
* If the objectId is present, then we're editing an existing
* object.
*/
objectId: React.PropTypes.string,
objectType: React.PropTypes.string,
/**
* The thread (or threads) that were selected when this object form
* was requested. We use this to pre-fill contact form fields and
* for analytics.
*/
nylasObjectId: React.PropTypes.string,
nylasObjectIds: React.PropTypes.array,
nylasObjectType: React.PropTypes.string,
/**
* Used by SmartFields to pre-fill in data given the contact that
* was focused when the object form was requested.
*/
focusedNylasContactData: React.PropTypes.object,
}),
}
static propTypes = Object.assign({},
FormItem.propTypes, SalesforceObjectPicker.extendedPropTypes);
static defaultProps = {
value: [], // Does not protect if parent sets this to `null`
onChange: () => {},
contextData: {
nylasObjectId: null,
nylasObjectIds: [],
nylasObjectType: null,
focusedNylasContactData: null,
},
}
componentWillMount() {
this._usubs = [
SalesforceActions.salesforceWindowClosing.listen(this._onSalesforceWindowClosing),
SalesforceActions.syncbackSuccess.listen(this._onSyncbackSuccess),
]
}
componentWillUnmount() {
for (const usub of this._usubs) { usub() }
}
focus() {
this.refs.tokenizingTextField.focus()
}
_tokens() {
return this.props.value || []
}
_onSyncbackSuccess = ({objectType, objectId, contextData = {}} = {}) => {
return loadBasicObject(objectType, objectId)
.then((sObject) => {
this.props.onChange(this._tokens().map((o) => {
if (o.id === contextData.formId) return sObject;
return o;
}));
});
}
_onSalesforceWindowClosing = (args) => {
if (args.closingDueToObjectSuccess) { return; }
this.props.onChange(this._tokens().filter((o) => {
return (o.id !== args.contextData.formId)
}));
}
// Returns a salesforce object given the input
_lookupSalesforceObject = (input = "", {clear} = {}) => {
return new Promise((resolve, reject) => {
let referenceTo = this.props.referenceTo;
if (_.isString(this.props.referenceTo)) {
referenceTo = [this.props.referenceTo]
}
if (clear) return resolve([])
if (input.length > 0) {
return DatabaseStore.findAll(SalesforceObject,
{type: referenceTo})
.where([SalesforceObject.attributes.name.like(input)])
.then((objects = []) => {
const re = Utils.wordSearchRegExp(input);
const inputLower = input.toLowerCase()
const sortedObjs = objects.sort((o1, o2) => {
const o1Name = o1.name.toLowerCase()
const o2Name = o2.name.toLowerCase()
const i1 = re.test(o1.name) ? o1Name.search(inputLower) : 999
const i2 = re.test(o2.name) ? o2Name.search(inputLower) : 999
return i1 - i2
})
for (const referenceType of referenceTo) {
/**
* Note that we do NOT set an id for these objects. They will
* be assigned random IDs that we'll use to set the formIDs of
* the downstream forms that each of these represents.
*/
const obj = new PendingSalesforceObject({
type: referenceType,
name: input,
})
sortedObjs.push(obj)
}
return resolve(sortedObjs.slice(0, MAX_RESULTS))
})
.catch(reject)
}
return resolve([])
})
}
// An autocomplete suggestion item
_renderObjectSuggestion = (obj, {inputValue} = {}) => {
if (obj instanceof PendingSalesforceObject) {
return (
Create new {titleize(obj.type)} “{obj.name}”
)
}
return (
)
}
// Called with either a found object or a new value
_onTokensAdd = (objs = []) => {
objs.filter(o => o instanceof PendingSalesforceObject)
.forEach(this._createNew)
this.props.onChange(this._tokens().concat(objs))
Actions.closePopover()
}
_onEditMotion = (object) => {
if (!(object instanceof SalesforceObject)) return;
SalesforceActions.openObjectForm({
objectId: object.id,
objectType: object.type,
objectInitialData: object,
contextData: this.props.contextData,
})
}
_createNew = (pendingObj = {}) => {
if ((pendingObj.name || "").trim().length === 0) return
/**
* When we create a PendingSalesforceObject, that means we want to
* create a whole new form from that object. We use the
* PendingSalesforceObject's id as the formId of the newly generated
* form. The constructor of salesforce-object-form will detect the
* formId in the passed-in contextData and initialize with that ID. By
* letting us set the ID from here, we know what form to listen to
* when the downstream form closes or saves.
*/
const contextData = Object.assign({}, this.props.contextData, {
formId: pendingObj.id,
})
SalesforceActions.openObjectForm({
objectType: pendingObj.type,
contextData: contextData,
objectInitialData: this._initialDataForNewObject(pendingObj),
});
}
/**
* When you're going to create a new object there is a lot of
* information we can give you a head start on that new object.
*
* First when you create a new object through the Salesforce Object
* Picker, we have the name you just typed.
*
* Second, the GeneratedForm also passes to each formItem (including
* this one) the currentFormValues. We pass those along as initial data.
* If we're editing a Contact and we create a new Opportunity, the
* Opportunity will want the same AccountId as the Contact's AccountID.
* By passing along the currentFormValues, we can pre-fill the
* Opportunity with what we have already.
*
* Third, we create a backRefObj to the current form you have open. If
* we're creating a brand new Contact, and also start creating an
* Account, the Account form can have a back reference to the in-flight
* Contact we're creating or the existing Contact we already have.
*
* Fourth, we pass along all additional contextData. That contextData
* includes the Nylas Thread & Contact in scope when we create this
* object. Our SmartFields adapter will use that information to query
* Clearbit and other data sources to fill in as much as possible. See
* SmartFields for a variety of other techniques we use to pre-fill the
* form.
*/
_initialDataForNewObject = (pendingObj = {}) => {
const rawForm = this.props.currentFormValues || {}
const initialData = {}
for (const name of Object.keys(rawForm)) {
initialData[name] = formDataHelpers.serializeRawFormValue(rawForm[name])
}
initialData.Name = pendingObj.name;
const selfType = this.props.contextData.objectType;
let backRef = null
if (this.props.contextData.objectId) {
/**
* For existing objects, we just need the ID of the object. It will
* be re-inflated when the downstream form loads.
*/
backRef = this.props.contextData.objectId
} else {
/**
* The id needs to be the formId of the object that created us.
* When we send this initialData to a new form,
* SmartFields._resolveInitialRefs will unpack the
* PendingSalesforceObject JSON and create the appropriate
* PendingSalesforceObject with the given ID.
*/
backRef = new PendingSalesforceObject({
id: this.props.contextData.formId,
type: selfType,
}).toJSON()
}
let key = null
if (this.props.referenceType === "hasManyThrough") {
// Back-reference will be a hasManyThrough since this is a
// hasManyThrough
key = `${selfType}Ids`
} else if (this.props.referenceType === "hasMany") {
// Back-reference will be a belongsTo since this is a hasMany
key = `${selfType}Id`
} else {
// Back-reference will be a hasMany since this is a belongsTo
key = `${selfType}Ids`
}
if (!initialData[key]) initialData[key] = [];
initialData[key].push(backRef);
return initialData
}
// The found token object
_renderFoundObject = (props) => {
if (props.token instanceof SalesforceObject) {
return (
{props.token.name}
)
} else if (props.token instanceof PendingSalesforceObject) {
return (
Creating {props.token.type}…
)
}
return false
}
_onTokensRemoved = (objs = []) => {
const toRemoveIds = objs.map(o => o.id);
const val = this._tokens().filter(o => !toRemoveIds.includes(o.id));
this.props.onChange(val)
}
render() {
const objId = (obj) => obj.id
return (
)
}
}
export default SalesforceObjectPicker
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/salesforce-schema-adapter.es6
================================================
import { Utils } from 'nylas-exports';
import _ from 'underscore';
import SalesforceActions from '../salesforce-actions'
// Salesforce provides "Layouts", which are custom specifications on what
// various object creation / edit forms look like. The data here includes
// row & column information and fieldset data. Layouts are available at
// the: /sobjects/{OBJECT_TYPE}/describe/layouts endpoint
//
// Salesforce also provides a separate schema that defines the total fields
// of a particular object type. This is the ultimate truth on what the API
// will and will not accept. It lists all fields of an object and crucially
// indicates if it is `updateable` (aka user editable), and if it's
// `nillable` (aka required). The set of fields are availabe at the:
// /sobjects/{OBJECT_TYPE}/describe endpoint.
//
// We need to look at both the layout and the schema to determine the
// proper way to display the form (via the layout) and what to mark as
// required & editable (via the schema).
//
//
// This class converts Schemas and Layouts into an object that
// the GeneratedForm component can understand.
//
// A Salesforce /describe block (the schema) looks like (as of API v37 Sept
// 2016):
// rawData = {
// "actionOverrides": [],
// "activateable": false,
// "childRelationships": [],
// "compactLayoutable": true,
// "createable": true,
// "custom": false,
// "customSetting": false,
// "deletable": true,
// "deprecatedAndHidden": false,
// "feedEnabled": true,
// "fields": [
// {
// "aggregatable": true,
// "autoNumber": false,
// "byteLength": 18,
// "calculated": false,
// "calculatedFormula": null,
// "cascadeDelete": false,
// "caseSensitive": false,
// "controllerName": null,
// "createable": false,
// "custom": false,
// "defaultValue": null,
// "defaultValueFormula": null,
// "defaultedOnCreate": true,
// "dependentPicklist": false,
// "deprecatedAndHidden": false,
// "digits": 0,
// "displayLocationInDecimal": false,
// "encrypted": false,
// "externalId": false,
// "extraTypeInfo": null,
// "filterable": true,
// "filteredLookupInfo": null,
// "groupable": true,
// "highScaleNumber": false,
// "htmlFormatted": false,
// "idLookup": true,
// "inlineHelpText": null,
// "label": "Lead ID",
// "length": 18,
// "mask": null,
// "maskType": null,
// "name": "Id",
// "nameField": false,
// "namePointing": false,
// "nillable": false,
// "permissionable": false,
// "picklistValues": [],
// "precision": 0,
// "queryByDistance": false,
// "referenceTargetField": null,
// "referenceTo": [],
// "relationshipName": null,
// "relationshipOrder": null,
// "restrictedDelete": false,
// "restrictedPicklist": false,
// "scale": 0,
// "soapType": "tns:ID",
// "sortable": true,
// "type": "id",
// "unique": false,
// "updateable": false,
// "writeRequiresMasterRead": false
// }
// {}
// ...
// ],
// "keyPrefix": "00Q",
// "label": "Lead",
// "labelPlural": "Leads",
// "layoutable": true,
// "listviewable": null,
// "lookupLayoutable": null,
// "mergeable": true,
// "mruEnabled": true,
// "name": "Lead",
// "namedLayoutInfos": [],
// "networkScopeFieldName": null,
// "queryable": true,
// "recordTypeInfos": [],
// "replicateable": true,
// "retrieveable": true,
// "searchLayoutable": true,
// "searchable": true,
// "supportedScopes": [
// {
// "label": "All leads",
// "name": "everything"
// },
// ],
// "triggerable": true,
// "undeletable": true,
// "updateable": true,
// "urls": {}
// }
//
// A Salesforce full layout looks like (as of API v37 Aug 2016):
// rawData = {
// recordTypeMappings: [
// {
// "available": true,
// "defaultRecordTypeMapping": true,
// "layoutId": "00h41000000TRMmAAO",
// "master": false,
// "name": "Master",
// "picklistsForRecordType": [],
// "recordTypeId": "01241000000Yg3MAAS",
// "urls": {
// "layout": "/services/data/v37.0/sobjects/Opportunity/describe/layouts/01241000000Yg3MAAS"
// }
// },
// ],
// recordTypeSelectorRequired: []
// layouts: [
// { // layout
// id : "00h41000000TRMtAAO"
// buttonLayoutSection : {}
// detailLayoutSections : []
// feedView : null
// highlightsPanelLayoutSection : null
// multirowEditLayoutSections : []
// offlineLinks : []
// quickActionList : {}
// relatedContent : {}
// relatedLists : []
// editLayoutSections: [ // may be many layoutSections aka fieldsets
// { // layoutSection
// rows: 8
// columns: 2
// heading: "Contact Information"
// parentLayoutId: "00h41000000TRMt"
// tabOrder: "TopToBottom"
// useCollapsibleSection: false
// useHeading: true
// layoutRows: [
// { // layoutRow
// numItems: 2
// layoutItems: [
// { // layoutItem
// editableForNew: false
// editableForUpdate: false
// label: "Contact Owner"
// placeholder: false
// required: true
// layoutComponents: [
// { // layoutComponent
// displayLines: 1
// fieldType: "string"
// tabOrder: 32
// type: "Field"
// value: "Name"
//
// components: [
// {LAYOUT_COMPONENT}
// ... a couple layoutComponent
// ]
//
// details: {
// aggregatable : true
// autoNumber : false
// byteLength : 18
// calculated : false
// calculatedFormula : null
// cascadeDelete : false
// caseSensitive : false
// controllerName : null
// createable : true
// custom : false
// defaultValue : null
// defaultValueFormula : null
// defaultedOnCreate : false
// dependentPicklist : false
// deprecatedAndHidden : false
// digits : 0
// displayLocationInDecimal : false
// encrypted : false
// externalId : false
// extraTypeInfo : null
// filterable : true
// filteredLookupInfo : null
// groupable : true
// highScaleNumber : false
// htmlFormatted : false
// idLookup : false
// inlineHelpText : null
// label : "Account ID"
// length : 18
// mask : null
// maskType : null
// name : "AccountId"
// nameField : false
// namePointing : false
// nillable : true
// permissionable : true
// picklistValues : [
// {
// active : true
// defaultValue : false
// label : "Mr."
// validFor : null
// value : "Mr."
// }
// ...
// ]
// precision : 0
// queryByDistance : false
// referenceTargetField : null
// referenceTo : [
// "Account"
// ]
// relationshipName : "Account"
// relationshipOrder : null
// restrictedDelete : false
// restrictedPicklist : false
// scale : 0
// soapType : "tns:ID"
// sortable : true
// type : "reference"
// unique : false
// updateable : true
// writeRequiresMasterRead : false
// } // details
// } // layoutComponent. Usually only 1 layoutComponent
// ]
// }
// {LAYOUT_ITEM} // layoutItem. Exactly num of columns
// ]
// } // layoutRow
// ... many layoutRows
// ]
// } // layoutSection
// ... many layoutSections (aka fieldsets)
// ]
// } // layout. Usually only 1 layout
// ]
// } // rawData
//
//
export default class SalesforceSchemaAdapter {
// The /describe endpoint actually returns a listing of available
// "Record Layout" objects. For a given Salesforce record (like an
// opportunity), there may be many different layouts (aka record
// layouts) exposed to many different types of users. SREs, Account
// Managers, and Marketers may have different fields to fill out for the
// same Opportunity.
//
// We first attempt to find the "default" layout for the user. This is
// annotated by the `defaultRecordTypeMapping` attribute of each of the
// record types in the `recordTypeMappings` field.
//
// If we can't find one, we fall back to the record mapping labeled as
// "master".
//
// Sometimes the layouts automatically come down with the describe
// block. If there are 3 or more record mappings, they need to be
// separately fetched. We detect this and return the url of the layout
// to fetch so we can asynchronously grab that later.
static defaultLayout(layoutDescription = {}) {
if (!_.isArray(layoutDescription.layouts)) return null;
if (layoutDescription.layouts.length === 1) {
return layoutDescription.layouts[0]
}
const defaultRecordType = this.defaultRecordType(layoutDescription);
const id = defaultRecordType.layoutId;
return _.findWhere(layoutDescription.layouts, {id: id})
}
static defaultRecordType(layoutDescription = {}) {
const recordTypes = layoutDescription.recordTypeMappings
if (!_.isArray(recordTypes)) {
throw new Error("Unsupported Salesforce layout: No Record Type mappings")
}
let defaultRecordType = _.findWhere(recordTypes, {defaultRecordTypeMapping: true});
if (defaultRecordType) return defaultRecordType
defaultRecordType = _.findWhere(recordTypes, {master: true});
if (!defaultRecordType) {
throw new Error("Unsupported Salesforce layout: No default Record Type nor Master Record Type ")
}
return defaultRecordType
}
static pathForDefaultLayout(layoutDescription = {}) {
const recordType = this.defaultRecordType(layoutDescription) || {}
const path = (recordType.urls || {}).layout;
if (!path) {
throw new Error("Unsupported Salesforce layout: No url for default record type")
}
return path.slice(path.search("/sobjects"))
}
// As returned from the SObject Layouts endpoint
// https://www.salesforce.com/us/developer/docs/api_rest/
//
// See ../spec/fixtures/opportunity-layouts.json for an example schema
//
// See /src/components/generated-form.cjsx for the output schema
static convertFullEditLayout({objectType, rawLayout = {}}) {
try {
if ((rawLayout.editLayoutSections || []).length === 0) {
throw new Error("Unsupported Salesforce layout: No editLayoutSections")
}
if (!rawLayout.id) {
throw new Error("Unsupported Salesforce layout: No layout Id")
}
let fieldsets = rawLayout.editLayoutSections
fieldsets = fieldsets.map((layoutSection) => {
return this.normalizeFieldset(layoutSection);
});
fieldsets = this.addCustomFieldsets(objectType, fieldsets);
const genFormSchemaJSON = {
id: rawLayout.id,
schemaType: "full",
objectType,
fieldsets,
createdAt: new Date(),
};
return genFormSchemaJSON;
} catch (error) {
error.reportedToSentry = true;
SalesforceActions.reportError(error, {objectType, rawLayout});
throw error;
}
}
// A layoutSection (aka fieldset) needs to be normalized and flattened
// from the Salesforce schema
static normalizeFieldset(layoutSection = {}) {
// We flatten all layoutItems in all rows to a single array and record
// the row and column they're supposed to appear.
let normalizedLayoutItems = [];
const layoutRows = layoutSection.layoutRows || []
for (let rowIndex = 0; rowIndex < layoutRows.length; rowIndex++) {
const layoutRow = layoutRows[rowIndex]
const layoutItems = layoutRow.layoutItems || []
for (let colIndex = 0; colIndex < layoutItems.length; colIndex++) {
const layoutItem = layoutItems[colIndex];
layoutItem.row = rowIndex;
layoutItem.column = colIndex;
normalizedLayoutItems.push(layoutItem);
}
}
// Since some layoutItems contain one or more layoutComponents (e.g.
// the Mailing Address layoutItem has 5 layoutComponents for the
// Street, City, State, etc), we flatten them all out.
normalizedLayoutItems = normalizedLayoutItems.map(this.normalizeLayoutItem);
const flattenedLayoutItems = _.compact(_.flatten(normalizedLayoutItems));
const formItems = flattenedLayoutItems.map(this.layoutItemToFormItem.bind(this));
return {
id: Utils.generateTempId(),
rows: layoutSection.rows,
columns: layoutSection.columns,
heading: layoutSection.heading,
formItems: formItems,
useHeading: layoutSection.useHeading,
};
}
static normalizeLayoutItem(rawLayoutItem = {}) {
const layoutItem = _.clone(rawLayoutItem);
const layoutComponent = (layoutItem.layoutComponents || [])[0];
if (!layoutComponent) { return null; }
delete layoutItem.layoutComponents
const components = layoutComponent.components || []
if (components.length > 0) {
return components.map((_component = {}) => {
const component = _.extend({},
layoutItem, // NOTE: We want the 'label' of the layoutItem overridden
_component,
_component.details);
delete component.details;
return component;
});
}
const normalizedLayoutItem = _.extend({},
layoutComponent,
(layoutComponent.details || {}),
layoutItem); // NOTE: we want to use the 'label' of the layoutItem
delete normalizedLayoutItem.details;
return normalizedLayoutItem;
}
static layoutItemToFormItem(layoutItem = {}) {
return {
id: Utils.generateTempId(),
row: layoutItem.row || 0,
type: this.typeMap(layoutItem.type),
name: layoutItem.name, // can be null in the EmptySpace case.
label: layoutItem.label,
column: layoutItem.column,
length: layoutItem.length,
multiple: layoutItem.type === "multipicklist",
tabIndex: layoutItem.tabOrder || 0,
required: this._isRequired(layoutItem),
placeholder: this._placeholder(layoutItem),
referenceTo: this._referenceTo(layoutItem),
defaultValue: layoutItem.defaultValue, // Used in SmartFields
selectOptions: (layoutItem.picklistValues || []).map(this.picklistOptionToFormOption),
editableForNew: layoutItem.editableForNew,
editableForUpdate: layoutItem.editableForUpdate,
// Note the disabled field is calculated in the generatedForm via
// `editableForNew` and `editableForUpdate`
};
}
static picklistOptionToFormOption(picklistOption = {}) {
return {
label: picklistOption.label,
value: picklistOption.value,
validFor: picklistOption.validFor,
defaultValue: picklistOption.defaultValue,
};
}
static _isRequired(layoutItem) {
if (layoutItem.type === "EmptySpace") return false;
// It doesn't make sense to have a checkbox be required since when
// displayed it always deafults to "false". HTML forms erroneously bug
// you when a checkbox's value is null, when in reality users perceive
// that to simply be "unchecked" aka "false".
if (layoutItem.type === "boolean") return false;
return layoutItem.nillable === false
}
static _referenceTo(layoutItem) {
return layoutItem.referenceTo || [];
}
static _placeholder(layoutItem) {
if (layoutItem.type === "reference") {
let label = (layoutItem.label || "").toLowerCase();
if (label.slice(-3) === " id") { label = label.slice(0, -3); }
const a = label[0] === "a" || label[0] === "e" || label[0] === "i" || label[0] === "o" || label[0] === "u" ? "an" : "a";
return `Create or search for ${a} ${label}`;
}
return layoutItem.label;
}
static typeMap(type) {
const knownTypes = {
"int": "number",
"phone": "tel",
"string": "text",
"address": "textarea",
"boolean": "checkbox",
"percent": "number",
"currency": "number",
"picklist": "select",
"textarea": "textarea",
"EmptySpace": "EmptySpace",
"multipicklist": "select",
};
return knownTypes[type] != null ? knownTypes[type] : type;
}
static addCustomFieldsets(objectType, fieldsets = []) {
if (objectType === "Contact") {
fieldsets.unshift(this._opportunitiesInContacts());
} else if (objectType === "Opportunity") {
fieldsets.unshift(this._contactsInOpportunities());
} else if (objectType === "Account") {
fieldsets.unshift(this._contactsInAccounts());
}
return fieldsets;
}
// Contacts are linked to Opportunities through a trivial object called
// an OpportunityContactRole. Instead of popping up a whole new object
// creator, we provide a more user-friendly interface to pick an
// Opportunity through a standard picker and create the association
// object in the background for the users.
//
// Since any additional data will throw an error if fully submitted to
// the SalesforceAPI, we use the "hasManyThrough" `refereneType`
static _opportunitiesInContacts() {
return {
id: Utils.generateTempId(),
heading: "Related Opportunities",
useHeading: true,
formItems: [{
id: Utils.generateTempId(),
row: 0,
type: "reference",
name: "OpportunityIds",
label: "Opportunities",
column: 0,
tabIndex: 0,
required: false,
multiple: true,
placeholder: "Create or search for opportunities",
defaultValue: null,
referenceTo: ["Opportunity"],
referenceType: "hasManyThrough",
referenceThrough: "OpportunityContactRole",
referenceThroughSelfKey: "identifier",
referenceThroughForeignKey: "relatedToId",
}],
};
}
static _contactsInOpportunities() {
return {
id: Utils.generateTempId(),
heading: "Contacts for Opportunity",
useHeading: true,
formItems: [{
id: Utils.generateTempId(),
row: 0,
type: "reference",
name: "ContactIds",
label: "Contacts",
column: 0,
tabIndex: 0,
required: false,
multiple: true,
placeholder: "Create or search for contacts",
defaultValue: null,
referenceTo: ["Contact"],
referenceType: "hasManyThrough",
referenceThrough: "OpportunityContactRole",
referenceThroughSelfKey: "relatedToId",
referenceThroughForeignKey: "identifier",
}],
};
}
static _contactsInAccounts() {
return {
id: Utils.generateTempId(),
heading: "Contacts for Account",
useHeading: true,
formItems: [{
id: Utils.generateTempId(),
row: 0,
type: "reference",
name: "ContactIds",
label: "Contacts",
column: 0,
tabIndex: 0,
required: false,
multiple: true,
placeholder: "Create or search for contacts",
defaultValue: null,
referenceTo: ["Contact"],
referenceType: "hasMany",
}],
};
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/salesforce-window-launcher.es6
================================================
import _str from 'underscore.string'
import {remote} from 'electron'
import SalesforceActions from '../salesforce-actions'
const WINDOW_TYPE = "SalesforceObjectForm"
const WIN_WIDTH = 600
const WIN_HEIGHT = 800
function isFormWindow(windowKey) {
const re = new RegExp(WINDOW_TYPE);
return re.test(windowKey)
}
function getScreenSize() {
return remote.screen.getPrimaryDisplay().workAreaSize
}
function findSpotOn(dir = "right") {
const defaultStart = dir === "right" ? 0 : 9999
let adjustedX = defaultStart;
let adjustedY = defaultStart;
const allWindowDimensions = NylasEnv.getAllWindowDimensions();
const testFn = dir === "right" ? Math.max : Math.min;
for (const windowKey of Object.keys(allWindowDimensions)) {
if (!isFormWindow(windowKey)) continue;
const dims = allWindowDimensions[windowKey];
const newX = dir === "right" ? dims.x + dims.width : dims.x - dims.width
adjustedX = testFn(adjustedX, newX);
adjustedY = testFn(adjustedY, dims.y);
}
return {adjustedX, adjustedY}
}
function calcBoundsForNextWindow() {
const {width, height} = getScreenSize();
const screenWidth = width
const screenHeight = height
// By default, center window in the screen.
let winY = Math.round((screenHeight / 2) - (WIN_HEIGHT / 2))
let winX = Math.round((screenWidth / 2) - (WIN_WIDTH / 2))
let {adjustedX, adjustedY} = findSpotOn('right');
if (adjustedX + WIN_WIDTH > screenWidth) {
const newDims = findSpotOn('left');
adjustedX = newDims.adjustedX
adjustedY = newDims.adjustedY
}
adjustedX = Math.min(adjustedX, screenWidth - WIN_WIDTH);
adjustedY = Math.min(adjustedY, screenHeight - WIN_HEIGHT);
// If there are other windows, place to the right of that window.
if (adjustedX > 0 || adjustedY > 0) {
winX = adjustedX;
winY = adjustedY;
}
return {
x: winX,
y: winY,
width: WIN_WIDTH,
height: WIN_HEIGHT,
}
}
class SalesforceWindowLauncher {
activate() {
this._usub = SalesforceActions.openObjectForm.listen(this._newForm)
}
deactivate() {
this._usub();
}
/**
* This will create a new Salesforce Object form in a popout window
*
* Options:
* - objectType: The Salesforce objectType i.e. "Opportunity" or "Account"
* - objectId: OPTIONAL- If present that means we want to open an
* a form to edit the given objectId
* - objectInitialData: Some initial data to seed the creation with.
* It's a hash whose keys are the SalesforceObject data keys and
* whose values are the default values for that.
* - contextData: The entity that originated the call to create a
* new window may wish to pass some identifying information to be
* passed along with the call. This is useful to let the caller know
* when a separate window closed, or an object was created, etc.
*/
_newForm({objectId, objectType, objectInitialData, contextData}) {
const objName = _str.titleize(_str.humanize(objectType))
let title = `Create New ${objName}`
if (objectId) {
title = `Update ${(objectInitialData || {}).Name || objName}`
}
NylasEnv.newWindow({
title: title,
bounds: calcBoundsForNextWindow(),
windowType: WINDOW_TYPE,
windowProps: {objectId, objectType, objectInitialData, contextData},
})
}
}
export default new SalesforceWindowLauncher()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/form/smart-fields.es6
================================================
import _ from 'underscore'
import moment from 'moment'
import {Utils, Contact, DatabaseStore} from 'nylas-exports'
import SalesforceEnv from '../salesforce-env'
import SalesforceObject from '../models/salesforce-object'
import PendingSalesforceObject from './pending-salesforce-object'
/**
* This attempts to pre-fill as much data as possible in generic
* Salesforce forms. We keep a known mapping of Nylas Fields to common
* Salesforce fields.
*
* The initialSchema is the blank schema loaded via
* `FetchEmptySchemaForType`
*
* contextData is data passed into a form from the form's creator. This
* provides context and additional data (like related Contacts) to the
* form so we can have something to pre-fill from.
*
* objectInitialData is the combination of initially passed in data to the
* form and existing data if the object already exists and we're editing
* it.
*
* This uses the Clearbit API to load contextual information about various
* contacts.
*/
class SmartFields {
fillForm = (args = {}) => {
return Promise.resolve(this.checkArgs(args))
.then(this.addSelfAsOwner)
.then(this.loadAssociatedContact)
.then(this.formItemEach((formItem) => {
if (this.isUnfillable(formItem)) return formItem;
return Promise.resolve(formItem)
.then(this.fillFromInitialData(args.objectInitialData))
.then(this.fillJoinedReferences(args))
.then(this.fillFromDefaultValue)
.then(this.fillFromKnownFields)
.then(this.fillFromClearbit(args))
.then(this.normalizeValue)
})).then((formData) => formData)
}
checkArgs(args) {
if (!args.initialSchema) {
throw new Error("Need initial schema")
}
return args
}
addSelfAsOwner = (args) => {
return SalesforceEnv.loadIdentity().then(identity => {
args.objectInitialData.OwnerId = identity.id;
return args
});
}
loadAssociatedContact = (args) => {
const {focusedNylasContactData} = args.initialSchema.contextData;
if (!focusedNylasContactData) return args;
if (focusedNylasContactData.id) {
return DatabaseStore.find(Contact, focusedNylasContactData.id)
.then((contact) => {
args.initialSchema.contextData.contact = contact;
return args
})
}
args.initialSchema.contextData.contact = new Contact({
name: focusedNylasContactData.name,
email: focusedNylasContactData.email,
});
return Promise.resolve(args)
}
formItemEach(eachFn) {
return (args) => {
const formData = Utils.deepClone(args.initialSchema);
return Promise.each(formData.fieldsets, (fieldset) => {
return Promise.each(fieldset.formItems, (formItem) => {
// Designed to update formData via each formItem in place
return eachFn(formItem)
})
}).then(() => formData)
}
}
hasEmptyValue(formItem) {
if (typeof formItem.value === 'string' || _.isArray(formItem.value)) {
return formItem.value.length === 0
}
return (formItem.value === null || formItem.value === undefined)
}
isUnfillable = (formItem) => {
return formItem.type === "EmptySpace" || !formItem.name
}
fillFromInitialData = (objectInitialData = {}) => {
return (formItem) => {
if (formItem.name in objectInitialData) {
formItem.value = objectInitialData[formItem.name];
}
return formItem;
}
}
/**
* For "hasMany" and "hasManyThrough" reference types. Based on the
* objectId we lookup all related objects for that object given the
* reference flags stored on the formItem's value.
*
* The formItem's value for a type reference must always be an array.
* The array must end up filled with zero or more SalesforceObjects
*
* To resolve these references properly we make use of the following
* formItem fields:
*
* - type === "reference"
* - referenceTo
* - referenceType
* - referenceThrough
* - referenceThroughSelfKey
* - referenceThroughForeignKey
*
* See SalesforceSchemaAdapter for a place we insert objects with
* more complex referenceTypes
*/
fillJoinedReferences = (args) => {
const objectId = args.initialSchema.contextData.objectId;
return (formItem) => {
if (formItem.type !== "reference") return formItem;
if (!formItem.referenceType) formItem.referenceType = "belongsTo";
return this._resolveInitialRefs(formItem.value, formItem.referenceTo, args.initialSchema.contextData).then((value) => {
formItem.value = value;
if (!objectId) return formItem;
if (formItem.referenceType === "hasMany") {
// Example: An Account hasMany Contacts. Each Contact has an
// AccountId pointer in their "relatedToId" field
return DatabaseStore.findAll(SalesforceObject, {
type: formItem.referenceTo,
relatedToId: objectId,
}).then((objs = []) => {
formItem.value = formItem.value.concat(objs);
return formItem
})
} else if (formItem.referenceType === "hasManyThrough") {
// Example: A Contact hasMany Opportunities through
// OpportunityContactRoles.
//
// For a given Contact, we can lookup OpportunityContactRoles by
// the Contact's id. The referenceThroughSelfKey for a Contact is
// the "identifier" field of an OpportunityContactRole. The
// referenceThroughForeignKey for a Contact is the "relatedToId"
// field of an OpportunityContactRole.
const joinWhere = {type: formItem.referenceThrough}
joinWhere[formItem.referenceThroughSelfKey] = objectId;
return DatabaseStore.findAll(SalesforceObject, joinWhere)
.then((joinItems = []) => {
if (joinItems.length === 0) return [];
const objIds = _.pluck(joinItems, formItem.referenceThroughForeignKey)
return DatabaseStore.findAll(SalesforceObject, {
type: formItem.referenceTo, id: objIds,
})
})
.then((objs = []) => {
formItem.value = formItem.value.concat(objs);
return formItem
})
}
// We get here if it's a "belongsTo" referenceType and the field
// is blank & empty. In the "belongsTo" case there's nothing to
// lookup, so we simply return the formItem.
return formItem
})
}
}
/**
* When we fill reference types, the key may already have a value
* associated with it. That value represents objects we want to pre-fill
* into a field, in addition to those we find already related to the
* object. The value may come to us in a variety of formats.
*
* We return an array of zero or more SalesforceObject or
* PendingSalesforceObject types.
*/
_resolveInitialRefs = (rawValue = [], referenceTo) => {
const ids = []
const outValue = []
const pendingJSON = []
if (rawValue === null || rawValue === undefined) {
return Promise.resolve(outValue)
} else if (typeof rawValue === "string") {
ids.push(rawValue)
} else if (_.isArray(rawValue)) {
for (const val of rawValue) {
if (typeof val == "string") ids.push(val);
if (val.pendingSalesforceObject) pendingJSON.push(val);
if (val instanceof SalesforceObject) outValue.push(val);
if (val instanceof PendingSalesforceObject) outValue.push(val);
}
} else if (rawValue.pendingSalesforceObject) {
pendingJSON.push(rawValue)
} else {
return Promise.resolve(outValue)
}
return DatabaseStore.findAll(SalesforceObject, {
type: referenceTo,
id: ids,
}).then((objs = []) => {
return outValue.concat(objs).concat(pendingJSON.map((objJSON) => {
/**
* As initialData we can pass in the JSON of a
* PendingSalesforceObject. We do this to initialize back
* references to forms. The id of the JSON has been set to the
* formId of the form creating the backref. That way if the
* creating form closes, the creating form's ID will match with
* our PendingSalesforceObject's ID, and we'll properly dismiss
* the PendingSalesforceObject in the form.
*/
return new PendingSalesforceObject(objJSON)
}));
})
}
fillFromDefaultValue = (formItem) => {
if (!this.hasEmptyValue(formItem)) return formItem
if (formItem.defaultValue && formItem.defaultValue.length > 0) {
formItem.value = formItem.defaultValue
}
if (formItem.type === "checkbox") { formItem.value = false; }
return formItem
}
fillFromKnownFields = (formItem) => {
if (!this.hasEmptyValue(formItem)) return formItem;
const knownFields = {
CloseDate: () => moment().add(1, 'month').format("YYYY-MM-DD"),
StageName: () => "Prospecting",
ForecastCategoryName: () => "Pipeline",
LeadStatus: () => "Working - Contacted",
}
if (formItem.name in knownFields) {
formItem.value = knownFields[formItem.name]()
}
if (!this.hasEmptyValue(formItem)) formItem.prefilled = true;
return formItem;
}
fillFromClearbit = (args) => {
return (formItem) => {
if (!this.hasEmptyValue(formItem)) return formItem;
const contact = args.initialSchema.contextData.contact;
const objectType = args.initialSchema.contextData.objectType;
if (!contact || !this.hasEmptyValue(formItem)) return formItem;
formItem.value = this.getFieldFromClearbit(contact, objectType, formItem.name)
if (!this.hasEmptyValue(formItem)) formItem.prefilled = true;
return formItem;
}
}
normalizeValue = (formItem) => {
if (this.hasEmptyValue(formItem)) return formItem;
if (formItem.name.includes("LinkedIn")) {
formItem.value = `https://linkedin.com/${formItem.value}`
} else if (formItem.name === "FirstName") {
if (formItem.value.includes("@")) {
formItem.value = null
}
}
if (this.hasEmptyValue(formItem)) formItem.prefilled = false;
return formItem;
}
/**
* This is the default field mapping between Clearbit's Enrichment
* API for Persons (version 2016-01-04) and Companies
* (version 2016-05-18), and a standard uncustomized Salesforce
* environment
*
* TODO: Load a custom config from the `SalesforceEnv` that lets users
* customize their field mappings.
*
* See https://dashboard.clearbit.com/docs#enrichment-api-company-api-attributes
*
*/
getFieldFromClearbit(contact, objectType, formItemName) {
const cbPerson = "thirdPartyData.clearbit.rawClearbitData.person"
const cbCompany = "thirdPartyData.clearbit.rawClearbitData.company"
const personMapping = {
Name: "name",
Email: "email",
Phone: "phone",
Salutation: "",
FirstName: "firstName",
MiddleName: "",
LastName: "lastName",
Suffix: "",
Company: `company,${cbPerson}.employment.name,${cbCompany}.name,guessCompanyFromEmail`,
Title: `${cbPerson}.employment.title`,
Department: `${cbPerson}.employment.role`,
Website: `${cbCompany}.url`,
Street: "",
City: `${cbPerson}.city,${cbCompany}.city`,
// StateCode: `${cbPerson}.stateCode,${cbCompany}.stateCode`,
PostalCode: "",
// CountryCode: `${cbPerson}.countryCode,${cbCompany}.countryCode`,
LinkedIn__c: `${cbPerson}.linkedin.handle`,
LinkedIn_personal_url__c: `${cbPerson}.linkedin.handle`,
}
const companyMapping = {
Name: `${cbCompany}.name`,
Website: `${cbCompany}.url`,
Phone: `${cbCompany}.phone`,
Description: `${cbCompany}.description`,
Industry: `${cbCompany}.category.industry`,
NumberOfEmployees: `${cbCompany}.metrics.employees`,
BillingStreet: `${cbCompany}.geo.streetNumber+${cbCompany}.geo.streetName`,
BillingCity: `${cbCompany}.geo.city`,
// BillingStateCode: `${cbCompany}.geo.state`,
BillingPostalCode: `${cbCompany}.geo.postalCode`,
// BillingCountryCode: `${cbCompany}.geo.country`,
ShippingStreet: `${cbCompany}.geo.streetNumber+${cbCompany}.geo.streetName`,
ShippingCity: `${cbCompany}.geo.city`,
// ShippingStateCode: `${cbCompany}.geo.state`,
ShippingPostalCode: `${cbCompany}.geo.postalCode`,
// ShippingCountryCode: `${cbCompany}.geo.country`,
}
const mapping = {
Lead: personMapping,
Contact: personMapping,
Account: companyMapping,
Opportunity: companyMapping,
}
const lookupPath = (mapping[objectType] || {})[formItemName]
return Utils.resolvePath(lookupPath, contact)
}
}
export default new SmartFields()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/main.jsx
================================================
import React from 'react'
import {Rx, ComponentRegistry, WorkspaceStore} from 'nylas-exports'
// Worker to fetch new Salesforce objects
import SalesforceDataReset from './salesforce-data-reset'
import SalesforceSyncWorker from './salesforce-sync-worker'
// Plugin-wide environment and object store
import SalesforceEnv from './salesforce-env'
import SalesforceAPIError from './salesforce-api-error'
import SalesforceErrorReporter from './salesforce-error-reporter'
import SalesforceNewMailListener from './salesforce-new-mail-listener'
// import SalesforceIntroNotification from './salesforce-intro-notification'
// Database Objects
import SalesforceSchema from './models/salesforce-schema'
import SalesforceObject from './models/salesforce-object'
// Salesforce Create / Update Forms
import SalesforceObjectForm from './form/salesforce-object-form'
import SalesforceWindowLauncher from './form/salesforce-window-launcher'
// Enhancements to Thread
import SalesforceSyncLabel from './thread/salesforce-sync-label'
import RelatedObjectsForThread from './thread/related-objects-for-thread'
import SalesforceSyncMessageStatus from './thread/salesforce-sync-message-status'
import SalesforceManuallyRelateThreadButton from './thread/salesforce-manually-relate-thread-button'
// Enhancements to Sidebar Contact info
import SalesforceContactInfo from './contact/salesforce-contact-info'
// Enhancements to Search
import SalesforceSearchIndexer from './search/salesforce-search-indexer'
import SalesforceSearchBarResults from './search/salesforce-search-bar-results'
// Enhancements to Composer
import ParticipantDecorator from './composer/participant-decorator'
import ContactSearchResults from './composer/contact-search-results'
// Tasks to sync emails back to Salesforce
import SyncSalesforceObjectsTask from './tasks/sync-salesforce-objects-task'
import DestroySalesforceObjectTask from './tasks/destroy-salesforce-object-task'
import SyncbackSalesforceObjectTask from './tasks/syncback-salesforce-object-task'
import EnsureMessageOnSalesforceTask from './tasks/ensure-message-on-salesforce-task'
import DestroyMessageOnSalesforceTask from './tasks/destroy-message-on-salesforce-task'
import UpsertOpportunityContactRoleTask from './tasks/upsert-opportunity-contact-role-task'
import ManuallyRelateSalesforceObjectTask from './tasks/manually-relate-salesforce-object-task'
import SyncThreadActivityToSalesforceTask from './tasks/sync-thread-activity-to-salesforce-task'
import RemoveManualRelationToSalesforceObjectTask from './tasks/remove-manual-relation-to-salesforce-object-task'
import SalesforceIntroNotification from './salesforce-intro-notification'
import SalesforceRelatedObjectCache from './salesforce-related-object-cache'
function SalesforceObjectFormWithWindowProps() {
return
}
SalesforceObjectFormWithWindowProps.containerRequired = false
SalesforceObjectFormWithWindowProps.displayName = "SalesforceObjectFormWithWindowProps"
// This special `modelConstructors` key will add the following
// constructors to the `DatabaseObjectRegistry`. This will enable model
// serialization across IPC as well as SQL Table construction.
export const modelConstructors = [
SalesforceSchema,
SalesforceObject,
]
// This special `taskConstructors` key will add the following
// constructors to the `TaskRegistry`. This will enable task serialization
export const taskConstructors = [
SalesforceAPIError, // So it can go across the action bridge
SyncSalesforceObjectsTask,
DestroySalesforceObjectTask,
SyncbackSalesforceObjectTask,
EnsureMessageOnSalesforceTask,
DestroyMessageOnSalesforceTask,
UpsertOpportunityContactRoleTask,
SyncThreadActivityToSalesforceTask,
ManuallyRelateSalesforceObjectTask,
RemoveManualRelationToSalesforceObjectTask,
]
const components = [
{
component: SalesforceSyncLabel,
role: "Thread:MailLabel",
window: "default",
onlyWhenLoggedIn: true,
},
{
component: SalesforceManuallyRelateThreadButton,
role: "ThreadActionsToolbarButton",
window: "default",
onlyWhenLoggedIn: true,
},
{
component: RelatedObjectsForThread,
role: "MessageListHeaders",
window: "default",
onlyWhenLoggedIn: true,
},
{
component: SalesforceSyncMessageStatus,
role: "MessageFooterStatus",
window: "default",
onlyWhenLoggedIn: true,
},
{
component: SalesforceContactInfo,
role: "MessageListSidebar:ContactCard",
window: "default",
onlyWhenLoggedIn: true,
},
{
component: SalesforceSearchBarResults,
role: "SearchBarResults",
window: "default",
onlyWhenLoggedIn: true,
},
{
component: ParticipantDecorator,
role: "Composer:RecipientChip",
window: "default",
onlyWhenLoggedIn: true,
},
{
component: ContactSearchResults,
role: "ContactSearchResults",
window: "default",
onlyWhenLoggedIn: true,
},
{
component: SalesforceIntroNotification,
role: "RootSidebar:Notifications",
window: "default",
onlyWhenLoggedIn: false,
},
{
component: ParticipantDecorator,
role: "Composer:RecipientChip",
window: "composer",
onlyWhenLoggedIn: true,
},
{
component: ContactSearchResults,
role: "ContactSearchResults",
window: "composer",
onlyWhenLoggedIn: true,
},
{
component: SalesforceObjectFormWithWindowProps,
location: WorkspaceStore.Location.Center,
window: "SalesforceObjectForm",
onlyWhenLoggedIn: false,
},
]
function setComponentActivation() {
components.forEach((opts) => {
if (NylasEnv.getWindowType() !== opts.window) return;
if (opts.onlyWhenLoggedIn && !SalesforceEnv.isLoggedIn()) {
ComponentRegistry.unregister(opts.component);
} else {
ComponentRegistry.register(opts.component, opts)
}
})
}
const stores = [
{store: SalesforceEnv, window: "all"},
{store: SalesforceWindowLauncher, window: "all"},
{store: SalesforceErrorReporter, window: "default"},
{store: SalesforceNewMailListener, window: "default"},
{store: SalesforceRelatedObjectCache, window: "default"},
{store: SalesforceDataReset, window: "work"},
{store: SalesforceSyncWorker, window: "work"},
{store: SalesforceSearchIndexer, window: "work"},
]
function storesForWindow() {
return stores.filter(({window}) => {
return window === NylasEnv.getWindowType() || window === "all"
}).map(({store}) => store)
}
let disp = {dispose: () => {}}
export function activate() {
if (NylasEnv.getWindowType() === 'SalesforceObjectForm') {
WorkspaceStore.defineSheet(
'Main',
{root: true},
{popout: ['Center']},
)
}
disp = Rx.Observable.fromConfig('salesforce.id').subscribe(setComponentActivation)
storesForWindow().forEach(s => s.activate())
}
export function deactivate() {
disp.dispose()
components.forEach(opts => ComponentRegistry.unregister(opts.component))
storesForWindow().forEach(s => s.deactivate())
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/metadata-helpers.es6
================================================
import {Utils} from 'nylas-exports'
import {PLUGIN_ID} from './salesforce-constants'
// When we attach metadata to Nylas objects we save SalesforceObjects
// according to the following schemas.
//
// thread.metadata = {
// manuallyRelatedTo: {
// "salesforceAccountId": {
// id: "salesforceAccountId",
// type: "Account",
// }
// "salesforceOpportunityId": {
// id: "salesforceOpportunityId",
// type: "Opportunity",
// }
// "salesforceCaseId": {
// id: "salesforceCaseId",
// type: "Case",
// }
// }
//
// syncActivityTo: {
// "salesforceOpportunityId": {
// id: "salesforceOpportunityId",
// type: "Opportunity",
// }
// }
// }
//
// message.metadata = {
// clonedAs: {
// "opportunityId": {
// "taskId1": {
// id: "taskId1",
// type: "Task",
// relatedToId: "opportunityId",
// },
// "emailMessageId": {
// id: null,
// errorCode: "FILE_TOO_LARGE"
// errorMessage: "Body too large"
// type: "EmailMessage",
// relatedToId: "opportunityId",
// },
// },
// "accountId": {
// "taskId2": {
// id: "taskId2",
// type: "Task",
// relatedToId: "accountId",
// },
// "emailMessageId2": {
// id: "emailMessageId2",
// type: "EmailMessage",
// relatedToId: "accountId",
// },
// },
// }
// }
//
// Schema before 2016-10-18 the schema used to look like:
// nylasObject.metadata = {
// sObjects: {
// "salesforceID1": {
// id: "salesforceID1",
// type: "Opportunity",
// },
// "salesforceID2": {
// id: "salesforceID2",
// type: "Account",
// },
// "salesforceID3": {
// id: "salesforceID3",
// type: "Task",
// relatedToId: "salesforceID1"
// },
// "salesforceID4": {
// id: "salesforceID4",
// type: "EmailMessage",
// relatedToId: "salesforceID1"
// }
// }
// }
function metadataClone(nylasObject) {
return Utils.deepClone(nylasObject.metadataForPluginId(PLUGIN_ID) || {});
}
export function getManuallyRelatedObjects(nylasObject) {
const metadata = metadataClone(nylasObject);
return metadata.manuallyRelatedTo || {}
}
/**
* Note we only store the id and type in the metadata.
*/
export function setManuallyRelatedObject(nylasObject, {id, type} = {}) {
if (!id || !type) throw new Error("Must provide id and type of object");
const metadata = metadataClone(nylasObject);
const manuallyRelatedTo = metadata.manuallyRelatedTo || {};
manuallyRelatedTo[id] = {id, type, name};
metadata.manuallyRelatedTo = manuallyRelatedTo;
nylasObject.applyPluginMetadata(PLUGIN_ID, metadata);
return metadata
}
export function removeManuallyRelatedObject(nylasObject, {id} = {}) {
if (!id) throw new Error("Must provide id");
const metadata = metadataClone(nylasObject);
const manuallyRelatedTo = metadata.manuallyRelatedTo || {};
delete manuallyRelatedTo[id];
metadata.manuallyRelatedTo = manuallyRelatedTo;
nylasObject.applyPluginMetadata(PLUGIN_ID, metadata);
return metadata
}
export function getSObjectsToSyncActivityTo(nylasObject) {
const metadata = metadataClone(nylasObject);
return metadata.syncActivityTo || {}
}
export function addActivitySyncSObject(nylasObject, {id, type} = {}) {
if (!id || !type) throw new Error("Must provide id and type of object");
const metadata = metadataClone(nylasObject);
const syncActivityTo = metadata.syncActivityTo || {};
syncActivityTo[id] = {id, type};
metadata.syncActivityTo = syncActivityTo;
nylasObject.applyPluginMetadata(PLUGIN_ID, metadata);
return metadata
}
export function removeActivitySyncSObject(nylasObject, {id} = {}) {
if (!id) throw new Error("Must provide id of object");
const metadata = metadataClone(nylasObject);
const syncActivityTo = metadata.syncActivityTo || {};
delete syncActivityTo[id]
metadata.syncActivityTo = syncActivityTo;
nylasObject.applyPluginMetadata(PLUGIN_ID, metadata);
return metadata
}
export function getClonedAsForSObject(nylasObject, relatedSObject = {}) {
const metadata = metadataClone(nylasObject);
const clonedAs = metadata.clonedAs || {}
return clonedAs[relatedSObject.id] || {}
}
export function getClonedAs(nylasObject) {
const metadata = metadataClone(nylasObject);
return metadata.clonedAs || {}
}
// A Nylas Message may be replicated as a Salesforce Task on multiple
// Salesforce Opportunities. Given a Salesforce Task sObject, this will
// look through all opportunities until we find one with that Task id,
// then if so, return the corresponding opportunityId it's found under.
//
// This is useful when we discover that a Salesforce Task has been deleted
// and we need to cleanup references to that Task.
export function relatedIdForClonedSObject(nylasObject, sObject = {}) {
const metadata = metadataClone(nylasObject);
const clonedAs = metadata.clonedAs || {}
for (const relatedToId of Object.keys(clonedAs)) {
if (clonedAs[relatedToId][sObject.id]) return relatedToId;
}
return null;
}
export function addClonedSObject(nylasObject, relatedSObject = {}, {id, type, relatedToId} = {}) {
if (!id || !type || !relatedToId) throw new Error("Must provide id, type, and relatedToId of object");
if (!relatedSObject.id) throw new Error("Must provide a related sObject with an id")
const metadata = metadataClone(nylasObject);
const clonedAs = metadata.clonedAs || {};
const clonedAsForObj = clonedAs[relatedSObject.id] || {};
clonedAsForObj[id] = {id, type, relatedToId};
clonedAs[relatedSObject.id] = clonedAsForObj;
clonedAs[relatedSObject.id].type = relatedSObject.type;
metadata.clonedAs = clonedAs;
nylasObject.applyPluginMetadata(PLUGIN_ID, metadata);
return metadata
}
export function removeClonedSObject(nylasObject, relatedSObject = {}, {id}) {
if (!id) throw new Error("Must provide id of cloned object");
if (!relatedSObject.id) throw new Error("Must provide a related sObject with an id")
const metadata = metadataClone(nylasObject);
const clonedAs = metadata.clonedAs || {};
const clonedAsForObj = clonedAs[relatedSObject.id] || {};
delete clonedAsForObj[id]
clonedAs[relatedSObject.id] = clonedAsForObj;
if (Object.keys(clonedAsForObj).length === 0) {
delete clonedAs[relatedSObject.id]
}
metadata.clonedAs = clonedAs;
nylasObject.applyPluginMetadata(PLUGIN_ID, metadata);
return metadata
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/models/salesforce-object.es6
================================================
import {Model, Attributes} from 'nylas-exports'
class SalesforceObject extends Model {
static searchable = true
static searchFields = ['content']
// This intentionally does NOT use _.extend(... Model.Attributes)
// because we do NOT want most of those attributes.
// So, we pick the relevant attributes by hand.
static 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',
}),
type: Attributes.String({
queryable: true,
modelKey: 'type',
jsonKey: 'type',
}),
name: Attributes.String({
queryable: true,
modelKey: 'name',
jsonKey: 'name',
}),
// Can optionally be used to query for objects if the name is not
// sufficient. For example, a Contact object might want to put the
// `email` field here. A Task might want to put the `description`
// field here.
// We also downcase and trim the data before it goes into the
// "identifier" field.
identifier: Attributes.String({
queryable: true,
modelKey: 'identifier',
jsonKey: 'identifier',
}),
relatedToId: Attributes.String({
queryable: true,
modelKey: 'relatedToId',
jsonKey: 'relatedToId',
}),
updatedAt: Attributes.DateTime({
queryable: true,
modelKey: 'updatedAt',
jsonKey: 'updatedAt',
}),
// NOTE: We always expect that rawData is filled with the complete
// object (not a partial object). We use that to determine if we have
// enough information to display a SalesforceObject Edit field.
rawData: Attributes.Object({
modelKey: 'rawData',
jsonKey: 'rawData',
}),
isSearchIndexed: Attributes.Boolean({
queryable: true,
modelKey: 'isSearchIndexed',
jsonKey: 'is_search_indexed',
defaultValue: false,
loadFromColumn: true,
}),
// This corresponds to the rowid in the FTS table. We need to use the FTS
// rowid when updating and deleting items in the FTS table because otherwise
// these operations would be way too slow on large FTS tables.
searchIndexId: Attributes.Number({
modelKey: 'searchIndexId',
jsonKey: 'search_index_id',
}),
}
static sortOrderAttribute = () => {
return SalesforceObject.attributes.name
}
static naturalSortOrder = () => {
return SalesforceObject.sortOrderAttribute().descending()
}
static additionalSQLiteConfig = {
setup: () => [
'CREATE INDEX IF NOT EXISTS TypeIdentifierIndex ON `SalesforceObject` (type, identifier)',
'CREATE INDEX IF NOT EXISTS TypeRelatedToIdIndex ON `SalesforceObject` (type, relatedToId)',
],
}
}
export default SalesforceObject
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/models/salesforce-schema.es6
================================================
import {Model, Attributes} from 'nylas-exports'
class SalesforceSchema extends Model {
static attributes = {
id: Attributes.String({
queryable: true,
modelKey: 'id',
jsonKey: 'id',
}),
schemaType: Attributes.String({
queryable: true,
modelKey: 'schemaType',
jsonKey: 'schemaType',
}),
objectType: Attributes.String({
queryable: true,
modelKey: 'objectType',
jsonKey: 'objectType',
}),
fieldsets: Attributes.Object({
modelKey: 'fieldsets',
jsonKey: 'fieldsets',
}),
createdAt: Attributes.DateTime({
queryable: true,
modelKey: 'createdAt',
jsonKey: 'createdAt',
}),
}
}
export default SalesforceSchema
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/related-object-helpers.es6
================================================
import _ from 'underscore'
import {
Rx,
DatabaseStore,
Thread,
} from 'nylas-exports'
import * as mdHelpers from './metadata-helpers'
import SalesforceRelatedObjectCache from './salesforce-related-object-cache'
export function getUniqueRelatedSObjects(directObjects, manualObjects) {
const sObjects = []
const addedObjectIds = []
for (const obj of directObjects.concat(manualObjects)) {
if (!addedObjectIds.includes(obj.id)) {
sObjects.push(obj)
addedObjectIds.push(obj.id)
}
}
return sObjects
}
export function relatedSObjectsForThread(thread) {
const direct = _.values(SalesforceRelatedObjectCache.directlyRelatedSObjectsForThread(thread))
const manual = _.values(mdHelpers.getManuallyRelatedObjects(thread))
if (!direct) return []
return getUniqueRelatedSObjects(direct, manual)
}
export function observeRelatedSObjectsForThread(thread) {
const directSource = SalesforceRelatedObjectCache.observeDirectlyRelatedSObjectsForThread(thread)
const manualSource = Rx.Observable.fromQuery(DatabaseStore.find(Thread, thread.id))
return Rx.Observable.combineLatest(directSource, manualSource).map((objects) => {
const [directObjectMap, observedThread] = objects
const directObjects = _.values(directObjectMap);
let manualObjects = _.values(mdHelpers.getManuallyRelatedObjects(observedThread))
manualObjects = manualObjects.map((manualObject) => {
manualObject.manuallyRelated = true
return manualObject
})
return getUniqueRelatedSObjects(directObjects, manualObjects)
})
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-actions.es6
================================================
import _ from 'underscore'
import {Reflux} from 'nylas-exports'
const globalSFActions = Reflux.createActions([
"deleteSuccess",
"syncbackSuccess",
"syncbackFailed",
"salesforceWindowClosing",
"loginToSalesforce",
"logoutOfSalesforce",
"syncSalesforce",
"reportError",
])
const localSFActions = Reflux.createActions([
"openObjectForm",
])
const SalesforceActions = _.extend({}, localSFActions, globalSFActions)
for (const actionName of Object.keys(SalesforceActions)) {
SalesforceActions[actionName].sync = true
}
NylasEnv.registerGlobalActions({
pluginName: "Salesforce",
actions: globalSFActions,
});
export default SalesforceActions
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-api-error.es6
================================================
import _ from 'underscore'
import {APIError} from 'nylas-exports'
// Salesforce errors have a JSON body of the following format:
//
// error.message = [
// {
// message: "Some Salesforce error message."
// errorCode: "SOME_SALESFORCE_ERROR_CODE_STRING"
// }
// ]
export default class SalesforceAPIError extends APIError {
constructor(args) {
super(args);
if (_.isArray(this.body)) {
this.messages = _.pluck(this.body, "message")
this.errorCodes = _.pluck(this.body, "errorCode")
this.message = this.messages[0]
this.errorCode = this.errorCodes[0]
} else if (_.isString(this.body)) {
this.messages = [this.body]
this.errorCodes = []
this.message = this.body
this.errorCode = null
} else {
this.messages = []
this.errorCodes = []
this.message = "Unknown Salesforce Error"
this.errorCode = null
}
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-api.jsx
================================================
import {shell, remote} from 'electron'
import React from 'react'
import {Actions, NylasAPIRequest} from 'nylas-exports'
import SalesforceActions from './salesforce-actions'
import SalesforceOAuth from './salesforce-oauth'
import SalesforceAPIError from './salesforce-api-error'
class SalesforceAPI {
constructor() {
this.VERSION = "v37.0"
NylasEnv.config.onDidChange('salesforce.instance_url', this._setAPIRoot)
this._setAPIRoot()
this._apiDisabled = false
this._tokenRefreshPromise = null
}
_setAPIRoot = () => {
const instanceUrl = NylasEnv.config.get("salesforce.instance_url");
if (instanceUrl) {
this.APIRoot = `${instanceUrl}/services/data/${this.VERSION}`;
this._apiDisabled = false
} else {
this.APIRoot = null
}
}
makeRequest(options = {}) {
if (NylasEnv.getLoadSettings().isSpec) {
return Promise.resolve();
}
if (this._apiDisabled === true) { return Promise.resolve() }
if (!this.APIRoot) {
return Promise.reject(new Error("Please authenticate Salesforce first"))
}
// Always refresh since the accessToken may have changed.
options.auth = {
bearer: SalesforceOAuth.accessToken(),
}
const req = new NylasAPIRequest({
api: this,
options,
});
return req.run()
.catch((apiError) => {
const salesforceAPIError = new SalesforceAPIError(apiError)
if (this._isBadTokenError(salesforceAPIError)) {
if (!this._tokenRefreshPromise) {
this._tokenRefreshPromise = this._refreshToken()
}
return this._tokenRefreshPromise.then(() => {
this._tokenRefreshPromise = null;
}).then(this._retry(options))
} else if (salesforceAPIError.errorCode === "API_DISABLED_FOR_ORG") {
Actions.recordUserEvent("Salesforce Connect Errored", {
errorType: salesforceAPIError.constructor.name,
errorCode: salesforceAPIError.errorCode,
errorMessage: salesforceAPIError.message,
})
this._handleAPIDisabled()
return Promise.reject(salesforceAPIError)
}
return Promise.reject(salesforceAPIError)
})
}
_retry(options) {
return () => {
if (options.retries >= 2) {
this._unableToAuth()
return Promise.reject(new Error("Unable to refresh token"))
}
options.retries = (options.retries || 0) + 1
return this.makeRequest(options) // Try one more time
}
}
_isBadTokenError(salesforceAPIError) {
const statusCode = salesforceAPIError.statusCode
if (statusCode === 401 || statusCode === 403) {
if (salesforceAPIError.errorCode === "INVALID_SESSION_ID") return true;
if (salesforceAPIError.message === "Bad_OAuth_Token") return true;
SalesforceActions.reportError(salesforceAPIError);
return false
}
return false
}
_isLimitExceeded(salesforceAPIError) {
return (salesforceAPIError.errorCode === "REQUEST_LIMIT_EXCEEDED")
}
_handleAPIDisabled() {
if (this._apiDisabled) return;
this._apiDisabled = true
const openLink = () => shell.openExternal("https://help.salesforce.com/HTViewSolution?id=000005140")
Actions.openModal({
component: (
We can’t connect to your Salesforce environment
Your Salesforce environment does not have API access enabled. If you are using Group or Professional editions, you must either add API access or upgrade to Enterprise edition. If you are already using Enterprise or Ultimate editions please check your installation settings to ensure API access is turned on.
See this Salesforce support article for more information regarding API access.
),
height: 290,
width: 700,
})
SalesforceActions.logoutOfSalesforce()
}
_unableToAuth() {
SalesforceActions.logoutOfSalesforce()
const response = remote.dialog.showMessageBox({
message: 'Salesforce Connection Problem',
detail: `We could no longer access your Salesforce environment. Please reconnect Salesforce.`,
buttons: ['Connect Salesforce', 'Dismiss'],
type: 'warning',
});
if (response === 0) {
SalesforceActions.loginToSalesforce()
}
}
_refreshToken() {
return SalesforceOAuth.fetchNewToken()
.catch((apiError) => {
this._unableToAuth()
throw apiError
})
}
}
export default new SalesforceAPI()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-constants.es6
================================================
import plugin from '../package.json'
export const PLUGIN_NAME = plugin.title
export const PLUGIN_ID = plugin.name;
export const INFO_DOC_URL = "https://paper.dropbox.com/doc/N1-Salesforce-Alpha-Program-XVthfW6SeEei8RoKr9X1i"
export const CORE_RELATEABLE_OBJECT_TYPES = ["Opportunity", "Account", "Case"]
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-contact-crawler.es6
================================================
import _ from 'underscore'
import {
Contact,
DatabaseStore,
Utils,
} from 'nylas-exports'
import SalesforceEnv from './salesforce-env'
import SalesforceActions from './salesforce-actions'
import SalesforceObject from './models/salesforce-object'
// Check for new contacts once a day
const REFRESH_INTERVAL = 1000 * 60 * 60 * 24
const JSOB_BLOB_KEY = "SalesforceContactCrawler"
class SalesforceContactCrawler {
activate() {
this._unsubscribe = SalesforceActions.syncSalesforce.listen(this._run)
this._interval = setInterval(this._run, REFRESH_INTERVAL)
setTimeout(this._run, 3000)
this._sContacts = []
this._domains = []
this._suggestedContacts = []
}
deactivate() {
this._unsubscribe()
clearInterval(this._interval)
}
_run = () => {
if (!SalesforceEnv.isLoggedIn()) return
DatabaseStore.findAll(SalesforceObject, {
type: "Contact",
})
.then((sContacts) => {
this._sContacts = sContacts
for (const sContact of sContacts) {
this._addToDomains(sContact.identifier)
}
return Promise.resolve()
})
.then(() => {
return DatabaseStore.findAll(Contact)
})
.then((contacts) => {
for (const contact of contacts) {
this._addToSuggestedContacts(contact)
}
return Promise.resolve()
})
.then(() => {
DatabaseStore.inTransaction((t) => {
return t.persistJSONBlob(JSOB_BLOB_KEY, this._suggestedContacts)
})
})
}
_getDomain(email) {
return _.last(email.toLowerCase().trim().split("@"))
}
// Check if domain is probably a company (i.e., not Gmail) and unique
_addToDomains(email) {
const domain = this._getDomain(email)
if (domain.length > 0 &&
!Utils.emailHasCommonDomain(email) &&
!this._domains.includes(domain)) {
this._domains.push(domain)
}
}
// Check if contact is real person and is from same company as a Salesforce contact
_addToSuggestedContacts(contact) {
if (contact.email &&
!Utils.likelyNonHumanEmail(contact.email) &&
this._domains.includes(this._getDomain(contact.email))) {
this._suggestedContacts.push(contact)
}
}
}
export default new SalesforceContactCrawler()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-data-reset.es6
================================================
import {DatabaseStore} from 'nylas-exports'
import SalesforceObject from './models/salesforce-object'
import SalesforceSchema from './models/salesforce-schema'
import SalesforceActions from './salesforce-actions'
const SALESFORCE_DATA_VERSION = 1;
const CONFIG_KEY = "salesforceDataVersion"
class SalesforceDataReset {
activate() {
const version = NylasEnv.config.get(CONFIG_KEY);
if (version !== SALESFORCE_DATA_VERSION) {
this.deleteAllData().then(() => {
SalesforceActions.syncSalesforce();
NylasEnv.config.set(CONFIG_KEY, SALESFORCE_DATA_VERSION)
})
}
}
deactivate() {
return true
}
deleteAllData() {
return DatabaseStore.inTransaction((t) => {
return Promise.all([
t.removeAllOfClass(SalesforceObject),
t.removeAllOfClass(SalesforceSchema),
])
});
}
}
export default new SalesforceDataReset()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-env.es6
================================================
import NylasStore from 'nylas-store'
import {DatabaseStore} from 'nylas-exports'
import SalesforceAPI from './salesforce-api'
import SalesforceOAuth from './salesforce-oauth'
import SalesforceObject from './models/salesforce-object'
import SalesforceActions from './salesforce-actions'
import SalesforceDataReset from './salesforce-data-reset'
// const loggedInMenu = require('../menus/salesforce-logged-in.json');
// const loggedOutMenu = require('../menus/salesforce-logged-out.json');
/**
* The Salesforce environment. Requires config to be populated from an
* Oauth request with the following information:
*
* Note we store the access_token and refresh_token in the system keychain
*
* "salesforce": {
* "instance_url": "",
* "id": ""
* },
*
*/
class SalesforceEnv extends NylasStore {
constructor() {
super()
this._menuListeners = []
this._subs = []
this._lastIdentityUrl = this._getIdentityUrl()
}
activate() {
if (NylasEnv.isMainWindow()) {
SalesforceOAuth.activate()
this.listenTo(SalesforceActions.loginToSalesforce, this._login)
this.listenTo(SalesforceActions.logoutOfSalesforce, this._logout)
}
this._subs.push(NylasEnv.config.onDidChange('salesforce.id', this._onIdentityChange));
this._listenForLoginState()
this._onIdentityChange()
}
deactivate() {
for (const sub of this._subs) { sub.dispose() }
for (const sub of this._menuListeners) { sub.dispose() }
this.stopListeningToAll();
if (NylasEnv.isMainWindow()) {
SalesforceOAuth.deactivate()
}
}
isLoggedIn() {
return this._getIdentityUrl() && this._getIdentityUrl().length > 0
}
// menuForLoginState() {
// return this.isLoggedIn() ? loggedInMenu : loggedOutMenu
// }
_listenForLoginState() {
for (const sub of this._menuListeners) { sub.dispose() }
this._menuListeners = [
NylasEnv.commands.add(document.body, "salesforce:sync", () => SalesforceActions.syncSalesforce()),
]
if (this.isLoggedIn()) {
this._menuListeners.push(NylasEnv.commands.add(document.body, "salesforce:disconnect", this._logout));
} else {
this._menuListeners.push(NylasEnv.commands.add(document.body, "salesforce:connect", this._login));
}
}
instanceUrl() {
return NylasEnv.config.get("salesforce.instance_url")
}
loadIdentity() {
const idUrl = this._getIdentityUrl();
if (!idUrl) return Promise.resolve(null);
return DatabaseStore.findBy(SalesforceObject, {
identifier: idUrl,
}).then((identity) => {
if (!identity) {
return this._fetchIdentityFromAPI().then(this._saveIdentity)
}
return identity
})
}
_getIdentityUrl() {
return NylasEnv.config.get("salesforce.id")
}
_onIdentityChange = () => {
if (this._lastIdentityUrl !== this._getIdentityUrl()) {
this._lastIdentityUrl = this._getIdentityUrl()
this._listenForLoginState();
}
this.trigger()
}
_fetchIdentityFromAPI = () => {
const idUrl = this._getIdentityUrl();
if (!idUrl) return Promise.resolve(null);
return SalesforceAPI.makeRequest({
APIRoot: idUrl,
path: "/",
})
}
_saveIdentity = (identityJSON) => {
if (!identityJSON) {
SalesforceActions.reportError(new Error("Could not load Identity"), {
APIRoot: this._getIdentityUrl(),
});
return {};
}
const user = new SalesforceObject({
id: identityJSON.user_id,
type: "User",
name: identityJSON.display_name,
identifier: this._getIdentityUrl(),
object: "SalesforceObject",
rawData: identityJSON,
})
return DatabaseStore.inTransaction(t => t.persistModel(user))
.then(() => user)
}
_login = () => {
SalesforceOAuth.connect()
}
_logout = () => {
return SalesforceDataReset.deleteAllData()
.then(() => {
NylasEnv.config.set("salesforce", {});
SalesforceOAuth.clearTokens()
});
}
}
export default new SalesforceEnv()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-error-reporter.es6
================================================
import SalesforceEnv from './salesforce-env'
import SalesforceActions from './salesforce-actions'
class SalesforceErrorReporter {
activate() {
this._usub = SalesforceActions.reportError.listen(this._onError)
}
deactivate() {
this._usub();
}
_onError = (error, extraInfo = {}) => {
SalesforceEnv.loadIdentity().then((identity) => {
NylasEnv.reportError(error, Object.assign({}, extraInfo, {
identity: identity,
instanceUrl: SalesforceEnv.instanceUrl(),
}));
})
}
}
export default new SalesforceErrorReporter()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-intro-notification.jsx
================================================
import React from 'react'
import {shell} from 'electron'
import {Notification} from 'nylas-component-kit'
import SalesforceEnv from './salesforce-env'
import {INFO_DOC_URL} from './salesforce-constants'
import SalesforceActions from './salesforce-actions'
export default class SalesforceIntroNotification extends React.Component {
static displayName = "SalesforceIntroNotification"
static containerRequired = false
constructor(props) {
super(props)
this.state = {
isLoggedIn: SalesforceEnv.isLoggedIn(),
}
}
componentDidMount() {
this._unsub = SalesforceEnv.listen(this._onLoginStateChanged)
}
componentWillUnmount() {
this._unsub()
}
_onLoginStateChanged = () => {
this.setState({isLoggedIn: SalesforceEnv.isLoggedIn()})
}
render() {
let actions = [{
label: "Connect Salesforce",
fn: () => {
SalesforceActions.loginToSalesforce()
},
}]
if (this.state.isLoggedIn) {
actions = [{
label: "Learn More",
fn: () => {
shell.openExternal(INFO_DOC_URL)
},
}]
}
return (
)
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-metadata-cleanup-listener.es6
================================================
import {
Thread,
Message,
Actions,
DatabaseStore,
SyncbackMetadataTask,
} from 'nylas-exports'
import {PLUGIN_ID} from './salesforce-constants'
import SalesforceObject from './models/salesforce-object'
import * as mdHelpers from './metadata-helpers'
import SyncThreadActivityToSalesforceTask from './tasks/sync-thread-activity-to-salesforce-task'
import RemoveManualRelationToSalesforceObjectTask from './tasks/remove-manual-relation-to-salesforce-object-task'
/**
* When sObjects get deleted from the database, we need to cleanup their
* references in our metadata.
*
* If we've manually related sObjects and/or are trying to sync thread
* activity with them, we need to spawn tasks that clear these when
* sObjects get deleted.
*
*/
class SalesforceMetadataCleanupListener {
constructor() {
this._unsubscribers = []
}
activate() {
this._unsubscribers = [
DatabaseStore.listen(this._onDataChanged),
]
}
deactivate() {
this._unsubscribers.forEach((usub) => usub())
}
_onDataChanged = (change) => {
if (change.objectClass !== SalesforceObject.name) return;
if (change.type !== 'unpersist') {
this._onSObjectsDeleted(change.objects)
}
}
_onSObjectsDeleted = (deletedSObjects) => {
DatabaseStore.findAll(Thread)
.where(Thread.attributes.pluginMetadata.contains(PLUGIN_ID))
.then((threads) => {
for (const thread of threads) {
for (const deletedSObject of deletedSObjects) {
this._cleanupThread(thread, deletedSObject)
}
}
})
DatabaseStore.findAll(Message)
.where(Message.attributes.pluginMetadata.contains(PLUGIN_ID))
.then((messages) => {
for (const message of messages) {
for (const deletedSObject of deletedSObjects) {
this._cleanupMessage(message, deletedSObject)
}
}
})
}
// If we're syncing a thread with an sObject that just got deleted, stop
// syncing with that sObject.
_cleanupThread = (thread, deletedSObject) => {
// A thread might be manually related to the recently deleted sObject.
// Be sure to clean that up
if (mdHelpers.getManuallyRelatedObjects(thread)[deletedSObject.id]) {
const t = new RemoveManualRelationToSalesforceObjectTask({
sObjectId: deletedSObject.id,
sObjectType: deletedSObject.type,
nylasObjectId: thread.id,
nylasObjectType: thread.type,
})
Actions.queueTask(t)
} else if (mdHelpers.getSObjectsToSyncActivityTo(thread)[deletedSObject.id]) {
// Note, this is an ELSE if, because the
// RemoveManualRelationToSalesforceObjectTask automatically checks
// if we've enabled sync witht that thread. If so it'll do the same
// thing and mark the thread to stop syncing.
//
// It's common for us to be syncing with threads that were
// automatically related and have no records in our "manually
// related" objects.
const t = new SyncThreadActivityToSalesforceTask({
threadId: thread.id,
threadClientId: thread.clientId,
sObjectsToStopSyncing: [deletedSObject],
})
Actions.queueTask(t)
}
return Promise.resolve()
}
_cleanupMessage = (message, deletedSObject) => {
const relatedToId = mdHelpers.relatedIdForClonedSObject(message, deletedSObject)
if (relatedToId) {
const relatedSObject = {id: relatedToId}
const sObjectToRemove = {id: relatedToId}
mdHelpers.removeClonedSObject(message, relatedSObject, sObjectToRemove);
return DatabaseStore.inTransaction(t => t.persistModel(message))
.then(() => {
const t = new SyncbackMetadataTask(message.clientId, message.constructor.name, PLUGIN_ID);
Actions.queueTask(t);
})
}
return Promise.resolve()
}
}
export default new SalesforceMetadataCleanupListener()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-new-mail-listener.es6
================================================
import _ from 'underscore';
import NylasStore from 'nylas-store';
import { Thread, Actions, DatabaseStore } from 'nylas-exports';
import SalesforceEnv from './salesforce-env';
import SyncThreadActivityToSalesforceTask from './tasks/sync-thread-activity-to-salesforce-task';
import * as mdHelpers from './metadata-helpers'
class SalesforceNewMailListener extends NylasStore {
activate() {
this.listenTo(Actions.onNewMailDeltas, this._newMailReceived);
this.listenTo(Actions.draftDeliverySucceeded, this._onSendDraftSuccess);
}
deactivate() {
return this.stopListeningToAll();
}
_ensureThreadSynced = (thread) => {
if (!thread) return;
const ids = Object.keys(mdHelpers.getSObjectsToSyncActivityTo(thread));
if (ids.length === 0) return;
const task = new SyncThreadActivityToSalesforceTask({
threadId: thread.id, threadClientId: thread.clientId,
});
Actions.queueTask(task);
}
/**
* For replies, the thread will exist. For new messages, the thread will
* not exist, but will come in shortly via
* `didPassivelyReceivedNewModels`.
*/
_onSendDraftSuccess = ({message}) => {
return DatabaseStore.find(Thread, message.threadId)
.then(this._ensureThreadSynced)
}
_newMailReceived = (incoming) => {
if (!SalesforceEnv.isLoggedIn()) return;
if (!incoming.message || incoming.message.length <= 0) { return; }
const tids = _.pluck(incoming.message, "threadId");
const incomingThreads = incoming.thread || [];
Promise.map(tids, (tid) => {
const thread = _.findWhere(incomingThreads, {id: tid});
if (thread) return thread;
return DatabaseStore.find(Thread, tid)
}).each(this._ensureThreadSynced);
}
}
export default new SalesforceNewMailListener();
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-oauth.jsx
================================================
import {shell} from 'electron'
import crypto from 'crypto';
import {Actions, NylasAPIRequest, React, KeyManager} from 'nylas-exports'
const ACCESS_TOKEN_KEY_NAME = 'Nylas Salesforce Token';
const REFRESH_TOKEN_KEY_NAME = 'Nylas Salesforce Refresh Token';
class SalesforceOAuth {
constructor() {
this._onConfigChanged()
this._resetDelay()
this.MAX_POLLS = 100;
this._connectionAttempt = 0
}
activate() {
this._usub = NylasEnv.config.onDidChange('env', this._onConfigChanged)
}
deactivate() {
this._usub.dispose()
}
connect() {
if (NylasEnv.getLoadSettings().isSpec) { return Promise.resolve() }
this._connectionAttempt += 1
this._resetDelay()
Actions.recordUserEvent("Salesforce Connect Started")
shell.openExternal(`${this.APIRoot}/connect/salesforce?state=${this.state}`)
this._numPolls = 0;
return this._pollForToken(this._connectionAttempt)
}
fetchNewToken() {
const req = new NylasAPIRequest({
api: this,
options: {
path: `/salesforce/token/refresh`,
method: "POST",
body: {refresh_token: this.refreshToken()},
auth: {user: "", pass: "", sendImmediately: true},
},
});
return req.run().then((tokenData) => {
const configData = this._extractAndSetTokens(tokenData)
const oldConfig = NylasEnv.config.get("salesforce") || {}
NylasEnv.config.set("salesforce", Object.assign({}, oldConfig, configData))
Actions.recordUserEvent("Salesforce Token Refreshed", {
instanceUrl: configData.instance_url,
})
})
}
clearTokens() {
KeyManager.deletePassword(ACCESS_TOKEN_KEY_NAME);
KeyManager.deletePassword(REFRESH_TOKEN_KEY_NAME);
}
accessToken() {
return KeyManager.getPassword(ACCESS_TOKEN_KEY_NAME, {migrateFromService: "Nylas Salesforce"})
}
refreshToken() {
return KeyManager.getPassword(REFRESH_TOKEN_KEY_NAME, {migrateFromService: "Nylas Salesforce"})
}
_resetDelay() {
this.state = (new Buffer(crypto.randomBytes(40))).toString('base64');
this.delay = 1000;
if (this.currentTimeout) clearTimeout(this.currentTimeout);
}
_onConfigChanged = () => {
const env = NylasEnv.config.get('env')
if (['development', 'local'].includes(env)) {
this.APIRoot = "http://localhost:3000"
} else if (env === 'staging') {
this.APIRoot = "https://nylas-salesforce.herokuapp.com"
} else {
this.APIRoot = "https://nylas-salesforce.herokuapp.com"
}
}
_onConnection = (tokenData) => {
Actions.recordUserEvent("Salesforce Connected", {
instanceUrl: tokenData.instance_url,
})
const configData = this._extractAndSetTokens(tokenData)
NylasEnv.config.set("salesforce", configData)
NylasEnv.show()
Actions.openModal({
component: (
Success! Nylas Mail and Salesforce are now connected.
Select a message to create or edit contact and lead records or to sync the thread with an opportunity. Here’s how it works!
VIDEO
),
height: 520,
width: 700,
})
}
_extractAndSetTokens = (tokenData) => {
const clonedData = Object.assign({}, tokenData);
const accessToken = tokenData.access_token
const refreshToken = tokenData.refresh_token
delete clonedData.access_token
delete clonedData.refresh_token
if (accessToken) {
KeyManager.replacePassword(ACCESS_TOKEN_KEY_NAME, accessToken)
}
if (refreshToken) {
KeyManager.replacePassword(REFRESH_TOKEN_KEY_NAME, refreshToken)
}
return clonedData
}
_pollForToken(connectionAttempt) {
const req = new NylasAPIRequest({
api: this,
options: {
auth: {user: "", pass: "", sendImmediately: true},
path: `/salesforce/token?state=${this.state}`,
},
});
return req.run()
.then(this._onConnection)
.catch((apiError) => {
if (apiError.statusCode === 404) {
if (this._connectionAttempt === connectionAttempt) {
return this._tryAgain(() => this._pollForToken(connectionAttempt))
}
return Promise.resolve()
}
return Promise.reject(apiError)
})
}
_tryAgain(fn) {
return new Promise((resolve, reject) => {
if (this._numPolls > this.MAX_POLLS) {
reject()
return
}
this.currentTimeout = setTimeout(() => {
fn.call(this).then(resolve).catch(reject)
}, this.delay);
})
}
}
export default new SalesforceOAuth();
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-object-helpers.es6
================================================
import _ from 'underscore'
import moment from 'moment';
import querystring from "querystring";
import {DatabaseStore} from 'nylas-exports'
import SalesforceEnv from './salesforce-env';
import SalesforceAPI from './salesforce-api';
import SalesforceObject from './models/salesforce-object'
import SalesforceActions from './salesforce-actions'
import * as mdHelpers from './metadata-helpers'
function _defaultFieldMapping() {
return {
Id: "id",
Name: "name",
LastModifiedDate: "updatedAt",
};
}
function _fieldMapping() {
return {
User: _defaultFieldMapping(),
Account: _defaultFieldMapping(),
Opportunity: {
Id: "id",
Name: "name",
AccountId: "relatedToId",
LastModifiedDate: "updatedAt",
},
Contact: {
Id: "id",
Name: "name",
Email: "identifier",
AccountId: "relatedToId",
LastModifiedDate: "updatedAt",
},
Lead: {
Id: "id",
Name: "name",
Email: "identifier",
LastModifiedDate: "updatedAt",
},
Case: {
Id: "id",
Subject: "name",
CaseNumber: "identifier",
AccountId: "relatedToId",
LastModifiedDate: "updatedAt",
},
EmailMessage: {
Id: "id",
Subject: "identifier",
LastModifiedDate: "updatedAt",
},
OpportunityContactRole: {
Id: "id",
ContactId: "identifier",
OpportunityId: "relatedToId",
LastModifiedDate: "updatedAt",
},
};
}
function _rawSalesforceDataAdapter(rawData, objectType) {
if (!objectType) {
console.error(rawData);
throw new Error("Requested Salesforce object does not have a objectType");
}
let fieldMapping = _fieldMapping()[objectType];
if (!fieldMapping) { fieldMapping = _defaultFieldMapping(); }
let attrs = {};
if (_.isFunction(fieldMapping)) {
attrs = fieldMapping(rawData)
} else {
for (const sfKey of Object.keys(fieldMapping)) {
const nyKey = fieldMapping[sfKey];
let val;
if (nyKey === "updatedAt") {
val = moment(rawData[sfKey]).toDate();
} else if (nyKey === "identifier") {
if (sfKey === "Email") {
val = (rawData[sfKey] || "").toLowerCase().trim();
} else {
val = rawData[sfKey];
}
} else {
val = rawData[sfKey];
}
attrs[nyKey] = val;
}
}
const obj = new SalesforceObject(Object.assign(attrs, {
type: objectType,
object: "SalesforceObject",
}))
return obj;
}
export function newBasicObjectsQuery(objectType, where = "", fields = []) {
let fieldsStr = ""
if (fields.length === 0) {
let fieldMapping = _fieldMapping()[objectType];
if (!fieldMapping) {
fieldMapping = _defaultFieldMapping();
}
fieldsStr = Object.keys(fieldMapping).join(',');
} else {
fieldsStr = fields.join(',')
}
return querystring.stringify({q: `SELECT ${fieldsStr} FROM ${objectType} WHERE ${where}`});
}
export function loadBasicObjectsByField({objectType, where = {}, fields = []}) {
if (!SalesforceEnv.isLoggedIn()) { return Promise.resolve(); }
const wheres = []
for (const field of Object.keys(where)) {
wheres.push(`${field} = '${where[field]}'`)
}
const whereStr = wheres.join(" AND ");
const query = newBasicObjectsQuery(objectType, whereStr, fields);
return SalesforceAPI.makeRequest({path: `/query/?${query}`});
}
export function requestFullObjectFromAPI({objectType, objectId}) {
return SalesforceAPI.makeRequest({
path: `/sobjects/${objectType}/${objectId}`})
.then((rawFullData) => {
const obj = _rawSalesforceDataAdapter(rawFullData, objectType);
// Note: The presence of rawData being filled is what makes this a
// "full" object instead of a "basic" object.
obj.rawData = rawFullData;
return DatabaseStore.inTransaction(t => t.persistModel(obj).then(() => obj));
})
.catch((apiError = {}) => {
if (apiError.statusCode !== 404) {
// We don't re-throw since we've already reported the error and
// don't want to take down the app at this point.
SalesforceActions.reportError(apiError, {objectType, objectId})
return null
}
return null
});
}
// Attempts to fetch the given object from the Database. If the `rawData`
// field isn't populated or if the object doesn't exist, then we grab it
// from Salesforce
export function loadFullObject({objectType, objectId}) {
return DatabaseStore.findBy(SalesforceObject, {id: objectId, type: objectType})
.then((object = {}) => {
if (object && object.rawData && _.size(object.rawData) > 0) { return object; }
return requestFullObjectFromAPI({objectType, objectId});
});
}
export function loadManuallyRelatedObjects(nylasObject) {
const sObjects = _.values(mdHelpers.getManuallyRelatedObjects(nylasObject));
return Promise.map(sObjects, (sObject) => {
return loadFullObject({objectType: sObject.type, objectId: sObject.id});
});
}
// Supports an array of objectTypes. This is useful when trying to look
// up an object that may be a reference to multiple things
export function loadBasicObject(objectTypes, objectId) {
let types = objectTypes;
if (_.isString(types)) types = [objectTypes];
return DatabaseStore.findBy(SalesforceObject, {id: objectId, type: types})
.then(object => {
if (object) { return object; }
return Promise.map(types, (type) => {
return requestFullObjectFromAPI({objectType: type, objectId});
}).then((objects = []) => {
// There's only 1 ID, but we're searching across multiple object
// types. There should only be 1 value returned.
return _.compact(objects)[0]
})
});
}
export function upsertBasicObjects(data = {}) {
const records = data.records || []
if (records.length === 0) { return Promise.resolve([]); }
try {
const models = records.map((rawBasicData) => {
const objectType = rawBasicData.attributes.type;
return _rawSalesforceDataAdapter(rawBasicData, objectType)
});
if (models.length === 0) return Promise.resolve([]);
return DatabaseStore.inTransaction(t => t.persistModels(models))
.thenReturn(models)
} catch (err) {
SalesforceActions.reportError(err, {rawApiData: data})
return Promise.reject(err)
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-related-object-cache.es6
================================================
import _ from 'underscore'
import { Rx, AccountStore, DatabaseStore } from 'nylas-exports'
import SalesforceObject from './models/salesforce-object'
class SalesforceRelatedObjectCache {
constructor() {
this._unsubscribers = []
this._observers = {}
this._sObjectsByEmail = new Map();
this._changes = []
this._updateCache = _.debounce(this._updateCache, 100);
}
activate() {
this._initializeCache()
this._unsubscribers = [
DatabaseStore.listen(this._onDataChanged),
]
}
directlyRelatedSObjectsForThread(thread) {
const objs = {}
const myEmails = AccountStore.emailAddresses().map((em) => em.toLowerCase())
for (const participant of thread.participants) {
if (myEmails.includes(participant.email.toLowerCase())) continue;
Object.assign(objs, this.directlyRelatedSObjectsByEmail(participant.email))
}
return objs;
}
directlyRelatedSObjectsByEmail(rawEmail) {
const email = rawEmail.trim().toLowerCase()
return this._sObjectsByEmail.get(email) || {}
}
observeDirectlyRelatedSObjectsByEmail(email) {
return Rx.Observable.create((observer) => {
if (!this._observers[email]) this._observers[email] = [];
this._observers[email].push({
observer: observer,
observerType: "email",
})
observer.onNext(this.directlyRelatedSObjectsByEmail(email))
return Rx.Disposable.create(() => {
if (!this._observers[email]) return;
this._observers[email] = this._observers[email].filter(obs => obs.observer !== observer)
if (this._observers[email].length === 0) {
delete this._observers[email]
}
})
})
}
observeDirectlyRelatedSObjectsForThread(thread) {
return Rx.Observable.create((observer) => {
const myEmails = AccountStore.emailAddresses().map((em) => em.toLowerCase())
observer.alsoFireFor = thread.participants
for (const participant of thread.participants) {
if (myEmails.includes(participant.email.toLowerCase())) continue;
if (!this._observers[participant.email]) {
this._observers[participant.email] = []
}
this._observers[participant.email].push({
observer: observer,
observerType: "thread",
thread: thread,
})
}
observer.onNext(this.directlyRelatedSObjectsForThread(thread))
return Rx.Disposable.create(() => {
for (const participant of thread.participants) {
if (!this._observers[participant.email]) {
continue
}
this._observers[participant.email] = this._observers[participant.email].filter(obs => obs.observer !== observer)
if (this._observers[participant.email].length === 0) {
delete this._observers[participant.email]
}
}
})
})
}
_initializeCache() {
return DatabaseStore.findAll(SalesforceObject, {type: "Contact"})
.then(this._updateContacts)
}
_onDataChanged = (change) => {
if (change.objectClass !== SalesforceObject.name) return
this._changes = this._changes.concat(change);
this._updateCache()
}
_getBasicObject = (sObject) => {
const objForCache = Object.assign({}, sObject);
delete objForCache.rawData;
objForCache.id = sObject.id;
return objForCache
}
_changeObjectInCache = (sObject, rawEmail, changeType) => {
if (!rawEmail || !sObject) return
const email = rawEmail.trim().toLowerCase()
let relatedObjects = this._sObjectsByEmail.get(email)
const objectToUpdate = this._getBasicObject(sObject)
if (!relatedObjects) relatedObjects = {}
if (changeType === "unpersist") {
delete relatedObjects[sObject.id]
} else {
relatedObjects[sObject.id] = objectToUpdate
}
this._sObjectsByEmail.set(email, relatedObjects)
if (this._observers[email]) {
for (const {observer, observerType, thread} of this._observers[email]) {
if (observerType === "email") {
observer.onNext(this.directlyRelatedSObjectsByEmail(email))
} else if (observerType === "thread") {
observer.onNext(this.directlyRelatedSObjectsForThread(thread))
}
}
}
}
// Contact: Find accounts and opportunities using email from contacts
// and add to cache. This is optimized for many contacts at once since
// we rebuild the cache on every launch.
_updateContacts = (contacts = [], changeType) => {
if (contacts.length === 0) return Promise.resolve()
const contactIds = _.compact(_.pluck(contacts, "id"))
const relatedToIds = _.compact(_.pluck(contacts, "relatedToId"))
const objectsToUpdate = {}
for (const contact of contacts) {
objectsToUpdate[contact.identifier] = [contact]
}
if (relatedToIds.length === 0) {
for (const email of Object.keys(objectsToUpdate)) {
for (const sObject of objectsToUpdate[email]) {
this._changeObjectInCache(sObject, email, changeType)
}
}
return Promise.resolve()
}
return DatabaseStore.findAll(SalesforceObject, {
type: "Account",
id: relatedToIds,
})
.then((accounts) => {
const accById = _.groupBy(accounts, "id");
for (const contact of contacts) {
const account = (accById[contact.relatedToId] || [])[0]
if (!account) continue;
objectsToUpdate[contact.identifier].push(account)
}
return Promise.resolve()
})
.then(() => {
if (contactIds.length === 0) return {opportunityContactRoles: []}
return DatabaseStore.findAll(SalesforceObject, {
type: "OpportunityContactRole",
identifier: contactIds,
})
})
.then((opportunityContactRoles = []) => {
const oppIds = _.compact(_.pluck(opportunityContactRoles, "relatedToId"));
if (oppIds.length === 0) return {opportunities: [], opportunityContactRoles}
return DatabaseStore.findAll(SalesforceObject, {
type: "Opportunity",
id: oppIds,
}).then((opportunities = []) => {
return {opportunities, opportunityContactRoles}
})
})
.then(({opportunities, opportunityContactRoles}) => {
const roleByCid = _.groupBy(opportunityContactRoles, "identifier");
const oppById = _.groupBy(opportunities, "id")
for (const contact of contacts) {
const role = (roleByCid[contact.id] || [])[0]
if (!role) continue;
const opp = (oppById[role.relatedToId] || [])[0];
if (!opp) continue;
objectsToUpdate[contact.identifier].push(opp)
}
})
.then(() => {
for (const email of Object.keys(objectsToUpdate)) {
for (const sObject of objectsToUpdate[email]) {
this._changeObjectInCache(sObject, email, changeType)
}
}
})
}
// Account: Add accounts to cache using email from contact
_updateAccounts = (accounts = [], changeType) => {
if (accounts.length === 0) return Promise.resolve();
const aids = _.pluck(accounts, "id");
return DatabaseStore.findAll(SalesforceObject, {
type: "Contact",
relatedToId: aids,
}).then((contacts) => {
const accById = _.groupBy(accounts, "id");
for (const contact of contacts) {
const account = (accById[contact.relatedToId] || [])[0]
this._changeObjectInCache(account, contact.identifier, changeType)
}
})
}
// Opportunity: Add opportunities to cache using email from contact
_updateOpportunities = (opportunities = [], changeType) => {
if (opportunities.length === 0) return Promise.resolve();
const oids = _.pluck(opportunities, "id");
return DatabaseStore.findAll(SalesforceObject, {
type: "OpportunityContactRole",
relatedToId: oids,
})
.then((opportunityContactRoles) => {
const contactIds = _.pluck(opportunityContactRoles, "identifier");
const roleByCid = _.groupBy(opportunityContactRoles, "identifier");
const oppById = _.groupBy(opportunities, "id");
return DatabaseStore.findAll(SalesforceObject, {
type: "Contact",
id: contactIds,
}).then((contacts) => {
for (const contact of contacts) {
const role = (roleByCid[contact.id] || [])[0]
if (!role) continue;
const opp = (oppById[role.relatedToId] || [])[0];
if (!opp) continue;
this._changeObjectInCache(opp, contact.identifier, changeType)
}
})
})
}
// OpportunityContactRole: Add opportunities to cache using email from contact
_updateOpportunityContactRoles = (opportunityContactRoles = [], changeType) => {
if (opportunityContactRoles.length === 0) return Promise.resolve();
const cids = _.pluck(opportunityContactRoles, "identifier");
const roleByCid = _.groupBy(opportunityContactRoles, "identifier");
const oppIds = _.pluck(opportunityContactRoles, "relatedToId")
return DatabaseStore.findAll(SalesforceObject, {
type: "Contact",
id: cids,
})
.then((contacts) => {
return DatabaseStore.findAll(SalesforceObject, {
type: "Opportunity",
id: oppIds,
})
.then((opportunities) => {
const oppById = _.groupBy(opportunities, "id");
for (const contact of contacts) {
const role = (roleByCid[contact.id] || [])[0]
if (!role) continue;
const opp = (oppById[role.relatedToId] || [])[0];
if (!opp) continue;
this._changeObjectInCache(opp, contact.identifier, changeType)
}
})
})
}
_updateLeads = (leads = [], changeType) => {
if (leads.length === 0) return Promise.resolve()
for (const lead of leads) {
this._changeObjectInCache(lead, lead.identifier, changeType)
}
return Promise.resolve();
}
/*
The cache is keyed by email and the values represent the related Salesforce
objects (opportunities and accounts).
This method id debounced and loads the latest from _changes.
this._sObjectsByEmail = {
"jackie@nylas.com": {
"ACCOUNT_ID": {
name: "",
type: "",
identifier: "",
relatedToId: "",
},
"OPPORTUNITY_ID": {
...
}
}
}
*/
_updateCache = () => {
const changes = this._changes;
this._changes = [];
const changeByType = _.groupBy(changes, "type");
for (const changeType of Object.keys(changeByType)) {
const sObjects = _.flatten(changeByType[changeType].map(c => c.objects));
if (sObjects.length === 0) continue;
const objsByType = _.groupBy(sObjects, "type");
Promise.all([
this._updateLeads(objsByType.Lead, changeType),
this._updateContacts(objsByType.Contact, changeType),
this._updateAccounts(objsByType.Account, changeType),
this._updateOpportunities(objsByType.Opportunity, changeType),
this._updateOpportunityContactRoles(objsByType.OpportunityContactRole, changeType),
])
}
}
deactivate() {
this._unsubscribers.forEach((usub) => usub())
}
}
export default new SalesforceRelatedObjectCache()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/salesforce-sync-worker.es6
================================================
import {DatabaseStore, Actions} from 'nylas-exports'
import SalesforceEnv from './salesforce-env'
import SalesforceActions from './salesforce-actions'
import SalesforceObject from './models/salesforce-object'
import SalesforceDataReset from './salesforce-data-reset'
import SyncSalesforceObjectsTask from './tasks/sync-salesforce-objects-task'
// How often we poll Salesforce and pull down all new objects (in sec)
const REFRESH_INTERVAL = 1000 * 60 * 10; // (10 minutes)
// The list of objects we optimistically pull down a full set of
const ObjectTypes = [
"User",
"Case",
"Contact",
"Account",
"Opportunity",
"OpportunityContactRole",
]
function getMostRecentUpdateTime(objectType) {
return DatabaseStore.findBy(SalesforceObject, {type: objectType})
.order(SalesforceObject.attributes.updatedAt.descending())
.limit(1)
.then((obj = {}) => obj.updatedAt);
}
class SalesforceSyncWorker {
activate() {
this._disposables = [
NylasEnv.config.onDidChange('salesforce.id', this._resetLocalData),
]
this._unsubscribers = [
SalesforceActions.syncSalesforce.listen(this._run),
SalesforceActions.logoutOfSalesforce.listen(this._onLogout),
]
this._interval = setInterval(this._run, REFRESH_INTERVAL);
// Give the app time to bootup before queuing these resource-intensive
// tasks.
setTimeout(this._run, 3000)
}
deactivate() {
this._disposables.forEach((disp) => disp.dispose())
this._unsubscribers.forEach((usub) => usub())
clearInterval(this._interval)
}
_run = () => {
if (!SalesforceEnv.isLoggedIn()) { return; }
ObjectTypes.forEach((objectType) => {
getMostRecentUpdateTime(objectType)
.then((lastUpdateTime = 0) => {
const task = new SyncSalesforceObjectsTask({objectType, lastUpdateTime})
Actions.queueTask(task)
})
})
}
_onLogout = () => {
clearInterval(this._interval)
}
_resetLocalData = () => {
if (!SalesforceEnv.isLoggedIn()) { return Promise.resolve(); }
clearInterval(this._interval)
this._interval = setInterval(this._run, REFRESH_INTERVAL);
return SalesforceDataReset.deleteAllData().then(this._run);
}
}
export default new SalesforceSyncWorker()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/search/salesforce-search-bar-results.jsx
================================================
import _ from 'underscore'
import React from 'react'
import {Rx, Thread, DatabaseStore} from 'nylas-exports'
import SalesforceIcon from '../shared-components/salesforce-icon'
import SalesforceObject from '../models/salesforce-object'
import {relatedSObjectsForThread} from '../related-object-helpers'
function SearchBarResult(sObject) {
return (
{sObject.name}
)
}
function _searchObjects(name) {
return DatabaseStore.findAll(SalesforceObject).search(name)
}
let idleCallback = -1;
function _forAllPages(fn, offset = 0) {
const SERACH_SIZE = 100;
return DatabaseStore.findAll(Thread)
.limit(SERACH_SIZE)
.offset(offset)
.order(Thread.attributes.lastMessageReceivedTimestamp.descending())
.then((threads) => {
if (!fn(threads)) return;
window.cancelIdleCallback(idleCallback)
idleCallback = window.requestIdleCallback(() => {
_forAllPages(fn, offset + SERACH_SIZE);
})
})
}
export default class SalesforceSearchBarResults extends React.Component {
static displayName = "SalesforceSearchBarResults";
static searchLabel() { return "Salesforce Objects" }
static fetchSearchSuggestions(searchQuery) {
return Promise.map(_searchObjects(searchQuery), (sObject) => {
return {
customElement: SearchBarResult(sObject),
label: sObject.id,
value: sObject.name,
}
})
}
static observeThreadIdsForQuery(searchQuery) {
let cancelPagination = false;
return Rx.Observable.create((observer) => {
_searchObjects(searchQuery)
.then((sObjects => {
if (sObjects.length === 0) {
observer.onCompleted();
return;
}
const ids = new Set(sObjects.map(o => o.id))
_forAllPages((threads) => {
if (cancelPagination) return false;
if (threads.length === 0) return false;
const threadIds = threads.filter((thread) => {
return _.any(relatedSObjectsForThread(thread),
(sObject) => ids.has(sObject.id))
}).map(t => t.id);
observer.onNext(threadIds);
return true;
})
}))
return Rx.Disposable.create(() => {
window.cancelIdleCallback(idleCallback)
cancelPagination = true;
})
})
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/search/salesforce-search-indexer.es6
================================================
import { ModelSearchIndexer } from 'nylas-exports';
import SalesforceObject from '../models/salesforce-object'
class SalesforceSearchIndexer extends ModelSearchIndexer {
get MaxIndexSize() {
return 10000
}
get ConfigKey() {
return "salesforce.searchIndexVersion"
}
get IndexVersion() {
return 1
}
get ModelClass() {
return SalesforceObject
}
getIndexDataForModel(sObject) {
return {
content: [
sObject.name ? sObject.name : '',
sObject.identifier ? sObject.identifier : '',
sObject.identifier ? sObject.identifier.replace('@', ' ') : '',
].join(' '),
};
}
}
export default new SalesforceSearchIndexer()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/shared-components/open-in-salesforce-btn.jsx
================================================
import React from 'react'
import {shell} from 'electron'
import {RetinaImg} from 'nylas-component-kit'
import SalesforceEnv from '../salesforce-env'
export default function OpenInSalesforceBtn({objectId, size = "small"}) {
const openLink = (event) => {
event.stopPropagation()
event.preventDefault()
shell.openExternal(`${SalesforceEnv.instanceUrl()}/${objectId}`)
}
return (
)
}
OpenInSalesforceBtn.propTypes = {
objectId: React.PropTypes.string,
size: React.PropTypes.string,
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/shared-components/salesforce-icon.jsx
================================================
import React from 'react'
import {RetinaImg} from 'nylas-component-kit'
export default function SalesforceIcon(props = {}) {
const DEFAULT_COLOR = "#8199af"
const {objectType, className, onClick} = props
// See https://www.lightningdesignsystem.com/icons/
const type = objectType.toLowerCase();
const colorMap = {
"lead": "#f88962",
"task": "#4bc076",
"case": "#f2cf5b",
"account": "#7f8de1",
"contact": "#a094ed",
"pending": DEFAULT_COLOR,
"opportunity": "#fcb95b",
"lead_convert": "#f88962",
"emailmessage": "#95aec5",
}
const clickFn = onClick || (() => {});
const color = props.pending ? DEFAULT_COLOR : (colorMap[type] || DEFAULT_COLOR);
return (
)
}
SalesforceIcon.propTypes = {
title: React.PropTypes.string,
pending: React.PropTypes.bool,
onClick: React.PropTypes.func,
className: React.PropTypes.string,
objectType: React.PropTypes.string.isRequired,
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/shared-components/salesforce-login-prompt.jsx
================================================
import React from 'react'
import {shell} from 'electron'
import {RetinaImg} from 'nylas-component-kit'
import {INFO_DOC_URL} from '../salesforce-constants'
import SalesforceActions from '../salesforce-actions'
class SalesforceLoginPrompt extends React.Component {
static displayName = "SalesforceLoginPrompt"
_connectSalesforce() {
SalesforceActions.loginToSalesforce()
}
render() {
const onClick = () => shell.openExternal(INFO_DOC_URL)
return (
)
}
}
export default SalesforceLoginPrompt
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/tasks/destroy-message-on-salesforce-task.es6
================================================
import _ from 'underscore'
import {
Task,
Utils,
Message,
Actions,
DatabaseStore,
SyncbackMetadataTask,
} from 'nylas-exports'
import {PLUGIN_ID} from '../salesforce-constants'
import SalesforceAPI from '../salesforce-api'
import * as mdHelpers from '../metadata-helpers'
export default class DestroyMessageOnSalesforceTask extends Task {
constructor({messageId, sObjectId} = {}) {
super()
this.messageId = messageId;
this.sObjectId = sObjectId;
this.isCanceled = false;
}
isSameAndOlderTask(other) {
return other instanceof DestroyMessageOnSalesforceTask &&
other.messageId === this.messageId &&
other.sequentialId < this.sequentialId;
}
isComplementTask(other) {
return other.constructor.name === "EnsureMessageOnSalesforceTask" &&
other.messageId === this.messageId &&
other.sequentialId < this.sequentialId;
}
shouldDequeueOtherTask(other) {
return this.isSameAndOlderTask(other) || this.isComplementTask(other);
}
isDependentOnTask(other) {
return this.isSameAndOlderTask(other) || this.isComplementTask(other);
}
performLocal() {
return DatabaseStore.find(Message, this.messageId)
.then(this._markPendingStatus)
}
performRemote() {
return DatabaseStore.find(Message, this.messageId)
.then(this._deleteClonedSObjects)
.thenReturn(Task.Status.Success)
}
cancel() {
this.isCanceled = true;
}
_deleteClonedSObjects = (message) => {
if (this.isCanceled) return Promise.resolve();
const clonedAs = _.values(mdHelpers.getClonedAsForSObject(message, {
id: this.sObjectId}));
if (clonedAs.length === 0) return Promise.resolve();
return Promise.each(clonedAs, (clonedSObject) => {
if (this.isCanceled) return Promise.resolve();
return SalesforceAPI.makeRequest({
method: "DELETE",
path: `/sobjects/${clonedSObject.type}/${clonedSObject.id}`,
})
.catch((apiError) => {
if (apiError.errorCode === "ENTITY_IS_DELETED") {
return Promise.resolve(); // go ahead and remove from metadata
}
throw apiError
})
.then(() => {
mdHelpers.removeClonedSObject(message,
{id: this.sObjectId}, {id: clonedSObject.id})
})
}).finally(() => { this._syncbackMetadata(message) })
}
_syncbackMetadata = (message) => {
const metadata = Utils.deepClone(message.metadataForPluginId(PLUGIN_ID));
metadata.pendingSync = false;
message.applyPluginMetadata(PLUGIN_ID, metadata);
return DatabaseStore.inTransaction(t => t.persistModel(message))
.then(() => {
const task = new SyncbackMetadataTask(message.clientId, "Message", PLUGIN_ID);
Actions.queueTask(task);
})
}
_markPendingStatus = (message) => {
const metadata = Utils.deepClone(message.metadataForPluginId(PLUGIN_ID) || {});
metadata.pendingSync = true
message.applyPluginMetadata(PLUGIN_ID, metadata);
return DatabaseStore.inTransaction(t => t.persistModel(message))
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/tasks/destroy-salesforce-object-task.es6
================================================
import { Task, DatabaseStore } from 'nylas-exports'
import SalesforceAPI from '../salesforce-api'
import SalesforceObject from '../models/salesforce-object'
/**
* Attempts to delete a Salesforce Object remotely and then locally from
* N1.
*
* Note that when sObjects get deleted from our Database, the
* SalesforceRelatedObjectCache listens for those changes and queues the
* appropriate cleanup tasks.
*
* For example, if we delete an Opportunity, we want to cleanup any manual
* relations we've setup and stop trying to sync emails to it.
*
* If we delete a Salesforce Task, we want to remove that Task from the
* corresponding paired Message (if any).
*/
export default class DestroySalesforceObjectTask extends Task {
constructor(args = {}) {
super();
this.args = args;
this.sObjectId = args.sObjectId
this.sObjectType = args.sObjectType
}
isSameAndOlderTask(other) {
return other instanceof DestroySalesforceObjectTask &&
other.sObjectId === this.sObjectId &&
other.sObjectType === this.sObjectType &&
other.sequentialId < this.sequentialId;
}
shouldDequeueOtherTask(other) {
return this.isSameAndOlderTask(other)
}
isDependentOnTask(other) {
return this.isSameAndOlderTask(other)
}
performLocal() {
return Promise.resolve()
}
performRemote() {
return SalesforceAPI.makeRequest({
method: "DELETE",
path: `/sobjects/${this.sObjectType}/${this.sObjectId}`,
})
.then(this._removeLocally)
.catch((err = {}) => {
if (err.statusCode === 404) return this._removeLocally()
throw err
})
.then(() => Task.Status.Success);
}
_removeLocally = () => {
return DatabaseStore.findBy(SalesforceObject,
{id: this.sObjectId, type: this.sObjectType})
.then((obj) => (
DatabaseStore.inTransaction(t => t.unpersistModel(obj))
))
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/tasks/ensure-message-on-salesforce-task.es6
================================================
import _ from 'underscore'
import {
Task,
Utils,
Message,
Actions,
DatabaseStore,
SyncbackMetadataTask,
} from 'nylas-exports'
import moment from 'moment'
import {PLUGIN_ID} from '../salesforce-constants'
import SalesforceAPI from '../salesforce-api'
import * as mdHelpers from '../metadata-helpers'
import SalesforceObject from '../models/salesforce-object'
import SalesforceActions from '../salesforce-actions'
export default class EnsureMessageOnSalesforceTask extends Task {
constructor({messageId, sObjectId, sObjectType} = {}) {
super()
this.messageId = messageId;
this.sObjectId = sObjectId;
this.sObjectType = sObjectType;
this.isCanceled = false;
}
isSameAndOlderTask(other) {
return other instanceof EnsureMessageOnSalesforceTask &&
other.messageId === this.messageId &&
other.sequentialId < this.sequentialId;
}
isComplementTask(other) {
return other.constructor.name === "DestroyMessageOnSalesforceTask" &&
other.messageId === this.messageId &&
other.sequentialId < this.sequentialId;
}
shouldDequeueOtherTask(other) {
return this.isSameAndOlderTask(other) || this.isComplementTask(other);
}
isDependentOnTask(other) {
return this.isSameAndOlderTask(other) || this.isComplementTask(other);
}
performLocal() {
return DatabaseStore.find(Message, this.messageId)
.then(this._markPendingStatus)
}
performRemote() {
return this._checkIfFullySynced()
.then((fullySynced) => {
if (fullySynced) {
return DatabaseStore.find(Message, this.messageId)
.then(this._unmarkPendingStatus)
.then(() => Task.Status.Success)
}
if (this.isCanceled) return Promise.resolve(Task.Status.Success);
return DatabaseStore.find(Message, this.messageId)
.include(Message.attributes.body)
.then(this._prepareMessageBody)
.then(this._createNewActivityObjects)
.thenReturn(Task.Status.Success)
})
}
cancel() {
this.isCanceled = true;
}
_markPendingStatus = (message) => {
const metadata = Utils.deepClone(message.metadataForPluginId(PLUGIN_ID) || {});
metadata.pendingSync = true
message.applyPluginMetadata(PLUGIN_ID, metadata);
return DatabaseStore.inTransaction(t => t.persistModel(message))
}
_unmarkPendingStatus = (message) => {
const metadata = Utils.deepClone(message.metadataForPluginId(PLUGIN_ID) || {});
metadata.pendingSync = false
message.applyPluginMetadata(PLUGIN_ID, metadata);
return DatabaseStore.inTransaction(t => t.persistModel(message))
}
// We do an initial check here to see if we need to fully load the
// message's body and go through the effort of creating objects.
_checkIfFullySynced() {
return DatabaseStore.find(Message, this.messageId).then((message) => {
return this._typesToClone(message).length === 0
})
}
_typesToClone(message) {
const clonedAs = _.values(mdHelpers.getClonedAsForSObject(message, {
id: this.sObjectId}));
return _.difference(["Task", "EmailMessage"], _.pluck(clonedAs, "type"))
}
_prepareMessageBody = (message) => {
if (this.isCanceled) return Promise.resolve();
const mDom = message.computeDOMWithoutQuotes();
const bodies = {
plainTextUnquoted: message.cleanPlainTextBody(mDom.body.innerText),
htmlUnquoted: mDom.body.innerHTML,
}
return Promise.resolve({message, bodies})
}
_createNewActivityObjects = ({message, bodies}) => {
if (this.isCanceled) return Promise.resolve();
const clonedAs = mdHelpers.getClonedAsForSObject(message, {id: this.sObjectId});
const clonedAsTypes = _.pluck(_.values(clonedAs), "type")
return Promise.resolve()
.then(() => {
if (clonedAsTypes.includes("EmailMessage")) { return Promise.resolve() }
return this._newEmailMessage({message, bodies})
.then((sfCreatedObj = {}) => {
mdHelpers.addClonedSObject(message, {id: this.sObjectId}, {
id: sfCreatedObj.id,
type: "EmailMessage",
relatedToId: this.sObjectId,
})
return sfCreatedObj.id
})
})
.catch((err) => {
// If we can't create the EmailMessage object, attempt to
// manually create a Task instead.
//
// This happens fairly frequently because the EmailMessage
// object type has fairly strict limits on the size of the HTML
// body you can upload to Salesforce.
//
// We store the error if it's permanent so we don't keep retrying
// the same error
if (clonedAsTypes.includes("Task")) { return Promise.resolve() }
return this._newTask({message, bodies})
.then((sfCreatedObj) => {
mdHelpers.addClonedSObject(message, {id: this.sObjectId}, {
id: sfCreatedObj.id,
type: "Task",
relatedToId: this.sObjectId,
})
}).then(() => {
// Be sure to re-throw the error
throw err
})
})
.then((newEmailMessageId) => {
if (clonedAsTypes.includes("Task")) { return Promise.resolve() }
// Once an EmailMessage object is created, it'll automatically
// also create a corresponding Task object.
return SalesforceAPI.makeRequest({
path: `/sobjects/EmailMessage/${newEmailMessageId}`,
})
})
.then((rawEmailMessage) => {
if (clonedAsTypes.includes("Task")) { return Promise.resolve() }
// Load the raw Task.
return SalesforceAPI.makeRequest({
path: `/sobjects/Task/${rawEmailMessage.ActivityId}`,
})
})
.then((rawTask) => {
if (clonedAsTypes.includes("Task")) { return Promise.resolve() }
return this._updateTask({rawTask, message, bodies})
.then(() => {
mdHelpers.addClonedSObject(message, {id: this.sObjectId}, {
id: rawTask.Id,
type: "Task",
relatedToId: this.sObjectId,
})
})
})
.finally(() => { this._syncbackMetadata(message) })
}
_syncbackMetadata = (message) => {
return this._unmarkPendingStatus(message).then(() => {
const task = new SyncbackMetadataTask(message.clientId, "Message", PLUGIN_ID);
Actions.queueTask(task);
})
}
_updateTask({rawTask, message}) {
return DatabaseStore.findBy(SalesforceObject,
{type: "Contact", identifier: message.fromContact().email})
.then((contact) => {
const updates = {
Status: "Completed",
}
if (contact) { updates.WhoId = contact.id }
return SalesforceAPI.makeRequest({
method: "PATCH",
path: `/sobjects/Task/${rawTask.Id}`,
body: updates,
}).catch((apiError) => {
// These shouldn't fail. If they do we need to look on Sentry.
// Unfortunately there's no user feedback yet, so report and
// re-throw so the task fails.
SalesforceActions.reportError(apiError, {rawPostData: updates})
throw apiError
});
})
}
// https://na16.salesforce.com/services/data/v37.0/query?q=SELECT id, subject FROM EmailMessage WHERE sObjectIdToSync='006j000000T2I08AAF'
// Example Task:
// https://na16.salesforce.com/services/data/v37.0/sobjects/Task/00Tj000001M3XtrEAF
_newTask({message, bodies}) {
const to = message.to.map((p) => p.email)
const cc = message.cc.map((p) => p.email)
const bcc = message.bcc.map((p) => p.email)
const fileNames = message.files.map((f) => f.filename)
const task = {
Subject: `Email: ${message.subject}`,
Description: `Date: ${moment(message.date).format('MMMM Do YYYY, h:mm:ss a')}\nAdditional To: ${to.join(", ")}\nCC: ${cc.join(", ")}\nBCC: ${bcc.join(", ")}\nAttachment: ${fileNames.join(", ")}\n\nSubject: ${message.subject}\nBody:\n${bodies.plainTextUnquoted}`,
WhatId: this.sObjectId,
TaskSubtype: "Email",
ActivityDate: moment(message.date).format("YYYY-MM-DD"),
Status: "Completed",
Priority: "Normal",
}
return DatabaseStore.findBy(SalesforceObject,
{type: "Contact", identifier: message.fromContact().email})
.then((contact) => {
if (contact) { task.WhoId = contact.id }
return SalesforceAPI.makeRequest({
method: "POST",
path: `/sobjects/Task/`,
body: task,
}).catch((apiError) => {
// These shouldn't fail. If they do we need to look on Sentry.
// Unfortunately there's no user feedback yet, so report and
// re-throw so the task fails.
SalesforceActions.reportError(apiError, {rawPostData: task})
throw apiError
});
})
}
// Note this invisible Task:
// https://na16.salesforce.com/services/data/v37.0/sobjects/Task/00Tj000001LLhZbEAL
// Is a duplicate of this EmailMessage:
// Example EmailMessage:
// https://na16.salesforce.com/services/data/v37.0/sobjects/EmailMessage/02sj0000006tyw6AAA
_newEmailMessage({message, bodies}) {
const emailMessage = {
TextBody: bodies.plainTextUnquoted,
HtmlBody: bodies.htmlUnquoted,
Headers: null,
Subject: message.subject,
FromName: message.fromContact().name,
FromAddress: message.fromContact().email,
ToAddress: message.to.map((p) => p.email).join(";"),
CcAddress: message.cc.map((p) => p.email).join(";"),
BccAddress: message.bcc.map((p) => p.email).join(";"),
MessageDate: moment(message.date).format(),
ReplyToEmailMessageId: null,
RelatedToId: this.sObjectId,
}
return SalesforceAPI.makeRequest({
method: "POST",
path: `/sobjects/EmailMessage/`,
body: emailMessage,
}).catch((apiError) => {
// These shouldn't fail. If they do we need to look on Sentry.
// Unfortunately there's no user feedback yet, so report and
// re-throw so the task fails.
SalesforceActions.reportError(apiError, {rawPostData: emailMessage})
throw apiError
});
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/tasks/manually-relate-salesforce-object-task.es6
================================================
import _ from 'underscore'
import {
Task,
Actions,
DatabaseStore,
SyncbackMetadataTask,
DatabaseObjectRegistry,
} from 'nylas-exports'
import { PLUGIN_ID } from '../salesforce-constants';
import * as mdHelpers from "../metadata-helpers";
import * as dataHelpers from "../salesforce-object-helpers";
import UpsertOpportunityContactRoleTask from './upsert-opportunity-contact-role-task'
import SyncThreadActivityToSalesforceTask from './sync-thread-activity-to-salesforce-task'
export default class ManuallyRelateSalesforceObjectTask extends Task {
constructor(args = {}) {
super();
this.args = args;
this.sObjectId = args.sObjectId
this.sObjectType = args.sObjectType
this.nylasObjectId = args.nylasObjectId
this.syncbackThread = args.syncbackThread
this.nylasObjectType = args.nylasObjectType
}
isSameAndOlderTask(other) {
return other instanceof ManuallyRelateSalesforceObjectTask &&
other.sObjectId === this.sObjectId &&
other.sObjectType === this.sObjectType &&
other.nylasObjectId === this.nylasObjectId &&
other.nylasObjectType === this.nylasObjectType &&
other.sequentialId < this.sequentialId;
}
isDependentOnTask(other) {
return ((other instanceof SyncbackMetadataTask) &&
(other.modelClassName === "Thread") &&
(other.pluginId === PLUGIN_ID)) ||
(other.constructor.name === "SyncbackSalesforceObjectTask" &&
other.objectId === this.sObjectId &&
other.objectType === this.sObjectType) ||
this.isSameAndOlderTask(other)
}
shouldDequeueOtherTask(other) {
return this.isSameAndOlderTask(other)
}
performLocal() {
return Promise.resolve({objectId: this.sObjectId, objectType: this.sObjectType})
.then(dataHelpers.loadFullObject)
.then(this._loadNylasObject)
.then(this._updateMetadata)
}
performRemote() {
return Promise.resolve({objectId: this.sObjectId, objectType: this.sObjectType})
.then(dataHelpers.loadFullObject)
.then(this._loadNylasObject)
.then(this._queueSyncbackMetadata)
.then(this._queueSyncThreadActivity)
.then(this._queueRelatedObjectUpsert)
.then(() => Task.Status.Success)
}
_loadNylasObject = (fullSObject) => {
const klass = DatabaseObjectRegistry.get(this.nylasObjectType);
return DatabaseStore.find(klass, this.nylasObjectId)
.then((nylasObject) => { return {nylasObject, fullSObject} })
}
_updateMetadata = ({fullSObject, nylasObject}) => {
mdHelpers.setManuallyRelatedObject(nylasObject, fullSObject);
return DatabaseStore.inTransaction(t => t.persistModel(nylasObject))
.then(() => { return {fullSObject, nylasObject} })
}
_queueSyncbackMetadata = ({fullSObject, nylasObject}) => {
const task = new SyncbackMetadataTask(nylasObject.clientId, nylasObject.constructor.name, PLUGIN_ID);
Actions.queueTask(task);
return Promise.resolve({fullSObject, nylasObject})
}
// When manually relating an sObject, we can optional auto-enable whether
// we syncback activity to that sObject. If we didn't have this feature
// users would always have to manually flip the "Sync to this sObject"
// toggle.
_queueSyncThreadActivity = ({fullSObject, nylasObject}) => {
// Make sure we haven't already flagged this as a thread to sync
if (!mdHelpers.getSObjectsToSyncActivityTo(nylasObject)[fullSObject.id]) {
if (this.syncbackThread && this.nylasObjectType === "Thread") {
const t = new SyncThreadActivityToSalesforceTask({
threadId: this.nylasObjectId,
threadClientId: nylasObject.clientId,
newSObjectsToSync: [fullSObject],
sObjectsToStopSyncing: [],
});
Actions.queueTask(t);
}
}
return Promise.resolve({fullSObject, nylasObject})
}
// This is reciprocal to code in SyncbackSalesforceObjectTask
//
// When we link a Thread to an Opportunity and we know there are
// Contacts on the Thread, we can link them to this opportunity if
// they're not attached already. Contacts and Opportunities are
// connected through OpportunityContactRole objects.
_queueRelatedObjectUpsert = ({fullSObject, nylasObject}) => {
if (this.sObjectType === "Opportunity" && this.nylasObjectType === "Thread") {
return this._queueUpsertOpportunityContactRole({fullSObject, nylasObject})
}
return Promise.resolve()
}
_queueUpsertOpportunityContactRole = ({nylasObject}) => {
const contacts = nylasObject.participants.filter((contact) => {
return !contact.isMe() && !contact.hasSameDomainAsMe()
})
const t = new UpsertOpportunityContactRoleTask({
opportunityId: this.sObjectId,
emails: _.pluck(contacts, "email"),
})
Actions.queueTask(t)
return Promise.resolve()
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/tasks/remove-manual-relation-to-salesforce-object-task.es6
================================================
import {
Task,
Actions,
DatabaseStore,
SyncbackMetadataTask,
DatabaseObjectRegistry,
} from 'nylas-exports'
import { PLUGIN_ID } from '../salesforce-constants';
import * as mdHelpers from "../metadata-helpers";
import SyncThreadActivityToSalesforceTask from './sync-thread-activity-to-salesforce-task'
export default class RemoveManualRelationToSalesforceObjectTask extends Task {
constructor({sObjectId, nylasObjectId, nylasObjectType} = {}) {
super();
this.sObjectId = sObjectId
this.nylasObjectId = nylasObjectId
this.nylasObjectType = nylasObjectType
this.metadataUpdated = false
}
isSameAndOlderTask(other) {
return other instanceof RemoveManualRelationToSalesforceObjectTask &&
other.sObjectId === this.sObjectId &&
other.nylasObjectId === this.nylasObjectId &&
other.nylasObjectType === this.nylasObjectType &&
other.sequentialId < this.sequentialId;
}
isDependentOnTask(other) {
return this.isSameAndOlderTask(other)
}
shouldDequeueOtherTask(other) {
return this.isSameAndOlderTask(other)
}
performLocal() {
return this._loadNylasObject()
.then(this._updateMetadata)
}
performRemote() {
if (this.metadataUpdated) {
return this._loadNylasObject
.then(this._queueSyncbackMetadata)
.then(this._queueSyncThreadActivity)
.then(() => Task.Status.Success)
}
return Promise.resolve(Task.Status.Success)
}
_loadNylasObject() {
const klass = DatabaseObjectRegistry.get(this.nylasObjectType);
return DatabaseStore.find(klass, this.nylasObjectId)
}
_updateMetadata = (nylasObject) => {
if (mdHelpers.getManuallyRelatedObjects(nylasObject)[this.sObjectId]) {
mdHelpers.removeManuallyRelatedObject(nylasObject, {id: this.sObjectId});
this.metadataUpdated = true
return DatabaseStore.inTransaction(t => t.persistModel(nylasObject))
}
return Promise.resolve()
}
_queueSyncbackMetadata = (nylasObject) => {
const task = new SyncbackMetadataTask(nylasObject.clientId, nylasObject.constructor.name, PLUGIN_ID);
Actions.queueTask(task);
return Promise.resolve(nylasObject)
}
// When removing a manually related sObject, we also want to stop
// syncing the thread to it (if we marked it to sync).
_queueSyncThreadActivity = (nylasObject) => {
if (mdHelpers.getSObjectsToSyncActivityTo(nylasObject)[this.sObjectId]) {
const t = new SyncThreadActivityToSalesforceTask({
threadId: nylasObject.id,
threadClientId: nylasObject.clientId,
sObjectsToStopSyncing: [{id: this.sObjectId}],
});
Actions.queueTask(t);
}
return Promise.resolve()
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/tasks/sync-salesforce-objects-task.es6
================================================
import _ from 'underscore'
import moment from 'moment'
import querystring from "querystring";
import {Task, TaskQueue, DatabaseStore} from 'nylas-exports'
import SalesforceActions from '../salesforce-actions'
import SalesforceAPI from '../salesforce-api'
import SalesforceEnv from '../salesforce-env'
import SalesforceObject from '../models/salesforce-object'
import {upsertBasicObjects, newBasicObjectsQuery} from '../salesforce-object-helpers'
class SyncSalesforceObjectsTask extends Task {
constructor({objectType, lastUpdateTime} = {}) {
super()
this._objectType = objectType
this._lastUpdateTime = lastUpdateTime
}
get objectType() {
return this._objectType
}
performLocal() {
if (!this._objectType) {
return Promise.reject(new Error('SyncSalesforceObjectsTask: Must provide an objectType'))
}
return Promise.resolve()
}
performRemote() {
if (!SalesforceEnv.isLoggedIn()) { return Promise.resolve(Task.Status.Continue) }
const queuedSyncs = TaskQueue.findTasks(SyncSalesforceObjectsTask, {objectType: this._objectType})
if (queuedSyncs.length > 1) {
return Promise.resolve(Task.Status.Continue)
}
console.log(`Salesforce: Syncing ${this._objectType}...`)
return Promise.all([
this._fetchNewOrUpdatedObjects(this._objectType, this._lastUpdateTime),
this._removeOldObjects(this._objectType, this._lastUpdateTime),
])
.then(() => console.log(`Salesforce: Done syncing ${this._objectType}`))
.then(() => Promise.resolve(Task.Status.Success))
.catch((err) => {
SalesforceActions.reportError(err)
return Promise.resolve([Task.Status.Failed, err])
})
}
_handleFetchResponse = (data) => {
return upsertBasicObjects(data)
.then(() => {
const {done, nextRecordsUrl} = data
if (!done) {
const nextPath = nextRecordsUrl.match(/\/query\/.*/)[0];
if (!nextPath) {
return Promise.reject(
new Error(`SyncSalesforceObjectsTask: Could not load all objects of type ${this._objectType}. Invalid nextRecordsUrl: ${nextRecordsUrl}`)
)
}
return SalesforceAPI.makeRequest({
path: nextPath,
})
.then(this._handleFetchResponse)
}
return Promise.resolve()
})
}
_fetchNewOrUpdatedObjects(objectType, lastUpdateTime) {
const lastModifiedDate = moment(+lastUpdateTime).utc().format();
const where = `LastModifiedDate > ${lastModifiedDate}`;
const query = newBasicObjectsQuery(objectType, where);
return SalesforceAPI.makeRequest({
path: `/query/?${query}`,
})
.then(this._handleFetchResponse)
}
// See Salesforce API documentation for
// Geting a List of Deleted Records Within the past 30 days
_removeOldObjects(objectType, lastUpdateTime) {
if (lastUpdateTime === 0) { return Promise.resolve(); }
let start = moment()
const isTooOld = moment(lastUpdateTime).add(29, 'days').isBefore(start);
start = isTooOld ?
start.subtract(29, 'days') : moment(lastUpdateTime);
const end = moment()
const query = querystring.stringify({
start: start.utc().format(),
end: end.utc().format(),
});
return SalesforceAPI.makeRequest({
path: `/sobjects/${objectType}/deleted/?${query}`,
})
.then((data) => {
const deletedRecords = data.deletedRecords || []
if (deletedRecords.length === 0) { return Promise.resolve(); }
const ids = _.pluck(deletedRecords, "id");
return Promise.all(ids.map((id) =>
DatabaseStore.find(SalesforceObject, id).then((model) => {
if (!model) { return Promise.resolve() }
return DatabaseStore.inTransaction(t => t.unpersistModel(model));
})
));
})
}
}
export default SyncSalesforceObjectsTask
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/tasks/sync-thread-activity-to-salesforce-task.es6
================================================
import {
Task,
Thread,
Message,
Actions,
DatabaseStore,
SyncbackMetadataTask,
} from 'nylas-exports'
import {PLUGIN_ID} from '../salesforce-constants'
import * as mdHelpers from '../metadata-helpers'
import EnsureMessageOnSalesforceTask from './ensure-message-on-salesforce-task'
import DestroyMessageOnSalesforceTask from './destroy-message-on-salesforce-task'
/**
* Given a threadId, this will load all of the messages on the thread and
* make sure that there are EmailMessages associated with each of the
* correspondingly linked SalesforceObjects
*
* See lib/metadata-helpers.es6 for documentation on what metadata on the
* object looks like.
*
*/
export default class SyncThreadActivityToSalesforceTask extends Task {
constructor({threadId, threadClientId, newSObjectsToSync, sObjectsToStopSyncing} = {}) {
super();
this.threadId = threadId;
this.isCanceled = false;
this.threadClientId = threadClientId;
this.newSObjectsToSync = newSObjectsToSync || [];
this.sObjectsToStopSyncing = sObjectsToStopSyncing || [];
}
isSameAndOlderTask(other) {
return other instanceof SyncThreadActivityToSalesforceTask &&
other.threadId === this.threadId &&
other.threadClientId === this.threadClientId &&
other.sequentialId < this.sequentialId;
}
shouldDequeueOtherTask(other) {
return this.isSameAndOlderTask(other)
}
isDependentOnTask(other) {
return (other instanceof SyncbackMetadataTask) &&
(other.modelClassName === "Thread") &&
(other.clientId === this.threadClientId) &&
(other.pluginId === PLUGIN_ID) ||
this.isSameAndOlderTask(other);
}
performLocal() {
return this._loadThread()
.then(this._updateMetadata)
}
performRemote() {
return this._loadThread()
.then(this._queueSyncbackMetadata)
.then(this._queueMessageTasks)
.thenReturn(Task.Status.Success)
}
cancel() {
this.isCanceled = true;
}
_loadThread = () => {
return DatabaseStore.find(Thread, this.threadId)
}
_updateMetadata = (thread) => {
for (const newSObject of this.newSObjectsToSync) {
mdHelpers.addActivitySyncSObject(thread, newSObject);
}
for (const sObject of this.sObjectsToStopSyncing) {
mdHelpers.removeActivitySyncSObject(thread, sObject);
}
return DatabaseStore.inTransaction(t => t.persistModel(thread))
.then(() => thread)
}
_queueSyncbackMetadata = (thread) => {
if (this.isCanceled) return Promise.resolve(thread);
const task = new SyncbackMetadataTask(thread.clientId, thread.constructor.name, PLUGIN_ID);
Actions.queueTask(task);
return Promise.resolve(thread)
}
_queueMessageTasks = (thread) => {
if (this.isCanceled) return Promise.resolve(thread);
const sObjectsToSync = mdHelpers.getSObjectsToSyncActivityTo(thread);
if (Object.keys(sObjectsToSync).length === 0 && this.sObjectsToStopSyncing.length === 0) {
return Promise.resolve()
}
// Since we don't need the very expensive bodies!
const basicMsgQuery = DatabaseStore.findAll(Message).where({threadId: thread.id})
return Promise.each(basicMsgQuery, (message) => {
if (this.isCanceled) return;
for (const sObjectToStopSyncing of this.sObjectsToStopSyncing) {
const t = new DestroyMessageOnSalesforceTask({
messageId: message.id,
sObjectId: sObjectToStopSyncing.id,
})
Actions.queueTask(t);
}
for (const sObjectId of Object.keys(sObjectsToSync)) {
const t = new EnsureMessageOnSalesforceTask({
messageId: message.id,
sObjectId: sObjectId,
sObjectType: sObjectsToSync[sObjectId].type,
})
Actions.queueTask(t);
}
})
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/tasks/syncback-salesforce-object-task.es6
================================================
import _ from 'underscore'
import {Task, Utils, Actions, DatabaseStore} from 'nylas-exports'
import { PLUGIN_ID } from '../salesforce-constants';
import SalesforceAPI from '../salesforce-api'
import SalesforceObject from '../models/salesforce-object'
import SalesforceActions from '../salesforce-actions'
import * as dataHelpers from '../salesforce-object-helpers'
import DestroySalesforceObjectTask from './destroy-salesforce-object-task'
export default class SyncbackSalesforceObjectTask extends Task {
constructor({objectId, objectType, formPostData, contextData, relatedObjectsData} = {}) {
super()
this.objectId = objectId
this.objectType = objectType
this.contextData = contextData || {}
this.formPostData = formPostData || {}
this.relatedObjectsData = relatedObjectsData || {}
}
isDependentOnTask(other) {
return ((other.constructor.name === "SyncbackMetadataTask") &&
(other.modelClassName === "Thread") &&
(other.pluginId === PLUGIN_ID))
}
shouldDequeueOtherTask(other) {
return other instanceof SyncbackSalesforceObjectTask &&
other.objectId === this.objectId &&
other.objectType === this.objectType &&
Utils.isEqual(other.contextData, this.contextData) &&
Utils.isEqual(other.formPostData, this.formPostData) &&
Utils.isEqual(other.relatedObjectsData, this.relatedObjectsData)
}
performRemote() {
return Promise.resolve()
.then(this.submitToSalesforce)
.then(this.loadAndSaveFullObject)
.then(this.upsertRelatedObjects)
.then(this.notifySuccess)
.then(() => Task.Status.Success)
.catch(this.handleError)
}
submitToSalesforce = () => {
// If the objectId is present that means we're updating with new data.
// If it's blank, that means we're creating a new one.
const method = this.objectId ? "PATCH" : "POST";
const oidPath = this.objectId != null ? this.objectId : "";
const path = `/sobjects/${this.objectType}/${oidPath}`;
return SalesforceAPI.makeRequest({
path,
method,
body: this.formPostData,
})
}
// When you create an object on Salesforce, it returns a stub object
// with the new id according to the schema here:
// https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_sobject_create.htm
loadAndSaveFullObject = (sfCreatedObj = {}) => {
const objectId = this.objectId || sfCreatedObj.id
// Note: After we request the full object from the API we save it to
// the Database here:
return dataHelpers.requestFullObjectFromAPI({objectType: this.objectType, objectId});
}
// When we Create a Contact and there's an Opportunity present, we also
// connect the newly created Contact to that Opportunity via a special
// `OpportunityContactRole` object.
//
// This is reciprocal to code in ManuallyRelateSalesforceObjectTask
upsertRelatedObjects = (sObject) => {
const updates = []
if (sObject.type === "Contact" &&
this.relatedObjectsData.OpportunityIds) {
updates.push(this._setOpportunitiesForContact(sObject))
} else if (sObject.type === "Opportunity" &&
this.relatedObjectsData.ContactIds) {
updates.push(this._setContactsForOpportunity(sObject))
} else if (sObject.type === "Account" &&
this.relatedObjectsData.ContactIds) {
updates.push(this._setContactsForAccount(sObject))
}
return Promise.all(updates).then(() => sObject)
}
_setOpportunitiesForContact(contact) {
return DatabaseStore.findAll(SalesforceObject, {
type: "OpportunityContactRole",
identifier: contact.id,
}).then((roles = []) => {
const existingOppIds = _.pluck(roles, "relatedToId");
const desiredOppIds = this.relatedObjectsData.OpportunityIds;
const rolesToDelete = roles.filter(role => {
return !(desiredOppIds.includes(role.relatedToId))
})
const oppIdsToCreate = desiredOppIds.filter((oid) => {
return !(existingOppIds.includes(oid))
})
const tasks = []
for (const oppId of oppIdsToCreate) {
tasks.push(new SyncbackSalesforceObjectTask({
objectType: "OpportunityContactRole",
formPostData: {
OpportunityId: oppId,
ContactId: contact.id,
},
}))
}
for (const role of rolesToDelete) {
tasks.push(new DestroySalesforceObjectTask({
sObjectType: "OpportunityContactRole",
sObjectId: role.id,
}))
}
if (tasks.length === 0) return;
Actions.queueTasks(tasks);
})
}
_setContactsForOpportunity(opp) {
return DatabaseStore.findAll(SalesforceObject, {
type: "OpportunityContactRole",
relatedToId: opp.id,
}).then((roles = []) => {
const existingContactIds = _.pluck(roles, "identifier");
const desiredContactIds = this.relatedObjectsData.ContactIds;
const rolesToDelete = roles.filter(role => {
return !(desiredContactIds.includes(role.identifier))
})
const contactIdsToCreate = desiredContactIds.filter((cid) => {
return !(existingContactIds.includes(cid))
})
const tasks = []
for (const cid of contactIdsToCreate) {
tasks.push(new SyncbackSalesforceObjectTask({
objectType: "OpportunityContactRole",
formPostData: {
OpportunityId: opp.id,
ContactId: cid,
},
}))
}
for (const role of rolesToDelete) {
tasks.push(new DestroySalesforceObjectTask({
sObjectType: "OpportunityContactRole",
sObjectId: role.id,
}))
}
if (tasks.length === 0) return;
Actions.queueTasks(tasks);
})
}
// An Account must have the following Contacts. Therefore we need to
// update Contact objects to have the correct AccountId
_setContactsForAccount(account) {
return DatabaseStore.findAll(SalesforceObject, {
type: "Contact",
relatedToId: account.id,
}).then((contacts = []) => {
const existingContactIds = _.pluck(contacts, "id");
const desiredContactIds = this.relatedObjectsData.ContactIds;
const contactsToRemoveAccount = contacts.filter(c => {
return !(desiredContactIds.includes(c.relatedToId))
})
const contactsToAddAccount = desiredContactIds.filter((cid) => {
return !(existingContactIds.includes(cid))
})
const tasks = []
for (const cid of contactsToAddAccount) {
tasks.push(new SyncbackSalesforceObjectTask({
objectType: "Contact",
objectId: cid,
formPostData: { AccountId: account.id },
}))
}
for (const contact of contactsToRemoveAccount) {
tasks.push(new SyncbackSalesforceObjectTask({
objectType: "Contact",
objectId: contact.id,
formPostData: { AccountId: "" },
}))
}
if (tasks.length === 0) return;
Actions.queueTasks(tasks);
})
}
notifySuccess = (sObject) => {
SalesforceActions.syncbackSuccess({
objectId: sObject.id,
objectType: sObject.type,
contextData: this.contextData,
formPostData: this.formPostData,
relatedObjectsData: this.relatedObjectsData,
})
}
handleError = (apiError = {}) => {
const name = this.formPostData.Name || this.formPostData.Email
SalesforceActions.reportError(apiError, {
sObjectId: this.objectId,
sObjectType: this.objectType,
sObjectName: name,
contextData: this.contextData,
formPostData: this.formPostData,
relatedObjectsData: this.relatedObjectsData,
});
SalesforceActions.syncbackFailed({
objectType: this.objectType,
contextData: this.contextData,
error: apiError,
});
return Promise.resolve([Task.Status.Failed, apiError])
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/tasks/upsert-opportunity-contact-role-task.es6
================================================
import _ from 'underscore'
import {Task, Utils, DatabaseStore} from 'nylas-exports'
import SalesforceAPI from '../salesforce-api'
import SalesforceObject from '../models/salesforce-object'
import * as dataHelpers from '../salesforce-object-helpers'
class UpsertOpportunityContactRoleTask extends Task {
constructor({opportunityId, emails} = {}) {
super()
this.opportunityId = opportunityId
this.emails = emails
}
isSameAndOlderTask(other) {
return other instanceof UpsertOpportunityContactRoleTask &&
other.opportunityId === this.opportunityId &&
Utils.isEqual(other.emails, this.emails) &&
other.sequentialId < this.sequentialId;
}
shouldDequeueOtherTask(other) {
return this.isSameAndOlderTask(other)
}
isDependentOnTask(other) {
return this.isSameAndOlderTask(other)
}
performRemote() {
return Promise.resolve()
.then(this._fetchAndSaveContactsFromEmails)
.then(this._fetchAndSaveRolesFromContacts)
.then(this._calculateMissingRoles)
.then(this._submitMissingRoles)
.then(() => Task.Status.Success)
}
_identifier(contact) {
return `${this.opportunityId}-${contact.id}`
}
_fetchAndSaveContactsFromEmails = () => {
// console.log("---> Finding Contacts from emails")
return DatabaseStore.findAll(SalesforceObject, {
type: "Contact",
identifier: this.emails,
}).then((sfContactModels = []) => {
const toFetch = _.difference(this.emails, _.pluck(sfContactModels, "identifier"));
return Promise.map(toFetch, (emailToFetch) => {
return dataHelpers.loadBasicObjectsByField({
objectType: "Contact",
where: {Email: emailToFetch},
})
.then(dataHelpers.upsertBasicObjects)
}).then((savedContactsFromAPI = []) => {
return sfContactModels.concat(_.compact(_.flatten(savedContactsFromAPI)))
})
})
}
_fetchAndSaveRolesFromContacts = (sfContactModels = []) => {
// console.log("---> Found Contats", sfContactModels)
const identifiers = sfContactModels.map((sfContact) => {
return this._identifier(sfContact);
})
// console.log("---> Finding OpportunityContactRoles from Contats")
return DatabaseStore.findAll(SalesforceObject, {
type: "OpportunityContactRole",
identifier: identifiers,
}).then((roles = []) => {
const toFetch = _.difference(identifiers, _.pluck(roles, "identifier"));
return Promise.map(toFetch, (identifier) => {
return dataHelpers.loadBasicObjectsByField({
objectType: "OpportunityContactRole",
fields: ["Id", "OpportunityId", "ContactId"],
where: {
OpportunityId: this.opportunityId,
ContactId: identifier.split("-")[1],
},
})
.then(dataHelpers.upsertBasicObjects)
})
.then((savedOpportunityContactRoles = []) => {
return roles.concat(_.compact(_.flatten(savedOpportunityContactRoles)))
})
.then((sfOpportunityContactRoles = []) => {
return {sfContactModels, sfOpportunityContactRoles}
})
})
}
_calculateMissingRoles = ({sfContactModels, sfOpportunityContactRoles}) => {
// console.log("---> Found OpportunityContatRoles", sfOpportunityContactRoles)
const contactIds = sfContactModels.map((sfContact) => {
return this._identifier(sfContact);
})
const roleIds = _.pluck(sfOpportunityContactRoles, "identifier");
return _.difference(contactIds, roleIds).map((ident) => {
return ident.split("-")[1]
})
}
_submitMissingRoles = (missingContactIds = []) => {
// console.log(`---> ${missingContactIds.length} missing Roles`, missingContactIds)
if (missingContactIds.length === 0) return Promise.resolve();
return Promise.each(missingContactIds, (contactId) => {
return SalesforceAPI.makeRequest({
path: `/sobjects/OpportunityContactRole`,
method: "POST",
body: {
OpportunityId: this.opportunityId,
ContactId: contactId,
},
}).then((sfCreatedObj) => {
return dataHelpers.requestFullObjectFromAPI({
objectType: "OpportunityContactRole",
objectId: sfCreatedObj.id,
})
})
}).then(() => {
// console.log("Saved OpportunityContactRoles")
})
}
}
export default UpsertOpportunityContactRoleTask
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/thread/related-objects-for-thread.jsx
================================================
import _ from 'underscore'
import _str from 'underscore.string'
import React from 'react'
import moment from 'moment'
import ReactDOM from 'react-dom'
import {Utils, DatabaseStore, AccountStore, FocusedContactsStore} from 'nylas-exports'
import SalesforceIcon from '../shared-components/salesforce-icon'
import SalesforceObject from '../models/salesforce-object'
import SyncThreadToggle from './sync-thread-toggle'
import SalesforceActions from '../salesforce-actions'
import OpenInSalesforceBtn from '../shared-components/open-in-salesforce-btn'
import * as dataHelpers from '../salesforce-object-helpers'
import * as relatedHelpers from '../related-object-helpers'
import {CORE_RELATEABLE_OBJECT_TYPES} from '../salesforce-constants'
class RelatedObjectsForThread extends React.Component {
static displayName = "RelatedObjectsForThread"
static containerStyles = {
order: 2,
flexShrink: 0,
}
static propTypes = {
thread: React.PropTypes.object,
}
constructor(props) {
super(props);
this.state = this._initialState();
}
componentWillMount() {
this._setupDataSource(this.props);
this._usub = FocusedContactsStore.listen(this._onContactChange)
}
componentWillReceiveProps(nextProps) {
this._setupDataSource(nextProps)
}
componentWillUnmount() {
if (this.disposable && this.disposable.dispose) {
this.disposable.dispose()
}
this._usub()
}
_initialState() {
return {
expanded: false,
subObjects: {},
relatedObjects: [],
focusedContact: FocusedContactsStore.focusedContact(),
focusedContacts: FocusedContactsStore.sortedContacts(),
}
}
_onContactChange = () => {
this.setState({
focusedContact: FocusedContactsStore.focusedContact(),
focusedContacts: FocusedContactsStore.sortedContacts(),
})
}
_setupDataSource(props) {
if (this.disposable && this.disposable.dispose) {
this.disposable.dispose()
}
this.setState(this._initialState())
this.disposable = relatedHelpers.observeRelatedSObjectsForThread(props.thread).subscribe((relatedObjects) => {
return Promise.map(relatedObjects, (relatedObj) => {
return dataHelpers.loadFullObject({
objectId: relatedObj.id,
objectType: relatedObj.type,
})
}).then((fullObjs) => {
const objs = fullObjs.filter(obj =>
CORE_RELATEABLE_OBJECT_TYPES.includes(obj.type))
this.setState({relatedObjects: objs})
return Promise.each(this._mainObjects(objs), this._loadSubObjects)
})
})
}
// TODO: Do in more extensible way when SalesforceConfig Main Object
// types come into play
// TODO: Make a subObject observer;
_loadSubObjects = (mainObj) => {
if (mainObj.type === "Opportunity") {
return DatabaseStore.findAll(SalesforceObject,
{type: "OpportunityContactRole", relatedToId: mainObj.id})
.then((roles = []) => {
if (roles.length === 0) return []
return DatabaseStore.findAll(SalesforceObject, {
type: "Contact", id: roles.map(r => r.identifier),
})
}).then((contacts = []) => {
let p = Promise.resolve([]);
if (mainObj.relatedToId) {
p = DatabaseStore.findAll(SalesforceObject, {type: "Account", id: mainObj.relatedToId})
}
return p.then((accounts = []) => {
const subObjs = Utils.deepClone(this.state.subObjects);
subObjs[mainObj.id] = accounts.concat(contacts);
this.setState({subObjects: subObjs})
})
})
} else if (mainObj.type === "Account") {
return DatabaseStore.findAll(SalesforceObject,
{type: "Contact", relatedToId: mainObj.id})
.then((objs = []) => {
const subObjs = Utils.deepClone(this.state.subObjects);
subObjs[mainObj.id] = objs;
this.setState({subObjects: subObjs})
})
}
return Promise.resolve()
}
_extraInfoForObj(obj) {
if (obj.type === "Opportunity" && obj.rawData) {
const opp = obj.rawData
const info = []
if (opp.Amount) {
const amnt = opp.Amount.toFixed(0).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, "$1,")
info.push(`$${amnt}`)
}
if (opp.StageName) {
info.push(`${opp.StageName}`)
}
if (opp.Probability) {
info.push(`${opp.Probability}%`)
}
if (opp.CloseDate) {
info.push(`Close ${opp.CloseDate}`)
}
if (opp.LastActivityDate) {
info.push(`Last activity: ${moment(opp.LastActivityDate).fromNow()}`)
}
return info.join(" • ")
} else if (obj.type === "Contact") {
return obj.identifier || ""
}
return ""
}
_requestEdit(object) {
let focusedNylasContactData = null;
if (this.state.focusedContact) {
focusedNylasContactData = {
id: this.state.focusedContact.id,
name: this.state.focusedContact.name,
email: this.state.focusedContact.email,
}
}
SalesforceActions.openObjectForm({
objectId: object.id,
objectType: object.type,
objectInitialData: object,
contextData: {
nylasObjectId: this.props.thread.id,
nylasObjectType: "Thread",
focusedNylasContactData: focusedNylasContactData,
},
})
}
_createNewContact(participant, mainObj) {
const objectInitialData = {}
if (mainObj.type === "Opportunity") {
objectInitialData.OpportunityIds = [mainObj.id]
}
const subObjs = this._subObjects(mainObj);
const account = subObjs.filter(o => o.type === "Account")[0]
if (account) {
objectInitialData.AccountId = account.id;
}
SalesforceActions.openObjectForm({
objectType: "Contact",
objectInitialData: objectInitialData,
contextData: {
nylasObjectId: this.props.thread.id,
nylasObjectType: "Thread",
focusedNylasContactData: {
name: participant.name,
email: participant.email,
},
},
})
}
_editObj = (obj) => {
const reqEdit = _.debounce(() => this._requestEdit(obj), 1000, true);
return (event) => {
const wrap = ReactDOM.findDOMNode(this.refs.relObjects);
const toggles = Array.from(wrap.querySelectorAll(".thread-toggles"));
for (const toggle of toggles) {
if (toggle.contains(event.target)) {
return
}
}
reqEdit()
}
}
_humanize(type) {
return _str.titleize(_str.humanize(type))
}
_renderMainObject = (obj) => {
if (!obj) return null
return (
{obj.name}
{this._extraInfoForObj(obj)}
{this._renderSubObjects(obj)}
)
}
_renderSubObjects(obj) {
const PADDING = 5 + 5 + 1; // paddings + border-bottom
const SUB_OBJ_HEIGHT = 26;
const NUM_TO_SHOW = 3;
const subObjs = this._subObjects(obj);
if (subObjs.length === 0) return false;
const participants = this._remainingParticipants(subObjs);
let numParticipants = participants.length;
if (this.state.focusedContacts.length === 0) {
// This means we're still loading participants. Guess box height
// from thread participants so we don't reflow the message list of
// the thread for users.
numParticipants = this.props.thread.participants.length
}
const numSubObjs = subObjs.length + numParticipants;
const numToShow = this.state.expanded ? numSubObjs : Math.min(numSubObjs, NUM_TO_SHOW);
const onToggle = () => this.setState({expanded: !this.state.expanded})
const hasToggle = (numSubObjs > NUM_TO_SHOW)
const msg = this.state.expanded ? "Collapse" : "Show more"
const toggle = (
{msg}
)
// Since you can't animate to height: auto
let height = numToShow * SUB_OBJ_HEIGHT + PADDING;
// Otherwise the base height overflows
if (hasToggle) height -= 2;
if (this.state.expanded) {
height = numToShow * SUB_OBJ_HEIGHT + PADDING;
}
return [
{subObjs.map(this._renderSubObject)}
{participants.map(this._renderSuggestedContact(obj))}
,
(hasToggle ? toggle : false),
]
}
_remainingParticipants = (subObjs) => {
const emails = new Set(subObjs.map(o => (o.identifier || "")))
return this.state.focusedContacts.filter(p =>
!emails.has(p.email) && !AccountStore.accountForEmail(p.email)
)
}
_renderSuggestedContact = (mainObj) => {
return (participant) => {
const reqCreate = _.debounce(() =>
this._createNewContact(participant, mainObj), 1000, true
);
return (
Add:
{participant.fullName()}
{participant.email}
)
}
}
_renderSubObject = (subObj) => {
return (
{subObj.name}
{this._extraInfoForObj(subObj)}
)
}
// _renderNewPrompt() {
// const forWhom = this.state.focusedContact;
// let company = null;
// let text = ""
// if (forWhom) {
// company = SmartFields.getFieldFromClearbit(forWhom, "Contact", "Company");
// text = `for ${company || forWhom.firstName()}`;
// }
// return (
//
//
//
// Create Opportunity {text}
//
//
// )
// }
// TODO: Replace with SalesforceConfig
_mainObjects = (objs = []) => {
const opps = objs.filter(o => o.type === "Opportunity");
if (opps.length > 0) return opps;
const accounts = objs.filter(o => o.type === "Account");
if (accounts.length > 0) return accounts;
return [];
}
_subObjects(obj) {
return this.state.subObjects[obj.id] || []
}
_renderMainObjects() {
const mainObjects = this._mainObjects(this.state.relatedObjects);
if (mainObjects.length > 0) {
return (
{mainObjects.map(this._renderMainObject)}
)
}
return false;
}
render() {
if (!this.props.thread) return false
return (
{this._renderMainObjects()}
)
}
}
export default RelatedObjectsForThread
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/thread/salesforce-manually-relate-thread-button.jsx
================================================
import _ from 'underscore'
import React from 'react'
import ReactDOM from 'react-dom'
import {RetinaImg, KeyCommandsRegion} from 'nylas-component-kit'
import {Rx, Actions, FocusedContactsStore} from 'nylas-exports'
import SalesforceEnv from '../salesforce-env'
import SalesforceObject from '../models/salesforce-object'
import SalesforceActions from '../salesforce-actions'
import PendingSalesforceObject from '../form/pending-salesforce-object'
import ManuallyRelateSalesforceObjectTask from '../tasks/manually-relate-salesforce-object-task'
import SalesforceManuallyRelateThreadPopover from './salesforce-manually-relate-thread-popover'
export default class SalesforceManuallyRelateThreadButton extends React.Component {
static displayName = "SalesforceManuallyRelateThreadButton"
static containerRequired = false
static propTypes = {
items: React.PropTypes.array,
}
static defaultProps = {
items: [],
}
constructor(props) {
super(props)
this._pendingPickerObjs = {};
this.state = {
isLoggedIn: SalesforceEnv.isLoggedIn(),
focusedContact: FocusedContactsStore.focusedContact(),
}
}
componentWillMount() {
this._usubs = [
SalesforceActions.syncbackSuccess.listen(this._onObjectCreate),
SalesforceActions.salesforceWindowClosing.listen(this._onWinClose),
];
this.disposable = Rx.Observable.combineLatest([
Rx.Observable.fromStore(FocusedContactsStore),
Rx.Observable.fromStore(SalesforceEnv),
]).subscribe(() => {
this.setState({
isLoggedIn: SalesforceEnv.isLoggedIn(),
focusedContact: FocusedContactsStore.focusedContact(),
})
})
}
componentWillUnmount() {
this._pendingPickerObjs = {};
for (const usub of this._usubs) { usub() }
this.disposable.dispose()
}
/**
* When you create a new object with the picker, we drop a
* PendingSalesforceObject in the picker before closing the popover and
* unmounting the picker. If that objects ends up getting created, we
* want to make sure we catch that and finish the intended user action
* of manually relating the salesforce object.
*/
_onObjectCreate = ({objectType, objectId, contextData = {}} = {}) => {
const formId = contextData.formId
const threadIds = this._pendingPickerObjs[formId] || []
delete this._pendingPickerObjs[formId]
const tasks = threadIds.map((threadId) => {
Actions.recordUserEvent("Salesforce Manually Related", {
existingObject: false,
sObjectId: objectId,
sObjectType: objectType,
nylasObjectId: threadId,
nylasObjectType: "Thread",
});
return new ManuallyRelateSalesforceObjectTask({
sObjectId: objectId,
sObjectType: objectType,
nylasObjectId: threadId,
nylasObjectType: "Thread",
})
})
if (tasks.length > 0) Actions.queueTasks(tasks);
}
_onWinClose = ({contextData = {}, closingDueToObjectSuccess} = {}) => {
if (!closingDueToObjectSuccess) {
delete this._pendingPickerObjs[contextData.formId]
}
}
_emails(props) {
_.uniq(_.flatten(props.items.map((thread) => {
return _.pluck((thread.participants || []), "email")
})))
}
_openPopover = () => {
const buttonRect = ReactDOM.findDOMNode(this.refs.button).getBoundingClientRect()
Actions.openPopover(
,
{
originRect: buttonRect,
direction: 'down',
}
)
return
}
_onObjectsPicked = (pickerObjects = []) => {
const tasks = []
const threadIds = this.props.items.map(thread => thread.id)
for (const pickerObj of pickerObjects) {
if (pickerObj instanceof SalesforceObject) {
for (const threadId of threadIds) {
Actions.recordUserEvent("Salesforce Manually Related", {
existingObject: true,
sObjectId: pickerObj.id,
sObjectType: pickerObj.type,
nylasObjectId: threadId,
nylasObjectType: "Thread",
});
const task = new ManuallyRelateSalesforceObjectTask({
sObjectId: pickerObj.id,
sObjectType: pickerObj.type,
nylasObjectId: threadId,
nylasObjectType: "Thread",
})
tasks.push(task);
}
} else if (pickerObj instanceof PendingSalesforceObject) {
this._pendingPickerObjs[pickerObj.id] = threadIds
} else {
console.error(pickerObj)
throw new Error("Invalid picker object type")
}
}
if (tasks.length > 0) Actions.queueTasks(tasks);
}
_keymapHandlers() {
return {
"salesforce:show-relate-thread-popover": this._openPopover,
}
}
_menuItems() {
return [{
label: "Thread",
submenu: [{
label: "Relate With Salesforce Objects...",
command: "salesforce:show-relate-thread-popover",
position: "endof=thread-actions",
}],
}]
}
render() {
const title = "Relate thread to Salesforce objects"
return (
)
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/thread/salesforce-manually-relate-thread-popover.jsx
================================================
import React from 'react'
import _ from 'underscore'
import classNames from 'classnames'
import {RetinaImg} from 'nylas-component-kit'
import SmartFields from '../form/smart-fields'
import SalesforceLoginPrompt from '../shared-components/salesforce-login-prompt'
import SalesforceObjectPicker from '../form/salesforce-object-picker'
import {CORE_RELATEABLE_OBJECT_TYPES} from '../salesforce-constants'
const PICKER_ID = "manually-relate-thread-popover"
class SalesforceManuallyRelateThreadPopover extends React.Component {
static displayName = "SalesforceManuallyRelateThreadPopover"
static propTypes = {
threads: React.PropTypes.array,
isLoggedIn: React.PropTypes.bool,
focusedContact: React.PropTypes.object,
onObjectsPicked: React.PropTypes.func,
}
static containerStyles = {
order: 2,
flexShrink: 0,
}
constructor(props) {
super(props);
this.state = {
pickerValue: [],
}
}
_threadIds() {
return _.pluck(this.props.threads, "id")
}
_placeholder() {
return (
Create or search for objects
)
}
_onChange = (pickerObjects = []) => {
this.props.onObjectsPicked(pickerObjects);
this.setState({pickerValue: []})
}
_renderAssociationPicker() {
let focusedNylasContactData = null;
let company = null
if (this.props.focusedContact) {
company = SmartFields.getFieldFromClearbit(this.props.focusedContact, "Contact", "Company");
}
if (this.props.focusedContact) {
focusedNylasContactData = {
id: this.props.focusedContact.id,
name: this.props.focusedContact.name,
email: this.props.focusedContact.email,
}
}
return [
Relate Object to Thread ,
,
]
}
_renderSalesforce() {
if (!this.props.isLoggedIn) {
return
}
const classes = classNames({
"salesforce": true,
"salesforce-manually-relate-popover": true,
})
return (
{this._renderAssociationPicker()}
)
}
render() {
if (!this.props.threads) return false
return (
{this._renderSalesforce()}
)
}
}
export default SalesforceManuallyRelateThreadPopover
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/thread/salesforce-sync-label.jsx
================================================
import React from 'react';
import SalesforceIcon from '../shared-components/salesforce-icon'
import SalesforceActions from '../salesforce-actions'
import {CORE_RELATEABLE_OBJECT_TYPES} from '../salesforce-constants'
import * as relatedHelpers from '../related-object-helpers'
import * as metadataHelpers from '../metadata-helpers'
class SalesforceSyncLabel extends React.Component {
static displayName = 'SalesforceSyncLabel'
static containerRequired = false
static propTypes = {
thread: React.PropTypes.object,
}
constructor(props) {
super(props)
this.state = this._initialState(props)
}
componentDidMount() {
this._mounted = true;
this._setupDataSource(this.props)
}
componentWillReceiveProps(nextProps) {
this._setupDataSource(nextProps);
this.setState(this._initialState(nextProps))
}
componentWillUnmount() {
this._mounted = false;
if (this.disposable && this.disposable.dispose) {
this.disposable.dispose()
}
}
_initialState(props) {
return {
relatedObjects: relatedHelpers.relatedSObjectsForThread(props.thread),
}
}
_setupDataSource() {
if (this.disposable && this.disposable.dispose) {
this.disposable.dispose()
}
clearTimeout(this.observableTimeout)
this.observableTimeout = setTimeout(() => {
if (!this._mounted) return;
this.disposable = relatedHelpers.observeRelatedSObjectsForThread(this.props.thread).subscribe((relatedObjects) => {
this.setState({relatedObjects: relatedObjects})
})
}, 3000)
}
_requestEdit(object) {
SalesforceActions.openObjectForm({
objectId: object.id,
objectType: object.type,
objectInitialData: object,
})
}
render() {
const syncingWith = metadataHelpers.getSObjectsToSyncActivityTo(this.props.thread);
const objs = this.state.relatedObjects
.filter(o => CORE_RELATEABLE_OBJECT_TYPES.includes(o.type))
.map((sObject) => {
const syncing = syncingWith[sObject.id] ? "and syncing with " : ""
const title = `Related to ${syncing}${sObject.type}`
return (
)
})
return (
{objs}
)
}
}
export default SalesforceSyncLabel
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/thread/salesforce-sync-message-status.jsx
================================================
import {React} from 'nylas-exports'
import {PLUGIN_ID} from '../salesforce-constants'
import * as mdHelpers from '../metadata-helpers'
import SalesforceActions from '../salesforce-actions'
import SalesforceIcon from '../shared-components/salesforce-icon'
export default class SalesforceSyncMessageStatus extends React.Component {
static displayName = "SalesforceSyncMessageStatus";
static containerRequired = false;
static propTypes = {
message: React.PropTypes.object.isRequired,
};
static containerStyles = {
paddingTop: 4,
};
_getRelatedIds() {
const taskIds = []
const emailMessageIds = []
const clonedAs = mdHelpers.getClonedAs(this.props.message);
for (const relatedToId of Object.keys(clonedAs)) {
for (const clonedSObjectId of Object.keys(clonedAs[relatedToId])) {
const clonedObj = clonedAs[relatedToId][clonedSObjectId] || {}
if (clonedObj.type === "Task") taskIds.push(clonedSObjectId)
if (clonedObj.type === "EmailMessage") emailMessageIds.push(clonedSObjectId)
}
}
return {taskIds, emailMessageIds}
}
_hasRelatedSObject() {
const {taskIds, emailMessageIds} = this._getRelatedIds();
return taskIds.length > 0 || emailMessageIds.length > 0
}
_editActivityBtn(type, id) {
const onClick = () => {
SalesforceActions.openObjectForm({
objectId: id,
objectType: type,
})
}
return
}
_editEmailMessageFn(id) {
SalesforceActions.openObjectForm({
objectId: id,
objectType: "EmailMessage",
})
}
_isPendingSync() {
return (this.props.message.metadataForPluginId(PLUGIN_ID) || {}).pendingSync
}
_renderPendingSync() {
return Syncing to Salesforce…
}
render() {
if (this._isPendingSync()) return this._renderPendingSync();
if (!this._hasRelatedSObject()) return false;
const {taskIds, emailMessageIds} = this._getRelatedIds();
const id = emailMessageIds[0] || taskIds[0];
const tasks = taskIds.map((taskId) => {
return this._editActivityBtn("Task", taskId)
})
const emailMessages = emailMessageIds.map((emailMessageId) => {
return this._editActivityBtn("EmailMessage", emailMessageId)
})
if (!id) return false;
return (
Synced to Salesforce:
{tasks}
{emailMessages}
)
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/lib/thread/sync-thread-toggle.jsx
================================================
import React from 'react'
import _str from 'underscore.string'
import {Switch} from 'nylas-component-kit'
import {Actions} from 'nylas-exports'
import * as mdHelpers from '../metadata-helpers'
import SyncThreadActivityToSalesforceTask from '../tasks/sync-thread-activity-to-salesforce-task'
export default function SyncThreadToggle(props) {
const checked = mdHelpers.getSObjectsToSyncActivityTo(props.thread)[props.sObjectId]
const onChange = () => {
const newSObjectsToSync = []
const sObjectsToStopSyncing = []
const obj = {id: props.sObjectId, type: props.sObjectType}
let mixpanelEvent;
if (checked) {
mixpanelEvent = "Salesforce Thread Unsynced";
sObjectsToStopSyncing.push(obj)
} else {
mixpanelEvent = "Salesforce Thread Synced";
newSObjectsToSync.push(obj)
}
const task = new SyncThreadActivityToSalesforceTask({
threadId: props.thread.id,
threadClientId: props.thread.clientId,
newSObjectsToSync: newSObjectsToSync,
sObjectsToStopSyncing: sObjectsToStopSyncing,
})
Actions.queueTask(task);
Actions.recordUserEvent(mixpanelEvent, {
threadId: props.thread.id,
sObjectId: obj.id,
sObjectType: obj.type,
});
}
const objName = _str.titleize(_str.humanize(props.sObjectType))
const msgOn = `Upload all messages to this ${objName}`
const msgOff = `Remove all messages from this ${objName}`
const title = checked ? msgOff : msgOn
return (
Sync:
)
}
SyncThreadToggle.displayName = "SyncThreadToggle"
SyncThreadToggle.propTypes = {
thread: React.PropTypes.object,
sObjectId: React.PropTypes.string,
sObjectType: React.PropTypes.string,
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/menus/salesforce.json
================================================
{
"menu": [
{
"label": "Salesforce",
"submenu": [
{ "label": "Refresh Salesforce Data", "command": "salesforce:sync" },
{ "type": "separator" },
{ "label": "Connect Salesforce",
"command": "salesforce:connect",
"hideWhenDisabled": true
},
{ "label": "Disconnect Salesforce",
"command": "salesforce:disconnect",
"hideWhenDisabled": true
}
]
}
]
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/package.json
================================================
{
"name": "salesforce",
"title": "Salesforce",
"description": "Nylas Mail and Salesforce integration",
"isHiddenOnPluginsPage": true,
"isOptional": true,
"icon": "./icon.png",
"version": "0.1.0",
"main": "./lib/main",
"license": "Proprietary",
"engines": {
"nylas": "*"
},
"windowTypes": {
"default": true,
"composer": true,
"work": true,
"SalesforceObjectForm": true
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/spec/fixtures/opportunity-layouts-alt.json
================================================
{"buttonLayoutSection":{"detailButtons":[{"custom":false,"label":"Edit","name":"Edit"},{"custom":false,"label":"Delete","name":"Delete"},{"custom":false,"label":"Clone","name":"Clone"},{"custom":false,"label":"Sharing","name":"Share"}]},"detailLayoutSections":[{"columns":2,"heading":"Opportunity Information","layoutRows":[{"layoutItems":[{"editable":false,"label":"Opportunity Name","layoutComponents":[{"details":{"autoNumber":false,"byteLength":360,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":true,"inlineHelpText":null,"label":"Name","length":120,"name":"Name","nameField":true,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"string","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":1,"type":"Field","value":"Name"}],"placeholder":false,"required":false},{"editable":false,"label":"Opportunity Owner","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Owner ID","length":18,"name":"OwnerId","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":["User"],"relationshipName":"Owner","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":7,"type":"Field","value":"OwnerId"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":false,"label":"Account Name","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Account ID","length":18,"name":"AccountId","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"referenceTo":["Account"],"relationshipName":"Account","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":2,"type":"Field","value":"AccountId"}],"placeholder":false,"required":false},{"editable":false,"label":"Close Date","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Close Date","length":0,"name":"CloseDate","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:date","sortable":true,"type":"date","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":8,"type":"Field","value":"CloseDate"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":false,"label":"Type","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Opportunity Type","length":40,"name":"Type","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"Existing Business","validFor":null,"value":"Existing Business"},{"active":true,"defaultValue":false,"label":"New Business","validFor":null,"value":"New Business"},{"active":true,"defaultValue":false,"label":"Other Business","validFor":null,"value":"Other Business"},{"active":true,"defaultValue":false,"label":"Not Your Business","validFor":null,"value":"Not Your Business"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":3,"type":"Field","value":"Type"}],"placeholder":false,"required":false},{"editable":false,"label":"Stage","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Stage","length":40,"name":"StageName","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[{"active":true,"defaultValue":false,"label":"Prospecting","validFor":null,"value":"Prospecting"},{"active":true,"defaultValue":false,"label":"Qualification","validFor":null,"value":"Qualification"},{"active":true,"defaultValue":false,"label":"Needs Analysis","validFor":null,"value":"Needs Analysis"},{"active":true,"defaultValue":false,"label":"Value Proposition","validFor":null,"value":"Value Proposition"},{"active":true,"defaultValue":false,"label":"Id. Decision Makers","validFor":null,"value":"Id. Decision Makers"},{"active":true,"defaultValue":false,"label":"Perception Analysis","validFor":null,"value":"Perception Analysis"},{"active":true,"defaultValue":false,"label":"Proposal/Price Quote","validFor":null,"value":"Proposal/Price Quote"},{"active":true,"defaultValue":false,"label":"Negotiation/Review","validFor":null,"value":"Negotiation/Review"},{"active":true,"defaultValue":false,"label":"Closed Won","validFor":null,"value":"Closed Won"},{"active":true,"defaultValue":false,"label":"Closed Lost","validFor":null,"value":"Closed Lost"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":9,"type":"Field","value":"StageName"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":false,"label":"Primary Campaign Source","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Campaign ID","length":18,"name":"CampaignId","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"referenceTo":["Campaign"],"relationshipName":"Campaign","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":4,"type":"Field","value":"CampaignId"}],"placeholder":false,"required":false},{"editable":false,"label":"Probability (%)","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Probability (%)","length":0,"name":"Probability","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":3,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:double","sortable":true,"type":"percent","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":10,"type":"Field","value":"Probability"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":false,"label":"CustomOppType","layoutComponents":[{"details":{"autoNumber":false,"byteLength":765,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"CustomOppType","length":255,"name":"CustomOppType__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":true,"label":"Outbound Sales","validFor":null,"value":"Outbound Sales"},{"active":true,"defaultValue":false,"label":"Incoming Requests","validFor":null,"value":"Incoming Requests"},{"active":true,"defaultValue":false,"label":"Customer Support","validFor":null,"value":"Customer Support"},{"active":true,"defaultValue":false,"label":"Developer Platform","validFor":null,"value":"Developer Platform"},{"active":true,"defaultValue":false,"label":"Recruiting","validFor":null,"value":"Recruiting"},{"active":true,"defaultValue":false,"label":"Events","validFor":null,"value":"Events"},{"active":true,"defaultValue":false,"label":"Office Operations","validFor":null,"value":"Office Operations"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":5,"type":"Field","value":"CustomOppType__c"}],"placeholder":false,"required":false},{"editable":false,"label":"Amount","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Amount","length":0,"name":"Amount","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":18,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":2,"soapType":"xsd:double","sortable":true,"type":"currency","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":11,"type":"Field","value":"Amount"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":false,"label":"CoolnessPercent","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":"50","defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"CoolnessPercent","length":0,"name":"CoolnessPercent__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":5,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":2,"soapType":"xsd:double","sortable":true,"type":"percent","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":6,"type":"Field","value":"CoolnessPercent__c"}],"placeholder":false,"required":false},{"editable":false,"label":"Forecast Category","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Forecast Category","length":40,"name":"ForecastCategoryName","nameField":false,"namePointing":false,"nillable":true,"permissionable":false,"picklistValues":[{"active":true,"defaultValue":false,"label":"Omitted","validFor":null,"value":"Omitted"},{"active":true,"defaultValue":false,"label":"Pipeline","validFor":null,"value":"Pipeline"},{"active":true,"defaultValue":false,"label":"Best Case","validFor":null,"value":"Best Case"},{"active":true,"defaultValue":false,"label":"Commit","validFor":null,"value":"Commit"},{"active":true,"defaultValue":false,"label":"Closed","validFor":null,"value":"Closed"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":true,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":12,"type":"Field","value":"ForecastCategoryName"}],"placeholder":false,"required":false}],"numItems":2}],"rows":6,"useCollapsibleSection":false,"useHeading":false},{"columns":2,"heading":"Additional Information","layoutRows":[{"layoutItems":[{"editable":false,"label":"Next Step","layoutComponents":[{"details":{"autoNumber":false,"byteLength":765,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Next Step","length":255,"name":"NextStep","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"string","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":25,"type":"Field","value":"NextStep"}],"placeholder":false,"required":false},{"editable":false,"label":"Lead Source","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Lead Source","length":40,"name":"LeadSource","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"Advertisement","validFor":null,"value":"Advertisement"},{"active":true,"defaultValue":false,"label":"Employee Referral","validFor":null,"value":"Employee Referral"},{"active":true,"defaultValue":false,"label":"External Referral","validFor":null,"value":"External Referral"},{"active":true,"defaultValue":false,"label":"Partner","validFor":null,"value":"Partner"},{"active":true,"defaultValue":false,"label":"Public Relations","validFor":null,"value":"Public Relations"},{"active":true,"defaultValue":false,"label":"Seminar - Internal","validFor":null,"value":"Seminar - Internal"},{"active":true,"defaultValue":false,"label":"Seminar - Partner","validFor":null,"value":"Seminar - Partner"},{"active":true,"defaultValue":false,"label":"Trade Show","validFor":null,"value":"Trade Show"},{"active":true,"defaultValue":false,"label":"Web","validFor":null,"value":"Web"},{"active":true,"defaultValue":false,"label":"Word of mouth","validFor":null,"value":"Word of mouth"},{"active":true,"defaultValue":false,"label":"Other","validFor":null,"value":"Other"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":26,"type":"Field","value":"LeadSource"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":false,"label":"Description","layoutComponents":[{"details":{"autoNumber":false,"byteLength":96000,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":false,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Description","length":32000,"name":"Description","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":false,"type":"textarea","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":4,"tabOrder":27,"type":"Field","value":"Description"}],"placeholder":false,"required":false},{"editable":false,"label":"","layoutComponents":[],"placeholder":true,"required":false}],"numItems":2}],"rows":2,"useCollapsibleSection":true,"useHeading":true},{"columns":2,"heading":"System Information","layoutRows":[{"layoutItems":[{"editable":false,"label":"Created By","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":false,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Created By ID","length":18,"name":"CreatedById","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":["User"],"relationshipName":"CreatedBy","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":false,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":31,"type":"Field","value":"CreatedById"},{"displayLines":1,"tabOrder":32,"type":"Separator","value":", "},{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":false,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Created Date","length":0,"name":"CreatedDate","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:dateTime","sortable":true,"type":"datetime","unique":false,"updateable":false,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":33,"type":"Field","value":"CreatedDate"}],"placeholder":false,"required":false},{"editable":false,"label":"Last Modified By","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":false,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Last Modified By ID","length":18,"name":"LastModifiedById","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":["User"],"relationshipName":"LastModifiedBy","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":false,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":34,"type":"Field","value":"LastModifiedById"},{"displayLines":1,"tabOrder":35,"type":"Separator","value":", "},{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":false,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Last Modified Date","length":0,"name":"LastModifiedDate","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:dateTime","sortable":true,"type":"datetime","unique":false,"updateable":false,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":36,"type":"Field","value":"LastModifiedDate"}],"placeholder":false,"required":false}],"numItems":2}],"rows":1,"useCollapsibleSection":true,"useHeading":true}],"editLayoutSections":[{"columns":2,"heading":"Opportunity Information","layoutRows":[{"layoutItems":[{"editable":true,"label":"Opportunity Name","layoutComponents":[{"details":{"autoNumber":false,"byteLength":360,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":true,"inlineHelpText":null,"label":"Name","length":120,"name":"Name","nameField":true,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"string","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":13,"type":"Field","value":"Name"}],"placeholder":false,"required":true},{"editable":false,"label":"Opportunity Owner","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Owner ID","length":18,"name":"OwnerId","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":["User"],"relationshipName":"Owner","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":19,"type":"Field","value":"OwnerId"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":true,"label":"Account Name","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Account ID","length":18,"name":"AccountId","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"referenceTo":["Account"],"relationshipName":"Account","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":14,"type":"Field","value":"AccountId"}],"placeholder":false,"required":true},{"editable":true,"label":"Close Date","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Close Date","length":0,"name":"CloseDate","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:date","sortable":true,"type":"date","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":20,"type":"Field","value":"CloseDate"}],"placeholder":false,"required":true}],"numItems":2},{"layoutItems":[{"editable":true,"label":"Type","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Opportunity Type","length":40,"name":"Type","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"Existing Business","validFor":null,"value":"Existing Business"},{"active":true,"defaultValue":false,"label":"New Business","validFor":null,"value":"New Business"},{"active":true,"defaultValue":false,"label":"Other Business","validFor":null,"value":"Other Business"},{"active":true,"defaultValue":false,"label":"Not Your Business","validFor":null,"value":"Not Your Business"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":15,"type":"Field","value":"Type"}],"placeholder":false,"required":false},{"editable":true,"label":"Stage","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Stage","length":40,"name":"StageName","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[{"active":true,"defaultValue":false,"label":"Prospecting","validFor":null,"value":"Prospecting"},{"active":true,"defaultValue":false,"label":"Qualification","validFor":null,"value":"Qualification"},{"active":true,"defaultValue":false,"label":"Needs Analysis","validFor":null,"value":"Needs Analysis"},{"active":true,"defaultValue":false,"label":"Value Proposition","validFor":null,"value":"Value Proposition"},{"active":true,"defaultValue":false,"label":"Id. Decision Makers","validFor":null,"value":"Id. Decision Makers"},{"active":true,"defaultValue":false,"label":"Perception Analysis","validFor":null,"value":"Perception Analysis"},{"active":true,"defaultValue":false,"label":"Proposal/Price Quote","validFor":null,"value":"Proposal/Price Quote"},{"active":true,"defaultValue":false,"label":"Negotiation/Review","validFor":null,"value":"Negotiation/Review"},{"active":true,"defaultValue":false,"label":"Closed Won","validFor":null,"value":"Closed Won"},{"active":true,"defaultValue":false,"label":"Closed Lost","validFor":null,"value":"Closed Lost"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":21,"type":"Field","value":"StageName"}],"placeholder":false,"required":true}],"numItems":2},{"layoutItems":[{"editable":true,"label":"Primary Campaign Source","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Campaign ID","length":18,"name":"CampaignId","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"referenceTo":["Campaign"],"relationshipName":"Campaign","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":16,"type":"Field","value":"CampaignId"}],"placeholder":false,"required":false},{"editable":true,"label":"Probability (%)","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Probability (%)","length":0,"name":"Probability","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":3,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:double","sortable":true,"type":"percent","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":22,"type":"Field","value":"Probability"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":true,"label":"CustomOppType","layoutComponents":[{"details":{"autoNumber":false,"byteLength":765,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"CustomOppType","length":255,"name":"CustomOppType__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":true,"label":"Outbound Sales","validFor":null,"value":"Outbound Sales"},{"active":true,"defaultValue":false,"label":"Incoming Requests","validFor":null,"value":"Incoming Requests"},{"active":true,"defaultValue":false,"label":"Customer Support","validFor":null,"value":"Customer Support"},{"active":true,"defaultValue":false,"label":"Developer Platform","validFor":null,"value":"Developer Platform"},{"active":true,"defaultValue":false,"label":"Recruiting","validFor":null,"value":"Recruiting"},{"active":true,"defaultValue":false,"label":"Events","validFor":null,"value":"Events"},{"active":true,"defaultValue":false,"label":"Office Operations","validFor":null,"value":"Office Operations"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":17,"type":"Field","value":"CustomOppType__c"}],"placeholder":false,"required":false},{"editable":true,"label":"Amount","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Amount","length":0,"name":"Amount","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":18,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":2,"soapType":"xsd:double","sortable":true,"type":"currency","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":23,"type":"Field","value":"Amount"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":true,"label":"CoolnessPercent","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":"50","defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"CoolnessPercent","length":0,"name":"CoolnessPercent__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":5,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":2,"soapType":"xsd:double","sortable":true,"type":"percent","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":18,"type":"Field","value":"CoolnessPercent__c"}],"placeholder":false,"required":false},{"editable":true,"label":"Forecast Category","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Forecast Category","length":40,"name":"ForecastCategoryName","nameField":false,"namePointing":false,"nillable":true,"permissionable":false,"picklistValues":[{"active":true,"defaultValue":false,"label":"Omitted","validFor":null,"value":"Omitted"},{"active":true,"defaultValue":false,"label":"Pipeline","validFor":null,"value":"Pipeline"},{"active":true,"defaultValue":false,"label":"Best Case","validFor":null,"value":"Best Case"},{"active":true,"defaultValue":false,"label":"Commit","validFor":null,"value":"Commit"},{"active":true,"defaultValue":false,"label":"Closed","validFor":null,"value":"Closed"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":true,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":24,"type":"Field","value":"ForecastCategoryName"}],"placeholder":false,"required":true}],"numItems":2}],"rows":6,"useCollapsibleSection":false,"useHeading":true},{"columns":2,"heading":"Additional Information","layoutRows":[{"layoutItems":[{"editable":true,"label":"Next Step","layoutComponents":[{"details":{"autoNumber":false,"byteLength":765,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Next Step","length":255,"name":"NextStep","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"string","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":28,"type":"Field","value":"NextStep"}],"placeholder":false,"required":false},{"editable":true,"label":"Lead Source","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":true,"groupable":true,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Lead Source","length":40,"name":"LeadSource","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"Advertisement","validFor":null,"value":"Advertisement"},{"active":true,"defaultValue":false,"label":"Employee Referral","validFor":null,"value":"Employee Referral"},{"active":true,"defaultValue":false,"label":"External Referral","validFor":null,"value":"External Referral"},{"active":true,"defaultValue":false,"label":"Partner","validFor":null,"value":"Partner"},{"active":true,"defaultValue":false,"label":"Public Relations","validFor":null,"value":"Public Relations"},{"active":true,"defaultValue":false,"label":"Seminar - Internal","validFor":null,"value":"Seminar - Internal"},{"active":true,"defaultValue":false,"label":"Seminar - Partner","validFor":null,"value":"Seminar - Partner"},{"active":true,"defaultValue":false,"label":"Trade Show","validFor":null,"value":"Trade Show"},{"active":true,"defaultValue":false,"label":"Web","validFor":null,"value":"Web"},{"active":true,"defaultValue":false,"label":"Word of mouth","validFor":null,"value":"Word of mouth"},{"active":true,"defaultValue":false,"label":"Other","validFor":null,"value":"Other"}],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":29,"type":"Field","value":"LeadSource"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editable":true,"label":"Description","layoutComponents":[{"details":{"autoNumber":false,"byteLength":96000,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"filterable":false,"groupable":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Description","length":32000,"name":"Description","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":false,"type":"textarea","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":4,"tabOrder":30,"type":"Field","value":"Description"}],"placeholder":false,"required":false},{"editable":false,"label":"","layoutComponents":[],"placeholder":true,"required":false}],"numItems":2}],"rows":2,"useCollapsibleSection":false,"useHeading":true}],"id":"00hj0000000wT8jAAE","multirowEditLayoutSections":[],"offlineLinks":[],"quickActionList":{"quickActionListItems":[{"iconUrl":null,"label":"Post","miniIconUrl":"","quickActionName":"FeedItem.TextPost","targetSobjectType":null,"type":"Post"},{"iconUrl":null,"label":"File","miniIconUrl":"","quickActionName":"FeedItem.ContentPost","targetSobjectType":null,"type":"Post"},{"iconUrl":"https://na16.salesforce.com/img/icon/home32.png","label":"Task","miniIconUrl":"https://na16.salesforce.com/img/icon/tasks16.png","quickActionName":"Opportunity.Task","targetSobjectType":"Task","type":"Create"},{"iconUrl":"https://na16.salesforce.com/img/icon/home32.png","label":"Log a Call","miniIconUrl":"https://na16.salesforce.com/img/icon/tasks16.png","quickActionName":"Opportunity.Log_a_Call","targetSobjectType":"Task","type":"Create"},{"iconUrl":"https://na16.salesforce.com/img/icon/home32.png","label":"Event","miniIconUrl":"https://na16.salesforce.com/img/icon/calendar16.png","quickActionName":"Opportunity.Event","targetSobjectType":"Event","type":"Create"},{"iconUrl":null,"label":"Link","miniIconUrl":"","quickActionName":"FeedItem.LinkPost","targetSobjectType":null,"type":"Post"},{"iconUrl":null,"label":"Poll","miniIconUrl":"","quickActionName":"FeedItem.PollPost","targetSobjectType":null,"type":"Post"}]},"relatedLists":[{"columns":[{"field":"OpenActivity.Subject","format":null,"label":"Subject","lookupId":"Id","name":"Subject"},{"field":"Name.Name","format":null,"label":"Name","lookupId":"WhoId","name":"Who.Name"},{"field":"OpenActivity.IsTask","format":null,"label":"Task","lookupId":null,"name":"IsTask"},{"field":"OpenActivity.ActivityDate","format":"date","label":"Due Date","lookupId":null,"name":"ActivityDate"},{"field":"OpenActivity.Status","format":null,"label":"Status","lookupId":null,"name":"toLabel(Status)"},{"field":"OpenActivity.Priority","format":null,"label":"Priority","lookupId":null,"name":"toLabel(Priority)"},{"field":"User.Name","format":null,"label":"Assigned To","lookupId":"Owner.Id","name":"Owner.Name"}],"custom":false,"field":"WhatId","label":"Open Activities","limitRows":5,"name":"OpenActivities","sobject":"OpenActivity","sort":[{"ascending":true,"column":"ActivityDate"},{"ascending":false,"column":"LastModifiedDate"}]},{"columns":[{"field":"ActivityHistory.Subject","format":null,"label":"Subject","lookupId":"Id","name":"Subject"},{"field":"Name.Name","format":null,"label":"Name","lookupId":"WhoId","name":"Who.Name"},{"field":"ActivityHistory.IsTask","format":null,"label":"Task","lookupId":null,"name":"IsTask"},{"field":"ActivityHistory.ActivityDate","format":"date","label":"Due Date","lookupId":null,"name":"ActivityDate"},{"field":"User.Name","format":null,"label":"Assigned To","lookupId":"Owner.Id","name":"Owner.Name"},{"field":"ActivityHistory.LastModifiedDate","format":"datetime","label":"Last Modified Date/Time","lookupId":null,"name":"LastModifiedDate"}],"custom":false,"field":"WhatId","label":"Activity History","limitRows":5,"name":"ActivityHistories","sobject":"ActivityHistory","sort":[{"ascending":false,"column":"ActivityDate"},{"ascending":false,"column":"LastModifiedDate"}]},{"columns":[{"field":"NoteAndAttachment.IsNote","format":null,"label":"Type","lookupId":null,"name":"IsNote"},{"field":"Note.Title","format":null,"label":"Title","lookupId":null,"name":"Title"},{"field":"Note.LastModifiedDate","format":null,"label":"Last Modified","lookupId":null,"name":"LastModifiedDate"},{"field":"Note.CreatedById","format":null,"label":"Created By","lookupId":null,"name":"CreatedBy.Name"}],"custom":false,"field":"ParentId","label":"Notes & Attachments","limitRows":5,"name":"NotesAndAttachments","sobject":"NoteAndAttachment","sort":[{"ascending":false,"column":"LastModifiedDate"}]},{"columns":[{"field":"Contact.Name","format":null,"label":"Contact Name","lookupId":null,"name":"Contact.Name"},{"field":"Account.Name","format":null,"label":"Account Name","lookupId":null,"name":"Contact.Account.Name"},{"field":"Contact.Email","format":null,"label":"Email","lookupId":null,"name":"Contact.Email"},{"field":"Contact.Phone","format":null,"label":"Phone","lookupId":null,"name":"Contact.Phone"},{"field":"OpportunityContactRole.Role","format":null,"label":"Role","lookupId":null,"name":"Role"},{"field":"OpportunityContactRole.IsPrimary","format":null,"label":"Primary","lookupId":null,"name":"IsPrimary"}],"custom":false,"field":"OpportunityId","label":"Contact Roles","limitRows":5,"name":"OpportunityContactRoles","sobject":"OpportunityContactRole","sort":[{"ascending":true,"column":"Contact.Name"}]},{"columns":[{"field":"Product2.Name","format":null,"label":"Product","lookupId":null,"name":"PricebookEntry.Product2.Name"},{"field":"OpportunityLineItem.Quantity","format":null,"label":"Quantity","lookupId":null,"name":"Quantity"},{"field":"OpportunityLineItem.UnitPrice","format":null,"label":"Sales Price","lookupId":null,"name":"UnitPrice"},{"field":"OpportunityLineItem.ServiceDate","format":"date","label":"Date","lookupId":null,"name":"ServiceDate"},{"field":"OpportunityLineItem.Description","format":null,"label":"Line Description","lookupId":null,"name":"Description"},{"field":"PricebookEntry.UnitPrice","format":null,"label":"List Price","lookupId":null,"name":"PricebookEntry.UnitPrice"}],"custom":false,"field":"OpportunityId","label":"Products","limitRows":5,"name":"OpportunityLineItems","sobject":"OpportunityLineItem","sort":[{"ascending":true,"column":"SortOrder"}]},{"columns":[{"field":"Quote.QuoteNumber","format":null,"label":"Quote Number","lookupId":"Id","name":"QuoteNumber"},{"field":"Quote.Name","format":null,"label":"Quote Name","lookupId":"Id","name":"Name"},{"field":"Quote.IsSyncing","format":null,"label":"Syncing","lookupId":null,"name":"IsSyncing"},{"field":"Quote.ExpirationDate","format":"date","label":"Expiration Date","lookupId":null,"name":"ExpirationDate"},{"field":"Quote.Discount","format":null,"label":"Discount","lookupId":null,"name":"Discount"},{"field":"Quote.GrandTotal","format":null,"label":"Grand Total","lookupId":null,"name":"GrandTotal"},{"field":"User.Name","format":null,"label":"Created By","lookupId":"CreatedBy.Id","name":"CreatedBy.Name"}],"custom":false,"field":"OpportunityId","label":"Quotes","limitRows":5,"name":"Quotes","sobject":"Quote","sort":[{"ascending":true,"column":"Name"}]}]}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/spec/fixtures/opportunity-layouts.json
================================================
{"layouts":[{"buttonLayoutSection":{"detailButtons":[{"behavior":null,"colors":[{"color":"1DCCBF","context":"primary","theme":"theme4"}],"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/edit.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/edit_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/edit_120.png","width":120}],"label":"Edit","menubar":false,"name":"Edit","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":[{"color":"E6717C","context":"primary","theme":"theme4"}],"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/delete.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/delete_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/delete_120.png","width":120}],"label":"Delete","menubar":false,"name":"Delete","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":[{"color":"6CA1E9","context":"primary","theme":"theme4"}],"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/clone.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/clone_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/clone_120.png","width":120}],"label":"Clone","menubar":false,"name":"Clone","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"Sharing","menubar":false,"name":"Share","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null}]},"detailLayoutSections":[{"columns":2,"heading":"Opportunity Information","layoutRows":[{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Opportunity Name","layoutComponents":[{"details":{"autoNumber":false,"byteLength":360,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":true,"inlineHelpText":null,"label":"Name","length":120,"mask":null,"maskType":null,"name":"Name","nameField":true,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"string","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":1,"type":"Field","value":"Name"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"Opportunity Owner","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Owner ID","length":18,"mask":null,"maskType":null,"name":"OwnerId","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["User"],"relationshipName":"Owner","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":8,"type":"Field","value":"OwnerId"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Account Name","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Account ID","length":18,"mask":null,"maskType":null,"name":"AccountId","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["Account"],"relationshipName":"Account","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":2,"type":"Field","value":"AccountId"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"Close Date","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Close Date","length":0,"mask":null,"maskType":null,"name":"CloseDate","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:date","sortable":true,"type":"date","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":9,"type":"Field","value":"CloseDate"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Type","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Opportunity Type","length":40,"mask":null,"maskType":null,"name":"Type","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"Existing Business","validFor":null,"value":"Existing Business"},{"active":true,"defaultValue":false,"label":"New Business","validFor":null,"value":"New Business"},{"active":true,"defaultValue":false,"label":"Other Business","validFor":null,"value":"Other Business"},{"active":true,"defaultValue":false,"label":"Not Your Business","validFor":null,"value":"Not Your Business"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":3,"type":"Field","value":"Type"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"Stage","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Stage","length":40,"mask":null,"maskType":null,"name":"StageName","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[{"active":true,"defaultValue":false,"label":"Prospecting","validFor":null,"value":"Prospecting"},{"active":true,"defaultValue":false,"label":"Qualification","validFor":null,"value":"Qualification"},{"active":true,"defaultValue":false,"label":"Needs Analysis","validFor":null,"value":"Needs Analysis"},{"active":true,"defaultValue":false,"label":"Value Proposition","validFor":null,"value":"Value Proposition"},{"active":true,"defaultValue":false,"label":"Id. Decision Makers","validFor":null,"value":"Id. Decision Makers"},{"active":true,"defaultValue":false,"label":"Perception Analysis","validFor":null,"value":"Perception Analysis"},{"active":true,"defaultValue":false,"label":"Proposal/Price Quote","validFor":null,"value":"Proposal/Price Quote"},{"active":true,"defaultValue":false,"label":"Negotiation/Review","validFor":null,"value":"Negotiation/Review"},{"active":true,"defaultValue":false,"label":"Closed Won","validFor":null,"value":"Closed Won"},{"active":true,"defaultValue":false,"label":"Closed Lost","validFor":null,"value":"Closed Lost"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":10,"type":"Field","value":"StageName"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Primary Campaign Source","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Campaign ID","length":18,"mask":null,"maskType":null,"name":"CampaignId","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["Campaign"],"relationshipName":"Campaign","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":4,"type":"Field","value":"CampaignId"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"Probability (%)","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Probability (%)","length":0,"mask":null,"maskType":null,"name":"Probability","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":3,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:double","sortable":true,"type":"percent","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":11,"type":"Field","value":"Probability"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"CustomOppType","layoutComponents":[{"details":{"autoNumber":false,"byteLength":765,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"CustomOppType","length":255,"mask":null,"maskType":null,"name":"CustomOppType__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":true,"label":"Outbound Sales","validFor":null,"value":"Outbound Sales"},{"active":true,"defaultValue":false,"label":"Incoming Requests","validFor":null,"value":"Incoming Requests"},{"active":true,"defaultValue":false,"label":"Customer Support","validFor":null,"value":"Customer Support"},{"active":true,"defaultValue":false,"label":"Developer Platform","validFor":null,"value":"Developer Platform"},{"active":true,"defaultValue":false,"label":"Recruiting","validFor":null,"value":"Recruiting"},{"active":true,"defaultValue":false,"label":"Events","validFor":null,"value":"Events"},{"active":true,"defaultValue":false,"label":"Office Operations","validFor":null,"value":"Office Operations"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":5,"type":"Field","value":"CustomOppType__c"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"","layoutComponents":[{"displayLines":1,"tabOrder":12,"type":"EmptySpace","value":null}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"CoolnessPercent","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":"50","defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"CoolnessPercent","length":0,"mask":null,"maskType":null,"name":"CoolnessPercent__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":5,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":2,"soapType":"xsd:double","sortable":true,"type":"percent","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":6,"type":"Field","value":"CoolnessPercent__c"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"Amount","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Amount","length":0,"mask":null,"maskType":null,"name":"Amount","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":18,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":2,"soapType":"xsd:double","sortable":true,"type":"currency","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":13,"type":"Field","value":"Amount"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Sell To","layoutComponents":[{"details":{"autoNumber":false,"byteLength":4099,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":"Select all that apply","label":"Sell To","length":4099,"mask":null,"maskType":null,"name":"All_the_things__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"San Francisco","validFor":null,"value":"San Francisco"},{"active":true,"defaultValue":false,"label":"Boston","validFor":null,"value":"Boston"},{"active":true,"defaultValue":false,"label":"London","validFor":null,"value":"London"},{"active":true,"defaultValue":false,"label":"Tokyo","validFor":null,"value":"Tokyo"},{"active":true,"defaultValue":false,"label":"Shanghai","validFor":null,"value":"Shanghai"},{"active":true,"defaultValue":false,"label":"San Diego","validFor":null,"value":"San Diego"}],"precision":4,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":false,"type":"multipicklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":4,"tabOrder":7,"type":"Field","value":"All_the_things__c"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"Forecast Category","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Forecast Category","length":40,"mask":null,"maskType":null,"name":"ForecastCategoryName","nameField":false,"namePointing":false,"nillable":true,"permissionable":false,"picklistValues":[{"active":true,"defaultValue":false,"label":"Omitted","validFor":null,"value":"Omitted"},{"active":true,"defaultValue":false,"label":"Pipeline","validFor":null,"value":"Pipeline"},{"active":true,"defaultValue":false,"label":"Best Case","validFor":null,"value":"Best Case"},{"active":true,"defaultValue":false,"label":"Commit","validFor":null,"value":"Commit"},{"active":true,"defaultValue":false,"label":"Closed","validFor":null,"value":"Closed"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":true,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":14,"type":"Field","value":"ForecastCategoryName"}],"placeholder":false,"required":false}],"numItems":2}],"rows":7,"tabOrder":"TopToBottom","useCollapsibleSection":false,"useHeading":false},{"columns":2,"heading":"Additional Information","layoutRows":[{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Next Step","layoutComponents":[{"details":{"autoNumber":false,"byteLength":765,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Next Step","length":255,"mask":null,"maskType":null,"name":"NextStep","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"string","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":29,"type":"Field","value":"NextStep"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"Lead Source","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Lead Source","length":40,"mask":null,"maskType":null,"name":"LeadSource","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"Advertisement","validFor":null,"value":"Advertisement"},{"active":true,"defaultValue":false,"label":"Employee Referral","validFor":null,"value":"Employee Referral"},{"active":true,"defaultValue":false,"label":"External Referral","validFor":null,"value":"External Referral"},{"active":true,"defaultValue":false,"label":"Partner","validFor":null,"value":"Partner"},{"active":true,"defaultValue":false,"label":"Public Relations","validFor":null,"value":"Public Relations"},{"active":true,"defaultValue":false,"label":"Seminar - Internal","validFor":null,"value":"Seminar - Internal"},{"active":true,"defaultValue":false,"label":"Seminar - Partner","validFor":null,"value":"Seminar - Partner"},{"active":true,"defaultValue":false,"label":"Trade Show","validFor":null,"value":"Trade Show"},{"active":true,"defaultValue":false,"label":"Web","validFor":null,"value":"Web"},{"active":true,"defaultValue":false,"label":"Word of mouth","validFor":null,"value":"Word of mouth"},{"active":true,"defaultValue":false,"label":"Other","validFor":null,"value":"Other"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":30,"type":"Field","value":"LeadSource"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Description","layoutComponents":[{"details":{"autoNumber":false,"byteLength":96000,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":"plaintextarea","filterable":false,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Description","length":32000,"mask":null,"maskType":null,"name":"Description","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":false,"type":"textarea","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":4,"tabOrder":31,"type":"Field","value":"Description"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"","layoutComponents":[],"placeholder":true,"required":false}],"numItems":2}],"rows":2,"tabOrder":"LeftToRight","useCollapsibleSection":true,"useHeading":true},{"columns":1,"heading":"Even More Info","layoutRows":[{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Checkout","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":"We encourage you to check more than one","label":"Checkout","length":0,"mask":null,"maskType":null,"name":"Checkout__c","nameField":false,"namePointing":false,"nillable":false,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:boolean","sortable":true,"type":"boolean","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":35,"type":"Field","value":"Checkout__c"}],"placeholder":false,"required":false}],"numItems":1},{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Send Date Time","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":"Please pick a time too","label":"Send Date Time","length":0,"mask":null,"maskType":null,"name":"Send_Date_Time__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:dateTime","sortable":true,"type":"datetime","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":36,"type":"Field","value":"Send_Date_Time__c"}],"placeholder":false,"required":false}],"numItems":1}],"rows":2,"tabOrder":"TopToBottom","useCollapsibleSection":true,"useHeading":true},{"columns":1,"heading":"Description Information","layoutRows":[{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Other thoughts","layoutComponents":[{"details":{"autoNumber":false,"byteLength":98304,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":"\"Once upon a time…\"","defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":"plaintextarea","filterable":false,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":"More things to say","label":"Other thoughts","length":32768,"mask":null,"maskType":null,"name":"Other_thoughts__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":false,"type":"textarea","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":10,"tabOrder":39,"type":"Field","value":"Other_thoughts__c"}],"placeholder":false,"required":false}],"numItems":1}],"rows":1,"tabOrder":"TopToBottom","useCollapsibleSection":false,"useHeading":false},{"columns":2,"heading":"System Information","layoutRows":[{"layoutItems":[{"editableForNew":false,"editableForUpdate":false,"label":"Created By","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":false,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Created By ID","length":18,"mask":null,"maskType":null,"name":"CreatedById","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["User"],"relationshipName":"CreatedBy","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":false,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":41,"type":"Field","value":"CreatedById"},{"displayLines":1,"tabOrder":42,"type":"Separator","value":", "},{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":false,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Created Date","length":0,"mask":null,"maskType":null,"name":"CreatedDate","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:dateTime","sortable":true,"type":"datetime","unique":false,"updateable":false,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":43,"type":"Field","value":"CreatedDate"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"Last Modified By","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":false,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Last Modified By ID","length":18,"mask":null,"maskType":null,"name":"LastModifiedById","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["User"],"relationshipName":"LastModifiedBy","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":false,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":44,"type":"Field","value":"LastModifiedById"},{"displayLines":1,"tabOrder":45,"type":"Separator","value":", "},{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":false,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Last Modified Date","length":0,"mask":null,"maskType":null,"name":"LastModifiedDate","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:dateTime","sortable":true,"type":"datetime","unique":false,"updateable":false,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":46,"type":"Field","value":"LastModifiedDate"}],"placeholder":false,"required":false}],"numItems":2}],"rows":1,"tabOrder":"TopToBottom","useCollapsibleSection":true,"useHeading":true}],"editLayoutSections":[{"columns":2,"heading":"Opportunity Information","layoutRows":[{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Opportunity Name","layoutComponents":[{"details":{"autoNumber":false,"byteLength":360,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":true,"inlineHelpText":null,"label":"Name","length":120,"mask":null,"maskType":null,"name":"Name","nameField":true,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"string","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":15,"type":"Field","value":"Name"}],"placeholder":false,"required":true},{"editableForNew":false,"editableForUpdate":false,"label":"Opportunity Owner","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Owner ID","length":18,"mask":null,"maskType":null,"name":"OwnerId","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["User"],"relationshipName":"Owner","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":22,"type":"Field","value":"OwnerId"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Account Name","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Account ID","length":18,"mask":null,"maskType":null,"name":"AccountId","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["Account"],"relationshipName":"Account","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":16,"type":"Field","value":"AccountId"}],"placeholder":false,"required":true},{"editableForNew":true,"editableForUpdate":true,"label":"Close Date","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Close Date","length":0,"mask":null,"maskType":null,"name":"CloseDate","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:date","sortable":true,"type":"date","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":23,"type":"Field","value":"CloseDate"}],"placeholder":false,"required":true}],"numItems":2},{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Type","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Opportunity Type","length":40,"mask":null,"maskType":null,"name":"Type","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"Existing Business","validFor":null,"value":"Existing Business"},{"active":true,"defaultValue":false,"label":"New Business","validFor":null,"value":"New Business"},{"active":true,"defaultValue":false,"label":"Other Business","validFor":null,"value":"Other Business"},{"active":true,"defaultValue":false,"label":"Not Your Business","validFor":null,"value":"Not Your Business"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":17,"type":"Field","value":"Type"}],"placeholder":false,"required":false},{"editableForNew":true,"editableForUpdate":true,"label":"Stage","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Stage","length":40,"mask":null,"maskType":null,"name":"StageName","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[{"active":true,"defaultValue":false,"label":"Prospecting","validFor":null,"value":"Prospecting"},{"active":true,"defaultValue":false,"label":"Qualification","validFor":null,"value":"Qualification"},{"active":true,"defaultValue":false,"label":"Needs Analysis","validFor":null,"value":"Needs Analysis"},{"active":true,"defaultValue":false,"label":"Value Proposition","validFor":null,"value":"Value Proposition"},{"active":true,"defaultValue":false,"label":"Id. Decision Makers","validFor":null,"value":"Id. Decision Makers"},{"active":true,"defaultValue":false,"label":"Perception Analysis","validFor":null,"value":"Perception Analysis"},{"active":true,"defaultValue":false,"label":"Proposal/Price Quote","validFor":null,"value":"Proposal/Price Quote"},{"active":true,"defaultValue":false,"label":"Negotiation/Review","validFor":null,"value":"Negotiation/Review"},{"active":true,"defaultValue":false,"label":"Closed Won","validFor":null,"value":"Closed Won"},{"active":true,"defaultValue":false,"label":"Closed Lost","validFor":null,"value":"Closed Lost"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":24,"type":"Field","value":"StageName"}],"placeholder":false,"required":true}],"numItems":2},{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Primary Campaign Source","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Campaign ID","length":18,"mask":null,"maskType":null,"name":"CampaignId","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["Campaign"],"relationshipName":"Campaign","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":18,"type":"Field","value":"CampaignId"}],"placeholder":false,"required":false},{"editableForNew":true,"editableForUpdate":true,"label":"Probability (%)","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Probability (%)","length":0,"mask":null,"maskType":null,"name":"Probability","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":3,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:double","sortable":true,"type":"percent","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":25,"type":"Field","value":"Probability"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"CustomOppType","layoutComponents":[{"details":{"autoNumber":false,"byteLength":765,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"CustomOppType","length":255,"mask":null,"maskType":null,"name":"CustomOppType__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":true,"label":"Outbound Sales","validFor":null,"value":"Outbound Sales"},{"active":true,"defaultValue":false,"label":"Incoming Requests","validFor":null,"value":"Incoming Requests"},{"active":true,"defaultValue":false,"label":"Customer Support","validFor":null,"value":"Customer Support"},{"active":true,"defaultValue":false,"label":"Developer Platform","validFor":null,"value":"Developer Platform"},{"active":true,"defaultValue":false,"label":"Recruiting","validFor":null,"value":"Recruiting"},{"active":true,"defaultValue":false,"label":"Events","validFor":null,"value":"Events"},{"active":true,"defaultValue":false,"label":"Office Operations","validFor":null,"value":"Office Operations"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":19,"type":"Field","value":"CustomOppType__c"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"","layoutComponents":[{"displayLines":1,"tabOrder":26,"type":"EmptySpace","value":null}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"CoolnessPercent","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":"50","defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"CoolnessPercent","length":0,"mask":null,"maskType":null,"name":"CoolnessPercent__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":5,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":2,"soapType":"xsd:double","sortable":true,"type":"percent","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":20,"type":"Field","value":"CoolnessPercent__c"}],"placeholder":false,"required":false},{"editableForNew":true,"editableForUpdate":true,"label":"Amount","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Amount","length":0,"mask":null,"maskType":null,"name":"Amount","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":18,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":2,"soapType":"xsd:double","sortable":true,"type":"currency","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":27,"type":"Field","value":"Amount"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Sell To","layoutComponents":[{"details":{"autoNumber":false,"byteLength":4099,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":"Select all that apply","label":"Sell To","length":4099,"mask":null,"maskType":null,"name":"All_the_things__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"San Francisco","validFor":null,"value":"San Francisco"},{"active":true,"defaultValue":false,"label":"Boston","validFor":null,"value":"Boston"},{"active":true,"defaultValue":false,"label":"London","validFor":null,"value":"London"},{"active":true,"defaultValue":false,"label":"Tokyo","validFor":null,"value":"Tokyo"},{"active":true,"defaultValue":false,"label":"Shanghai","validFor":null,"value":"Shanghai"},{"active":true,"defaultValue":false,"label":"San Diego","validFor":null,"value":"San Diego"}],"precision":4,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":false,"type":"multipicklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":4,"tabOrder":21,"type":"Field","value":"All_the_things__c"}],"placeholder":false,"required":false},{"editableForNew":true,"editableForUpdate":true,"label":"Forecast Category","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Forecast Category","length":40,"mask":null,"maskType":null,"name":"ForecastCategoryName","nameField":false,"namePointing":false,"nillable":true,"permissionable":false,"picklistValues":[{"active":true,"defaultValue":false,"label":"Omitted","validFor":null,"value":"Omitted"},{"active":true,"defaultValue":false,"label":"Pipeline","validFor":null,"value":"Pipeline"},{"active":true,"defaultValue":false,"label":"Best Case","validFor":null,"value":"Best Case"},{"active":true,"defaultValue":false,"label":"Commit","validFor":null,"value":"Commit"},{"active":true,"defaultValue":false,"label":"Closed","validFor":null,"value":"Closed"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":true,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":28,"type":"Field","value":"ForecastCategoryName"}],"placeholder":false,"required":true}],"numItems":2}],"rows":7,"tabOrder":"TopToBottom","useCollapsibleSection":false,"useHeading":true},{"columns":2,"heading":"Additional Information","layoutRows":[{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Next Step","layoutComponents":[{"details":{"autoNumber":false,"byteLength":765,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Next Step","length":255,"mask":null,"maskType":null,"name":"NextStep","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"string","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":32,"type":"Field","value":"NextStep"}],"placeholder":false,"required":false},{"editableForNew":true,"editableForUpdate":true,"label":"Lead Source","layoutComponents":[{"details":{"autoNumber":false,"byteLength":120,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Lead Source","length":40,"mask":null,"maskType":null,"name":"LeadSource","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[{"active":true,"defaultValue":false,"label":"Advertisement","validFor":null,"value":"Advertisement"},{"active":true,"defaultValue":false,"label":"Employee Referral","validFor":null,"value":"Employee Referral"},{"active":true,"defaultValue":false,"label":"External Referral","validFor":null,"value":"External Referral"},{"active":true,"defaultValue":false,"label":"Partner","validFor":null,"value":"Partner"},{"active":true,"defaultValue":false,"label":"Public Relations","validFor":null,"value":"Public Relations"},{"active":true,"defaultValue":false,"label":"Seminar - Internal","validFor":null,"value":"Seminar - Internal"},{"active":true,"defaultValue":false,"label":"Seminar - Partner","validFor":null,"value":"Seminar - Partner"},{"active":true,"defaultValue":false,"label":"Trade Show","validFor":null,"value":"Trade Show"},{"active":true,"defaultValue":false,"label":"Web","validFor":null,"value":"Web"},{"active":true,"defaultValue":false,"label":"Word of mouth","validFor":null,"value":"Word of mouth"},{"active":true,"defaultValue":false,"label":"Other","validFor":null,"value":"Other"}],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":true,"type":"picklist","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":33,"type":"Field","value":"LeadSource"}],"placeholder":false,"required":false}],"numItems":2},{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Description","layoutComponents":[{"details":{"autoNumber":false,"byteLength":96000,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":"plaintextarea","filterable":false,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Description","length":32000,"mask":null,"maskType":null,"name":"Description","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":false,"type":"textarea","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":4,"tabOrder":34,"type":"Field","value":"Description"}],"placeholder":false,"required":false},{"editableForNew":false,"editableForUpdate":false,"label":"","layoutComponents":[],"placeholder":true,"required":false}],"numItems":2}],"rows":2,"tabOrder":"LeftToRight","useCollapsibleSection":false,"useHeading":true},{"columns":1,"heading":"Even More Info","layoutRows":[{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Checkout","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":"We encourage you to check more than one","label":"Checkout","length":0,"mask":null,"maskType":null,"name":"Checkout__c","nameField":false,"namePointing":false,"nillable":false,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:boolean","sortable":true,"type":"boolean","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":37,"type":"Field","value":"Checkout__c"}],"placeholder":false,"required":false}],"numItems":1},{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Send Date Time","layoutComponents":[{"details":{"autoNumber":false,"byteLength":0,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":"Please pick a time too","label":"Send Date Time","length":0,"mask":null,"maskType":null,"name":"Send_Date_Time__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:dateTime","sortable":true,"type":"datetime","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":38,"type":"Field","value":"Send_Date_Time__c"}],"placeholder":false,"required":false}],"numItems":1}],"rows":2,"tabOrder":"TopToBottom","useCollapsibleSection":false,"useHeading":true},{"columns":1,"heading":"Description Information","layoutRows":[{"layoutItems":[{"editableForNew":true,"editableForUpdate":true,"label":"Other thoughts","layoutComponents":[{"details":{"autoNumber":false,"byteLength":98304,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":true,"defaultValue":null,"defaultValueFormula":"\"Once upon a time…\"","defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":"plaintextarea","filterable":false,"filteredLookupInfo":null,"groupable":false,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":"More things to say","label":"Other thoughts","length":32768,"mask":null,"maskType":null,"name":"Other_thoughts__c","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":[],"relationshipName":null,"relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"xsd:string","sortable":false,"type":"textarea","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":10,"tabOrder":40,"type":"Field","value":"Other_thoughts__c"}],"placeholder":false,"required":false}],"numItems":1}],"rows":1,"tabOrder":"TopToBottom","useCollapsibleSection":false,"useHeading":true}],"highlightsPanelLayoutSection":null,"id":"00hj0000000wT8jAAE","multirowEditLayoutSections":[],"offlineLinks":[],"quickActionList":{"quickActionListItems":[{"accessLevelRequired":null,"colors":[{"color":"65CAE4","context":"primary","theme":"theme4"}],"iconUrl":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_post.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_post_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_post_120.png","width":120}],"label":"Post","miniIconUrl":"","quickActionName":"FeedItem.TextPost","targetSobjectType":null,"type":"Post","urls":{}},{"accessLevelRequired":null,"colors":[{"color":"BAAC93","context":"primary","theme":"theme4"}],"iconUrl":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_file.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_file_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_file_120.png","width":120}],"label":"File","miniIconUrl":"","quickActionName":"FeedItem.ContentPost","targetSobjectType":null,"type":"Post","urls":{}},{"accessLevelRequired":null,"colors":[{"color":"4BC076","context":"primary","theme":"theme4"},{"color":"1797C0","context":"primary","theme":"theme3"}],"iconUrl":"https://na16.salesforce.com/img/icon/home32.png","icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_task.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_task_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_task_120.png","width":120}],"label":"Task","miniIconUrl":"https://na16.salesforce.com/img/icon/tasks16.png","quickActionName":"Opportunity.Task","targetSobjectType":"Task","type":"Create","urls":{"defaultValuesTemplate":"/services/data/v33.0/sobjects/Opportunity/quickActions/Task/defaultValues/{ID}","quickAction":"/services/data/v33.0/sobjects/Opportunity/quickActions/Task","defaultValues":"/services/data/v33.0/sobjects/Opportunity/quickActions/Task/defaultValues","describe":"/services/data/v33.0/sobjects/Opportunity/quickActions/Task/describe"}},{"accessLevelRequired":null,"colors":[{"color":"4BC076","context":"primary","theme":"theme4"},{"color":"1797C0","context":"primary","theme":"theme3"}],"iconUrl":"https://na16.salesforce.com/img/icon/home32.png","icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_task.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_task_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_task_120.png","width":120}],"label":"Log a Call","miniIconUrl":"https://na16.salesforce.com/img/icon/tasks16.png","quickActionName":"Opportunity.Log_a_Call","targetSobjectType":"Task","type":"Create","urls":{"defaultValuesTemplate":"/services/data/v33.0/sobjects/Opportunity/quickActions/Log_a_Call/defaultValues/{ID}","quickAction":"/services/data/v33.0/sobjects/Opportunity/quickActions/Log_a_Call","defaultValues":"/services/data/v33.0/sobjects/Opportunity/quickActions/Log_a_Call/defaultValues","describe":"/services/data/v33.0/sobjects/Opportunity/quickActions/Log_a_Call/describe"}},{"accessLevelRequired":null,"colors":[{"color":"EB7092","context":"primary","theme":"theme4"},{"color":"1797C0","context":"primary","theme":"theme3"}],"iconUrl":"https://na16.salesforce.com/img/icon/home32.png","icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_event.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_event_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_event_120.png","width":120}],"label":"Event","miniIconUrl":"https://na16.salesforce.com/img/icon/calendar16.png","quickActionName":"Opportunity.Event","targetSobjectType":"Event","type":"Create","urls":{"defaultValuesTemplate":"/services/data/v33.0/sobjects/Opportunity/quickActions/Event/defaultValues/{ID}","quickAction":"/services/data/v33.0/sobjects/Opportunity/quickActions/Event","defaultValues":"/services/data/v33.0/sobjects/Opportunity/quickActions/Event/defaultValues","describe":"/services/data/v33.0/sobjects/Opportunity/quickActions/Event/describe"}},{"accessLevelRequired":null,"colors":[{"color":"7A9AE6","context":"primary","theme":"theme4"}],"iconUrl":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_link.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_link_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_link_120.png","width":120}],"label":"Link","miniIconUrl":"","quickActionName":"FeedItem.LinkPost","targetSobjectType":null,"type":"Post","urls":{}},{"accessLevelRequired":null,"colors":[{"color":"699BE1","context":"primary","theme":"theme4"}],"iconUrl":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_poll.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_poll_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/share_poll_120.png","width":120}],"label":"Poll","miniIconUrl":"","quickActionName":"FeedItem.PollPost","targetSobjectType":null,"type":"Post","urls":{}}]},"relatedContent":{"relatedContentItems":[{"describeLayoutItem":{"editableForNew":false,"editableForUpdate":false,"label":"Account Name","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":false,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Account ID","length":18,"mask":null,"maskType":null,"name":"AccountId","nameField":false,"namePointing":false,"nillable":true,"permissionable":true,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["Account"],"relationshipName":"Account","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":47,"type":"ExpandedLookup","value":"AccountId"}],"placeholder":false,"required":false}},{"describeLayoutItem":{"editableForNew":false,"editableForUpdate":false,"label":"Opportunity Owner","layoutComponents":[{"details":{"autoNumber":false,"byteLength":18,"calculated":false,"calculatedFormula":null,"cascadeDelete":false,"caseSensitive":false,"controllerName":null,"createable":true,"custom":false,"defaultValue":null,"defaultValueFormula":null,"defaultedOnCreate":true,"dependentPicklist":false,"deprecatedAndHidden":false,"digits":0,"displayLocationInDecimal":false,"externalId":false,"extraTypeInfo":null,"filterable":true,"filteredLookupInfo":null,"groupable":true,"highScaleNumber":false,"htmlFormatted":false,"idLookup":false,"inlineHelpText":null,"label":"Owner ID","length":18,"mask":null,"maskType":null,"name":"OwnerId","nameField":false,"namePointing":false,"nillable":false,"permissionable":false,"picklistValues":[],"precision":0,"queryByDistance":false,"referenceTargetField":null,"referenceTo":["User"],"relationshipName":"Owner","relationshipOrder":null,"restrictedDelete":false,"restrictedPicklist":false,"scale":0,"soapType":"tns:ID","sortable":true,"type":"reference","unique":false,"updateable":true,"writeRequiresMasterRead":false},"displayLines":1,"tabOrder":48,"type":"ExpandedLookup","value":"OwnerId"}],"placeholder":false,"required":false}}]},"relatedLists":[{"accessLevelRequiredForCreate":null,"buttons":[{"behavior":null,"colors":[{"color":"4BC076","context":"primary","theme":"theme4"}],"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_task.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_task_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_task_120.png","width":120}],"label":"New Task","menubar":false,"name":"NewTask","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":[{"color":"EB7092","context":"primary","theme":"theme4"}],"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_event.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_event_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/new_event_120.png","width":120}],"label":"New Event","menubar":false,"name":"NewEvent","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"New Meeting Request","menubar":false,"name":"NewProposeMeeting","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null}],"columns":[{"field":"OpenActivity.Subject","format":null,"label":"Subject","lookupId":"Id","name":"Subject"},{"field":"Name.Name","format":null,"label":"Name","lookupId":"WhoId","name":"Who.Name"},{"field":"OpenActivity.IsTask","format":null,"label":"Task","lookupId":null,"name":"IsTask"},{"field":"OpenActivity.ActivityDate","format":"date","label":"Due Date","lookupId":null,"name":"ActivityDate"},{"field":"OpenActivity.Status","format":null,"label":"Status","lookupId":null,"name":"toLabel(Status)"},{"field":"OpenActivity.Priority","format":null,"label":"Priority","lookupId":null,"name":"toLabel(Priority)"},{"field":"User.Name","format":null,"label":"Assigned To","lookupId":"Owner.Id","name":"Owner.Name"}],"custom":false,"field":"WhatId","label":"Open Activities","limitRows":5,"name":"OpenActivities","sobject":"OpenActivity","sort":[{"ascending":true,"column":"ActivityDate"},{"ascending":false,"column":"LastModifiedDate"}]},{"accessLevelRequiredForCreate":null,"buttons":[{"behavior":null,"colors":[{"color":"48C3CC","context":"primary","theme":"theme4"}],"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/log_a_call.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/log_a_call_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/log_a_call_120.png","width":120}],"label":"Log a Call","menubar":false,"name":"LogCall","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"Mail Merge","menubar":false,"name":"MailMerge","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":[{"color":"95AEC5","context":"primary","theme":"theme4"}],"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/email.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/email_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/email_120.png","width":120}],"label":"Send an Email","menubar":false,"name":"SendEmail","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"Compose Gmail","menubar":false,"name":"ComposeGmail","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"Request Update","menubar":false,"name":"RequestUpdate","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"View All","menubar":false,"name":"ViewAll","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null}],"columns":[{"field":"ActivityHistory.Subject","format":null,"label":"Subject","lookupId":"Id","name":"Subject"},{"field":"Name.Name","format":null,"label":"Name","lookupId":"WhoId","name":"Who.Name"},{"field":"ActivityHistory.IsTask","format":null,"label":"Task","lookupId":null,"name":"IsTask"},{"field":"ActivityHistory.ActivityDate","format":"date","label":"Due Date","lookupId":null,"name":"ActivityDate"},{"field":"User.Name","format":null,"label":"Assigned To","lookupId":"Owner.Id","name":"Owner.Name"},{"field":"ActivityHistory.LastModifiedDate","format":"datetime","label":"Last Modified Date/Time","lookupId":null,"name":"LastModifiedDate"}],"custom":false,"field":"WhatId","label":"Activity History","limitRows":5,"name":"ActivityHistories","sobject":"ActivityHistory","sort":[{"ascending":false,"column":"ActivityDate"},{"ascending":false,"column":"LastModifiedDate"}]},{"accessLevelRequiredForCreate":null,"buttons":null,"columns":[{"field":"CombinedAttachment.Title","format":null,"label":"Title","lookupId":"Id","name":"Title"},{"field":"CombinedAttachment.RecordType","format":null,"label":"Type","lookupId":null,"name":"RecordType"},{"field":"CombinedAttachment.LastModifiedDate","format":null,"label":"Last Modified","lookupId":null,"name":"LastModifiedDate"},{"field":"CombinedAttachment.CreatedById","format":null,"label":"Created By","lookupId":null,"name":"CreatedBy.Name"},{"field":"CombinedAttachment.FileType","format":null,"label":"File Type","lookupId":null,"name":"FileType"},{"field":"CombinedAttachment.ContentSize","format":null,"label":"Content Size","lookupId":null,"name":"ContentSize"},{"field":"CombinedAttachment.FileExtension","format":null,"label":"File Extension","lookupId":null,"name":"FileExtension"},{"field":"CombinedAttachment.ContentUrl","format":null,"label":"Content URL","lookupId":null,"name":"ContentUrl"}],"custom":false,"field":"ParentId","label":"Notes & Attachments","limitRows":5,"name":"CombinedAttachments","sobject":"CombinedAttachment","sort":[{"ascending":false,"column":"LastModifiedDate"}]},{"accessLevelRequiredForCreate":null,"buttons":null,"columns":[{"field":"Contact.Name","format":null,"label":"Contact Name","lookupId":null,"name":"Contact.Name"},{"field":"Account.Name","format":null,"label":"Account Name","lookupId":null,"name":"Contact.Account.Name"},{"field":"Contact.Email","format":null,"label":"Email","lookupId":null,"name":"Contact.Email"},{"field":"Contact.Phone","format":null,"label":"Phone","lookupId":null,"name":"Contact.Phone"},{"field":"OpportunityContactRole.Role","format":null,"label":"Role","lookupId":null,"name":"Role"},{"field":"OpportunityContactRole.IsPrimary","format":null,"label":"Primary","lookupId":null,"name":"IsPrimary"}],"custom":false,"field":"OpportunityId","label":"Contact Roles","limitRows":5,"name":"OpportunityContactRoles","sobject":"OpportunityContactRole","sort":[{"ascending":true,"column":"Contact.Name"}]},{"accessLevelRequiredForCreate":null,"buttons":[{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"Add Product","menubar":false,"name":"AddProduct","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"Edit All","menubar":false,"name":"EditAllProduct","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"Choose Price Book","menubar":false,"name":"ChoosePricebook","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null},{"behavior":null,"colors":[{"color":"FAB9A5","context":"primary","theme":"theme4"}],"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":[{"contentType":"image/svg+xml","height":0,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/sort.svg","width":0},{"contentType":"image/png","height":60,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/sort_60.png","width":60},{"contentType":"image/png","height":120,"theme":"theme4","url":"https://na16.salesforce.com/img/icon/t4v32/action/sort_120.png","width":120}],"label":"Sort","menubar":false,"name":"Sort","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null}],"columns":[{"field":"Product2.Name","format":null,"label":"Product","lookupId":null,"name":"PricebookEntry.Product2.Name"},{"field":"OpportunityLineItem.Quantity","format":null,"label":"Quantity","lookupId":null,"name":"Quantity"},{"field":"OpportunityLineItem.UnitPrice","format":null,"label":"Sales Price","lookupId":null,"name":"UnitPrice"},{"field":"OpportunityLineItem.ServiceDate","format":"date","label":"Date","lookupId":null,"name":"ServiceDate"},{"field":"OpportunityLineItem.Description","format":null,"label":"Line Description","lookupId":null,"name":"Description"},{"field":"PricebookEntry.UnitPrice","format":null,"label":"List Price","lookupId":null,"name":"PricebookEntry.UnitPrice"}],"custom":false,"field":"OpportunityId","label":"Products","limitRows":5,"name":"OpportunityLineItems","sobject":"OpportunityLineItem","sort":[{"ascending":true,"column":"SortOrder"}]},{"accessLevelRequiredForCreate":null,"buttons":[{"behavior":null,"colors":null,"content":null,"contentSource":null,"custom":false,"encoding":null,"height":null,"icons":null,"label":"New Quote","menubar":false,"name":"NewQuote","overridden":false,"resizeable":false,"scrollbars":false,"showsLocation":false,"showsStatus":false,"toolbar":false,"url":null,"width":null,"windowPosition":null}],"columns":[{"field":"Quote.QuoteNumber","format":null,"label":"Quote Number","lookupId":"Id","name":"QuoteNumber"},{"field":"Quote.Name","format":null,"label":"Quote Name","lookupId":"Id","name":"Name"},{"field":"Quote.IsSyncing","format":null,"label":"Syncing","lookupId":null,"name":"IsSyncing"},{"field":"Quote.ExpirationDate","format":"date","label":"Expiration Date","lookupId":null,"name":"ExpirationDate"},{"field":"Quote.Discount","format":null,"label":"Discount","lookupId":null,"name":"Discount"},{"field":"Quote.GrandTotal","format":null,"label":"Grand Total","lookupId":null,"name":"GrandTotal"},{"field":"User.Name","format":null,"label":"Created By","lookupId":"CreatedBy.Id","name":"CreatedBy.Name"}],"custom":false,"field":"OpportunityId","label":"Quotes","limitRows":5,"name":"Quotes","sobject":"Quote","sort":[{"ascending":true,"column":"Name"}]}]}],"recordTypeMappings":[{"available":true,"defaultRecordTypeMapping":true,"layoutId":"00hj0000000wT8jAAE","name":"Master","picklistsForRecordType":[],"recordTypeId":"012000000000000AAA","urls":{"layout":"/services/data/v33.0/sobjects/Opportunity/describe/layouts/012000000000000AAA"}}],"recordTypeSelectorRequired":[false]}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/spec/form-builder-spec.jsx
================================================
import React from 'react'
import ReactTestUtils from 'react-addons-test-utils'
import {
FormItem,
GeneratedForm,
GeneratedFieldset,
} from 'nylas-component-kit'
import SalesforceSchemaAdapter from '../lib/form/salesforce-schema-adapter'
import rawData from './fixtures/opportunity-layouts.json'
const rawLayout = SalesforceSchemaAdapter.defaultLayout(rawData)
const testData = SalesforceSchemaAdapter.convertFullEditLayout({objectType: "opportunity", rawLayout: rawLayout})
function StubDiv() {
return
}
xdescribe('Form Builder', function describeBlock() {
beforeEach(() => {
for (let i = 0; i < testData.fieldsets.length; i++) {
const fieldset = testData.fieldsets[i];
for (let j = 0; j < fieldset.formItems.length; j++) {
const formItem = fieldset.formItems[j];
if (formItem.type === "reference") {
formItem.type = StubDiv
}
}
}
this.form = ReactTestUtils.renderIntoDocument(
{}} onChange={() => {}} />
)
})
it("generates a form", () => {
const forms = ReactTestUtils.scryRenderedComponentsWithType(this.form, GeneratedForm);
const $forms = ReactTestUtils.scryRenderedDOMComponentsWithTag(this.form, "form");
expect(forms.length).toBeGreaterThan(0);
expect($forms.length).toBeGreaterThan(0);
});
it("generates a fieldset", () => {
const fieldsets = ReactTestUtils.scryRenderedComponentsWithType(this.form, GeneratedFieldset);
const $fieldsets = ReactTestUtils.scryRenderedDOMComponentsWithTag(this.form, "fieldset");
expect(fieldsets.length).toBeGreaterThan(0);
expect($fieldsets.length).toBeGreaterThan(0);
});
it("generates a form item", () => {
const items = ReactTestUtils.scryRenderedComponentsWithType(this.form, FormItem);
expect(items.length).toBeGreaterThan(0);
});
});
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/spec/generate-test-data.es6
================================================
import {
DatabaseStore,
Thread,
} from 'nylas-exports'
import SalesforceObject from '../lib/models/salesforce-object'
import SalesforceAPI from '../lib/salesforce-api'
const companyNames = [
"Lyft",
"Airbnb",
"Salesforce",
"Facebook",
"Google",
"Apple",
"Tesla",
"SpaceX",
"Dropbox",
"Snap",
"Twitter",
"Oracle",
"Sequoia Capital",
"KPCB",
"Andreessen Horowitz",
]
const oppNames = [
"Lyft Sales",
"Airbnb Marketing",
"Salesforce Recruiting",
"Facebook Sales",
"Google Marketing",
"Apple Recruiting",
"Tesla Sales",
"SpaceX Marketing",
"Dropbox Recruiting",
"Snap Sales",
"Twitter Marketing",
"Oracle Recruiting",
"Sequoia Capital Sales",
"KPCB Marketing",
"Andreessen Horowitz Recruiting",
]
class GenerateTestData {
constructor() {
this._threads = []
this._contacts = []
this._accounts = []
this._index = 29
}
populateAccounts() {
DatabaseStore.findAll(SalesforceObject, {
type: "Account",
})
.then((accounts) => {
console.log(accounts)
this._accounts = accounts
})
}
createSalesforceContacts() {
DatabaseStore.findAll(Thread)
.order(Thread.attributes.lastMessageReceivedTimestamp.descending())
.limit(2000)
.then((threads) => {
this._threads = threads
const contactEmails = []
this._threads.forEach((thread) => {
thread.participants.forEach((contact) => {
if (!contactEmails.includes(contact.email)) {
this._contacts.push(contact)
contactEmails.push(contact.email)
}
})
})
for (const contact of this._contacts) {
const formPostData = {
Email: contact.email,
FirstName: contact.firstName,
LastName: contact.lastName,
OwnerId: "00541000000ohxCAAQ",
}
SalesforceAPI.makeRequest({
path: "/sobjects/Contact/",
method: "POST",
body: formPostData,
})
}
})
}
createSalesforceAccounts() {
for (const companyName of companyNames) {
const formPostData = {
Name: companyName,
OwnerId: "00541000000ohxCAAQ",
}
SalesforceAPI.makeRequest({
path: "/sobjects/Account/",
method: "POST",
body: formPostData,
})
}
}
createSalesforceOpportunities() {
for (const oppName of oppNames) {
const formPostData = {
CloseDate: "2016-11-20",
Name: oppName,
StageName: "Prospecting",
Probability: "20",
Amount: "15000",
OwnerId: "00541000000ohxCAAQ",
}
SalesforceAPI.makeRequest({
path: "/sobjects/Opportunity/",
method: "POST",
body: formPostData,
})
}
}
// Adding to accounts didn't work for some reason
addContactsToAccountsAndOpportunities() {
DatabaseStore.findAll(SalesforceObject, {
type: "Contact",
})
.then((contacts) => {
for (const contact of contacts) {
if (this._isLucky()) {
this._chooseAccountAndOpportunity()
.then(({account, opportunity}) => {
const accountData = {
Email: contact.email,
FirstName: contact.firstName,
LastName: contact.lastName,
AccountId: account.id,
OwnerId: "00541000000ohxCAAQ",
}
SalesforceAPI.makeRequest({
path: "/sobjects/Contact/",
method: "PATCH",
body: accountData,
})
const opportunityData = {
ContactId: contact.id,
OpportunityId: opportunity.id,
}
SalesforceAPI.makeRequest({
path: "/sobjects/OpportunityContactRole/",
method: "POST",
body: opportunityData,
})
})
}
}
})
}
_chooseAccountAndOpportunity() {
const account = this._accounts[this._getIndex()]
return DatabaseStore.findAll(SalesforceObject, {
type: "Opportunity",
})
.where(SalesforceObject.attributes.name.like(account.name))
.then((opportunities) => {
return Promise.resolve({
account: account,
opportunity: opportunities[0],
})
})
}
_isLucky() {
return Math.floor((Math.random() * 100)) < 75
}
_getIndex() {
if (this._index === 44) {
this._index = 29
}
this._index++
return this._index
}
}
export default new GenerateTestData()
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/spec/salesforce-schema-adapter-spec.es6
================================================
import _ from 'underscore';
import fs from 'fs';
import path from 'path';
import {GeneratedForm, GeneratedFieldset, FormItem} from 'nylas-component-kit';
import SalesforceSchemaAdapter from '../lib/form/salesforce-schema-adapter';
const fpath = path.resolve(__dirname, 'fixtures/opportunity-layouts.json');
const opportunityLayouts = JSON.parse(fs.readFileSync(fpath, 'utf-8'));
describe("SalesforceSchemaAdapter", function describeBlock() {
beforeEach(() => {
const rawLayout = SalesforceSchemaAdapter.defaultLayout(opportunityLayouts);
this.schema = SalesforceSchemaAdapter.convertFullEditLayout({objectType: "opportunity", rawLayout});
});
it("gets the values into the schema correctly", () => {
expect(this.schema.id).toBeDefined();
expect(this.schema.objectType).toBe("opportunity");
const {fieldsets} = this.schema;
expect(fieldsets.length).toBe(4);
const fieldset = fieldsets[0];
expect(fieldset.heading).toBe("Opportunity Information");
expect(fieldset.formItems.length).toBe(14);
const formItem = fieldset.formItems[11];
expect(formItem.label).toBe("Amount");
expect(formItem.type).toBe("number");
expect(formItem.row).toBe(5);
expect(formItem.column).toBe(1);
expect(fieldset.formItems[0].row).toBe(0);
expect(fieldset.formItems[0].column).toBe(0);
const {selectOptions} = fieldset.formItems[5];
expect(selectOptions.length).toBe(10);
expect(selectOptions[0].value).toBe("Prospecting");
});
it("only uses valid form types", () => {
const {formItems} = this.schema.fieldsets[0];
const types = _.pluck(formItems, "type");
const validTypes = Object.keys(FormItem.inputElementTypes);
// Code elsewhere will custom handle these types
const customTypes = ["reference", "textarea", "select", "EmptySpace"];
expect(_.difference(types, validTypes.concat(customTypes))).toEqual([]);
});
it("generates the correct schema", () => {
expect(_.difference(Object.keys(this.schema),
Object.keys(GeneratedForm.propTypes)))
.toEqual(["schemaType", "objectType", "createdAt"]); // Leftovers not used in element
const fieldset = this.schema.fieldsets[0];
expect(_.difference(Object.keys(fieldset),
Object.keys(GeneratedFieldset.propTypes)))
.toEqual(["rows", "columns"]); // Leftovers not used in element
const formItem = fieldset.formItems[5];
expect(_.difference(Object.keys(formItem),
Object.keys(FormItem.propTypes)))
.toEqual(["length"]); // Leftovers not used in element
});
});
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/spec/syncback-salesforce-object-task-spec.es6
================================================
describe("SyncbackSalesforceObjectTask", function SyncbackSalesforceObjectTaskSpec() {
describe("when saving an opportunity", () => {
it("keeps contacts when they're the same");
it("creates new contacts when added to field");
it("removes contats when removed from field");
});
});
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/open-in-salesforce-btn.less
================================================
@import "ui-variables";
.open-in-salesforce-btn {
padding: 3px 4px 5px 4px;
box-shadow: inset 0 0 0 0.5px rgba(0,0,0,0.15), 0 0.5px 0.5px 0.5px rgba(0,0,0,0.07);
border-radius: 3px;
background-color: @background-primary;
background-image: linear-gradient(to top, fadeout(difference(@background-primary, white), 97), @background-primary 100%);
line-height: 12px;
display: inline-block;
height: 20px;
&.large {
padding: 5px 4px 6px 5px;
}
}
.large-cell .open-in-salesforce-btn.large {
margin-top: -1px;
height: 24px;
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-association.less
================================================
@import "ui-variables";
@import "ui-mixins";
.message-list-salesforce-opportunity-bar {
background: @background-primary;
border-bottom: 1px solid @border-color-primary;
padding: 4px 15px 5px 15px;
.opp-picker {
padding-left: 15px;
}
.opp-name {
font-weight:@headings-font-weight;
padding-left: 0.5em;
}
.unlink {
font-size: @font-size-small;
padding-left: 0.5em;
a {
color: @text-color-subtle;
border-bottom: 1px solid @text-color-subtle;
&:hover {
color: darken(@text-color-link, 10%);
border-bottom: 1px solid darken(@text-color-link, 10%);
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-composer.less
================================================
.token .participant .sf-icon-wrap, .collapsed-contact .sf-icon-wrap {
position: relative;
left: 0;
top: 0;
margin: 0 6px 0 0;
}
.salesforce-contact-search-result {
.sf-icon-wrap {
top: 0;
left: 0;
margin-right: 6px;
position: relative;
}
}
.item.selected .salesforce-contact-search-result .sf-icon-wrap {
background: transparent !important;
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-contact.less
================================================
@import "ui-variables";
.salesforce-contact-info {
font-size: 12px;
margin-bottom: 22px;
.sf-profile {
.profile-source-icon {
width: 18px;
padding-top: 0;
float: left;
}
.profile-source-link {
color: @text-color-link;
a {
text-decoration: none;
&:hover {
color: @text-color-link;
}
}
}
}
.create-sf-obj-link {
display: block;
position: relative;
margin-top: 0.5em;
&:first-child {
margin-top: 0;
}
color: @text-color-link;
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-icon.less
================================================
@import "ui-variables";
@icon-size: 20px;
.sf-icon-wrap {
position: absolute;
left: 10px;
top: 50%;
margin-top: @icon-size / -2;
box-shadow: inset 0 0 0 0.5px rgba(0,0,0,0.1);
background-image: linear-gradient(to top, rgba(255,255,255,0) 0%, rgba(255,255,255,0.11) 100%);
display: inline-block;
width: @icon-size;
height: @icon-size;
line-height: @icon-size - 1px;
border-radius: 2px;
&.checked {
&:after {
content: "✓";
width: 8px;
height: 8px;
font-size: 6px;
position: absolute;
color: #fff;
background: @color-success;
bottom: 0;
right: 0;
border-radius: 2px 0 2px 0;
line-height: 6px;
text-align: center;
padding-top: 1px;
padding-left: 1px;
box-shadow: 0 0 0 0.5px rgba(0,0,0,0.1);
}
}
&.round {
border-radius: 50%;
padding: 5px;
.sf-icon-img {
width: @icon-size * 0.58;
height: @icon-size * 0.58;
vertical-align: top;
}
}
&.round-create {
border-radius: 50%;
padding: 1px;
.sf-icon-img {
width: @icon-size * 0.9;
height: @icon-size * 0.9;
vertical-align: text-top;
}
}
&.inline {
position: relative;
left: 0;
top: 0;
margin-top: 0;
margin-left: 10px;
}
.sf-icon-img {
width: @icon-size;
height: @icon-size;
position: relative;
margin-top: -1px;
}
}
.cell-item.action-item .sf-icon-wrap {
width: 14px;
height: 14px;
margin-top: -7px;
left: 16px;
padding: 4px 3px;
.sf-icon-img {
width: 8px;
height: 8px;
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-object-form.less
================================================
@import "ui-variables";
.salesforce-object-form-wrap {
overflow-y: auto;
}
.salesforce-object-form {
flex: 1;
position: relative;
.spinner {
z-index: 1001;
}
.form-name {
padding: 0 22px;
h1 {
margin: 22px 0;
}
}
.form-footer {
position: fixed;
width: 100%;
bottom: 0;
z-index: 9999;
}
fieldset {
&:last-child {
margin-bottom: 44px;
}
position: relative;
// z-index set by generated-form.cjsx
border-bottom: 0;
}
&.schema-error {
display: flex;
color: @color-error;
align-items: center;
justify-content: center;
max-width: 540px;
margin: 0 auto;
padding: 20px;
}
}
.salesforce-delete-object {
position: absolute;
right: 0;
padding: 22px 22px 0 22px;
text-align: right;
z-index: 10;
background: white;
&.confirm-control {
padding-top: 12px;
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-object-picker.less
================================================
@import "ui-variables";
@lead: #f88962;
@task: #4bc076;
@case: #f2cf5b;
@account: #7f8de1;
@contact: #a094ed;
@opportunity: #fcb95b;
@lead_convert: #f88962;
@emailmessage: #95aec5;
body.platform-win32 {
.salesforce .cell-container {
border-radius: 0;
}
}
.salesforce {
.linkable-object-name {
flex: 1;
font-weight: 600;
font-size: 13.6px;
margin-right: 18px;
&.Lead { color: @lead; }
&.Task { color: @task; }
&.Case { color: @case; }
&.Account { color: @account; }
&.Contact { color: @contact; }
&.Opportunity { color: @opportunity; }
&.EmailMessage { color: @emailmessage; }
}
h2.sidebar-h2 {
// font-size: 11px;
// font-weight: @font-weight-semi-bold;
// text-transform: uppercase;
// color: @text-color-very-subtle;
// border-bottom: 1px solid @border-color-divider;
// border-bottom: 0;
// margin: 1.5em 0 1em 0;
// &:after {
// background-image: url(nylas://nylas-private-salesforce/static/images/sidebar-section-divider@2x.png);
// background-repeat: repeat-x;
// width: 100%;
// height: 3px;
// }
// &:first-child {
// margin-top: 0;
// }
}
.cell-container {
position: relative;
font-size: 13px;
box-shadow: inset 0 0 0 0.5px rgba(0,0,0,0.14), 0 1px 1px rgba(0,0,0,0.08);
border-radius: 4px;
margin: 0 0 12px 0;
}
.cell-item {
&:hover {
cursor: default;
}
padding: 8px 8.5px 8px 8.5px;
border-top: 1px solid rgba(0,0,0,0.1);
position: relative;
&.large-cell {
padding: 14px 12px 14px 12px;
}
&:first-child {
border-top: 0;
}
&.action-item {
padding-left: 38px;
padding-right: 32px;
font-size: 11px;
color: @text-color-subtle;
box-shadow: inset 0 0 0.5px 0 rgba(0,0,0,0.15);
}
}
.action-icon {
position: relative;
top: -1px;
}
.linkable-object-details {
font-size: 11px;
font-weight: @font-weight-medium;
color: @text-color-subtle;
}
}
.salesforce-object-picker {
.item {
padding-left: 5px;
padding-right: 5px;
}
.salesforce-suggestion {
position: relative;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-left: 25px;
}
.sf-icon-wrap {
left: 0;
}
.tokenizing-field {
.token {
.sf-icon-wrap {
left: 3px;
}
}
}
}
.salesforce-login {
margin: 0 10px 10px 10px;
}
.salesforce-manually-relate-popover {
width: 280px;
padding: 10px;
h5 {
margin: 0;
color: #afafaf;
}
input {
border: none !important;
}
.header-container {
padding: 0 !important;
}
.content-container {
background: white !important;
max-height: 300px;
}
.salesforce-object-picker {
padding: 10px 0 0 0;
}
.placeholder {
font-weight: normal !important;
}
}
.related-objects-wrap {
a{text-decoration: none}
.replica-status {
position: absolute;
right: 15px;
top: 8px;
.synced { display: none; }
.syncing { display: block; }
}
.has-replica {
.replica-status {
.synced { display: block; }
.syncing { display: none; }
}
}
.salesforce-prompt {
text-align: center;
color: fade(@text-color, 30%);
ul {
list-style: none;
padding-left: 0;
}
p {
margin: 10px 0 0 0;
}
}
.salesforce-no-connect-placeholder {
text-align: center;
margin: 25px 0;
}
h3.sidebar-h3 {
font-size: @font-size-smaller;
font-weight: @font-weight-normal;
margin: 1em 0 0.4em 0;
}
.tokenizing-field-input, .menu .header-container, .tokenizing-field {
border-bottom: 0;
}
.tokenizing-field-input {
background: @white;
overflow: hidden;
padding: 0 5px 5px 5px;
margin-left: 0;
.placeholder {
font-size: 14px;
}
.token {
padding: 0 20px 0 28px;
margin: 5px 5px 0 0;
&.selected {
border: 0;
}
}
input[type=text] {
border: 0;
box-shadow: none;
margin: 5px 0 0 0;
padding: 0;
line-height: 26px;
}
}
.sidebar-item {
position: relative;
}
.cell-container:hover {
.unassociate-object {
display: block;
}
}
.unassociate-object {
display: none;
cursor: default;
line-height: 15px;
font-size: 15px;
position: absolute;
right: -10px;
top: -10px;
}
img.colorfill {
background: @source-list-active-color;
}
.association-picker {
display: flex;
flex-direction: column;
.tokenizing-field-input {
border: 1px solid rgba(0,0,0,0.11);
}
.spacer {
padding: 5px;
}
}
.new-links {
margin-top: 10px;
}
}
.salesforce-sync-message-status {
padding-left: 20px;
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-picker.less
================================================
@import "ui-variables";
.salesforce-composer-picker {
h2.picker-h2 {
font-size: 11px;
font-weight: @font-weight-semi-bold;
text-transform: uppercase;
color: @text-color-very-subtle;
margin: 0;
}
.popover {
padding: @spacing-standard;
}
.tokenizing-field-input {
padding: 0 0.5em;
padding-top: 5px;
background: @white;
margin-top: @spacing-half;
border: 1px solid @input-border-color;
}
.tokenizing-field {
border: 0;
}
.menu .content-container {
background: @white;
position: absolute;
left: -235px;
bottom: -15px;
max-height: 300px;
overflow-y: auto;
}
img.colorfill {
background: @source-list-active-color;
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-related-object.less
================================================
@import "ui-variables";
.salesforce .cell-item.sf-related-object {
position: relative;
display: flex;
flex-direction: column;
padding: 0;
&.create-sf-obj-link {
border-top: 0;
margin-top: 0;
}
.synced-wrap {
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
padding-right: 9px;
}
.main-cell-wrap {
padding: 10px 10px 10px 37px;
box-shadow: inset 0 0 0 0.5px rgba(0,0,0,0.14), 0 1px 1px rgba(0,0,0,0.08);
border-radius: 4px 4px 0 0;
display: flex;
position: relative;
z-index: 2;
background: @background-off-primary;
.sf-icon-wrap {
left: 10px;
}
}
.sub-items-wrap {
padding: 5px 0;
position: relative;
border-bottom: 1px solid rgba(0,0,0,0.1);
overflow: auto;
z-index: 1;
transition: height 200ms ease-in-out;
}
.sub-item {
height: 26px;
padding: 3px 10px 3px 37px;
position: relative;
display: flex;
}
.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;
}
}
.cell-container .new-item {
position: relative;
padding: 7px 10px 8px 37px;
}
.cell-container.inline {
display: inline-block;
}
body.platform-win32 {
.salesforce .cell-item.sf-related-object .main-cell-wrap {
border-radius: 0;
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-sync-label.less
================================================
.list-item.focused .salesforce-thread-icons .sf-icon-wrap {
background: transparent !important;
}
.salesforce-thread-icons {
position: relative;
line-height: 24px;
.sf-icon-wrap {
position: relative;
top: -1px;
left: 0;
width: 21px;
height: 21px;
margin-right: 6px;
line-height: 19px;
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-sync-message-status.less
================================================
@import "ui-variables";
.salesforce-sync-message-status {
width: 100%;
text-align: right;
color: @text-color-very-subtle;
font-size: @font-size-tiny;
&:hover {
cursor: default;
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/salesforce-welcome-view.less
================================================
@import "ui-variables";
.salesforce-welcome {
padding: 40px;
text-align: center;
h2 {
margin-top: -10px;
}
p {
margin: 15px auto;
width: 500px;
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/search-results.less
================================================
.salesforce-search-bar-result {
position: relative;
padding-left: 26px;
.sf-icon-wrap {
left: 0;
}
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-salesforce/stylesheets/sync-thread-toggle.less
================================================
@import "ui-variables";
.sync-thread-toggle {
font-size: @font-size-tiny;
margin-right: 10px;
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-sounds/lib/main.es6
================================================
import {SoundRegistry} from 'nylas-exports'
export function activate() {
// FIXME: Use the nylas:// protocol handlers once we upgrade Electron past
// v30.0
// See: https://github.com/atom/electron/issues/1123
SoundRegistry.register({
"send": ["internal_packages", "nylas-private-sounds", "NYLAS_UI_Send_v1.ogg"],
"confirm": ["internal_packages", "nylas-private-sounds", "NYLAS_UI_Confirm_v1.ogg"],
"hit-send": ["internal_packages", "nylas-private-sounds", "NYLAS_UI_HitSend_v1.ogg"],
"new-mail": ["internal_packages", "nylas-private-sounds", "NYLAS_UI_NewMail_v1.ogg"],
})
}
export function deactivate() {
SoundRegistry.unregister(["send", "confirm", "hit-send", "new-mail"])
}
================================================
FILE: packages/client-app/internal_packages/nylas-private-sounds/package.json
================================================
{
"name": "nylas-private-sounds",
"version": "0.1.0",
"main": "./lib/main",
"description": "Nylas Sounds",
"license": "Proprietary",
"private": true,
"engines": {
"nylas": "*"
},
"windowTypes": {
"all": true
}
}
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/account-types.es6
================================================
// 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;
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/decorators/create-page-for-form.jsx
================================================
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;
const {errorStatusCode: statusCode} = this.state
this.setState({submitting: true});
const reqOptions = {}
const isCertificateError = statusCode === 495
if (isCertificateError) {
reqOptions.forceTrustCertificate = true
}
runAuthRequest(accountInfo, reqOptions)
.then((json) => {
OnboardingActions.moveToPage('account-onboarding-success')
OnboardingActions.accountJSONReceived(json, json.localToken, json.cloudToken)
})
.catch((err) => {
Actions.recordUserEvent('Email Account Auth Failed', {
erroredEmail: accountInfo.email,
errorMessage: err.message,
errorLocation: err.location,
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, errorStatusCode} = this.state;
let buttonLabel = FormComponent.submitLabel(accountInfo);
const isCertificateError = errorStatusCode === 495
if (isCertificateError) {
buttonLabel = 'Connect anyway'
}
// 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, errorStatusCode, 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;
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/form-error-message.jsx
================================================
import React from 'react';
import {RegExpUtils} from 'nylas-exports';
const FormErrorMessage = (props) => {
const {message, statusCode, empty} = props;
if (!message) {
return {empty}
;
}
const isCertificateError = statusCode === 495
if (isCertificateError) {
return (
{message}
The certificate for this server is invalid. Would you like to connect to the server anyway?
);
}
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,
statusCode: React.PropTypes.number,
};
export default FormErrorMessage;
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/form-field.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/main.es6
================================================
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() {
}
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/onboarding-actions.es6
================================================
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;
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/onboarding-helpers.es6
================================================
/* 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, {forceTrustCertificate = false} = {}) {
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,
imap_allow_insecure_ssl: forceTrustCertificate,
smtp_allow_insecure_ssl: forceTrustCertificate,
},
},
},
})
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(identityId) {
return `${identityId}-----${base64url(crypto.randomBytes(40))}`;
}
export function buildGmailAuthURL(sessionKey) {
return `${N1CloudAPI.APIRoot}/auth/gmail?state=${sessionKey}`;
}
export function runAuthRequest(accountInfo, {forceTrustCertificate = false} = {}) {
const {username, type, email, name} = accountInfo;
const settings = Object.assign({}, accountInfo)
if (forceTrustCertificate) {
settings.imap_allow_insecure_ssl = true
settings.smtp_allow_insecure_ssl = true
}
const data = {
provider: type,
email: email,
name: name,
settings,
};
// 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()
.catch((err) => {
err.location = "cloud"
throw err
})
.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()
.catch((err) => {
err.location = "client"
throw err
})
.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);
}
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/onboarding-root.jsx
================================================
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 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,
"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 (
);
}
}
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/onboarding-store.es6
================================================
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();
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();
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 {
// Standard new user onboarding flow.
this._pageStack = ['welcome'];
}
}
_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 later.");
}
}
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();
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/page-account-choose.jsx
================================================
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()}
);
}
}
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/page-account-onboarding-success.jsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/page-account-settings-exchange.jsx
================================================
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);
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/page-account-settings-gmail.jsx
================================================
import React from 'react';
import {OAuthSignInPage} from 'nylas-component-kit';
import {IdentityStore} from 'nylas-exports'
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(IdentityStore.identityId());
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 (
);
}
}
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/page-account-settings-imap.jsx
================================================
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);
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/page-account-settings.jsx
================================================
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);
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/page-initial-preferences.cjsx
================================================
React = require 'react'
path = require 'path'
fs = require 'fs'
_ = require 'underscore'
{RetinaImg, Flexbox, ConfigPropContainer} = require 'nylas-component-kit'
{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
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/page-top-bar.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/onboarding/lib/page-welcome.jsx
================================================
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 = () => {
OnboardingActions.moveToPage("account-choose");
}
render() {
return (
);
}
}
================================================
FILE: packages/client-app/internal_packages/onboarding/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/onboarding/stylesheets/onboarding-reset.less
================================================
@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%)));
}
}
}
================================================
FILE: packages/client-app/internal_packages/onboarding/stylesheets/onboarding.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/open-tracking/README.md
================================================
## Open Tracking
Adds tracking pixels to messages and tracks whether they have been opened.
================================================
FILE: packages/client-app/internal_packages/open-tracking/lib/main.es6
================================================
import {
ComponentRegistry,
ExtensionRegistry,
} from 'nylas-exports';
import {HasTutorialTip} from 'nylas-component-kit';
import OpenTrackingButton from './open-tracking-button';
import OpenTrackingIcon from './open-tracking-icon';
import OpenTrackingMessageStatus from './open-tracking-message-status';
import OpenTrackingComposerExtension from './open-tracking-composer-extension';
const OpenTrackingButtonWithTutorialTip = HasTutorialTip(OpenTrackingButton, {
title: "See when recipients open this email",
instructions: "When enabled, Nylas Mail will notify you as soon as someone reads this message. Sending to a group? Nylas Mail shows you which recipients opened your email so you can follow up with precision.",
});
export function activate() {
ComponentRegistry.register(OpenTrackingButtonWithTutorialTip,
{role: 'Composer:ActionButton'});
ComponentRegistry.register(OpenTrackingIcon,
{role: 'ThreadListIcon'});
ComponentRegistry.register(OpenTrackingMessageStatus,
{role: 'MessageHeaderStatus'});
ExtensionRegistry.Composer.register(OpenTrackingComposerExtension);
}
export function serialize() {}
export function deactivate() {
ComponentRegistry.unregister(OpenTrackingButtonWithTutorialTip);
ComponentRegistry.unregister(OpenTrackingIcon);
ComponentRegistry.unregister(OpenTrackingMessageStatus);
ExtensionRegistry.Composer.unregister(OpenTrackingComposerExtension);
}
================================================
FILE: packages/client-app/internal_packages/open-tracking/lib/open-tracking-button.jsx
================================================
// import {DraftStore, React, Actions, NylasAPI, DatabaseStore, Message, Rx} from 'nylas-exports'
import {React, APIError, NylasAPI} from 'nylas-exports'
import {MetadataComposerToggleButton} from 'nylas-component-kit'
import {PLUGIN_ID, PLUGIN_NAME} from './open-tracking-constants'
export default class OpenTrackingButton extends React.Component {
static displayName = 'OpenTrackingButton';
static propTypes = {
draft: React.PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired,
};
shouldComponentUpdate(nextProps) {
return (nextProps.draft.metadataForPluginId(PLUGIN_ID) !== this.props.draft.metadataForPluginId(PLUGIN_ID));
}
_title(enabled) {
const dir = enabled ? "Disable" : "Enable";
return `${dir} open tracking`
}
_errorMessage(error) {
if (error instanceof APIError && NylasAPI.TimeoutErrorCodes.includes(error.statusCode)) {
return `Open tracking does not work offline. Please re-enable when you come back online.`
}
return `Unfortunately, open tracking is currently not available. Please try again later. Error: ${error.message}`
}
render() {
const enabledValue = {
open_count: 0,
open_data: [],
};
return (
)
}
}
OpenTrackingButton.containerRequired = false;
================================================
FILE: packages/client-app/internal_packages/open-tracking/lib/open-tracking-composer-extension.es6
================================================
import {ComposerExtension} from 'nylas-exports';
import {PLUGIN_ID, PLUGIN_URL} from './open-tracking-constants';
export default class OpenTrackingComposerExtension extends ComposerExtension {
/**
* This inserts a placeholder image tag to serve as our open tracking
* pixel.
*
* See cloud-api/routes/open-tracking
*
* This image tag is NOT complete at this stage. It requires substantial
* post processing just before send. This happens in iso-core since
* sending can happen immediately or later in cloud-workers.
*
* See isomorphic-core tracking-utils.es6
*
* We don't add a `src` parameter here since we don't want the tracking
* pixel to prematurely load with an incorrect url.
*
* We also don't have a Message Id yet since this is still a draft. We
* generate and replace `MESSAGE_ID` later with the correct one.
*
* We also need to add individualized recipients to each tracking pixel
* for each message sent to each person.
*
* We finally need to remove the tracking pixel from the message that
* ends up in the users's sent folder. This ensures the sender doesn't
* trip their own open track.
*/
static applyTransformsForSending({draftBodyRootNode, draft}) {
// grab message metadata, if any
const messageUid = draft.clientId;
const metadata = draft.metadataForPluginId(PLUGIN_ID);
if (!metadata) {
return;
}
// insert a tracking pixel into the message
const serverUrl = `${PLUGIN_URL}/open/MESSAGE_ID`
const imgFragment = document.createRange().createContextualFragment(` `);
const beforeEl = draftBodyRootNode.querySelector('.gmail_quote');
if (beforeEl) {
beforeEl.parentNode.insertBefore(imgFragment, beforeEl);
} else {
draftBodyRootNode.appendChild(imgFragment);
}
// save the uid info to draft metadata
metadata.uid = messageUid;
draft.applyPluginMetadata(PLUGIN_ID, metadata);
}
static unapplyTransformsForSending({draftBodyRootNode}) {
const imgEl = draftBodyRootNode.querySelector('.n1-open');
if (imgEl) {
imgEl.parentNode.removeChild(imgEl);
}
}
}
================================================
FILE: packages/client-app/internal_packages/open-tracking/lib/open-tracking-constants.es6
================================================
import plugin from '../package.json'
export const PLUGIN_NAME = plugin.title
export const PLUGIN_ID = plugin.name;
export const PLUGIN_URL = plugin.serverUrl[NylasEnv.config.get("env")];
================================================
FILE: packages/client-app/internal_packages/open-tracking/lib/open-tracking-icon.jsx
================================================
import {React, ReactDOM, Actions} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import OpenTrackingMessagePopover from './open-tracking-message-popover';
import {PLUGIN_ID} from './open-tracking-constants';
export default class OpenTrackingIcon extends React.Component {
static displayName = 'OpenTrackingIcon';
static propTypes = {
thread: React.PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = this._getStateFromThread(props.thread)
}
componentWillReceiveProps(newProps) {
this.setState(this._getStateFromThread(newProps.thread));
}
onMouseDown = () => {
const rect = ReactDOM.findDOMNode(this).getBoundingClientRect();
Actions.openPopover(
,
{originRect: rect, direction: 'down'}
)
}
_getStateFromThread(thread) {
const messages = thread.__messages || []
let lastMessage = null;
for (let i = messages.length - 1; i >= 0; i--) {
if (!messages[i].draft) {
lastMessage = messages[i];
break;
}
}
if (!lastMessage) {
return {
message: null,
opened: false,
openCount: null,
hasMetadata: false,
};
}
const lastMessageMeta = lastMessage.metadataForPluginId(PLUGIN_ID);
const hasMetadata = lastMessageMeta != null && lastMessageMeta.open_count != null;
return {
message: lastMessage,
opened: hasMetadata && lastMessageMeta.open_count > 0,
openCount: hasMetadata ? lastMessageMeta.open_count : null,
hasMetadata: hasMetadata,
};
}
_renderImage() {
return (
);
}
render() {
if (!this.state.hasMetadata) return ;
const openedTitle = `${this.state.openCount} open${this.state.openCount === 1 ? "" : "s"}`;
const title = this.state.opened ? openedTitle : "This message has not been opened";
return (
{this._renderImage()}
);
}
}
================================================
FILE: packages/client-app/internal_packages/open-tracking/lib/open-tracking-message-popover.jsx
================================================
import React from 'react';
import {DateUtils} from 'nylas-exports';
import {Flexbox} from 'nylas-component-kit';
import ActivityListStore from '../../activity-list/lib/activity-list-store';
class OpenTrackingMessagePopover extends React.Component {
static displayName = 'OpenTrackingMessagePopover';
static propTypes = {
message: React.PropTypes.object,
openMetadata: React.PropTypes.object,
};
renderOpenActions() {
const opens = this.props.openMetadata.open_data;
return opens.map((open) => {
const recipients = this.props.message.to.concat(this.props.message.cc, this.props.message.bcc);
const recipient = ActivityListStore.getRecipient(open.recipient, recipients);
const date = new Date(0);
date.setUTCSeconds(open.timestamp);
return (
{recipient ? recipient.displayName() : "Someone"}
{DateUtils.shortTimeString(date)}
);
});
}
render() {
return (
Opened by:
{this.renderOpenActions()}
);
}
}
export default OpenTrackingMessagePopover;
================================================
FILE: packages/client-app/internal_packages/open-tracking/lib/open-tracking-message-status.jsx
================================================
import {React, ReactDOM, Actions} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import OpenTrackingMessagePopover from './open-tracking-message-popover'
import {PLUGIN_ID} from './open-tracking-constants'
export default class OpenTrackingMessageStatus extends React.Component {
static displayName = "OpenTrackingMessageStatus";
static propTypes = {
message: React.PropTypes.object.isRequired,
};
static containerStyles = {
paddingTop: 4,
};
constructor(props) {
super(props);
this.state = this._getStateFromMessage(props.message)
}
componentWillReceiveProps(nextProps) {
this.setState(this._getStateFromMessage(nextProps.message))
}
onMouseDown = () => {
const rect = ReactDOM.findDOMNode(this).getBoundingClientRect();
Actions.openPopover(
,
{originRect: rect, direction: 'down'}
)
}
_getStateFromMessage(message) {
const metadata = message.metadataForPluginId(PLUGIN_ID);
if (!metadata || metadata.open_count == null) {
return {
hasMetadata: false,
openCount: null,
opened: false,
};
}
return {
hasMetadata: true,
openCount: metadata.open_count,
opened: metadata.open_count > 0,
};
}
renderImage() {
return (
);
}
render() {
if (!this.state.hasMetadata) return false;
let openedCount = `${this.state.openCount} open${this.state.openCount === 1 ? "" : "s"}`;
if (this.state.openCount > 999) openedCount = "999+ opens";
const text = this.state.opened ? openedCount : "No opens";
return (
{this.renderImage()} {text}
)
}
}
================================================
FILE: packages/client-app/internal_packages/open-tracking/package.json
================================================
{
"name": "open-tracking",
"main": "./lib/main",
"version": "0.1.0",
"serverUrl": {
"local": "https://local-n1.nylas.com",
"development": "https://local-n1.nylas.com",
"staging": "https://n1-staging.nylas.com",
"production": "https://n1.nylas.com"
},
"title": "Open Tracking",
"description": "Track when email messages have been opened by recipients.",
"icon": "./icon.png",
"isOptional": true,
"supportedEnvs": ["local", "development", "staging", "production"],
"repository": {
"type": "git",
"url": ""
},
"engines": {
"nylas": "*"
},
"windowTypes": {
"default": true,
"composer": true,
"thread-popout": true
},
"dependencies": {},
"license": "GPL-3.0"
}
================================================
FILE: packages/client-app/internal_packages/open-tracking/spec/open-tracking-composer-extension-spec.es6
================================================
import {Message} from 'nylas-exports';
import OpenTrackingComposerExtension from '../lib/open-tracking-composer-extension'
import {PLUGIN_ID, PLUGIN_URL} from '../lib/open-tracking-constants';
const accountId = 'fake-accountId';
const clientId = 'local-31d8df57-1442';
const beforeBody = `TEST_BODY On Feb 25 2016, at 3:38 pm, Drew <drew@nylas.com> wrote: twst `;
const afterBody = `TEST_BODY On Feb 25 2016, at 3:38 pm, Drew <drew@nylas.com> wrote: twst `;
const nodeForHTML = (html) => {
const fragment = document.createDocumentFragment();
const node = document.createElement('root');
fragment.appendChild(node);
node.innerHTML = html;
return node;
}
xdescribe('Open tracking composer extension', function openTrackingComposerExtension() {
describe("applyTransformsForSending", () => {
beforeEach(() => {
this.draftBodyRootNode = nodeForHTML(beforeBody);
this.draft = new Message({
clientId: clientId,
accountId: accountId,
body: beforeBody,
});
});
it("takes no action if there is no metadata", () => {
OpenTrackingComposerExtension.applyTransformsForSending({
draftBodyRootNode: this.draftBodyRootNode,
draft: this.draft,
});
const actualAfterBody = this.draftBodyRootNode.innerHTML;
expect(actualAfterBody).toEqual(beforeBody);
});
describe("With properly formatted metadata and correct params", () => {
beforeEach(() => {
this.metadata = {open_count: 0};
this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
OpenTrackingComposerExtension.applyTransformsForSending({
draftBodyRootNode: this.draftBodyRootNode,
draft: this.draft,
});
this.metadata = this.draft.metadataForPluginId(PLUGIN_ID);
});
it("appends an image with the correct server URL to the unquoted body", () => {
const actualAfterBody = this.draftBodyRootNode.innerHTML;
expect(actualAfterBody).toEqual(afterBody);
});
});
});
describe("unapplyTransformsForSending", () => {
it("takes no action if the img tag is missing", () => {
this.draftBodyRootNode = nodeForHTML(beforeBody);
this.draft = new Message({
clientId: clientId,
accountId: accountId,
body: beforeBody,
});
OpenTrackingComposerExtension.unapplyTransformsForSending({
draftBodyRootNode: this.draftBodyRootNode,
draft: this.draft,
});
const actualAfterBody = this.draftBodyRootNode.innerHTML;
expect(actualAfterBody).toEqual(beforeBody);
});
it("removes the image from the body and restore the body to it's exact original content", () => {
this.metadata = {open_count: 0};
this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
this.draftBodyRootNode = nodeForHTML(afterBody);
this.draft = new Message({
clientId: clientId,
accountId: accountId,
body: afterBody,
});
OpenTrackingComposerExtension.unapplyTransformsForSending({
draftBodyRootNode: this.draftBodyRootNode,
draft: this.draft,
});
const actualAfterBody = this.draftBodyRootNode.innerHTML;
expect(actualAfterBody).toEqual(beforeBody);
});
});
});
================================================
FILE: packages/client-app/internal_packages/open-tracking/spec/open-tracking-icon-spec.jsx
================================================
import React from 'react';
import ReactDOM from 'react-dom';
import {findRenderedDOMComponentWithClass} from 'react-addons-test-utils';
import {Message, NylasTestUtils} from 'nylas-exports'
import OpenTrackingIcon from '../lib/open-tracking-icon'
import {PLUGIN_ID} from '../lib/open-tracking-constants'
const {renderIntoDocument} = NylasTestUtils;
function makeIcon(thread, props = {}) {
return renderIntoDocument( );
}
function find(component, className) {
return ReactDOM.findDOMNode(findRenderedDOMComponentWithClass(component, className))
}
function addOpenMetadata(obj, openCount) {
obj.applyPluginMetadata(PLUGIN_ID, {open_count: openCount});
}
describe('Open tracking icon', function openTrackingIcon() {
beforeEach(() => {
this.thread = {__messages: []};
});
it("shows no icon if the thread has no messages", () => {
const icon = ReactDOM.findDOMNode(makeIcon(this.thread));
expect(icon.children.length).toEqual(0);
});
it("shows no icon if the thread messages have no metadata", () => {
this.thread.__messages.push(new Message());
this.thread.__messages.push(new Message());
const icon = ReactDOM.findDOMNode(makeIcon(this.thread));
expect(icon.children.length).toEqual(0);
});
describe("With messages and metadata", () => {
beforeEach(() => {
this.messages = [new Message(), new Message(), new Message({draft: true})];
this.thread.__messages.push(...this.messages);
});
it("shows no icon if metadata is malformed", () => {
this.messages[0].applyPluginMetadata(PLUGIN_ID, {gar: "bage"});
const icon = ReactDOM.findDOMNode(makeIcon(this.thread));
expect(icon.children.length).toEqual(0);
});
it("shows an unopened icon if last non draft message has metadata and is unopened", () => {
addOpenMetadata(this.messages[0], 1);
addOpenMetadata(this.messages[1], 0);
const icon = find(makeIcon(this.thread), "open-tracking-icon");
expect(icon.children.length).toEqual(1);
expect(icon.querySelector("img.unopened")).not.toBeNull();
expect(icon.querySelector("img.opened")).toBeNull();
});
it("shows an opened icon if last non draft message with metadata is opened", () => {
addOpenMetadata(this.messages[0], 0);
addOpenMetadata(this.messages[1], 1);
const icon = find(makeIcon(this.thread), "open-tracking-icon");
expect(icon.children.length).toEqual(1);
expect(icon.querySelector("img.unopened")).toBeNull();
expect(icon.querySelector("img.opened")).not.toBeNull();
});
});
});
================================================
FILE: packages/client-app/internal_packages/open-tracking/spec/open-tracking-message-status-spec.jsx
================================================
import React from 'react';
import ReactDOM from 'react-dom';
import {Message, NylasTestUtils} from 'nylas-exports'
import OpenTrackingMessageStatus from '../lib/open-tracking-message-status'
import {PLUGIN_ID} from '../lib/open-tracking-constants'
const {renderIntoDocument} = NylasTestUtils;
function makeIcon(message, props = {}) {
return renderIntoDocument(
);
}
function addOpenMetadata(obj, openCount) {
obj.applyPluginMetadata(PLUGIN_ID, {open_count: openCount});
}
describe('Open tracking message status', function openTrackingMessageStatus() {
beforeEach(() => {
this.message = new Message();
});
it("shows nothing if the message has no metadata", () => {
const icon = ReactDOM.findDOMNode(makeIcon(this.message));
expect(icon.querySelector(".open-tracking-message-status")).toBeNull();
});
it("shows nothing if metadata is malformed", () => {
this.message.applyPluginMetadata(PLUGIN_ID, {gar: "bage"});
const icon = ReactDOM.findDOMNode(makeIcon(this.message));
expect(icon.querySelector(".open-tracking-message-status")).toBeNull();
});
it("shows an unopened icon if the message has metadata and is unopened", () => {
addOpenMetadata(this.message, 0);
const icon = ReactDOM.findDOMNode(makeIcon(this.message));
expect(icon.querySelector("img.unopened")).not.toBeNull();
expect(icon.querySelector("img.opened")).toBeNull();
});
it("shows an opened icon if the message has metadata and is opened", () => {
addOpenMetadata(this.message, 1);
const icon = ReactDOM.findDOMNode(makeIcon(this.message));
expect(icon.querySelector("img.unopened")).toBeNull();
expect(icon.querySelector("img.opened")).not.toBeNull();
});
});
================================================
FILE: packages/client-app/internal_packages/open-tracking/stylesheets/main.less
================================================
@import "ui-variables";
@import "ui-mixins";
@open-tracking-color: #7C19CC;
.open-tracking-icon img {
vertical-align: initial;
}
.open-tracking-icon img.content-mask.unopened {
background-color: fadeout(@open-tracking-color, 80%);
cursor: default;
}
.open-tracking-icon img.content-mask.opened {
background-color: @open-tracking-color;
cursor: pointer;
}
.list-item.focused, .list-item.selected {
.open-tracking-icon img.content-mask.unopened {
background-color: fadeout(@text-color-inverse, 70%);
}
.open-tracking-icon img.content-mask.opened {
background-color: @text-color-inverse;
}
}
.open-tracking-icon .open-count {
display: inline-block;
position: relative;
left: -16px;
text-align: center;
background-color: @text-color-link;
font-size: 12px;
font-weight: bold;
}
.open-tracking-icon {
width: 15px;
margin: 0 2px;
}
.open-tracking-message-status {
color: @text-color-very-subtle;
margin-left: 10px;
&.unopened {
img.content-mask {
background-color: @text-color-very-subtle;
}
}
&.opened {
cursor: pointer;
img.content-mask {
background-color: @open-tracking-color;
}
}
}
.open-tracking-message-popover {
width: 200px;
max-height: 240px;
.open-tracking-header {
padding: @padding-base-vertical @padding-base-horizontal 0 @padding-base-horizontal;
text-align: center;
color: @text-color-subtle;
font-weight: 600;
}
.open-history-container {
max-height: 216px;
padding: 0 @padding-base-horizontal @padding-base-vertical @padding-base-horizontal;
overflow: auto;
.open-action {
color: @text-color-subtle;
.recipient {
text-overflow: ellipsis;
overflow: hidden;
}
.spacer {
flex: 1 1 0;
}
.timestamp {
color: @text-color-very-subtle;
flex-shrink: 0;
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/participant-profile/lib/clearbit-data-source.coffee
================================================
# This file is in coffeescript just to use the existential operator!
{AccountStore} = require 'nylas-exports'
MAX_RETRY = 10
module.exports = class ClearbitDataSource
clearbitAPI: ->
return "https://person.clearbit.com/v2/combined"
find: ({email, tryCount}) ->
# TODO: If you have a Clearbit API key, insert the request to clearbit here!
return Promise.resolve({})
# 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
================================================
FILE: packages/client-app/internal_packages/participant-profile/lib/main.es6
================================================
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() {
}
================================================
FILE: packages/client-app/internal_packages/participant-profile/lib/participant-profile-store.es6
================================================
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
================================================
FILE: packages/client-app/internal_packages/participant-profile/lib/sidebar-participant-profile.jsx
================================================
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()}
)
}
}
================================================
FILE: packages/client-app/internal_packages/participant-profile/lib/sidebar-related-threads.jsx
================================================
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()}
)
}
}
================================================
FILE: packages/client-app/internal_packages/participant-profile/package.json
================================================
{
"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"
}
================================================
FILE: packages/client-app/internal_packages/participant-profile/stylesheets/participant-profile.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/personal-level-indicators/README.md
================================================
# 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.
================================================
FILE: packages/client-app/internal_packages/personal-level-indicators/docs/docco.css
================================================
/*--------------------- 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;
}
================================================
FILE: packages/client-app/internal_packages/personal-level-indicators/docs/personal-level-icon.html
================================================
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
================================================
FILE: packages/client-app/internal_packages/personal-level-indicators/docs/public/stylesheets/normalize.css
================================================
/*! 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;
}
================================================
FILE: packages/client-app/internal_packages/personal-level-indicators/lib/main.es6
================================================
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);
}
================================================
FILE: packages/client-app/internal_packages/personal-level-indicators/lib/personal-level-icon.jsx
================================================
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;
}
}
================================================
FILE: packages/client-app/internal_packages/personal-level-indicators/package.json
================================================
{
"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"
}
================================================
FILE: packages/client-app/internal_packages/personal-level-indicators/stylesheets/main.less
================================================
@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%);
}
}
}
================================================
FILE: packages/client-app/internal_packages/phishing-detection/README.md
================================================
## 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:

#### 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
```
================================================
FILE: packages/client-app/internal_packages/phishing-detection/docs/docco.css
================================================
/*--------------------- 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;
}
================================================
FILE: packages/client-app/internal_packages/phishing-detection/docs/main.coffee
================================================
# # 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)
================================================
FILE: packages/client-app/internal_packages/phishing-detection/docs/main.html
================================================
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)
================================================
FILE: packages/client-app/internal_packages/phishing-detection/docs/public/stylesheets/normalize.css
================================================
/*! 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;
}
================================================
FILE: packages/client-app/internal_packages/phishing-detection/lib/main.jsx
================================================
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);
}
================================================
FILE: packages/client-app/internal_packages/phishing-detection/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/phishing-detection/spec/main-spec.jsx
================================================
describe("Phishing Detection Indicator", () => {
it("should exhibit some behavior", () => {
expect(true).toBe(true);
});
});
================================================
FILE: packages/client-app/internal_packages/phishing-detection/stylesheets/index.less
================================================
@import "ui-variables";
@import "ui-mixins";
.phishing-indicator {
text-align: center;
background-color: white;
}
================================================
FILE: packages/client-app/internal_packages/phishing-detection/stylesheets/phishing.less
================================================
.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;
}
================================================
FILE: packages/client-app/internal_packages/plugins/lib/main.jsx
================================================
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)
}
================================================
FILE: packages/client-app/internal_packages/plugins/lib/package-set.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/plugins/lib/package.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/plugins/lib/packages-store.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/plugins/lib/plugins-actions.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/plugins/lib/plugins-tabs-view.jsx
================================================
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 (
);
}
}
export default PluginsTabs;
================================================
FILE: packages/client-app/internal_packages/plugins/lib/preferences-plugins.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/plugins/lib/tab-explore.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/plugins/lib/tab-installed.jsx
================================================
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 (
);
}
}
export default TabInstalled;
================================================
FILE: packages/client-app/internal_packages/plugins/lib/tabs-store.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/plugins/lib/tabs.jsx
================================================
import TabInstalled from './tab-installed';
const Tabs = [{
key: 'installed',
name: 'Installed',
icon: 'tbd',
component: TabInstalled,
}]
export default Tabs;
================================================
FILE: packages/client-app/internal_packages/plugins/package.json
================================================
{
"name": "plugins",
"version": "0.1.0",
"main": "./lib/main",
"description": "Plugins",
"license": "GPL-3.0",
"private": true,
"engines": {
"nylas": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/plugins/stylesheets/plugins.less
================================================
@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;
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/preferences/lib/main.jsx
================================================
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';
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: '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;
}
================================================
FILE: packages/client-app/internal_packages/preferences/lib/preferences-root.jsx
================================================
/* 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}
},
});
================================================
FILE: packages/client-app/internal_packages/preferences/lib/preferences-tabs-bar.jsx
================================================
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 (
);
}
}
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 (
);
}
}
export default PreferencesTabsBar;
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/config-schema-item.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/keymaps/command-item.jsx
================================================
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}
);
}
}
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/keymaps/displayed-keybindings.js
================================================
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"'],
],
},
]
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/keymaps/mousetrap-keybinding-helpers.js
================================================
/* 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};
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/preferences-account-details.jsx
================================================
/* eslint global-require: 0 */
import React, {Component, PropTypes} from 'react';
import {EditableList} 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;
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/preferences-account-list.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/preferences-accounts.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/preferences-appearance.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/preferences-general.jsx
================================================
/* 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 (
Nylas Mail 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;
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/preferences-keymaps.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/preferences-mail-rules.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/sending-section.jsx
================================================
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),
}
},
});
================================================
FILE: packages/client-app/internal_packages/preferences/lib/tabs/workspace-section.jsx
================================================
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 Nylas Mail 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;
================================================
FILE: packages/client-app/internal_packages/preferences/package.json
================================================
{
"name": "preferences",
"version": "0.1.0",
"main": "./lib/main",
"description": "Nylas Preferences Window Component",
"license": "GPL-3.0",
"private": true,
"engines": {
"nylas": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/preferences/spec/preferences-account-details-spec.jsx
================================================
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}
)
});
});
});
});
================================================
FILE: packages/client-app/internal_packages/preferences/stylesheets/preferences-accounts.less
================================================
@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%;
}
}
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/preferences/stylesheets/preferences-identity.less
================================================
@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;
}
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/preferences/stylesheets/preferences-mail-rules.less
================================================
@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;
}
}
}
================================================
FILE: packages/client-app/internal_packages/preferences/stylesheets/preferences.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/print/lib/main.es6
================================================
import Printer from './printer';
let printer = null;
export function activate() {
printer = new Printer();
}
export function deactivate() {
if (printer) printer.deactivate();
}
export function serialize() {
}
================================================
FILE: packages/client-app/internal_packages/print/lib/print-window.es6
================================================
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}`);
}
}
================================================
FILE: packages/client-app/internal_packages/print/lib/printer.es6
================================================
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;
================================================
FILE: packages/client-app/internal_packages/print/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/print/static/print-styles.css
================================================
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;
}
}
================================================
FILE: packages/client-app/internal_packages/print/static/print.js
================================================
(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;
})();
================================================
FILE: packages/client-app/internal_packages/remove-tracking-pixels/lib/main.es6
================================================
/* 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);
}
================================================
FILE: packages/client-app/internal_packages/remove-tracking-pixels/package.json
================================================
{
"name": "remove-tracking-pixels",
"version": "0.1.0",
"main": "./lib/main",
"license": "GPL-3.0",
"engines": {
"nylas": "*"
},
"windowTypes": {
"default": true,
"thread-popout": true
}
}
================================================
FILE: packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/a-after.txt
================================================
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
================================================
FILE: packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/a-before.txt
================================================
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
================================================
FILE: packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/b-after.txt
================================================
Hey Ben,
This is the reply! This tracking pixel should not be removed.
This is the email I sent!
================================================
FILE: packages/client-app/internal_packages/remove-tracking-pixels/spec/fixtures/b-before.txt
================================================
Hey Ben,
This is the reply! This tracking pixel should not be removed.
This is the email I sent!
================================================
FILE: packages/client-app/internal_packages/remove-tracking-pixels/spec/tracking-pixels-extension-spec.es6
================================================
/* 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);
});
});
================================================
FILE: packages/client-app/internal_packages/screenshot-mode/assets/font-override.css
================================================
@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');
}
================================================
FILE: packages/client-app/internal_packages/screenshot-mode/lib/main.coffee
================================================
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: ->
================================================
FILE: packages/client-app/internal_packages/screenshot-mode/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/search-index/lib/contact-search-indexer.es6
================================================
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()
================================================
FILE: packages/client-app/internal_packages/search-index/lib/event-search-indexer.es6
================================================
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()
================================================
FILE: packages/client-app/internal_packages/search-index/lib/main.es6
================================================
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()
}
================================================
FILE: packages/client-app/internal_packages/search-index/lib/thread-search-index-store.es6
================================================
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()
================================================
FILE: packages/client-app/internal_packages/search-index/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/send-and-archive/lib/main.es6
================================================
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)
}
================================================
FILE: packages/client-app/internal_packages/send-and-archive/lib/send-and-archive-extension.es6
================================================
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,
})
})
},
}]
}
================================================
FILE: packages/client-app/internal_packages/send-and-archive/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/send-and-archive/spec/send-and-archive-spec.coffee
================================================
describe "SendAndArchive", ->
================================================
FILE: packages/client-app/internal_packages/send-and-archive/styles/send-and-archive.less
================================================
.send-and-archive {
}
================================================
FILE: packages/client-app/internal_packages/send-later/lib/main.es6
================================================
import {ComponentRegistry} from 'nylas-exports';
import {HasTutorialTip} from 'nylas-component-kit';
import SendLaterButton from './send-later-button';
import SendLaterStatus from './send-later-status';
const SendLaterButtonWithTip = HasTutorialTip(SendLaterButton, {
title: "Send on your own schedule",
instructions: "Schedule this message to send at the ideal time. N1 makes it easy to control the fabric of spacetime!",
});
export function activate() {
ComponentRegistry.register(SendLaterButtonWithTip, {role: 'Composer:ActionButton'})
ComponentRegistry.register(SendLaterStatus, {role: 'DraftList:DraftStatus'})
}
export function deactivate() {
ComponentRegistry.unregister(SendLaterButtonWithTip)
ComponentRegistry.unregister(SendLaterStatus)
}
export function serialize() {
}
================================================
FILE: packages/client-app/internal_packages/send-later/lib/send-later-button.jsx
================================================
import fs from 'fs';
import React, {Component, PropTypes} from 'react'
import ReactDOM from 'react-dom'
import {Actions, DateUtils, NylasAPIHelpers, DraftHelpers, FeatureUsageStore} from 'nylas-exports'
import {RetinaImg, FeatureUsedUpModal} from 'nylas-component-kit'
import SendLaterPopover from './send-later-popover'
import {PLUGIN_ID, PLUGIN_NAME} from './send-later-constants'
const {NylasAPIRequest, NylasAPI, N1CloudAPI} = require('nylas-exports')
const OPEN_TRACKING_ID = NylasEnv.packages.pluginIdFor('open-tracking')
const LINK_TRACKING_ID = NylasEnv.packages.pluginIdFor('link-tracking')
Promise.promisifyAll(fs);
class SendLaterButton extends Component {
static displayName = 'SendLaterButton';
static containerRequired = false;
static propTypes = {
draft: PropTypes.object.isRequired,
session: PropTypes.object.isRequired,
isValidDraft: PropTypes.func,
};
constructor() {
super();
this.state = {
saving: false,
};
}
componentDidMount() {
this.mounted = true;
}
shouldComponentUpdate(nextProps, nextState) {
if (nextState.saving !== this.state.saving) {
return true;
}
if (this._sendLaterDateForDraft(nextProps.draft) !== this._sendLaterDateForDraft(this.props.draft)) {
return true;
}
return false;
}
componentWillUnmount() {
this.mounted = false;
}
onAssignSendLaterDate = async (sendLaterDate, dateLabel) => {
if (!this.props.isValidDraft()) { return }
Actions.closePopover();
const currentSendLaterDate = this._sendLaterDateForDraft(this.props.draft)
if (currentSendLaterDate === sendLaterDate) { return }
// Only check for feature usage and record metrics if this draft is not
// already set to send later.
if (!currentSendLaterDate) {
const lexicon = {
displayName: "Send Later",
usedUpHeader: "All delayed sends used",
iconUrl: "nylas://send-later/assets/ic-send-later-modal@2x.png",
}
try {
await FeatureUsageStore.asyncUseFeature('send-later', {lexicon})
} catch (error) {
if (error instanceof FeatureUsageStore.NoProAccess) {
return
}
}
this.setState({saving: true});
const sendInSec = Math.round(((new Date(sendLaterDate)).valueOf() - Date.now()) / 1000)
Actions.recordUserEvent("Draft Send Later", {
timeInSec: sendInSec,
timeInLog10Sec: Math.log10(sendInSec),
label: dateLabel,
});
}
this.onSetMetadata({expiration: sendLaterDate});
};
onCancelSendLater = () => {
Actions.closePopover();
this.onSetMetadata({expiration: null, cancelled: true});
};
onSetMetadata = async (metadatum = {}) => {
if (!this.mounted) { return; }
const {draft, session} = this.props;
const {expiration, ...extra} = metadatum
this.setState({saving: true});
try {
await NylasAPIHelpers.authPlugin(PLUGIN_ID, PLUGIN_NAME, draft.accountId);
if (!this.mounted) { return; }
if (!expiration) {
session.changes.addPluginMetadata(PLUGIN_ID, {
...extra,
expiration: null,
});
} else {
session.changes.add({pristine: false})
const draftContents = await DraftHelpers.finalizeDraft(session);
const req = new NylasAPIRequest({
api: NylasAPI,
options: {
path: `/drafts/build`,
method: 'POST',
body: draftContents,
accountId: draft.accountId,
returnsModel: false,
},
});
const draftMessage = await req.run();
const uploads = [];
// Now, upload attachments to our blob service.
for (const attachment of draftContents.uploads) {
const uploadReq = new NylasAPIRequest({
api: N1CloudAPI,
options: {
path: `/blobs`,
method: 'PUT',
blob: true,
accountId: draft.accountId,
returnsModel: false,
formData: {
id: attachment.id,
file: fs.createReadStream(attachment.originPath),
},
},
});
await uploadReq.run();
attachment.serverId = `${draftContents.accountId}-${attachment.id}`;
uploads.push(attachment);
}
draftMessage.usesOpenTracking = draft.metadataForPluginId(OPEN_TRACKING_ID) != null;
draftMessage.usesLinkTracking = draft.metadataForPluginId(LINK_TRACKING_ID) != null;
session.changes.add({serverId: draftMessage.id})
session.changes.addPluginMetadata(PLUGIN_ID, {
...draftMessage,
...extra,
expiration,
uploads,
});
}
// TODO: This currently is only useful for syncing the draft metadata,
// even though we don't actually syncback drafts
Actions.finalizeDraftAndSyncbackMetadata(draft.clientId);
if (expiration && NylasEnv.isComposerWindow()) {
NylasEnv.close();
}
} catch (error) {
NylasEnv.reportError(error);
NylasEnv.showErrorDialog(`Sorry, we were unable to schedule this message. ${error.message}`);
}
if (!this.mounted) { return }
this.setState({saving: false})
}
onClick = () => {
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
Actions.openPopover(
,
{originRect: buttonRect, direction: 'up'}
)
};
_sendLaterDateForDraft(draft) {
if (!draft) {
return null;
}
const messageMetadata = draft.metadataForPluginId(PLUGIN_ID) || {};
return messageMetadata.expiration;
}
render() {
let className = 'btn btn-toolbar btn-send-later';
if (this.state.saving) {
return (
);
}
let sendLaterLabel = false;
const sendLaterDate = this._sendLaterDateForDraft(this.props.draft);
if (sendLaterDate) {
className += ' btn-enabled';
const momentDate = DateUtils.futureDateFromString(sendLaterDate);
if (momentDate) {
sendLaterLabel = Sending in {momentDate.fromNow(true)} ;
} else {
sendLaterLabel = Sending now ;
}
}
return (
{sendLaterLabel}
);
}
}
export default SendLaterButton
================================================
FILE: packages/client-app/internal_packages/send-later/lib/send-later-constants.es6
================================================
import plugin from '../package.json'
export const PLUGIN_ID = plugin.name;
export const PLUGIN_NAME = "Send Later"
================================================
FILE: packages/client-app/internal_packages/send-later/lib/send-later-popover.jsx
================================================
import React, {PropTypes} from 'react'
import {DateUtils} from 'nylas-exports'
import {DatePickerPopover} from 'nylas-component-kit'
const SendLaterOptions = {
'In 1 hour': DateUtils.in1Hour,
'In 2 hours': DateUtils.in2Hours,
'Later today': DateUtils.laterToday,
'Tomorrow morning': DateUtils.tomorrow,
'Tomorrow evening': DateUtils.tomorrowEvening,
'This weekend': DateUtils.thisWeekend,
'Next week': DateUtils.nextWeek,
}
function SendLaterPopover(props) {
let footer;
const {onAssignSendLaterDate, onCancelSendLater, sendLaterDate} = props
const header = Send later:
if (sendLaterDate) {
footer = [
,
Unschedule Send
,
]
}
return (
);
}
SendLaterPopover.displayName = 'SendLaterPopover';
SendLaterPopover.propTypes = {
sendLaterDate: PropTypes.string,
onAssignSendLaterDate: PropTypes.func.isRequired,
onCancelSendLater: PropTypes.func.isRequired,
};
export default SendLaterPopover
================================================
FILE: packages/client-app/internal_packages/send-later/lib/send-later-status.jsx
================================================
import React, {Component, PropTypes} from 'react'
import moment from 'moment'
import {DateUtils, Actions} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import {PLUGIN_ID} from './send-later-constants'
const {DATE_FORMAT_SHORT} = DateUtils
export default class SendLaterStatus extends Component {
static displayName = 'SendLaterStatus';
static propTypes = {
draft: PropTypes.object,
};
onCancelSendLater = () => {
Actions.setMetadata(this.props.draft, PLUGIN_ID, {expiration: null, cancelled: true});
};
render() {
const {draft} = this.props
const metadata = draft.metadataForPluginId(PLUGIN_ID)
if (metadata && metadata.expiration) {
const {expiration} = metadata
const formatted = DateUtils.format(moment(expiration), DATE_FORMAT_SHORT)
return (
{`Scheduled for ${formatted}`}
)
}
return
}
}
================================================
FILE: packages/client-app/internal_packages/send-later/package.json
================================================
{
"name": "send-later",
"version": "1.0.0",
"title": "Send Later",
"description": "Choose to send emails at a specified time in the future.",
"isHiddenOnPluginsPage": true,
"icon": "./icon.png",
"main": "lib/main",
"supportedEnvs": ["local", "development", "staging", "production"],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"windowTypes": {
"default": true,
"composer": true,
"thread-popout": true
},
"isOptional": true,
"engines": {
"nylas": "*"
},
"license": "GPL-3.0"
}
================================================
FILE: packages/client-app/internal_packages/send-later/spec/send-later-button-spec.jsx
================================================
/* eslint react/no-render-return-value: 0 */
import React from 'react';
import ReactDOM from 'react-dom';
import {findRenderedDOMComponentWithClass} from 'react-addons-test-utils';
import {DateUtils, NylasAPIHelpers, Actions} from 'nylas-exports'
import SendLaterButton from '../lib/send-later-button';
import {PLUGIN_ID, PLUGIN_NAME} from '../lib/send-later-constants'
const node = document.createElement('div');
const makeButton = (initialState, metadataValue) => {
const draft = {
accountId: 'accountId',
metadataForPluginId: () => metadataValue,
}
const session = {
changes: {
add: jasmine.createSpy('add'),
addPluginMetadata: jasmine.createSpy('addPluginMetadata'),
},
}
const button = ReactDOM.render( true} />, node);
if (initialState) {
button.setState(initialState)
}
return button
};
xdescribe('SendLaterButton', function sendLaterButton() {
beforeEach(() => {
spyOn(DateUtils, 'format').andReturn('formatted')
});
describe('onSendLater', () => {
it('sets scheduled date to "saving" and adds plugin metadata to the session', () => {
const button = makeButton(null, {sendLaterDate: 'date'})
spyOn(button, 'setState')
spyOn(NylasAPIHelpers, 'authPlugin').andReturn(Promise.resolve());
spyOn(Actions, 'finalizeDraftAndSyncbackMetadata')
const sendLaterDate = {utc: () => 'utc'}
button.onSendLater(sendLaterDate)
advanceClock()
expect(button.setState).toHaveBeenCalledWith({saving: true})
expect(NylasAPIHelpers.authPlugin).toHaveBeenCalledWith(PLUGIN_ID, PLUGIN_NAME, button.props.draft.accountId)
expect(button.props.session.changes.addPluginMetadata).toHaveBeenCalledWith(PLUGIN_ID, {sendLaterDate})
});
it('displays dialog if an auth error occurs', () => {
const button = makeButton(null, {sendLaterDate: 'date'})
spyOn(button, 'setState')
spyOn(NylasEnv, 'reportError')
spyOn(NylasEnv, 'showErrorDialog')
spyOn(NylasAPIHelpers, 'authPlugin').andReturn(Promise.reject(new Error('Oh no!')))
spyOn(Actions, 'finalizeDraftAndSyncbackMetadata')
button.onSendLater({utc: () => 'utc'})
advanceClock()
expect(NylasEnv.reportError).toHaveBeenCalled()
expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
});
it('closes the composer window if a sendLaterDate has been set', () => {
const button = makeButton(null, {sendLaterDate: 'date'})
spyOn(button, 'setState')
spyOn(NylasEnv, 'close')
spyOn(NylasAPIHelpers, 'authPlugin').andReturn(Promise.resolve());
spyOn(NylasEnv, 'isComposerWindow').andReturn(true)
spyOn(Actions, 'finalizeDraftAndSyncbackMetadata')
button.onSendLater({utc: () => 'utc'})
advanceClock()
expect(NylasEnv.close).toHaveBeenCalled()
});
});
describe('render', () => {
it('renders spinner if saving', () => {
const button = ReactDOM.findDOMNode(makeButton({saving: true}, null))
expect(button.title).toEqual('Saving send date...')
});
it('renders date if message is scheduled', () => {
spyOn(DateUtils, 'futureDateFromString').andReturn({fromNow: () => '5 minutes'})
const button = makeButton({saving: false}, {sendLaterDate: 'date'})
const span = ReactDOM.findDOMNode(findRenderedDOMComponentWithClass(button, 'at'))
expect(span.textContent).toEqual('Sending in 5 minutes')
});
it('does not render date if message is not scheduled', () => {
const button = makeButton(null, null)
expect(() => {
findRenderedDOMComponentWithClass(button, 'at')
}).toThrow()
});
});
});
================================================
FILE: packages/client-app/internal_packages/send-later/spec/send-later-popover-spec.jsx
================================================
import React from 'react';
import {mount} from 'enzyme'
import SendLaterPopover from '../lib/send-later-popover';
const makePopover = (props = {}) => {
return mount(
{}}
onCancelSendLater={() => {}}
{...props}
/>
);
};
describe('SendLaterPopover', function sendLaterPopover() {
describe('render', () => {
it('renders cancel button if scheduled', () => {
const onCancelSendLater = jasmine.createSpy('onCancelSendLater')
const popover = makePopover({onCancelSendLater, sendLaterDate: 'date'})
const button = popover.find('.btn-cancel')
button.simulate('click')
expect(onCancelSendLater).toHaveBeenCalled()
});
});
});
================================================
FILE: packages/client-app/internal_packages/send-later/stylesheets/send-later-used-modal.less
================================================
@import "ui-variables";
.feature-usage-modal.send-later {
@send-later-color: #777ff0;
.feature-header {
@from: @send-later-color;
@to: lighten(@send-later-color, 10%);
background: linear-gradient(to top, @from, @to);
}
.feature-name {
color: @send-later-color;
}
.pro-description {
li {
&:before {
color: @send-later-color;
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/send-later/stylesheets/send-later.less
================================================
@import "ui-variables";
.send-later-popover {
.btn-cancel {
width: 100%;
}
}
.btn-send-later {
.at {
margin-left: 3px;
}
}
.send-later-status {
display: flex;
align-items: center;
.time {
font-size: 0.9em;
opacity: 0.62;
color: @component-active-color;
font-weight: @font-weight-normal;
}
img {
width: 38px;
margin-left: 15px;
}
}
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/main.es6
================================================
import {ComponentRegistry, ExtensionRegistry} from 'nylas-exports';
import {HasTutorialTip} from 'nylas-component-kit';
import SendRemindersThreadTimestamp from './send-reminders-thread-timestamp';
import SendRemindersComposerButton from './send-reminders-composer-button';
import SendRemindersToolbarButton from './send-reminders-toolbar-button';
import {ThreadHeader, MessageHeader} from './send-reminders-headers';
import SendRemindersStore from './send-reminders-store';
import * as ThreadListExtension from './send-reminders-thread-list-extension';
import * as AccountSidebarExtension from './send-reminders-account-sidebar-extension';
const ComposerButtonWithTip = HasTutorialTip(SendRemindersComposerButton, {
title: "Get reminded!",
instructions: "Get reminded if you don't receive a reply for this message within a specified time.",
});
export function activate() {
ComponentRegistry.register(ComposerButtonWithTip, {role: 'Composer:ActionButton'})
ComponentRegistry.register(SendRemindersToolbarButton, {role: 'ThreadActionsToolbarButton'});
ComponentRegistry.register(SendRemindersThreadTimestamp, {role: 'ThreadListTimestamp'});
ComponentRegistry.register(MessageHeader, {role: 'MessageHeader'});
ComponentRegistry.register(ThreadHeader, {role: 'MessageListHeaders'});
ExtensionRegistry.ThreadList.register(ThreadListExtension)
ExtensionRegistry.AccountSidebar.register(AccountSidebarExtension)
SendRemindersStore.activate()
}
export function deactivate() {
ComponentRegistry.unregister(ComposerButtonWithTip)
ComponentRegistry.unregister(SendRemindersToolbarButton)
ComponentRegistry.unregister(SendRemindersThreadTimestamp);
ComponentRegistry.unregister(MessageHeader);
ComponentRegistry.unregister(ThreadHeader);
ExtensionRegistry.ThreadList.unregister(ThreadListExtension)
ExtensionRegistry.AccountSidebar.unregister(AccountSidebarExtension)
SendRemindersStore.deactivate()
}
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-account-sidebar-extension.es6
================================================
import SendRemindersMailboxPerspective from './send-reminders-mailbox-perspective'
export const name = 'SendRemindersAccountSidebarExtension'
export function sidebarItem(accountIds) {
return {
id: 'Reminders',
name: 'Reminders',
iconName: 'reminders.png',
perspective: new SendRemindersMailboxPerspective(accountIds),
}
}
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-composer-button.jsx
================================================
import React, {Component, PropTypes} from 'react'
import ReactDOM from 'react-dom'
import {Actions} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import SendRemindersPopover from './send-reminders-popover'
import {setDraftReminder, reminderDateForMessage, getReminderLabel} from './send-reminders-utils'
class SendRemindersComposerButton extends Component {
static displayName = 'SendRemindersComposerButton';
static containerRequired = false;
static propTypes = {
draft: PropTypes.object.isRequired,
session: PropTypes.object.isRequired,
};
constructor(props) {
super(props)
this.state = {
saving: false,
}
}
componentWillReceiveProps() {
if (this.state.saving) {
this.setState({saving: false})
}
}
shouldComponentUpdate(nextProps) {
if (reminderDateForMessage(nextProps.draft) !== reminderDateForMessage(this.props.draft)) {
return true;
}
return false;
}
onSetReminder = (reminderDate, dateLabel) => {
const {draft, session} = this.props
this.setState({saving: true})
setDraftReminder(draft.accountId, session, reminderDate, dateLabel)
}
onClick = () => {
const {draft} = this.props
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
Actions.openPopover(
this.onSetReminder(null)}
/>,
{originRect: buttonRect, direction: 'up'}
)
};
render() {
const {saving} = this.state
let className = 'btn btn-toolbar btn-send-reminder';
if (saving) {
return (
);
}
const {draft} = this.props
const reminderDate = reminderDateForMessage(draft);
let reminderLabel = 'Set reminder';
if (reminderDate) {
className += ' btn-enabled';
reminderLabel = getReminderLabel(reminderDate, {fromNow: true})
}
return (
);
}
}
export default SendRemindersComposerButton
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-constants.es6
================================================
import plugin from '../package.json'
export const PLUGIN_ID = plugin.name;
export const PLUGIN_NAME = "Send Reminders"
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-headers.jsx
================================================
import React, {PropTypes} from 'react'
import {RetinaImg} from 'nylas-component-kit'
import {FocusedPerspectiveStore} from 'nylas-exports'
import {getReminderLabel, getLatestMessage, getLatestMessageWithReminder, setMessageReminder} from './send-reminders-utils'
import {PLUGIN_ID} from './send-reminders-constants'
export function MessageHeader(props) {
const {thread, messages, message} = props
const {shouldNotify} = thread.metadataForPluginId(PLUGIN_ID) || {}
if (!shouldNotify) {
return
}
const latestMessage = getLatestMessage(thread, messages)
if (message.id !== latestMessage.id) {
return
}
return (
Reminder
)
}
MessageHeader.displayName = 'MessageHeader'
MessageHeader.containerRequired = false
MessageHeader.propTypes = {
messages: PropTypes.array,
message: PropTypes.object,
thread: PropTypes.object,
}
export function ThreadHeader(props) {
const {thread, messages} = props
const message = getLatestMessageWithReminder(thread, messages)
if (!message) {
return
}
const {expiration} = message.metadataForPluginId(PLUGIN_ID) || {}
const clearReminder = () => {
setMessageReminder(message.accountId, message, null)
}
return (
{` ${getReminderLabel(expiration)}`}
Cancel
)
}
ThreadHeader.displayName = 'ThreadHeader'
ThreadHeader.containerRequired = false
ThreadHeader.propTypes = {
thread: PropTypes.object,
messages: PropTypes.array,
}
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-mailbox-perspective.es6
================================================
import {
MailboxPerspective,
} from 'nylas-exports'
import SendRemindersQuerySubscription from './send-reminders-query-subscription'
class SendRemindersMailboxPerspective extends MailboxPerspective {
constructor(accountIds) {
super(accountIds)
this.accountIds = accountIds
this.name = 'Reminders'
this.iconName = 'reminders.png'
}
get isReminders() {
return true
}
emptyMessage() {
return "No reminders set"
}
threads() {
return new SendRemindersQuerySubscription(this.accountIds)
}
canReceiveThreadsFromAccountIds() {
return false
}
canArchiveThreads() {
return false
}
canTrashThreads() {
return false
}
canMoveThreadsTo() {
return false
}
}
export default SendRemindersMailboxPerspective
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-popover-button.jsx
================================================
import React, {Component, PropTypes} from 'react';
import ReactDOM from 'react-dom';
import {Rx, Actions, Message, DatabaseStore} from 'nylas-exports';
import {RetinaImg, ListensToObservable} from 'nylas-component-kit';
import SendRemindersPopover from './send-reminders-popover';
import {getLatestMessage, setMessageReminder, reminderDateForMessage} from './send-reminders-utils'
function getMessageObservable({thread} = {}) {
if (!thread) { return Rx.Observable.empty() }
const latestMessage = getLatestMessage(thread) || {}
const query = DatabaseStore.find(Message, latestMessage.id)
return Rx.Observable.fromQuery(query)
}
function getStateFromObservable(message, {props}) {
const {thread} = props
const latestMessage = message || getLatestMessage(thread)
return {latestMessage}
}
class SendRemindersPopoverButton extends Component {
static displayName = 'SendRemindersPopoverButton';
static propTypes = {
className: PropTypes.string,
thread: PropTypes.object,
latestMessage: PropTypes.object,
direction: PropTypes.string,
getBoundingClientRect: PropTypes.func,
};
static defaultProps = {
className: 'btn btn-toolbar',
direction: 'down',
getBoundingClientRect: (inst) => ReactDOM.findDOMNode(inst).getBoundingClientRect(),
};
onSetReminder = (reminderDate, dateLabel) => {
const {latestMessage, thread} = this.props
setMessageReminder(latestMessage.accountId, latestMessage, reminderDate, dateLabel, thread)
}
onClick = (event) => {
event.stopPropagation()
const {direction, latestMessage, getBoundingClientRect} = this.props
const reminderDate = reminderDateForMessage(latestMessage)
const buttonRect = getBoundingClientRect(this)
Actions.openPopover(
this.onSetReminder(null)}
/>,
{originRect: buttonRect, direction}
)
};
render() {
const {className, latestMessage} = this.props
const reminderDate = reminderDateForMessage(latestMessage)
const title = reminderDate ? 'Edit reminder' : 'Set reminder';
return (
);
}
}
export default ListensToObservable(SendRemindersPopoverButton, {
getObservable: getMessageObservable,
getStateFromObservable,
})
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-popover.jsx
================================================
import React, {PropTypes} from 'react'
import {DateUtils} from 'nylas-exports'
import {DatePickerPopover} from 'nylas-component-kit'
import {getReminderLabel} from './send-reminders-utils'
const SendRemindersOptions = {
'In 1 hour': DateUtils.in1Hour,
'In 2 hours': DateUtils.in2Hours,
'In 4 hours': () => DateUtils.minutesFromNow(240),
'Tomorrow morning': DateUtils.tomorrow,
'Tomorrow evening': DateUtils.tomorrowEvening,
'In 2 days': () => DateUtils.hoursFromNow(48),
'In 4 days': () => DateUtils.hoursFromNow(96),
'In 1 week': () => DateUtils.weeksFromNow(1),
'In 2 weeks': () => DateUtils.weeksFromNow(2),
'In 1 month': () => DateUtils.monthsFromNow(1),
}
function SendRemindersPopover(props) {
const {reminderDate, onRemind, onCancelReminder} = props
const header = Remind me if no one replies:
const footer = [
reminderDate ?
: null,
reminderDate ?
This thread will come back to the top of your inbox if nobody replies by:
{` ${getReminderLabel(reminderDate)}`}
Clear reminder
:
null,
]
return (
);
}
SendRemindersPopover.displayName = 'SendRemindersPopover';
SendRemindersPopover.propTypes = {
reminderDate: PropTypes.string,
onRemind: PropTypes.func,
onCancelReminder: PropTypes.func,
};
export default SendRemindersPopover
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-query-subscription.es6
================================================
import {
Thread,
DatabaseStore,
MutableQuerySubscription,
} from 'nylas-exports'
import {observableForThreadsWithReminders} from './send-reminders-utils'
class SendRemindersQuerySubscription extends MutableQuerySubscription {
constructor(accountIds) {
super(null, {emitResultSet: true})
this._disposable = null
this._accountIds = accountIds
setImmediate(() => this.fetchThreadsWithReminders())
}
replaceRange = () => {
// TODO
}
fetchThreadsWithReminders() {
this._disposable = observableForThreadsWithReminders(this._accountIds, {emitIds: true})
.subscribe((threadIds) => {
const threadQuery = (
DatabaseStore.findAll(Thread)
.where({id: threadIds})
.order(Thread.attributes.lastMessageReceivedTimestamp.descending())
)
this.replaceQuery(threadQuery)
})
}
onLastCallbackRemoved() {
if (this._disposable) {
this._disposable.dispose()
}
}
}
export default SendRemindersQuerySubscription
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-store.es6
================================================
import {Actions, FocusedContentStore} from 'nylas-exports'
import {PLUGIN_ID} from './send-reminders-constants'
import {
getLatestMessage,
setMessageReminder,
getLatestMessageWithReminder,
asyncUpdateFromSentMessage,
observableForThreadsWithReminders,
} from './send-reminders-utils'
class SendRemindersStore {
activate() {
this._lastFocusedThread = null
this._unsubscribers = [
FocusedContentStore.listen(this._onFocusedContentChanged),
Actions.draftDeliverySucceeded.listen(asyncUpdateFromSentMessage),
]
this._disposables = [
observableForThreadsWithReminders().subscribe(this._onThreadsWithRemindersChanged),
]
}
_onFocusedContentChanged = () => {
const thread = FocusedContentStore.focused('thread') || null
const didUnfocusLastThread = (
(!thread && this._lastFocusedThread) ||
(thread && this._lastFocusedThread && thread.id !== this._lastFocusedThread.id)
)
// When we unfocus a thread that had `shouldNotify == true`, it means that
// we have acknowledged the notification, or in this case, the reminder. If
// that's the case, set `shouldNotify` to false.
if (didUnfocusLastThread) {
const {shouldNotify} = this._lastFocusedThread.metadataForPluginId(PLUGIN_ID) || {}
if (shouldNotify) {
const nextMetadata = {shouldNotify: false}
Actions.setMetadata(this._lastFocusedThread.clone(), PLUGIN_ID, nextMetadata)
}
}
this._lastFocusedThread = thread
}
_onThreadsWithRemindersChanged = (threads) => {
// If a new message was received on the thread, clear the reminder
threads.forEach((thread) => {
const {accountId} = thread
thread.messages().then((messages) => {
const latestMessage = getLatestMessage(thread, messages)
const latestMessageWithReminder = getLatestMessageWithReminder(thread, messages)
if (!latestMessageWithReminder) { return }
if (latestMessage.id !== latestMessageWithReminder.id) {
setMessageReminder(accountId, latestMessageWithReminder, null)
}
})
})
}
deactivate() {
this._unsubscribers.forEach((unsub) => unsub())
this._disposables.forEach((disp) => disp.dispose())
}
}
export default new SendRemindersStore()
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-thread-list-extension.es6
================================================
import {PLUGIN_ID} from './send-reminders-constants'
import {getLatestMessageWithReminder} from './send-reminders-utils'
export const name = 'SendRemindersThreadListExtension'
export function cssClassNamesForThreadListItem(thread) {
const {shouldNotify} = thread.metadataForPluginId(PLUGIN_ID) || {}
if (shouldNotify) {
return 'thread-list-reminder-item'
}
return ''
}
export function cssClassNamesForThreadListIcon(thread) {
const {shouldNotify} = thread.metadataForPluginId(PLUGIN_ID) || {}
if (shouldNotify) {
return 'thread-icon-reminder-triggered'
}
if (getLatestMessageWithReminder(thread)) {
return 'thread-icon-reminder-pending'
}
return ''
}
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-thread-timestamp.jsx
================================================
import React, {Component, PropTypes} from 'react';
import {RetinaImg} from 'nylas-component-kit';
import {Rx, Message, DatabaseStore, FocusedPerspectiveStore} from 'nylas-exports';
import {getReminderLabel, getLatestMessageWithReminder, setMessageReminder} from './send-reminders-utils'
import {PLUGIN_ID} from './send-reminders-constants';
function canRenderTimestamp(message) {
const current = FocusedPerspectiveStore.current()
if (!current.isReminders) {
return false
}
if (!message) {
return false
}
return true
}
class SendRemindersThreadTimestamp extends Component {
static displayName = 'SendRemindersThreadTimestamp';
static propTypes = {
thread: PropTypes.object,
messages: PropTypes.array,
fallback: PropTypes.func,
};
static containerRequired = false;
constructor(props) {
super(props)
this._disposable = null
this.state = {
message: getLatestMessageWithReminder(props.thread, props.messages),
}
}
componentDidMount() {
const {message} = this.state
this.setupMessageObservable(message)
}
componentWillReceiveProps(nextProps) {
const {thread, messages} = nextProps
const message = getLatestMessageWithReminder(thread, messages)
this.disposeMessageObservable()
if (!message) {
this.setState({message})
} else {
this.setupMessageObservable(message)
}
}
componentWillUnmount() {
this.disposeMessageObservable()
}
onRemoveReminder(message) {
setMessageReminder(message.accountId, message, null)
}
setupMessageObservable(message) {
if (!canRenderTimestamp(message)) { return }
const message$ = Rx.Observable.fromQuery(DatabaseStore.find(Message, message.id))
this._disposable = message$.subscribe((msg) => {
const {expiration} = msg.metadataForPluginId(PLUGIN_ID) || {};
if (!expiration) {
this.setState({message: null})
} else {
this.setState({message: msg})
}
})
}
disposeMessageObservable() {
if (this._disposable) {
this._disposable.dispose()
}
}
render() {
const {message} = this.state;
const Fallback = this.props.fallback;
if (!canRenderTimestamp(message)) {
return
}
const {expiration} = message.metadataForPluginId(PLUGIN_ID);
const title = getReminderLabel(expiration, {fromNow: true})
const shortLabel = getReminderLabel(expiration, {shortFormat: true})
return (
{shortLabel}
)
}
}
export default SendRemindersThreadTimestamp
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-toolbar-button.jsx
================================================
import React, {PropTypes} from 'react';
import {HasTutorialTip} from 'nylas-component-kit';
import {getLatestMessage} from './send-reminders-utils'
import SendRemindersPopoverButton from './send-reminders-popover-button';
const SendRemindersPopoverButtonWithTip = HasTutorialTip(SendRemindersPopoverButton, {
title: "Get reminded!",
instructions: "Get reminded if you don't receive a reply for this message within a specified time.",
});
function canSetReminderOnThread(thread) {
const {from} = getLatestMessage(thread) || {}
return (
from && from.length > 0 && from[0].isMe()
)
}
export default function SendRemindersToolbarButton(props) {
const threads = props.items
if (threads.length > 1) {
return ;
}
const thread = threads[0]
if (!canSetReminderOnThread(thread)) {
return ;
}
return (
);
}
SendRemindersToolbarButton.containerRequired = false;
SendRemindersToolbarButton.displayName = 'SendRemindersToolbarButton';
SendRemindersToolbarButton.propTypes = {
items: PropTypes.array,
};
================================================
FILE: packages/client-app/internal_packages/send-reminders/lib/send-reminders-utils.jsx
================================================
import moment from 'moment'
import {
Rx,
Thread,
Message,
Actions,
Category,
NylasAPIHelpers,
DateUtils,
DatabaseStore,
FeatureUsageStore,
} from 'nylas-exports'
import {PLUGIN_ID, PLUGIN_NAME} from './send-reminders-constants'
const {DATE_FORMAT_LONG_NO_YEAR} = DateUtils
export function reminderDateForMessage(message) {
if (!message) {
return null;
}
const messageMetadata = message.metadataForPluginId(PLUGIN_ID) || {};
return messageMetadata.expiration;
}
async function asyncBuildMetadata({message, thread, expiration} = {}) {
if (message) { // Not a draft
let messageIdHeaders = [message.messageIdHeader];
let folderImapNames = [];
let hasPrimary = false; // whether folderImapNames includes All Mail or Inbox
// There won't be a thread if this is a newly sent draft that wasn't a reply.
if (thread) {
// We need to include the hidden messages so the cloud-worker doesn't think
// that previously hidden messages are new replies to the thread.
const messages = await thread.messages({includeHidden: true})
messageIdHeaders = messages.map(msg => msg.messageIdHeader)
const folders = thread.categories.filter(c => c.object === 'folder')
hasPrimary = folders.some(f => ['all', 'inbox'].includes(f.name))
folderImapNames = folders.map(f => f.imapName).filter(name => name)
}
// We always want to check the main inbox folder for replies
if (!hasPrimary) {
let primary = await DatabaseStore.findBy(Category, {name: 'all', accountId: message.accountId})
if (!primary) {
primary = await DatabaseStore.findBy(Category, {name: 'inbox', accountId: message.accountId})
}
const primaryName = primary ? primary.imapName : null;
if (primaryName) {
folderImapNames.unshift(primaryName); // Put it at the front so we check it first
}
}
return {
expiration,
folderImapNames,
messageIdHeaders,
replyTo: message.messageIdHeader,
subject: message.subject,
}
}
// else: this is a draft, the rest of the metadata will be updated after send.
return {expiration}
}
export async function asyncUpdateFromSentMessage({messageClientId}) {
const message = await DatabaseStore.findBy(Message, {clientId: messageClientId})
if (!message) {
throw new Error("SendReminders: Could not find message to update")
}
const {expiration} = message.metadataForPluginId(PLUGIN_ID) || {}
if (!expiration) {
// This message doesn't have a reminder
return;
}
let thread;
if (message.threadId) {
thread = await DatabaseStore.find(Thread, message.threadId)
} // else: this message wasn't a reply and doesn't have a thread yet
const newMetadata = await asyncBuildMetadata({message, thread, expiration})
Actions.setMetadata(message, PLUGIN_ID, newMetadata)
}
async function asyncSetReminder(accountId, reminderDate, dateLabel, {message, thread, isDraft, draftSession} = {}) {
// Only check for feature usage and record metrics if this message doesn't
// already have a reminder set
if (!reminderDateForMessage(message)) {
const lexicon = {
displayName: "be Reminded",
usedUpHeader: "All reminders used",
iconUrl: "nylas://send-reminders/assets/ic-send-reminders-modal@2x.png",
}
try {
await FeatureUsageStore.asyncUseFeature('send-reminders', {lexicon})
} catch (error) {
if (error instanceof FeatureUsageStore.NoProAccess) {
return
}
}
if (reminderDate && dateLabel) {
const remindInSec = Math.round(((new Date(reminderDate)).valueOf() - Date.now()) / 1000)
Actions.recordUserEvent("Set Reminder", {
timeInSec: remindInSec,
timeInLog10Sec: Math.log10(remindInSec),
label: dateLabel,
});
}
}
let metadata = {}
if (reminderDate) {
metadata = await asyncBuildMetadata({message, thread, expiration: reminderDate})
} // else: we're clearing the reminder and the metadata should remain empty
await NylasAPIHelpers.authPlugin(PLUGIN_ID, PLUGIN_NAME, accountId)
.then(() => {
if (isDraft) {
if (!draftSession) { throw new Error('setDraftReminder: Must provide draftSession') }
draftSession.changes.add({pristine: false})
draftSession.changes.addPluginMetadata(PLUGIN_ID, metadata);
} else {
if (!message) { throw new Error('setMessageReminder: Must provide message') }
Actions.setMetadata(message, PLUGIN_ID, metadata)
}
Actions.closePopover()
})
.catch((error) => {
Actions.closePopover()
NylasEnv.reportError(error);
NylasEnv.showErrorDialog(`Sorry, we were unable to save the reminder for this message. ${error.message}`);
});
}
export function setMessageReminder(accountId, message, reminderDate, dateLabel, thread) {
return asyncSetReminder(accountId, reminderDate, dateLabel, {isDraft: false, message, thread})
}
export function setDraftReminder(accountId, draftSession, reminderDate, dateLabel) {
return asyncSetReminder(accountId, reminderDate, dateLabel, {isDraft: true, draftSession})
}
function reminderThreadIdsFromMessages(messages) {
return Array.from(new Set(
messages
.filter((message) => (message.metadataForPluginId(PLUGIN_ID) || {}).expiration != null)
.map(({threadId}) => threadId)
.filter((threadId) => threadId != null)
))
}
export function observableForThreadsWithReminders(accountIds = [], {emitIds = false} = {}) {
let messagesQuery = (
DatabaseStore.findAll(Message)
.where(Message.attributes.pluginMetadata.contains(PLUGIN_ID))
)
if (accountIds.length === 1) {
messagesQuery = messagesQuery.where({accountId: accountIds[0]})
}
const messages$ = Rx.Observable.fromQuery(messagesQuery)
if (emitIds) {
return messages$.map((messages) => reminderThreadIdsFromMessages(messages))
}
return messages$.flatMapLatest((messages) => {
const threadIds = reminderThreadIdsFromMessages(messages)
const threadsQuery = (
DatabaseStore.findAll(Thread)
.where({id: threadIds})
.order(Thread.attributes.lastMessageReceivedTimestamp.descending())
)
return Rx.Observable.fromQuery(threadsQuery)
})
}
export function getLatestMessage(thread, messages) {
const msgs = messages || thread.__messages || [];
return msgs[msgs.length - 1]
}
export function getLatestMessageWithReminder(thread, messages) {
const msgs = (messages || thread.__messages || []).slice().reverse();
return msgs.find((message) => {
const {expiration} = message.metadataForPluginId(PLUGIN_ID) || {}
return expiration != null
})
}
export function getReminderLabel(reminderDate, {fromNow = false, shortFormat = false} = {}) {
const momentDate = DateUtils.futureDateFromString(reminderDate);
if (shortFormat) {
return momentDate ? `in ${momentDate.fromNow(true)}` : 'now'
}
if (fromNow) {
return momentDate ? `Reminder set for ${momentDate.fromNow(true)} from now` : `Reminder set`;
}
return moment(reminderDate).format(DATE_FORMAT_LONG_NO_YEAR)
}
================================================
FILE: packages/client-app/internal_packages/send-reminders/package.json
================================================
{
"name": "send-reminders",
"version": "1.0.0",
"title": "Send Reminders",
"description": "Get reminded if you don't receive a reply for a message within a specified time in the future",
"isHiddenOnPluginsPage": true,
"icon": "./icon.png",
"main": "lib/main",
"supportedEnvs": ["local", "development", "staging", "production"],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"windowTypes": {
"default": true,
"composer": true
},
"isOptional": true,
"engines": {
"nylas": "*"
},
"license": "GPL-3.0"
}
================================================
FILE: packages/client-app/internal_packages/send-reminders/stylesheets/reminders-used-modal.less
================================================
@import "ui-variables";
.feature-usage-modal.send-reminders {
@send-reminders-color: #517ff2;
.feature-header {
@from: @send-reminders-color;
@to: lighten(@send-reminders-color, 10%);
background: linear-gradient(to top, @from, @to);
}
.feature-name {
color: @send-reminders-color;
}
.pro-description {
li {
&:before {
color: @send-reminders-color;
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/send-reminders/stylesheets/send-reminders.less
================================================
@import "ui-variables";
@reminders-background-color: #f6f6fe;
@reminders-color: #5a31e1;
.send-reminders-popover {
.section.send-reminders-footer {
.reminders-label {
color: @text-color-very-subtle;
font-size: 0.8em;
min-height: 36px;
.reminder-date {
font-weight: bold;
}
}
.btn-cancel {
width: 100%;
margin-top: 5px;
}
}
}
.send-reminders-toolbar-button {
order: -102;
}
.thread-list {
.list-item.focused {
.timestamp.send-reminders-thread-timestamp {
color: #fff;
img {
background-color: #fff;
}
}
}
.timestamp.send-reminders-thread-timestamp {
opacity: 0.82;
color: #979797;
img {
background-color: #979797;
margin-top: -3px;
padding-right: 6px;
}
}
}
.send-reminders-header {
color: @reminders-color;
background: linear-gradient(to bottom, #fbfafe 0%, #fff 25%);
padding-top: 13px;
padding-bottom: 10px;
cursor: default;
margin-top: -19px;
margin-bottom: 15px;
border-bottom: 1px solid #e7e1fb;
img {
background-color: @reminders-color;
margin-top: -6px;
margin-right: 10px;
}
}
.message-list-headers .send-reminders-header {
padding-left: 15px;
margin-top: 0;
.reminder-date {
font-weight: bold;
}
.clear-reminder {
position: absolute;
right: 18px;
cursor: pointer;
text-decoration: underline;
}
}
.thread-list .list-item.thread-list-reminder-item {
background-color: @reminders-background-color;
&.unread {
background-color: @reminders-background-color;
&:not(.focused):not(.selected) {
background-color: @reminders-background-color;
}
}
&.focused,&.selected {
background: @list-focused-bg;
}
.thread-icon-reminder-triggered {
margin-top: 1px;
background-image:url(../static/images/thread-list/icon-reminder@2x.png);
}
}
.thread-list .list-item {
.thread-icon-reminder-pending {
margin-top: 1px;
background-image:url(../static/images/thread-list/icon-reminder-outline@2x.png);
}
}
================================================
FILE: packages/client-app/internal_packages/sync-health-checker/lib/main.es6
================================================
import SyncHealthChecker from './sync-health-checker'
export function activate() {
SyncHealthChecker.start()
}
export function deactivate() {
SyncHealthChecker.stop()
}
================================================
FILE: packages/client-app/internal_packages/sync-health-checker/lib/sync-health-checker.es6
================================================
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()
================================================
FILE: packages/client-app/internal_packages/sync-health-checker/package.json
================================================
{
"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": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/sync-health-checker/spec/sync-health-checker-spec.es6
================================================
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)
})
})
})
================================================
FILE: packages/client-app/internal_packages/system-tray/lib/main.es6
================================================
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() {
}
================================================
FILE: packages/client-app/internal_packages/system-tray/lib/system-tray-icon-store.es6
================================================
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;
================================================
FILE: packages/client-app/internal_packages/system-tray/package.json
================================================
{
"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": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/system-tray/spec/system-tray-icon-store-spec.es6
================================================
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)
});
});
});
================================================
FILE: packages/client-app/internal_packages/theme-picker/lib/main.jsx
================================================
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();
}
================================================
FILE: packages/client-app/internal_packages/theme-picker/lib/theme-option.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/theme-picker/lib/theme-picker.jsx
================================================
/* 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;
================================================
FILE: packages/client-app/internal_packages/theme-picker/package.json
================================================
{
"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": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/theme-picker/preview-styles/theme-option.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/theme-picker/spec/theme-picker-spec.jsx
================================================
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();
});
});
================================================
FILE: packages/client-app/internal_packages/theme-picker/styles/theme-picker.less
================================================
@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;
}
}
}
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/category-removal-target-rulesets.es6
================================================
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
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/injects-toolbar-buttons.jsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/main.es6
================================================
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);
}
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/message-list-toolbar.jsx
================================================
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)
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/selected-items-stack.jsx
================================================
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})
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list-columns.cjsx
================================================
_ = 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)}
module.exports =
Narrow: [cNarrow]
Wide: [c1, c2, c3, c4, c5]
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list-context-menu.es6
================================================
/* 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());
});
}
}
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list-data-source.es6
================================================
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;
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list-icon.cjsx
================================================
_ = 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
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list-participants.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list-quick-actions.cjsx
================================================
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 }
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list-scroll-tooltip.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list-store.coffee
================================================
_ = 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()
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list-toolbar.jsx
================================================
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)
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-list.cjsx
================================================
_ = 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
================================================
FILE: packages/client-app/internal_packages/thread-list/lib/thread-toolbar-buttons.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/thread-list/package.json
================================================
{
"name": "thread-list",
"version": "0.1.0",
"main": "./lib/main",
"description": "View threads using React",
"license": "GPL-3.0",
"private": true,
"engines": {
"nylas": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/thread-list/spec/category-removal-target-rulesets-spec.es6
================================================
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)
});
});
});
});
================================================
FILE: packages/client-app/internal_packages/thread-list/spec/thread-list-column-spec.coffee
================================================
================================================
FILE: packages/client-app/internal_packages/thread-list/spec/thread-list-participants-spec.cjsx
================================================
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')
================================================
FILE: packages/client-app/internal_packages/thread-list/spec/thread-list-spec.cjsx
================================================
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)
================================================
FILE: packages/client-app/internal_packages/thread-list/spec/thread-toolbar-buttons-spec.cjsx
================================================
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()
================================================
FILE: packages/client-app/internal_packages/thread-list/stylesheets/selected-items-stack.less
================================================
@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;
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/thread-list/stylesheets/thread-list.less
================================================
@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;
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/thread-search/README.md
================================================
# React version of thread list
================================================
FILE: packages/client-app/internal_packages/thread-search/lib/main.es6
================================================
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)
}
================================================
FILE: packages/client-app/internal_packages/thread-search/lib/search-actions.es6
================================================
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
================================================
FILE: packages/client-app/internal_packages/thread-search/lib/search-mailbox-perspective.es6
================================================
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;
================================================
FILE: packages/client-app/internal_packages/thread-search/lib/search-query-subscription.es6
================================================
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
================================================
FILE: packages/client-app/internal_packages/thread-search/lib/search-store.es6
================================================
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();
================================================
FILE: packages/client-app/internal_packages/thread-search/lib/thread-search-bar.jsx
================================================
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(),
};
},
})
================================================
FILE: packages/client-app/internal_packages/thread-search/package.json
================================================
{
"name": "thread-search",
"version": "0.1.0",
"main": "./lib/main",
"description": "Search for threads",
"license": "GPL-3.0",
"private": true,
"engines": {
"nylas": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/thread-search/spec/search-bar-spec.cjsx
================================================
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)
================================================
FILE: packages/client-app/internal_packages/thread-search/stylesheets/thread-search-bar.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/thread-sharing/lib/copy-button.jsx
================================================
import React from 'react'
import {Utils} from 'nylas-exports';
import {clipboard} from 'electron';
class CopyButton extends React.Component {
static propTypes = {
btnLabel: React.PropTypes.string,
copyValue: React.PropTypes.string,
}
constructor(props) {
super(props)
this.state = {
btnLabel: props.btnLabel,
}
this._timeout = null
}
componentWillReceiveProps(nextProps) {
clearTimeout(this._timeout)
this._timeout = null
this.setState({btnLabel: nextProps.btnLabel})
}
componentWillUnmount() {
clearTimeout(this._timeout)
}
_onCopy = () => {
if (this._timeout) { return }
const {copyValue, btnLabel} = this.props
clipboard.writeText(copyValue)
this.setState({btnLabel: 'Copied!'})
this._timeout = setTimeout(() => {
this._timeout = null
this.setState({btnLabel: btnLabel})
}, 2000)
}
render() {
const {btnLabel} = this.state
const otherProps = Utils.fastOmit(this.props, Object.keys(CopyButton.propTypes));
return (
{btnLabel}
)
}
}
export default CopyButton
================================================
FILE: packages/client-app/internal_packages/thread-sharing/lib/external-threads.es6
================================================
import url from 'url'
import querystring from 'querystring';
import {ipcRenderer} from 'electron';
import {DatabaseStore, Thread, Matcher, Actions} from "nylas-exports";
const DATE_EPSILON = 60 // Seconds
const parseOpenThreadUrl = (nylasUrlString) => {
const parsedUrl = url.parse(nylasUrlString)
const params = querystring.parse(parsedUrl.query)
params.lastDate = parseInt(params.lastDate, 10)
return params;
}
const findCorrespondingThread = ({subject, lastDate}, dateEpsilon = DATE_EPSILON) => {
return DatabaseStore.findBy(Thread).where([
Thread.attributes.subject.equal(subject),
new Matcher.Or([
new Matcher.And([
Thread.attributes.lastMessageSentTimestamp.lessThan(lastDate + dateEpsilon),
Thread.attributes.lastMessageSentTimestamp.greaterThan(lastDate - dateEpsilon),
]),
new Matcher.And([
Thread.attributes.lastMessageReceivedTimestamp.lessThan(lastDate + dateEpsilon),
Thread.attributes.lastMessageReceivedTimestamp.greaterThan(lastDate - dateEpsilon),
]),
]),
])
}
const _openExternalThread = (event, nylasUrl) => {
const {subject, lastDate} = parseOpenThreadUrl(nylasUrl);
findCorrespondingThread({subject, lastDate})
.then((thread) => {
if (!thread) {
throw new Error('Thread not found')
}
Actions.popoutThread(thread);
})
.catch((error) => {
NylasEnv.reportError(error)
NylasEnv.showErrorDialog(`The thread ${subject} does not exist in your mailbox!`)
})
}
const activate = () => {
ipcRenderer.on('openExternalThread', _openExternalThread)
}
const deactivate = () => {
ipcRenderer.removeListener('openExternalThread', _openExternalThread)
}
export default {activate, deactivate}
================================================
FILE: packages/client-app/internal_packages/thread-sharing/lib/main.es6
================================================
import {ComponentRegistry} from 'nylas-exports';
import ThreadSharingButton from "./thread-sharing-button";
import ExternalThreads from "./external-threads"
export function activate() {
ComponentRegistry.register(ThreadSharingButton, {
role: 'ThreadActionsToolbarButton',
});
ExternalThreads.activate();
}
export function deactivate() {
ComponentRegistry.unregister(ThreadSharingButton);
ExternalThreads.deactivate();
}
================================================
FILE: packages/client-app/internal_packages/thread-sharing/lib/thread-sharing-button.jsx
================================================
import {Actions, React, ReactDOM} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import ThreadSharingPopover from './thread-sharing-popover';
export default class ThreadSharingButton extends React.Component {
static displayName = 'ThreadSharingButton';
static containerRequired = false;
static propTypes = {
items: React.PropTypes.array,
thread: React.PropTypes.object,
};
componentWillReceiveProps(nextProps) {
if (nextProps.thread.id !== this.props.thread.id) {
Actions.closePopover()
}
}
_onClick = () => {
const {thread} = this.props;
Actions.openPopover(
,
{
originRect: ReactDOM.findDOMNode(this).getBoundingClientRect(),
direction: 'down',
}
)
}
render() {
if (this.props.items && this.props.items.length > 1) {
return
}
return (
)
}
}
================================================
FILE: packages/client-app/internal_packages/thread-sharing/lib/thread-sharing-constants.es6
================================================
import plugin from '../package.json'
export const PLUGIN_NAME = plugin.title
export const PLUGIN_ID = plugin.name;
export const PLUGIN_URL = plugin.serverUrl[NylasEnv.config.get("env")];
================================================
FILE: packages/client-app/internal_packages/thread-sharing/lib/thread-sharing-popover.jsx
================================================
/* eslint jsx-a11y/tabindex-no-positive: 0 */
import React from 'react'
import ReactDOM from 'react-dom'
import classnames from 'classnames';
import {Rx, Actions, NylasAPIHelpers, Thread, DatabaseStore} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import CopyButton from './copy-button';
import {PLUGIN_ID, PLUGIN_NAME, PLUGIN_URL} from './thread-sharing-constants';
function isShared(thread) {
const metadata = thread.metadataForPluginId(PLUGIN_ID) || {};
return metadata.shared || false;
}
export default class ThreadSharingPopover extends React.Component {
static propTypes = {
thread: React.PropTypes.object,
accountId: React.PropTypes.string,
}
constructor(props) {
super(props);
this.state = {
shared: isShared(props.thread),
saving: false,
}
this._disposable = {dispose: () => {}}
}
componentDidMount() {
const {thread} = this.props;
this._mounted = true;
this._disposable = Rx.Observable.fromQuery(DatabaseStore.find(Thread, thread.id))
.subscribe((t) => this.setState({shared: isShared(t)}))
}
componentDidUpdate() {
ReactDOM.findDOMNode(this).focus()
}
componentWillUnmount() {
this._disposable.dispose();
this._mounted = false;
}
_onToggleShared = () => {
const {thread, accountId} = this.props;
const {shared} = this.state;
this.setState({saving: true});
NylasAPIHelpers.authPlugin(PLUGIN_ID, PLUGIN_NAME, accountId)
.then(() => {
if (!this._mounted) { return; }
if (!shared === true) {
Actions.recordUserEvent("Thread Sharing Enabled", {accountId, threadId: thread.id})
}
Actions.setMetadata(thread, PLUGIN_ID, {shared: !shared})
})
.catch((error) => {
NylasEnv.reportError(error);
NylasEnv.showErrorDialog(`Sorry, we were unable to update your sharing settings.\n\n${error.message}`)
})
.finally(() => {
if (!this._mounted) { return; }
this.setState({saving: false})
});
}
_onClickInput = (event) => {
const input = event.target
input.select()
}
render() {
const {thread, accountId} = this.props;
const {shared, saving} = this.state;
const url = `${PLUGIN_URL}/thread/${accountId}/${thread.id}`
const shareMessage = shared ? 'Anyone with the link can read the thread' : 'Sharing is disabled';
const classes = classnames({
'thread-sharing-popover': true,
'disabled': !shared,
})
const control = saving ? (
) : (
);
// tabIndex is necessary for the popover's onBlur events to work properly
return (
{control}
Share this thread
{shareMessage}
Open in browser
)
}
}
================================================
FILE: packages/client-app/internal_packages/thread-sharing/package.json
================================================
{
"name": "thread-sharing",
"version": "0.1.0",
"serverUrl": {
"development": "http://localhost:5009",
"staging": "https://share-staging.nylas.com",
"production": "https://share.nylas.com"
},
"title": "Thread Sharing",
"description": "Share a thread through the web.",
"main": "./lib/main",
"private": true,
"engines": {
"nylas": "*"
},
"license": "GPL-3.0"
}
================================================
FILE: packages/client-app/internal_packages/thread-sharing/stylesheets/main.less
================================================
@import "ui-variables";
.thread-sharing-button {
order: -102;
}
.fixed-popover .thread-sharing-popover {
position: relative;
width: 265px;
.share-toggle {
padding: 7px 10px;
input {
margin-right: @spacing-half;
}
}
.share-input {
border-top: 2px solid rgba(0, 0, 0, 0.15);
padding: 10px 5px 0 5px;
input[type="text"] {
cursor: default;
-webkit-user-select: all;
}
}
.share-controls {
text-align: center;
padding: 10px;
.share-message {
color: @text-color-very-subtle;
margin-bottom: 10px;
font-size: @font-size-smaller;
}
button.btn + button.btn {
margin-left: 10px;
}
}
&.disabled {
.share-input input[type="text"] {
color: @text-color-very-subtle;
-webkit-user-select: none;
}
button.btn {
color: @text-color-very-subtle;
}
}
}
================================================
FILE: packages/client-app/internal_packages/thread-snooze/README.md
================================================
================================================
FILE: packages/client-app/internal_packages/thread-snooze/lib/main.es6
================================================
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() {
}
================================================
FILE: packages/client-app/internal_packages/thread-snooze/lib/snooze-actions.es6
================================================
import Reflux from 'reflux';
const SnoozeActions = Reflux.createActions([
'snoozeThreads',
])
for (const key of Object.keys(SnoozeActions)) {
SnoozeActions[key].sync = true
}
export default SnoozeActions
================================================
FILE: packages/client-app/internal_packages/thread-snooze/lib/snooze-buttons.jsx
================================================
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 (
);
}
}
================================================
FILE: packages/client-app/internal_packages/thread-snooze/lib/snooze-constants.es6
================================================
import plugin from '../package.json'
export const PLUGIN_ID = plugin.name;
export const PLUGIN_NAME = "Snooze Plugin"
export const SNOOZE_CATEGORY_NAME = "N1-Snoozed"
================================================
FILE: packages/client-app/internal_packages/thread-snooze/lib/snooze-mail-label.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/thread-snooze/lib/snooze-popover.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/thread-snooze/lib/snooze-store.jsx
================================================
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;
================================================
FILE: packages/client-app/internal_packages/thread-snooze/lib/snooze-utils.es6
================================================
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
================================================
FILE: packages/client-app/internal_packages/thread-snooze/package.json
================================================
{
"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"
}
================================================
FILE: packages/client-app/internal_packages/thread-snooze/spec/snooze-store-spec.es6
================================================
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()
})
})
});
});
})
================================================
FILE: packages/client-app/internal_packages/thread-snooze/spec/snooze-utils-spec.es6
================================================
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)
})
})
});
});
});
================================================
FILE: packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-feature-used-modal.less
================================================
@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;
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-mail-label.less
================================================
@snooze-color: #472B82;
.snooze-mail-label {
display: flex;
align-items: center;
img {
background-color: @snooze-color;
margin-right: 5px;
}
}
================================================
FILE: packages/client-app/internal_packages/thread-snooze/stylesheets/snooze-popover.less
================================================
@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;
}
}
}
================================================
FILE: packages/client-app/internal_packages/ui-dark/LICENSE.md
================================================
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.
================================================
FILE: packages/client-app/internal_packages/ui-dark/README.md
================================================
# 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.
================================================
FILE: packages/client-app/internal_packages/ui-dark/package.json
================================================
{
"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
}
================================================
FILE: packages/client-app/internal_packages/ui-dark/styles/email-frame.less
================================================
.ignore-in-parent-frame {
body {
-webkit-filter: invert() hue-rotate(180deg);
color: #111;
}
img {
-webkit-filter: invert() hue-rotate(180deg);
}
}
================================================
FILE: packages/client-app/internal_packages/ui-dark/styles/ui-variables.less
================================================
@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%);
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/LICENSE
================================================
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.
================================================
FILE: packages/client-app/internal_packages/ui-darkside/README.md
================================================
# 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).
================================================
FILE: packages/client-app/internal_packages/ui-darkside/index.less
================================================
@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";
================================================
FILE: packages/client-app/internal_packages/ui-darkside/package.json
================================================
{
"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
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-composer.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-drafts.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-inputs.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-labels.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-message-list.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-notifications.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-preferences.less
================================================
@import "darkside-variables";
.preferences-sidebar,
.preferences-content {
background: @messagelist-bg;
}
.preferences-wrap .preferences-content > .scroll-region-content {
padding-bottom: 100px;
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-sidebar.less
================================================
@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%);
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-swiping.less
================================================
@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%);
}
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-thread-icons.less
================================================
@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%);
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-threadlist.less
================================================
@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%);
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-toolbars.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-variables.less
================================================
// 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;
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/darkside-window-controls.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/ui-darkside/styles/theme-colors.less
================================================
@import "darkside-variables";
@component-active-color: @accent;
@panel-background-color: @sidebar;
================================================
FILE: packages/client-app/internal_packages/ui-less-is-more/LICENSE
================================================
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.
================================================
FILE: packages/client-app/internal_packages/ui-less-is-more/README.md
================================================
# 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.
================================================
FILE: packages/client-app/internal_packages/ui-less-is-more/index.less
================================================
================================================
FILE: packages/client-app/internal_packages/ui-less-is-more/package.json
================================================
{
"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
}
================================================
FILE: packages/client-app/internal_packages/ui-less-is-more/styles/less-is-more.less
================================================
//====================================================
// 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;
}
================================================
FILE: packages/client-app/internal_packages/ui-less-is-more/styles/theme-colors.less
================================================
@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;
================================================
FILE: packages/client-app/internal_packages/ui-light/package.json
================================================
{
"name": "ui-light",
"displayName": "Light",
"theme": "ui",
"version": "0.1.0",
"description": "The N1 Client Theme",
"license": "GPL-3.0",
"engines": {
"nylas": "*"
},
"private": true
}
================================================
FILE: packages/client-app/internal_packages/ui-light/styles/ui-variables.less
================================================
@background-primary: #ffffff;
================================================
FILE: packages/client-app/internal_packages/ui-taiga/LICENSE
================================================
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.
================================================
FILE: packages/client-app/internal_packages/ui-taiga/README.md
================================================
# Taiga
Taiga is a clean, simple, Mailbox-inspired theme for N1 that allows you to focus on what matters most: your emails.

## 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:
================================================
FILE: packages/client-app/internal_packages/ui-taiga/package.json
================================================
{
"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
}
================================================
FILE: packages/client-app/internal_packages/ui-taiga/styles/controls.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/ui-taiga/styles/email-frame.less
================================================
@import "variables";
.ignore-in-parent-frame {
body {
color: @taiga-dark;
}
img {
color: @taiga-dark;
}
}
================================================
FILE: packages/client-app/internal_packages/ui-taiga/styles/notifications.less
================================================
@import "variables";
.notification {
color: @white !important;
background: @taiga-accent !important;
.action {
color: @white !important;
background: darken(@taiga-accent, 20%);
}
}
================================================
FILE: packages/client-app/internal_packages/ui-taiga/styles/sidebar.less
================================================
@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;
}
}
}
}
}
}
}
================================================
FILE: packages/client-app/internal_packages/ui-taiga/styles/theme-colors.less
================================================
@component-active-color: #5dade1;
@toolbar-background-color: #ddedf4;
================================================
FILE: packages/client-app/internal_packages/ui-taiga/styles/threads.less
================================================
@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;
}
}
================================================
FILE: packages/client-app/internal_packages/ui-taiga/styles/ui-variables.less
================================================
@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;
================================================
FILE: packages/client-app/internal_packages/ui-taiga/styles/variables.less
================================================
/**
* Colors
*/
@taiga-light: darken(#A3ACB1, 10%);
@taiga-lighter: #F0F7FA;
@taiga-dark: darken(#727C83, 4%);
@taiga-accent: #5DADE1;
@white: #ffffff;
/**
* Borders
*/
@base-border-radius: 4px;
================================================
FILE: packages/client-app/internal_packages/ui-ubuntu/README.md
================================================
# Ubuntu Theme for Nylas Mail #

## 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.
================================================
FILE: packages/client-app/internal_packages/ui-ubuntu/index.less
================================================
================================================
FILE: packages/client-app/internal_packages/ui-ubuntu/package.json
================================================
{
"name": "ui-ubuntu",
"displayName": "Ubuntu",
"theme": "ui",
"version": "0.1.0",
"description": "The Ubuntu theme for N1.",
"license": "Proprietary",
"engines": {
"nylas": "*"
},
"private": true
}
================================================
FILE: packages/client-app/internal_packages/ui-ubuntu/styles/theme-colors.less
================================================
@component-active-color: #f07746;
@toolbar-background-color: #41403b;
================================================
FILE: packages/client-app/internal_packages/ui-ubuntu/styles/ui-variables.less
================================================
@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;
}
================================================
FILE: packages/client-app/internal_packages/undo-redo/lib/main.es6
================================================
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)
}
}
================================================
FILE: packages/client-app/internal_packages/undo-redo/lib/undo-redo-thread-list-toast.jsx
================================================
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,
}
},
})
================================================
FILE: packages/client-app/internal_packages/undo-redo/lib/undo-send-store.es6
================================================
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()
================================================
FILE: packages/client-app/internal_packages/undo-redo/lib/undo-send-toast.jsx
================================================
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(),
}
},
})
================================================
FILE: packages/client-app/internal_packages/undo-redo/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/unread-notifications/lib/main.es6
================================================
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();
}
================================================
FILE: packages/client-app/internal_packages/unread-notifications/package.json
================================================
{
"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": "*"
}
}
================================================
FILE: packages/client-app/internal_packages/unread-notifications/spec/main-spec.es6
================================================
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()
});
});
});
================================================
FILE: packages/client-app/internal_packages/verify-install-location/lib/main.es6
================================================
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() {
}
================================================
FILE: packages/client-app/internal_packages/verify-install-location/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/worker-ui/lib/developer-bar-curl-item.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/worker-ui/lib/developer-bar-long-poll-item.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/worker-ui/lib/developer-bar-store.coffee
================================================
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()
================================================
FILE: packages/client-app/internal_packages/worker-ui/lib/developer-bar-task.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/worker-ui/lib/developer-bar.cjsx
================================================
_ = 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
================================================
FILE: packages/client-app/internal_packages/worker-ui/lib/main.cjsx
================================================
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
================================================
FILE: packages/client-app/internal_packages/worker-ui/package.json
================================================
{
"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
}
}
================================================
FILE: packages/client-app/internal_packages/worker-ui/stylesheets/worker-ui.less
================================================
@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; }
}
}
}
================================================
FILE: packages/client-app/keymaps/README.m
================================================
# 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.
================================================
FILE: packages/client-app/keymaps/base-darwin.json
================================================
{
"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"
}
================================================
FILE: packages/client-app/keymaps/base-linux.json
================================================
{
"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"
}
================================================
FILE: packages/client-app/keymaps/base-win32.json
================================================
{
"window:toggle-full-screen": "f11",
"window:reload": "ctrl+shift+r",
"window:toggle-dev-tools": "ctrl+shift+i"
}
================================================
FILE: packages/client-app/keymaps/base.json
================================================
{
"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"
}
================================================
FILE: packages/client-app/keymaps/templates/Apple Mail.json
================================================
{
"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"
}
================================================
FILE: packages/client-app/keymaps/templates/Gmail.json
================================================
{
"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": "-"
}
================================================
FILE: packages/client-app/keymaps/templates/Inbox by Gmail.json
================================================
{
"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"
}
================================================
FILE: packages/client-app/keymaps/templates/Outlook.json
================================================
{
"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"
}
================================================
FILE: packages/client-app/menus/darwin.json
================================================
{
"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 },
{ "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" }
]
}
]
}
================================================
FILE: packages/client-app/menus/linux.json
================================================
{
"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" }
]
}
]
}
================================================
FILE: packages/client-app/menus/win32.json
================================================
{
"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 },
{ "type": "separator" },
{ "label": "Print Current Thread", "command": "core:print-thread" },
{ "type": "separator" },
{ "label": "E&xit", "command": "application:quit" }
]
}
================================================
FILE: packages/client-app/package.json
================================================
{
"name": "nylas-mail",
"productName": "Nylas Mail",
"version": "2.0.32",
"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": {
"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": "7.0.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"
}
}
================================================
FILE: packages/client-app/script/grunt
================================================
#!/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);
================================================
FILE: packages/client-app/script/grunt.cmd
================================================
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\grunt" %*
) ELSE (
node "%~dp0\grunt" %*
)
================================================
FILE: packages/client-app/script/mkdeb
================================================
#!/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"
================================================
FILE: packages/client-app/script/mkrpm
================================================
#!/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"
================================================
FILE: packages/client-app/script/publish-docs
================================================
#!/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
================================================
FILE: packages/client-app/script/utils/child-process-wrapper.js
================================================
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);
});
}
================================================
FILE: packages/client-app/spec/action-bridge-spec.coffee
================================================
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()
================================================
FILE: packages/client-app/spec/async-test-spec.es6
================================================
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)
// });
});
================================================
FILE: packages/client-app/spec/buffered-process-spec.coffee
================================================
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"'
================================================
FILE: packages/client-app/spec/components/blockquote-manager-spec.es6
================================================
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);
})
);
});
================================================
FILE: packages/client-app/spec/components/clipboard-service-spec.coffee
================================================
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)
================================================
FILE: packages/client-app/spec/components/contenteditable-component-spec.cjsx
================================================
# 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')
================================================
FILE: packages/client-app/spec/components/date-input-spec.jsx
================================================
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()
});
});
});
================================================
FILE: packages/client-app/spec/components/date-picker-popover-spec.jsx
================================================
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)
});
});
});
================================================
FILE: packages/client-app/spec/components/editable-list-spec.jsx
================================================
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);
});
});
});
================================================
FILE: packages/client-app/spec/components/editable-table-spec.jsx
================================================
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)
});
});
});
================================================
FILE: packages/client-app/spec/components/evented-iframe-spec.cjsx
================================================
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()
================================================
FILE: packages/client-app/spec/components/fixed-popover-spec.jsx
================================================
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
});
});
================================================
FILE: packages/client-app/spec/components/injected-component-set-spec.jsx
================================================
/* 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);
});
});
});
});
================================================
FILE: packages/client-app/spec/components/multiselect-dropdown-spec.jsx
================================================
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()
})
})
})
================================================
FILE: packages/client-app/spec/components/multiselect-list-interaction-handler-spec.coffee
================================================
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)
================================================
FILE: packages/client-app/spec/components/multiselect-split-interaction-handler-spec.coffee
================================================
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)
================================================
FILE: packages/client-app/spec/components/nylas-calendar/calendar-toggles-spec.jsx
================================================
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);
});
});
================================================
FILE: packages/client-app/spec/components/nylas-calendar/fixtures/events.es6
================================================
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"}),
]
================================================
FILE: packages/client-app/spec/components/nylas-calendar/test-data-source.es6
================================================
// 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}
}
}
================================================
FILE: packages/client-app/spec/components/nylas-calendar/test-utils.es6
================================================
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")
================================================
FILE: packages/client-app/spec/components/nylas-calendar/week-view-extended-spec.jsx
================================================
// import {events} from './fixtures/events'
// import {NylasCalendar} from 'nylas-component-kit'
//
// describe('Extended Nylas Calendar Week View', function extendedNylasCalendarWeekView() {
// });
================================================
FILE: packages/client-app/spec/components/nylas-calendar/week-view-spec.jsx
================================================
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())
});
});
================================================
FILE: packages/client-app/spec/components/participants-text-field-spec.jsx
================================================
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: [],
});
}
});
});
});
});
================================================
FILE: packages/client-app/spec/components/selectable-table-spec.jsx
================================================
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)
});
});
});
================================================
FILE: packages/client-app/spec/components/table/table-data-source-spec.jsx
================================================
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)
});
});
});
================================================
FILE: packages/client-app/spec/components/table/table-spec.jsx
================================================
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)
});
});
});
});
================================================
FILE: packages/client-app/spec/components/tokenizing-text-field-spec.cjsx
================================================
_ = 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)
================================================
FILE: packages/client-app/spec/database-object-registry-spec.es6
================================================
/* 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()
);
});
================================================
FILE: packages/client-app/spec/default-client-helper-spec.coffee
================================================
_ = 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()
================================================
FILE: packages/client-app/spec/fixtures/css.css
================================================
body {
font-size: 1234px;
width: 110%;
font-weight: bold !important;
}
================================================
FILE: packages/client-app/spec/fixtures/db-test-model.coffee
================================================
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
================================================
FILE: packages/client-app/spec/fixtures/emails/correct_sig.txt
================================================
this is an email with a correct -- signature.
--
rick
================================================
FILE: packages/client-app/spec/fixtures/emails/email_1.html
================================================
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