feat(outbox): Sending status now appears beside drafts

Summary:
This diff adds an "OutboxStore" which reflects the TaskQueue and
adds a progress bar / cancel button to drafts which are currently sending.

- Sending state is different from things like Send later because drafts
  which are sending shouldn't be editable. You should have to stop them
  from sending before editing. I think we can implement "Send Later"
  indicators, etc. with a simple InjectedComponentSet on the draft list
  rows, but the OutboxStore is woven into the DraftList query subscription
  so every draft has a `uploadTaskId`.

- The TaskQueue now saves periodically (every one second) when there are
  "Processing" tasks. This is not really necessary, but makes it super
  easy for tasks to expose "progress", because they're essentially
  serialized and propagated to all windows every one second with the
  current progress value. Kind of questionable, but super convenient.

- I also cleaned up ListTabular and MultiselectList a bit because they
  applied the className prop to an inner element and not the top one.

- If a DestroyDraft task is created for a draft without a server id, it
  ends with Task.Status.Continue and not Failed.

- The SendDraftTask doesn't delete uploads until the send actually goes
  through, in case the app crashes and it forgets the file IDs it created.

Test Plan: Tests coming soon

Reviewers: juan, evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2524
This commit is contained in:
Ben Gotow 2016-02-04 14:14:24 -08:00
parent 40cb19d122
commit 8b3f7f0578
32 changed files with 391 additions and 149 deletions

View file

@ -15,7 +15,7 @@ idForCategories = (categories) ->
countForItem = (perspective) ->
unreadCountEnabled = NylasEnv.config.get('core.workspace.showUnreadForAllCategories')
if perspective.isInbox() or unreadCountEnabled
return perspective.threadUnreadCount()
return perspective.unreadCount()
return 0
isItemSelected = (perspective) ->

View file

@ -4,6 +4,7 @@ _ = require 'underscore'
AccountStore,
ThreadCountsStore,
WorkspaceStore,
OutboxStore,
FocusedPerspectiveStore,
CategoryStore} = require 'nylas-exports'
@ -42,6 +43,7 @@ class SidebarStore extends NylasStore
@listenTo AccountStore, @_onAccountsChanged
@listenTo FocusedPerspectiveStore, @_onFocusedPerspectiveChanged
@listenTo WorkspaceStore, @_updateSections
@listenTo OutboxStore, @_updateSections
@listenTo ThreadCountsStore, @_updateSections
@listenTo CategoryStore, @_updateSections

View file

@ -20,7 +20,7 @@ class DraftDeleteButton extends React.Component
_destroySelected: =>
for item in @props.selection.items()
Actions.queueTask(new DestroyDraftTask(draftClientId: item.clientId))
Actions.destroyDraft(item.clientId)
@props.selection.clear()
return

View file

@ -2,10 +2,17 @@ _ = require 'underscore'
React = require 'react'
classNames = require 'classnames'
{ListTabular, InjectedComponent} = require 'nylas-component-kit'
{ListTabular,
InjectedComponent,
Flexbox} = require 'nylas-component-kit'
{timestamp,
subject} = require './formatting-utils'
{Actions} = require 'nylas-exports'
SendingProgressBar = require './sending-progress-bar'
SendingCancelButton = require './sending-cancel-button'
snippet = (html) =>
return "" unless html and typeof(html) is 'string'
try
@ -16,17 +23,23 @@ snippet = (html) =>
catch
return ""
c1 = new ListTabular.Column
name: "Name"
ParticipantsColumn = new ListTabular.Column
name: "Participants"
width: 200
resolver: (draft) =>
<div className="participants">
<InjectedComponent matching={role:"Participants"}
exposedProps={participants: [].concat(draft.to, draft.cc, draft.bcc), clickable: false}/>
</div>
list = [].concat(draft.to, draft.cc, draft.bcc)
c2 = new ListTabular.Column
name: "Message"
if list.length > 0
<div className="participants">
{list.map (p) => <span key={p.email}>{p.displayName()}</span>}
</div>
else
<div className="participants no-recipients">
(No Recipients)
</div>
ContentsColumn = new ListTabular.Column
name: "Contents"
flex: 4
resolver: (draft) =>
attachments = []
@ -38,11 +51,16 @@ c2 = new ListTabular.Column
{attachments}
</span>
c3 = new ListTabular.Column
name: "Date"
flex: 1
SendStateColumn = new ListTabular.Column
name: "State"
resolver: (draft) =>
<span className="timestamp">{timestamp(draft.date)}</span>
if draft.uploadTaskId
<Flexbox style={width:150, whiteSpace: 'no-wrap'}>
<SendingProgressBar style={flex: 1, marginRight: 10} progress={draft.uploadProgress * 100} />
<SendingCancelButton taskId={draft.uploadTaskId} />
</Flexbox>
else
<span className="timestamp">{timestamp(draft.date)}</span>
module.exports =
Wide: [c1, c2, c3]
Wide: [ParticipantsColumn, ContentsColumn, SendStateColumn]

