refactor(draft): clean up draft store session proxy logic

Summary:
This is an effort to make the logic around the process of sending a draft
cleaner and easier to understand.

Fixes T1253

Test Plan: edgehill --test

Reviewers: bengotow

Reviewed By: bengotow

Maniphest Tasks: T1253

Differential Revision: https://phab.nylas.com/D1518
This commit is contained in:
Evan Morikawa 2015-05-19 12:07:08 -07:00
parent 684a8ef4c0
commit ae41c94e42
16 changed files with 236 additions and 264 deletions

View file

@ -52,7 +52,7 @@ class ComposerView extends React.Component
showbcc: false
showsubject: false
showQuotedText: false
isSending: DraftStore.sendingState(@props.localId)
isSending: DraftStore.isSendingDraft(@props.localId)
componentWillMount: =>
@_prepareForDraft(@props.localId)
@ -418,7 +418,7 @@ class ComposerView extends React.Component
@focus "textFieldCc"
_onSendingStateChanged: =>
@setState isSending: DraftStore.sendingState(@props.localId)
@setState isSending: DraftStore.isSendingDraft(@props.localId)
undo: (event) =>

View file

@ -181,12 +181,12 @@ describe "populated composer", ->
describe "When sending a message", ->
beforeEach ->
spyOn(atom, "isMainWindow").andReturn true
remote = require('remote')
@dialog = remote.require('dialog')
spyOn(remote, "getCurrentWindow")
spyOn(@dialog, "showMessageBox")
spyOn(Actions, "sendDraft")
DraftStore._sendingState = {}
it "shows a warning if there are no recipients", ->
useDraft.call @, subject: "no recipients"
@ -291,7 +291,7 @@ describe "populated composer", ->
expect(Actions.sendDraft.calls.length).toBe 1
simulateDraftStore = ->
DraftStore._sendingState[DRAFT_LOCAL_ID] = true
spyOn(DraftStore, "isSendingDraft").andReturn true
DraftStore.trigger()
it "doesn't send twice if you double click", ->
@ -314,14 +314,20 @@ describe "populated composer", ->
expect(@composer.state.isSending).toBe true
it "re-enables the composer if sending threw an error", ->
sending = null
spyOn(DraftStore, "isSendingDraft").andCallFake => return sending
useFullDraft.apply(@); makeComposer.call(@)
sendBtn = React.findDOMNode(@composer.refs.sendButton)
ReactTestUtils.Simulate.click sendBtn
simulateDraftStore()
expect(@composer.state.isSending).toBe true
Actions.sendDraftError("oh no")
DraftStore._sendingState[DRAFT_LOCAL_ID] = false
sending = true
DraftStore.trigger()
expect(@composer.state.isSending).toBe true
sending = false
DraftStore.trigger()
expect(@composer.state.isSending).toBe false
describe "when sending a message with keyboard inputs", ->

View file

@ -21,7 +21,7 @@
"clear-cut": "0.4.0",
"coffee-react": "^2.0.0",
"coffee-script": "1.9.0",
"coffeestack": "0.8.0",
"coffeestack": "^1.1",
"classnames": "1.2.1",
"color": "^0.7.3",
"delegato": "^1",

View file

