refactor(db): change ID system to have clientIDs and serverIDs

Summary: Major ID refactor

Test Plan: edgehill --test

Reviewers: bengotow, dillon

Differential Revision: https://phab.nylas.com/D1946
This commit is contained in:
Ben Gotow 2015-08-28 11:12:53 -07:00
parent 9d995ded67
commit f8c5f7b967
84 changed files with 654 additions and 884 deletions
build
exports
internal_packages
spec-nylas
spec
src
static/package-template

View file

@ -208,9 +208,9 @@ module.exports = (grunt) ->
'exports/**/*.coffee' 'exports/**/*.coffee'
'src/**/*.coffee' 'src/**/*.coffee'
'src/**/*.cjsx' 'src/**/*.cjsx'
'spec/*.coffee' 'spec/**/*.coffee'
'spec-nylas/*.cjsx' 'spec-nylas/**/*.cjsx'
'spec-nylas/*.coffee' 'spec-nylas/**/*.coffee'
] ]
coffeelint: coffeelint:
@ -229,9 +229,13 @@ module.exports = (grunt) ->
'build/Gruntfile.coffee' 'build/Gruntfile.coffee'
] ]
test: [ test: [
'spec/*.coffee' 'spec/**/*.coffee'
'spec-nylas/*.cjsx' 'spec-nylas/**/*.cjsx'
'spec-nylas/*.coffee' 'spec-nylas/**/*.coffee'
]
static: [
'static/**/*.coffee'
'static/**/*.cjsx'
] ]
target: target:
grunt.option("target")?.split(" ") or [] grunt.option("target")?.split(" ") or []

View file

@ -18,4 +18,4 @@ module.exports = (grunt) ->
done(new Error("#{f} contains a bad require including an coffee / cjsx / jsx extension. Remove the extension!")) done(new Error("#{f} contains a bad require including an coffee / cjsx / jsx extension. Remove the extension!"))
return return
done(null) done(null)

View file

@ -60,7 +60,6 @@ class NylasExports
@require "Contact", 'flux/models/contact' @require "Contact", 'flux/models/contact'
@require "Calendar", 'flux/models/calendar' @require "Calendar", 'flux/models/calendar'
@require "Metadata", 'flux/models/metadata' @require "Metadata", 'flux/models/metadata'
@require "LocalLink", 'flux/models/local-link'
@require "DatabaseObjectRegistry", "database-object-registry" @require "DatabaseObjectRegistry", "database-object-registry"
# Exported so 3rd party packages can subclass Model # Exported so 3rd party packages can subclass Model

View file

@ -13,7 +13,7 @@ class AttachmentComponent extends React.Component
download: React.PropTypes.object download: React.PropTypes.object
removable: React.PropTypes.bool removable: React.PropTypes.bool
targetPath: React.PropTypes.string targetPath: React.PropTypes.string
messageLocalId: React.PropTypes.string messageClientId: React.PropTypes.string
constructor: (@props) -> constructor: (@props) ->
@state = progressPercent: 0 @state = progressPercent: 0
@ -79,7 +79,7 @@ class AttachmentComponent extends React.Component
_onClickRemove: (event) => _onClickRemove: (event) =>
Actions.removeFile Actions.removeFile
file: @props.file file: @props.file
messageLocalId: @props.messageLocalId messageClientId: @props.messageClientId
event.stopPropagation() # Prevent 'onClickView' event.stopPropagation() # Prevent 'onClickView'
_onClickDownload: (event) => _onClickDownload: (event) =>

View file

@ -37,7 +37,7 @@ class ComposerView extends React.Component
@containerRequired: false @containerRequired: false
@propTypes: @propTypes:
localId: React.PropTypes.string.isRequired draftClientId: React.PropTypes.string.isRequired
# Either "inline" or "fullwindow" # Either "inline" or "fullwindow"
mode: React.PropTypes.string mode: React.PropTypes.string
@ -65,10 +65,10 @@ class ComposerView extends React.Component
showbcc: false showbcc: false
showsubject: false showsubject: false
showQuotedText: false showQuotedText: false
uploads: FileUploadStore.uploadsForMessage(@props.localId) ? [] uploads: FileUploadStore.uploadsForMessage(@props.draftClientId) ? []
componentWillMount: => componentWillMount: =>
@_prepareForDraft(@props.localId) @_prepareForDraft(@props.draftClientId)
shouldComponentUpdate: (nextProps, nextState) => shouldComponentUpdate: (nextProps, nextState) =>
not Utils.isEqualReact(nextProps, @props) or not Utils.isEqualReact(nextProps, @props) or
@ -113,24 +113,24 @@ class ComposerView extends React.Component
componentWillReceiveProps: (newProps) => componentWillReceiveProps: (newProps) =>
@_ignoreNextTrigger = false @_ignoreNextTrigger = false
if newProps.localId isnt @props.localId if newProps.draftClientId isnt @props.draftClientId
# When we're given a new draft localId, we have to stop listening to our # When we're given a new draft draftClientId, we have to stop listening to our
# current DraftStoreProxy, create a new one and listen to that. The simplest # current DraftStoreProxy, create a new one and listen to that. The simplest
# way to do this is to just re-call registerListeners. # way to do this is to just re-call registerListeners.
@_teardownForDraft() @_teardownForDraft()
@_prepareForDraft(newProps.localId) @_prepareForDraft(newProps.draftClientId)
_prepareForDraft: (localId) => _prepareForDraft: (draftClientId) =>
@unlisteners = [] @unlisteners = []
return unless localId return unless draftClientId
# UndoManager must be ready before we call _onDraftChanged for the first time # UndoManager must be ready before we call _onDraftChanged for the first time
@undoManager = new UndoManager @undoManager = new UndoManager
DraftStore.sessionForLocalId(localId).then(@_setupSession) DraftStore.sessionForClientId(draftClientId).then(@_setupSession)
_setupSession: (proxy) => _setupSession: (proxy) =>
return if @_unmounted return if @_unmounted
return unless proxy.draftLocalId is @props.localId return unless proxy.draftClientId is @props.draftClientId
@_proxy = proxy @_proxy = proxy
@_preloadImages(@_proxy.draft()?.files) @_preloadImages(@_proxy.draft()?.files)
@unlisteners.push @_proxy.listen(@_onDraftChanged) @unlisteners.push @_proxy.listen(@_onDraftChanged)
@ -317,12 +317,12 @@ class ComposerView extends React.Component
tabIndex="109" /> tabIndex="109" />
_renderFooterRegions: => _renderFooterRegions: =>
return <div></div> unless @props.localId return <div></div> unless @props.draftClientId
<div className="composer-footer-region"> <div className="composer-footer-region">
<InjectedComponentSet <InjectedComponentSet
matching={role: "Composer:Footer"} matching={role: "Composer:Footer"}
exposedProps={draftLocalId:@props.localId, threadId: @props.threadId}/> exposedProps={draftClientId:@props.draftClientId, threadId: @props.threadId}/>
</div> </div>
_renderAttachments: -> _renderAttachments: ->
@ -347,7 +347,7 @@ class ComposerView extends React.Component
file: file file: file
removable: true removable: true
targetPath: targetPath targetPath: targetPath
messageLocalId: @props.localId messageClientId: @props.draftClientId
if role is "Attachment" if role is "Attachment"
className = "file-wrap" className = "file-wrap"
@ -397,14 +397,14 @@ class ComposerView extends React.Component
_.compact(uploads.concat(@state.files)) _.compact(uploads.concat(@state.files))
_onFileUploadStoreChange: => _onFileUploadStoreChange: =>
@setState uploads: FileUploadStore.uploadsForMessage(@props.localId) @setState uploads: FileUploadStore.uploadsForMessage(@props.draftClientId)
_renderActionsRegion: => _renderActionsRegion: =>
return <div></div> unless @props.localId return <div></div> unless @props.draftClientId
<InjectedComponentSet className="composer-action-bar-content" <InjectedComponentSet className="composer-action-bar-content"
matching={role: "Composer:ActionButton"} matching={role: "Composer:ActionButton"}
exposedProps={draftLocalId:@props.localId, threadId: @props.threadId}> exposedProps={draftClientId:@props.draftClientId, threadId: @props.threadId}>
<button className="btn btn-toolbar btn-trash" style={order: 100} <button className="btn btn-toolbar btn-trash" style={order: 100}
data-tooltip="Delete draft" data-tooltip="Delete draft"
@ -532,14 +532,14 @@ class ComposerView extends React.Component
_onDrop: (e) => _onDrop: (e) =>
# Accept drops of real files from other applications # Accept drops of real files from other applications
for file in e.dataTransfer.files for file in e.dataTransfer.files
Actions.attachFilePath({path: file.path, messageLocalId: @props.localId}) Actions.attachFilePath({path: file.path, messageClientId: @props.draftClientId})
# Accept drops from attachment components / images within the app # Accept drops from attachment components / images within the app
if (uri = @_nonNativeFilePathForDrop(e)) if (uri = @_nonNativeFilePathForDrop(e))
Actions.attachFilePath({path: uri, messageLocalId: @props.localId}) Actions.attachFilePath({path: uri, messageClientId: @props.draftClientId})
_onFilePaste: (path) => _onFilePaste: (path) =>
Actions.attachFilePath({path: path, messageLocalId: @props.localId}) Actions.attachFilePath({path: path, messageClientId: @props.draftClientId})
_onChangeParticipants: (changes={}) => _onChangeParticipants: (changes={}) =>
@_addToProxy(changes) @_addToProxy(changes)
@ -589,7 +589,7 @@ class ComposerView extends React.Component
@_saveToHistory(selections) unless source.fromUndoManager @_saveToHistory(selections) unless source.fromUndoManager
_popoutComposer: => _popoutComposer: =>
Actions.composePopoutDraft @props.localId Actions.composePopoutDraft @props.draftClientId
_sendDraft: (options = {}) => _sendDraft: (options = {}) =>
return unless @_proxy return unless @_proxy
@ -598,7 +598,7 @@ class ComposerView extends React.Component
# immediately and synchronously updated as soon as this function # immediately and synchronously updated as soon as this function
# fires. Since `setState` is asynchronous, if we used that as our only # fires. Since `setState` is asynchronous, if we used that as our only
# check, then we might get a false reading. # check, then we might get a false reading.
return if DraftStore.isSendingDraft(@props.localId) return if DraftStore.isSendingDraft(@props.draftClientId)
draft = @_proxy.draft() draft = @_proxy.draft()
remote = require('remote') remote = require('remote')
@ -651,17 +651,17 @@ class ComposerView extends React.Component
@_sendDraft({force: true}) @_sendDraft({force: true})
return return
Actions.sendDraft(@props.localId) Actions.sendDraft(@props.draftClientId)
_mentionsAttachment: (body) => _mentionsAttachment: (body) =>
body = QuotedHTMLParser.removeQuotedHTML(body.toLowerCase().trim()) body = QuotedHTMLParser.removeQuotedHTML(body.toLowerCase().trim())
return body.indexOf("attach") >= 0 return body.indexOf("attach") >= 0
_destroyDraft: => _destroyDraft: =>
Actions.destroyDraft(@props.localId) Actions.destroyDraft(@props.draftClientId)
_attachFile: => _attachFile: =>
Actions.attachFile({messageLocalId: @props.localId}) Actions.attachFile({messageClientId: @props.draftClientId})
_showAndFocusBcc: => _showAndFocusBcc: =>
@setState {showbcc: true} @setState {showbcc: true}
@ -734,7 +734,7 @@ class ComposerView extends React.Component
_deleteDraftIfEmpty: => _deleteDraftIfEmpty: =>
return unless @_proxy return unless @_proxy
if @_proxy.draft().pristine then Actions.destroyDraft(@props.localId) if @_proxy.draft().pristine then Actions.destroyDraft(@props.draftClientId)
module.exports = ComposerView module.exports = ComposerView

View file

@ -28,7 +28,7 @@ class ComposerWithWindowProps extends React.Component
render: -> render: ->
<div className="composer-full-window"> <div className="composer-full-window">
<ComposerView mode="fullwindow" localId={@state.draftLocalId} /> <ComposerView mode="fullwindow" draftClientId={@state.draftClientId} />
</div> </div>
_showInitialErrorDialog: (msg) -> _showInitialErrorDialog: (msg) ->

View file

@ -30,8 +30,6 @@ u5 = new Contact(name: "Ben Gotow", email: "ben@nylas.com")
file = new File(id: 'file_1_id', filename: 'a.png', contentType: 'image/png', size: 10, object: "file") file = new File(id: 'file_1_id', filename: 'a.png', contentType: 'image/png', size: 10, object: "file")
users = [u1, u2, u3, u4, u5] users = [u1, u2, u3, u4, u5]
AccountStore._current = new Account(
{name: u1.name, provider: "inbox", emailAddress: u1.email})
reactStub = (className) -> reactStub = (className) ->
React.createClass({render: -> <div className={className}>{@props.children}</div>}) React.createClass({render: -> <div className={className}>{@props.children}</div>})
@ -45,11 +43,11 @@ passThroughStub = (props={}) ->
React.createClass React.createClass
render: -> <div {...props}>{props.children}</div> render: -> <div {...props}>{props.children}</div>
draftStoreProxyStub = (localId, returnedDraft) -> draftStoreProxyStub = (draftClientId, returnedDraft) ->
listen: -> -> listen: -> ->
draft: -> (returnedDraft ? new Message(draft: true)) draft: -> (returnedDraft ? new Message(draft: true))
draftPristineBody: -> null draftPristineBody: -> null
draftLocalId: localId draftClientId: draftClientId
cleanup: -> cleanup: ->
changes: changes:
add: -> add: ->
@ -72,27 +70,21 @@ ComposerView = proxyquire "../lib/composer-view",
DraftStore: DraftStore DraftStore: DraftStore
beforeEach -> beforeEach ->
# spyOn(ComponentRegistry, "findComponentsMatching").andCallFake (matching) ->
# return passThroughStub
# spyOn(ComponentRegistry, "showComponentRegions").andReturn true
# The AccountStore isn't set yet in the new window, populate it first. # The AccountStore isn't set yet in the new window, populate it first.
AccountStore.populateItems().then -> AccountStore.populateItems().then ->
new Promise (resolve, reject) -> draft = new Message
draft = new Message from: [AccountStore.current().me()]
from: [AccountStore.current().me()] date: (new Date)
date: (new Date) draft: true
draft: true accountId: AccountStore.current().id
accountId: AccountStore.current().id
DatabaseStore.persistModel(draft).then -> DatabaseStore.persistModel(draft).then ->
DatabaseStore.localIdForModel(draft).then(resolve).catch(reject) return draft
.catch(reject)
describe "A blank composer view", -> describe "A blank composer view", ->
beforeEach -> beforeEach ->
@composer = ReactTestUtils.renderIntoDocument( @composer = ReactTestUtils.renderIntoDocument(
<ComposerView localId="test123" /> <ComposerView draftClientId="test123" />
) )
@composer.setState @composer.setState
body: "" body: ""
@ -110,23 +102,23 @@ describe "A blank composer view", ->
# This will setup the mocks necessary to make the composer element (once # This will setup the mocks necessary to make the composer element (once
# mounted) think it's attached to the given draft. This mocks out the # mounted) think it's attached to the given draft. This mocks out the
# proxy system used by the composer. # proxy system used by the composer.
DRAFT_LOCAL_ID = "local-123" DRAFT_CLIENT_ID = "local-123"
useDraft = (draftAttributes={}) -> useDraft = (draftAttributes={}) ->
@draft = new Message _.extend({draft: true, body: ""}, draftAttributes) @draft = new Message _.extend({draft: true, body: ""}, draftAttributes)
draft = @draft draft = @draft
proxy = draftStoreProxyStub(DRAFT_LOCAL_ID, @draft) proxy = draftStoreProxyStub(DRAFT_CLIENT_ID, @draft)
spyOn(ComposerView.prototype, "componentWillMount").andCallFake -> spyOn(ComposerView.prototype, "componentWillMount").andCallFake ->
@_prepareForDraft(DRAFT_LOCAL_ID) @_prepareForDraft(DRAFT_CLIENT_ID)
@_setupSession(proxy) @_setupSession(proxy)
# Normally when sessionForLocalId resolves, it will call `_setupSession` # Normally when sessionForClientId resolves, it will call `_setupSession`
# and pass the new session proxy. However, in our faked # and pass the new session proxy. However, in our faked
# `componentWillMount`, we manually call sessionForLocalId to make this # `componentWillMount`, we manually call sessionForClientId to make this
# part of the test synchronous. We need to make the `then` block of the # part of the test synchronous. We need to make the `then` block of the
# sessionForLocalId do nothing so `_setupSession` is not called twice! # sessionForClientId do nothing so `_setupSession` is not called twice!
spyOn(DraftStore, "sessionForLocalId").andCallFake -> spyOn(DraftStore, "sessionForClientId").andCallFake ->
then: -> then: ->
useFullDraft = -> useFullDraft = ->
@ -141,7 +133,7 @@ useFullDraft = ->
makeComposer = -> makeComposer = ->
@composer = ReactTestUtils.renderIntoDocument( @composer = ReactTestUtils.renderIntoDocument(
<ComposerView localId={DRAFT_LOCAL_ID} /> <ComposerView draftClientId={DRAFT_CLIENT_ID} />
) )
describe "populated composer", -> describe "populated composer", ->
@ -245,9 +237,9 @@ describe "populated composer", ->
describe "if the draft has not yet loaded", -> describe "if the draft has not yet loaded", ->
it "should set _focusOnUpdate and focus after the next render", -> it "should set _focusOnUpdate and focus after the next render", ->
@draft = new Message(draft: true, body: "") @draft = new Message(draft: true, body: "")
proxy = draftStoreProxyStub(DRAFT_LOCAL_ID, @draft) proxy = draftStoreProxyStub(DRAFT_CLIENT_ID, @draft)
proxyResolve = null proxyResolve = null
spyOn(DraftStore, "sessionForLocalId").andCallFake -> spyOn(DraftStore, "sessionForClientId").andCallFake ->
new Promise (resolve, reject) -> new Promise (resolve, reject) ->
proxyResolve = resolve proxyResolve = resolve
@ -462,7 +454,7 @@ describe "populated composer", ->
useFullDraft.apply(@); makeComposer.call(@) useFullDraft.apply(@); makeComposer.call(@)
sendBtn = React.findDOMNode(@composer.refs.sendButton) sendBtn = React.findDOMNode(@composer.refs.sendButton)
ReactTestUtils.Simulate.click sendBtn ReactTestUtils.Simulate.click sendBtn
expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_LOCAL_ID) expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID)
expect(Actions.sendDraft.calls.length).toBe 1 expect(Actions.sendDraft.calls.length).toBe 1
it "doesn't send twice if you double click", -> it "doesn't send twice if you double click", ->
@ -472,7 +464,7 @@ describe "populated composer", ->
@isSending.state = true @isSending.state = true
DraftStore.trigger() DraftStore.trigger()
ReactTestUtils.Simulate.click sendBtn ReactTestUtils.Simulate.click sendBtn
expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_LOCAL_ID) expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID)
expect(Actions.sendDraft.calls.length).toBe 1 expect(Actions.sendDraft.calls.length).toBe 1
describe "when sending a message with keyboard inputs", -> describe "when sending a message with keyboard inputs", ->
@ -630,14 +622,14 @@ describe "populated composer", ->
@up1 = @up1 =
uploadTaskId: 4 uploadTaskId: 4
messageLocalId: DRAFT_LOCAL_ID messageClientId: DRAFT_CLIENT_ID
filePath: "/foo/bar/f4.bmp" filePath: "/foo/bar/f4.bmp"
fileName: "f4.bmp" fileName: "f4.bmp"
fileSize: 1024 fileSize: 1024
@up2 = @up2 =
uploadTaskId: 5 uploadTaskId: 5
messageLocalId: DRAFT_LOCAL_ID messageClientId: DRAFT_CLIENT_ID
filePath: "/foo/bar/f5.zip" filePath: "/foo/bar/f5.zip"
fileName: "f5.zip" fileName: "f5.zip"
fileSize: 1024 fileSize: 1024

View file