View file

@ -2,6 +2,8 @@ NylasStore = require 'nylas-store'
Rx = require 'rx-lite'
_ = require 'underscore'
{Message,
OutboxStore,
MutableQueryResultSet,
MutableQuerySubscription,
ObservableListDataSource,
FocusedPerspectiveStore,
@ -34,7 +36,28 @@ class DraftListStore extends NylasStore
.page(0, 1)
subscription = new MutableQuerySubscription(query, {asResultSet: true})
$resultSet = Rx.Observable.fromPrivateQuerySubscription('draft-list', subscription)
$resultSet = Rx.Observable.fromNamedQuerySubscription('draft-list', subscription)
$resultSet = Rx.Observable.combineLatest [
$resultSet,
Rx.Observable.fromStore(OutboxStore)
], (resultSet, outbox) =>
# Generate a new result set that includes additional information on
# the draft objects. This is similar to what we do in the thread-list,
# where we set thread.metadata to the message array.
resultSetWithTasks = new MutableQueryResultSet(resultSet)
mailboxPerspective.accountIds.forEach (aid) =>
OutboxStore.itemsForAccount(aid).forEach (task) =>
draft = resultSet.modelWithId(task.draft.clientId)
if draft
draft = draft.clone()
draft.uploadTaskId = task.id
draft.uploadProgress = task.progress
resultSetWithTasks.replaceModel(draft)
return resultSetWithTasks.immutableClone()
@_dataSource = new ObservableListDataSource($resultSet, subscription.replaceRange)
else
@_dataSource = new ListTabular.DataSource.Empty()

View file

@ -1,9 +1,7 @@
_ = require 'underscore'
React = require 'react'
{Actions,
FocusedContentStore} = require 'nylas-exports'
{ListTabular,
FluxContainer,
{Actions} = require 'nylas-exports'
{FluxContainer,
MultiselectList} = require 'nylas-component-kit'
DraftListStore = require './draft-list-store'
DraftListColumns = require './draft-list-columns'
@ -12,7 +10,6 @@ EmptyState = require './empty-state'
class DraftList extends React.Component
@displayName: 'DraftList'
@containerRequired: false
render: =>
@ -25,24 +22,28 @@ class DraftList extends React.Component
onDoubleClick={@_onDoubleClick}
emptyComponent={EmptyState}
keymapHandlers={@_keymapHandlers()}
itemPropsProvider={ -> {} }
itemPropsProvider={@_itemPropsProvider}
itemHeight={39}
className="draft-list" />
</FocusContainer>
</FluxContainer>
_itemPropsProvider: (draft) ->
props = {}
props.className = 'sending' if draft.uploadTaskId
props
_keymapHandlers: =>
'core:remove-from-view': @_onRemoveFromView
_onDoubleClick: (item) =>
Actions.composePopoutDraft(item.clientId)
_onDoubleClick: (draft) =>
unless draft.uploadTaskId
Actions.composePopoutDraft(draft.clientId)
# Additional Commands
_onRemoveFromView: =>
items = DraftListStore.dataSource().selection.items()
for item in items
Actions.destroyDraft(item.clientId)
drafts = DraftListStore.dataSource().selection.items()
Actions.destroyDraft(draft.clientId) for draft in drafts
module.exports = DraftList

View file

@ -0,0 +1,32 @@
React = require 'react'
{Actions} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
class SendingCancelButton extends React.Component
@displayName: 'SendingCancelButton'
@propTypes:
taskId: React.PropTypes.string.isRequired
constructor: (@props) ->
@state =
cancelling: false
render: =>
if @state.cancelling
<RetinaImg
style={width: 20, height: 20, marginTop: 2}
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve} />
else
<div onClick={@_onClick} style={marginTop: 1}>
<RetinaImg
name="image-cancel-button.png"
mode={RetinaImg.Mode.ContentPreserve} />
</div>
_onClick: =>
Actions.dequeueTask(@props.taskId)
@setState(cancelling: true)
module.exports = SendingCancelButton

View file

@ -0,0 +1,21 @@
React = require 'react'
_ = require 'underscore'
class SendingProgressBar extends React.Component
@propTypes:
progress: React.PropTypes.number.isRequired
render: ->
otherProps = _.omit(@props, _.keys(@constructor.propTypes))
if 0 < @props.progress < 99
<div className="sending-progress" {...otherProps}>
<div className="filled"
style={width:"#{Math.min(100, @props.progress)}%"}>
</div>
</div>
else
<div className="sending-progress" {...otherProps}>
<div className="indeterminate"></div>
</div>
module.exports = SendingProgressBar

View file

@ -102,7 +102,7 @@ c5 = new ListTabular.Column
children=
{[
<ThreadTrashQuickAction key="thread-trash-quick-action" thread={thread} />
<ThreadArchiveQuickAction key="thread-arhive-quick-action" thread={thread} />
<ThreadArchiveQuickAction key="thread-archive-quick-action" thread={thread} />
]}
matching={role: "ThreadListQuickAction"}
className="thread-injected-quick-actions"

View file

@ -63,12 +63,12 @@ _observableForThreadMessages = (id, initialModels) ->
asResultSet: true,
initialModels: initialModels
})
Rx.Observable.fromPrivateQuerySubscription('message-'+id, subscription)
Rx.Observable.fromNamedQuerySubscription('message-'+id, subscription)
class ThreadListDataSource extends ObservableListDataSource
constructor: (subscription) ->
$resultSetObservable = Rx.Observable.fromPrivateQuerySubscription('thread-list', subscription)
$resultSetObservable = Rx.Observable.fromNamedQuerySubscription('thread-list', subscription)
$resultSetObservable = _flatMapJoiningMessages($resultSetObservable)
super($resultSetObservable, subscription.replaceRange)