@ -5,6 +5,7 @@ ModelQuery = require '../../src/flux/models/query'
NamespaceStore = require '../../src/flux/stores/namespace-store'
DatabaseStore = require '../../src/flux/stores/database-store'
DraftStore = require '../../src/flux/stores/draft-store'
TaskQueue = require '../../src/flux/stores/task-queue'
SendDraftTask = require '../../src/flux/tasks/send-draft'
DestroyDraftTask = require '../../src/flux/tasks/destroy-draft'
Actions = require '../../src/flux/actions'
@ -307,28 +308,28 @@ describe "DraftStore", ->
, (thread, message) ->
expect(message).toEqual(fakeMessage1)
{}
describe "onDestroyDraft", ->
beforeEach ->
@draftReset = jasmine.createSpy('draft reset')
spyOn(Actions, 'queueTask')
DraftStore._draftSessions = {"abc":{
@session =
draft: ->
pristine: false
changes:
commit: -> Promise.resolve()
reset: @draftReset
cleanup: ->
}}
DraftStore._draftSessions = {"abc": @session}
spyOn(Actions, 'queueTask')
it "should reset the draft session, ensuring no more saves are made", ->
DraftStore._onDestroyDraft('abc')
expect(@draftReset).toHaveBeenCalled()
it "should not do anything if the draft session is not in the window", ->
it "should throw if the draft session is not in the window", ->
expect ->
DraftStore._onDestroyDraft('other')
.not.toThrow()
.toThrow()
expect(@draftReset).not.toHaveBeenCalled()
it "should queue a destroy draft task", ->
@ -337,9 +338,21 @@ describe "DraftStore", ->
expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true)
it "should clean up the draft session", ->
spyOn(DraftStore, 'cleanupSessionForLocalId')
spyOn(DraftStore, '_doneWithSession')
DraftStore._onDestroyDraft('abc')
expect(DraftStore.cleanupSessionForLocalId).toHaveBeenCalledWith('abc')
expect(DraftStore._doneWithSession).toHaveBeenCalledWith(@session)
it "should close the window if it's a popout", ->
spyOn(atom, "close")
spyOn(DraftStore, "_isPopout").andReturn true
DraftStore._onDestroyDraft('abc')
expect(atom.close).toHaveBeenCalled()
it "should NOT close the window if isn't a popout", ->
spyOn(atom, "close")
spyOn(DraftStore, "_isPopout").andReturn false
DraftStore._onDestroyDraft('abc')
expect(atom.close).not.toHaveBeenCalled()
describe "before unloading", ->
it "should destroy pristine drafts", ->
@ -389,7 +402,6 @@ describe "DraftStore", ->
describe "sending a draft", ->
draftLocalId = "local-123"
beforeEach ->
DraftStore._sendingState = {}
DraftStore._draftSessions = {}
proxy =
prepare: -> Promise.resolve(proxy)
@ -398,32 +410,25 @@ describe "DraftStore", ->
changes:
commit: -> Promise.resolve()
DraftStore._draftSessions[draftLocalId] = proxy
spyOn(DraftStore, "_doneWithSession").andCallThrough()
spyOn(DraftStore, "trigger")
TaskQueue._queue = []
it "sets the sending state when sending", ->
DraftStore._onSendDraft(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe true
expect(DraftStore.trigger).toHaveBeenCalled()
spyOn(atom, "isMainWindow").andReturn true
spyOn(TaskQueue, "_update")
spyOn(Actions, "queueTask").andCallThrough()
runs ->
DraftStore._onSendDraft(draftLocalId)
waitsFor ->
Actions.queueTask.calls.length > 0
runs ->
expect(DraftStore.isSendingDraft(draftLocalId)).toBe true
expect(DraftStore.trigger).toHaveBeenCalled()
it "returns false if the draft hasn't been seen", ->
expect(DraftStore.sendingState(draftLocalId)).toBe false
it "resets the sending state on success", ->
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(DraftStore.sendingState(draftLocalId)).toBe true
DraftStore._onSendDraftSuccess({draftLocalId})
expect(DraftStore.sendingState(draftLocalId)).toBe false
expect(DraftStore.trigger).toHaveBeenCalled()
it "resets the sending state on error", ->
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(DraftStore.sendingState(draftLocalId)).toBe true
DraftStore._onSendDraftError(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe false
expect(DraftStore.trigger).toHaveBeenCalled()
spyOn(atom, "isMainWindow").andReturn true
expect(DraftStore.isSendingDraft(draftLocalId)).toBe false
it "closes the window if it's a popout", ->
spyOn(atom, "getWindowType").andReturn "composer"
@ -432,91 +437,63 @@ describe "DraftStore", ->
runs ->
DraftStore._onSendDraft(draftLocalId)
waitsFor "Atom to close", ->
advanceClock(1000)
atom.close.calls.length > 0
it "doesn't close the window if it's inline", ->
spyOn(atom, "getWindowType").andReturn "other"
spyOn(atom, "isMainWindow").andReturn false
spyOn(atom, "close")
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(atom.close).not.toHaveBeenCalled()
spyOn(DraftStore, "_isPopout").andCallThrough()
runs ->
DraftStore._onSendDraft(draftLocalId)
waitsFor ->
DraftStore._isPopout.calls.length > 0
runs ->
expect(atom.close).not.toHaveBeenCalled()
it "queues a SendDraftTask", ->
spyOn(Actions, "queueTask")
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(Actions.queueTask).toHaveBeenCalled()
task = Actions.queueTask.calls[0].args[0]
expect(task instanceof SendDraftTask).toBe true
expect(task.draftLocalId).toBe draftLocalId
expect(task.fromPopout).toBe false
runs ->
DraftStore._onSendDraft(draftLocalId)
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.fromPopout).toBe false
it "queues a SendDraftTask with popout info", ->
spyOn(atom, "getWindowType").andReturn "composer"
spyOn(atom, "isMainWindow").andReturn false
spyOn(atom, "close")
spyOn(Actions, "queueTask")
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(Actions.queueTask).toHaveBeenCalled()
task = Actions.queueTask.calls[0].args[0]
expect(task.fromPopout).toBe true
runs ->
DraftStore._onSendDraft(draftLocalId)
waitsFor ->
DraftStore._doneWithSession.calls.length > 0
runs ->
expect(Actions.queueTask).toHaveBeenCalled()
task = Actions.queueTask.calls[0].args[0]
expect(task.fromPopout).toBe true
describe "cleanupSessionForLocalId", ->
it "should destroy the draft if it is pristine", ->
DraftStore._draftSessions = {"abc":{
describe "session cleanup", ->
beforeEach ->
@draftCleanup = jasmine.createSpy('draft cleanup')
@session =
draftLocalId: "abc"
draft: ->
pristine: true
cleanup: ->
}}
spyOn(Actions, 'queueTask')
DraftStore.cleanupSessionForLocalId('abc')
expect(Actions.queueTask).toHaveBeenCalled()
expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true)
pristine: false
changes:
commit: -> Promise.resolve()
reset: ->
cleanup: @draftCleanup
DraftStore._draftSessions = {"abc": @session}
DraftStore._doneWithSession(@session)
it "should not do anything bad if the session does not exist", ->
expect ->
DraftStore.cleanupSessionForLocalId('dne')
.not.toThrow()
it "removes from the list of draftSessions", ->
expect(DraftStore._draftSessions["abc"]).toBeUndefined()
describe "when in the popout composer", ->
beforeEach ->
spyOn(atom, "getWindowType").andReturn "composer"
spyOn(atom, "isMainWindow").andReturn false
DraftStore._draftSessions = {"abc":{
draft: ->
pristine: false
cleanup: ->
}}
it "should close the composer window", ->
spyOn(atom, 'close')
DraftStore.cleanupSessionForLocalId('abc')
advanceClock(1000)
expect(atom.close).toHaveBeenCalled()
it "should not close the composer window if the draft session is not in the window", ->
spyOn(atom, 'close')
DraftStore.cleanupSessionForLocalId('other-random-draft-id')
advanceClock(1000)
expect(atom.close).not.toHaveBeenCalled()
describe "when it is in a main window", ->
beforeEach ->
@cleanup = jasmine.createSpy('cleanup')
spyOn(atom, "isMainWindow").andReturn true
DraftStore._draftSessions = {"abc":{
draft: ->
pristine: false
cleanup: @cleanup
}}
it "should call proxy.cleanup() to unlink listeners", ->
DraftStore.cleanupSessionForLocalId('abc')
expect(@cleanup).toHaveBeenCalled()
it "should remove the proxy from the sessions list", ->
DraftStore.cleanupSessionForLocalId('abc')
expect(DraftStore._draftSessions).toEqual({})
it "Calls cleanup on the session", ->
expect(@draftCleanup).toHaveBeenCalled

