From 8b3f7f057865c9ed7b831a0904e2ded5d9886871 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Thu, 4 Feb 2016 14:14:24 -0800 Subject: [PATCH] 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 --- .../account-sidebar/lib/sidebar-item.coffee | 2 +- .../account-sidebar/lib/sidebar-store.coffee | 2 + .../thread-list/lib/draft-buttons.cjsx | 2 +- .../thread-list/lib/draft-list-columns.cjsx | 46 ++++++++---- .../thread-list/lib/draft-list-store.coffee | 25 ++++++- .../thread-list/lib/draft-list.cjsx | 25 ++++--- .../lib/sending-cancel-button.cjsx | 32 ++++++++ .../thread-list/lib/sending-progress-bar.cjsx | 21 ++++++ .../thread-list/lib/thread-list-columns.cjsx | 2 +- .../lib/thread-list-data-source.coffee | 4 +- .../thread-list/stylesheets/thread-list.less | 73 +++++++++++++++++-- spec/list-selection-spec.coffee | 6 +- spec/stores/draft-store-spec.coffee | 16 ++-- spec/stores/task-queue-spec.coffee | 4 +- spec/tasks/send-draft-spec.coffee | 6 +- src/components/list-selection.coffee | 22 +++--- src/components/list-tabular.cjsx | 5 +- src/components/multiselect-list.cjsx | 3 +- src/flux/action-bridge.coffee | 13 ++++ src/flux/models/query-result-set.coffee | 5 +- src/flux/stores/draft-store.coffee | 10 ++- .../stores/observable-list-data-source.coffee | 6 -- src/flux/stores/outbox-store.es6 | 27 +++++++ src/flux/stores/task-queue.coffee | 26 ++++++- src/flux/stores/unread-badge-store.coffee | 4 +- src/flux/tasks/destroy-draft.coffee | 27 +++---- src/flux/tasks/send-draft.coffee | 72 +++++++++++------- src/global/nylas-exports.coffee | 2 + src/global/nylas-observables.coffee | 2 +- src/mailbox-perspective.coffee | 10 ++- src/nylas-env.coffee | 13 +--- src/window-event-handler.coffee | 27 +++++-- 32 files changed, 391 insertions(+), 149 deletions(-) create mode 100644 internal_packages/thread-list/lib/sending-cancel-button.cjsx create mode 100644 internal_packages/thread-list/lib/sending-progress-bar.cjsx create mode 100644 src/flux/stores/outbox-store.es6 diff --git a/internal_packages/account-sidebar/lib/sidebar-item.coffee b/internal_packages/account-sidebar/lib/sidebar-item.coffee index 54b95884d..fa9e56146 100644 --- a/internal_packages/account-sidebar/lib/sidebar-item.coffee +++ b/internal_packages/account-sidebar/lib/sidebar-item.coffee @@ -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) -> diff --git a/internal_packages/account-sidebar/lib/sidebar-store.coffee b/internal_packages/account-sidebar/lib/sidebar-store.coffee index 1be62feac..84380a78d 100644 --- a/internal_packages/account-sidebar/lib/sidebar-store.coffee +++ b/internal_packages/account-sidebar/lib/sidebar-store.coffee @@ -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 diff --git a/internal_packages/thread-list/lib/draft-buttons.cjsx b/internal_packages/thread-list/lib/draft-buttons.cjsx index b616d6e60..9cb7a1f76 100644 --- a/internal_packages/thread-list/lib/draft-buttons.cjsx +++ b/internal_packages/thread-list/lib/draft-buttons.cjsx @@ -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 diff --git a/internal_packages/thread-list/lib/draft-list-columns.cjsx b/internal_packages/thread-list/lib/draft-list-columns.cjsx index 6725f8c5b..02f242c02 100644 --- a/internal_packages/thread-list/lib/draft-list-columns.cjsx +++ b/internal_packages/thread-list/lib/draft-list-columns.cjsx @@ -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) => -
- -
+ list = [].concat(draft.to, draft.cc, draft.bcc) -c2 = new ListTabular.Column - name: "Message" + if list.length > 0 +
+ {list.map (p) => {p.displayName()}} +
+ else +
+ (No Recipients) +
+ +ContentsColumn = new ListTabular.Column + name: "Contents" flex: 4 resolver: (draft) => attachments = [] @@ -38,11 +51,16 @@ c2 = new ListTabular.Column {attachments} -c3 = new ListTabular.Column - name: "Date" - flex: 1 +SendStateColumn = new ListTabular.Column + name: "State" resolver: (draft) => - {timestamp(draft.date)} + if draft.uploadTaskId + + + + + else + {timestamp(draft.date)} module.exports = - Wide: [c1, c2, c3] + Wide: [ParticipantsColumn, ContentsColumn, SendStateColumn] diff --git a/internal_packages/thread-list/lib/draft-list-store.coffee b/internal_packages/thread-list/lib/draft-list-store.coffee index 1e0c04f29..85057886a 100644 --- a/internal_packages/thread-list/lib/draft-list-store.coffee +++ b/internal_packages/thread-list/lib/draft-list-store.coffee @@ -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() diff --git a/internal_packages/thread-list/lib/draft-list.cjsx b/internal_packages/thread-list/lib/draft-list.cjsx index 0ab69d84c..129a00304 100644 --- a/internal_packages/thread-list/lib/draft-list.cjsx +++ b/internal_packages/thread-list/lib/draft-list.cjsx @@ -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" /> + _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 diff --git a/internal_packages/thread-list/lib/sending-cancel-button.cjsx b/internal_packages/thread-list/lib/sending-cancel-button.cjsx new file mode 100644 index 000000000..c93d1ad56 --- /dev/null +++ b/internal_packages/thread-list/lib/sending-cancel-button.cjsx @@ -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 + + else +
+ +
+ + _onClick: => + Actions.dequeueTask(@props.taskId) + @setState(cancelling: true) + +module.exports = SendingCancelButton diff --git a/internal_packages/thread-list/lib/sending-progress-bar.cjsx b/internal_packages/thread-list/lib/sending-progress-bar.cjsx new file mode 100644 index 000000000..cb1441293 --- /dev/null +++ b/internal_packages/thread-list/lib/sending-progress-bar.cjsx @@ -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 +
+
+
+
+ else +
+
+
+ +module.exports = SendingProgressBar diff --git a/internal_packages/thread-list/lib/thread-list-columns.cjsx b/internal_packages/thread-list/lib/thread-list-columns.cjsx index 765dc86aa..41da71248 100644 --- a/internal_packages/thread-list/lib/thread-list-columns.cjsx +++ b/internal_packages/thread-list/lib/thread-list-columns.cjsx @@ -102,7 +102,7 @@ c5 = new ListTabular.Column children= {[ - + ]} matching={role: "ThreadListQuickAction"} className="thread-injected-quick-actions" diff --git a/internal_packages/thread-list/lib/thread-list-data-source.coffee b/internal_packages/thread-list/lib/thread-list-data-source.coffee index 9578b6fba..f0bdd1f07 100644 --- a/internal_packages/thread-list/lib/thread-list-data-source.coffee +++ b/internal_packages/thread-list/lib/thread-list-data-source.coffee @@ -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) diff --git a/internal_packages/thread-list/stylesheets/thread-list.less b/internal_packages/thread-list/stylesheets/thread-list.less index 1a2ee767c..0149fb93c 100644 --- a/internal_packages/thread-list/stylesheets/thread-list.less +++ b/internal_packages/thread-list/stylesheets/thread-list.less @@ -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; + } + } +} diff --git a/spec/list-selection-spec.coffee b/spec/list-selection-spec.coffee index 628e4f377..c1eb905a9 100644 --- a/spec/list-selection-spec.coffee +++ b/spec/list-selection-spec.coffee @@ -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']) diff --git a/spec/stores/draft-store-spec.coffee b/spec/stores/draft-store-spec.coffee index b18c338a6..682fed840 100644 --- a/spec/stores/draft-store-spec.coffee +++ b/spec/stores/draft-store-spec.coffee @@ -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 -> diff --git a/spec/stores/task-queue-spec.coffee b/spec/stores/task-queue-spec.coffee index 1b39ad47b..daaf276f5 100644 --- a/spec/stores/task-queue-spec.coffee +++ b/spec/stores/task-queue-spec.coffee @@ -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 -> diff --git a/spec/tasks/send-draft-spec.coffee b/spec/tasks/send-draft-spec.coffee index 9180674a0..f7b8af81b 100644 --- a/spec/tasks/send-draft-spec.coffee +++ b/spec/tasks/send-draft-spec.coffee @@ -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 -> diff --git a/src/components/list-selection.coffee b/src/components/list-selection.coffee index 811242ff1..b61e1148f 100644 --- a/src/components/list-selection.coffee +++ b/src/components/list-selection.coffee @@ -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 diff --git a/src/components/list-tabular.cjsx b/src/components/list-tabular.cjsx index ee57ea701..cbfe606b2 100644 --- a/src/components/list-tabular.cjsx +++ b/src/components/list-tabular.cjsx @@ -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} /> -
+
{@_rows()} diff --git a/src/components/multiselect-list.cjsx b/src/components/multiselect-list.cjsx index 91da334a7..bad5485ef 100644 --- a/src/components/multiselect-list.cjsx +++ b/src/components/multiselect-list.cjsx @@ -112,10 +112,9 @@ class MultiselectList extends React.Component props['data-item-id'] = item.id props - + @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 diff --git a/src/flux/models/query-result-set.coffee b/src/flux/models/query-result-set.coffee index 8dc6af1e2..b4e7fd7d9 100644 --- a/src/flux/models/query-result-set.coffee +++ b/src/flux/models/query-result-set.coffee @@ -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({ diff --git a/src/flux/stores/draft-store.coffee b/src/flux/stores/draft-store.coffee index 7eb2a7341..72d76a679 100644 --- a/src/flux/stores/draft-store.coffee +++ b/src/flux/stores/draft-store.coffee @@ -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)) diff --git a/src/flux/stores/observable-list-data-source.coffee b/src/flux/stores/observable-list-data-source.coffee index c52261ff8..6dc1c0c54 100644 --- a/src/flux/stores/observable-list-data-source.coffee +++ b/src/flux/stores/observable-list-data-source.coffee @@ -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' ### diff --git a/src/flux/stores/outbox-store.es6 b/src/flux/stores/outbox-store.es6 new file mode 100644 index 000000000..b48ae5b2a --- /dev/null +++ b/src/flux/stores/outbox-store.es6 @@ -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(); diff --git a/src/flux/stores/task-queue.coffee b/src/flux/stores/task-queue.coffee index cb2fdf307..eb44f2f36 100644 --- a/src/flux/stores/task-queue.coffee +++ b/src/flux/stores/task-queue.coffee @@ -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 diff --git a/src/flux/stores/unread-badge-store.coffee b/src/flux/stores/unread-badge-store.coffee index ba4eb73a0..6b9eb1f25 100644 --- a/src/flux/stores/unread-badge-store.coffee +++ b/src/flux/stores/unread-badge-store.coffee @@ -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() diff --git a/src/flux/tasks/destroy-draft.coffee b/src/flux/tasks/destroy-draft.coffee index 211dd82a1..67cf4e34b 100644 --- a/src/flux/tasks/destroy-draft.coffee +++ b/src/flux/tasks/destroy-draft.coffee @@ -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]) diff --git a/src/flux/tasks/send-draft.coffee b/src/flux/tasks/send-draft.coffee index 3308663f7..ea2f2961d 100644 --- a/src/flux/tasks/send-draft.coffee +++ b/src/flux/tasks/send-draft.coffee @@ -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]) diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index 25017d539..94b4212f1 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -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' diff --git a/src/global/nylas-observables.coffee b/src/global/nylas-observables.coffee index c2323051c..dabe08c0d 100644 --- a/src/global/nylas-observables.coffee +++ b/src/global/nylas-observables.coffee @@ -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) diff --git a/src/mailbox-perspective.coffee b/src/mailbox-perspective.coffee index b1229c44f..9ce5991ed 100644 --- a/src/mailbox-perspective.coffee +++ b/src/mailbox-perspective.coffee @@ -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) diff --git a/src/nylas-env.coffee b/src/nylas-env.coffee index 77dc1403f..1a54aff7c 100644 --- a/src/nylas-env.coffee +++ b/src/nylas-env.coffee @@ -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 = -> diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee index 69e326803..ef7af6787 100644 --- a/src/window-event-handler.coffee +++ b/src/window-event-handler.coffee @@ -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.