@ -14,17 +14,22 @@ ParticipantsTextField = proxyquire '../lib/participants-text-field',
'nylas-exports': {Contact, ContactStore} 'nylas-exports': {Contact, ContactStore}
participant1 = new Contact participant1 = new Contact
id: 'local-1'
email: 'ben@nylas.com' email: 'ben@nylas.com'
participant2 = new Contact participant2 = new Contact
id: 'local-2'
email: 'ben@example.com' email: 'ben@example.com'
name: 'Ben Gotow' name: 'Ben Gotow'
participant3 = new Contact participant3 = new Contact
id: 'local-3'
email: 'evan@nylas.com' email: 'evan@nylas.com'
name: 'Evan Morikawa' name: 'Evan Morikawa'
participant4 = new Contact participant4 = new Contact
id: 'local-4',
email: 'ben@elsewhere.com', email: 'ben@elsewhere.com',
name: 'ben Again' name: 'ben Again'
participant5 = new Contact participant5 = new Contact
id: 'local-5',
email: 'evan@elsewhere.com', email: 'evan@elsewhere.com',
name: 'EVAN' name: 'EVAN'
@ -54,7 +59,7 @@ describe 'ParticipantsTextField', ->
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Enter', keyCode: 9}) ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Enter', keyCode: 9})
reviver = (k,v) -> reviver = (k,v) ->
return undefined if k in ["id", "object"] return undefined if k in ["id", "client_id", "server_id", "object"]
return v return v
found = @propChange.mostRecentCall.args[0] found = @propChange.mostRecentCall.args[0]
found = JSON.parse(JSON.stringify(found), reviver) found = JSON.parse(JSON.stringify(found), reviver)
@ -102,7 +107,7 @@ describe 'ParticipantsTextField', ->
describe "when text contains Name (Email) formatted data", -> describe "when text contains Name (Email) formatted data", ->
it "should correctly parse it into named Contact objects", -> it "should correctly parse it into named Contact objects", ->
newContact1 = new Contact(name:'Ben Imposter', email:'imposter@nylas.com') newContact1 = new Contact(id: "b1", name:'Ben Imposter', email:'imposter@nylas.com')
newContact2 = new Contact(name:'Nylas Team', email:'feedback@nylas.com') newContact2 = new Contact(name:'Nylas Team', email:'feedback@nylas.com')
inputs = [ inputs = [
@ -120,8 +125,8 @@ describe 'ParticipantsTextField', ->
describe "when text contains emails mixed with garbage text", -> describe "when text contains emails mixed with garbage text", ->
it "should still parse out emails into Contact objects", -> it "should still parse out emails into Contact objects", ->
newContact1 = new Contact(name:'garbage-man@nylas.com', email:'garbage-man@nylas.com') newContact1 = new Contact(id: 'gm', name:'garbage-man@nylas.com', email:'garbage-man@nylas.com')
newContact2 = new Contact(name:'recycling-guy@nylas.com', email:'recycling-guy@nylas.com') newContact2 = new Contact(id: 'rm', name:'recycling-guy@nylas.com', email:'recycling-guy@nylas.com')
inputs = [ inputs = [
"Hello world I real. \n asd. garbage-man@nylas.com—he's cool Also 'recycling-guy@nylas.com'!", "Hello world I real. \n asd. garbage-man@nylas.com—he's cool Also 'recycling-guy@nylas.com'!",

View file

@ -10,4 +10,4 @@ module.exports =
deactivate: -> deactivate: ->
ComponentRegistry.unregister EventComponent ComponentRegistry.unregister EventComponent
serialize: -> @state serialize: -> @state

View file

@ -100,16 +100,15 @@ class MessageControls extends React.Component
body: @props.message.body body: @props.message.body
DatabaseStore.persistModel(draft).then => DatabaseStore.persistModel(draft).then =>
DatabaseStore.localIdForModel(draft).then (localId) => Actions.sendDraft(draft.clientId)
Actions.sendDraft(localId)
dialog = remote.require('dialog') dialog = remote.require('dialog')
dialog.showMessageBox remote.getCurrentWindow(), { dialog.showMessageBox remote.getCurrentWindow(), {
type: 'warning' type: 'warning'
buttons: ['OK'], buttons: ['OK'],
message: "Thank you." message: "Thank you."
detail: "The contents of this message have been sent to the Edgehill team and we added to a test suite." detail: "The contents of this message have been sent to the Edgehill team and we added to a test suite."
} }
_onShowOriginal: => _onShowOriginal: =>
fs = require 'fs' fs = require 'fs'

View file

@ -15,11 +15,6 @@ class MessageItemContainer extends React.Component
@propTypes = @propTypes =
thread: React.PropTypes.object.isRequired thread: React.PropTypes.object.isRequired
message: React.PropTypes.object.isRequired message: React.PropTypes.object.isRequired
# The localId (in the case of draft's local ID) is a derived
# property that only the parent MessageList knows about.
localId: React.PropTypes.string
collapsed: React.PropTypes.bool collapsed: React.PropTypes.bool
isLastMsg: React.PropTypes.bool isLastMsg: React.PropTypes.bool
isBeforeReplyArea: React.PropTypes.bool isBeforeReplyArea: React.PropTypes.bool
@ -65,7 +60,7 @@ class MessageItemContainer extends React.Component
_renderComposer: => _renderComposer: =>
props = props =
mode: "inline" mode: "inline"
localId: @props.localId draftClientId: @props.message.clientId
threadId: @props.thread.id threadId: @props.thread.id
onRequestScrollTo: @props.onRequestScrollTo onRequestScrollTo: @props.onRequestScrollTo
@ -81,10 +76,10 @@ class MessageItemContainer extends React.Component
"message-item-wrap": true "message-item-wrap": true
"before-reply-area": @props.isBeforeReplyArea "before-reply-area": @props.isBeforeReplyArea
_onSendingStateChanged: (draftLocalId) => _onSendingStateChanged: (draftClientId) =>
@setState(@_getStateFromStores()) if draftLocalId is @props.localId @setState(@_getStateFromStores()) if draftClientId is @props.message.clientId
_getStateFromStores: -> _getStateFromStores: ->
isSending: DraftStore.isSendingDraft(@props.localId) isSending: DraftStore.isSendingDraft(@props.message.clientId)
module.exports = MessageItemContainer module.exports = MessageItemContainer

View file

@ -128,8 +128,8 @@ class MessageList extends React.Component
newDraftIds = _.map(_.filter((@state.messages ? []), (m) -> m.draft), (m) -> m.id) newDraftIds = _.map(_.filter((@state.messages ? []), (m) -> m.draft), (m) -> m.id)
return _.difference(newDraftIds, oldDraftIds) ? [] return _.difference(newDraftIds, oldDraftIds) ? []
_getMessageContainer: (id) => _getMessageContainer: (messageId) =>
@refs["message-container-#{id}"] @refs["message-container-#{messageId}"]
_focusDraft: (draftElement) => _focusDraft: (draftElement) =>
# Note: We don't want the contenteditable view competing for scroll offset, # Note: We don't want the contenteditable view competing for scroll offset,
@ -155,7 +155,7 @@ class MessageList extends React.Component
# in reply to and the draft session and change the participants. # in reply to and the draft session and change the participants.
if last.draft is true if last.draft is true
data = data =
session: DraftStore.sessionForLocalId(@state.messageLocalIds[last.id]) session: DraftStore.sessionForClientId(last.clientId)
replyToMessage: Promise.resolve(@state.messages[@state.messages.length - 2]) replyToMessage: Promise.resolve(@state.messages[@state.messages.length - 2])
type: type type: type
@ -299,14 +299,11 @@ class MessageList extends React.Component
isLastMsg = (messages.length - 1 is idx) isLastMsg = (messages.length - 1 is idx)
isBeforeReplyArea = isLastMsg and hasReplyArea isBeforeReplyArea = isLastMsg and hasReplyArea
localId = @state.messageLocalIds[message.id]
elements.push( elements.push(
<MessageItemContainer key={idx} <MessageItemContainer key={idx}
ref={"message-container-#{message.id}"} ref={"message-container-#{message.id}"}
thread={@state.currentThread} thread={@state.currentThread}
message={message} message={message}
localId={localId}
collapsed={collapsed} collapsed={collapsed}
isLastMsg={isLastMsg} isLastMsg={isLastMsg}
isBeforeReplyArea={isBeforeReplyArea} isBeforeReplyArea={isBeforeReplyArea}
@ -404,7 +401,6 @@ class MessageList extends React.Component
_getStateFromStores: => _getStateFromStores: =>
messages: (MessageStore.items() ? []) messages: (MessageStore.items() ? [])
messageLocalIds: MessageStore.itemLocalIds()
messagesExpandedState: MessageStore.itemsExpandedState() messagesExpandedState: MessageStore.itemsExpandedState()
currentThread: MessageStore.thread() currentThread: MessageStore.thread()
loading: MessageStore.itemsLoading() loading: MessageStore.itemsLoading()

View file

@ -16,7 +16,7 @@ MessageItemContainer = proxyquire '../lib/message-item-container',
{InjectedComponent} = require 'nylas-component-kit' {InjectedComponent} = require 'nylas-component-kit'
testThread = new Thread(id: "t1") testThread = new Thread(id: "t1")
testLocalId = "local-id" testClientId = "local-id"
testMessage = new Message(id: "m1", draft: false, unread: true) testMessage = new Message(id: "m1", draft: false, unread: true)
testDraft = new Message(id: "d1", draft: true, unread: true) testDraft = new Message(id: "d1", draft: true, unread: true)
@ -30,7 +30,7 @@ describe 'MessageItemContainer', ->
ReactTestUtils.renderIntoDocument( ReactTestUtils.renderIntoDocument(
<MessageItemContainer thread={testThread} <MessageItemContainer thread={testThread}
message={message} message={message}
localId={testLocalId} /> draftClientId={testClientId} />
) )
it "shows composer if it's a draft", -> it "shows composer if it's a draft", ->

View file

@ -114,7 +114,7 @@ describe "MessageItem", ->
snippet: "snippet one..." snippet: "snippet one..."
subject: "Subject One" subject: "Subject One"
threadId: "thread_12345" threadId: "thread_12345"
accountId: "test_account_id" accountId: TEST_ACCOUNT_ID
@thread = new Thread @thread = new Thread
id: 'thread-111' id: 'thread-111'

View file

@ -33,12 +33,6 @@ MessageItemContainer = proxyquire("../lib/message-item-container", {
MessageList = proxyquire '../lib/message-list', MessageList = proxyquire '../lib/message-list',
"./message-item-container": MessageItemContainer "./message-item-container": MessageItemContainer
me = new Account
name: "User One",
emailAddress: "user1@nylas.com"
provider: "inbox"
AccountStore._current = me
user_1 = new Contact user_1 = new Contact
name: "User One" name: "User One"
email: "user1@nylas.com" email: "user1@nylas.com"
@ -70,7 +64,7 @@ m1 = (new Message).fromJSON({
"snippet" : "snippet one...", "snippet" : "snippet one...",
"subject" : "Subject One", "subject" : "Subject One",
"thread_id" : "thread_12345", "thread_id" : "thread_12345",
"account_id" : "test_account_id" "account_id" : TEST_ACCOUNT_ID
}) })
m2 = (new Message).fromJSON({ m2 = (new Message).fromJSON({
"id" : "222", "id" : "222",
@ -87,7 +81,7 @@ m2 = (new Message).fromJSON({
"snippet" : "snippet Two...", "snippet" : "snippet Two...",
"subject" : "Subject Two", "subject" : "Subject Two",
"thread_id" : "thread_12345", "thread_id" : "thread_12345",
"account_id" : "test_account_id" "account_id" : TEST_ACCOUNT_ID
}) })
m3 = (new Message).fromJSON({ m3 = (new Message).fromJSON({
"id" : "333", "id" : "333",
@ -104,7 +98,7 @@ m3 = (new Message).fromJSON({
"snippet" : "snippet Three...", "snippet" : "snippet Three...",
"subject" : "Subject Three", "subject" : "Subject Three",
"thread_id" : "thread_12345", "thread_id" : "thread_12345",
"account_id" : "test_account_id" "account_id" : TEST_ACCOUNT_ID
}) })
m4 = (new Message).fromJSON({ m4 = (new Message).fromJSON({
"id" : "444", "id" : "444",
@ -121,7 +115,7 @@ m4 = (new Message).fromJSON({
"snippet" : "snippet Four...", "snippet" : "snippet Four...",
"subject" : "Subject Four", "subject" : "Subject Four",
"thread_id" : "thread_12345", "thread_id" : "thread_12345",
"account_id" : "test_account_id" "account_id" : TEST_ACCOUNT_ID
}) })
m5 = (new Message).fromJSON({ m5 = (new Message).fromJSON({
"id" : "555", "id" : "555",
@ -138,7 +132,7 @@ m5 = (new Message).fromJSON({
"snippet" : "snippet Five...", "snippet" : "snippet Five...",
"subject" : "Subject Five", "subject" : "Subject Five",
"thread_id" : "thread_12345", "thread_id" : "thread_12345",
"account_id" : "test_account_id" "account_id" : TEST_ACCOUNT_ID
}) })
testMessages = [m1, m2, m3, m4, m5] testMessages = [m1, m2, m3, m4, m5]
draftMessages = [ draftMessages = [
@ -157,11 +151,12 @@ draftMessages = [
"snippet" : "draft snippet one...", "snippet" : "draft snippet one...",
"subject" : "Draft One", "subject" : "Draft One",
"thread_id" : "thread_12345", "thread_id" : "thread_12345",
"account_id" : "test_account_id" "account_id" : TEST_ACCOUNT_ID
}), }),
] ]
test_thread = (new Thread).fromJSON({ test_thread = (new Thread).fromJSON({
"id": "12345"
"id" : "thread_12345" "id" : "thread_12345"
"subject" : "Subject 12345" "subject" : "Subject 12345"
}) })
@ -170,8 +165,6 @@ describe "MessageList", ->
beforeEach -> beforeEach ->
MessageStore._items = [] MessageStore._items = []
MessageStore._threadId = null MessageStore._threadId = null
spyOn(MessageStore, "itemLocalIds").andCallFake ->
{"666": "666"}
spyOn(MessageStore, "itemsLoading").andCallFake -> spyOn(MessageStore, "itemsLoading").andCallFake ->
false false
@ -224,7 +217,7 @@ describe "MessageList", ->
messages: msgs.concat(draftMessages) messages: msgs.concat(draftMessages)
expect(@messageList._focusDraft).toHaveBeenCalled() expect(@messageList._focusDraft).toHaveBeenCalled()
expect(@messageList._focusDraft.mostRecentCall.args[0].props.localId).toEqual(draftMessages[0].id) expect(@messageList._focusDraft.mostRecentCall.args[0].props.draftClientId).toEqual(draftMessages[0].draftClientId)
it "includes drafts as message item containers", -> it "includes drafts as message item containers", ->
msgs = @messageList.state.messages msgs = @messageList.state.messages
@ -306,7 +299,7 @@ describe "MessageList", ->
draft: => @draft draft: => @draft
changes: changes:
add: jasmine.createSpy('session.changes.add') add: jasmine.createSpy('session.changes.add')
spyOn(DraftStore, 'sessionForLocalId').andCallFake => spyOn(DraftStore, 'sessionForClientId').andCallFake =>
Promise.resolve(@sessionStub) Promise.resolve(@sessionStub)
it "should not fire a composer action", -> it "should not fire a composer action", ->

View file

@ -38,4 +38,4 @@ describe "MessageTimestamp", ->
it "displays month, day, and year for messages over a year ago", -> it "displays month, day, and year for messages over a year ago", ->
now = msgTime().add(2, 'years') now = msgTime().add(2, 'years')
expect(@item._formattedDate(msgTime(), now)).toBe "Feb 14, 2010" expect(@item._formattedDate(msgTime(), now)).toBe "Feb 14, 2010"

View file

@ -74,14 +74,14 @@ class TemplatePicker extends React.Component
templates: @_filteredTemplates(newSearch) templates: @_filteredTemplates(newSearch)
_onChooseTemplate: (template) => _onChooseTemplate: (template) =>
Actions.insertTemplateId({templateId:template.id, draftLocalId: @props.draftLocalId}) Actions.insertTemplateId({templateId:template.id, draftClientId: @props.draftClientId})
@refs.popover.close() @refs.popover.close()
_onManageTemplates: => _onManageTemplates: =>
Actions.showTemplates() Actions.showTemplates()
_onNewTemplate: => _onNewTemplate: =>
Actions.createTemplate({draftLocalId: @props.draftLocalId}) Actions.createTemplate({draftClientId: @props.draftClientId})
module.exports = TemplatePicker module.exports = TemplatePicker

View file

@ -11,15 +11,15 @@ class TemplateStatusBar extends React.Component
margin:'auto' margin:'auto'
@propTypes: @propTypes:
draftLocalId: React.PropTypes.string draftClientId: React.PropTypes.string
constructor: (@props) -> constructor: (@props) ->
@state = draft: null @state = draft: null
componentDidMount: => componentDidMount: =>
DraftStore.sessionForLocalId(@props.draftLocalId).then (_proxy) => DraftStore.sessionForClientId(@props.draftClientId).then (_proxy) =>
return if @_unmounted return if @_unmounted
return unless _proxy.draftLocalId is @props.draftLocalId return unless _proxy.draftClientId is @props.draftClientId
@_proxy = _proxy @_proxy = _proxy
@unsubscribe = @_proxy.listen(@_onDraftChange, @) @unsubscribe = @_proxy.listen(@_onDraftChange, @)
@_onDraftChange() @_onDraftChange()

View file

@ -54,9 +54,9 @@ TemplateStore = Reflux.createStore
path: path.join(@_templatesDir, filename) path: path.join(@_templatesDir, filename)
@trigger(@) @trigger(@)
_onCreateTemplate: ({draftLocalId, name, contents} = {}) -> _onCreateTemplate: ({draftClientId, name, contents} = {}) ->
if draftLocalId if draftClientId
DraftStore.sessionForLocalId(draftLocalId).then (session) => DraftStore.sessionForClientId(draftClientId).then (session) =>
draft = session.draft() draft = session.draft()
name ?= draft.subject name ?= draft.subject
contents ?= draft.body contents ?= draft.body
@ -92,13 +92,13 @@ TemplateStore = Reflux.createStore
path: templatePath path: templatePath
@trigger(@) @trigger(@)
_onInsertTemplateId: ({templateId, draftLocalId} = {}) -> _onInsertTemplateId: ({templateId, draftClientId} = {}) ->
template = _.find @_items, (item) -> item.id is templateId template = _.find @_items, (item) -> item.id is templateId
return unless template return unless template
fs.readFile template.path, (err, data) -> fs.readFile template.path, (err, data) ->
body = data.toString() body = data.toString()
DraftStore.sessionForLocalId(draftLocalId).then (session) -> DraftStore.sessionForClientId(draftClientId).then (session) ->
session.changes.add(body: body) session.changes.add(body: body)
module.exports = TemplateStore module.exports = TemplateStore

View file

@ -59,13 +59,13 @@ describe "TemplateStore", ->
it "should insert the template with the given id into the draft with the given id", -> it "should insert the template with the given id into the draft with the given id", ->
add = jasmine.createSpy('add') add = jasmine.createSpy('add')
spyOn(DraftStore, 'sessionForLocalId').andCallFake -> spyOn(DraftStore, 'sessionForClientId').andCallFake ->
Promise.resolve(changes: {add}) Promise.resolve(changes: {add})
runs -> runs ->
TemplateStore._onInsertTemplateId TemplateStore._onInsertTemplateId
templateId: 'template1.html', templateId: 'template1.html',
draftLocalId: 'localid-draft' draftClientId: 'localid-draft'
waitsFor -> waitsFor ->
add.calls.length > 0 add.calls.length > 0
runs -> runs ->
@ -75,8 +75,8 @@ describe "TemplateStore", ->
describe "onCreateTemplate", -> describe "onCreateTemplate", ->
beforeEach -> beforeEach ->
TemplateStore.init() TemplateStore.init()
spyOn(DraftStore, 'sessionForLocalId').andCallFake (draftLocalId) -> spyOn(DraftStore, 'sessionForClientId').andCallFake (draftClientId) ->
if draftLocalId is 'localid-nosubject' if draftClientId is 'localid-nosubject'
d = new Message(subject: '', body: '<p>Body</p>') d = new Message(subject: '', body: '<p>Body</p>')
else else
d = new Message(subject: 'Subject', body: '<p>Body</p>') d = new Message(subject: 'Subject', body: '<p>Body</p>')
@ -118,7 +118,7 @@ describe "TemplateStore", ->
spyOn(TemplateStore, 'trigger') spyOn(TemplateStore, 'trigger')
spyOn(TemplateStore, '_populate') spyOn(TemplateStore, '_populate')
runs -> runs ->
TemplateStore._onCreateTemplate({draftLocalId: 'localid-b'}) TemplateStore._onCreateTemplate({draftClientId: 'localid-b'})
waitsFor -> waitsFor ->
TemplateStore.trigger.callCount > 0 TemplateStore.trigger.callCount > 0
runs -> runs ->
@ -127,7 +127,7 @@ describe "TemplateStore", ->
it "should display an error if the draft has no subject", -> it "should display an error if the draft has no subject", ->
spyOn(TemplateStore, '_displayError') spyOn(TemplateStore, '_displayError')
runs -> runs ->
TemplateStore._onCreateTemplate({draftLocalId: 'localid-nosubject'}) TemplateStore._onCreateTemplate({draftClientId: 'localid-nosubject'})
waitsFor -> waitsFor ->
TemplateStore._displayError.callCount > 0 TemplateStore._displayError.callCount > 0
runs -> runs ->

View file

@ -51,11 +51,6 @@ DraftListStore = Reflux.createStore
selected = @_view.selection.items() selected = @_view.selection.items()
for item in selected for item in selected
DatabaseStore.localIdForModel(item).then (localId) => Actions.queueTask(new DestroyDraftTask(draftClientId: item.clientId))
Actions.queueTask(new DestroyDraftTask(draftLocalId: localId))
# if thread.id is focusedId
# Actions.setFocus(collection: 'thread', item: null)
# if thread.id is keyboardId
# Actions.setCursorPosition(collection: 'thread', item: null)
@_view.selection.clear() @_view.selection.clear()

View file

@ -64,16 +64,14 @@ class DraftList extends React.Component
collection="draft" /> collection="draft" />
_onDoubleClick: (item) => _onDoubleClick: (item) =>
DatabaseStore.localIdForModel(item).then (localId) -> Actions.composePopoutDraft(item.clientId)
Actions.composePopoutDraft(localId)
# Additional Commands # Additional Commands
_onDelete: ({focusedId}) => _onDelete: ({focusedId}) =>
item = DraftListStore.view().getById(focusedId) item = DraftListStore.view().getById(focusedId)
return unless item return unless item
DatabaseStore.localIdForModel(item).then (localId) -> Actions.destroyDraft(item.clientId)
Actions.destroyDraft(localId)
module.exports = DraftList module.exports = DraftList

View file

@ -35,7 +35,7 @@ class ThreadListIcon extends React.Component
_nonDraftMessages: => _nonDraftMessages: =>
msgs = @props.thread.metadata msgs = @props.thread.metadata
return [] unless msgs and msgs instanceof Array return [] unless msgs and msgs instanceof Array
msgs = _.filter msgs, (m) -> m.isSaved() and not m.draft msgs = _.filter msgs, (m) -> m.serverId and not m.draft
return msgs return msgs
shouldComponentUpdate: (nextProps) => shouldComponentUpdate: (nextProps) =>

View file

@ -34,20 +34,13 @@ ThreadList = require "../lib/thread-list"
ParticipantsItem = React.createClass ParticipantsItem = React.createClass
render: -> <div></div> render: -> <div></div>
me = new Account(
"name": "User One",
"email": "user1@nylas.com"
"provider": "inbox"
)
AccountStore._current = me
test_threads = -> [ test_threads = -> [
(new Thread).fromJSON({ (new Thread).fromJSON({
"id": "111", "id": "111",
"object": "thread", "object": "thread",
"created_at": null, "created_at": null,
"updated_at": null, "updated_at": null,
"account_id": "test_account_id", "account_id": TEST_ACCOUNT_ID,
"snippet": "snippet 111", "snippet": "snippet 111",
"subject": "Subject 111", "subject": "Subject 111",
"tags": [ "tags": [
@ -103,7 +96,7 @@ test_threads = -> [
"object": "thread", "object": "thread",
"created_at": null, "created_at": null,
"updated_at": null, "updated_at": null,
"account_id": "test_account_id", "account_id": TEST_ACCOUNT_ID,
"snippet": "snippet 222", "snippet": "snippet 222",
"subject": "Subject 222", "subject": "Subject 222",
"tags": [ "tags": [
@ -153,7 +146,7 @@ test_threads = -> [
"object": "thread", "object": "thread",
"created_at": null, "created_at": null,
"updated_at": null, "updated_at": null,
"account_id": "test_account_id", "account_id": TEST_ACCOUNT_ID,
"snippet": "snippet 333", "snippet": "snippet 333",
"subject": "Subject 333", "subject": "Subject 333",
"tags": [ "tags": [

View file

@ -10,4 +10,4 @@ module.exports =
deactivate: -> deactivate: ->
ComponentRegistry.unregister UndoRedoComponent ComponentRegistry.unregister UndoRedoComponent
serialize: -> @state serialize: -> @state

View file

@ -18,7 +18,7 @@ describe "NylasSyncWorker", ->
spyOn(DatabaseStore, 'persistJSONObject').andReturn(Promise.resolve()) spyOn(DatabaseStore, 'persistJSONObject').andReturn(Promise.resolve())
spyOn(DatabaseStore, 'findJSONObject').andCallFake (key) => spyOn(DatabaseStore, 'findJSONObject').andCallFake (key) =>
expected = "NylasSyncWorker:account-id" expected = "NylasSyncWorker:#{TEST_ACCOUNT_ID}"
return throw new Error("Not stubbed! #{key}") unless key is expected return throw new Error("Not stubbed! #{key}") unless key is expected
Promise.resolve _.extend {}, { Promise.resolve _.extend {}, {
"contacts": "contacts":
@ -29,7 +29,7 @@ describe "NylasSyncWorker", ->
complete: true complete: true
} }
@account = new Account(id: 'account-id', organizationUnit: 'label') @account = new Account(clientId: TEST_ACCOUNT_CLIENT_ID, serverId: TEST_ACCOUNT_ID, organizationUnit: 'label')
@worker = new NylasSyncWorker(@api, @account) @worker = new NylasSyncWorker(@api, @account)
@connection = @worker.connection() @connection = @worker.connection()
advanceClock() advanceClock()

View file

@ -140,7 +140,6 @@ DeveloperBarStore = Reflux.createStore
#{debugData} #{debugData}
""" """
DatabaseStore.persistModel(draft).then -> DatabaseStore.persistModel(draft).then ->
DatabaseStore.localIdForModel(draft).then (localId) -> Actions.composePopoutDraft(draft.clientId)
Actions.composePopoutDraft(localId)
module.exports = DeveloperBarStore module.exports = DeveloperBarStore

View file

@ -51,7 +51,7 @@ describe "ActionBridge", ->
@bridge = new ActionBridge(ipc) @bridge = new ActionBridge(ipc)
@message = new Message @message = new Message
id: 'test-id' id: 'test-id'
accountId: 'test-account-id' accountId: TEST_ACCOUNT_ID
it "should have the role Role.SECONDARY", -> it "should have the role Role.SECONDARY", ->
expect(@bridge.role).toBe(ActionBridge.Role.SECONDARY) expect(@bridge.role).toBe(ActionBridge.Role.SECONDARY)
@ -83,19 +83,19 @@ describe "ActionBridge", ->
describe "when called with TargetWindows.ALL", -> describe "when called with TargetWindows.ALL", ->
it "should broadcast the action over IPC to all windows", -> it "should broadcast the action over IPC to all windows", ->
spyOn(ipc, 'send') spyOn(ipc, 'send')
Actions.didSwapModel.firing = false Actions.logout.firing = false
@bridge.onRebroadcast(ActionBridge.TargetWindows.ALL, 'didSwapModel', [{oldModel: '1', newModel: 2}]) @bridge.onRebroadcast(ActionBridge.TargetWindows.ALL, 'logout', [{oldModel: '1', newModel: 2}])
expect(ipc.send).toHaveBeenCalledWith('action-bridge-rebroadcast-to-all', 'popout', 'didSwapModel', '[{"oldModel":"1","newModel":2}]') expect(ipc.send).toHaveBeenCalledWith('action-bridge-rebroadcast-to-all', 'popout', 'logout', '[{"oldModel":"1","newModel":2}]')
describe "when called with TargetWindows.WORK", -> describe "when called with TargetWindows.WORK", ->
it "should broadcast the action over IPC to the main window only", -> it "should broadcast the action over IPC to the main window only", ->
spyOn(ipc, 'send') spyOn(ipc, 'send')
Actions.didSwapModel.firing = false Actions.logout.firing = false
@bridge.onRebroadcast(ActionBridge.TargetWindows.WORK, 'didSwapModel', [{oldModel: '1', newModel: 2}]) @bridge.onRebroadcast(ActionBridge.TargetWindows.WORK, 'logout', [{oldModel: '1', newModel: 2}])
expect(ipc.send).toHaveBeenCalledWith('action-bridge-rebroadcast-to-work', 'popout', 'didSwapModel', '[{"oldModel":"1","newModel":2}]') expect(ipc.send).toHaveBeenCalledWith('action-bridge-rebroadcast-to-work', 'popout', 'logout', '[{"oldModel":"1","newModel":2}]')
it "should not do anything if the current invocation of the Action was triggered by itself", -> it "should not do anything if the current invocation of the Action was triggered by itself", ->
spyOn(ipc, 'send') spyOn(ipc, 'send')
Actions.didSwapModel.firing = true Actions.logout.firing = true
@bridge.onRebroadcast(ActionBridge.TargetWindows.ALL, 'didSwapModel', [{oldModel: '1', newModel: 2}]) @bridge.onRebroadcast(ActionBridge.TargetWindows.ALL, 'logout', [{oldModel: '1', newModel: 2}])
expect(ipc.send).not.toHaveBeenCalled() expect(ipc.send).not.toHaveBeenCalled()

View file

@ -9,12 +9,6 @@ ReactTestUtils = React.addons.TestUtils
} = require 'nylas-exports' } = require 'nylas-exports'
{TokenizingTextField, Menu} = require 'nylas-component-kit' {TokenizingTextField, Menu} = require 'nylas-component-kit'
me = new Account
name: 'Test User'
email: 'test@example.com'
provider: 'inbox'
AccountStore._current = me
CustomToken = React.createClass CustomToken = React.createClass
render: -> render: ->
<span>{@props.item.email}</span> <span>{@props.item.email}</span>
@ -125,6 +119,8 @@ describe 'TokenizingTextField', ->
describe "when the user drags and drops a token between two fields", -> describe "when the user drags and drops a token between two fields", ->
it "should work properly", -> it "should work properly", ->
participant2.clientId = '123'
tokensA = [participant1, participant2, participant3] tokensA = [participant1, participant2, participant3]
fieldA = @rebuildRenderedField(tokensA) fieldA = @rebuildRenderedField(tokensA)
@ -142,7 +138,7 @@ describe 'TokenizingTextField', ->
ReactTestUtils.Simulate.dragStart(token, dragStartEvent) ReactTestUtils.Simulate.dragStart(token, dragStartEvent)
expect(dragStartEventData).toEqual({ expect(dragStartEventData).toEqual({
'nylas-token-item': '{"id":"2","name":"Nylas Burger Basket","email":"burgers@nylas.com","__constructorName":"Contact"}' 'nylas-token-item': '{"client_id":"123","server_id":"2","name":"Nylas Burger Basket","email":"burgers@nylas.com","id":"2","__constructorName":"Contact"}'
'text/plain': 'Nylas Burger Basket <burgers@nylas.com>' 'text/plain': 'Nylas Burger Basket <burgers@nylas.com>'
}) })

View file

@ -8,12 +8,30 @@ class TestModel extends Model
queryable: true queryable: true
modelKey: 'id' 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.configureBasic = ->
TestModel.additionalSQLiteConfig = undefined TestModel.additionalSQLiteConfig = undefined
TestModel.attributes = TestModel.attributes =
'id': Attributes.String 'id': Attributes.String
queryable: true queryable: true
modelKey: 'id' 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.configureWithAllAttributes = ->
TestModel.additionalSQLiteConfig = undefined TestModel.additionalSQLiteConfig = undefined
@ -40,6 +58,14 @@ TestModel.configureWithCollectionAttribute = ->
'id': Attributes.String 'id': Attributes.String
queryable: true queryable: true
modelKey: 'id' modelKey: 'id'
'clientId': Attributes.String
queryable: true
modelKey: 'clientId'
jsonKey: 'client_id'
'serverId': Attributes.ServerId
queryable: true
modelKey: 'serverId'
jsonKey: 'server_id'
'labels': Attributes.Collection 'labels': Attributes.Collection
queryable: true queryable: true
modelKey: 'labels' modelKey: 'labels'
@ -52,6 +78,14 @@ TestModel.configureWithJoinedDataAttribute = ->
'id': Attributes.String 'id': Attributes.String
queryable: true queryable: true
modelKey: 'id' 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 'body': Attributes.JoinedData
modelTable: 'TestModelBody' modelTable: 'TestModelBody'
modelKey: 'body' modelKey: 'body'
@ -62,6 +96,12 @@ TestModel.configureWithAdditionalSQLiteConfig = ->
'id': Attributes.String 'id': Attributes.String
queryable: true queryable: true
modelKey: 'id' modelKey: 'id'
'clientId': Attributes.String
modelKey: 'clientId'
jsonKey: 'client_id'
'serverId': Attributes.ServerId
modelKey: 'serverId'
jsonKey: 'server_id'
'body': Attributes.JoinedData 'body': Attributes.JoinedData
modelTable: 'TestModelBody' modelTable: 'TestModelBody'
modelKey: 'body' modelKey: 'body'

View file

@ -1,4 +1,5 @@
Model = require '../../src/flux/models/model' Model = require '../../src/flux/models/model'
Utils = require '../../src/flux/models/utils'
Attributes = require '../../src/flux/attributes' Attributes = require '../../src/flux/attributes'
{isTempId} = require '../../src/flux/models/utils' {isTempId} = require '../../src/flux/models/utils'
_ = require 'underscore' _ = require 'underscore'
@ -13,22 +14,43 @@ describe "Model", ->
expect(m.id).toBe(attrs.id) expect(m.id).toBe(attrs.id)
expect(m.accountId).toBe(attrs.accountId) expect(m.accountId).toBe(attrs.accountId)
it "should assign a local- ID to the model if no ID is provided", -> it "by default assigns things passed into the id constructor to the serverId", ->
attrs =
id: "A",
m = new Model(attrs)
expect(m.serverId).toBe(attrs.id)
it "by default assigns values passed into the id constructor that look like localIds to be a localID", ->
attrs =
id: "A",
m = new Model(attrs)
expect(m.serverId).toBe(attrs.id)
it "assigns serverIds and clientIds", ->
attrs =
clientId: "local-A",
serverId: "A",
m = new Model(attrs)
expect(m.serverId).toBe(attrs.serverId)
expect(m.clientId).toBe(attrs.clientId)
expect(m.id).toBe(attrs.serverId)
it "throws an error if you attempt to manually assign the id", ->
m = new Model(id: "foo")
expect( -> m.id = "bar" ).toThrow()
it "automatically assigns a clientId (and id) to the model if no id is provided", ->
m = new Model m = new Model
expect(isTempId(m.id)).toBe(true) expect(Utils.isTempId(m.id)).toBe true
expect(Utils.isTempId(m.clientId)).toBe true
expect(m.serverId).toBeUndefined()
describe "attributes", -> describe "attributes", ->
it "should return the attributes of the class", -> it "should return the attributes of the class EXCEPT the id field", ->
m = new Model() m = new Model()
expect(m.attributes()).toBe(m.constructor.attributes) retAttrs = _.clone(m.constructor.attributes)
delete retAttrs["id"]
describe "isSaved", -> expect(m.attributes()).toEqual(retAttrs)
it "should return false if the object has a temp ID", ->
a = new Model()
expect(a.isSaved()).toBe(false)
b = new Model({id: "b"})
expect(b.isSaved()).toBe(true)
describe "clone", -> describe "clone", ->
it "should return a deep copy of the object", -> it "should return a deep copy of the object", ->

View file

@ -1,4 +1,3 @@
{generateTempId} = require '../../src/flux/models/utils'
Message = require '../../src/flux/models/message' Message = require '../../src/flux/models/message'
Thread = require '../../src/flux/models/thread' Thread = require '../../src/flux/models/thread'
_ = require 'underscore' _ = require 'underscore'

View file

@ -11,8 +11,6 @@ describe "ContactStore", ->
ContactStore._contactCache = [] ContactStore._contactCache = []
ContactStore._fetchOffset = 0 ContactStore._fetchOffset = 0
ContactStore._accountId = null ContactStore._accountId = null
AccountStore._current =
id: "test_account_id"
afterEach -> afterEach ->
atom.testOrganizationUnit = null atom.testOrganizationUnit = null
@ -36,9 +34,7 @@ describe "ContactStore", ->
spyOn(ContactStore, "_refreshCache") spyOn(ContactStore, "_refreshCache")
ContactStore._contactCache = [1,2,3] ContactStore._contactCache = [1,2,3]
ContactStore._fetchOffset = 3 ContactStore._fetchOffset = 3
ContactStore._accountId = "test_account_id" ContactStore._accountId = TEST_ACCOUNT_ID
AccountStore._current =
id: "test_account_id"
AccountStore.trigger() AccountStore.trigger()
expect(ContactStore._contactCache).toEqual [1,2,3] expect(ContactStore._contactCache).toEqual [1,2,3]
expect(ContactStore._fetchOffset).toBe 3 expect(ContactStore._fetchOffset).toBe 3

View file

@ -30,7 +30,7 @@ describe "DatabaseSetupQueryBuilder", ->
TestModel.configureWithCollectionAttribute() TestModel.configureWithCollectionAttribute()
queries = @builder.setupQueriesForTable(TestModel) queries = @builder.setupQueriesForTable(TestModel)
expected = [ expected = [
'CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB)', 'CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,client_id TEXT,server_id TEXT)',
'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_id` ON `TestModel` (`id`)', 'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_id` ON `TestModel` (`id`)',
'CREATE TABLE IF NOT EXISTS `TestModel-Label` (id TEXT KEY, `value` TEXT)' 'CREATE TABLE IF NOT EXISTS `TestModel-Label` (id TEXT KEY, `value` TEXT)'
'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_Label_id_val` ON `TestModel-Label` (`id`,`value`)', 'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_Label_id_val` ON `TestModel-Label` (`id`,`value`)',

View file

@ -7,9 +7,9 @@ ModelQuery = require '../../src/flux/models/query'
DatabaseStore = require '../../src/flux/stores/database-store' DatabaseStore = require '../../src/flux/stores/database-store'
testMatchers = {'id': 'b'} testMatchers = {'id': 'b'}
testModelInstance = new TestModel(id: '1234') testModelInstance = new TestModel(id: "1234")
testModelInstanceA = new TestModel(id: 'AAA') testModelInstanceA = new TestModel(id: "AAA")
testModelInstanceB = new TestModel(id: 'BBB') testModelInstanceB = new TestModel(id: "BBB")
describe "DatabaseStore", -> describe "DatabaseStore", ->
beforeEach -> beforeEach ->
@ -146,7 +146,7 @@ describe "DatabaseStore", ->
it "should compose a REPLACE INTO query to save the model", -> it "should compose a REPLACE INTO query to save the model", ->
TestModel.configureWithCollectionAttribute() TestModel.configureWithCollectionAttribute()
DatabaseStore._writeModels([testModelInstance]) DatabaseStore._writeModels([testModelInstance])
expect(@performed[0].query).toBe("REPLACE INTO `TestModel` (id,data) VALUES (?,?)") expect(@performed[0].query).toBe("REPLACE INTO `TestModel` (id,data,client_id,server_id) VALUES (?,?,?,?)")
it "should save the model JSON into the data column", -> it "should save the model JSON into the data column", ->
DatabaseStore._writeModels([testModelInstance]) DatabaseStore._writeModels([testModelInstance])
@ -234,9 +234,9 @@ describe "DatabaseStore", ->
TestModel.configureWithJoinedDataAttribute() TestModel.configureWithJoinedDataAttribute()
it "should not include the value to the joined attribute in the JSON written to the main model table", -> it "should not include the value to the joined attribute in the JSON written to the main model table", ->
@m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world') @m = new TestModel(clientId: 'local-6806434c-b0cd', serverId: 'server-1', body: 'hello world')
DatabaseStore._writeModels([@m]) DatabaseStore._writeModels([@m])
expect(@performed[0].values).toEqual(['local-6806434c-b0cd', '{"id":"local-6806434c-b0cd"}']) expect(@performed[0].values).toEqual(['server-1', '{"client_id":"local-6806434c-b0cd","server_id":"server-1","id":"server-1"}', 'local-6806434c-b0cd', 'server-1'])
it "should write the value to the joined table if it is defined", -> it "should write the value to the joined table if it is defined", ->
@m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world') @m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
@ -244,7 +244,7 @@ describe "DatabaseStore", ->
expect(@performed[1].query).toBe('REPLACE INTO `TestModelBody` (`id`, `value`) VALUES (?, ?)') expect(@performed[1].query).toBe('REPLACE INTO `TestModelBody` (`id`, `value`) VALUES (?, ?)')
expect(@performed[1].values).toEqual([@m.id, @m.body]) expect(@performed[1].values).toEqual([@m.id, @m.body])
it "should not write the valeu to the joined table if it undefined", -> it "should not write the value to the joined table if it undefined", ->
@m = new TestModel(id: 'local-6806434c-b0cd') @m = new TestModel(id: 'local-6806434c-b0cd')
DatabaseStore._writeModels([@m]) DatabaseStore._writeModels([@m])
expect(@performed.length).toBe(1) expect(@performed.length).toBe(1)

View file

@ -107,7 +107,6 @@ describe "DraftStore", ->
return Promise.resolve(fakeMessage2) if query._klass is Message return Promise.resolve(fakeMessage2) if query._klass is Message
return Promise.reject(new Error('Not Stubbed')) return Promise.reject(new Error('Not Stubbed'))
spyOn(DatabaseStore, 'persistModel').andCallFake -> Promise.resolve() spyOn(DatabaseStore, 'persistModel').andCallFake -> Promise.resolve()
spyOn(DatabaseStore, 'bindToLocalId')
afterEach -> afterEach ->
# Have to cleanup the DraftStoreProxy objects or we'll get a memory # Have to cleanup the DraftStoreProxy objects or we'll get a memory
@ -277,21 +276,13 @@ describe "DraftStore", ->
, (model) -> , (model) ->
expect(model.constructor).toBe(Message) expect(model.constructor).toBe(Message)
it "should assign and save a local Id for the new message", -> it "should setup a draft session for the draftClientId, so that a subsequent request for the session's draft resolves immediately.", ->
@_callNewMessageWithContext {threadId: fakeThread.id} @_callNewMessageWithContext {threadId: fakeThread.id}
, (thread, message) -> , (thread, message) ->
{} {}
, (model) -> , (model) ->
expect(DatabaseStore.bindToLocalId).toHaveBeenCalled() session = DraftStore.sessionForClientId(model.id).value()
expect(session.draft()).toBe(model)
it "should setup a draft session for the draftLocalId, so that a subsequent request for the session's draft resolves immediately.", ->
@_callNewMessageWithContext {threadId: fakeThread.id}
, (thread, message) ->
{}
, (model) ->
[draft, localId] = DatabaseStore.bindToLocalId.mostRecentCall.args
session = DraftStore.sessionForLocalId(localId).value()
expect(session.draft()).toBe(draft)
it "should set the subject of the new message automatically", -> it "should set the subject of the new message automatically", ->
@_callNewMessageWithContext {threadId: fakeThread.id} @_callNewMessageWithContext {threadId: fakeThread.id}
@ -559,7 +550,7 @@ describe "DraftStore", ->
expect(DraftStore._onBeforeUnload()).toBe(true) expect(DraftStore._onBeforeUnload()).toBe(true)
describe "sending a draft", -> describe "sending a draft", ->
draftLocalId = "local-123" draftClientId = "local-123"
beforeEach -> beforeEach ->
DraftStore._draftSessions = {} DraftStore._draftSessions = {}
DraftStore._draftsSending = {} DraftStore._draftsSending = {}
@ -569,7 +560,7 @@ describe "DraftStore", ->
draft: -> {} draft: -> {}
changes: changes:
commit: -> Promise.resolve() commit: -> Promise.resolve()
DraftStore._draftSessions[draftLocalId] = proxy DraftStore._draftSessions[draftClientId] = proxy
spyOn(DraftStore, "_doneWithSession").andCallThrough() spyOn(DraftStore, "_doneWithSession").andCallThrough()
spyOn(DraftStore, "trigger") spyOn(DraftStore, "trigger")
@ -577,23 +568,23 @@ describe "DraftStore", ->
spyOn(atom, "isMainWindow").andReturn true spyOn(atom, "isMainWindow").andReturn true
spyOn(Actions, "queueTask").andCallThrough() spyOn(Actions, "queueTask").andCallThrough()
runs -> runs ->
DraftStore._onSendDraft(draftLocalId) DraftStore._onSendDraft(draftClientId)
waitsFor -> waitsFor ->
Actions.queueTask.calls.length > 0 Actions.queueTask.calls.length > 0
runs -> runs ->
expect(DraftStore.isSendingDraft(draftLocalId)).toBe true expect(DraftStore.isSendingDraft(draftClientId)).toBe true
expect(DraftStore.trigger).toHaveBeenCalled() expect(DraftStore.trigger).toHaveBeenCalled()
it "returns false if the draft hasn't been seen", -> it "returns false if the draft hasn't been seen", ->
spyOn(atom, "isMainWindow").andReturn true spyOn(atom, "isMainWindow").andReturn true
expect(DraftStore.isSendingDraft(draftLocalId)).toBe false expect(DraftStore.isSendingDraft(draftClientId)).toBe false
it "closes the window if it's a popout", -> it "closes the window if it's a popout", ->
spyOn(atom, "getWindowType").andReturn "composer" spyOn(atom, "getWindowType").andReturn "composer"
spyOn(atom, "isMainWindow").andReturn false spyOn(atom, "isMainWindow").andReturn false
spyOn(atom, "close") spyOn(atom, "close")
runs -> runs ->
DraftStore._onSendDraft(draftLocalId) DraftStore._onSendDraft(draftClientId)
waitsFor "Atom to close", -> waitsFor "Atom to close", ->
atom.close.calls.length > 0 atom.close.calls.length > 0
@ -603,7 +594,7 @@ describe "DraftStore", ->
spyOn(atom, "close") spyOn(atom, "close")
spyOn(DraftStore, "_isPopout").andCallThrough() spyOn(DraftStore, "_isPopout").andCallThrough()
runs -> runs ->
DraftStore._onSendDraft(draftLocalId) DraftStore._onSendDraft(draftClientId)
waitsFor -> waitsFor ->
DraftStore._isPopout.calls.length > 0 DraftStore._isPopout.calls.length > 0
runs -> runs ->
@ -612,14 +603,14 @@ describe "DraftStore", ->
it "queues a SendDraftTask", -> it "queues a SendDraftTask", ->
spyOn(Actions, "queueTask") spyOn(Actions, "queueTask")
runs -> runs ->
DraftStore._onSendDraft(draftLocalId) DraftStore._onSendDraft(draftClientId)
waitsFor -> waitsFor ->
DraftStore._doneWithSession.calls.length > 0 DraftStore._doneWithSession.calls.length > 0
runs -> runs ->
expect(Actions.queueTask).toHaveBeenCalled() expect(Actions.queueTask).toHaveBeenCalled()
task = Actions.queueTask.calls[0].args[0] task = Actions.queueTask.calls[0].args[0]
expect(task instanceof SendDraftTask).toBe true expect(task instanceof SendDraftTask).toBe true
expect(task.draftLocalId).toBe draftLocalId expect(task.draftClientId).toBe draftClientId
expect(task.fromPopout).toBe false expect(task.fromPopout).toBe false
it "queues a SendDraftTask with popout info", -> it "queues a SendDraftTask with popout info", ->
@ -628,7 +619,7 @@ describe "DraftStore", ->
spyOn(atom, "close") spyOn(atom, "close")
spyOn(Actions, "queueTask") spyOn(Actions, "queueTask")
runs -> runs ->
DraftStore._onSendDraft(draftLocalId) DraftStore._onSendDraft(draftClientId)
waitsFor -> waitsFor ->
DraftStore._doneWithSession.calls.length > 0 DraftStore._doneWithSession.calls.length > 0
runs -> runs ->
@ -640,7 +631,7 @@ describe "DraftStore", ->
beforeEach -> beforeEach ->
@draftTeardown = jasmine.createSpy('draft teardown') @draftTeardown = jasmine.createSpy('draft teardown')
@session = @session =
draftLocalId: "abc" draftClientId: "abc"
draft: -> draft: ->
pristine: false pristine: false
changes: changes:
@ -675,7 +666,7 @@ describe "DraftStore", ->
received = null received = null
spyOn(DraftStore, '_finalizeAndPersistNewMessage').andCallFake (draft) -> spyOn(DraftStore, '_finalizeAndPersistNewMessage').andCallFake (draft) ->
received = draft received = draft
Promise.resolve({draftLocalId: 123}) Promise.resolve({draftClientId: 123})
expected = "EmailSubjectLOLOL" expected = "EmailSubjectLOLOL"
DraftStore._onHandleMailtoLink('mailto:asdf@asdf.com?subject=' + expected) DraftStore._onHandleMailtoLink('mailto:asdf@asdf.com?subject=' + expected)

View file

@ -10,8 +10,6 @@ describe "EventStore", ->
atom.testOrganizationUnit = "folder" atom.testOrganizationUnit = "folder"
EventStore._eventCache = {} EventStore._eventCache = {}
EventStore._accountId = null EventStore._accountId = null
AccountStore._current =
id: "test_account_id"
afterEach -> afterEach ->
atom.testOrganizationUnit = null atom.testOrganizationUnit = null
@ -36,29 +34,27 @@ describe "EventStore", ->
it "does nothing", -> it "does nothing", ->
spyOn(EventStore, "_refreshCache") spyOn(EventStore, "_refreshCache")
EventStore._eventCache = {1: '', 2: '', 3: ''} EventStore._eventCache = {1: '', 2: '', 3: ''}
EventStore._accountId = "test_account_id" EventStore._accountId = TEST_ACCOUNT_ID
AccountStore._current =
id: "test_account_id"
AccountStore.trigger() AccountStore.trigger()
expect(EventStore._eventCache).toEqual {1: '', 2: '', 3: ''} expect(EventStore._eventCache).toEqual {1: '', 2: '', 3: ''}
expect(EventStore._refreshCache).not.toHaveBeenCalled() expect(EventStore._refreshCache).not.toHaveBeenCalled()
describe "getEvent", -> describe "getEvent", ->
beforeEach -> beforeEach ->
@e1 = new Event(id: 1, title:'Test1', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}]) @e1 = new Event(id: 'a', title:'Test1', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}])
@e2 = new Event(id: 2, title:'Test2', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}]) @e2 = new Event(id: 'b', title:'Test2', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}])
@e3 = new Event(id: 3, title:'Test3', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}]) @e3 = new Event(id: 'c', title:'Test3', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}])
@e4 = new Event(id: 4, title:'Test4', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}]) @e4 = new Event(id: 'd', title:'Test4', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}])
EventStore._eventCache = {} EventStore._eventCache = {}
for e in [@e1, @e2, @e3, @e4] for e in [@e1, @e2, @e3, @e4]
EventStore._eventCache[e.id] = e EventStore._eventCache[e.id] = e
it "returns event object based on id", -> it "returns event object based on id", ->
first = EventStore.getEvent(1) first = EventStore.getEvent('a')
expect(first.title).toBe 'Test1' expect(first.title).toBe 'Test1'
second = EventStore.getEvent(2) second = EventStore.getEvent('b')
expect(second.title).toBe 'Test2' expect(second.title).toBe 'Test2'
third = EventStore.getEvent(3) third = EventStore.getEvent('c')
expect(third.title).toBe 'Test3' expect(third.title).toBe 'Test3'
fourth = EventStore.getEvent(4) fourth = EventStore.getEvent('d')
expect(fourth.title).toBe 'Test4' expect(fourth.title).toBe 'Test4'

View file

@ -41,7 +41,7 @@ describe "FileDownloadStore", ->
beforeEach -> beforeEach ->
spyOn(shell, 'showItemInFolder') spyOn(shell, 'showItemInFolder')
spyOn(shell, 'openItem') spyOn(shell, 'openItem')
@testfile = new File(filename: '123.png', contentType: 'image/png', id: 'id', size: 100) @testfile = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
FileDownloadStore._downloads = {} FileDownloadStore._downloads = {}
FileDownloadStore._downloadDirectory = "/Users/testuser/.nylas/downloads" FileDownloadStore._downloadDirectory = "/Users/testuser/.nylas/downloads"
@ -60,7 +60,7 @@ describe "FileDownloadStore", ->
describe "_checkForDownloadedFile", -> describe "_checkForDownloadedFile", ->
it "should return true if the file exists at the path and is the right size", -> it "should return true if the file exists at the path and is the right size", ->
f = new File(filename: '123.png', contentType: 'image/png', id: 'id', size: 100) f = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
spyOn(fs, 'stat').andCallFake (path, callback) -> spyOn(fs, 'stat').andCallFake (path, callback) ->
callback(null, {size: 100}) callback(null, {size: 100})
waitsForPromise -> waitsForPromise ->
@ -68,7 +68,7 @@ describe "FileDownloadStore", ->
expect(downloaded).toBe(true) expect(downloaded).toBe(true)
it "should return false if the file does not exist", -> it "should return false if the file does not exist", ->
f = new File(filename: '123.png', contentType: 'image/png', id: 'id', size: 100) f = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
spyOn(fs, 'stat').andCallFake (path, callback) -> spyOn(fs, 'stat').andCallFake (path, callback) ->
callback(new Error("File does not exist")) callback(new Error("File does not exist"))
waitsForPromise -> waitsForPromise ->
@ -76,7 +76,7 @@ describe "FileDownloadStore", ->
expect(downloaded).toBe(false) expect(downloaded).toBe(false)
it "should return false if the file is too small", -> it "should return false if the file is too small", ->
f = new File(filename: '123.png', contentType: 'image/png', id: 'id', size: 100) f = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
spyOn(fs, 'stat').andCallFake (path, callback) -> spyOn(fs, 'stat').andCallFake (path, callback) ->
callback(null, {size: 50}) callback(null, {size: 50})
waitsForPromise -> waitsForPromise ->

View file

@ -14,7 +14,7 @@ describe 'FileUploadStore', ->
size: 12345 size: 12345
@uploadData = @uploadData =
uploadTaskId: 123 uploadTaskId: 123
messageLocalId: msgId messageClientId: msgId
filePath: fpath filePath: fpath
fileSize: 12345 fileSize: 12345
@ -29,11 +29,11 @@ describe 'FileUploadStore', ->
it "throws if the message id is blank", -> it "throws if the message id is blank", ->
spyOn(Actions, "attachFilePath") spyOn(Actions, "attachFilePath")
Actions.attachFile messageLocalId: msgId Actions.attachFile messageClientId: msgId
expect(atom.showOpenDialog).toHaveBeenCalled() expect(atom.showOpenDialog).toHaveBeenCalled()
expect(Actions.attachFilePath).toHaveBeenCalled() expect(Actions.attachFilePath).toHaveBeenCalled()
args = Actions.attachFilePath.calls[0].args[0] args = Actions.attachFilePath.calls[0].args[0]
expect(args.messageLocalId).toBe msgId expect(args.messageClientId).toBe msgId
expect(args.path).toBe fpath expect(args.path).toBe fpath
describe 'attachFilePath', -> describe 'attachFilePath', ->
@ -44,19 +44,19 @@ describe 'FileUploadStore', ->
spyOn(fs, 'stat').andCallFake (path, callback) -> spyOn(fs, 'stat').andCallFake (path, callback) ->
callback(null, {isDirectory: -> false}) callback(null, {isDirectory: -> false})
Actions.attachFilePath Actions.attachFilePath
messageLocalId: msgId messageClientId: msgId
path: fpath path: fpath
expect(Actions.queueTask).toHaveBeenCalled() expect(Actions.queueTask).toHaveBeenCalled()
t = Actions.queueTask.calls[0].args[0] t = Actions.queueTask.calls[0].args[0]
expect(t.filePath).toBe fpath expect(t.filePath).toBe fpath
expect(t.messageLocalId).toBe msgId expect(t.messageClientId).toBe msgId
it 'displays an error if the file path given is a directory', -> it 'displays an error if the file path given is a directory', ->
spyOn(FileUploadStore, '_onAttachFileError') spyOn(FileUploadStore, '_onAttachFileError')
spyOn(fs, 'stat').andCallFake (path, callback) -> spyOn(fs, 'stat').andCallFake (path, callback) ->
callback(null, {isDirectory: -> true}) callback(null, {isDirectory: -> true})
Actions.attachFilePath Actions.attachFilePath
messageLocalId: msgId messageClientId: msgId
path: fpath path: fpath
expect(Actions.queueTask).not.toHaveBeenCalled() expect(Actions.queueTask).not.toHaveBeenCalled()
expect(FileUploadStore._onAttachFileError).toHaveBeenCalled() expect(FileUploadStore._onAttachFileError).toHaveBeenCalled()

View file

@ -24,7 +24,7 @@ describe "FocusedCategoryStore", ->
it "should set the current category to Inbox when the current category no longer exists in the CategoryStore", -> it "should set the current category to Inbox when the current category no longer exists in the CategoryStore", ->
otherAccountInbox = @inboxCategory.clone() otherAccountInbox = @inboxCategory.clone()
otherAccountInbox.id = 'other-id' otherAccountInbox.serverId = 'other-id'
FocusedCategoryStore._category = otherAccountInbox FocusedCategoryStore._category = otherAccountInbox
FocusedCategoryStore._onCategoryStoreChanged() FocusedCategoryStore._onCategoryStoreChanged()
expect(FocusedCategoryStore.category().id).toEqual(@inboxCategory.id) expect(FocusedCategoryStore.category().id).toEqual(@inboxCategory.id)

View file

@ -1,17 +1,7 @@
proxyquire = require 'proxyquire' proxyquire = require 'proxyquire'
Reflux = require 'reflux' Reflux = require 'reflux'
MessageStoreStub = Reflux.createStore FocusedContactsStore = require '../../src/flux/stores/focused-contacts-store'
items: -> []
extensions: -> []
threadId: -> null
AccountStoreStub = Reflux.createStore
current: -> null
FocusedContactsStore = proxyquire '../../src/flux/stores/focused-contacts-store',
"./message-store": MessageStoreStub
"./account-store": AccountStoreStub
describe "FocusedContactsStore", -> describe "FocusedContactsStore", ->
beforeEach -> beforeEach ->

View file

@ -2,8 +2,6 @@ Actions = require '../../src/flux/actions'
TaskQueue = require '../../src/flux/stores/task-queue' TaskQueue = require '../../src/flux/stores/task-queue'
Task = require '../../src/flux/tasks/task' Task = require '../../src/flux/tasks/task'
{isTempId} = require '../../src/flux/models/utils'
{APIError, {APIError,
OfflineError, OfflineError,
TimeoutError} = require '../../src/flux/errors' TimeoutError} = require '../../src/flux/errors'

View file

@ -19,7 +19,7 @@ describe "UnreadCountStore", ->
atom.testOrganizationUnit = 'folder' atom.testOrganizationUnit = 'folder'
UnreadCountStore._fetchCount() UnreadCountStore._fetchCount()
advanceClock() advanceClock()
expect(DatabaseStore.findBy).toHaveBeenCalledWith(Folder, {name: 'inbox', accountId: 'test_account_id'}) expect(DatabaseStore.findBy).toHaveBeenCalledWith(Folder, {name: 'inbox', accountId: TEST_ACCOUNT_ID})
[Model, Matchers] = DatabaseStore.count.calls[0].args [Model, Matchers] = DatabaseStore.count.calls[0].args
expect(Model).toBe(Thread) expect(Model).toBe(Thread)
@ -33,7 +33,7 @@ describe "UnreadCountStore", ->
atom.testOrganizationUnit = 'label' atom.testOrganizationUnit = 'label'
UnreadCountStore._fetchCount() UnreadCountStore._fetchCount()
advanceClock() advanceClock()
expect(DatabaseStore.findBy).toHaveBeenCalledWith(Label, {name: 'inbox', accountId: 'test_account_id'}) expect(DatabaseStore.findBy).toHaveBeenCalledWith(Label, {name: 'inbox', accountId: TEST_ACCOUNT_ID})
[Model, Matchers] = DatabaseStore.count.calls[0].args [Model, Matchers] = DatabaseStore.count.calls[0].args
expect(Matchers[0].attr.modelKey).toBe('accountId') expect(Matchers[0].attr.modelKey).toBe('accountId')

View file

@ -1,44 +0,0 @@
Task = require '../src/task'
xdescribe "Task", ->
describe "@once(taskPath, args..., callback)", ->
it "terminates the process after it completes", ->
handlerResult = null
task = Task.once require.resolve('./fixtures/task-spec-handler'), (result) ->
handlerResult = result
processErrored = false
childProcess = task.childProcess
spyOn(childProcess, 'kill').andCallThrough()
task.childProcess.on 'error', -> processErrored = true
waitsFor ->
handlerResult?
runs ->
expect(handlerResult).toBe 'hello'
expect(childProcess.kill).toHaveBeenCalled()
expect(processErrored).toBe false
it "calls listeners registered with ::on when events are emitted in the task", ->
task = new Task(require.resolve('./fixtures/task-spec-handler'))
eventSpy = jasmine.createSpy('eventSpy')
task.on("some-event", eventSpy)
waitsFor (done) -> task.start(done)
runs ->
expect(eventSpy).toHaveBeenCalledWith(1, 2, 3)
it "unregisters listeners when the Disposable returned by ::on is disposed", ->
task = new Task(require.resolve('./fixtures/task-spec-handler'))
eventSpy = jasmine.createSpy('eventSpy')
disposable = task.on("some-event", eventSpy)
disposable.dispose()
waitsFor (done) -> task.start(done)
runs ->
expect(eventSpy).not.toHaveBeenCalled()

View file

@ -15,7 +15,7 @@ describe "EventRSVPTask", ->
@myEmail = "tester@nylas.com" @myEmail = "tester@nylas.com"
@event = new Event @event = new Event
id: '12233AEDF5' id: '12233AEDF5'
accountId: 'test_account_id' accountId: TEST_ACCOUNT_ID
title: 'Meeting with Ben Bitdiddle' title: 'Meeting with Ben Bitdiddle'
description: '' description: ''
location: '' location: ''

View file

@ -26,7 +26,7 @@ test_file_paths = [
noop = -> noop = ->
localId = "local-id_1234" messageClientId = "local-id_1234"
fake_draft = new Message fake_draft = new Message
id: "draft-id_1234" id: "draft-id_1234"
@ -52,13 +52,13 @@ describe "FileUploadTask", ->
@uploadData = @uploadData =
startDate: DATE startDate: DATE
messageLocalId: localId messageClientId: messageClientId
filePath: test_file_paths[0] filePath: test_file_paths[0]
fileSize: 1234 fileSize: 1234
fileName: "file.txt" fileName: "file.txt"
bytesUploaded: 0 bytesUploaded: 0
@task = new FileUploadTask(test_file_paths[0], localId) @task = new FileUploadTask(test_file_paths[0], messageClientId)
@req = jasmine.createSpyObj('req', ['abort']) @req = jasmine.createSpyObj('req', ['abort'])
@simulateRequestSuccessImmediately = false @simulateRequestSuccessImmediately = false
@ -82,13 +82,13 @@ describe "FileUploadTask", ->
(new FileUploadTask).performLocal().catch (err) -> (new FileUploadTask).performLocal().catch (err) ->
expect(err instanceof Error).toBe true expect(err instanceof Error).toBe true
it "rejects if not initialized with a messageLocalId", -> it "rejects if not initialized with a messageClientId", ->
waitsForPromise -> waitsForPromise ->
(new FileUploadTask(test_file_paths[0])).performLocal().catch (err) -> (new FileUploadTask(test_file_paths[0])).performLocal().catch (err) ->
expect(err instanceof Error).toBe true expect(err instanceof Error).toBe true
it 'initializes the upload start', -> it 'initializes the upload start', ->
task = new FileUploadTask(test_file_paths[0], localId) task = new FileUploadTask(test_file_paths[0], messageClientId)
expect(task._startDate).toBe DATE expect(task._startDate).toBe DATE
it "notifies when the task locally starts", -> it "notifies when the task locally starts", ->
@ -159,7 +159,7 @@ describe "FileUploadTask", ->
@simulateRequestSuccessImmediately = true @simulateRequestSuccessImmediately = true
spyOn(Actions, "uploadStateChanged") spyOn(Actions, "uploadStateChanged")
spyOn(DraftStore, "sessionForLocalId").andCallFake => spyOn(DraftStore, "sessionForClientId").andCallFake =>
Promise.resolve( Promise.resolve(
draft: => files: @testFiles draft: => files: @testFiles
changes: changes:
@ -178,12 +178,14 @@ describe "FileUploadTask", ->
waitsForPromise => @task.performRemote().then -> waitsForPromise => @task.performRemote().then ->
options = NylasAPI.makeRequest.mostRecentCall.args[0] options = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(options.path).toBe("/files") expect(options.path).toBe("/files")
expect(options.accountId).toBe("test_account_id") expect(options.accountId).toBe(TEST_ACCOUNT_ID)
expect(options.method).toBe('POST') expect(options.method).toBe('POST')
expect(options.formData.file.value).toBe("Read Stream") expect(options.formData.file.value).toBe("Read Stream")
it "attaches the file to the draft", -> it "attaches the file to the draft", ->
waitsForPromise => @task.performRemote().then => waitsForPromise => @task.performRemote().then =>
delete @changes[0].clientId
delete equivalentFile.clientId
expect(@changes).toEqual [equivalentFile] expect(@changes).toEqual [equivalentFile]
describe "file upload notifications", -> describe "file upload notifications", ->
@ -201,15 +203,17 @@ describe "FileUploadTask", ->
bytesUploaded: 1000 bytesUploaded: 1000
[{file, uploadData}] = Actions.fileUploaded.calls[0].args [{file, uploadData}] = Actions.fileUploaded.calls[0].args
delete file.clientId
delete equivalentFile.clientId
expect(file).toEqual(equivalentFile) expect(file).toEqual(equivalentFile)
expect(_.isMatch(uploadData, uploadDataExpected)).toBe(true) expect(_.isMatch(uploadData, uploadDataExpected)).toBe(true)
describe "when attaching a lot of files", -> describe "when attaching a lot of files", ->
it "attaches them all to the draft", -> it "attaches them all to the draft", ->
t1 = new FileUploadTask("1.a", localId) t1 = new FileUploadTask("1.a", messageClientId)
t2 = new FileUploadTask("2.b", localId) t2 = new FileUploadTask("2.b", messageClientId)
t3 = new FileUploadTask("3.c", localId) t3 = new FileUploadTask("3.c", messageClientId)
t4 = new FileUploadTask("4.d", localId) t4 = new FileUploadTask("4.d", messageClientId)
@simulateRequestSuccessImmediately = true @simulateRequestSuccessImmediately = true
waitsForPromise => Promise.all([ waitsForPromise => Promise.all([

View file

@ -3,7 +3,6 @@ Actions = require '../../src/flux/actions'
SyncbackDraftTask = require '../../src/flux/tasks/syncback-draft' SyncbackDraftTask = require '../../src/flux/tasks/syncback-draft'
SendDraftTask = require '../../src/flux/tasks/send-draft' SendDraftTask = require '../../src/flux/tasks/send-draft'
DatabaseStore = require '../../src/flux/stores/database-store' DatabaseStore = require '../../src/flux/stores/database-store'
{generateTempId} = require '../../src/flux/models/utils'
{APIError} = require '../../src/flux/errors' {APIError} = require '../../src/flux/errors'
Message = require '../../src/flux/models/message' Message = require '../../src/flux/models/message'
TaskQueue = require '../../src/flux/stores/task-queue' TaskQueue = require '../../src/flux/stores/task-queue'
@ -39,7 +38,7 @@ describe "SendDraftTask", ->
expect(@sendA.shouldWaitForTask(@saveA)).toBe(true) expect(@sendA.shouldWaitForTask(@saveA)).toBe(true)
describe "performLocal", -> describe "performLocal", ->
it "should throw an exception if the first parameter is not a localId", -> it "should throw an exception if the first parameter is not a clientId", ->
badTasks = [new SendDraftTask()] badTasks = [new SendDraftTask()]
goodTasks = [new SendDraftTask('localid-a')] goodTasks = [new SendDraftTask('localid-a')]
caught = [] caught = []
@ -60,8 +59,10 @@ describe "SendDraftTask", ->
describe "performRemote", -> describe "performRemote", ->
beforeEach -> beforeEach ->
@draftClientId = "local-123"
@draft = new Message @draft = new Message
version: '1' version: '1'
clientId: @draftClientId
id: '1233123AEDF1' id: '1233123AEDF1'
accountId: 'A12ADE' accountId: 'A12ADE'
subject: 'New Draft' subject: 'New Draft'
@ -70,12 +71,13 @@ describe "SendDraftTask", ->
to: to:
name: 'Dummy' name: 'Dummy'
email: 'dummy@nylas.com' email: 'dummy@nylas.com'
@draftLocalId = "local-123" @task = new SendDraftTask(@draftClientId)
@task = new SendDraftTask(@draftLocalId)
spyOn(NylasAPI, 'makeRequest').andCallFake (options) => spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
options.success?(@draft.toJSON()) options.success?(@draft.toJSON())
Promise.resolve(@draft.toJSON()) Promise.resolve(@draft.toJSON())
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) => spyOn(DatabaseStore, 'findBy').andCallFake (klass, id) =>
Promise.resolve(@draft)
spyOn(DatabaseStore, 'find').andCallFake (klass, id) =>
Promise.resolve(@draft) Promise.resolve(@draft)
spyOn(DatabaseStore, 'unpersistModel').andCallFake (draft) -> spyOn(DatabaseStore, 'unpersistModel').andCallFake (draft) ->
Promise.resolve() Promise.resolve()
@ -90,7 +92,7 @@ describe "SendDraftTask", ->
it "should notify the draft was sent", -> it "should notify the draft was sent", ->
waitsForPromise => @task.performRemote().then => waitsForPromise => @task.performRemote().then =>
args = Actions.sendDraftSuccess.calls[0].args[0] args = Actions.sendDraftSuccess.calls[0].args[0]
expect(args.draftLocalId).toBe @draftLocalId expect(args.draftClientId).toBe @draftClientId
it "get an object back on success", -> it "get an object back on success", ->
waitsForPromise => @task.performRemote().then => waitsForPromise => @task.performRemote().then =>
@ -117,12 +119,12 @@ describe "SendDraftTask", ->
expect(NylasAPI.makeRequest.calls.length).toBe(1) expect(NylasAPI.makeRequest.calls.length).toBe(1)
options = NylasAPI.makeRequest.mostRecentCall.args[0] options = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(options.body.version).toBe(@draft.version) expect(options.body.version).toBe(@draft.version)
expect(options.body.draft_id).toBe(@draft.id) expect(options.body.draft_id).toBe(@draft.serverId)
describe "when the draft has not been saved", -> describe "when the draft has not been saved", ->
beforeEach -> beforeEach ->
@draft = new Message @draft = new Message
id: generateTempId() id: "local-12345"
accountId: 'A12ADE' accountId: 'A12ADE'
subject: 'New Draft' subject: 'New Draft'
draft: true draft: true
@ -130,7 +132,7 @@ describe "SendDraftTask", ->
to: to:
name: 'Dummy' name: 'Dummy'
email: 'dummy@nylas.com' email: 'dummy@nylas.com'
@task = new SendDraftTask(@draftLocalId) @task = new SendDraftTask(@draftClientId)
it "should send the draft JSON", -> it "should send the draft JSON", ->
waitsForPromise => waitsForPromise =>
@ -157,7 +159,7 @@ describe "SendDraftTask", ->
beforeEach -> beforeEach ->
@draft = new Message @draft = new Message
version: '1' version: '1'
id: '1233123AEDF1' clientId: 'local-1234'
accountId: 'A12ADE' accountId: 'A12ADE'
threadId: 'threadId' threadId: 'threadId'
replyToMessageId: 'replyToMessageId' replyToMessageId: 'replyToMessageId'
@ -167,15 +169,15 @@ describe "SendDraftTask", ->
to: to:
name: 'Dummy' name: 'Dummy'
email: 'dummy@nylas.com' email: 'dummy@nylas.com'
@task = new SendDraftTask(@draft.id) @task = new SendDraftTask("local-1234")
spyOn(Actions, "dequeueTask") spyOn(Actions, "dequeueTask")
spyOn(DatabaseStore, 'unpersistModel').andCallFake (draft) -> spyOn(DatabaseStore, 'unpersistModel').andCallFake (draft) ->
Promise.resolve() Promise.resolve()
describe "when the server responds with `Invalid message public ID`", -> describe "when the server responds with `Invalid message public ID`", ->
it "should resend the draft without the reply_to_message_id key set", -> it "should resend the draft without the reply_to_message_id key set", ->
@draft.id = generateTempId() spyOn(DatabaseStore, 'findBy').andCallFake =>
spyOn(DatabaseStore, 'findByLocalId').andCallFake => Promise.resolve(@draft) Promise.resolve(@draft)
spyOn(NylasAPI, 'makeRequest').andCallFake ({body, success, error}) => spyOn(NylasAPI, 'makeRequest').andCallFake ({body, success, error}) =>
if body.reply_to_message_id if body.reply_to_message_id
err = new APIError(body: "Invalid message public id", statusCode: 400) err = new APIError(body: "Invalid message public id", statusCode: 400)
@ -193,8 +195,7 @@ describe "SendDraftTask", ->
describe "when the server responds with `Invalid thread ID`", -> describe "when the server responds with `Invalid thread ID`", ->
it "should resend the draft without the thread_id or reply_to_message_id keys set", -> it "should resend the draft without the thread_id or reply_to_message_id keys set", ->
@draft.id = generateTempId() spyOn(DatabaseStore, 'findBy').andCallFake => Promise.resolve(@draft)
spyOn(DatabaseStore, 'findByLocalId').andCallFake => Promise.resolve(@draft)
spyOn(NylasAPI, 'makeRequest').andCallFake ({body, success, error}) => spyOn(NylasAPI, 'makeRequest').andCallFake ({body, success, error}) =>
new Promise (resolve, reject) => new Promise (resolve, reject) =>
if body.thread_id if body.thread_id
@ -214,21 +215,21 @@ describe "SendDraftTask", ->
console.log(err.trace) console.log(err.trace)
it "throws an error if the draft can't be found", -> it "throws an error if the draft can't be found", ->
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) -> spyOn(DatabaseStore, 'findBy').andCallFake (klass, clientId) ->
Promise.resolve() Promise.resolve()
waitsForPromise => waitsForPromise =>
@task.performRemote().catch (error) -> @task.performRemote().catch (error) ->
expect(error.message).toBeDefined() expect(error.message).toBeDefined()
it "throws an error if the draft isn't saved", -> it "throws an error if the draft isn't saved", ->
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) -> spyOn(DatabaseStore, 'findBy').andCallFake (klass, clientId) ->
Promise.resolve(isSaved: false) Promise.resolve(serverId: null)
waitsForPromise => waitsForPromise =>
@task.performRemote().catch (error) -> @task.performRemote().catch (error) ->
expect(error.message).toBeDefined() expect(error.message).toBeDefined()
it "throws an error if the DB store has issues", -> it "throws an error if the DB store has issues", ->
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) -> spyOn(DatabaseStore, 'findBy').andCallFake (klass, clientId) ->
Promise.reject("DB error") Promise.reject("DB error")
waitsForPromise => waitsForPromise =>
@task.performRemote().catch (error) -> @task.performRemote().catch (error) ->

View file

@ -1,5 +1,4 @@
_ = require 'underscore' _ = require 'underscore'
{generateTempId, isTempId} = require '../../src/flux/models/utils'
NylasAPI = require '../../src/flux/nylas-api' NylasAPI = require '../../src/flux/nylas-api'
Task = require '../../src/flux/tasks/task' Task = require '../../src/flux/tasks/task'
@ -33,30 +32,27 @@ testData =
accountId: "abc123" accountId: "abc123"
body: '<body>123</body>' body: '<body>123</body>'
localDraft = new Message _.extend {}, testData, {id: "local-id"} localDraft = -> new Message _.extend {}, testData, {clientId: "local-id"}
remoteDraft = new Message _.extend {}, testData, {id: "remoteid1234"} remoteDraft = -> new Message _.extend {}, testData, {clientId: "local-id", serverId: "remoteid1234"}
describe "SyncbackDraftTask", -> describe "SyncbackDraftTask", ->
beforeEach -> beforeEach ->
spyOn(DatabaseStore, "findByLocalId").andCallFake (klass, localId) -> spyOn(DatabaseStore, "findBy").andCallFake (klass, {clientId}) ->
if localId is "localDraftId" then Promise.resolve(localDraft)
else if localId is "remoteDraftId" then Promise.resolve(remoteDraft)
else if localId is "missingDraftId" then Promise.resolve()
spyOn(DatabaseStore, 'findBy').andCallFake (klass, matchers) ->
if klass is Account if klass is Account
Promise.resolve(new Account(id: 'abc123')) return Promise.resolve(new Account(clientId: 'local-abc123', serverId: 'abc123'))
if clientId is "localDraftId" then Promise.resolve(localDraft())
else if clientId is "remoteDraftId" then Promise.resolve(remoteDraft())
else if clientId is "missingDraftId" then Promise.resolve()
else return Promise.resolve()
spyOn(DatabaseStore, "persistModel").andCallFake -> spyOn(DatabaseStore, "persistModel").andCallFake ->
Promise.resolve() Promise.resolve()
spyOn(DatabaseStore, "swapModel").andCallFake ->
Promise.resolve()
describe "performRemote", -> describe "performRemote", ->
beforeEach -> beforeEach ->
spyOn(NylasAPI, 'makeRequest').andCallFake (opts) -> spyOn(NylasAPI, 'makeRequest').andCallFake (opts) ->
Promise.resolve(remoteDraft.toJSON()) Promise.resolve(remoteDraft().toJSON())
it "does nothing if no draft can be found in the db", -> it "does nothing if no draft can be found in the db", ->
task = new SyncbackDraftTask("missingDraftId") task = new SyncbackDraftTask("missingDraftId")
@ -101,33 +97,7 @@ describe "SyncbackDraftTask", ->
options = NylasAPI.makeRequest.mostRecentCall.args[0] options = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(options.returnsModel).toBe(false) expect(options.returnsModel).toBe(false)
it "should swap the ids if we got a new one from the DB", ->
task = new SyncbackDraftTask("localDraftId")
waitsForPromise =>
task.performRemote().then ->
expect(DatabaseStore.swapModel).toHaveBeenCalled()
expect(DatabaseStore.persistModel).not.toHaveBeenCalled()
it "should not swap the ids if we're using a persisted one", ->
task = new SyncbackDraftTask("remoteDraftId")
waitsForPromise =>
task.performRemote().then ->
expect(DatabaseStore.swapModel).not.toHaveBeenCalled()
expect(DatabaseStore.persistModel).toHaveBeenCalled()
describe "When the api throws a 404 error", -> describe "When the api throws a 404 error", ->
beforeEach -> beforeEach ->
spyOn(NylasAPI, "makeRequest").andCallFake (opts) -> spyOn(NylasAPI, "makeRequest").andCallFake (opts) ->
Promise.reject(testError(opts)) Promise.reject(testError(opts))
it "resets the id", ->
task = new SyncbackDraftTask("remoteDraftId")
taskStatus = null
task.performRemote().then (status) => taskStatus = status
waitsFor ->
DatabaseStore.swapModel.calls.length > 0
runs ->
newDraft = DatabaseStore.swapModel.mostRecentCall.args[0].newModel
expect(isTempId(newDraft.id)).toBe true
expect(taskStatus).toBe(Task.Status.Retry)

View file

@ -1,4 +1,4 @@
class quicksort class Quicksort
sort: (items) -> sort: (items) ->
return items if items.length <= 1 return items if items.length <= 1
@ -20,4 +20,4 @@ class quicksort
noop: -> noop: ->
# just a noop # just a noop
exports.modules = quicksort exports.modules = Quicksort

View file

@ -1,4 +0,0 @@
# This is a comment
if this.studyingEconomics
buy() while supply > demand
sell() until supply > demand

View file

@ -1,4 +0,0 @@
# Econ 101
if this.studyingEconomics
buy() while supply > demand
sell() until supply > demand

View file

@ -19,6 +19,7 @@ ServiceHub = require 'service-hub'
pathwatcher = require 'pathwatcher' pathwatcher = require 'pathwatcher'
clipboard = require 'clipboard' clipboard = require 'clipboard'
Account = require "../src/flux/models/account"
AccountStore = require "../src/flux/stores/account-store" AccountStore = require "../src/flux/stores/account-store"
Contact = require '../src/flux/models/contact' Contact = require '../src/flux/models/contact'
{TaskQueue, ComponentRegistry} = require "nylas-exports" {TaskQueue, ComponentRegistry} = require "nylas-exports"
@ -104,6 +105,10 @@ Promise.setScheduler (fn) ->
setTimeout(fn, 0) setTimeout(fn, 0)
process.nextTick -> advanceClock(1) process.nextTick -> advanceClock(1)
# So it passes the Utils.isTempId test
window.TEST_ACCOUNT_CLIENT_ID = "local-test-account-client-id"
window.TEST_ACCOUNT_ID = "test-account-server-id"
beforeEach -> beforeEach ->
atom.testOrganizationUnit = null atom.testOrganizationUnit = null
Grim.clearDeprecations() if isCoreSpec Grim.clearDeprecations() if isCoreSpec
@ -146,9 +151,13 @@ beforeEach ->
spyOn(atom.menu, 'sendToBrowserProcess') spyOn(atom.menu, 'sendToBrowserProcess')
# Log in a fake user # Log in a fake user
spyOn(AccountStore, 'current').andCallFake -> spyOn(AccountStore, 'current').andCallFake -> new Account
name: "Nylas Test"
provider: "gmail"
emailAddress: 'tester@nylas.com' emailAddress: 'tester@nylas.com'
id: 'test_account_id' organizationUnit: atom.testOrganizationUnit
clientId: TEST_ACCOUNT_CLIENT_ID
serverId: TEST_ACCOUNT_ID
usesLabels: -> atom.testOrganizationUnit is "label" usesLabels: -> atom.testOrganizationUnit is "label"
usesFolders: -> atom.testOrganizationUnit is "folder" usesFolders: -> atom.testOrganizationUnit is "folder"
me: -> me: ->

View file

@ -250,7 +250,7 @@ class Application
@on 'application:send-feedback', => @windowManager.sendToMainWindow('send-feedback') @on 'application:send-feedback', => @windowManager.sendToMainWindow('send-feedback')
@on 'application:open-preferences', => @windowManager.sendToMainWindow('open-preferences') @on 'application:open-preferences', => @windowManager.sendToMainWindow('open-preferences')
@on 'application:show-main-window', => @windowManager.ensurePrimaryWindowOnscreen() @on 'application:show-main-window', => @windowManager.openWindowsForTokenState()
@on 'application:show-work-window', => @windowManager.showWorkWindow() @on 'application:show-work-window', => @windowManager.showWorkWindow()
@on 'application:check-for-update', => @autoUpdateManager.check() @on 'application:check-for-update', => @autoUpdateManager.check()
@on 'application:install-update', => @on 'application:install-update', =>
@ -259,9 +259,9 @@ class Application
@autoUpdateManager.install() @autoUpdateManager.install()
@on 'application:open-dev', => @on 'application:open-dev', =>
@devMode = true @devMode = true
@windowManager.closeMainWindow() @windowManager.closeAllWindows()
@windowManager.devMode = true @windowManager.devMode = true
@windowManager.ensurePrimaryWindowOnscreen() @windowManager.openWindowsForTokenState()
@on 'application:toggle-theme', => @on 'application:toggle-theme', =>
themes = @config.get('core.themes') ? [] themes = @config.get('core.themes') ? []
@ -326,7 +326,7 @@ class Application
@windowManager.sendToMainWindow('from-react-remote-window', json) @windowManager.sendToMainWindow('from-react-remote-window', json)
app.on 'activate-with-no-open-windows', (event) => app.on 'activate-with-no-open-windows', (event) =>
@windowManager.ensurePrimaryWindowOnscreen() @windowManager.openWindowsForTokenState()
event.preventDefault() event.preventDefault()
ipc.on 'update-application-menu', (event, template, keystrokesByCommand) => ipc.on 'update-application-menu', (event, template, keystrokesByCommand) =>

View file

@ -13,11 +13,11 @@ components inside of your React render method. Rather than explicitly render
a component, such as a `<Composer>`, you can use InjectedComponent: a component, such as a `<Composer>`, you can use InjectedComponent:
```coffee ```coffee
<InjectedComponent matching={role:"Composer"} exposedProps={draftId:123} /> <InjectedComponent matching={role:"Composer"} exposedProps={draftClientId:123} />
``` ```
InjectedComponent will look up the component registered with that role in the InjectedComponent will look up the component registered with that role in the
{ComponentRegistry} and render it, passing the exposedProps (`draftId={123}`) along. {ComponentRegistry} and render it, passing the exposedProps (`draftClientId={123}`) along.
InjectedComponent monitors the ComponentRegistry for changes. If a new component InjectedComponent monitors the ComponentRegistry for changes. If a new component
is registered that matches the descriptor you provide, InjectedComponent will refresh. is registered that matches the descriptor you provide, InjectedComponent will refresh.

View file

@ -58,13 +58,6 @@ Section: General
### ###
class Actions class Actions
###
Public: Fired when the {DatabaseStore} has changed the ID of a {Model}.
*Scope: Global*
###
@didSwapModel: ActionScopeGlobal
### ###
Public: Fired when the Nylas API Connector receives new data from the API. Public: Fired when the Nylas API Connector receives new data from the API.
@ -427,7 +420,7 @@ class Actions
``` ```
Actions.removeFile Actions.removeFile
file: fileObject file: fileObject
messageLocalId: draftLocalId messageClientId: draftClientId
``` ```
### ###
@removeFile: ActionScopeWindow @removeFile: ActionScopeWindow

View file

@ -8,6 +8,7 @@ AttributeBoolean = require './attributes/attribute-boolean'
AttributeDateTime = require './attributes/attribute-datetime' AttributeDateTime = require './attributes/attribute-datetime'
AttributeCollection = require './attributes/attribute-collection' AttributeCollection = require './attributes/attribute-collection'
AttributeJoinedData = require './attributes/attribute-joined-data' AttributeJoinedData = require './attributes/attribute-joined-data'
AttributeServerId = require './attributes/attribute-serverid'
module.exports = module.exports =
Matcher: Matcher Matcher: Matcher
@ -20,6 +21,7 @@ module.exports =
DateTime: -> new AttributeDateTime(arguments...) DateTime: -> new AttributeDateTime(arguments...)
Collection: -> new AttributeCollection(arguments...) Collection: -> new AttributeCollection(arguments...)
JoinedData: -> new AttributeJoinedData(arguments...) JoinedData: -> new AttributeJoinedData(arguments...)
ServerId: -> new AttributeServerId(arguments...)
AttributeNumber: AttributeNumber AttributeNumber: AttributeNumber
AttributeString: AttributeString AttributeString: AttributeString
@ -28,3 +30,4 @@ module.exports =
AttributeDateTime: AttributeDateTime AttributeDateTime: AttributeDateTime
AttributeCollection: AttributeCollection AttributeCollection: AttributeCollection
AttributeJoinedData: AttributeJoinedData AttributeJoinedData: AttributeJoinedData
AttributeServerId: AttributeServerId

View file

@ -10,8 +10,7 @@ For example, Threads in N1 have a collection of Labels or Folders.
When Collection attributes are marked as `queryable`, the DatabaseStore When Collection attributes are marked as `queryable`, the DatabaseStore
automatically creates a join table and maintains it as you create, save, automatically creates a join table and maintains it as you create, save,
and delete models. When you call `persistModel`, entries are added to the and delete models. When you call `persistModel`, entries are added to the
join table associating the ID of the model with the IDs of models in the join table associating the ID of the model with the IDs of models in the collection.
collection.
Collection attributes have an additional clause builder, `contains`: Collection attributes have an additional clause builder, `contains`:
@ -28,9 +27,7 @@ WHERE `M1`.`value` = 'inbox'
ORDER BY `Thread`.`last_message_received_timestamp` DESC ORDER BY `Thread`.`last_message_received_timestamp` DESC
``` ```
The value of this attribute is always an array of ff other model objects. To use The value of this attribute is always an array of other model objects.
a Collection attribute, the JSON for the parent object must contain the nested
objects, complete with their `object` field.
Section: Database Section: Database
### ###
@ -58,10 +55,11 @@ class AttributeCollection extends Attribute
objs = [] objs = []
for objJSON in json for objJSON in json
obj = new @itemClass(objJSON) obj = new @itemClass(objJSON)
# Important: if no ids are in the JSON, don't make them up randomly. # Important: if no ids are in the JSON, don't make them up
# This causes an object to be "different" each time it's de-serialized # randomly. This causes an object to be "different" each time it's
# even if it's actually the same, makes React components re-render! # de-serialized even if it's actually the same, makes React
obj.id = undefined # components re-render!
obj.clientId = undefined
obj.fromJSON(objJSON) if obj.fromJSON? obj.fromJSON(objJSON) if obj.fromJSON?
objs.push(obj) objs.push(obj)
objs objs

View file

@ -23,7 +23,7 @@ class AttributeObject extends Attribute
# Important: if no ids are in the JSON, don't make them up randomly. # Important: if no ids are in the JSON, don't make them up randomly.
# This causes an object to be "different" each time it's de-serialized # This causes an object to be "different" each time it's de-serialized
# even if it's actually the same, makes React components re-render! # even if it's actually the same, makes React components re-render!
obj.id = undefined obj.clientId = undefined
# Warning: typeof(null) is object # Warning: typeof(null) is object
if obj.fromJSON and val and typeof(val) is 'object' if obj.fromJSON and val and typeof(val) is 'object'
obj.fromJSON(val) obj.fromJSON(val)

View file

@ -0,0 +1,22 @@
AttributeString = require './attribute-string'
Matcher = require './matcher'
###
Public: The value of this attribute is always a string or `null`.
String attributes can be queries using `equal`, `not`, and `startsWith`. Matching on
`greaterThan` and `lessThan` is not supported.
Section: Database
###
class AttributeServerId extends AttributeString
toJSON: (val) ->
if val and Utils.isTempId(val)
throw (new Error "AttributeServerId::toJSON (#{@modelKey}) #{val} does not look like a valid server id")
equal: (val) ->
if val and Utils.isTempId(val)
throw (new Error "AttributeServerId::equal (#{@modelKey}) #{val} is not a valid value for this field.")
super
module.exports = AttributeString

View file

@ -6,11 +6,6 @@ _ = require 'underscore'
class Event extends Model class Event extends Model
@attributes: _.extend {}, Model.attributes, @attributes: _.extend {}, Model.attributes,
'id': Attributes.String
queryable: true
modelKey: 'id'
jsonKey: 'id'
'title': Attributes.String 'title': Attributes.String
modelKey: 'title' modelKey: 'title'
jsonKey: 'title' jsonKey: 'title'

View file

@ -1,18 +0,0 @@
_ = require 'underscore'
Model = require './model'
Attributes = require '../attributes'
class LocalLink extends Model
@attributes:
'id': Attributes.String
queryable: true
modelKey: 'id'
'objectId': Attributes.String
queryable: true
modelKey: 'objectId'
constructor: ({@id, @objectId} = {}) ->
@
module.exports = LocalLink

View file

@ -118,7 +118,7 @@ class Message extends Model
'snippet': Attributes.String 'snippet': Attributes.String
modelKey: 'snippet' modelKey: 'snippet'
'threadId': Attributes.String 'threadId': Attributes.ServerId
queryable: true queryable: true
modelKey: 'threadId' modelKey: 'threadId'
jsonKey: 'thread_id' jsonKey: 'thread_id'
@ -140,7 +140,7 @@ class Message extends Model
modelKey: 'version' modelKey: 'version'
queryable: true queryable: true
'replyToMessageId': Attributes.String 'replyToMessageId': Attributes.ServerId
modelKey: 'replyToMessageId' modelKey: 'replyToMessageId'
jsonKey: 'reply_to_message_id' jsonKey: 'reply_to_message_id'
@ -160,6 +160,7 @@ class Message extends Model
@additionalSQLiteConfig: @additionalSQLiteConfig:
setup: -> setup: ->
['CREATE INDEX IF NOT EXISTS MessageListIndex ON Message(account_id, thread_id, date ASC)', ['CREATE INDEX IF NOT EXISTS MessageListIndex ON Message(account_id, thread_id, date ASC)',
'CREATE UNIQUE INDEX IF NOT EXISTS MessageDraftIndex ON Message(client_id)',
'CREATE UNIQUE INDEX IF NOT EXISTS MessageBodyIndex ON MessageBody(id)'] 'CREATE UNIQUE INDEX IF NOT EXISTS MessageBodyIndex ON MessageBody(id)']
constructor: -> constructor: ->

View file

@ -1,7 +1,6 @@
Attributes = require '../attributes'
ModelQuery = require './query'
{isTempId, generateTempId} = require './utils'
_ = require 'underscore' _ = require 'underscore'
Utils = require './utils'
Attributes = require '../attributes'
### ###
Public: A base class for API objects that provides abstract support for Public: A base class for API objects that provides abstract support for
@ -9,7 +8,19 @@ serialization and deserialization, matching by attributes, and ID-based equality
## Attributes ## Attributes
`id`: {AttributeString} The ID of the model. Queryable. `id`: {AttributeString} The resolved canonical ID of the model used in the
database and generally throughout the app. The id property is a custom
getter that resolves to the serverId first, and then the clientId.
`clientId`: {AttributeString} An ID created at object construction and
persists throughout the lifetime of the object. This is extremely useful
for optimistically creating objects (like drafts and categories) and
having a constant reference to it. In all other cases, use the resolved
`id` field.
`serverId`: {AttributeServerId} The server ID of the model. In most cases,
except optimistic creation, this will also be the canonical id of the
object.
`object`: {AttributeString} The model's type. This field is used by the JSON `object`: {AttributeString} The model's type. This field is used by the JSON
deserializer to create an instance of the correct class when inflating the object. deserializer to create an instance of the correct class when inflating the object.
@ -20,15 +31,31 @@ Section: Models
### ###
class Model class Model
Object.defineProperty @prototype, "id",
enumerable: false
get: -> @serverId ? @clientId
set: ->
throw new Error("You may not directly set the ID of an object. Set either the `clientId` or the `serverId` instead.")
@attributes: @attributes:
# Lookups will go through the custom getter.
'id': Attributes.String 'id': Attributes.String
queryable: true queryable: true
modelKey: 'id' modelKey: 'id'
'clientId': Attributes.String
queryable: true
modelKey: 'clientId'
jsonKey: 'client_id'
'serverId': Attributes.ServerId
modelKey: 'serverId'
jsonKey: 'server_id'
'object': Attributes.String 'object': Attributes.String
modelKey: 'object' modelKey: 'object'
'accountId': Attributes.String 'accountId': Attributes.ServerId
queryable: true queryable: true
modelKey: 'accountId' modelKey: 'accountId'
jsonKey: 'account_id' jsonKey: 'account_id'
@ -36,9 +63,13 @@ class Model
@naturalSortOrder: -> null @naturalSortOrder: -> null
constructor: (values = {}) -> constructor: (values = {}) ->
if values["id"] and Utils.isTempId(values["id"])
values["clientId"] ?= values["id"]
else
values["serverId"] ?= values["id"]
for key, definition of @attributes() for key, definition of @attributes()
@[key] = values[key] if values[key]? @[key] = values[key] if values[key]?
@id ||= generateTempId() @clientId ?= Utils.generateTempId()
@ @
clone: -> clone: ->
@ -47,12 +78,9 @@ class Model
# Public: Returns an {Array} of {Attribute} objects defined on the Model's constructor # Public: Returns an {Array} of {Attribute} objects defined on the Model's constructor
# #
attributes: -> attributes: ->
@constructor.attributes attrs = _.clone(@constructor.attributes)
delete attrs["id"]
# Public Returns true if the object has a server-provided ID, false otherwise. return attrs
#
isSaved: ->
!isTempId(@id)
## ##
# Public: Inflates the model object from JSON, using the defined attributes to # Public: Inflates the model object from JSON, using the defined attributes to
@ -63,6 +91,8 @@ class Model
# This method is chainable. # This method is chainable.
# #
fromJSON: (json) -> fromJSON: (json) ->
if json["id"] and not Utils.isTempId(json["id"])
@serverId = json["id"]
for key, attr of @attributes() for key, attr of @attributes()
@[key] = attr.fromJSON(json[attr.jsonKey]) unless json[attr.jsonKey] is undefined @[key] = attr.fromJSON(json[attr.jsonKey]) unless json[attr.jsonKey] is undefined
@ @
@ -82,6 +112,7 @@ class Model
if attr instanceof Attributes.AttributeJoinedData and options.joined is false if attr instanceof Attributes.AttributeJoinedData and options.joined is false
continue continue
json[attr.jsonKey] = value json[attr.jsonKey] = value
json["id"] = @id
json json
toString: -> toString: ->

View file

@ -94,20 +94,6 @@ class Thread extends Model
@lastMessageReceivedTimestamp ||= new Date(json['last_message_timestamp'] * 1000) @lastMessageReceivedTimestamp ||= new Date(json['last_message_timestamp'] * 1000)
@ @
# Public: Returns true if the thread has a {Category} with the given ID.
#
# * `id` A {String} {Category} ID
#
hasCategoryId: (id) ->
return false unless id
for folder in (@folders ? [])
return true if folder.id is id
for label in (@labels ? [])
return true if label.id is id
return false
hasLabelId: (id) -> @hasCategoryId(id)
hasFolderId: (id) -> @hasCategoryId(id)
# Public: Returns true if the thread has a {Category} with the given # Public: Returns true if the thread has a {Category} with the given
# name. Note, only `CategoryStore::standardCategories` have valid # name. Note, only `CategoryStore::standardCategories` have valid
# `names` # `names`

View file

@ -211,7 +211,7 @@ Utils =
isEqualReact: (a, b, options={}) -> isEqualReact: (a, b, options={}) ->
options.functionsAreEqual = true options.functionsAreEqual = true
options.ignoreKeys = (options.ignoreKeys ? []).push("localId") options.ignoreKeys = (options.ignoreKeys ? []).push("clientId")
Utils.isEqual(a, b, options) Utils.isEqual(a, b, options)
# Customized version of Underscore 1.8.2's isEqual function # Customized version of Underscore 1.8.2's isEqual function

View file

@ -213,8 +213,9 @@ class NylasAPI
return Promise.resolve() return Promise.resolve()
# Returns a Promsie that resolves when any parsed out models (if any) # Returns a Promise that resolves when any parsed out models (if any)
# have been created and persisted to the database. # have been created and persisted to the database.
#
_handleModelResponse: (jsons) -> _handleModelResponse: (jsons) ->
if not jsons if not jsons
return Promise.reject(new Error("handleModelResponse with no JSON provided")) return Promise.reject(new Error("handleModelResponse with no JSON provided"))
@ -223,26 +224,49 @@ class NylasAPI
if jsons.length is 0 if jsons.length is 0
return Promise.resolve([]) return Promise.resolve([])
# Run a few assertions to make sure we're not going to run into problems
uniquedJSONs = _.uniq jsons, false, (model) -> model.id
if uniquedJSONs.length < jsons.length
console.warn("NylasAPI.handleModelResponse: called with non-unique object set. Maybe an API request returned the same object more than once?")
type = jsons[0].object type = jsons[0].object
klass = @_apiObjectToClassMap[type] klass = @_apiObjectToClassMap[type]
if not klass if not klass
console.warn("NylasAPI::handleModelResponse: Received unknown API object type: #{type}") console.warn("NylasAPI::handleModelResponse: Received unknown API object type: #{type}")
return Promise.resolve([]) return Promise.resolve([])
accepted = Promise.resolve(uniquedJSONs) # Step 1: Make sure the list of objects contains no duplicates, which cause
if type is "thread" or type is "draft" # problems downstream when we try to write to the database.
accepted = @_acceptableModelsInResponse(klass, uniquedJSONs) uniquedJSONs = _.uniq jsons, false, (model) -> model.id
if uniquedJSONs.length < jsons.length
console.warn("NylasAPI.handleModelResponse: called with non-unique object set. Maybe an API request returned the same object more than once?")
mapper = (json) -> (new klass).fromJSON(json) # Step 2: Filter out any objects locked by the optimistic change tracker.
unlockedJSONs = _.filter uniquedJSONs, (json) =>
if @_optimisticChangeTracker.acceptRemoteChangesTo(klass, json.id) is false
json._delta?.ignoredBecause = "This model is locked by the optimistic change tracker"
return false
return true
accepted.map(mapper).then (objects) -> # Step 3: Retrieve any existing models from the database for the given IDs.
DatabaseStore.persistModels(objects).then -> ids = _.pluck(unlockedJSONs, 'id')
return Promise.resolve(objects) DatabaseStore = require './stores/database-store'
DatabaseStore.findAll(klass).where(klass.attributes.id.in(ids)).then (models) ->
existingModels = {}
existingModels[model.id] = model for model in models
responseModels = []
changedModels = []
# Step 4: Merge the response data into the existing data for each model,
# skipping changes when we already have the given version
unlockedJSONs.forEach (json) =>
model = existingModels[json.id]
unless model and model.version? and json.version? and model.version is json.version
model ?= new klass()
model.fromJSON(json)
changedModels.push(model)
responseModels.push(model)
# Step 5: Save models that have changed, and then return all of the models
# that were in the response body.
DatabaseStore.persistModels(changedModels).then ->
return Promise.resolve(responseModels)
_apiObjectToClassMap: _apiObjectToClassMap:
"file": require('./models/file') "file": require('./models/file')
@ -257,25 +281,6 @@ class NylasAPI
"calendar": require('./models/calendar') "calendar": require('./models/calendar')
"metadata": require('./models/metadata') "metadata": require('./models/metadata')
_acceptableModelsInResponse: (klass, jsons) ->
# Filter out models that are locked by pending optimistic changes
accepted = jsons.filter (json) =>
if @_optimisticChangeTracker.acceptRemoteChangesTo(klass, json.id) is false
json._delta?.ignoredBecause = "This model is locked by the optimistic change tracker"
return false
return true
# Filter out models that already have newer versions in the local cache
ids = _.pluck(accepted, 'id')
DatabaseStore = require './stores/database-store'
DatabaseStore.findVersions(klass, ids).then (versions) ->
accepted = accepted.filter (json) ->
if json.version and versions[json.id] >= json.version
json._delta?.ignoredBecause = "This version (#{json.version}) is not newer. Already have (#{versions[json.id]})"
return false
return true
Promise.resolve(accepted)
getThreads: (accountId, params = {}, requestOptions = {}) -> getThreads: (accountId, params = {}, requestOptions = {}) ->
requestSuccess = requestOptions.success requestSuccess = requestOptions.success
requestOptions.success = (json) => requestOptions.success = (json) =>

View file

@ -52,10 +52,10 @@ AnalyticsStore = Reflux.createStore
composeReply: ({threadId, messageId}) -> {threadId, messageId} composeReply: ({threadId, messageId}) -> {threadId, messageId}
composeForward: ({threadId, messageId}) -> {threadId, messageId} composeForward: ({threadId, messageId}) -> {threadId, messageId}
composeReplyAll: ({threadId, messageId}) -> {threadId, messageId} composeReplyAll: ({threadId, messageId}) -> {threadId, messageId}
composePopoutDraft: (draftLocalId) -> {draftLocalId: draftLocalId} composePopoutDraft: (draftClientId) -> {draftClientId: draftClientId}
composeNewBlankDraft: -> {} composeNewBlankDraft: -> {}
sendDraft: (draftLocalId) -> {draftLocalId: draftLocalId} sendDraft: (draftClientId) -> {draftClientId}
destroyDraft: (draftLocalId) -> {draftLocalId: draftLocalId} destroyDraft: (draftClientId) -> {draftClientId}
searchQueryCommitted: (query) -> {} searchQueryCommitted: (query) -> {}
fetchAndOpenFile: -> {} fetchAndOpenFile: -> {}
fetchAndSaveFile: -> {} fetchAndSaveFile: -> {}
@ -65,7 +65,7 @@ AnalyticsStore = Reflux.createStore
coreGlobalActions: -> coreGlobalActions: ->
fileAborted: (uploadData={}) -> {fileSize: uploadData.fileSize} fileAborted: (uploadData={}) -> {fileSize: uploadData.fileSize}
fileUploaded: (uploadData={}) -> {fileSize: uploadData.fileSize} fileUploaded: (uploadData={}) -> {fileSize: uploadData.fileSize}
sendDraftSuccess: ({draftLocalId}) -> {draftLocalId: draftLocalId} sendDraftSuccess: ({draftClientId}) -> {draftClientId}
track: (action, data={}) -> track: (action, data={}) ->
_.defer => _.defer =>

View file

@ -4,8 +4,8 @@ async = require 'async'
path = require 'path' path = require 'path'
sqlite3 = require 'sqlite3' sqlite3 = require 'sqlite3'
Model = require '../models/model' Model = require '../models/model'
Utils = require '../models/utils'
Actions = require '../actions' Actions = require '../actions'
LocalLink = require '../models/local-link'
ModelQuery = require '../models/query' ModelQuery = require '../models/query'
NylasStore = require '../../../exports/nylas-store' NylasStore = require '../../../exports/nylas-store'
DatabaseSetupQueryBuilder = require './database-setup-query-builder' DatabaseSetupQueryBuilder = require './database-setup-query-builder'
@ -14,12 +14,10 @@ PriorityUICoordinator = require '../../priority-ui-coordinator'
{AttributeCollection, AttributeJoinedData} = require '../attributes' {AttributeCollection, AttributeJoinedData} = require '../attributes'
{tableNameForJoin, {tableNameForJoin,
generateTempId,
serializeRegisteredObjects, serializeRegisteredObjects,
deserializeRegisteredObjects, deserializeRegisteredObjects} = require '../models/utils'
isTempId} = require '../models/utils'
DatabaseVersion = 59 DatabaseVersion = 12
DatabasePhase = DatabasePhase =
Setup: 'setup' Setup: 'setup'
@ -81,7 +79,6 @@ class DatabaseStore extends NylasStore
constructor: -> constructor: ->
@_triggerPromise = null @_triggerPromise = null
@_localIdLookupCache = {}
@_inflightTransactions = 0 @_inflightTransactions = 0
@_open = false @_open = false
@_waiting = [] @_waiting = []
@ -221,10 +218,11 @@ class DatabaseStore extends NylasStore
str = results.map((row) -> row.detail).join('\n') + " for " + query str = results.map((row) -> row.detail).join('\n') + " for " + query
@_prettyConsoleLog(str) if str.indexOf("SCAN") isnt -1 @_prettyConsoleLog(str) if str.indexOf("SCAN") isnt -1
# Important: once the user begins a transaction, queries need to run in serial. # Important: once the user begins a transaction, queries need to run
# This ensures that the subsequent "COMMIT" call actually runs after the other # in serial. This ensures that the subsequent "COMMIT" call
# queries in the transaction, and that no other code can execute "BEGIN TRANS." # actually runs after the other queries in the transaction, and that
# until the previously queued BEGIN/COMMIT have been processed. # no other code can execute "BEGIN TRANS." until the previously
# queued BEGIN/COMMIT have been processed.
# We don't exit serial execution mode until the last pending transaction has # We don't exit serial execution mode until the last pending transaction has
# finished executing. # finished executing.
@ -316,8 +314,7 @@ class DatabaseStore extends NylasStore
new ModelQuery(klass, @).where(predicates).count() new ModelQuery(klass, @).where(predicates).count()
# Public: Modelify converts the provided array of IDs or models (or a mix of # Public: Modelify converts the provided array of IDs or models (or a mix of
# IDs and models) into an array of models of the `klass` provided by querying # IDs and models) into an array of models of the `klass` provided by querying for the missing items.
# for the missing items.
# #
# Modelify is efficient and uses a single database query. It resolves Immediately # Modelify is efficient and uses a single database query. It resolves Immediately
# if no query is necessary. # if no query is necessary.
@ -353,106 +350,6 @@ class DatabaseStore extends NylasStore
return Promise.resolve(arr) return Promise.resolve(arr)
###
Support for Local IDs
###
# Public: Retrieve a Model given a localId.
#
# - `class` The class of the {Model} you're trying to retrieve.
# - `localId` The {String} localId of the object.
#
# Returns a {Promise} that:
# - resolves with the Model associated with the localId
# - rejects if no matching object is found
#
# Note: When fetching an object by local Id, joined attributes
# (like body, stored in a separate table) are always included.
#
findByLocalId: (klass, localId) =>
return Promise.reject(new Error("DatabaseStore::findByLocalId - You must provide a class")) unless klass
return Promise.reject(new Error("DatabaseStore::findByLocalId - You must provide a localId")) unless localId
new Promise (resolve, reject) =>
@find(LocalLink, localId).then (link) =>
return reject(new Error("DatabaseStore::findByLocalId - no LocalLink found")) unless link
query = @find(klass, link.objectId).includeAll().then(resolve)
# Public: Give a Model a localId.
#
# - `model` A {Model} object to assign a localId.
# - `localId` (optional) The {String} localId. If you don't pass a LocalId, one
# will be automatically assigned.
#
# Returns a {Promise} that:
# - resolves with the localId assigned to the model
bindToLocalId: (model, localId = null) =>
return Promise.reject(new Error("DatabaseStore::bindToLocalId - You must provide a model")) unless model
return Promise.reject(new Error("DatabaseStore::bindToLocalId - Recieved a model with no ID")) unless model.id?
new Promise (resolve, reject) =>
unless localId
if isTempId(model.id)
localId = model.id
else
localId = generateTempId()
link = new LocalLink({id: localId, objectId: model.id})
@_localIdLookupCache[model.id] = localId
@persistModel(link).then ->
resolve(localId)
.catch(reject)
# Public: Look up the localId assigned to the model. If no localId has been
# assigned to the model yet, it assigns a new one and persists it to the database.
#
# - `model` A {Model} object to assign a localId.
#
# Returns a {Promise} that:
# - resolves with the {String} localId.
localIdForModel: (model) =>
return Promise.reject(new Error("DatabaseStore::localIdForModel - You must provide a model")) unless model
new Promise (resolve, reject) =>
if @_localIdLookupCache[model.id]
return resolve(@_localIdLookupCache[model.id])
@findBy(LocalLink, {objectId: model.id}).then (link) =>
if link
@_localIdLookupCache[model.id] = link.id
resolve(link.id)
else
@bindToLocalId(model).then(resolve).catch(reject)
# Private: Returns an {Object} with id-version key value pairs for models in
# the ID set provided. Does not retrieve or inflate object JSON from the database.
#
# Using this method requires that the klass has declared the version field queryable.
#
findVersions: (klass, allIds) =>
return Promise.reject(new Error("DatabaseStore::findVersions - You must provide a class")) unless klass
return Promise.reject(new Error("DatabaseStore::findVersions - version field must be queryable")) unless klass.attributes.version.queryable
_findVersionsFor = (ids) =>
marks = new Array(ids.length)
marks[idx] = '?' for m, idx in marks
@_query("SELECT id, version FROM `#{klass.name}` WHERE id IN (#{marks.join(",")})", ids).then (results) ->
map = {}
map[id] = version for {id, version} in results
Promise.resolve(map)
promises = []
while allIds.length > 0
promises.push(_findVersionsFor(allIds.splice(0, 100)))
# We can only use WHERE IN for up to ~250 items at a time. Run a query for
# every 100 items and then combine the results before returning.
Promise.all(promises).then (results) =>
all = {}
all = _.extend(all, result) for result in results
Promise.resolve(all)
# Public: Executes a {ModelQuery} on the local database. # Public: Executes a {ModelQuery} on the local database.
# #
# - `modelQuery` A {ModelQuery} to execute. # - `modelQuery` A {ModelQuery} to execute.
@ -530,34 +427,6 @@ class DatabaseStore extends NylasStore
]).then => ]).then =>
@_triggerSoon({objectClass: model.constructor.name, objects: [model], type: 'unpersist'}) @_triggerSoon({objectClass: model.constructor.name, objects: [model], type: 'unpersist'})
# Public: Given an `oldModel` with a unique `localId`, it will swap the
# item out in the database.
#
# - `args` An arguments hash with:
# - `oldModel` The old model
# - `newModel` The new model
# - `localId` The localId to reference
#
# Returns a {Promise} that
# - resolves after the database queries are complete and any listening
# database callbacks have finished
# - rejects if any databse query fails or one of the triggering
# callbacks failed
swapModel: ({oldModel, newModel, localId}) =>
queryPromise = Promise.all([
@_query(BEGIN_TRANSACTION)
@_deleteModel(oldModel)
@_writeModels([newModel])
@_writeModels([new LocalLink(id: localId, objectId: newModel.id)]) if localId
@_query(COMMIT)
]).then =>
Actions.didSwapModel({
oldModel: oldModel,
newModel: newModel,
localId: localId
})
@_triggerSoon({objectClass: newModel.constructor.name, objects: [oldModel, newModel], type: 'swap'})
persistJSONObject: (key, json) -> persistJSONObject: (key, json) ->
jsonString = serializeRegisteredObjects(json) jsonString = serializeRegisteredObjects(json)
@_query(BEGIN_TRANSACTION) @_query(BEGIN_TRANSACTION)

View file

@ -22,7 +22,7 @@ DraftChangeSet associated with the store proxy. The DraftChangeSet does two thin
Section: Drafts Section: Drafts
### ###
class DraftChangeSet class DraftChangeSet
constructor: (@localId, @_onChange) -> constructor: (@clientId, @_onChange) ->
@_commitChain = Promise.resolve() @_commitChain = Promise.resolve()
@_pending = {} @_pending = {}
@_saving = {} @_saving = {}
@ -51,19 +51,19 @@ class DraftChangeSet
return Promise.resolve(true) return Promise.resolve(true)
DatabaseStore = require './database-store' DatabaseStore = require './database-store'
return DatabaseStore.findByLocalId(Message, @localId).then (draft) => DatabaseStore.findBy(Message, clientId: @clientId).then (draft) =>
if @_destroyed if @_destroyed
return Promise.resolve(true) return Promise.resolve(true)
if not draft if not draft
throw new Error("DraftChangeSet.commit: Assertion failure. Draft #{@localId} is not in the database.") throw new Error("DraftChangeSet.commit: Assertion failure. Draft #{@clientId} is not in the database.")
@_saving = @_pending @_saving = @_pending
@_pending = {} @_pending = {}
draft = @applyToModel(draft) draft = @applyToModel(draft)
return DatabaseStore.persistModel(draft).then => return DatabaseStore.persistModel(draft).then =>
syncback = new SyncbackDraftTask(@localId) syncback = new SyncbackDraftTask(@clientId)
Actions.queueTask(syncback) Actions.queueTask(syncback)
@_saving = {} @_saving = {}
@ -94,17 +94,16 @@ class DraftStoreProxy
@include Publisher @include Publisher
@include Listener @include Listener
constructor: (@draftLocalId, draft = null) -> constructor: (@draftClientId, draft = null) ->
DraftStore = require './draft-store' DraftStore = require './draft-store'
@listenTo DraftStore, @_onDraftChanged @listenTo DraftStore, @_onDraftChanged
@listenTo Actions.didSwapModel, @_onDraftSwapped
@_draft = false @_draft = false
@_draftPristineBody = null @_draftPristineBody = null
@_destroyed = false @_destroyed = false
@changes = new DraftChangeSet @draftLocalId, => @changes = new DraftChangeSet @draftClientId, =>
return if @_destroyed return if @_destroyed
if !@_draft if !@_draft
throw new Error("DraftChangeSet was modified before the draft was prepared.") throw new Error("DraftChangeSet was modified before the draft was prepared.")
@ -115,7 +114,7 @@ class DraftStoreProxy
@_draftPromise = Promise.resolve(@) @_draftPromise = Promise.resolve(@)
@prepare() @prepare()
# Public: Returns the draft object with the latest changes applied. # Public: Returns the draft object with the latest changes applied.
# #
draft: -> draft: ->
@ -131,9 +130,9 @@ class DraftStoreProxy
prepare: -> prepare: ->
DatabaseStore = require './database-store' DatabaseStore = require './database-store'
@_draftPromise ?= DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) => @_draftPromise ?= DatabaseStore.findBy(Message, clientId: @draftClientId).then (draft) =>
return Promise.reject(new Error("Draft has been destroyed.")) if @_destroyed return Promise.reject(new Error("Draft has been destroyed.")) if @_destroyed
return Promise.reject(new Error("Assertion Failure: Draft #{@draftLocalId} not found.")) if not draft return Promise.reject(new Error("Assertion Failure: Draft #{@draftClientId} not found.")) if not draft
@_setDraft(draft) @_setDraft(draft)
Promise.resolve(@) Promise.resolve(@)
@_draftPromise @_draftPromise
@ -167,12 +166,4 @@ class DraftStoreProxy
@_draft = _.extend @_draft, _.last(myDrafts) @_draft = _.extend @_draft, _.last(myDrafts)
@trigger() @trigger()
_onDraftSwapped: (change) ->
# A draft was saved with a new ID. Since we use the draft ID to
# watch for changes to our draft, we need to pull again using our
# localId.
if change.oldModel.id is @_draft.id
@_setDraft(change.newModel)
module.exports = DraftStoreProxy module.exports = DraftStoreProxy

View file

@ -18,7 +18,7 @@ Actions = require '../actions'
TaskQueue = require './task-queue' TaskQueue = require './task-queue'
{subjectWithPrefix, generateTempId} = require '../models/utils' {subjectWithPrefix} = require '../models/utils'
{Listener, Publisher} = require '../modules/reflux-coffee' {Listener, Publisher} = require '../modules/reflux-coffee'
CoffeeHelpers = require '../coffee-helpers' CoffeeHelpers = require '../coffee-helpers'
DOMUtils = require '../../dom-utils' DOMUtils = require '../../dom-utils'
@ -46,7 +46,7 @@ class DraftStore
@listenTo Actions.composeReply, @_onComposeReply @listenTo Actions.composeReply, @_onComposeReply
@listenTo Actions.composeForward, @_onComposeForward @listenTo Actions.composeForward, @_onComposeForward
@listenTo Actions.composeReplyAll, @_onComposeReplyAll @listenTo Actions.composeReplyAll, @_onComposeReplyAll
@listenTo Actions.composePopoutDraft, @_onPopoutDraftLocalId @listenTo Actions.composePopoutDraft, @_onPopoutDraftClientId
@listenTo Actions.composeNewBlankDraft, @_onPopoutBlankDraft @listenTo Actions.composeNewBlankDraft, @_onPopoutBlankDraft
atom.commands.add 'body', atom.commands.add 'body',
@ -89,30 +89,30 @@ class DraftStore
######### PUBLIC ####################################################### ######### PUBLIC #######################################################
# Public: Fetch a {DraftStoreProxy} for displaying and/or editing the # Public: Fetch a {DraftStoreProxy} for displaying and/or editing the
# draft with `localId`. # draft with `clientId`.
# #
# Example: # Example:
# #
# ```coffee # ```coffee
# session = DraftStore.sessionForLocalId(localId) # session = DraftStore.sessionForClientId(clientId)
# session.prepare().then -> # session.prepare().then ->
# # session.draft() is now ready # # session.draft() is now ready
# ``` # ```
# #
# - `localId` The {String} local ID of the draft. # - `clientId` The {String} clientId of the draft.
# #
# Returns a {Promise} that resolves to an {DraftStoreProxy} for the # Returns a {Promise} that resolves to an {DraftStoreProxy} for the
# draft once it has been prepared: # draft once it has been prepared:
sessionForLocalId: (localId) => sessionForClientId: (clientId) =>
if not localId if not clientId
throw new Error("DraftStore::sessionForLocalId requires a localId") throw new Error("DraftStore::sessionForClientId requires a clientId")
@_draftSessions[localId] ?= new DraftStoreProxy(localId) @_draftSessions[clientId] ?= new DraftStoreProxy(clientId)
@_draftSessions[localId].prepare() @_draftSessions[clientId].prepare()
# Public: Look up the sending state of the given draft Id. # Public: Look up the sending state of the given draftClientId.
# In popout windows the existance of the window is the sending state. # In popout windows the existance of the window is the sending state.
isSendingDraft: (draftLocalId) -> isSendingDraft: (draftClientId) ->
return @_draftsSending[draftLocalId]? return @_draftsSending[draftClientId]?
### ###
Composer Extensions Composer Extensions
@ -142,7 +142,7 @@ class DraftStore
_doneWithSession: (session) -> _doneWithSession: (session) ->
session.teardown() session.teardown()
delete @_draftSessions[session.draftLocalId] delete @_draftSessions[session.draftClientId]
_onBeforeUnload: => _onBeforeUnload: =>
promises = [] promises = []
@ -153,7 +153,7 @@ class DraftStore
# window.close() within on onbeforeunload could do weird things. # window.close() within on onbeforeunload could do weird things.
for key, session of @_draftSessions for key, session of @_draftSessions
if session.draft()?.pristine if session.draft()?.pristine
Actions.queueTask(new DestroyDraftTask(draftLocalId: session.draftLocalId)) Actions.queueTask(new DestroyDraftTask(draftClientId: session.draftClientId))
else else
promises.push(session.changes.commit()) promises.push(session.changes.commit())
@ -204,19 +204,12 @@ class DraftStore
continue unless extension.prepareNewDraft continue unless extension.prepareNewDraft
extension.prepareNewDraft(draft) extension.prepareNewDraft(draft)
# Normally we'd allow the DatabaseStore to create a localId, wait for it to
# commit a LocalLink and resolve, etc. but it's faster to create one now.
draftLocalId = generateTempId()
# Optimistically create a draft session and hand it the draft so that it # Optimistically create a draft session and hand it the draft so that it
# doesn't need to do a query for it a second from now when the composer wants it. # doesn't need to do a query for it a second from now when the composer wants it.
@_draftSessions[draftLocalId] = new DraftStoreProxy(draftLocalId, draft) @_draftSessions[draft.clientId] = new DraftStoreProxy(draft.clientId, draft)
Promise.all([ DatabaseStore.persistModel(draft).then =>
DatabaseStore.bindToLocalId(draft, draftLocalId) Promise.resolve(draftClientId: draft.clientId)
DatabaseStore.persistModel(draft)
]).then =>
return Promise.resolve({draftLocalId})
_newMessageWithContext: ({thread, threadId, message, messageId, popout}, attributesCallback) => _newMessageWithContext: ({thread, threadId, message, messageId, popout}, attributesCallback) =>
return unless AccountStore.current() return unless AccountStore.current()
@ -254,34 +247,34 @@ class DraftStore
DOMUtils.escapeHTMLCharacters(_.invoke(cs, "toString").join(", ")) DOMUtils.escapeHTMLCharacters(_.invoke(cs, "toString").join(", "))
if attributes.replyToMessage if attributes.replyToMessage
msg = attributes.replyToMessage replyToMessage = attributes.replyToMessage
attributes.subject = subjectWithPrefix(msg.subject, 'Re:') attributes.subject = subjectWithPrefix(replyToMessage.subject, 'Re:')
attributes.replyToMessageId = msg.id attributes.replyToMessageId = replyToMessage.id
attributes.body = """ attributes.body = """
<br><br><blockquote class="gmail_quote" <br><br><blockquote class="gmail_quote"
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;"> style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
#{DOMUtils.escapeHTMLCharacters(msg.replyAttributionLine())} #{DOMUtils.escapeHTMLCharacters(replyToMessage.replyAttributionLine())}
<br> <br>
#{@_formatBodyForQuoting(msg.body)} #{@_formatBodyForQuoting(replyToMessage.body)}
</blockquote>""" </blockquote>"""
delete attributes.quotedMessage delete attributes.quotedMessage
if attributes.forwardMessage if attributes.forwardMessage
msg = attributes.forwardMessage forwardMessage = attributes.forwardMessage
fields = [] fields = []
fields.push("From: #{contactsAsHtml(msg.from)}") if msg.from.length > 0 fields.push("From: #{contactsAsHtml(forwardMessage.from)}") if forwardMessage.from.length > 0
fields.push("Subject: #{msg.subject}") fields.push("Subject: #{forwardMessage.subject}")
fields.push("Date: #{msg.formattedDate()}") fields.push("Date: #{forwardMessage.formattedDate()}")
fields.push("To: #{contactsAsHtml(msg.to)}") if msg.to.length > 0 fields.push("To: #{contactsAsHtml(forwardMessage.to)}") if forwardMessage.to.length > 0
fields.push("CC: #{contactsAsHtml(msg.cc)}") if msg.cc.length > 0 fields.push("CC: #{contactsAsHtml(forwardMessage.cc)}") if forwardMessage.cc.length > 0
fields.push("BCC: #{contactsAsHtml(msg.bcc)}") if msg.bcc.length > 0 fields.push("BCC: #{contactsAsHtml(forwardMessage.bcc)}") if forwardMessage.bcc.length > 0
if msg.files?.length > 0 if forwardMessage.files?.length > 0
attributes.files ?= [] attributes.files ?= []
attributes.files = attributes.files.concat(msg.files) attributes.files = attributes.files.concat(forwardMessage.files)
attributes.subject = subjectWithPrefix(msg.subject, 'Fwd:') attributes.subject = subjectWithPrefix(forwardMessage.subject, 'Fwd:')
attributes.body = """ attributes.body = """
<br><br><blockquote class="gmail_quote" <br><br><blockquote class="gmail_quote"
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;"> style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
@ -289,7 +282,7 @@ class DraftStore
<br><br> <br><br>
#{fields.join('<br>')} #{fields.join('<br>')}
<br><br> <br><br>
#{@_formatBodyForQuoting(msg.body)} #{@_formatBodyForQuoting(forwardMessage.body)}
</blockquote>""" </blockquote>"""
delete attributes.forwardedMessage delete attributes.forwardedMessage
@ -301,8 +294,8 @@ class DraftStore
threadId: thread.id threadId: thread.id
accountId: thread.accountId accountId: thread.accountId
@_finalizeAndPersistNewMessage(draft).then ({draftLocalId}) => @_finalizeAndPersistNewMessage(draft).then ({draftClientId}) =>
Actions.composePopoutDraft(draftLocalId) if popout Actions.composePopoutDraft(draftClientId) if popout
# Eventually we'll want a nicer solution for inline attachments # Eventually we'll want a nicer solution for inline attachments
@ -325,18 +318,18 @@ class DraftStore
pristine: true pristine: true
accountId: account.id accountId: account.id
@_finalizeAndPersistNewMessage(draft).then ({draftLocalId}) => @_finalizeAndPersistNewMessage(draft).then ({draftClientId}) =>
@_onPopoutDraftLocalId(draftLocalId, {newDraft: true}) @_onPopoutDraftClientId(draftClientId, {newDraft: true})
_onPopoutDraftLocalId: (draftLocalId, options = {}) => _onPopoutDraftClientId: (draftClientId, options = {}) =>
return unless AccountStore.current() return unless AccountStore.current()
if not draftLocalId? if not draftClientId?
throw new Error("DraftStore::onPopoutDraftLocalId - You must provide a draftLocalId") throw new Error("DraftStore::onPopoutDraftId - You must provide a draftClientId")
save = Promise.resolve() save = Promise.resolve()
if @_draftSessions[draftLocalId] if @_draftSessions[draftClientId]
save = @_draftSessions[draftLocalId].changes.commit() save = @_draftSessions[draftClientId].changes.commit()
title = if options.newDraft then "New Message" else "Message" title = if options.newDraft then "New Message" else "Message"
@ -344,7 +337,7 @@ class DraftStore
atom.newWindow atom.newWindow
title: title title: title
windowType: "composer" windowType: "composer"
windowProps: _.extend(options, {draftLocalId}) windowProps: _.extend(options, {draftClientId})
_onHandleMailtoLink: (urlString) => _onHandleMailtoLink: (urlString) =>
account = AccountStore.current() account = AccountStore.current()
@ -374,32 +367,32 @@ class DraftStore
if query[attr] if query[attr]
draft[attr] = ContactStore.parseContactsInString(query[attr]) draft[attr] = ContactStore.parseContactsInString(query[attr])
@_finalizeAndPersistNewMessage(draft).then ({draftLocalId}) => @_finalizeAndPersistNewMessage(draft).then ({draftClientId}) =>
@_onPopoutDraftLocalId(draftLocalId) @_onPopoutDraftClientId({draftClientId})
_onDestroyDraft: (draftLocalId) => _onDestroyDraft: (draftClientId) =>
session = @_draftSessions[draftLocalId] session = @_draftSessions[draftClientId]
# Immediately reset any pending changes so no saves occur # Immediately reset any pending changes so no saves occur
if session if session
@_doneWithSession(session) @_doneWithSession(session)
# Queue the task to destroy the draft # Queue the task to destroy the draft
Actions.queueTask(new DestroyDraftTask(draftLocalId: draftLocalId)) Actions.queueTask(new DestroyDraftTask(draftClientId: draftClientId))
atom.close() if @_isPopout() atom.close() if @_isPopout()
# The user request to send the draft # The user request to send the draft
_onSendDraft: (draftLocalId) => _onSendDraft: (draftClientId) =>
@_draftsSending[draftLocalId] = true @_draftsSending[draftClientId] = true
@trigger(draftLocalId) @trigger(draftClientId)
@sessionForLocalId(draftLocalId).then (session) => @sessionForClientId(draftClientId).then (session) =>
@_runExtensionsBeforeSend(session) @_runExtensionsBeforeSend(session)
# Immediately save any pending changes so we don't save after sending # Immediately save any pending changes so we don't save after sending
session.changes.commit().then => session.changes.commit().then =>
task = new SendDraftTask(draftLocalId, {fromPopout: @_isPopout()}) task = new SendDraftTask(draftClientId, {fromPopout: @_isPopout()})
Actions.queueTask(task) Actions.queueTask(task)
@_doneWithSession(session) @_doneWithSession(session)
atom.close() if @_isPopout() atom.close() if @_isPopout()
@ -413,8 +406,8 @@ class DraftStore
continue unless extension.finalizeSessionBeforeSending continue unless extension.finalizeSessionBeforeSending
extension.finalizeSessionBeforeSending(session) extension.finalizeSessionBeforeSending(session)
_onRemoveFile: ({file, messageLocalId}) => _onRemoveFile: ({file, messageClientId}) =>
@sessionForLocalId(messageLocalId).then (session) -> @sessionForClientId(messageClientId).then (session) ->
files = _.clone(session.draft().files) ? [] files = _.clone(session.draft().files) ? []
files = _.reject files, (f) -> f.id is file.id files = _.reject files, (f) -> f.id is file.id
session.changes.add({files}, immediate: true) session.changes.add({files}, immediate: true)

View file

@ -59,7 +59,7 @@ class Download
return @promise if @promise return @promise if @promise
@promise = new Promise (resolve, reject) => @promise = new Promise (resolve, reject) =>
account = AccountStore.current()?.id accountId = AccountStore.current()?.id
stream = fs.createWriteStream(@targetPath) stream = fs.createWriteStream(@targetPath)
finished = false finished = false
finishedAction = null finishedAction = null
@ -82,7 +82,7 @@ class Download
NylasAPI.makeRequest NylasAPI.makeRequest
json: false json: false
path: "/files/#{@fileId}/download" path: "/files/#{@fileId}/download"
accountId: account accountId: accountId
encoding: null # Tell `request` not to parse the response data encoding: null # Tell `request` not to parse the response data
started: (req) => started: (req) =>
@request = req @request = req

View file

@ -20,7 +20,7 @@ FileUploadStore = Reflux.createStore
@listenTo Actions.fileAborted, @_onFileAborted @listenTo Actions.fileAborted, @_onFileAborted
# We don't save uploads to the DB, we keep it in memory in the store. # We don't save uploads to the DB, we keep it in memory in the store.
# The key is the messageLocalId. The value is a hash of paths and # The key is the messageClientId. The value is a hash of paths and
# corresponding upload data. # corresponding upload data.
@_fileUploads = {} @_fileUploads = {}
@_linkedFiles = {} @_linkedFiles = {}
@ -28,18 +28,18 @@ FileUploadStore = Reflux.createStore
######### PUBLIC ####################################################### ######### PUBLIC #######################################################
uploadsForMessage: (messageLocalId) -> uploadsForMessage: (messageClientId) ->
if not messageLocalId? then return [] if not messageClientId? then return []
_.filter @_fileUploads, (uploadData, uploadKey) -> _.filter @_fileUploads, (uploadData, uploadKey) ->
uploadData.messageLocalId is messageLocalId uploadData.messageClientId is messageClientId
linkedUpload: (file) -> @_linkedFiles[file.id] linkedUpload: (file) -> @_linkedFiles[file.id]
########### PRIVATE #################################################### ########### PRIVATE ####################################################
_onAttachFile: ({messageLocalId}) -> _onAttachFile: ({messageClientId}) ->
@_verifyId(messageLocalId) @_verifyId(messageClientId)
# When the dialog closes, it triggers `Actions.pathsToOpen` # When the dialog closes, it triggers `Actions.pathsToOpen`
atom.showOpenDialog {properties: ['openFile', 'multiSelections']}, (pathsToOpen) -> atom.showOpenDialog {properties: ['openFile', 'multiSelections']}, (pathsToOpen) ->
@ -47,7 +47,7 @@ FileUploadStore = Reflux.createStore
pathsToOpen = [pathsToOpen] if _.isString(pathsToOpen) pathsToOpen = [pathsToOpen] if _.isString(pathsToOpen)
pathsToOpen.forEach (path) -> pathsToOpen.forEach (path) ->
Actions.attachFilePath({messageLocalId, path}) Actions.attachFilePath({messageClientId, path})
_onAttachFileError: (message) -> _onAttachFileError: (message) ->
remote = require('remote') remote = require('remote')
@ -58,8 +58,8 @@ FileUploadStore = Reflux.createStore
message: 'Cannot Attach File', message: 'Cannot Attach File',
detail: message detail: message
_onAttachFilePath: ({messageLocalId, path}) -> _onAttachFilePath: ({messageClientId, path}) ->
@_verifyId(messageLocalId) @_verifyId(messageClientId)
fs.stat path, (err, stats) => fs.stat path, (err, stats) =>
filename = require('path').basename(path) filename = require('path').basename(path)
if err if err
@ -67,12 +67,12 @@ FileUploadStore = Reflux.createStore
else if stats.isDirectory() else if stats.isDirectory()
@_onAttachFileError("#{filename} is a directory. Try compressing it and attaching it again.") @_onAttachFileError("#{filename} is a directory. Try compressing it and attaching it again.")
else else
Actions.queueTask(new FileUploadTask(path, messageLocalId)) Actions.queueTask(new FileUploadTask(path, messageClientId))
# Receives: # Receives:
# uploadData: # uploadData:
# uploadTaskId - A unique id # uploadTaskId - A unique id
# messageLocalId - The localId of the message (draft) we're uploading to # messageClientId - The clientId of the message (draft) we're uploading to
# filePath - The full absolute local system file path # filePath - The full absolute local system file path
# fileSize - The size in bytes # fileSize - The size in bytes
# fileName - The basename of the file # fileName - The basename of the file
@ -107,6 +107,6 @@ FileUploadStore = Reflux.createStore
delete @_fileUploads[uploadData.uploadTaskId] delete @_fileUploads[uploadData.uploadTaskId]
@trigger() @trigger()
_verifyId: (messageLocalId) -> _verifyId: (messageClientId) ->
if messageLocalId.blank? if messageClientId.blank?
throw new Error "You need to pass the ID of the message (draft) this Action refers to" throw new Error "You need to pass the ID of the message (draft) this Action refers to"

View file

@ -1,19 +1,18 @@
_ = require 'underscore' _ = require 'underscore'
Reflux = require 'reflux'
Utils = require '../models/utils' Utils = require '../models/utils'
Actions = require '../actions' Actions = require '../actions'
NylasStore = require 'nylas-store'
MessageStore = require './message-store' MessageStore = require './message-store'
AccountStore = require './account-store' AccountStore = require './account-store'
FocusedContentStore = require './focused-content-store' FocusedContentStore = require './focused-content-store'
# A store that handles the focuses collections of and individual contacts # A store that handles the focuses collections of and individual contacts
module.exports = class FocusedContactsStore extends NylasStore
FocusedContactsStore = Reflux.createStore constructor: ->
init: ->
@listenTo Actions.focusContact, @_focusContact @listenTo Actions.focusContact, @_focusContact
@listenTo MessageStore, => @_onMessageStoreChanged() @listenTo MessageStore, @_onMessageStoreChanged
@listenTo AccountStore, => @_onAccountChanged() @listenTo AccountStore, @_onAccountChanged
@listenTo FocusedContentStore, @_onFocusChanged @listenTo FocusedContentStore, @_onFocusChanged
@_currentThread = null @_currentThread = null
@ -31,7 +30,7 @@ FocusedContactsStore = Reflux.createStore
@_currentFocusedContact = null @_currentFocusedContact = null
@trigger() unless silent @trigger() unless silent
_onFocusChanged: (change) -> _onFocusChanged: (change) =>
return unless change.impactsCollection('thread') return unless change.impactsCollection('thread')
item = FocusedContentStore.focused('thread') item = FocusedContentStore.focused('thread')
return if @_currentThread?.id is item?.id return if @_currentThread?.id is item?.id
@ -42,13 +41,13 @@ FocusedContactsStore = Reflux.createStore
# We need to wait now for the MessageStore to grab all of the # We need to wait now for the MessageStore to grab all of the
# appropriate messages for the given thread. # appropriate messages for the given thread.
_onMessageStoreChanged: -> _.defer => _onMessageStoreChanged: =>
if MessageStore.threadId() is @_currentThread?.id if MessageStore.threadId() is @_currentThread?.id
@_setCurrentParticipants() @_setCurrentParticipants()
else else
@_clearCurrentParticipants() @_clearCurrentParticipants()
_onAccountChanged: -> _onAccountChanged: =>
@_myEmail = (AccountStore.current()?.me().email ? "").toLowerCase().trim() @_myEmail = (AccountStore.current()?.me().email ? "").toLowerCase().trim()
# For now we take the last message # For now we take the last message
@ -59,7 +58,7 @@ FocusedContactsStore = Reflux.createStore
@_focusContact(@_currentContacts[0], silent: true) @_focusContact(@_currentContacts[0], silent: true)
@trigger() @trigger()
_focusContact: (contact, {silent}={}) -> _focusContact: (contact, {silent}={}) =>
return unless contact return unless contact
@_currentFocusedContact = contact @_currentFocusedContact = contact
@trigger() unless silent @trigger() unless silent
@ -113,3 +112,4 @@ FocusedContactsStore = Reflux.createStore
theirDomain = _.last(email.split("@")) theirDomain = _.last(email.split("@"))
return myDomain.length > 0 and theirDomain.length > 0 and myDomain is theirDomain return myDomain.length > 0 and theirDomain.length > 0 and myDomain is theirDomain
module.exports = new FocusedContactsStore

View file

@ -31,8 +31,8 @@ class MessageStore extends NylasStore
# this.state == nextState is always true if we modify objects in place. # this.state == nextState is always true if we modify objects in place.
_.clone @_itemsExpanded _.clone @_itemsExpanded
itemLocalIds: => itemClientIds: ->
_.clone @_itemsLocalIds _.pluck(@_items, "clientId")
itemsLoading: -> itemsLoading: ->
@_itemsLoading @_itemsLoading
@ -71,7 +71,6 @@ class MessageStore extends NylasStore
_setStoreDefaults: => _setStoreDefaults: =>
@_items = [] @_items = []
@_itemsExpanded = {} @_itemsExpanded = {}
@_itemsLocalIds = {}
@_itemsLoading = false @_itemsLoading = false
@_thread = null @_thread = null
@_extensions = [] @_extensions = []
@ -89,20 +88,13 @@ class MessageStore extends NylasStore
inDisplayedThread = _.some change.objects, (obj) => obj.threadId is @_thread.id inDisplayedThread = _.some change.objects, (obj) => obj.threadId is @_thread.id
if inDisplayedThread if inDisplayedThread
# Are we most likely adding a new draft? If the item is a draft and we don't
# have it's local Id, optimistically add it to the set, resort, and trigger.
# Note: this can avoid 100msec+ of delay from "Reply" => composer onscreen,
item = change.objects[0] item = change.objects[0]
itemAlreadyExists = _.some @_items, (msg) -> msg.id is item.id itemAlreadyExists = _.some @_items, (msg) -> msg.id is item.id
if change.objects.length is 1 and item.draft is true and not itemAlreadyExists if change.objects.length is 1 and item.draft is true and not itemAlreadyExists
DatabaseStore.localIdForModel(item).then (localId) => @_items = [].concat(@_items, [item])
@_itemsLocalIds[item.id] = localId @_items = @_sortItemsForDisplay(@_items)
# We need to create a new copy of the items array so that the message-list @_expandItemsToDefault()
# can compare new state to previous state. @trigger()
@_items = [].concat(@_items, [item])
@_items = @_sortItemsForDisplay(@_items)
@_expandItemsToDefault()
@trigger()
else else
@_fetchFromCache() @_fetchFromCache()
@ -144,61 +136,53 @@ class MessageStore extends NylasStore
query.where(threadId: loadedThreadId, accountId: @_thread.accountId) query.where(threadId: loadedThreadId, accountId: @_thread.accountId)
query.include(Message.attributes.body) query.include(Message.attributes.body)
query.then (items) => query.then (items) =>
localIds = {} # Check to make sure that our thread is still the thread we were
async.each items, (item, callback) -> # loading items for. Necessary because this takes a while.
return callback() unless item.draft return unless loadedThreadId is @_thread?.id
DatabaseStore.localIdForModel(item).then (localId) ->
localIds[item.id] = localId
callback()
, =>
# Check to make sure that our thread is still the thread we were
# loading items for. Necessary because this takes a while.
return unless loadedThreadId is @_thread?.id
loaded = true loaded = true
@_items = @_sortItemsForDisplay(items) @_items = @_sortItemsForDisplay(items)
@_itemsLocalIds = localIds
# If no items were returned, attempt to load messages via the API. If items # If no items were returned, attempt to load messages via the API. If items
# are returned, this will trigger a refresh here. # are returned, this will trigger a refresh here.
if @_items.length is 0 if @_items.length is 0
@_fetchMessages() @_fetchMessages()
loaded = false loaded = false
@_expandItemsToDefault() @_expandItemsToDefault()
# Download the attachments on expanded messages. # Download the attachments on expanded messages.
@_fetchExpandedAttachments(@_items) @_fetchExpandedAttachments(@_items)
# Check that expanded messages have bodies. We won't mark ourselves # Check that expanded messages have bodies. We won't mark ourselves
# as loaded until they're all available. Note that items can be manually # as loaded until they're all available. Note that items can be manually
# expanded so this logic must be separate from above. # expanded so this logic must be separate from above.
if @_fetchExpandedBodies(@_items) if @_fetchExpandedBodies(@_items)
loaded = false loaded = false
# Normally, we would trigger often and let the view's # Normally, we would trigger often and let the view's
# shouldComponentUpdate decide whether to re-render, but if we # shouldComponentUpdate decide whether to re-render, but if we
# know we're not ready, don't even bother. Trigger once at start # know we're not ready, don't even bother. Trigger once at start
# and once when ready. Many third-party stores will observe # and once when ready. Many third-party stores will observe
# MessageStore and they'll be stupid and re-render constantly. # MessageStore and they'll be stupid and re-render constantly.
if loaded if loaded
# Mark the thread as read if necessary. Make sure it's still the # Mark the thread as read if necessary. Make sure it's still the
# current thread after the timeout. # current thread after the timeout.
# Override canBeUndone to return false so that we don't see undo prompts # Override canBeUndone to return false so that we don't see undo prompts
# (since this is a passive action vs. a user-triggered action.) # (since this is a passive action vs. a user-triggered action.)
if @_thread.unread if @_thread.unread
markAsReadDelay = atom.config.get('core.reading.markAsReadDelay') markAsReadDelay = atom.config.get('core.reading.markAsReadDelay')
setTimeout => setTimeout =>
return unless loadedThreadId is @_thread?.id return unless loadedThreadId is @_thread?.id
t = new ChangeUnreadTask(thread: @_thread, unread: false) t = new ChangeUnreadTask(thread: @_thread, unread: false)
t.canBeUndone = => false t.canBeUndone = => false
Actions.queueTask(t) Actions.queueTask(t)
, markAsReadDelay , markAsReadDelay
@_itemsLoading = false @_itemsLoading = false
@trigger(@) @trigger(@)
_fetchExpandedBodies: (items) -> _fetchExpandedBodies: (items) ->
startedAFetch = false startedAFetch = false

View file

@ -92,7 +92,7 @@ class ModelView
# "Total Refresh" - in a subclass, do something smarter # "Total Refresh" - in a subclass, do something smarter
@invalidateRetainedRange() @invalidateRetainedRange()
invalidateMetadataFor: (ids = []) -> invalidateMetadataFor: ->
# "Total Refresh" - in a subclass, do something smarter # "Total Refresh" - in a subclass, do something smarter
@invalidateRetainedRange() @invalidateRetainedRange()

View file

@ -1,7 +1,6 @@
_ = require 'underscore' _ = require 'underscore'
fs = require 'fs-plus' fs = require 'fs-plus'
path = require 'path' path = require 'path'
{generateTempId} = require '../models/utils'
{Listener, Publisher} = require '../modules/reflux-coffee' {Listener, Publisher} = require '../modules/reflux-coffee'
CoffeeHelpers = require '../coffee-helpers' CoffeeHelpers = require '../coffee-helpers'
@ -100,7 +99,7 @@ class TaskQueue
{SaveDraftTask} or 'SaveDraftTask') {SaveDraftTask} or 'SaveDraftTask')
- `matching`: Optional An {Object} with criteria to pass to _.isMatch. For a - `matching`: Optional An {Object} with criteria to pass to _.isMatch. For a
SaveDraftTask, this could be {draftLocalId: "123123"} SaveDraftTask, this could be {draftClientId: "123123"}
Returns a matching {Task}, or null. Returns a matching {Task}, or null.
### ###

View file

@ -9,7 +9,7 @@ ChangeMailTask = require './change-mail-task'
# Public: Create a new task to apply labels to a message or thread. # Public: Create a new task to apply labels to a message or thread.
# #
# Takes an options array of the form: # Takes an options array of the form:
# - `folder` The {Folder} or {Folder} id to move to # - `folder` The {Folder} or {Folder} IDs to move to
# - `threads` An array of {Thread}s or {Thread} IDs # - `threads` An array of {Thread}s or {Thread} IDs
# - `threads` An array of {Message}s or {Message} IDs # - `threads` An array of {Message}s or {Message} IDs
# - `undoData` Since changing the folder is a destructive action, # - `undoData` Since changing the folder is a destructive action,

View file

@ -60,7 +60,7 @@ class ChangeMailTask extends Task
# prepared the data they need and verified that requirements are met. # prepared the data they need and verified that requirements are met.
# #
# Note: Currently, *ALL* subclasses must use `DatabaseStore.modelify` # Note: Currently, *ALL* subclasses must use `DatabaseStore.modelify`
# to convert `threads` and `messages` from models/ids to models. # to convert `threads` and `messages` from models or ids to models.
# #
performLocal: -> performLocal: ->
if @_isUndoTask and not @_restoreValues if @_isUndoTask and not @_restoreValues

View file

@ -11,29 +11,29 @@ FileUploadTask = require './file-upload-task'
module.exports = module.exports =
class DestroyDraftTask extends Task class DestroyDraftTask extends Task
constructor: ({@draftLocalId, @draftId} = {}) -> super constructor: ({@draftClientId, @draftId} = {}) -> super
shouldDequeueOtherTask: (other) -> shouldDequeueOtherTask: (other) ->
if @draftLocalId if @draftClientId
(other instanceof DestroyDraftTask and other.draftLocalId is @draftLocalId) or (other instanceof DestroyDraftTask and other.draftClientId is @draftClientId) or
(other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId) or (other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId) or
(other instanceof SendDraftTask and other.draftLocalId is @draftLocalId) or (other instanceof SendDraftTask and other.draftClientId is @draftClientId) or
(other instanceof FileUploadTask and other.messageLocalId is @draftLocalId) (other instanceof FileUploadTask and other.messageClientId is @draftClientId)
else if @draftId else if @draftId
(other instanceof DestroyDraftTask and other.draftLocalId is @draftLocalId) (other instanceof DestroyDraftTask and other.draftClientId is @draftClientId)
else else
false false
shouldWaitForTask: (other) -> shouldWaitForTask: (other) ->
(other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId) (other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId)
performLocal: -> performLocal: ->
if @draftLocalId if @draftClientId
find = DatabaseStore.findByLocalId(Message, @draftLocalId) find = DatabaseStore.findBy(Message, clientId: @draftClientId)
else if @draftId else if @draftId
find = DatabaseStore.find(Message, @draftId) find = DatabaseStore.find(Message, @draftId)
else else
return Promise.reject(new Error("Attempt to call DestroyDraftTask.performLocal without draftLocalId or draftId")) return Promise.reject(new Error("Attempt to call DestroyDraftTask.performLocal without draftClientId"))
find.then (draft) => find.then (draft) =>
return Promise.resolve() unless draft return Promise.resolve() unless draft
@ -45,10 +45,10 @@ class DestroyDraftTask extends Task
# when we performed locally, or if the draft has never been synced to # when we performed locally, or if the draft has never been synced to
# the server (id is still self-assigned) # the server (id is still self-assigned)
return Promise.resolve(Task.Status.Finished) unless @draft return Promise.resolve(Task.Status.Finished) unless @draft
return Promise.resolve(Task.Status.Finished) unless @draft.isSaved() and @draft.version? return Promise.resolve(Task.Status.Finished) unless @draft.serverId and @draft.version?
NylasAPI.makeRequest NylasAPI.makeRequest
path: "/drafts/#{@draft.id}" path: "/drafts/#{@draft.serverId}"
accountId: @draft.accountId accountId: @draft.accountId
method: "DELETE" method: "DELETE"
body: body:

View file

@ -17,7 +17,7 @@ UploadCounter = 0
class FileUploadTask extends Task class FileUploadTask extends Task
constructor: (@filePath, @messageLocalId) -> constructor: (@filePath, @messageClientId) ->
super super
@_startDate = Date.now() @_startDate = Date.now()
@_startId = UploadCounter @_startId = UploadCounter
@ -27,7 +27,7 @@ class FileUploadTask extends Task
performLocal: -> performLocal: ->
return Promise.reject(new Error("Must pass an absolute path to upload")) unless @filePath?.length return Promise.reject(new Error("Must pass an absolute path to upload")) unless @filePath?.length
return Promise.reject(new Error("Must be attached to a messageLocalId")) unless isTempId(@messageLocalId) return Promise.reject(new Error("Must be attached to a messageClientId")) unless isTempId(@messageClientId)
Actions.uploadStateChanged @_uploadData("pending") Actions.uploadStateChanged @_uploadData("pending")
Promise.resolve() Promise.resolve()
@ -97,7 +97,7 @@ class FileUploadTask extends Task
Actions.linkFileToUpload(file: file, uploadData: @_uploadData("completed")) Actions.linkFileToUpload(file: file, uploadData: @_uploadData("completed"))
DraftStore = require '../stores/draft-store' DraftStore = require '../stores/draft-store'
DraftStore.sessionForLocalId(@messageLocalId).then (session) => DraftStore.sessionForClientId(@messageClientId).then (session) =>
files = _.clone(session.draft().files) ? [] files = _.clone(session.draft().files) ? []
files.push(file) files.push(file)
session.changes.add({files}) session.changes.add({files})
@ -121,7 +121,7 @@ class FileUploadTask extends Task
filename: @_uploadData().fileName filename: @_uploadData().fileName
# returns: # returns:
# messageLocalId - The localId of the message (draft) we're uploading to # messageClientId - The clientId of the message (draft) we're uploading to
# filePath - The full absolute local system file path # filePath - The full absolute local system file path
# fileSize - The size in bytes # fileSize - The size in bytes
# fileName - The basename of the file # fileName - The basename of the file
@ -132,7 +132,7 @@ class FileUploadTask extends Task
uploadTaskId: @id uploadTaskId: @id
startDate: @_startDate startDate: @_startDate
startId: @_startId startId: @_startId
messageLocalId: @messageLocalId messageClientId: @messageClientId
filePath: @filePath filePath: @filePath
fileSize: @_getFileSize(@filePath) fileSize: @_getFileSize(@filePath)
fileName: pathUtils.basename(@filePath) fileName: pathUtils.basename(@filePath)

View file

@ -1,5 +1,3 @@
{isTempId} = require '../models/utils'
Actions = require '../actions' Actions = require '../actions'
DatabaseStore = require '../stores/database-store' DatabaseStore = require '../stores/database-store'
Message = require '../models/message' Message = require '../models/message'
@ -13,40 +11,39 @@ NylasAPI = require '../nylas-api'
module.exports = module.exports =
class SendDraftTask extends Task class SendDraftTask extends Task
constructor: (@draftLocalId, {@fromPopout}={}) -> constructor: (@draftClientId, {@fromPopout}={}) ->
super super
label: -> label: ->
"Sending draft..." "Sending draft..."
shouldDequeueOtherTask: (other) -> shouldDequeueOtherTask: (other) ->
other instanceof SendDraftTask and other.draftLocalId is @draftLocalId other instanceof SendDraftTask and other.draftClientId is @draftClientId
shouldWaitForTask: (other) -> shouldWaitForTask: (other) ->
(other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId) or (other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId) or
(other instanceof FileUploadTask and other.messageLocalId is @draftLocalId) (other instanceof FileUploadTask and other.messageClientId is @draftClientId)
performLocal: -> performLocal: ->
# When we send drafts, we don't update anything in the app until # When we send drafts, we don't update anything in the app until
# it actually succeeds. We don't want users to think messages have # it actually succeeds. We don't want users to think messages have
# already sent when they haven't! # already sent when they haven't!
if not @draftLocalId if not @draftClientId
return Promise.reject(new Error("Attempt to call SendDraftTask.performLocal without @draftLocalId.")) return Promise.reject(new Error("Attempt to call SendDraftTask.performLocal without @draftClientId."))
Promise.resolve() Promise.resolve()
performRemote: -> performRemote: ->
# Fetch the latest draft data to make sure we make the request with the most # Fetch the latest draft data to make sure we make the request with the most
# recent draft version # recent draft version
DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) => DatabaseStore.findBy(Message, clientId: @draftClientId).then (draft) =>
# The draft may have been deleted by another task. Nothing we can do. # The draft may have been deleted by another task. Nothing we can do.
NylasAPI.incrementOptimisticChangeCount(Message, draft.id)
@draft = draft @draft = draft
if not draft if not draft
return Promise.reject(new Error("We couldn't find the saved draft.")) return Promise.reject(new Error("We couldn't find the saved draft."))
if draft.isSaved() if draft.serverId
body = body =
draft_id: draft.id draft_id: draft.serverId
version: draft.version version: draft.version
else else
body = draft.toJSON() body = draft.toJSON()
@ -68,13 +65,14 @@ class SendDraftTask extends Task
message = (new Message).fromJSON(json) message = (new Message).fromJSON(json)
atom.playSound('mail_sent.ogg') atom.playSound('mail_sent.ogg')
Actions.sendDraftSuccess Actions.sendDraftSuccess
draftLocalId: @draftLocalId draftClientId: @draftClientId
newMessage: message newMessage: message
DatabaseStore.unpersistModel(@draft).then => DestroyDraftTask = require './destroy-draft'
return Promise.resolve(Task.Status.Finished) task = new DestroyDraftTask(draftClientId: @draftClientId)
Actions.queueTask(task)
return Promise.resolve(Task.Status.Finished)
.catch APIError, (err) => .catch APIError, (err) =>
NylasAPI.decrementOptimisticChangeCount(Message, @draft.id)
if err.message?.indexOf('Invalid message public id') is 0 if err.message?.indexOf('Invalid message public id') is 0
body.reply_to_message_id = null body.reply_to_message_id = null
return @_send(body) return @_send(body)
@ -84,7 +82,7 @@ class SendDraftTask extends Task
return @_send(body) return @_send(body)
else if err.statusCode in NylasAPI.PermanentErrorCodes else if err.statusCode in NylasAPI.PermanentErrorCodes
msg = err.message ? "Your draft could not be sent." msg = err.message ? "Your draft could not be sent."
Actions.composePopoutDraft(@draftLocalId, {errorMessage: msg}) Actions.composePopoutDraft(@draftClientId, {errorMessage: msg})
return Promise.resolve(Task.Status.Finished) return Promise.resolve(Task.Status.Finished)
else else
return Promise.resolve(Task.Status.Retry) return Promise.resolve(Task.Status.Retry)

View file

@ -1,5 +1,4 @@
_ = require 'underscore' _ = require 'underscore'
{isTempId, generateTempId} = require '../models/utils'
Actions = require '../actions' Actions = require '../actions'
DatabaseStore = require '../stores/database-store' DatabaseStore = require '../stores/database-store'
@ -18,21 +17,21 @@ FileUploadTask = require './file-upload-task'
module.exports = module.exports =
class SyncbackDraftTask extends Task class SyncbackDraftTask extends Task
constructor: (@draftLocalId) -> constructor: (@draftClientId) ->
super super
shouldDequeueOtherTask: (other) -> shouldDequeueOtherTask: (other) ->
other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId and other.creationDate < @creationDate other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId and other.creationDate < @creationDate
shouldWaitForTask: (other) -> shouldWaitForTask: (other) ->
other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId and other.creationDate < @creationDate other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId and other.creationDate < @creationDate
performLocal: -> performLocal: ->
# SyncbackDraftTask does not do anything locally. You should persist your changes # SyncbackDraftTask does not do anything locally. You should persist your changes
# to the local database directly or using a DraftStoreProxy, and then queue a # to the local database directly or using a DraftStoreProxy, and then queue a
# SyncbackDraftTask to send those changes to the server. # SyncbackDraftTask to send those changes to the server.
if not @draftLocalId if not @draftClientId
errMsg = "Attempt to call SyncbackDraftTask.performLocal without @draftLocalId" errMsg = "Attempt to call SyncbackDraftTask.performLocal without @draftClientId"
return Promise.reject(new Error(errMsg)) return Promise.reject(new Error(errMsg))
Promise.resolve() Promise.resolve()
@ -42,8 +41,8 @@ class SyncbackDraftTask extends Task
return Promise.resolve() unless draft return Promise.resolve() unless draft
@checkDraftFromMatchesAccount(draft).then (draft) => @checkDraftFromMatchesAccount(draft).then (draft) =>
if draft.isSaved() if draft.serverId
path = "/drafts/#{draft.id}" path = "/drafts/#{draft.serverId}"
method = 'PUT' method = 'PUT'
else else
path = "/drafts" path = "/drafts"
@ -71,38 +70,31 @@ class SyncbackDraftTask extends Task
# below. We currently have no way of locking between processes. Maybe a # below. We currently have no way of locking between processes. Maybe a
# log-style data structure would be better suited for drafts. # log-style data structure would be better suited for drafts.
# #
@getLatestLocalDraft().then (draft) => @getLatestLocalDraft().then (draft) ->
updatedDraft = draft.clone() draft.version = json.version
updatedDraft.version = json.version draft.serverId = json.id
updatedDraft.id = json.id DatabaseStore.persistModel(draft)
if updatedDraft.id != draft.id
DatabaseStore.swapModel(oldModel: draft, newModel: updatedDraft, localId: @draftLocalId)
else
DatabaseStore.persistModel(updatedDraft)
.then => .then =>
return Promise.resolve(Task.Status.Finished) return Promise.resolve(Task.Status.Finished)
.catch APIError, (err) => .catch APIError, (err) =>
if err.statusCode in [400, 404, 409] and err.requestOptions.method is 'PUT' if err.statusCode in [400, 404, 409] and err.requestOptions.method is 'PUT'
return @getLatestLocalDraft().then (draft) => return Promise.resolve(Task.Status.Retry)
@detatchFromRemoteID(draft).then =>
Promise.resolve(Task.Status.Retry)
if err.statusCode in NylasAPI.PermanentErrorCodes if err.statusCode in NylasAPI.PermanentErrorCodes
return Promise.resolve(Task.Status.Finished) return Promise.resolve(Task.Status.Finished)
return Promise.resolve(Task.Status.Retry) return Promise.resolve(Task.Status.Retry)
getLatestLocalDraft: -> getLatestLocalDraft: =>
DatabaseStore.findByLocalId(Message, @draftLocalId) DatabaseStore.findBy(Message, clientId: @draftClientId)
checkDraftFromMatchesAccount: (existingAccountDraft) -> checkDraftFromMatchesAccount: (draft) ->
DatabaseStore.findBy(Account, [Account.attributes.emailAddress.equal(existingAccountDraft.from[0].email)]).then (acct) => DatabaseStore.findBy(Account, [Account.attributes.emailAddress.equal(draft.from[0].email)]).then (account) =>
promise = Promise.resolve(existingAccountDraft) promise = Promise.resolve(draft)
if existingAccountDraft.accountId isnt acct.id if draft.accountId isnt account.id
DestroyDraftTask = require './destroy-draft' DestroyDraftTask = require './destroy-draft'
destroy = new DestroyDraftTask(draftId: existingAccountDraft.id) destroy = new DestroyDraftTask(draftId: existingAccountDraft.id)
promise = TaskQueueStatusStore.waitForPerformLocal(destroy).then => promise = TaskQueueStatusStore.waitForPerformLocal(destroy).then =>
@ -115,11 +107,11 @@ class SyncbackDraftTask extends Task
detatchFromRemoteID: (draft, newAccountId = null) -> detatchFromRemoteID: (draft, newAccountId = null) ->
return Promise.resolve() unless draft return Promise.resolve() unless draft
newDraft = new Message(draft) newDraft = new Message(draft)
newDraft.id = generateTempId()
newDraft.accountId = newAccountId if newAccountId newDraft.accountId = newAccountId if newAccountId
delete newDraft.serverId
delete newDraft.version
delete newDraft.threadId delete newDraft.threadId
delete newDraft.replyToMessageId delete newDraft.replyToMessageId
DatabaseStore.swapModel(oldModel: draft, newModel: newDraft, localId: @draftLocalId).then => DatabaseStore.persistModel(newDraft)
Promise.resolve(newDraft)

View file

@ -11,7 +11,7 @@ class MyComposerButton extends React.Component
# reference to the draft, and you can look it up to perform # reference to the draft, and you can look it up to perform
# actions and retrieve data. # actions and retrieve data.
@propTypes: @propTypes:
draftLocalId: React.PropTypes.string.isRequired draftClientId: React.PropTypes.string.isRequired
render: => render: =>
<div className="my-package"> <div className="my-package">
@ -24,7 +24,7 @@ class MyComposerButton extends React.Component
# To retrieve information about the draft, we fetch the current editing # To retrieve information about the draft, we fetch the current editing
# session from the draft store. We can access attributes of the draft # session from the draft store. We can access attributes of the draft
# and add changes to the session which will be appear immediately. # and add changes to the session which will be appear immediately.
DraftStore.sessionForLocalId(@props.draftLocalId).then (session) => DraftStore.sessionForClientId(@props.draftClientId).then (session) =>
newSubject = "#{session.draft().subject} - It Worked!" newSubject = "#{session.draft().subject} - It Worked!"
dialog = @_getDialog() dialog = @_getDialog()
@ -40,4 +40,4 @@ class MyComposerButton extends React.Component
require('remote').require('dialog') require('remote').require('dialog')
module.exports = MyComposerButton module.exports = MyComposerButton

View file

@ -9,7 +9,7 @@ dialogStub =
describe "MyComposerButton", -> describe "MyComposerButton", ->
beforeEach -> beforeEach ->
@component = ReactTestUtils.renderIntoDocument( @component = ReactTestUtils.renderIntoDocument(
<MyComposerButton draftLocalId="test" /> <MyComposerButton draftClientId="test" />
) )
it "should render into the page", -> it "should render into the page", ->
@ -22,4 +22,4 @@ describe "MyComposerButton", ->
spyOn(@component, '_onClick') spyOn(@component, '_onClick')
buttonNode = React.findDOMNode(@component.refs.button) buttonNode = React.findDOMNode(@component.refs.button)
ReactTestUtils.Simulate.click(buttonNode) ReactTestUtils.Simulate.click(buttonNode)
expect(@component._onClick).toHaveBeenCalled() expect(@component._onClick).toHaveBeenCalled()