View file

@ -15,6 +15,7 @@ class TaskSubclassB extends Task
constructor: (val) -> @bProp = val; super
describe "TaskQueue", ->
makeUnstartedTask = (task) ->
TaskQueue._initializeTask(task)
return task
@ -50,7 +51,6 @@ describe "TaskQueue", ->
return task
beforeEach ->
TaskQueue._onlineStatus = true
@task = new Task()
@unstartedTask = makeUnstartedTask(new Task())
@localStarted = makeLocalStarted(new Task())
@ -83,9 +83,11 @@ describe "TaskQueue", ->
taks.queueState.performedRemote = false
taks.queueState.notifiedOffline = false
afterEach ->
TaskQueue._queue = []
TaskQueue._completed = []
localSpy = (task) ->
spyOn(task, "performLocal").andCallFake -> Promise.resolve()
remoteSpy = (task) ->
spyOn(task, "performRemote").andCallFake -> Promise.resolve()
describe "enqueue", ->
it "makes sure you've queued a real task", ->
@ -169,8 +171,8 @@ describe "TaskQueue", ->
describe "process Task", ->
it "doesn't process processing tasks", ->
spyOn(@remoteStarted, "performLocal")
spyOn(@remoteStarted, "performRemote")
localSpy(@remoteStarted)
remoteSpy(@remoteStarted)
TaskQueue._processTask(@remoteStarted)
expect(@remoteStarted.performLocal).not.toHaveBeenCalled()
expect(@remoteStarted.performRemote).not.toHaveBeenCalled()
@ -181,8 +183,8 @@ describe "TaskQueue", ->
shouldWaitForTask: (other) -> other instanceof TaskSubclassA
blockedByTask = new BlockedByTaskA()
spyOn(blockedByTask, "performLocal")
spyOn(blockedByTask, "performRemote")
localSpy(blockedByTask)
remoteSpy(blockedByTask)
blockingTask = makeRemoteFailed(new TaskSubclassA())
@ -199,8 +201,8 @@ describe "TaskQueue", ->
shouldWaitForTask: (other) -> other instanceof BlockingTask
blockedByTask = new BlockingTask()
spyOn(blockedByTask, "performLocal")
spyOn(blockedByTask, "performRemote")
localSpy(blockedByTask)
remoteSpy(blockedByTask)
blockingTask = makeRemoteFailed(new BlockingTask())
@ -212,19 +214,21 @@ describe "TaskQueue", ->
expect(blockedByTask.performRemote).not.toHaveBeenCalled()
it "sets the processing bit", ->
spyOn(@unstartedTask, "performLocal").andCallFake -> Promise.resolve()
localSpy(@unstartedTask)
TaskQueue._queue = [@unstartedTask]
TaskQueue._processTask(@unstartedTask)
expect(@unstartedTask.queueState.isProcessing).toBe true
it "performs local if it's a fresh task", ->
spyOn(@unstartedTask, "performLocal").andCallFake -> Promise.resolve()
localSpy(@unstartedTask)
TaskQueue._queue = [@unstartedTask]
TaskQueue._processTask(@unstartedTask)
expect(@unstartedTask.performLocal).toHaveBeenCalled()
describe "performLocal", ->
it "on success it marks it as complete with the timestamp", ->
spyOn(@unstartedTask, "performLocal").andCallFake -> Promise.resolve()
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.resolve()
localSpy(@unstartedTask)
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@ -234,7 +238,7 @@ describe "TaskQueue", ->
it "throws an error if it fails", ->
spyOn(@unstartedTask, "performLocal").andCallFake -> Promise.reject("boo")
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.resolve()
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@ -246,7 +250,7 @@ describe "TaskQueue", ->
it "dequeues the task if it fails locally", ->
spyOn(@unstartedTask, "performLocal").andCallFake -> Promise.reject("boo")
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.resolve()
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@ -257,10 +261,10 @@ describe "TaskQueue", ->
describe "performRemote", ->
beforeEach ->
spyOn(@unstartedTask, "performLocal").andCallFake -> Promise.resolve()
localSpy(@unstartedTask)
it "performs remote properly", ->
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.resolve()
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@ -270,7 +274,7 @@ describe "TaskQueue", ->
expect(@unstartedTask.performRemote).toHaveBeenCalled()
it "dequeues on success", ->
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.resolve()
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@ -282,7 +286,7 @@ describe "TaskQueue", ->
it "notifies we're offline the first time", ->
spyOn(TaskQueue, "_isOnline").andReturn false
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.resolve()
remoteSpy(@unstartedTask)
spyOn(@unstartedTask, "onError")
runs ->
TaskQueue.enqueue(@unstartedTask)
@ -297,8 +301,8 @@ describe "TaskQueue", ->
it "doesn't notify we're offline the second+ time", ->
spyOn(TaskQueue, "_isOnline").andReturn false
spyOn(@remoteFailed, "performLocal").andCallFake -> Promise.resolve()
spyOn(@remoteFailed, "performRemote").andCallFake -> Promise.resolve()
localSpy(@remoteFailed)
remoteSpy(@remoteFailed)
spyOn(@remoteFailed, "onError")
@remoteFailed.queueState.notifiedOffline = true
TaskQueue._queue = [@remoteFailed]
@ -312,7 +316,7 @@ describe "TaskQueue", ->
expect(@remoteFailed.onError).not.toHaveBeenCalled()
it "marks performedRemote on success", ->
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.resolve()
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@ -362,8 +366,8 @@ describe "TaskQueue", ->
@remoteFailed]
it "when all tasks pass it processes all items", ->
for task in TaskQueue._queue
spyOn(task, "performLocal").andCallFake -> Promise.resolve()
spyOn(task, "performRemote").andCallFake -> Promise.resolve()
localSpy(task)
remoteSpy(task)
runs ->
TaskQueue.enqueue(new Task)
waitsFor ->

