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'
'src/**/*.coffee'
'src/**/*.cjsx'
'spec/*.coffee'
'spec-nylas/*.cjsx'
'spec-nylas/*.coffee'
'spec/**/*.coffee'
'spec-nylas/**/*.cjsx'
'spec-nylas/**/*.coffee'
]
coffeelint:
@ -229,9 +229,13 @@ module.exports = (grunt) ->
'build/Gruntfile.coffee'
]
test: [
'spec/*.coffee'
'spec-nylas/*.cjsx'
'spec-nylas/*.coffee'
'spec/**/*.coffee'
'spec-nylas/**/*.cjsx'
'spec-nylas/**/*.coffee'
]
static: [
'static/**/*.coffee'
'static/**/*.cjsx'
]
target:
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!"))
return
done(null)
done(null)

View file

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

View file

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

View file

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

View file

@ -28,7 +28,7 @@ class ComposerWithWindowProps extends React.Component
render: ->
<div className="composer-full-window">
<ComposerView mode="fullwindow" localId={@state.draftLocalId} />
<ComposerView mode="fullwindow" draftClientId={@state.draftClientId} />
</div>
_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")
users = [u1, u2, u3, u4, u5]
AccountStore._current = new Account(
{name: u1.name, provider: "inbox", emailAddress: u1.email})
reactStub = (className) ->
React.createClass({render: -> <div className={className}>{@props.children}</div>})
@ -45,11 +43,11 @@ passThroughStub = (props={}) ->
React.createClass
render: -> <div {...props}>{props.children}</div>
draftStoreProxyStub = (localId, returnedDraft) ->
draftStoreProxyStub = (draftClientId, returnedDraft) ->
listen: -> ->
draft: -> (returnedDraft ? new Message(draft: true))
draftPristineBody: -> null
draftLocalId: localId
draftClientId: draftClientId
cleanup: ->
changes:
add: ->
@ -72,27 +70,21 @@ ComposerView = proxyquire "../lib/composer-view",
DraftStore: DraftStore
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.
AccountStore.populateItems().then ->
new Promise (resolve, reject) ->
draft = new Message
from: [AccountStore.current().me()]
date: (new Date)
draft: true
accountId: AccountStore.current().id
draft = new Message
from: [AccountStore.current().me()]
date: (new Date)
draft: true
accountId: AccountStore.current().id
DatabaseStore.persistModel(draft).then ->
DatabaseStore.localIdForModel(draft).then(resolve).catch(reject)
.catch(reject)
DatabaseStore.persistModel(draft).then ->
return draft
describe "A blank composer view", ->
beforeEach ->
@composer = ReactTestUtils.renderIntoDocument(
<ComposerView localId="test123" />
<ComposerView draftClientId="test123" />
)
@composer.setState
body: ""
@ -110,23 +102,23 @@ describe "A blank composer view", ->
# 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
# proxy system used by the composer.
DRAFT_LOCAL_ID = "local-123"
DRAFT_CLIENT_ID = "local-123"
useDraft = (draftAttributes={}) ->
@draft = new Message _.extend({draft: true, body: ""}, draftAttributes)
draft = @draft
proxy = draftStoreProxyStub(DRAFT_LOCAL_ID, @draft)
proxy = draftStoreProxyStub(DRAFT_CLIENT_ID, @draft)
spyOn(ComposerView.prototype, "componentWillMount").andCallFake ->
@_prepareForDraft(DRAFT_LOCAL_ID)
@_prepareForDraft(DRAFT_CLIENT_ID)
@_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
# `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
# sessionForLocalId do nothing so `_setupSession` is not called twice!
spyOn(DraftStore, "sessionForLocalId").andCallFake ->
# sessionForClientId do nothing so `_setupSession` is not called twice!
spyOn(DraftStore, "sessionForClientId").andCallFake ->
then: ->
useFullDraft = ->
@ -141,7 +133,7 @@ useFullDraft = ->
makeComposer = ->
@composer = ReactTestUtils.renderIntoDocument(
<ComposerView localId={DRAFT_LOCAL_ID} />
<ComposerView draftClientId={DRAFT_CLIENT_ID} />
)
describe "populated composer", ->
@ -245,9 +237,9 @@ describe "populated composer", ->
describe "if the draft has not yet loaded", ->
it "should set _focusOnUpdate and focus after the next render", ->
@draft = new Message(draft: true, body: "")
proxy = draftStoreProxyStub(DRAFT_LOCAL_ID, @draft)
proxy = draftStoreProxyStub(DRAFT_CLIENT_ID, @draft)
proxyResolve = null
spyOn(DraftStore, "sessionForLocalId").andCallFake ->
spyOn(DraftStore, "sessionForClientId").andCallFake ->
new Promise (resolve, reject) ->
proxyResolve = resolve
@ -462,7 +454,7 @@ describe "populated composer", ->
useFullDraft.apply(@); makeComposer.call(@)
sendBtn = React.findDOMNode(@composer.refs.sendButton)
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
it "doesn't send twice if you double click", ->
@ -472,7 +464,7 @@ describe "populated composer", ->
@isSending.state = true
DraftStore.trigger()
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
describe "when sending a message with keyboard inputs", ->
@ -630,14 +622,14 @@ describe "populated composer", ->
@up1 =
uploadTaskId: 4
messageLocalId: DRAFT_LOCAL_ID
messageClientId: DRAFT_CLIENT_ID
filePath: "/foo/bar/f4.bmp"
fileName: "f4.bmp"
fileSize: 1024
@up2 =
uploadTaskId: 5
messageLocalId: DRAFT_LOCAL_ID
messageClientId: DRAFT_CLIENT_ID
filePath: "/foo/bar/f5.zip"
fileName: "f5.zip"
fileSize: 1024

View file