View file

@ -42,12 +42,11 @@
}
.thread-list, .draft-list {
order: 3;
flex: 1;
position:absolute;
width:100%;
height:100%;
-webkit-font-smoothing: subpixel-antialiased;
.list-container, .scroll-region {
width:100%;
height:100%;
-webkit-font-smoothing: subpixel-antialiased;
}
.list-item {
background-color: darken(@background-primary, 2%);
@ -88,6 +87,10 @@
overflow: hidden;
position: relative;
top:2px;
&.no-recipients {
color: @text-color-very-subtle;
}
}
.details {
@ -393,3 +396,61 @@ body.is-blurred {
}
}
}
@keyframes sending-progress-move {
0% {
background-position: 0 0;
}
100% {
background-position: 50px 50px;
}
}
.draft-list {
.sending {
background-color: @background-primary;
&:hover {
background-color: @background-primary;
}
}
.sending-progress {
display: block;
height:7px;
margin-top:10px;
background-color: @background-primary;
border-bottom:1px solid @border-color-divider;
position: relative;
.filled {
display: block;
background: @component-active-color;
height:6px;
width: 0px; //overridden by style
transition: width 1000ms linear;
}
.indeterminate {
display: block;
background: @component-active-color;
height:6px;
width: 100%;
}
.indeterminate:after {
content: "";
position: absolute;
top: 0; left: 0; bottom: 0; right: 0;
background-image: linear-gradient(
-45deg,
rgba(255, 255, 255, .2) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, .2) 50%,
rgba(255, 255, 255, .2) 75%,
transparent 75%,
transparent
);
background-size: 50px 50px;
animation: sending-progress-move 2s linear infinite;
}
}
}

View file

@ -11,7 +11,7 @@ describe "ListSelection", ->
@trigger = jasmine.createSpy('trigger')
@items = []
@items.push(new Thread(id: "#{ii}")) for ii in [0..99]
@items.push(new Thread(id: "#{ii}", clientId: "#{ii}")) for ii in [0..99]
@view = new ListDataSource()
@view.indexOfId = jasmine.createSpy('indexOfId').andCallFake (id) =>
@ -84,13 +84,13 @@ describe "ListSelection", ->
@selection.set([@items[2], @items[4], @items[7]])
expect(@selection.items()[0]).toBe(@items[2])
expect(@selection.items()[0].subject).toBe(undefined)
newItem2 = new Thread(id: '2', subject:'Hello world!')
newItem2 = new Thread(id: '2', clientId: '2', subject:'Hello world!')
@selection._applyChangeRecord({objectClass: 'Thread', objects: [newItem2], type: 'persist'})
expect(@selection.items()[0].subject).toBe('Hello world!')
it "should rremove items in the selection if type is unpersist", ->
@selection.set([@items[2], @items[4], @items[7]])
newItem2 = new Thread(id: '2', subject:'Hello world!')
newItem2 = new Thread(id: '2', clientId: '2', subject:'Hello world!')
@selection._applyChangeRecord({objectClass: 'Thread', objects: [newItem2], type: 'unpersist'})
expect(@selection.ids()).toEqual(['4', '7'])

View file