View file

@ -45,12 +45,14 @@ uploadData =
describe "FileUploadTask", ->
it "rejects if not initialized with a path name", (done) ->
waitsForPromise shouldReject: true, ->
(new FileUploadTask).performLocal()
waitsForPromise ->
(new FileUploadTask).performLocal().catch (err) ->
expect(err instanceof Error).toBe true
it "rejects if not initialized with a messageLocalId", ->
waitsForPromise shouldReject: true, ->
(new FileUploadTask(test_file_paths[0])).performLocal()
waitsForPromise ->
(new FileUploadTask(test_file_paths[0])).performLocal().catch (err) ->
expect(err instanceof Error).toBe true
beforeEach ->
@task = new FileUploadTask(test_file_paths[0], localId)

View file

@ -201,7 +201,6 @@ describe "SendDraftTask", ->
email: 'dummy@nylas.com'
@task = new SendDraftTask(@draft.id)
spyOn(Actions, "dequeueTask")
spyOn(Actions, "sendDraftError")
it "throws an error if the draft can't be found", ->
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) ->
@ -224,25 +223,15 @@ describe "SendDraftTask", ->
@task.performRemote().catch (error) ->
expect(error).toBe "DB error"
checkError = ->
expect(Actions.sendDraftError).toHaveBeenCalled()
args = Actions.sendDraftError.calls[0].args
expect(args[0]).toBe @draft.id
expect(args[1].length).toBeGreaterThan 0
it "onAPIError notifies of the error", ->
@task.onAPIError(message: "oh no")
checkError.call(@)
it "onOtherError notifies of the error", ->
@task.onOtherError()
checkError.call(@)
it "onTimeoutError notifies of the error", ->
@task.onTimeoutError()
checkError.call(@)
it "onOfflineError notifies of the error and dequeues", ->
@task.onOfflineError()
checkError.call(@)
expect(Actions.dequeueTask).toHaveBeenCalledWith(@task)