@ -14,17 +14,22 @@ ParticipantsTextField = proxyquire '../lib/participants-text-field',
'nylas-exports': {Contact, ContactStore}
participant1 = new Contact
id: 'local-1'
email: 'ben@nylas.com'
participant2 = new Contact
id: 'local-2'
email: 'ben@example.com'
name: 'Ben Gotow'
participant3 = new Contact
id: 'local-3'
email: 'evan@nylas.com'
name: 'Evan Morikawa'
participant4 = new Contact
id: 'local-4',
email: 'ben@elsewhere.com',
name: 'ben Again'
participant5 = new Contact
id: 'local-5',
email: 'evan@elsewhere.com',
name: 'EVAN'
@ -54,7 +59,7 @@ describe 'ParticipantsTextField', ->
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Enter', keyCode: 9})
reviver = (k,v) ->
return undefined if k in ["id", "object"]
return undefined if k in ["id", "client_id", "server_id", "object"]
return v
found = @propChange.mostRecentCall.args[0]
found = JSON.parse(JSON.stringify(found), reviver)
@ -102,7 +107,7 @@ describe 'ParticipantsTextField', ->
describe "when text contains Name (Email) formatted data", ->
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')
inputs = [
@ -120,8 +125,8 @@ describe 'ParticipantsTextField', ->
describe "when text contains emails mixed with garbage text", ->
it "should still parse out emails into Contact objects", ->
newContact1 = new Contact(name:'garbage-man@nylas.com', email:'garbage-man@nylas.com')
newContact2 = new Contact(name:'recycling-guy@nylas.com', email:'recycling-guy@nylas.com')
newContact1 = new Contact(id: 'gm', name:'garbage-man@nylas.com', email:'garbage-man@nylas.com')
newContact2 = new Contact(id: 'rm', name:'recycling-guy@nylas.com', email:'recycling-guy@nylas.com')
inputs = [
"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: ->
ComponentRegistry.unregister EventComponent
serialize: -> @state
serialize: -> @state

View file

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

View file

@ -15,11 +15,6 @@ class MessageItemContainer extends React.Component
@propTypes =
thread: 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
isLastMsg: React.PropTypes.bool
isBeforeReplyArea: React.PropTypes.bool
@ -65,7 +60,7 @@ class MessageItemContainer extends React.Component
_renderComposer: =>
props =
mode: "inline"
localId: @props.localId
draftClientId: @props.message.clientId
threadId: @props.thread.id
onRequestScrollTo: @props.onRequestScrollTo
@ -81,10 +76,10 @@ class MessageItemContainer extends React.Component
"message-item-wrap": true
"before-reply-area": @props.isBeforeReplyArea
_onSendingStateChanged: (draftLocalId) =>
@setState(@_getStateFromStores()) if draftLocalId is @props.localId
_onSendingStateChanged: (draftClientId) =>
@setState(@_getStateFromStores()) if draftClientId is @props.message.clientId
_getStateFromStores: ->
isSending: DraftStore.isSendingDraft(@props.localId)
isSending: DraftStore.isSendingDraft(@props.message.clientId)
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)
return _.difference(newDraftIds, oldDraftIds) ? []
_getMessageContainer: (id) =>
@refs["message-container-#{id}"]
_getMessageContainer: (messageId) =>
@refs["message-container-#{messageId}"]
_focusDraft: (draftElement) =>
# 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.
if last.draft is true
data =
session: DraftStore.sessionForLocalId(@state.messageLocalIds[last.id])
session: DraftStore.sessionForClientId(last.clientId)
replyToMessage: Promise.resolve(@state.messages[@state.messages.length - 2])
type: type
@ -299,14 +299,11 @@ class MessageList extends React.Component
isLastMsg = (messages.length - 1 is idx)
isBeforeReplyArea = isLastMsg and hasReplyArea
localId = @state.messageLocalIds[message.id]
elements.push(
<MessageItemContainer key={idx}
ref={"message-container-#{message.id}"}
thread={@state.currentThread}
message={message}
localId={localId}
collapsed={collapsed}
isLastMsg={isLastMsg}
isBeforeReplyArea={isBeforeReplyArea}
@ -404,7 +401,6 @@ class MessageList extends React.Component
_getStateFromStores: =>
messages: (MessageStore.items() ? [])
messageLocalIds: MessageStore.itemLocalIds()
messagesExpandedState: MessageStore.itemsExpandedState()
currentThread: MessageStore.thread()
loading: MessageStore.itemsLoading()

View file

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

View file

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

View file

@ -33,12 +33,6 @@ MessageItemContainer = proxyquire("../lib/message-item-container", {
MessageList = proxyquire '../lib/message-list',
"./message-item-container": MessageItemContainer
me = new Account
name: "User One",
emailAddress: "user1@nylas.com"
provider: "inbox"
AccountStore._current = me
user_1 = new Contact
name: "User One"
email: "user1@nylas.com"
@ -70,7 +64,7 @@ m1 = (new Message).fromJSON({
"snippet" : "snippet one...",
"subject" : "Subject One",
"thread_id" : "thread_12345",
"account_id" : "test_account_id"
"account_id" : TEST_ACCOUNT_ID
})
m2 = (new Message).fromJSON({
"id" : "222",
@ -87,7 +81,7 @@ m2 = (new Message).fromJSON({
"snippet" : "snippet Two...",
"subject" : "Subject Two",
"thread_id" : "thread_12345",
"account_id" : "test_account_id"
"account_id" : TEST_ACCOUNT_ID
})
m3 = (new Message).fromJSON({
"id" : "333",
@ -104,7 +98,7 @@ m3 = (new Message).fromJSON({
"snippet" : "snippet Three...",
"subject" : "Subject Three",
"thread_id" : "thread_12345",
"account_id" : "test_account_id"
"account_id" : TEST_ACCOUNT_ID
})
m4 = (new Message).fromJSON({
"id" : "444",
@ -121,7 +115,7 @@ m4 = (new Message).fromJSON({
"snippet" : "snippet Four...",
"subject" : "Subject Four",
"thread_id" : "thread_12345",
"account_id" : "test_account_id"
"account_id" : TEST_ACCOUNT_ID
})
m5 = (new Message).fromJSON({
"id" : "555",
@ -138,7 +132,7 @@ m5 = (new Message).fromJSON({
"snippet" : "snippet Five...",
"subject" : "Subject Five",
"thread_id" : "thread_12345",
"account_id" : "test_account_id"
"account_id" : TEST_ACCOUNT_ID
})
testMessages = [m1, m2, m3, m4, m5]
draftMessages = [
@ -157,11 +151,12 @@ draftMessages = [
"snippet" : "draft snippet one...",
"subject" : "Draft One",
"thread_id" : "thread_12345",
"account_id" : "test_account_id"
"account_id" : TEST_ACCOUNT_ID
}),
]
test_thread = (new Thread).fromJSON({
"id": "12345"
"id" : "thread_12345"
"subject" : "Subject 12345"
})
@ -170,8 +165,6 @@ describe "MessageList", ->
beforeEach ->
MessageStore._items = []
MessageStore._threadId = null
spyOn(MessageStore, "itemLocalIds").andCallFake ->
{"666": "666"}
spyOn(MessageStore, "itemsLoading").andCallFake ->
false
@ -224,7 +217,7 @@ describe "MessageList", ->
messages: msgs.concat(draftMessages)
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", ->
msgs = @messageList.state.messages
@ -306,7 +299,7 @@ describe "MessageList", ->
draft: => @draft
changes:
add: jasmine.createSpy('session.changes.add')
spyOn(DraftStore, 'sessionForLocalId').andCallFake =>
spyOn(DraftStore, 'sessionForClientId').andCallFake =>
Promise.resolve(@sessionStub)
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", ->
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)
_onChooseTemplate: (template) =>
Actions.insertTemplateId({templateId:template.id, draftLocalId: @props.draftLocalId})
Actions.insertTemplateId({templateId:template.id, draftClientId: @props.draftClientId})
@refs.popover.close()
_onManageTemplates: =>
Actions.showTemplates()
_onNewTemplate: =>
Actions.createTemplate({draftLocalId: @props.draftLocalId})
Actions.createTemplate({draftClientId: @props.draftClientId})
module.exports = TemplatePicker

View file

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

View file

@ -54,9 +54,9 @@ TemplateStore = Reflux.createStore
path: path.join(@_templatesDir, filename)
@trigger(@)
_onCreateTemplate: ({draftLocalId, name, contents} = {}) ->
if draftLocalId
DraftStore.sessionForLocalId(draftLocalId).then (session) =>
_onCreateTemplate: ({draftClientId, name, contents} = {}) ->
if draftClientId
DraftStore.sessionForClientId(draftClientId).then (session) =>
draft = session.draft()
name ?= draft.subject
contents ?= draft.body
@ -92,13 +92,13 @@ TemplateStore = Reflux.createStore
path: templatePath
@trigger(@)
_onInsertTemplateId: ({templateId, draftLocalId} = {}) ->
_onInsertTemplateId: ({templateId, draftClientId} = {}) ->
template = _.find @_items, (item) -> item.id is templateId
return unless template
fs.readFile template.path, (err, data) ->
body = data.toString()
DraftStore.sessionForLocalId(draftLocalId).then (session) ->
DraftStore.sessionForClientId(draftClientId).then (session) ->
session.changes.add(body: body)
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", ->
add = jasmine.createSpy('add')
spyOn(DraftStore, 'sessionForLocalId').andCallFake ->
spyOn(DraftStore, 'sessionForClientId').andCallFake ->
Promise.resolve(changes: {add})
runs ->
TemplateStore._onInsertTemplateId
templateId: 'template1.html',
draftLocalId: 'localid-draft'
draftClientId: 'localid-draft'
waitsFor ->
add.calls.length > 0
runs ->
@ -75,8 +75,8 @@ describe "TemplateStore", ->
describe "onCreateTemplate", ->
beforeEach ->
TemplateStore.init()
spyOn(DraftStore, 'sessionForLocalId').andCallFake (draftLocalId) ->
if draftLocalId is 'localid-nosubject'
spyOn(DraftStore, 'sessionForClientId').andCallFake (draftClientId) ->
if draftClientId is 'localid-nosubject'
d = new Message(subject: '', body: '<p>Body</p>')
else
d = new Message(subject: 'Subject', body: '<p>Body</p>')
@ -118,7 +118,7 @@ describe "TemplateStore", ->
spyOn(TemplateStore, 'trigger')
spyOn(TemplateStore, '_populate')
runs ->
TemplateStore._onCreateTemplate({draftLocalId: 'localid-b'})
TemplateStore._onCreateTemplate({draftClientId: 'localid-b'})
waitsFor ->
TemplateStore.trigger.callCount > 0
runs ->
@ -127,7 +127,7 @@ describe "TemplateStore", ->
it "should display an error if the draft has no subject", ->
spyOn(TemplateStore, '_displayError')
runs ->
TemplateStore._onCreateTemplate({draftLocalId: 'localid-nosubject'})
TemplateStore._onCreateTemplate({draftClientId: 'localid-nosubject'})
waitsFor ->
TemplateStore._displayError.callCount > 0
runs ->

View file

@ -51,11 +51,6 @@ DraftListStore = Reflux.createStore
selected = @_view.selection.items()
for item in selected
DatabaseStore.localIdForModel(item).then (localId) =>
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)
Actions.queueTask(new DestroyDraftTask(draftClientId: item.clientId))
@_view.selection.clear()

View file

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

View file

@ -35,7 +35,7 @@ class ThreadListIcon extends React.Component
_nonDraftMessages: =>
msgs = @props.thread.metadata
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
shouldComponentUpdate: (nextProps) =>

View file

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

View file

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

View file

@ -18,7 +18,7 @@ describe "NylasSyncWorker", ->
spyOn(DatabaseStore, 'persistJSONObject').andReturn(Promise.resolve())
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
Promise.resolve _.extend {}, {
"contacts":
@ -29,7 +29,7 @@ describe "NylasSyncWorker", ->
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)
@connection = @worker.connection()
advanceClock()

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
Model = require '../../src/flux/models/model'
Utils = require '../../src/flux/models/utils'
Attributes = require '../../src/flux/attributes'
{isTempId} = require '../../src/flux/models/utils'
_ = require 'underscore'
@ -13,22 +14,43 @@ describe "Model", ->
expect(m.id).toBe(attrs.id)
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
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", ->
it "should return the attributes of the class", ->
it "should return the attributes of the class EXCEPT the id field", ->
m = new Model()
expect(m.attributes()).toBe(m.constructor.attributes)
describe "isSaved", ->
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)
retAttrs = _.clone(m.constructor.attributes)
delete retAttrs["id"]
expect(m.attributes()).toEqual(retAttrs)
describe "clone", ->
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'
Thread = require '../../src/flux/models/thread'
_ = require 'underscore'

View file

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

View file

@ -30,7 +30,7 @@ describe "DatabaseSetupQueryBuilder", ->
TestModel.configureWithCollectionAttribute()
queries = @builder.setupQueriesForTable(TestModel)
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 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`)',

View file

@ -7,9 +7,9 @@ ModelQuery = require '../../src/flux/models/query'
DatabaseStore = require '../../src/flux/stores/database-store'
testMatchers = {'id': 'b'}
testModelInstance = new TestModel(id: '1234')
testModelInstanceA = new TestModel(id: 'AAA')
testModelInstanceB = new TestModel(id: 'BBB')
testModelInstance = new TestModel(id: "1234")
testModelInstanceA = new TestModel(id: "AAA")
testModelInstanceB = new TestModel(id: "BBB")
describe "DatabaseStore", ->
beforeEach ->
@ -146,7 +146,7 @@ describe "DatabaseStore", ->
it "should compose a REPLACE INTO query to save the model", ->
TestModel.configureWithCollectionAttribute()
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", ->
DatabaseStore._writeModels([testModelInstance])
@ -234,9 +234,9 @@ describe "DatabaseStore", ->
TestModel.configureWithJoinedDataAttribute()
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])
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", ->
@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].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')
DatabaseStore._writeModels([@m])
expect(@performed.length).toBe(1)

View file

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

View file

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

View file

@ -41,7 +41,7 @@ describe "FileDownloadStore", ->
beforeEach ->
spyOn(shell, 'showItemInFolder')
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._downloadDirectory = "/Users/testuser/.nylas/downloads"
@ -60,7 +60,7 @@ describe "FileDownloadStore", ->
describe "_checkForDownloadedFile", ->
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) ->
callback(null, {size: 100})
waitsForPromise ->
@ -68,7 +68,7 @@ describe "FileDownloadStore", ->
expect(downloaded).toBe(true)
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) ->
callback(new Error("File does not exist"))
waitsForPromise ->
@ -76,7 +76,7 @@ describe "FileDownloadStore", ->
expect(downloaded).toBe(false)
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) ->
callback(null, {size: 50})
waitsForPromise ->

View file

@ -14,7 +14,7 @@ describe 'FileUploadStore', ->
size: 12345
@uploadData =
uploadTaskId: 123
messageLocalId: msgId
messageClientId: msgId
filePath: fpath
fileSize: 12345
@ -29,11 +29,11 @@ describe 'FileUploadStore', ->
it "throws if the message id is blank", ->
spyOn(Actions, "attachFilePath")
Actions.attachFile messageLocalId: msgId
Actions.attachFile messageClientId: msgId
expect(atom.showOpenDialog).toHaveBeenCalled()
expect(Actions.attachFilePath).toHaveBeenCalled()
args = Actions.attachFilePath.calls[0].args[0]
expect(args.messageLocalId).toBe msgId
expect(args.messageClientId).toBe msgId
expect(args.path).toBe fpath
describe 'attachFilePath', ->
@ -44,19 +44,19 @@ describe 'FileUploadStore', ->
spyOn(fs, 'stat').andCallFake (path, callback) ->
callback(null, {isDirectory: -> false})
Actions.attachFilePath
messageLocalId: msgId
messageClientId: msgId
path: fpath
expect(Actions.queueTask).toHaveBeenCalled()
t = Actions.queueTask.calls[0].args[0]
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', ->
spyOn(FileUploadStore, '_onAttachFileError')
spyOn(fs, 'stat').andCallFake (path, callback) ->
callback(null, {isDirectory: -> true})
Actions.attachFilePath
messageLocalId: msgId
messageClientId: msgId
path: fpath
expect(Actions.queueTask).not.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", ->
otherAccountInbox = @inboxCategory.clone()
otherAccountInbox.id = 'other-id'
otherAccountInbox.serverId = 'other-id'
FocusedCategoryStore._category = otherAccountInbox
FocusedCategoryStore._onCategoryStoreChanged()
expect(FocusedCategoryStore.category().id).toEqual(@inboxCategory.id)

View file

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

View file

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

View file

@ -19,7 +19,7 @@ describe "UnreadCountStore", ->
atom.testOrganizationUnit = 'folder'
UnreadCountStore._fetchCount()
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
expect(Model).toBe(Thread)
@ -33,7 +33,7 @@ describe "UnreadCountStore", ->
atom.testOrganizationUnit = 'label'
UnreadCountStore._fetchCount()
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
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"
@event = new Event
id: '12233AEDF5'
accountId: 'test_account_id'
accountId: TEST_ACCOUNT_ID
title: 'Meeting with Ben Bitdiddle'
description: ''
location: ''

View file

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

View file

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

View file

@ -1,5 +1,4 @@
_ = require 'underscore'
{generateTempId, isTempId} = require '../../src/flux/models/utils'
NylasAPI = require '../../src/flux/nylas-api'
Task = require '../../src/flux/tasks/task'
@ -33,30 +32,27 @@ testData =
accountId: "abc123"
body: '<body>123</body>'
localDraft = new Message _.extend {}, testData, {id: "local-id"}
remoteDraft = new Message _.extend {}, testData, {id: "remoteid1234"}
localDraft = -> new Message _.extend {}, testData, {clientId: "local-id"}
remoteDraft = -> new Message _.extend {}, testData, {clientId: "local-id", serverId: "remoteid1234"}
describe "SyncbackDraftTask", ->
beforeEach ->
spyOn(DatabaseStore, "findByLocalId").andCallFake (klass, localId) ->
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) ->
spyOn(DatabaseStore, "findBy").andCallFake (klass, {clientId}) ->
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 ->
Promise.resolve()
spyOn(DatabaseStore, "swapModel").andCallFake ->
Promise.resolve()
describe "performRemote", ->
beforeEach ->
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", ->
task = new SyncbackDraftTask("missingDraftId")
@ -101,33 +97,7 @@ describe "SyncbackDraftTask", ->
options = NylasAPI.makeRequest.mostRecentCall.args[0]
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", ->
beforeEach ->
spyOn(NylasAPI, "makeRequest").andCallFake (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) ->
return items if items.length <= 1
@ -20,4 +20,4 @@ class quicksort
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'
clipboard = require 'clipboard'
Account = require "../src/flux/models/account"
AccountStore = require "../src/flux/stores/account-store"
Contact = require '../src/flux/models/contact'
{TaskQueue, ComponentRegistry} = require "nylas-exports"
@ -104,6 +105,10 @@ Promise.setScheduler (fn) ->
setTimeout(fn, 0)
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 ->
atom.testOrganizationUnit = null
Grim.clearDeprecations() if isCoreSpec
@ -146,9 +151,13 @@ beforeEach ->
spyOn(atom.menu, 'sendToBrowserProcess')
# Log in a fake user
spyOn(AccountStore, 'current').andCallFake ->
spyOn(AccountStore, 'current').andCallFake -> new Account
name: "Nylas Test"
provider: "gmail"
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"
usesFolders: -> atom.testOrganizationUnit is "folder"
me: ->

View file

@ -250,7 +250,7 @@ class Application
@on 'application:send-feedback', => @windowManager.sendToMainWindow('send-feedback')
@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:check-for-update', => @autoUpdateManager.check()
@on 'application:install-update', =>
@ -259,9 +259,9 @@ class Application
@autoUpdateManager.install()
@on 'application:open-dev', =>
@devMode = true
@windowManager.closeMainWindow()
@windowManager.closeAllWindows()
@windowManager.devMode = true
@windowManager.ensurePrimaryWindowOnscreen()
@windowManager.openWindowsForTokenState()
@on 'application:toggle-theme', =>
themes = @config.get('core.themes') ? []
@ -326,7 +326,7 @@ class Application
@windowManager.sendToMainWindow('from-react-remote-window', json)
app.on 'activate-with-no-open-windows', (event) =>
@windowManager.ensurePrimaryWindowOnscreen()
@windowManager.openWindowsForTokenState()
event.preventDefault()
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:
```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
{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
is registered that matches the descriptor you provide, InjectedComponent will refresh.

View file

@ -58,13 +58,6 @@ Section: General
###
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.
@ -427,7 +420,7 @@ class Actions
```
Actions.removeFile
file: fileObject
messageLocalId: draftLocalId
messageClientId: draftClientId
```
###
@removeFile: ActionScopeWindow

View file

@ -8,6 +8,7 @@ AttributeBoolean = require './attributes/attribute-boolean'
AttributeDateTime = require './attributes/attribute-datetime'
AttributeCollection = require './attributes/attribute-collection'
AttributeJoinedData = require './attributes/attribute-joined-data'
AttributeServerId = require './attributes/attribute-serverid'
module.exports =
Matcher: Matcher
@ -20,6 +21,7 @@ module.exports =
DateTime: -> new AttributeDateTime(arguments...)
Collection: -> new AttributeCollection(arguments...)
JoinedData: -> new AttributeJoinedData(arguments...)
ServerId: -> new AttributeServerId(arguments...)
AttributeNumber: AttributeNumber
AttributeString: AttributeString
@ -28,3 +30,4 @@ module.exports =
AttributeDateTime: AttributeDateTime
AttributeCollection: AttributeCollection
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
automatically creates a join table and maintains it as you create, save,
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
collection.
join table associating the ID of the model with the IDs of models in the collection.
Collection attributes have an additional clause builder, `contains`:
@ -28,9 +27,7 @@ WHERE `M1`.`value` = 'inbox'
ORDER BY `Thread`.`last_message_received_timestamp` DESC
```
The value of this attribute is always an array of ff other model objects. To use
a Collection attribute, the JSON for the parent object must contain the nested
objects, complete with their `object` field.
The value of this attribute is always an array of other model objects.
Section: Database
###
@ -58,10 +55,11 @@ class AttributeCollection extends Attribute
objs = []
for objJSON in json
obj = new @itemClass(objJSON)
# 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
# even if it's actually the same, makes React components re-render!
obj.id = undefined
# 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 even if it's actually the same, makes React
# components re-render!
obj.clientId = undefined
obj.fromJSON(objJSON) if obj.fromJSON?
objs.push(obj)
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.
# 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!
obj.id = undefined
obj.clientId = undefined
# Warning: typeof(null) is object
if obj.fromJSON and val and typeof(val) is 'object'
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
@attributes: _.extend {}, Model.attributes,
'id': Attributes.String
queryable: true
modelKey: 'id'
jsonKey: 'id'
'title': Attributes.String
modelKey: '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
modelKey: 'snippet'
'threadId': Attributes.String
'threadId': Attributes.ServerId
queryable: true
modelKey: 'threadId'
jsonKey: 'thread_id'
@ -140,7 +140,7 @@ class Message extends Model
modelKey: 'version'
queryable: true
'replyToMessageId': Attributes.String
'replyToMessageId': Attributes.ServerId
modelKey: 'replyToMessageId'
jsonKey: 'reply_to_message_id'
@ -160,6 +160,7 @@ class Message extends Model
@additionalSQLiteConfig:
setup: ->
['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)']
constructor: ->

View file

@ -1,7 +1,6 @@
Attributes = require '../attributes'
ModelQuery = require './query'
{isTempId, generateTempId} = require './utils'
_ = require 'underscore'
Utils = require './utils'
Attributes = require '../attributes'
###
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
`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
deserializer to create an instance of the correct class when inflating the object.
@ -20,15 +31,31 @@ Section: Models
###
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:
# Lookups will go through the custom getter.
'id': Attributes.String
queryable: true
modelKey: 'id'
'clientId': Attributes.String
queryable: true
modelKey: 'clientId'
jsonKey: 'client_id'
'serverId': Attributes.ServerId
modelKey: 'serverId'
jsonKey: 'server_id'
'object': Attributes.String
modelKey: 'object'
'accountId': Attributes.String
'accountId': Attributes.ServerId
queryable: true
modelKey: 'accountId'
jsonKey: 'account_id'
@ -36,9 +63,13 @@ class Model
@naturalSortOrder: -> null
constructor: (values = {}) ->
if values["id"] and Utils.isTempId(values["id"])
values["clientId"] ?= values["id"]
else
values["serverId"] ?= values["id"]
for key, definition of @attributes()
@[key] = values[key] if values[key]?
@id ||= generateTempId()
@clientId ?= Utils.generateTempId()
@
clone: ->
@ -47,12 +78,9 @@ class Model
# Public: Returns an {Array} of {Attribute} objects defined on the Model's constructor
#
attributes: ->
@constructor.attributes
# Public Returns true if the object has a server-provided ID, false otherwise.
#
isSaved: ->
!isTempId(@id)
attrs = _.clone(@constructor.attributes)
delete attrs["id"]
return attrs
##
# Public: Inflates the model object from JSON, using the defined attributes to
@ -63,6 +91,8 @@ class Model
# This method is chainable.
#
fromJSON: (json) ->
if json["id"] and not Utils.isTempId(json["id"])
@serverId = json["id"]
for key, attr of @attributes()
@[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
continue
json[attr.jsonKey] = value
json["id"] = @id
json
toString: ->

View file

@ -94,20 +94,6 @@ class Thread extends Model
@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
# name. Note, only `CategoryStore::standardCategories` have valid
# `names`

View file

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

View file

@ -213,8 +213,9 @@ class NylasAPI
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.
#
_handleModelResponse: (jsons) ->
if not jsons
return Promise.reject(new Error("handleModelResponse with no JSON provided"))
@ -223,26 +224,49 @@ class NylasAPI
if jsons.length is 0
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
klass = @_apiObjectToClassMap[type]
if not klass
console.warn("NylasAPI::handleModelResponse: Received unknown API object type: #{type}")
return Promise.resolve([])
accepted = Promise.resolve(uniquedJSONs)
if type is "thread" or type is "draft"
accepted = @_acceptableModelsInResponse(klass, uniquedJSONs)
# Step 1: Make sure the list of objects contains no duplicates, which cause
# problems downstream when we try to write to the database.
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) ->
DatabaseStore.persistModels(objects).then ->
return Promise.resolve(objects)
# Step 3: Retrieve any existing models from the database for the given IDs.
ids = _.pluck(unlockedJSONs, 'id')
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:
"file": require('./models/file')
@ -257,25 +281,6 @@ class NylasAPI
"calendar": require('./models/calendar')
"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 = {}) ->
requestSuccess = requestOptions.success
requestOptions.success = (json) =>

View file

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

View file

@ -4,8 +4,8 @@ async = require 'async'
path = require 'path'
sqlite3 = require 'sqlite3'
Model = require '../models/model'
Utils = require '../models/utils'
Actions = require '../actions'
LocalLink = require '../models/local-link'
ModelQuery = require '../models/query'
NylasStore = require '../../../exports/nylas-store'
DatabaseSetupQueryBuilder = require './database-setup-query-builder'
@ -14,12 +14,10 @@ PriorityUICoordinator = require '../../priority-ui-coordinator'
{AttributeCollection, AttributeJoinedData} = require '../attributes'
{tableNameForJoin,
generateTempId,
serializeRegisteredObjects,
deserializeRegisteredObjects,
isTempId} = require '../models/utils'
deserializeRegisteredObjects} = require '../models/utils'
DatabaseVersion = 59
DatabaseVersion = 12
DatabasePhase =
Setup: 'setup'
@ -81,7 +79,6 @@ class DatabaseStore extends NylasStore
constructor: ->
@_triggerPromise = null
@_localIdLookupCache = {}
@_inflightTransactions = 0
@_open = false
@_waiting = []
@ -221,10 +218,11 @@ class DatabaseStore extends NylasStore
str = results.map((row) -> row.detail).join('\n') + " for " + query
@_prettyConsoleLog(str) if str.indexOf("SCAN") isnt -1
# Important: once the user begins a transaction, queries need to run in serial.
# This ensures that the subsequent "COMMIT" call actually runs after the other
# queries in the transaction, and that no other code can execute "BEGIN TRANS."
# until the previously queued BEGIN/COMMIT have been processed.
# Important: once the user begins a transaction, queries need to run
# in serial. This ensures that the subsequent "COMMIT" call
# actually runs after the other queries in the transaction, and that
# 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
# finished executing.
@ -316,8 +314,7 @@ class DatabaseStore extends NylasStore
new ModelQuery(klass, @).where(predicates).count()
# 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
# for the missing items.
# IDs and models) into an array of models of the `klass` provided by querying for the missing items.
#
# Modelify is efficient and uses a single database query. It resolves Immediately
# if no query is necessary.
@ -353,106 +350,6 @@ class DatabaseStore extends NylasStore
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.
#
# - `modelQuery` A {ModelQuery} to execute.
@ -530,34 +427,6 @@ class DatabaseStore extends NylasStore
]).then =>
@_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) ->
jsonString = serializeRegisteredObjects(json)
@_query(BEGIN_TRANSACTION)

View file

@ -22,7 +22,7 @@ DraftChangeSet associated with the store proxy. The DraftChangeSet does two thin
Section: Drafts
###
class DraftChangeSet
constructor: (@localId, @_onChange) ->
constructor: (@clientId, @_onChange) ->
@_commitChain = Promise.resolve()
@_pending = {}
@_saving = {}
@ -51,19 +51,19 @@ class DraftChangeSet
return Promise.resolve(true)
DatabaseStore = require './database-store'
return DatabaseStore.findByLocalId(Message, @localId).then (draft) =>
DatabaseStore.findBy(Message, clientId: @clientId).then (draft) =>
if @_destroyed
return Promise.resolve(true)
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
@_pending = {}
draft = @applyToModel(draft)
return DatabaseStore.persistModel(draft).then =>
syncback = new SyncbackDraftTask(@localId)
syncback = new SyncbackDraftTask(@clientId)
Actions.queueTask(syncback)
@_saving = {}
@ -94,17 +94,16 @@ class DraftStoreProxy
@include Publisher
@include Listener
constructor: (@draftLocalId, draft = null) ->
constructor: (@draftClientId, draft = null) ->
DraftStore = require './draft-store'
@listenTo DraftStore, @_onDraftChanged
@listenTo Actions.didSwapModel, @_onDraftSwapped
@_draft = false
@_draftPristineBody = null
@_destroyed = false
@changes = new DraftChangeSet @draftLocalId, =>
@changes = new DraftChangeSet @draftClientId, =>
return if @_destroyed
if !@_draft
throw new Error("DraftChangeSet was modified before the draft was prepared.")
@ -115,7 +114,7 @@ class DraftStoreProxy
@_draftPromise = Promise.resolve(@)
@prepare()
# Public: Returns the draft object with the latest changes applied.
#
draft: ->
@ -131,9 +130,9 @@ class DraftStoreProxy
prepare: ->
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("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)
Promise.resolve(@)
@_draftPromise
@ -167,12 +166,4 @@ class DraftStoreProxy
@_draft = _.extend @_draft, _.last(myDrafts)
@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

View file

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

View file

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

View file

@ -20,7 +20,7 @@ FileUploadStore = Reflux.createStore
@listenTo Actions.fileAborted, @_onFileAborted
# 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.
@_fileUploads = {}
@_linkedFiles = {}
@ -28,18 +28,18 @@ FileUploadStore = Reflux.createStore
######### PUBLIC #######################################################
uploadsForMessage: (messageLocalId) ->
if not messageLocalId? then return []
uploadsForMessage: (messageClientId) ->
if not messageClientId? then return []
_.filter @_fileUploads, (uploadData, uploadKey) ->
uploadData.messageLocalId is messageLocalId
uploadData.messageClientId is messageClientId
linkedUpload: (file) -> @_linkedFiles[file.id]
########### PRIVATE ####################################################
_onAttachFile: ({messageLocalId}) ->
@_verifyId(messageLocalId)
_onAttachFile: ({messageClientId}) ->
@_verifyId(messageClientId)
# When the dialog closes, it triggers `Actions.pathsToOpen`
atom.showOpenDialog {properties: ['openFile', 'multiSelections']}, (pathsToOpen) ->
@ -47,7 +47,7 @@ FileUploadStore = Reflux.createStore
pathsToOpen = [pathsToOpen] if _.isString(pathsToOpen)
pathsToOpen.forEach (path) ->
Actions.attachFilePath({messageLocalId, path})
Actions.attachFilePath({messageClientId, path})
_onAttachFileError: (message) ->
remote = require('remote')
@ -58,8 +58,8 @@ FileUploadStore = Reflux.createStore
message: 'Cannot Attach File',
detail: message
_onAttachFilePath: ({messageLocalId, path}) ->
@_verifyId(messageLocalId)
_onAttachFilePath: ({messageClientId, path}) ->
@_verifyId(messageClientId)
fs.stat path, (err, stats) =>
filename = require('path').basename(path)
if err
@ -67,12 +67,12 @@ FileUploadStore = Reflux.createStore
else if stats.isDirectory()
@_onAttachFileError("#{filename} is a directory. Try compressing it and attaching it again.")
else
Actions.queueTask(new FileUploadTask(path, messageLocalId))
Actions.queueTask(new FileUploadTask(path, messageClientId))
# Receives:
# uploadData:
# 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
# fileSize - The size in bytes
# fileName - The basename of the file
@ -107,6 +107,6 @@ FileUploadStore = Reflux.createStore
delete @_fileUploads[uploadData.uploadTaskId]
@trigger()
_verifyId: (messageLocalId) ->
if messageLocalId.blank?
_verifyId: (messageClientId) ->
if messageClientId.blank?
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'
Reflux = require 'reflux'
Utils = require '../models/utils'
Actions = require '../actions'
NylasStore = require 'nylas-store'
MessageStore = require './message-store'
AccountStore = require './account-store'
FocusedContentStore = require './focused-content-store'
# A store that handles the focuses collections of and individual contacts
module.exports =
FocusedContactsStore = Reflux.createStore
init: ->
class FocusedContactsStore extends NylasStore
constructor: ->
@listenTo Actions.focusContact, @_focusContact
@listenTo MessageStore, => @_onMessageStoreChanged()
@listenTo AccountStore, => @_onAccountChanged()
@listenTo MessageStore, @_onMessageStoreChanged
@listenTo AccountStore, @_onAccountChanged
@listenTo FocusedContentStore, @_onFocusChanged
@_currentThread = null
@ -31,7 +30,7 @@ FocusedContactsStore = Reflux.createStore
@_currentFocusedContact = null
@trigger() unless silent
_onFocusChanged: (change) ->
_onFocusChanged: (change) =>
return unless change.impactsCollection('thread')
item = FocusedContentStore.focused('thread')
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
# appropriate messages for the given thread.
_onMessageStoreChanged: -> _.defer =>
_onMessageStoreChanged: =>
if MessageStore.threadId() is @_currentThread?.id
@_setCurrentParticipants()
else
@_clearCurrentParticipants()
_onAccountChanged: ->
_onAccountChanged: =>
@_myEmail = (AccountStore.current()?.me().email ? "").toLowerCase().trim()
# For now we take the last message
@ -59,7 +58,7 @@ FocusedContactsStore = Reflux.createStore
@_focusContact(@_currentContacts[0], silent: true)
@trigger()
_focusContact: (contact, {silent}={}) ->
_focusContact: (contact, {silent}={}) =>
return unless contact
@_currentFocusedContact = contact
@trigger() unless silent
@ -113,3 +112,4 @@ FocusedContactsStore = Reflux.createStore
theirDomain = _.last(email.split("@"))
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.
_.clone @_itemsExpanded
itemLocalIds: =>
_.clone @_itemsLocalIds
itemClientIds: ->
_.pluck(@_items, "clientId")
itemsLoading: ->
@_itemsLoading
@ -71,7 +71,6 @@ class MessageStore extends NylasStore
_setStoreDefaults: =>
@_items = []
@_itemsExpanded = {}
@_itemsLocalIds = {}
@_itemsLoading = false
@_thread = null
@_extensions = []
@ -89,20 +88,13 @@ class MessageStore extends NylasStore
inDisplayedThread = _.some change.objects, (obj) => obj.threadId is @_thread.id
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]
itemAlreadyExists = _.some @_items, (msg) -> msg.id is item.id
if change.objects.length is 1 and item.draft is true and not itemAlreadyExists
DatabaseStore.localIdForModel(item).then (localId) =>
@_itemsLocalIds[item.id] = localId
# We need to create a new copy of the items array so that the message-list
# can compare new state to previous state.
@_items = [].concat(@_items, [item])
@_items = @_sortItemsForDisplay(@_items)
@_expandItemsToDefault()
@trigger()
@_items = [].concat(@_items, [item])
@_items = @_sortItemsForDisplay(@_items)
@_expandItemsToDefault()
@trigger()
else
@_fetchFromCache()
@ -144,61 +136,53 @@ class MessageStore extends NylasStore
query.where(threadId: loadedThreadId, accountId: @_thread.accountId)
query.include(Message.attributes.body)
query.then (items) =>
localIds = {}
async.each items, (item, callback) ->
return callback() unless item.draft
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
# 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)
@_itemsLocalIds = localIds
@_items = @_sortItemsForDisplay(items)
# If no items were returned, attempt to load messages via the API. If items
# are returned, this will trigger a refresh here.
if @_items.length is 0
@_fetchMessages()
loaded = false
# If no items were returned, attempt to load messages via the API. If items
# are returned, this will trigger a refresh here.
if @_items.length is 0
@_fetchMessages()
loaded = false
@_expandItemsToDefault()
@_expandItemsToDefault()
# Download the attachments on expanded messages.
@_fetchExpandedAttachments(@_items)
# Download the attachments on expanded messages.
@_fetchExpandedAttachments(@_items)
# Check that expanded messages have bodies. We won't mark ourselves
# as loaded until they're all available. Note that items can be manually
# expanded so this logic must be separate from above.
if @_fetchExpandedBodies(@_items)
loaded = false
# Check that expanded messages have bodies. We won't mark ourselves
# as loaded until they're all available. Note that items can be manually
# expanded so this logic must be separate from above.
if @_fetchExpandedBodies(@_items)
loaded = false
# Normally, we would trigger often and let the view's
# shouldComponentUpdate decide whether to re-render, but if we
# know we're not ready, don't even bother. Trigger once at start
# and once when ready. Many third-party stores will observe
# MessageStore and they'll be stupid and re-render constantly.
if loaded
# Mark the thread as read if necessary. Make sure it's still the
# current thread after the timeout.
# Normally, we would trigger often and let the view's
# shouldComponentUpdate decide whether to re-render, but if we
# know we're not ready, don't even bother. Trigger once at start
# and once when ready. Many third-party stores will observe
# MessageStore and they'll be stupid and re-render constantly.
if loaded
# Mark the thread as read if necessary. Make sure it's still the
# current thread after the timeout.
# Override canBeUndone to return false so that we don't see undo prompts
# (since this is a passive action vs. a user-triggered action.)
if @_thread.unread
markAsReadDelay = atom.config.get('core.reading.markAsReadDelay')
setTimeout =>
return unless loadedThreadId is @_thread?.id
t = new ChangeUnreadTask(thread: @_thread, unread: false)
t.canBeUndone = => false
Actions.queueTask(t)
, markAsReadDelay
# Override canBeUndone to return false so that we don't see undo prompts
# (since this is a passive action vs. a user-triggered action.)
if @_thread.unread
markAsReadDelay = atom.config.get('core.reading.markAsReadDelay')
setTimeout =>
return unless loadedThreadId is @_thread?.id
t = new ChangeUnreadTask(thread: @_thread, unread: false)
t.canBeUndone = => false
Actions.queueTask(t)
, markAsReadDelay
@_itemsLoading = false
@trigger(@)
@_itemsLoading = false
@trigger(@)
_fetchExpandedBodies: (items) ->
startedAFetch = false

View file

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

View file

@ -1,7 +1,6 @@
_ = require 'underscore'
fs = require 'fs-plus'
path = require 'path'
{generateTempId} = require '../models/utils'
{Listener, Publisher} = require '../modules/reflux-coffee'
CoffeeHelpers = require '../coffee-helpers'
@ -100,7 +99,7 @@ class TaskQueue
{SaveDraftTask} or 'SaveDraftTask')
- `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.
###

View file

@ -9,7 +9,7 @@ ChangeMailTask = require './change-mail-task'
# Public: Create a new task to apply labels to a message or thread.
#
# 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 {Message}s or {Message} IDs
# - `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.
#
# 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: ->
if @_isUndoTask and not @_restoreValues

View file

@ -11,29 +11,29 @@ FileUploadTask = require './file-upload-task'
module.exports =
class DestroyDraftTask extends Task
constructor: ({@draftLocalId, @draftId} = {}) -> super
constructor: ({@draftClientId, @draftId} = {}) -> super
shouldDequeueOtherTask: (other) ->
if @draftLocalId
(other instanceof DestroyDraftTask and other.draftLocalId is @draftLocalId) or
(other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId) or
(other instanceof SendDraftTask and other.draftLocalId is @draftLocalId) or
(other instanceof FileUploadTask and other.messageLocalId is @draftLocalId)
if @draftClientId
(other instanceof DestroyDraftTask and other.draftClientId is @draftClientId) or
(other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId) or
(other instanceof SendDraftTask and other.draftClientId is @draftClientId) or
(other instanceof FileUploadTask and other.messageClientId is @draftClientId)
else if @draftId
(other instanceof DestroyDraftTask and other.draftLocalId is @draftLocalId)
(other instanceof DestroyDraftTask and other.draftClientId is @draftClientId)
else
false
shouldWaitForTask: (other) ->
(other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId)
(other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId)
performLocal: ->
if @draftLocalId
find = DatabaseStore.findByLocalId(Message, @draftLocalId)
if @draftClientId
find = DatabaseStore.findBy(Message, clientId: @draftClientId)
else if @draftId
find = DatabaseStore.find(Message, @draftId)
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) =>
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
# the server (id is still self-assigned)
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
path: "/drafts/#{@draft.id}"
path: "/drafts/#{@draft.serverId}"
accountId: @draft.accountId
method: "DELETE"
body:

View file

@ -17,7 +17,7 @@ UploadCounter = 0
class FileUploadTask extends Task
constructor: (@filePath, @messageLocalId) ->
constructor: (@filePath, @messageClientId) ->
super
@_startDate = Date.now()
@_startId = UploadCounter
@ -27,7 +27,7 @@ class FileUploadTask extends Task
performLocal: ->
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")
Promise.resolve()
@ -97,7 +97,7 @@ class FileUploadTask extends Task
Actions.linkFileToUpload(file: file, uploadData: @_uploadData("completed"))
DraftStore = require '../stores/draft-store'
DraftStore.sessionForLocalId(@messageLocalId).then (session) =>
DraftStore.sessionForClientId(@messageClientId).then (session) =>
files = _.clone(session.draft().files) ? []
files.push(file)
session.changes.add({files})
@ -121,7 +121,7 @@ class FileUploadTask extends Task
filename: @_uploadData().fileName
# 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
# fileSize - The size in bytes
# fileName - The basename of the file
@ -132,7 +132,7 @@ class FileUploadTask extends Task
uploadTaskId: @id
startDate: @_startDate
startId: @_startId
messageLocalId: @messageLocalId
messageClientId: @messageClientId
filePath: @filePath
fileSize: @_getFileSize(@filePath)
fileName: pathUtils.basename(@filePath)

View file

@ -1,5 +1,3 @@
{isTempId} = require '../models/utils'
Actions = require '../actions'
DatabaseStore = require '../stores/database-store'
Message = require '../models/message'
@ -13,40 +11,39 @@ NylasAPI = require '../nylas-api'
module.exports =
class SendDraftTask extends Task
constructor: (@draftLocalId, {@fromPopout}={}) ->
constructor: (@draftClientId, {@fromPopout}={}) ->
super
label: ->
"Sending draft..."
shouldDequeueOtherTask: (other) ->
other instanceof SendDraftTask and other.draftLocalId is @draftLocalId
other instanceof SendDraftTask and other.draftClientId is @draftClientId
shouldWaitForTask: (other) ->
(other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId) or
(other instanceof FileUploadTask and other.messageLocalId is @draftLocalId)
(other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId) or
(other instanceof FileUploadTask and other.messageClientId is @draftClientId)
performLocal: ->
# 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
# already sent when they haven't!
if not @draftLocalId
return Promise.reject(new Error("Attempt to call SendDraftTask.performLocal without @draftLocalId."))
if not @draftClientId
return Promise.reject(new Error("Attempt to call SendDraftTask.performLocal without @draftClientId."))
Promise.resolve()
performRemote: ->
# Fetch the latest draft data to make sure we make the request with the most
# 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.
NylasAPI.incrementOptimisticChangeCount(Message, draft.id)
@draft = draft
if not draft
return Promise.reject(new Error("We couldn't find the saved draft."))
if draft.isSaved()
if draft.serverId
body =
draft_id: draft.id
draft_id: draft.serverId
version: draft.version
else
body = draft.toJSON()
@ -68,13 +65,14 @@ class SendDraftTask extends Task
message = (new Message).fromJSON(json)
atom.playSound('mail_sent.ogg')
Actions.sendDraftSuccess
draftLocalId: @draftLocalId
draftClientId: @draftClientId
newMessage: message
DatabaseStore.unpersistModel(@draft).then =>
return Promise.resolve(Task.Status.Finished)
DestroyDraftTask = require './destroy-draft'
task = new DestroyDraftTask(draftClientId: @draftClientId)
Actions.queueTask(task)
return Promise.resolve(Task.Status.Finished)
.catch APIError, (err) =>
NylasAPI.decrementOptimisticChangeCount(Message, @draft.id)
if err.message?.indexOf('Invalid message public id') is 0
body.reply_to_message_id = null
return @_send(body)
@ -84,7 +82,7 @@ class SendDraftTask extends Task
return @_send(body)
else if err.statusCode in NylasAPI.PermanentErrorCodes
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)
else
return Promise.resolve(Task.Status.Retry)

View file

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

View file

@ -11,7 +11,7 @@ class MyComposerButton extends React.Component
# reference to the draft, and you can look it up to perform
# actions and retrieve data.
@propTypes:
draftLocalId: React.PropTypes.string.isRequired
draftClientId: React.PropTypes.string.isRequired
render: =>
<div className="my-package">
@ -24,7 +24,7 @@ class MyComposerButton extends React.Component
# To retrieve information about the draft, we fetch the current editing
# session from the draft store. We can access attributes of the draft
# 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!"
dialog = @_getDialog()
@ -40,4 +40,4 @@ class MyComposerButton extends React.Component
require('remote').require('dialog')
module.exports = MyComposerButton
module.exports = MyComposerButton

View file

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