@ -634,12 +634,12 @@ describe "DraftStore", ->
}}
it "should return false and call window.close itself", ->
spyOn(NylasEnv, 'finishUnload')
expect(DraftStore._onBeforeUnload()).toBe(false)
expect(NylasEnv.finishUnload).not.toHaveBeenCalled()
callback = jasmine.createSpy('callback')
expect(DraftStore._onBeforeUnload(callback)).toBe(false)
expect(callback).not.toHaveBeenCalled()
@resolve()
advanceClock(1000)
expect(NylasEnv.finishUnload).toHaveBeenCalled()
expect(callback).toHaveBeenCalled()
describe "when drafts return immediately fulfilled commit promises", ->
beforeEach ->
@ -651,11 +651,11 @@ describe "DraftStore", ->
}}
it "should still wait one tick before firing NylasEnv.close again", ->
spyOn(NylasEnv, 'finishUnload')
expect(DraftStore._onBeforeUnload()).toBe(false)
expect(NylasEnv.finishUnload).not.toHaveBeenCalled()
callback = jasmine.createSpy('callback')
expect(DraftStore._onBeforeUnload(callback)).toBe(false)
expect(callback).not.toHaveBeenCalled()
advanceClock()
expect(NylasEnv.finishUnload).toHaveBeenCalled()
expect(callback).toHaveBeenCalled()
describe "when there are no drafts", ->
beforeEach ->

View file

@ -35,13 +35,13 @@ describe "TaskQueue", ->
it "should fetch the queue from the database, reset flags and start processing", ->
queue = [@processingTask, @unstartedTask]
spyOn(DatabaseStore, 'findJSONBlob').andCallFake => Promise.resolve(queue)
spyOn(TaskQueue, '_processQueue')
spyOn(TaskQueue, '_updateSoon')
waitsForPromise =>
TaskQueue._restoreQueue().then =>
expect(TaskQueue._queue).toEqual(queue)
expect(@processingTask.queueState.isProcessing).toEqual(false)
expect(TaskQueue._processQueue).toHaveBeenCalled()
expect(TaskQueue._updateSoon).toHaveBeenCalled()
describe "findTask", ->
beforeEach ->

View file

@ -216,14 +216,12 @@ describe "SendDraftTask", ->
expect(status[1]).toBe thrownError
expect(Actions.draftSendingFailed).toHaveBeenCalled()
it "notifies of a permanent error on timeouts", ->
it "retries on timeouts", ->
thrownError = new APIError(statusCode: NylasAPI.TimeoutErrorCode, body: "err")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
expect(status[0]).toBe Task.Status.Failed
expect(status[1]).toBe thrownError
expect(Actions.draftSendingFailed).toHaveBeenCalled()
expect(status).toBe Task.Status.Retry
describe "checking the promise chain halts on errors", ->
beforeEach ->

View file

@ -45,7 +45,7 @@ class ListSelection
return unless item
throw new Error("toggle must be called with a Model") unless item instanceof Model
without = _.reject @_items, (t) -> t.id is item.id
without = _.reject @_items, (t) -> t.clientId is item.clientId
if without.length < @_items.length
@_items = without
else
@ -56,7 +56,7 @@ class ListSelection
return unless item
throw new Error("add must be called with a Model") unless item instanceof Model
updated = _.reject @_items, (t) -> t.id is item.id
updated = _.reject @_items, (t) -> t.clientId is item.clientId
updated.push(item)
if updated.length isnt @_items.length
@_items = updated
@ -73,7 +73,7 @@ class ListSelection
itemIds = _.pluck(items, 'id')
without = _.reject @_items, (t) -> t.id in itemIds
without = _.reject @_items, (t) -> t.clientId in itemIds
if without.length < @_items.length
@_items = without
@trigger(@)
@ -97,12 +97,12 @@ class ListSelection
# items are in the _items array in the order they were selected.
# (important for walking)
relativeTo = @_items[@_items.length - 1]
startIdx = @_view.indexOfId(relativeTo.id)
endIdx = @_view.indexOfId(item.id)
startIdx = @_view.indexOfId(relativeTo.clientId)
endIdx = @_view.indexOfId(item.clientId)
return if startIdx is -1 or endIdx is -1
for idx in [startIdx..endIdx]
item = @_view.get(idx)
@_items = _.reject @_items, (t) -> t.id is item.id
@_items = _.reject @_items, (t) -> t.clientId is item.clientId
@_items.push(item)
@trigger()
@ -116,7 +116,7 @@ class ListSelection
ids = @ids()
noSelection = @_items.length is 0
neitherSelected = (not current or ids.indexOf(current.id) is -1) and (not next or ids.indexOf(next.id) is -1)
neitherSelected = (not current or ids.indexOf(current.clientId) is -1) and (not next or ids.indexOf(next.clientId) is -1)
if noSelection or neitherSelected
@_items.push(current) if current
@ -124,15 +124,15 @@ class ListSelection
else
selectionPostPopHeadId = null
if @_items.length > 1
selectionPostPopHeadId = @_items[@_items.length - 2].id
selectionPostPopHeadId = @_items[@_items.length - 2].clientId
if next.id is selectionPostPopHeadId
if next.clientId is selectionPostPopHeadId
@_items.pop()
else
# Important: As you walk over this item, remove it and re-push it on the selected
# array even if it's already there. That way, the items in _items are always
# in the order you walked over them, and you can walk back to deselect them.
@_items = _.reject @_items, (t) -> t.id is next.id
@_items = _.reject @_items, (t) -> t.clientId is next.clientId
@_items.push(next)
@trigger()
@ -147,7 +147,7 @@ class ListSelection
touched = 0
for newer in change.objects
for existing, idx in @_items
if existing.id is newer.id
if existing.clientId is newer.clientId
@_items[idx] = newer
touched += 1
break