View file

@ -20,7 +20,7 @@ clipboard = require 'clipboard'
NamespaceStore = require "../src/flux/stores/namespace-store"
Contact = require '../src/flux/models/contact'
{ComponentRegistry} = require "nylas-exports"
{TaskQueue, ComponentRegistry} = require "nylas-exports"
atom.themes.loadBaseStylesheets()
atom.themes.requireStylesheet '../static/jasmine'
@ -101,6 +101,10 @@ beforeEach ->
Grim.clearDeprecations() if isCoreSpec
ComponentRegistry._clear()
TaskQueue._queue = []
TaskQueue._completed = []
TaskQueue._onlineStatus = true
$.fx.off = true
documentTitle = null
atom.packages.serviceHub = new ServiceHub

View file

@ -246,7 +246,7 @@ class Atom extends Model
# to prevent the developer tools from being shown
@emitter.emit('will-throw-error', eventObject)
if openDevTools
if openDevTools and @inDevMode()
@openDevTools()
@executeJavaScriptInDevTools('InspectorFrontendAPI.showConsole()')
@ -257,21 +257,17 @@ class Atom extends Model
# Since Bluebird is the promise library, we can properly report
# unhandled errors from business logic inside promises.
Promise.longStackTraces() unless @inSpecMode()
Promise.onPossiblyUnhandledRejection (error) =>
# In many cases, a promise will return a legitimate error which the receiver
# doesn't care to handle. The ones we want to surface are core javascript errors:
# Syntax problems, type errors, etc. If we didn't catch them here, these issues
# (usually inside then() blocks) would be hard to track down.
return unless (error instanceof TypeError or
error instanceof SyntaxError or
error instanceof RangeError or
error instanceof ReferenceError)
Promise.onPossiblyUnhandledRejection (error) =>
error.stack = convertStackTrace(error.stack, sourceMapCache)
eventObject = {message: error.message, originalError: error}
if @inSpecMode()
console.warn(error.stack)
console.error(error.stack)
else if @inDevMode()
console.error(error.message, error.stack, error)
@openDevTools()
@executeJavaScriptInDevTools('InspectorFrontendAPI.showConsole()')
else
console.warn(error)
console.warn(error.stack)

View file

@ -95,7 +95,6 @@ class Actions
@fileUploaded: ActionScopeGlobal
@attachFileComplete: ActionScopeGlobal
@multiWindowNotification: ActionScopeGlobal
@sendDraftError: ActionScopeGlobal
@sendDraftSuccess: ActionScopeGlobal
@sendToAllWindows: ActionScopeGlobal

View file

@ -64,7 +64,6 @@ AnalyticsStore = Reflux.createStore
logout: -> {}
fileAborted: (uploadData={}) -> {fileSize: uploadData.fileSize}
fileUploaded: (uploadData={}) -> {fileSize: uploadData.fileSize}
sendDraftError: (dId, msg) -> {drafLocalId: dId, error: msg}
sendDraftSuccess: ({draftLocalId}) -> {draftLocalId: draftLocalId}
track: (action, data={}) ->

View file

@ -1,6 +1,9 @@
Message = require '../models/message'
Actions = require '../actions'
EventEmitter = require('events').EventEmitter
{Listener, Publisher} = require '../modules/reflux-coffee'
CoffeeHelpers = require '../coffee-helpers'
_ = require 'underscore-plus'
###
@ -64,21 +67,23 @@ that display Draft objects or allow for interactive editing of Drafts.
Section: Drafts
###
class DraftStoreProxy
@include: CoffeeHelpers.includeModule
@include Publisher
@include Listener
constructor: (@draftLocalId) ->
DraftStore = require './draft-store'
@unlisteners = []
@unlisteners.push DraftStore.listen(@_onDraftChanged, @)
@unlisteners.push Actions.didSwapModel.listen(@_onDraftSwapped, @)
@listenTo DraftStore, @_onDraftChanged
@listenTo Actions.didSwapModel, @_onDraftSwapped
@_emitter = new EventEmitter()
@_draft = false
@_draftPromise = null
@changes = new DraftChangeSet @draftLocalId, =>
if !@_draft
throw new Error("DraftChangeSet was modified before the draft was prepared.")
@_emitter.emit('trigger')
@trigger()
@prepare().catch (error) ->
console.error(error)
@ -101,26 +106,14 @@ class DraftStoreProxy
.catch(reject)
@_draftPromise
listen: (callback, bindContext) ->
eventHandler = (args) ->
callback.apply(bindContext, args)
@_emitter.addListener('trigger', eventHandler)
return =>
@_emitter.removeListener('trigger', eventHandler)
if @_emitter.listeners('trigger').length is 0
DraftStore = require './draft-store'
DraftStore.cleanupSessionForLocalId(@draftLocalId)
cleanup: ->
# Unlink ourselves from the stores/actions we were listening to
# so that we can be garbage collected
unlisten() for unlisten in @unlisteners
@stopListeningToAll()
_setDraft: (draft) ->
if !draft.body?
throw new Error("DraftStoreProxy._setDraft - new draft has no body!")
@_draft = draft
@_emitter.emit('trigger')
@trigger()
_onDraftChanged: (change) ->
return if not change?

View file