View file

@ -40,12 +40,12 @@ class ListTabular extends React.Component
componentWillReceiveProps: (nextProps) =>
if nextProps.dataSource isnt @props.dataSource
@setupDataSource(nextProps.dataSource)
@setState(@buildStateForRange(dataSource: nextProps.dataSource))
setupDataSource: (dataSource) =>
@_unlisten?()
@_unlisten = dataSource.listen =>
@setState(@buildStateForRange())
@setState(@buildStateForRange(dataSource: dataSource))
buildStateForRange: ({dataSource, start, end} = {}) =>
start ?= @state.renderedRangeStart
@ -117,12 +117,11 @@ class ListTabular extends React.Component
if @props.emptyComponent
emptyElement = <@props.emptyComponent visible={@state.loaded and @state.empty} />
<div className={@props.className}>
<div className="list-container list-tabular #{@props.className}">
<ScrollRegion
ref="container"
onScroll={@onScroll}
tabIndex="-1"
className="list-container list-tabular"
scrollTooltipComponent={@props.scrollTooltipComponent}>
<div className="list-rows" style={innerStyles} {...otherProps}>
{@_rows()}

View file

@ -112,10 +112,9 @@ class MultiselectList extends React.Component
props['data-item-id'] = item.id
props
<KeyCommandsRegion globalHandlers={@_globalKeymapHandlers()} className="multiselect-list">
<KeyCommandsRegion globalHandlers={@_globalKeymapHandlers()} className={className}>
<ListTabular
ref="list"
className={className}
columns={@state.computedColumns}
dataSource={@props.dataSource}
itemPropsProvider={@itemPropsProvider}

View file

@ -40,9 +40,12 @@ class ActionBridge
constructor: (ipc) ->
@ipc = ipc
@ipcLastSendTime = null
@initiatorId = NylasEnv.getWindowType()
@role = if NylasEnv.isWorkWindow() then Role.WORK else Role.SECONDARY
NylasEnv.onBeforeUnload(@onBeforeUnload)
# Listen for action bridge messages from other windows
@ipc.on('action-bridge-message', @onIPCMessage)
@ -104,6 +107,16 @@ class ActionBridge
console.debug(printToConsole, "ActionBridge: #{@initiatorId} Action Bridge Broadcasting: #{name}")
@ipc.send("action-bridge-rebroadcast-to-#{target}", @initiatorId, name, json)
@ipcLastSendTime = Date.now()
onBeforeUnload: (readyToUnload) =>
# Unfortunately, if you call ipc.send and then immediately close the window,
# Electron won't actually send the message. To work around this, we wait an
# arbitrary amount of time before closing the window after the last IPC event
# was sent. https://github.com/atom/electron/issues/4366
if @ipcLastSendTime and Date.now() - @ipcLastSendTime < 100
setTimeout(readyToUnload, 100)
return false
return true
module.exports = ActionBridge

View file