@ -16,6 +16,8 @@ Message = require '../models/message'
MessageUtils = require '../models/message-utils'
Actions = require '../actions'
TaskQueue = require './task-queue'
{subjectWithPrefix} = require '../models/utils'
{Listener, Publisher} = require '../modules/reflux-coffee'
@ -50,19 +52,18 @@ class DraftStore
atom.commands.add 'body',
'application:new-message': => @_onPopoutBlankDraft()
# Remember that these two actions only fire in the current window and
# are picked up by the instance of the DraftStore in the current
# window.
@listenTo Actions.sendDraft, @_onSendDraft
@listenTo Actions.destroyDraft, @_onDestroyDraft
@listenTo Actions.removeFile, @_onRemoveFile
@listenTo Actions.attachFileComplete, @_onAttachFileComplete
@listenTo Actions.sendDraftError, @_onSendDraftError
@listenTo Actions.sendDraftSuccess, @_onSendDraftSuccess
atom.onBeforeUnload @_onBeforeUnload
@_draftSessions = {}
@_sendingState = {}
@_extensions = []
ipc.on 'mailto', @_onHandleMailtoLink
@ -86,7 +87,7 @@ class DraftStore
# - `localId` The {String} local ID of the draft.
#
# Returns a {Promise} that resolves to an {DraftStoreProxy} for the
# draft:
# draft once it has been prepared:
sessionForLocalId: (localId) =>
if not localId
console.log((new Error).stack)
@ -95,7 +96,15 @@ class DraftStore
@_draftSessions[localId].prepare()
# Public: Look up the sending state of the given draft Id.
sendingState: (draftLocalId) -> @_sendingState[draftLocalId] ? false
# In popout windows the existance of the window is the sending state.
isSendingDraft: (draftLocalId) ->
if atom.isMainWindow()
task = TaskQueue.findTask
object: "SendDraftTask"
matchKey: "draftLocalId"
matchValue: draftLocalId
return task?
else return false
###
Composer Extensions
@ -123,29 +132,9 @@ class DraftStore
########### PRIVATE ####################################################
cleanupSessionForLocalId: (localId) =>
session = @_draftSessions[localId]
return unless session
draft = session.draft()
Actions.queueTask(new DestroyDraftTask(localId)) if draft.pristine
if atom.getWindowType() is "composer"
# Sometimes we swap out one ID for another. In that case we don't
# want to close while it's swapping. We are using a defer here to
# give the swap code time to put the new ID in the @_draftSessions.
#
# This defer hack prevents us from having to pass around a lock or a
# parameter through functions who may do this in other parts of the
# application.
_.defer =>
if Object.keys(@_draftSessions).length is 0
atom.close()
if atom.isMainWindow()
session.cleanup()
delete @_draftSessions[localId]
_doneWithSession: (session) ->
session.cleanup()
delete @_draftSessions[session.draftLocalId]
_onBeforeUnload: =>
promises = []
@ -357,46 +346,49 @@ class DraftStore
DatabaseStore.localIdForModel(draft).then(@_onPopoutDraftLocalId)
_onDestroyDraft: (draftLocalId) =>
session = @_draftSessions[draftLocalId]
if not session
throw new Error("Couldn't find the draft session in the current window")
# Immediately reset any pending changes so no saves occur
@_draftSessions[draftLocalId]?.changes.reset()
session.changes.reset()
# Queue the task to destroy the draft
Actions.queueTask(new DestroyDraftTask(draftLocalId))
# Clean up the draft session
@cleanupSessionForLocalId(draftLocalId)
@_doneWithSession(session)
atom.close() if @_isPopout()
# The user request to send the draft
_onSendDraft: (draftLocalId) =>
new Promise (resolve, reject) =>
@_sendingState[draftLocalId] = true
@trigger()
@sessionForLocalId(draftLocalId).then (session) =>
@_runExtensionsBeforeSend(session)
@sessionForLocalId(draftLocalId).then (session) =>
# Give third-party plugins an opportunity to sanitize draft data
for extension in @_extensions
continue unless extension.finalizeSessionBeforeSending
extension.finalizeSessionBeforeSending(session)
# Immediately save any pending changes so we don't save after sending
session.changes.commit().then =>
# Immediately save any pending changes so we don't save after sending
session.changes.commit().then =>
# Queue the task to send the draft
fromPopout = atom.getWindowType() is "composer"
Actions.queueTask(new SendDraftTask(draftLocalId, fromPopout: fromPopout))
task = new SendDraftTask draftLocalId, {fromPopout: @_isPopout()}
Actions.queueTask(task)
# Clean up session, close window
@cleanupSessionForLocalId(draftLocalId)
# As far as this window is concerned, we're not making any more
# edits and are destroying the session. If there are errors down
# the line, we'll make a new session and handle them later
@_doneWithSession(session)
resolve()
atom.close() if @_isPopout()
_onSendDraftError: (draftLocalId, errorMessage) ->
@_sendingState[draftLocalId] = false
if atom.getWindowType() is "composer"
@_onPopoutDraftLocalId(draftLocalId, {errorMessage})
@trigger()
_onSendDraftSuccess: ({draftLocalId}) =>
@_sendingState[draftLocalId] = false
@trigger()
_isPopout: ->
atom.getWindowType() is "composer"
# Give third-party plugins an opportunity to sanitize draft data
_runExtensionsBeforeSend: (session) ->
for extension in @_extensions
continue unless extension.finalizeSessionBeforeSending
extension.finalizeSessionBeforeSending(session)
_onAttachFileComplete: ({file, messageLocalId}) =>
@sessionForLocalId(messageLocalId).then (session) ->