@ -35,11 +35,12 @@ class QueryResultSet
set
constructor: (other = {}) ->
@_modelsHash = other._modelsHash ? {}
@_offset = other._offset ? null
@_query = other._query ? null
@_ids = other._ids ? []
@_idToIndexHash = other._idToIndexHash ? null
# Clone, since the others may be frozen
@_modelsHash = Object.assign({}, other._modelsHash ? {})
@_ids = [].concat(other._ids ? [])
clone: ->
new @constructor({

View file

@ -8,6 +8,7 @@ DraftStoreProxy = require './draft-store-proxy'
DatabaseStore = require './database-store'
AccountStore = require './account-store'
ContactStore = require './contact-store'
TaskQueueStatusStore = require './task-queue-status-store'
FocusedPerspectiveStore = require './focused-perspective-store'
FocusedContentStore = require './focused-content-store'
@ -161,7 +162,7 @@ class DraftStore
for draftClientId, session of @_draftSessions
@_doneWithSession(session)
_onBeforeUnload: =>
_onBeforeUnload: (readyToUnload) =>
promises = []
# Normally we'd just append all promises, even the ones already
@ -180,7 +181,7 @@ class DraftStore
# handler, so we need to always defer by one tick before re-firing close.
Promise.settle(promises).then =>
@_draftSessions = {}
NylasEnv.finishUnload()
readyToUnload()
# Stop and wait before closing
return false
@ -487,6 +488,11 @@ class DraftStore
if session
@_doneWithSession(session)
# Stop any pending SendDraftTasks
for task in TaskQueueStatusStore.queue()
if task instanceof SendDraftTask and task.draft.clientId is draftClientId
Actions.dequeueTask(task.id)
# Queue the task to destroy the draft
Actions.queueTask(new DestroyDraftTask(draftClientId: draftClientId))

View file

@ -1,11 +1,5 @@
_ = require 'underscore'
Rx = require 'rx-lite'
DatabaseStore = require './database-store'
Message = require '../models/message'
QuerySubscriptionPool = require '../models/query-subscription-pool'
QuerySubscription = require '../models/query-subscription'
MutableQuerySubscription = require '../models/mutable-query-subscription'
{ListTabular} = require 'nylas-component-kit'
###

View file

@ -0,0 +1,27 @@
import _ from 'underscore';
import NylasStore from 'nylas-store';
import SendDraftTask from '../tasks/send-draft';
import TaskQueueStatusStore from './task-queue-status-store';
class OutboxStore extends NylasStore {
constructor() {
super();
this.listenTo(TaskQueueStatusStore, this._populate);
this._populate();
}
_populate() {
this._tasks = TaskQueueStatusStore.queue().filter((task)=> {
return task instanceof SendDraftTask;
});
this.trigger();
}
itemsForAccount(accountId) {
return this._tasks.filter((task)=> {
return task.draft.accountId === accountId;
});
}
}
module.exports = new OutboxStore();

View file

@ -73,6 +73,7 @@ class TaskQueue
constructor: ->
@_queue = []
@_completed = []
@_updatePeriodicallyTimeout = null
@_restoreQueue()
@ -169,12 +170,18 @@ class TaskQueue
# Helper Methods
_processQueue: =>
started = 0
for task in @_queue by -1
if @_taskIsBlocked(task)
task.queueState.debugStatus = Task.DebugStatus.WaitingOnDependency
continue
else
@_processTask(task)
started += 1
if started > 0
@trigger()
_processTask: (task) =>
return if task.queueState.isProcessing
@ -267,7 +274,7 @@ class TaskQueue
task.queueState ?= {}
task.queueState.isProcessing = false
@_queue = queue
@_processQueue()
@_updateSoon()
_updateSoon: =>
@_updateSoonThrottled ?= _.throttle =>
@ -275,9 +282,24 @@ class TaskQueue
t.persistJSONBlob(JSONBlobStorageKey, @_queue ? [])
_.defer =>
@_processQueue()
@trigger()
@_ensurePeriodicUpdates()
, 10
@_updateSoonThrottled()
_ensurePeriodicUpdates: =>
anyIsProcessing = _.any @_queue, (task) -> task.queueState.isProcessing
# The task queue triggers periodically as tasks are processed, even if no
# major events have occurred. This allows tasks which have state, like
# SendDraftTask.progress to be propogated through the app and inspected.
if anyIsProcessing and not @_updatePeriodicallyTimeout
@_updatePeriodicallyTimeout = setInterval =>
@_updateSoon()
, 1000
else if not anyIsProcessing and @_updatePeriodicallyTimeout
clearTimeout(@_updatePeriodicallyTimeout)
@_updatePeriodicallyTimeout = null
module.exports = new TaskQueue()
module.exports.JSONBlobStorageKey = JSONBlobStorageKey

View file

@ -7,7 +7,7 @@ ThreadCountsStore = require './thread-counts-store'
class UnreadBadgeStore extends NylasStore
constructor: ->
@_count = FocusedPerspectiveStore.current().threadUnreadCount()
@_count = FocusedPerspectiveStore.current().unreadCount()
@listenTo FocusedPerspectiveStore, @_updateCount
@listenTo ThreadCountsStore, @_updateCount
@ -26,7 +26,7 @@ class UnreadBadgeStore extends NylasStore
_updateCount: =>
current = FocusedPerspectiveStore.current()
if current.isInbox()
count = current.threadUnreadCount()
count = current.unreadCount()
return if @_count is count
@_count = count
@_setBadgeForCount()

View file

@ -10,30 +10,22 @@ SendDraftTask = require './send-draft'
module.exports =
class DestroyDraftTask extends Task
constructor: ({@draftClientId, @draftId} = {}) -> super
constructor: ({@draftClientId} = {}) ->
super
shouldDequeueOtherTask: (other) ->
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)
else if @draftId
(other instanceof DestroyDraftTask and other.draftClientId is @draftClientId)
else
false
(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)
isDependentTask: (other) ->
(other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId)
performLocal: ->
if @draftClientId
find = DatabaseStore.findBy(Message, clientId: @draftClientId)
else if @draftId
find = DatabaseStore.find(Message, @draftId)
else
unless @draftClientId
return Promise.reject(new Error("Attempt to call DestroyDraftTask.performLocal without draftClientId"))
find.include(Message.attributes.body).then (draft) =>
DatabaseStore.findBy(Message, clientId: @draftClientId).include(Message.attributes.body).then (draft) =>
return Promise.resolve() unless draft
@draft = draft
DatabaseStore.inTransaction (t) =>
@ -47,7 +39,10 @@ class DestroyDraftTask extends Task
err = new Error("No valid draft to destroy!")
return Promise.resolve([Task.Status.Failed, err])
if not @draft.serverId or not @draft.version?
if not @draft.serverId
return Promise.resolve(Task.Status.Continue)
if not @draft.version?
err = new Error("Can't destroy draft without a version or serverId")
return Promise.resolve([Task.Status.Failed, err])

View file

@ -26,11 +26,14 @@ class MultiRequestProgressMonitor
delete @_requests[filepath]
delete @_expected[filepath]
requests: =>
_.values(@_requests)
value: =>
sent = 0
expected = 1
for filepath, req of @_requests
sent += @req?.req?.connection?._bytesDispatched ? 0
for filepath, request of @_requests
sent += request.req?.connection?._bytesDispatched ? 0
expected += @_expected[filepath]
return sent / expected
@ -39,6 +42,7 @@ module.exports =
class SendDraftTask extends Task
constructor: (@draft) ->
@uploaded = []
super
label: ->
@ -75,15 +79,29 @@ class SendDraftTask extends Task
Promise.resolve()
performRemote: ->
@_uploadAttachments()
.then(@_sendAndCreateMessage)
.then(@_deleteRemoteDraft)
.then(@_onSuccess)
.catch(@_onError)
@_uploadAttachments().then =>
return Promise.resolve(Task.Status.Continue) if @_cancelled
@_sendAndCreateMessage()
.then(@_deleteRemoteDraft)
.then(@_onSuccess)
.catch(@_onError)
cancel: =>
# Note that you can only cancel during the uploadAttachments phase. Once
# we hit sendAndCreateMessage, nothing checks the cancelled bit and
# performRemote will continue through to success.
@_cancelled = true
for request in @_attachmentUploadsMonitor.requests()
request.abort()
@
_uploadAttachments: =>
progress = new MultiRequestProgressMonitor()
Object.defineProperty(@, 'progress', { get: -> progress.value() })
@_attachmentUploadsMonitor = new MultiRequestProgressMonitor()
Object.defineProperty(@, 'progress', {
configurable: true,
enumerable: true,
get: => @_attachmentUploadsMonitor.value()
})
Promise.all @draft.uploads.map (upload) =>
{targetPath, size} = upload
@ -101,18 +119,20 @@ class SendDraftTask extends Task
json: false
formData: formData
started: (req) =>
progress.add(targetPath, size, req)
@_attachmentUploadsMonitor.add(targetPath, size, req)
timeout: 20 * 60 * 1000
.finally =>
progress.remove(targetPath)
@_attachmentUploadsMonitor.remove(targetPath)
.then (rawResponseString) =>
json = JSON.parse(rawResponseString)
file = (new File).fromJSON(json[0])
@uploaded.push(upload)
@draft.uploads.splice(@draft.uploads.indexOf(upload), 1)
@draft.files.push(file)
# Deletes the attachment from the uploads folder
Actions.attachmentUploaded(upload)
# Note: We don't actually delete uploaded files until send completes,
# because it's possible for the app to quit without saving state and
# need to re-upload the file.
_sendAndCreateMessage: =>
NylasAPI.makeRequest
@ -169,6 +189,10 @@ class SendDraftTask extends Task
Actions.sendDraftSuccess
draftClientId: @draft.clientId
# Delete attachments from the uploads folder
for upload in @uploaded
Actions.attachmentUploaded(upload)
# Play the sending sound
if NylasEnv.config.get("core.sending.sounds")
SoundRegistry.playSound('send')
@ -176,16 +200,12 @@ class SendDraftTask extends Task
return Promise.resolve(Task.Status.Success)
_onError: (err) =>
# OUTBOX COMING SOON!
msg = "Your message could not be sent. Check your network connection and try again."
if err instanceof APIError and err.statusCode is NylasAPI.TimeoutErrorCode
msg = "We lost internet connection just as we were trying to send your message! Please wait a little bit to see if it went through. If not, check your internet connection and try sending again."
Actions.draftSendingFailed
threadId: @draft.threadId
draftClientId: @draft.clientId,
errorMessage: msg
NylasEnv.reportError(err)
return Promise.resolve([Task.Status.Failed, err])
if err instanceof APIError and not (err.statusCode in NylasAPI.PermanentErrorCodes)
return Promise.resolve(Task.Status.Retry)
else
Actions.draftSendingFailed
threadId: @draft.threadId
draftClientId: @draft.clientId,
errorMessage: "Your message could not be sent. Check your network connection and try again."
NylasEnv.reportError(err)
return Promise.resolve([Task.Status.Failed, err])

View file

@ -54,6 +54,7 @@ class NylasExports
@load "DatabaseStore", 'flux/stores/database-store'
@load "DatabaseTransaction", 'flux/stores/database-transaction'
@load "QueryResultSet", 'flux/models/query-result-set'
@load "MutableQueryResultSet", 'flux/models/mutable-query-result-set'
@load "ObservableListDataSource", 'flux/stores/observable-list-data-source'
@load "QuerySubscription", 'flux/models/query-subscription'
@load "MutableQuerySubscription", 'flux/models/mutable-query-subscription'
@ -115,6 +116,7 @@ class NylasExports
# listen-only and not explicitly required from anywhere. Stores
# currently set themselves up on require.
@require "DraftStore", 'flux/stores/draft-store'
@require "OutboxStore", 'flux/stores/outbox-store'
@require "AccountStore", 'flux/stores/account-store'
@require "MessageStore", 'flux/stores/message-store'
@require "ContactStore", 'flux/stores/contact-store'

View file

@ -93,7 +93,7 @@ Rx.Observable.fromQuery = (query) =>
observer.onNext(result)
return Rx.Disposable.create(unsubscribe)
Rx.Observable.fromPrivateQuerySubscription = (name, subscription) =>
Rx.Observable.fromNamedQuerySubscription = (name, subscription) =>
return Rx.Observable.create (observer) =>
unsubscribe = QuerySubscriptionPool.addPrivateSubscription name, subscription, (result) =>
observer.onNext(result)

View file

@ -4,6 +4,7 @@ TaskFactory = require './flux/tasks/task-factory'
AccountStore = require './flux/stores/account-store'
CategoryStore = require './flux/stores/category-store'
DatabaseStore = require './flux/stores/database-store'
OutboxStore = require './flux/stores/outbox-store'
SearchSubscription = require './search-subscription'
ThreadCountsStore = require './flux/stores/thread-counts-store'
MutableQuerySubscription = require './flux/models/mutable-query-subscription'
@ -67,7 +68,7 @@ class MailboxPerspective
threads: =>
throw new Error("threads: Not implemented in base class.")
threadUnreadCount: =>
unreadCount: =>
0
# Public:
@ -149,6 +150,11 @@ class DraftsMailboxPerspective extends MailboxPerspective
threads: =>
null
unreadCount: =>
count = 0
count += OutboxStore.itemsForAccount(aid).length for aid in @accountIds
count
canReceiveThreads: =>
false
@ -233,7 +239,7 @@ class CategoryMailboxPerspective extends MailboxPerspective
return new MutableQuerySubscription(query, {asResultSet: true})
threadUnreadCount: =>
unreadCount: =>
sum = 0
for cat in @_categories
sum += ThreadCountsStore.unreadCountForCategoryId(cat.id)

View file

@ -189,6 +189,8 @@ class NylasEnvConstructor extends Model
if event.binding.command.indexOf('application:') is 0 and event.binding.selector.indexOf("body") is 0
ipcRenderer.send('command', event.binding.command)
@windowEventHandler = new WindowEventHandler
unless @inSpecMode()
@actionBridge = new ActionBridge(ipcRenderer)
@ -210,7 +212,6 @@ class NylasEnvConstructor extends Model
@spellchecker = require('./nylas-spellchecker')
@subscribe @packages.onDidActivateInitialPackages => @watchThemes()
@windowEventHandler = new WindowEventHandler
# This ties window.onerror and Promise.onPossiblyUnhandledRejection to
# the publically callable `reportError` method. This will take care of
@ -938,16 +939,6 @@ class NylasEnvConstructor extends Model
onBeforeUnload: (callback) ->
@windowEventHandler.addUnloadCallback(callback)
# Call this method to resume the close / quit process if you returned
# false from a onBeforeUnload handler.
#
finishUnload: ->
_.defer =>
if remote.getGlobal('application').quitting
remote.require('app').quit()
else
@close()
enhanceEventObject: ->
overriddenStop = Event::stopPropagation
Event::stopPropagation = ->

View file

@ -116,16 +116,27 @@ class WindowEventHandler
@unloadCallbacks.push(callback)
runUnloadCallbacks: ->
continueUnload = true
unloadCallbacksRunning = 0
unloadCallbackComplete = =>
unloadCallbacksRunning -= 1
if unloadCallbacksRunning is 0
@runUnloadFinished()
for callback in @unloadCallbacks
returnValue = callback()
if returnValue is true
continue
else if returnValue is false
continueUnload = false
else
returnValue = callback(unloadCallbackComplete)
if returnValue is false
unloadCallbacksRunning += 1
else if returnValue isnt true
console.warn "You registered an `onBeforeUnload` callback that does not return either exactly `true` or `false`. It returned #{returnValue}", callback
return continueUnload
return (unloadCallbacksRunning > 0)
runUnloadFinished: ->
_.defer =>
if remote.getGlobal('application').quitting
remote.require('app').quit()
else
@close()
# Wire commands that should be handled by Chromium for elements with the
# `.override-key-bindings` class.