View file

@ -100,6 +100,12 @@ class TaskQueue
performedRemote: false
notifiedOffline: false
findTask: ({object, matchKey, matchValue}) ->
for other in @_queue by -1
if object is object and other[matchKey] is matchValue
return other
return null
enqueue: (task, {silent}={}) =>
if not (task instanceof Task)
throw new Error("You must queue a `Task` object")
@ -111,6 +117,8 @@ class TaskQueue
dequeue: (taskOrId={}, {silent}={}) =>
task = @_parseArgs(taskOrId)
if not task
throw new Error("Couldn't find task in queue to dequeue")
task.queueState.isProcessing = false
task.cleanup()
@ -125,14 +133,12 @@ class TaskQueue
@_update()
dequeueMatching: (task) =>
identifier = task.matchKey
propValue = task.matchValue
toDequeue = @findTask(task)
for other in @_queue by -1
if task.object == task.object
if other[identifier] == propValue
@dequeue(other, silent: true)
if not toDequeue
console.warn("Could not find task: #{task?.object}", task)
@dequeue(toDequeue, silent: true)
@_update()
clearCompleted: =>
@ -213,8 +219,6 @@ class TaskQueue
task = _.find @_queue, (task) -> task is taskOrId
else
task = _.findWhere(@_queue, id: taskOrId)
if not task?
throw new Error("Can't find task #{taskOrId}")
return task
_moveToCompleted: (task) =>
@ -237,7 +241,6 @@ class TaskQueue
if not atom.inSpecMode()
console.log("Queue deserialization failed with error: #{e.toString()}")
# It's very important that we debounce saving here. When the user bulk-archives
# items, they can easily process 1000 tasks at the same moment. We can't try to
# save 1000 times! (Do not remove debounce without a plan!)

View file

@ -37,30 +37,34 @@ class SendDraftTask extends Task
# The draft may have been deleted by another task. Nothing we can do.
return reject(new Error("We couldn't find the saved draft.")) unless draft
if draft.isSaved()
body =
draft_id: draft.id
version: draft.version
else
# Pass joined:true so the draft body is included
body = draft.toJSON(joined: true)
NylasAPI.makeRequest
path: "/n/#{draft.namespaceId}/send"
method: 'POST'
body: body
body: @_prepareBody(draft)
returnsModel: true
success: (newMessage) =>
newMessage = (new Message).fromJSON(newMessage)
atom.playSound('mail_sent.ogg')
Actions.postNotification({message: "Sent!", type: 'success'})
Actions.sendDraftSuccess
draftLocalId: @draftLocalId
newMessage: newMessage
DatabaseStore.unpersistModel(draft).then(resolve)
success: @_onSendDraftSuccess(draft, resolve, reject)
error: reject
.catch(reject)
_prepareBody: (draft) ->
if draft.isSaved()
body =
draft_id: draft.id
version: draft.version
else
# Pass joined:true so the draft body is included
body = draft.toJSON(joined: true)
return body
_onSendDraftSuccess: (draft, resolve, reject) => (newMessage) =>
newMessage = (new Message).fromJSON(newMessage)
atom.playSound('mail_sent.ogg')
Actions.postNotification({message: "Sent!", type: 'success'})
Actions.sendDraftSuccess
draftLocalId: @draftLocalId
newMessage: newMessage
DatabaseStore.unpersistModel(draft).then(resolve).catch(reject)
onAPIError: (apiError) ->
msg = apiError.message ? "Our server is having problems. Your message has not been sent."
@_notifyError(msg)
@ -80,5 +84,6 @@ class SendDraftTask extends Task
Actions.dequeueTask(@)
_notifyError: (msg) ->
Actions.sendDraftError(@draftLocalId, msg)
@notifyErrorMessage(msg)
if @fromPopout
Actions.composePopoutDraft(@draftLocalId, {errorMessage: msg})

View file

@ -84,6 +84,9 @@ class Task
else if error instanceof OfflineError
@onOfflineError(error)
else
if error instanceof Error
console.error "Task #{@constructor.name} threw an unknown error: #{error.message}"
console.error error.stack
@onOtherError(error)
notifyErrorMessage: (msg) ->