feat(trash): Trash for Gmail, and architectural changes for common tasks

Summary:
This diff centralizes logic for creating common tasks for things like moving to trash, archive, etc. TaskFactory exposes a set of convenience methods and hides the whole "and also remove the current label" business from the user.

This diff also formally separates the concept of "moving to trash" and "archiving" so that "remove" isn't used in an unclear way.

I also refactored where selection is managed. Previously you'd fire some action like archiveSelection and it'd clear the selection, but if you selected some items and used another method to archive a few, they were still selected. The selection is now bound to the ModelView as intended, so if items are removed from the modelView, they are removed from it's attached selection. This means that it shouldn't /technically/ be possible to have selected items which are not in view.

I haven't refactored the tests yet. They are likely broken...

Fix next/prev logic

Test Plan: Run tests

Reviewers: evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2157
This commit is contained in:
Ben Gotow 2015-10-21 10:38:00 -07:00
parent f7c6ae0774
commit 4f34c8403f
30 changed files with 549 additions and 716 deletions

View file

@ -2,7 +2,7 @@ NylasStore = require 'nylas-store'
_ = require 'underscore'
_s = require 'underscore.string'
{Actions, CategoryStore, AccountStore, ChangeLabelsTask,
ChangeFolderTask, ArchiveThreadHelper, ChangeStarredTask,
ChangeFolderTask, TaskFactory, ChangeStarredTask,
ChangeUnreadTask, Utils} = require 'nylas-exports'
# The FiltersStore performs all business logic for filters: the single source
@ -90,25 +90,14 @@ class FiltersStore extends NylasStore
unread: false
threads: [thread]
else if action is "archive" and val is true
ArchiveThreadHelper.getArchiveTask [thread]
TaskFactory.taskForArchiving({threads: [thread]})
else if action is "star" and val is true
new ChangeStarredTask
starred: true
threads: [thread]
else if action is "delete" and val is true
trash = CategoryStore.getStandardCategory "trash"
TaskFactory.taskForMovingToTrash({threads: [thread]})
# Some email providers use labels, like Gmail, and others use folders,
# like Microsoft Exchange. Labels and folders behave very differently,
# so there are different Task classes to modify records for them.
if AccountStore.current().usesFolders()
new ChangeFolderTask
folder: trash
threads: [thread]
else
new ChangeLabelsTask
labelsToAdd: [trash]
threads: [thread]
.value()
_getPassedFilters: ({message, thread}) =>

View file

@ -7,12 +7,11 @@ React = require 'react'
Thread,
Actions,
TaskQueue,
TaskFactory,
AccountStore,
CategoryStore,
DatabaseStore,
WorkspaceStore,
ChangeLabelsTask,
ChangeFolderTask,
SyncbackCategoryTask,
TaskQueueStatusStore,
FocusedMailViewStore} = require 'nylas-exports'
@ -194,72 +193,36 @@ class CategoryPicker extends React.Component
return <span>{parts}</span>
_onSelectCategory: (item) =>
return unless @_threads().length > 0
threads = @_threads()
return unless threads.length > 0
return unless @_account
@refs.menu.setSelectedItem(null)
if @_account.usesLabels()
if item.newCategoryItem
cat = new Label
displayName: @state.searchValue,
accountId: AccountStore.current().id
task = new SyncbackCategoryTask
category: cat
organizationUnit: "label"
if item.newCategoryItem
category = new AccountStore.current().categoryClass()
displayName: @state.searchValue,
accountId: AccountStore.current().id
syncbackTask = new SyncbackCategoryTask({category})
TaskQueueStatusStore.waitForPerformRemote(syncbackTask).then =>
DatabaseStore.findBy(category.constructor, clientId: category.clientId).then (category) =>
applyTask = TaskFactory.taskForApplyingCategory
threads: threads
category: category
Actions.queueTask(applyTask)
Actions.queueTask(syncbackTask)
TaskQueueStatusStore.waitForPerformRemote(task).then =>
DatabaseStore.findBy Label, clientId: cat.clientId
.then (cat) =>
changeLabelsTask = new ChangeLabelsTask
labelsToAdd: [cat]
threads: @_threads()
Actions.queueTask(changeLabelsTask)
Actions.queueTask(task)
else if item.usage > 0
task = new ChangeLabelsTask
labelsToRemove: [item.category]
threads: @_threads()
Actions.queueTask(task)
else
task = new ChangeLabelsTask
labelsToAdd: [item.category]
threads: @_threads()
Actions.queueTask(task)
else if @_account.usesFolders()
if item.newCategoryItem?
cat = new Folder
displayName: @state?.searchValue,
accountId: AccountStore.current().id
task = new SyncbackCategoryTask
category: cat
organizationUnit: "folder"
TaskQueueStatusStore.waitForPerformRemote(task).then =>
DatabaseStore.findBy Folder, clientId: cat.clientId
.then (cat) =>
return if not cat?.serverId
changeFolderTask = new ChangeFolderTask
folder: cat
threads: @_threads()
if @props.thread
Actions.moveThread(@props.thread, changeFolderTask)
else if @props.items
Actions.moveThreads(@_threads(), changeFolderTask)
Actions.queueTask(task)
else
task = new ChangeFolderTask
folder: item.category
threads: @_threads()
if @props.thread
Actions.moveThread(@props.thread, task)
else if @props.items
Actions.moveThreads(@_threads(), task)
else if item.usage is threads.length
applyTask = TaskFactory.taskForRemovingCategory
threads: threads
category: item.category
Actions.queueTask(applyTask)
else
throw new Error("Invalid organizationUnit")
applyTask = TaskFactory.taskForApplyingCategory
threads: threads
category: item.category
Actions.queueTask(applyTask)
@refs.popover.close()

View file

@ -11,13 +11,12 @@ CategoryPicker = require '../lib/category-picker'
Actions,
CategoryStore,
DatabaseStore,
ChangeLabelsTask,
ChangeFolderTask,
TaskFactory,
SyncbackCategoryTask,
FocusedMailViewStore,
TaskQueueStatusStore} = require 'nylas-exports'
describe 'CategoryPicker', ->
fdescribe 'CategoryPicker', ->
beforeEach ->
CategoryStore._categoryCache = {}
@ -78,23 +77,6 @@ describe 'CategoryPicker', ->
expect(data[2].name).toBeUndefined()
expect(data[2].category).toBe @userCategory
xdescribe 'when picking for a single Thread', ->
it 'renders a picker', ->
expect(ReactTestUtils.isCompositeComponentWithType @picker, CategoryPicker).toBe true
it "does not include a newItem prompt if there's no search", ->
outData = @picker._recalculateState().categoryData
newItem = _.findWhere(outData, newCategoryItem: true)
l1 = _.findWhere(outData, id: 'id-123')
expect(newItem).toBeUndefined()
expect(l1.name).toBe "inbox"
it "includes a newItem selector with the current search term", ->
xdescribe 'when picking labels for a single Thread', ->
beforeEach ->
atom.testOrganizationUnit = "label"
describe "'create new' item", ->
beforeEach ->
setupForCreateNew.call @
@ -128,156 +110,74 @@ describe 'CategoryPicker', ->
expect(count).toBe 1
describe "_onSelectCategory()", ->
describe "using labels", ->
beforeEach ->
setupForCreateNew.call @, "folder"
spyOn(TaskFactory, 'taskForRemovingCategory').andCallThrough()
spyOn(TaskFactory, 'taskForApplyingCategory').andCallThrough()
spyOn(Actions, "queueTask")
it "closes the popover", ->
spyOn(@popover, "close")
@picker._onSelectCategory { usage: 0, category: "asdf" }
expect(@popover.close).toHaveBeenCalled()
describe "when selecting a category currently on all the selected items", ->
it "fires a task to remove the category", ->
input =
category: "asdf"
usage: 1
@picker._onSelectCategory(input)
expect(TaskFactory.taskForRemovingCategory).toHaveBeenCalledWith
threads: [@testThread]
category: "asdf"
expect(Actions.queueTask).toHaveBeenCalled()
describe "when selecting a category not on all the selected items", ->
it "fires a task to add the category", ->
input =
category: "asdf"
usage: 0
@picker._onSelectCategory(input)
expect(TaskFactory.taskForApplyingCategory).toHaveBeenCalledWith
threads: [@testThread]
category: "asdf"
expect(Actions.queueTask).toHaveBeenCalled()
describe "when selecting a new category", ->
beforeEach ->
setupForCreateNew.call @, "label"
spyOn Actions, "queueTask"
it "adds a label if it was previously unused", ->
input = { usage: 0, newCategoryItem: undefined, category: "asdf" }
@picker._onSelectCategory input
input =
newCategoryItem: true
@picker.setState(searchValue: "teSTing!")
@picker._onSelectCategory(input)
it "queues a new syncback task for creating a category", ->
expect(Actions.queueTask).toHaveBeenCalled()
labelsToAdd = Actions.queueTask.calls[0].args[0].labelsToAdd
expect(labelsToAdd.length).toBe 1
expect(labelsToAdd[0]).toEqual input.category
threadsToUpdate = Actions.queueTask.calls[0].args[0].threads
expect(threadsToUpdate).toEqual [ @testThread ]
it "removes a label if it was previously used", ->
input = { usage: 1, newCategoryItem: undefined, category: "asdf" }
@picker._onSelectCategory input
expect(Actions.queueTask).toHaveBeenCalled()
labelsToRemove = Actions.queueTask.calls[0].args[0].labelsToRemove
expect(labelsToRemove.length).toBe 1
expect(labelsToRemove[0]).toEqual input.category
threadsToUpdate = Actions.queueTask.calls[0].args[0].threads
expect(threadsToUpdate).toEqual [ @testThread ]
it "creates a new label task", ->
input = { newCategoryItem: true }
@picker.setState searchValue: "teSTing!"
@picker._onSelectCategory input
expect(Actions.queueTask).toHaveBeenCalled()
syncbackTask = Actions.queueTask.calls[0].args[0]
newCategory = syncbackTask.category
expect(syncbackTask.organizationUnit).toBe "label"
expect(newCategory.displayName).toBe "teSTing!"
expect(newCategory.accountId).toBe TEST_ACCOUNT_ID
it "queues a change label task after performRemote for creating it", ->
input = { newCategoryItem: true }
label = new Label(clientId: "local-123")
it "queues a task for applying the category after it has saved", ->
label = new Label(displayName: "teSTing!")
spyOn(TaskQueueStatusStore, "waitForPerformRemote").andCallFake (task) ->
expect(task instanceof SyncbackCategoryTask).toBe true
Promise.resolve()
spyOn(DatabaseStore, "findBy").andCallFake (klass, {clientId}) ->
expect(klass).toBe Label
expect(typeof clientId).toBe "string"
Promise.resolve label
expect(klass).toBe(Label)
expect(typeof clientId).toBe("string")
Promise.resolve(label)
waitsFor ->
Actions.queueTask.calls.length > 1
label = Actions.queueTask.calls[0].args[0].category
runs ->
@picker.setState searchValue: "teSTing!"
@picker._onSelectCategory input
waitsFor -> Actions.queueTask.calls.length > 1
runs ->
changeLabelsTask = Actions.queueTask.calls[1].args[0]
expect(changeLabelsTask instanceof ChangeLabelsTask).toBe true
expect(changeLabelsTask.labelsToAdd).toEqual [ label ]
expect(changeLabelsTask.threads).toEqual [ @testThread ]
it "doesn't queue any duplicate syncback tasks", ->
input = { newCategoryItem: true }
label = new Label(clientId: "local-123")
spyOn(TaskQueueStatusStore, "waitForPerformRemote").andCallFake (task) ->
expect(task instanceof SyncbackCategoryTask).toBe true
Promise.resolve()
spyOn(DatabaseStore, "findBy").andCallFake (klass, {clientId}) ->
expect(klass).toBe Label
expect(typeof clientId).toBe "string"
Promise.resolve label
runs ->
@picker.setState searchValue: "teSTing!"
@picker._onSelectCategory input
waitsFor -> Actions.queueTask.calls.length > 1
runs ->
allInputs = Actions.queueTask.calls.map (c) -> c.args[0]
syncbackTasks = allInputs.filter (i) -> i instanceof SyncbackCategoryTask
expect(syncbackTasks.length).toBe 1
describe "using folders", ->
beforeEach ->
setupForCreateNew.call @, "folder"
spyOn Actions, "queueTask"
spyOn Actions, "moveThread"
spyOn Actions, "moveThreads"
it "moves a thread if the component has one", ->
input = { category: "blah" }
@picker._onSelectCategory input
expect(Actions.moveThread).toHaveBeenCalled()
args = Actions.moveThread.calls[0].args
expect(args[0]).toEqual @testThread
expect(args[1].folder).toEqual input.category
expect(args[1].threads).toEqual [ @testThread ]
it "moves threads if the component has no thread but has items", ->
@picker = ReactTestUtils.renderIntoDocument(
<CategoryPicker items={[@testThread]} />
)
@popover = ReactTestUtils.findRenderedComponentWithType @picker, Popover
@popover.open()
input = { category: "blah" }
@picker._onSelectCategory input
expect(Actions.moveThreads).toHaveBeenCalled()
it "creates a new folder task", ->
input = { newCategoryItem: true }
folder = new Folder(clientId: "local-456", serverId: "yes.")
spyOn(TaskQueueStatusStore, "waitForPerformRemote").andCallFake (task) ->
expect(task instanceof SyncbackCategoryTask).toBe true
Promise.resolve()
spyOn(DatabaseStore, "findBy").andCallFake (klass, {clientId}) ->
expect(klass).toBe Folder
expect(typeof clientId).toBe "string"
Promise.resolve folder
runs ->
@picker.setState searchValue: "teSTing!"
@picker._onSelectCategory input
waitsFor -> Actions.moveThread.calls.length > 0
runs ->
changeFoldersTask = Actions.moveThread.calls[0].args[1]
expect(changeFoldersTask instanceof ChangeFolderTask).toBe true
expect(changeFoldersTask.folder).toEqual folder
expect(changeFoldersTask.threads).toEqual [ @testThread ]
it "closes the popover", ->
setupForCreateNew.call @, "folder"
spyOn @popover, "close"
spyOn Actions, "moveThread"
@picker._onSelectCategory { usage: 0, category: "asdf" }
expect(@popover.close).toHaveBeenCalled()
expect(TaskFactory.taskForApplyingCategory).toHaveBeenCalledWith
threads: [@testThread]
category: label
expect(TaskFactory.taskForApplyingCategory.callCount).toBe(1)

View file

@ -9,7 +9,8 @@ MessageToolbarItems = require "./message-toolbar-items"
SidebarContactList} = require "./sidebar-components"
ThreadStarButton = require './thread-star-button'
ThreadRemoveButton = require './thread-remove-button'
ThreadArchiveButton = require './thread-archive-button'
ThreadTrashButton = require './thread-trash-button'
ThreadToggleUnreadButton = require './thread-toggle-unread-button'
AutolinkerExtension = require './plugins/autolinker-extension'
@ -36,7 +37,10 @@ module.exports =
ComponentRegistry.register ThreadStarButton,
role: 'message:Toolbar'
ComponentRegistry.register ThreadRemoveButton,
ComponentRegistry.register ThreadArchiveButton,
role: 'message:Toolbar'
ComponentRegistry.register ThreadTrashButton,
role: 'message:Toolbar'
ComponentRegistry.register ThreadToggleUnreadButton,
@ -48,7 +52,8 @@ module.exports =
deactivate: ->
ComponentRegistry.unregister MessageList
ComponentRegistry.unregister ThreadStarButton
ComponentRegistry.unregister ThreadRemoveButton
ComponentRegistry.unregister ThreadArchiveButton
ComponentRegistry.unregister ThreadTrashButton
ComponentRegistry.unregister ThreadToggleUnreadButton
ComponentRegistry.unregister MessageToolbarItems
ComponentRegistry.unregister SidebarContactCard

View file

@ -0,0 +1,35 @@
_ = require 'underscore'
React = require 'react'
{RetinaImg} = require 'nylas-component-kit'
{Actions,
TaskFactory,
DOMUtils,
FocusedMailViewStore} = require 'nylas-exports'
class ThreadArchiveButton extends React.Component
@displayName: "ThreadArchiveButton"
@containerRequired: false
@propTypes:
thread: React.PropTypes.object.isRequired
render: =>
return false unless FocusedMailViewStore.mailView()?.canArchiveThreads()
<button className="btn btn-toolbar btn-archive"
style={order: -107}
data-tooltip="Archive"
onClick={@_onArchive}>
<RetinaImg name="toolbar-archive.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
_onArchive: (e) =>
return unless DOMUtils.nodeIsVisible(e.currentTarget)
task = TaskFactory.taskForArchiving
threads: [@props.thread],
fromView: FocusedMailViewStore.mailView()
Actions.queueTask(task)
e.stopPropagation()
module.exports = ThreadArchiveButton

View file

@ -1,37 +0,0 @@
_ = require 'underscore'
React = require 'react'
{Actions,
DOMUtils,
RemoveThreadHelper,
FocusedMailViewStore} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
class ThreadRemoveButton extends React.Component
@displayName: "ThreadRemoveButton"
@containerRequired: false
render: =>
focusedMailViewFilter = FocusedMailViewStore.mailView()
return false unless focusedMailViewFilter?.canRemoveThreads()
if RemoveThreadHelper.removeType() is RemoveThreadHelper.Type.Archive
tooltip = "Archive"
imgName = "toolbar-archive.png"
else if RemoveThreadHelper.removeType() is RemoveThreadHelper.Type.Trash
tooltip = "Trash"
imgName = "toolbar-trash.png"
<button className="btn btn-toolbar"
style={order: -106}
data-tooltip={tooltip}
onClick={@_onRemove}>
<RetinaImg name={imgName} mode={RetinaImg.Mode.ContentIsMask}/>
</button>
_onRemove: (e) =>
return unless DOMUtils.nodeIsVisible(e.currentTarget)
Actions.removeCurrentlyFocusedThread()
e.stopPropagation()
module.exports = ThreadRemoveButton

View file

@ -18,12 +18,11 @@ class ThreadToggleUnreadButton extends React.Component
</button>
_onClick: (e) =>
e.stopPropagation()
task = new ChangeUnreadTask
thread: @props.thread
unread: !@props.thread.unread
Actions.queueTask task
Actions.queueTask(task)
Actions.popSheet()
e.stopPropagation()
module.exports = ThreadToggleUnreadButton

View file

@ -0,0 +1,36 @@
_ = require 'underscore'
React = require 'react'
{Actions,
DOMUtils,
TaskFactory,
FocusedMailViewStore} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
class ThreadTrashButton extends React.Component
@displayName: "ThreadTrashButton"
@containerRequired: false
@propTypes:
thread: React.PropTypes.object.isRequired
render: =>
focusedMailViewFilter = FocusedMailViewStore.mailView()
return false unless focusedMailViewFilter?.canTrashThreads()
<button className="btn btn-toolbar"
style={order: -106}
data-tooltip="Move to Trash"
onClick={@_onRemove}>
<RetinaImg name="toolbar-trash.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
_onRemove: (e) =>
return unless DOMUtils.nodeIsVisible(e.currentTarget)
task = TaskFactory.taskForMovingToTrash
threads: [@props.thread],
fromView: FocusedMailViewStore.mailView()
Actions.queueTask(task)
e.stopPropagation()
module.exports = ThreadTrashButton

View file

@ -14,11 +14,13 @@ class DraftDeleteButton extends React.Component
<button style={order:-100}
className="btn btn-toolbar"
data-tooltip="Delete"
onClick={@_destroyDraft}>
onClick={@_destroySelected}>
<RetinaImg name="icon-composer-trash.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
_destroyDraft: =>
Actions.deleteSelection()
_destroySelected: =>
for item in @props.selection.items()
Actions.queueTask(new DestroyDraftTask(draftClientId: item.clientId))
@props.selection.clear()
module.exports = {DraftDeleteButton}

View file

@ -13,7 +13,6 @@ class DraftListStore extends NylasStore
constructor: ->
@listenTo DatabaseStore, @_onDataChanged
@listenTo AccountStore, @_onAccountChanged
@listenTo Actions.deleteSelection, @_onDeleteSelection
# It's important to listen to sendDraftSuccess because the
# _onDataChanged method will ignore our newly created draft because it
@ -52,12 +51,4 @@ class DraftListStore extends NylasStore
return unless containsDraft and @_view
@_view.invalidate()
_onDeleteSelection: =>
selected = @_view.selection.items()
for item in selected
Actions.queueTask(new DestroyDraftTask(draftClientId: item.clientId))
@_view.selection.clear()
module.exports = new DraftListStore()

View file

@ -2,7 +2,8 @@ _ = require 'underscore'
React = require "react"
{ComponentRegistry, WorkspaceStore} = require "nylas-exports"
{DownButton, UpButton, ThreadBulkRemoveButton, ThreadBulkStarButton, ThreadBulkToggleUnreadButton} = require "./thread-buttons"
{DownButton, UpButton, ThreadBulkArchiveButton, ThreadBulkTrashButton,
ThreadBulkStarButton, ThreadBulkToggleUnreadButton} = require "./thread-buttons"
{DraftDeleteButton} = require "./draft-buttons"
ThreadSelectionBar = require './thread-selection-bar'
ThreadList = require './thread-list'
@ -44,7 +45,10 @@ module.exports =
location: WorkspaceStore.Sheet.Thread.Toolbar.Right
modes: ['list']
ComponentRegistry.register ThreadBulkRemoveButton,
ComponentRegistry.register ThreadBulkArchiveButton,
role: 'thread:BulkAction'
ComponentRegistry.register ThreadBulkTrashButton,
role: 'thread:BulkAction'
ComponentRegistry.register ThreadBulkStarButton,
@ -61,7 +65,8 @@ module.exports =
ComponentRegistry.unregister DraftSelectionBar
ComponentRegistry.unregister ThreadList
ComponentRegistry.unregister ThreadSelectionBar
ComponentRegistry.unregister ThreadBulkRemoveButton
ComponentRegistry.unregister ThreadBulkArchiveButton
ComponentRegistry.unregister ThreadBulkTrashButton
ComponentRegistry.unregister ThreadBulkToggleUnreadButton
ComponentRegistry.unregister DownButton
ComponentRegistry.unregister UpButton

View file

@ -3,37 +3,57 @@ classNames = require 'classnames'
ThreadListStore = require './thread-list-store'
{RetinaImg} = require 'nylas-component-kit'
{Actions,
RemoveThreadHelper,
TaskFactory,
CategoryStore,
FocusedContentStore,
FocusedMailViewStore} = require "nylas-exports"
class ThreadBulkRemoveButton extends React.Component
@displayName: 'ThreadBulkRemoveButton'
class ThreadBulkArchiveButton extends React.Component
@displayName: 'ThreadBulkArchiveButton'
@containerRequired: false
@propTypes:
selection: React.PropTypes.object.isRequired
render: ->
focusedMailViewFilter = FocusedMailViewStore.mailView()
return false unless focusedMailViewFilter?.canRemoveThreads()
return false unless mailViewFilter?.canArchiveThreads()
if RemoveThreadHelper.removeType() is RemoveThreadHelper.Type.Archive
tooltip = "Archive"
imgName = "toolbar-archive.png"
else if RemoveThreadHelper.removeType() is RemoveThreadHelper.Type.Trash
tooltip = "Trash"
imgName = "toolbar-trash.png"
<button style={order:-107}
className="btn btn-toolbar"
data-tooltip="Archive"
onClick={@_onArchive}>
<RetinaImg name="toolbar-archive.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
_onArchive: =>
task = TaskFactory.taskForArchiving
threads: @props.selection.items(),
fromView: FocusedMailViewStore.mailView()
Actions.queueTask(task)
class ThreadBulkTrashButton extends React.Component
@displayName: 'ThreadBulkTrashButton'
@containerRequired: false
@propTypes:
selection: React.PropTypes.object.isRequired
render: ->
mailViewFilter = FocusedMailViewStore.mailView()
return false unless mailViewFilter?.canTrashThreads()
<button style={order:-106}
className="btn btn-toolbar"
data-tooltip={tooltip}
data-tooltip="Move to Trash"
onClick={@_onRemove}>
<RetinaImg name={imgName} mode={RetinaImg.Mode.ContentIsMask} />
<RetinaImg name="toolbar-trash.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
_onRemove: =>
Actions.removeSelection()
task = TaskFactory.taskForMovingToTrash
threads: @props.selection.items(),
fromView: FocusedMailViewStore.mailView()
Actions.queueTask(task)
class ThreadBulkStarButton extends React.Component
@ -52,7 +72,8 @@ class ThreadBulkStarButton extends React.Component
</button>
_onStar: =>
Actions.toggleStarSelection()
task = TaskFactory.taskForInvertingStarred(threads: @props.selection.items())
Actions.queueTask(task)
class ThreadBulkToggleUnreadButton extends React.Component
@ -62,19 +83,9 @@ class ThreadBulkToggleUnreadButton extends React.Component
@propTypes:
selection: React.PropTypes.object.isRequired
constructor: ->
@state = @_getStateFromStores()
super
componentDidMount: =>
@unsubscribers = []
@unsubscribers.push ThreadListStore.listen @_onStoreChange
componentWillUnmount: =>
unsubscribe() for unsubscribe in @unsubscribers
render: =>
fragment = if @state.canMarkUnread then "unread" else "read"
canMarkUnread = not @props.selection.items().every (s) -> s.unread is true
fragment = if canMarkUnread then "unread" else "read"
<button style={order:-105}
className="btn btn-toolbar"
@ -85,15 +96,8 @@ class ThreadBulkToggleUnreadButton extends React.Component
</button>
_onClick: =>
Actions.toggleUnreadSelection()
_onStoreChange: =>
@setState @_getStateFromStores()
_getStateFromStores: =>
selections = ThreadListStore.view().selection.items()
canMarkUnread: not selections.every (s) -> s.unread is true
task = TaskFactory.taskForInvertingUnread(threads: @props.selection.items())
Actions.queueTask(task)
ThreadNavButtonMixin =
@ -171,4 +175,11 @@ UpButton = React.createClass
UpButton.containerRequired = false
DownButton.containerRequired = false
module.exports = {DownButton, UpButton, ThreadBulkRemoveButton, ThreadBulkStarButton, ThreadBulkToggleUnreadButton}
module.exports = {
DownButton,
UpButton,
ThreadBulkArchiveButton,
ThreadBulkTrashButton,
ThreadBulkStarButton,
ThreadBulkToggleUnreadButton
}

View file

@ -1,6 +1,7 @@
React = require 'react'
{Actions,
RemoveThreadHelper,
CategoryStore,
TaskFactory,
FocusedMailViewStore} = require 'nylas-exports'
class ThreadListQuickActions extends React.Component
@ -9,22 +10,42 @@ class ThreadListQuickActions extends React.Component
thread: React.PropTypes.object
render: =>
focusedMailViewFilter = FocusedMailViewStore.mailView()
return false unless focusedMailViewFilter?.canRemoveThreads()
mailViewFilter = FocusedMailViewStore.mailView()
archive = null
remove = null
classNames = "btn action action-#{RemoveThreadHelper.removeType()}"
if mailViewFilter?.canArchiveThreads()
archive = <div key="archive"
className="btn action action-archive"
onClick={@_onArchive}></div>
if mailViewFilter?.canTrashThreads()
trash = <div key="remove"
className='btn action action-trash'
onClick={@_onRemove}></div>
<div className="inner">
<div key="remove" className={classNames} onClick={@_onRemove}></div>
{archive}
{trash}
</div>
shouldComponentUpdate: (newProps, newState) ->
newProps.thread.id isnt @props?.thread.id
_onArchive: (event) =>
task = TaskFactory.taskForArchiving
threads: [@props.thread]
fromView: FocusedMailViewStore.mailView()
Actions.queueTask(task)
# Don't trigger the thread row click
event.stopPropagation()
_onRemove: (event) =>
focusedMailViewFilter = FocusedMailViewStore.mailView()
t = RemoveThreadHelper.getRemovalTask([@props.thread], focusedMailViewFilter)
Actions.queueTask(t)
task = TaskFactory.taskForMovingToTrash
threads: [@props.thread]
fromView: FocusedMailViewStore.mailView()
Actions.queueTask(task)
# Don't trigger the thread row click
event.stopPropagation()

View file

@ -9,10 +9,7 @@ NylasStore = require 'nylas-store'
DatabaseStore,
AccountStore,
WorkspaceStore,
ChangeUnreadTask,
ChangeStarredTask,
FocusedContentStore,
RemoveThreadHelper,
TaskQueueStatusStore,
FocusedMailViewStore} = require 'nylas-exports'
@ -22,20 +19,6 @@ class ThreadListStore extends NylasStore
constructor: ->
@_resetInstanceVars()
@listenTo Actions.removeSelection, @_onRemoveSelection
@listenTo Actions.removeCurrentlyFocusedThread, @_onRemoveAndAuto
@listenTo Actions.removeAndNext, @_onRemoveAndNext
@listenTo Actions.removeAndPrevious, @_onRemoveAndPrev
@listenTo Actions.moveThread, @_onMoveThread
@listenTo Actions.moveThreads, @_onMoveThreads
@listenTo Actions.toggleStarSelection, @_onToggleStarSelection
@listenTo Actions.toggleStarFocused, @_onToggleStarFocused
@listenTo Actions.toggleUnreadSelection, @_onToggleUnreadSelection
@listenTo DatabaseStore, @_onDataChanged
@listenTo AccountStore, @_onAccountChanged
@listenTo FocusedMailViewStore, @_onMailViewChanged
@ -115,139 +98,36 @@ class ThreadListStore extends NylasStore
return unless @_view
if change.objectClass is Thread.name
focusedId = FocusedContentStore.focusedId('thread')
keyboardId = FocusedContentStore.keyboardCursorId('thread')
viewModeAutofocuses = WorkspaceStore.layoutMode() is 'split' or WorkspaceStore.topSheet().root is true
focusedIndex = @_view.indexOfId(focusedId)
keyboardIndex = @_view.indexOfId(keyboardId)
shiftIndex = (i) =>
if i > 0 and (@_view.get(i - 1)?.unread or i >= @_view.count())
return i - 1
else
return i
@_view.invalidate({change: change, shallow: true})
focusedLost = focusedIndex >= 0 and @_view.indexOfId(focusedId) is -1
keyboardLost = keyboardIndex >= 0 and @_view.indexOfId(keyboardId) is -1
if viewModeAutofocuses and focusedLost
Actions.setFocus(collection: 'thread', item: @_view.get(shiftIndex(focusedIndex)))
if keyboardLost
Actions.setCursorPosition(collection: 'thread', item: @_view.get(shiftIndex(keyboardIndex)))
if change.objectClass is Message.name
# Important: Until we optimize this so that it detects the set change
# and avoids a query, this should be debounced since it's very unimportant
# and avoids a query, this should be defered since it's very unimportant
_.defer =>
threadIds = _.uniq _.map change.objects, (m) -> m.threadId
@_view.invalidateMetadataFor(threadIds)
_onToggleStarSelection: ->
threads = @_view.selection.items()
focusedId = FocusedContentStore.focusedId('thread')
keyboardId = FocusedContentStore.keyboardCursorId('thread')
oneAlreadyStarred = false
for thread in threads
if thread.starred
oneAlreadyStarred = true
starred = not oneAlreadyStarred
task = new ChangeStarredTask({threads, starred})
Actions.queueTask(task)
_onToggleStarFocused: ->
focused = FocusedContentStore.focused('thread')
cursor = FocusedContentStore.keyboardCursor('thread')
if focused
task = new ChangeStarredTask(thread: focused, starred: !focused.starred)
else if cursor
task = new ChangeStarredTask(thread: cursor, starred: !cursor.starred)
if task
Actions.queueTask(task)
_onToggleUnreadSelection: ->
threads = @_view.selection.items()
allUnread = threads.every (t) ->
t.unread is true
unread = not allUnread
task = new ChangeUnreadTask {threads, unread}
Actions.queueTask task
_onRemoveAndAuto: ->
@_removeAndShiftBy('auto')
_onRemoveAndPrev: ->
@_removeAndShiftBy(-1)
_onRemoveAndNext: ->
@_removeAndShiftBy(1)
_removeAndShiftBy: (offset) ->
mailViewFilter = FocusedMailViewStore.mailView()
return unless mailViewFilter.canApplyToThreads()
focused = FocusedContentStore.focused('thread')
return unless focused
task = RemoveThreadHelper.getRemovalTask([focused], mailViewFilter)
@_moveAndShiftBy(offset, task)
_onRemoveSelection: ->
mailViewFilter = FocusedMailViewStore.mailView()
return unless mailViewFilter.canApplyToThreads()
selectedThreads = @_view.selection.items()
return unless selectedThreads.length > 0
task = RemoveThreadHelper.getRemovalTask(selectedThreads, mailViewFilter)
@_onMoveThreads(selectedThreads, task)
_onMoveThread: (thread, task) ->
@_moveAndShiftBy('auto', task)
_onMoveThreads: (threads, task) ->
threadIds = threads.map (thread) -> thread.id
focusedId = FocusedContentStore.focusedId('thread')
keyboardId = FocusedContentStore.keyboardCursorId('thread')
if focusedId in threadIds
changeFocused = true
if keyboardId in threadIds
changeKeyboardCursor = true
if changeFocused or changeKeyboardCursor
newFocusIndex = Number.MAX_VALUE
for thread in threads
newFocusIndex = Math.min(newFocusIndex, @_view.indexOfId(thread.id))
TaskQueueStatusStore.waitForPerformLocal(task).then =>
layoutMode = WorkspaceStore.layoutMode()
if changeFocused
item = @_view.get(newFocusIndex)
Actions.setFocus(collection: 'thread', item: item)
if changeKeyboardCursor
item = @_view.get(newFocusIndex)
Actions.setCursorPosition(collection: 'thread', item: item)
Actions.setFocus(collection: 'thread', item: item) if layoutMode is 'split'
Actions.queueTask(task)
@_view.selection.clear()
_moveAndShiftBy: (offset, task) ->
layoutMode = WorkspaceStore.layoutMode()
focused = FocusedContentStore.focused('thread')
explicitOffset = if offset is "auto" then false else true
return unless focused
# Determine the current index
index = @_view.indexOfId(focused.id)
return if index is -1
# Determine the next index we want to move to
if offset is 'auto'
if @_view.get(index - 1)?.unread
offset = -1
else
offset = 1
index = Math.min(Math.max(index + offset, 0), @_view.count() - 2)
nextKeyboard = nextFocus = @_view.get(index)
# Remove the current thread from selection
@_view.selection.remove(focused)
# If the user is in list mode and removed without specifically saying
# "remove and next" or "remove and prev", return to the thread list
# instead of focusing on the next message.
if layoutMode is 'list' and not explicitOffset
nextFocus = null
# Remove the current thread
TaskQueueStatusStore.waitForPerformLocal(task).then =>
Actions.setFocus(collection: 'thread', item: nextFocus)
Actions.setCursorPosition(collection: 'thread', item: nextKeyboard)
Actions.queueTask(task)
module.exports = new ThreadListStore()

View file

@ -9,11 +9,13 @@ classNames = require 'classnames'
{timestamp, subject} = require './formatting-utils'
{Actions,
Utils,
CanvasUtils,
Thread,
CanvasUtils,
TaskFactory,
WorkspaceStore,
AccountStore,
CategoryStore,
FocusedContentStore,
FocusedMailViewStore} = require 'nylas-exports'
ThreadListParticipants = require './thread-list-participants'
@ -165,11 +167,21 @@ class ThreadList extends React.Component
@narrowColumns = [cNarrow]
_shift = ({offset, afterRunning}) =>
view = ThreadListStore.view()
focusedId = FocusedContentStore.focusedId('thread')
focusedIdx = Math.min(view.count() - 1, Math.max(0, view.indexOfId(focusedId) + offset))
item = view.get(focusedIdx)
afterRunning()
Actions.setFocus(collection: 'thread', item: item)
@commands =
'core:remove-item': @_onRemoveItem
'core:remove-item': @_onBackspace
'core:star-item': @_onStarItem
'core:remove-and-previous': -> Actions.removeAndPrevious()
'core:remove-and-next': -> Actions.removeAndNext()
'core:remove-and-previous': =>
_shift(offset: 1, afterRunning: @_onBackspace)
'core:remove-and-next': =>
_shift(offset: -1, afterRunning: @_onBackspace)
@itemPropsProvider = (item) ->
className: classNames
@ -252,22 +264,37 @@ class ThreadList extends React.Component
_onStarItem: =>
return unless ThreadListStore.view()
focused = FocusedContentStore.focused('thread')
if WorkspaceStore.layoutMode() is "list" and WorkspaceStore.topSheet() is WorkspaceStore.Sheet.Thread
Actions.toggleStarFocused()
threads = [focused]
else if ThreadListStore.view().selection.count() > 0
Actions.toggleStarSelection()
threads = ThreadListStore.view().selection.items()
else
Actions.toggleStarFocused()
threads = [focused]
_onRemoveItem: =>
task = TaskFactory.taskForInvertingStarred({threads})
Actions.queueTask(task)
_onBackspace: =>
return unless ThreadListStore.view()
if WorkspaceStore.layoutMode() is "list" and WorkspaceStore.topSheet() is WorkspaceStore.Sheet.Thread
Actions.removeCurrentlyFocusedThread()
focused = FocusedContentStore.focused('thread')
if WorkspaceStore.layoutMode() is "split" and focused
task = TaskFactory.taskForMovingToTrash
threads: [focused]
fromView: FocusedMailViewStore.mailView()
Actions.queueTask(task)
else if ThreadListStore.view().selection.count() > 0
Actions.removeSelection()
else
Actions.removeCurrentlyFocusedThread()
task = TaskFactory.taskForMovingToTrash
threads: ThreadListStore.view().selection.items()
fromView: FocusedMailViewStore.mailView()
Actions.queueTask(task)
else if WorkspaceStore.layoutMode() is "list" and WorkspaceStore.topSheet() is WorkspaceStore.Sheet.Thread
Actions.popSheet()
module.exports = ThreadList

View file

@ -216,9 +216,6 @@ describe "ThreadList", ->
spyOn(ThreadStore, "_onAccountChanged")
spyOn(DatabaseStore, "findAll").andCallFake ->
new Promise (resolve, reject) -> resolve(test_threads())
spyOn(Actions, "removeCurrentlyFocusedThread")
spyOn(Actions, "removeAndNext")
spyOn(Actions, "removeAndPrevious")
ReactTestUtils.spyOnClass(ThreadList, "_prepareColumns").andCallFake ->
@_columns = columns

View file

@ -198,6 +198,7 @@ describe "DatabaseView", ->
e.subject = subject
@view.invalidateAfterDatabaseChange({objects:[e], type: 'persist'})
waitsFor ->
advanceClock(1)
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[1].items[1].id).toEqual(@e.id)
@ -213,6 +214,7 @@ describe "DatabaseView", ->
b.labels = []
@view.invalidateAfterDatabaseChange({objects:[b], type: 'persist'})
waitsFor ->
advanceClock(1)
@view._emitter.emit.callCount > 0
it "should optimistically remove them and shift result pages", ->
@ -230,6 +232,7 @@ describe "DatabaseView", ->
runs ->
@view.invalidateAfterDatabaseChange({objects:[@b], type: 'unpersist'})
waitsFor ->
advanceClock(1)
@view._emitter.emit.callCount > 0
it "should optimistically remove them and shift result pages", ->
@ -301,6 +304,7 @@ describe "DatabaseView", ->
runs ->
@completeQuery()
waitsFor ->
advanceClock(1)
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[0].items).toEqual(@items)
@ -311,6 +315,7 @@ describe "DatabaseView", ->
expect(@view._pages[0].loading).toEqual(true)
@completeQuery()
waitsFor ->
advanceClock(1)
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[0].loading).toEqual(false)
@ -327,6 +332,7 @@ describe "DatabaseView", ->
runs ->
@completeQuery()
waitsFor ->
advanceClock(1)
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[0].items[0].metadata).toEqual('metadata-for-model-a')
@ -336,6 +342,7 @@ describe "DatabaseView", ->
runs ->
@completeQuery()
waitsFor ->
advanceClock(1)
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[0].metadata).toEqual
@ -366,6 +373,7 @@ describe "DatabaseView", ->
resolve()
waitsFor ->
advanceClock(1)
@view._emitter.emit.callCount > 0
runs ->

View file

@ -1,88 +0,0 @@
Account = require '../../src/flux/models/account'
CategoryStore = require '../../src/flux/stores/category-store'
RemoveThreadHelper = require '../../src/services/remove-thread-helper'
ChangeFolderTask = require '../../src/flux/tasks/change-folder-task'
ChangeLabelsTask = require '../../src/flux/tasks/change-labels-task'
describe "RemoveThreadHelper", ->
describe "removeType", ->
it "returns null if there's no current account", ->
spyOn(RemoveThreadHelper, "_currentAccount").andReturn null
expect(RemoveThreadHelper.removeType()).toBe null
it "returns the type if it's saved", ->
spyOn(atom.config, "get").andReturn "trash"
expect(RemoveThreadHelper.removeType()).toBe "trash"
it "returns the archive category if it exists", ->
spyOn(CategoryStore, "getStandardCategory").andReturn {name: "archive"}
expect(RemoveThreadHelper.removeType()).toBe "archive"
it "defaults to archive for Gmail", ->
spyOn(RemoveThreadHelper, "_currentAccount").andReturn provider: "gmail"
expect(RemoveThreadHelper.removeType()).toBe "archive"
it "defaults to trash for everything else", ->
spyOn(RemoveThreadHelper, "_currentAccount").andReturn provider: "eas"
expect(RemoveThreadHelper.removeType()).toBe "trash"
describe "getRemovalTask", ->
beforeEach ->
spyOn(CategoryStore, "byId").andReturn({id: "inbox-id", name: "inbox"})
@mailViewFilterStub = categoryId: -> "inbox-id"
@categories = []
spyOn(CategoryStore, "getStandardCategory").andCallFake (cat) =>
if cat in @categories
return {id: "cat-id", name: cat}
else return null
afterEach ->
atom.testOrganizationUnit = null
it "returns null if there's no current account", ->
spyOn(RemoveThreadHelper, "_currentAccount").andReturn null
expect(RemoveThreadHelper.getRemovalTask()).toBe null
it "creates the task when using labels and trashing", ->
atom.testOrganizationUnit = "label"
spyOn(RemoveThreadHelper, "_currentAccount").andReturn new Account
provider: "eas"
organizationUnit: "label"
@categories = ["all", "trash"]
t = RemoveThreadHelper.getRemovalTask([], @mailViewFilterStub)
expect(t instanceof ChangeLabelsTask).toBe true
expect(t.labelsToRemove[0].name).toBe "inbox"
expect(t.labelsToAdd[0].name).toBe "trash"
it "creates the task when using labels and archiving", ->
@categories = ["all", "archive", "trash"]
atom.testOrganizationUnit = "label"
spyOn(RemoveThreadHelper, "_currentAccount").andReturn new Account
provider: "gmail"
organizationUnit: "label"
t = RemoveThreadHelper.getRemovalTask([], @mailViewFilterStub)
expect(t instanceof ChangeLabelsTask).toBe true
expect(t.labelsToRemove[0].name).toBe "inbox"
expect(t.labelsToAdd[0].name).toBe "all"
it "creates the task when using folders and trashing", ->
@categories = ["all", "trash"]
atom.testOrganizationUnit = "folder"
spyOn(RemoveThreadHelper, "_currentAccount").andReturn new Account
provider: "eas"
organizationUnit: "folder"
t = RemoveThreadHelper.getRemovalTask([], @mailViewFilterStub)
expect(t instanceof ChangeFolderTask).toBe true
expect(t.folder.name).toBe "trash"
it "creates the task when using folders and archiving", ->
@categories = ["all", "archive", "trash"]
atom.testOrganizationUnit = "folder"
spyOn(RemoveThreadHelper, "_currentAccount").andReturn new Account
provider: "gmail"
organizationUnit: "folder"
t = RemoveThreadHelper.getRemovalTask([], @mailViewFilterStub)
expect(t instanceof ChangeFolderTask).toBe true
expect(t.folder.name).toBe "archive"

View file

@ -0,0 +1,88 @@
# Account = require '../../src/flux/models/account'
# CategoryStore = require '../../src/flux/stores/category-store'
# RemoveThreadHelper = require '../../src/services/remove-thread-helper'
#
# ChangeFolderTask = require '../../src/flux/tasks/change-folder-task'
# ChangeLabelsTask = require '../../src/flux/tasks/change-labels-task'
#
# describe "RemoveThreadHelper", ->
# describe "removeType", ->
# it "returns null if there's no current account", ->
# spyOn(RemoveThreadHelper, "_currentAccount").andReturn null
# expect(RemoveThreadHelper.removeType()).toBe null
#
# it "returns the type if it's saved", ->
# spyOn(atom.config, "get").andReturn "trash"
# expect(RemoveThreadHelper.removeType()).toBe "trash"
#
# it "returns the archive category if it exists", ->
# spyOn(CategoryStore, "getStandardCategory").andReturn {name: "archive"}
# expect(RemoveThreadHelper.removeType()).toBe "archive"
#
# it "defaults to archive for Gmail", ->
# spyOn(RemoveThreadHelper, "_currentAccount").andReturn provider: "gmail"
# expect(RemoveThreadHelper.removeType()).toBe "archive"
#
# it "defaults to trash for everything else", ->
# spyOn(RemoveThreadHelper, "_currentAccount").andReturn provider: "eas"
# expect(RemoveThreadHelper.removeType()).toBe "trash"
#
# describe "getRemovalTask", ->
# beforeEach ->
# spyOn(CategoryStore, "byId").andReturn({id: "inbox-id", name: "inbox"})
# @mailViewFilterStub = categoryId: -> "inbox-id"
# @categories = []
#
# spyOn(CategoryStore, "getStandardCategory").andCallFake (cat) =>
# if cat in @categories
# return {id: "cat-id", name: cat}
# else return null
#
# afterEach ->
# atom.testOrganizationUnit = null
#
# it "returns null if there's no current account", ->
# spyOn(RemoveThreadHelper, "_currentAccount").andReturn null
# expect(RemoveThreadHelper.getRemovalTask()).toBe null
#
# it "creates the task when using labels and trashing", ->
# atom.testOrganizationUnit = "label"
# spyOn(RemoveThreadHelper, "_currentAccount").andReturn new Account
# provider: "eas"
# organizationUnit: "label"
# @categories = ["all", "trash"]
# t = RemoveThreadHelper.getRemovalTask([], @mailViewFilterStub)
# expect(t instanceof ChangeLabelsTask).toBe true
# expect(t.labelsToRemove[0].name).toBe "inbox"
# expect(t.labelsToAdd[0].name).toBe "trash"
#
# it "creates the task when using labels and archiving", ->
# @categories = ["all", "archive", "trash"]
# atom.testOrganizationUnit = "label"
# spyOn(RemoveThreadHelper, "_currentAccount").andReturn new Account
# provider: "gmail"
# organizationUnit: "label"
# t = RemoveThreadHelper.getRemovalTask([], @mailViewFilterStub)
# expect(t instanceof ChangeLabelsTask).toBe true
# expect(t.labelsToRemove[0].name).toBe "inbox"
# expect(t.labelsToAdd[0].name).toBe "all"
#
# it "creates the task when using folders and trashing", ->
# @categories = ["all", "trash"]
# atom.testOrganizationUnit = "folder"
# spyOn(RemoveThreadHelper, "_currentAccount").andReturn new Account
# provider: "eas"
# organizationUnit: "folder"
# t = RemoveThreadHelper.getRemovalTask([], @mailViewFilterStub)
# expect(t instanceof ChangeFolderTask).toBe true
# expect(t.folder.name).toBe "trash"
#
# it "creates the task when using folders and archiving", ->
# @categories = ["all", "archive", "trash"]
# atom.testOrganizationUnit = "folder"
# spyOn(RemoveThreadHelper, "_currentAccount").andReturn new Account
# provider: "gmail"
# organizationUnit: "folder"
# t = RemoveThreadHelper.getRemovalTask([], @mailViewFilterStub)
# expect(t instanceof ChangeFolderTask).toBe true
# expect(t.folder.name).toBe "archive"

View file

@ -31,7 +31,7 @@ The MultiselectActionBar uses the `ComponentRegistry` to find items to display f
collection name. To add an item to the bar created in the example above, register it like this:
```coffee
ComponentRegistry.register ThreadBulkRemoveButton,
ComponentRegistry.register ThreadBulkTrashButton,
role: 'thread:BulkAction'
```

View file

@ -304,29 +304,6 @@ class Actions
###
@destroyDraft: ActionScopeWindow
###
Public: Remove the currently focused {Thread}.
*Scope: Window*
###
@removeCurrentlyFocusedThread: ActionScopeWindow
###
Public: Removes the Thread objects currently selected in the app's main thread list.
*Scope: Window*
###
@removeSelection: ActionScopeWindow
@removeAndNext: ActionScopeWindow
@removeAndPrevious: ActionScopeWindow
@toggleStarSelection: ActionScopeWindow
@toggleStarFocused: ActionScopeWindow
@toggleUnreadSelection: ActionScopeWindow
@deleteSelection: ActionScopeWindow
@moveThread: ActionScopeWindow
@moveThreads: ActionScopeWindow
###
Public: Updates the search query in the app's main search bar with the provided query text.

View file

@ -57,6 +57,12 @@ class Account extends Model
usesLabels: -> @organizationUnit is "label"
usesFolders: -> @organizationUnit is "folder"
categoryClass: ->
if @usesLabels()
return require './label'
else
return require './folder'
# Public: Returns the localized, properly capitalized provider name,
# like Gmail, Exchange, or Outlook 365
displayProvider: ->

View file

@ -72,6 +72,9 @@ class Model
@clientId ?= Utils.generateTempId()
@
isSaved: ->
@serverId?
clone: ->
(new @constructor).fromJSON(@toJSON())

View file

@ -154,6 +154,9 @@ class DatabaseView extends ModelView
if items.length is 0
return
@selection.updateModelReferences(items)
@selection.removeItemsNotMatching(@_matchers)
if items.length > 5
@log("invalidateAfterDatabaseChange on #{items.length} items would be expensive. Invalidating entire range.")
@invalidateCount()
@ -226,7 +229,7 @@ class DatabaseView extends ModelView
pagesCouldHaveChanged = true
if didMakeOptimisticChange
@_emitter.emit('trigger')
@trigger()
if pagesCouldHaveChanged
@invalidateCount()
@ -253,8 +256,7 @@ class DatabaseView extends ModelView
item.metadata = @_pages[page]?.metadata[item.id]
@_pages[page]?.items[pageIdx] = item
@selection.updateModelReferences(items)
@_emitter.emit('trigger')
@trigger()
invalidateMetadataFor: (ids = []) ->
# This method should be called when you know that only the metadata for
@ -281,7 +283,7 @@ class DatabaseView extends ModelView
invalidateCount: ->
DatabaseStore.findAll(@klass).where(@_matchers).count().then (count) =>
@_count = count
@_emitter.emit('trigger')
@trigger()
invalidateRetainedRange: ->
@_throttler.whenReady =>
@ -373,6 +375,7 @@ class DatabaseView extends ModelView
Utils.modelFreeze(item)
@selection.updateModelReferences(items)
@selection.removeItemsNotMatching(@_matchers)
page.items = items
page.loading = false
@ -380,7 +383,7 @@ class DatabaseView extends ModelView
page.lastTouchTime = touchTime
# Trigger if this is the last page that needed to be loaded
@_emitter.emit('trigger') if @loaded()
@trigger() if @loaded()
cullPages: ->
pagesLoaded = Object.keys(@_pages)

View file

@ -70,6 +70,12 @@ class ModelViewSelection
@_items = without
@trigger(@)
removeItemsNotMatching: (matchers) ->
count = @_items.length
@_items = _.filter @_items, (t) -> t.matches(matchers)
if @_items.length isnt count
@trigger(@)
expandTo: (item) ->
return unless item
throw new Error("expandTo must be called with a Model") unless item instanceof Model

View file

@ -11,12 +11,19 @@ class ModelView
@_pages = {}
@_emitter = new EventEmitter()
@selection = new ModelViewSelection @, => @_emitter.emit('trigger')
@selection = new ModelViewSelection(@, @trigger)
@
# Accessing Data
trigger: =>
return if @_triggering
@_triggering = true
_.defer =>
@_triggering = false
@_emitter.emit('trigger')
listen: (callback, bindContext) ->
eventHandler = (args) ->
callback.apply(bindContext, args)
@ -32,7 +39,7 @@ class ModelView
empty: ->
@count() <= 0
get: (idx) ->
unless _.isNumber(idx)
throw new Error("ModelView.get() takes a numeric index. Maybe you meant getById()?")

View file

@ -0,0 +1,79 @@
_ = require 'underscore'
ChangeFolderTask = require './change-folder-task'
ChangeLabelsTask = require './change-labels-task'
ChangeUnreadTask = require './change-unread-task'
ChangeStarredTask = require './change-starred-task'
AccountStore = require '../stores/account-store'
CategoryStore = require '../stores/category-store'
class TaskFactory
taskForApplyingCategory: ({threads, fromView, category, exclusive}) ->
account = AccountStore.current()
if account.usesFolders()
return null unless category
return new ChangeFolderTask
folder: category
threads: threads
else
labelsToRemove = []
if exclusive
currentLabel = CategoryStore.byId(fromView?.categoryId())
currentLabel ?= CategoryStore.getStandardCategory("inbox")
labelsToRemove = [currentLabel]
return new ChangeLabelsTask
threads: threads
labelsToRemove: labelsToRemove
labelsToAdd: [category]
taskForRemovingCategory: ({threads, fromView, category, exclusive}) ->
account = AccountStore.current()
if account.usesFolders()
return new ChangeFolderTask
folder: CategoryStore.getStandardCategory("inbox")
threads: threads
else
labelsToAdd = []
if exclusive
currentLabel = CategoryStore.byId(fromView?.categoryId())
currentLabel ?= CategoryStore.getStandardCategory("inbox")
labelsToAdd = [currentLabel]
return new ChangeLabelsTask
threads: threads
labelsToRemove: [category]
labelsToAdd: labelsToAdd
taskForArchiving: ({threads, fromView}) ->
category = @_archiveCategory()
@taskForApplyingCategory({threads, fromView, category, exclusive: true})
taskForUnarchiving: ({threads, fromView}) ->
category = @_archiveCategory()
@taskForRemovingCategory({threads, fromView, category, exclusive: true})
taskForMovingToTrash: ({threads, fromView}) ->
category = CategoryStore.getStandardCategory("trash")
@taskForApplyingCategory({threads, fromView, category, exclusive: true})
taskForMovingFromTrash: ({threads, fromView}) ->
category = CategoryStore.getStandardCategory("trash")
@taskForRemovingCategory({threads, fromView, category, exclusive: true})
taskForInvertingUnread: ({threads}) ->
unread = _.every threads, (t) -> _.isMatch(t, {unread: false})
return new ChangeUnreadTask({threads, unread})
taskForInvertingStarred: ({threads}) ->
starred = _.every threads, (t) -> _.isMatch(t, {starred: false})
return new ChangeStarredTask({threads, starred})
_archiveCategory: (account) ->
account = AccountStore.current()
if account.usesFolders()
return CategoryStore.getStandardCategory("archive")
else
return CategoryStore.getStandardCategory("all")
module.exports = new TaskFactory

View file

@ -72,7 +72,8 @@ class NylasExports
@require "Task", 'flux/tasks/task'
@require "TaskRegistry", "task-registry"
@require "TaskQueue", 'flux/stores/task-queue'
@load "TaskQueueStatusStore", 'flux/stores/task-queue-status-store'
@require "TaskFactory", 'flux/tasks/task-factory'
@load "TaskQueueStatusStore", 'flux/stores/task-queue-status-store'
@require "UndoRedoStore", 'flux/stores/undo-redo-store'
# Tasks
@ -134,7 +135,6 @@ class NylasExports
@load "SoundRegistry", 'sound-registry'
@load "QuotedHTMLParser", 'services/quoted-html-parser'
@load "QuotedPlainTextParser", 'services/quoted-plain-text-parser'
@require "RemoveThreadHelper", 'services/remove-thread-helper'
# Errors
@get "APIError", -> require('../flux/errors').APIError

View file

@ -49,8 +49,11 @@ class MailViewFilter
throw new Error("canApplyToThreads: Not implemented in base class.")
# Whether or not the current MailViewFilter can "archive" or "trash"
canRemoveThreads: ->
throw new Error("canRemoveThreads: Not implemented in base class.")
canArchiveThreads: ->
throw new Error("canArchiveThreads: Not implemented in base class.")
canTrashThreads: ->
throw new Error("canTrashThreads: Not implemented in base class.")
applyToThreads: (threadsOrIds) ->
throw new Error("applyToThreads: Not implemented in base class.")
@ -68,7 +71,10 @@ class SearchMailViewFilter extends MailViewFilter
canApplyToThreads: ->
false
canRemoveThreads: ->
canArchiveThreads: ->
false
canTrashThreads: ->
false
categoryId: ->
@ -90,7 +96,10 @@ class StarredMailViewFilter extends MailViewFilter
canApplyToThreads: ->
true
canRemoveThreads: ->
canArchiveThreads: ->
true
canTrashThreads: ->
true
applyToThreads: (threadsOrIds) ->
@ -128,9 +137,14 @@ class CategoryMailViewFilter extends MailViewFilter
canApplyToThreads: ->
not (@category.name in CategoryStore.LockedCategoryNames)
canRemoveThreads: ->
return false if @category.name in ["archive", "trash", "sent", "all"]
canArchiveThreads: ->
return false if @category.name in ["archive", "all", "sent"]
return false if @category.displayName is atom.config.get("core.archiveFolder")
return false unless CategoryStore.getStandardCategory("archive")
return true
canTrashThreads: ->
return false if @category.name in ["trash"]
return true
applyToThreads: (threadsOrIds) ->
@ -153,5 +167,4 @@ class CategoryMailViewFilter extends MailViewFilter
Actions.queueTask(task)
module.exports = MailViewFilter

View file

@ -1,93 +0,0 @@
_ = require 'underscore'
CategoryStore = require '../flux/stores/category-store'
ChangeLabelsTask = require '../flux/tasks/change-labels-task'
ChangeFolderTask = require '../flux/tasks/change-folder-task'
Actions = require '../flux/actions'
AccountStore = require '../flux/stores/account-store'
class RemoveThreadHelper
Type:
Trash: "trash"
Archive: "archive"
removeType: ->
currentAccount = @_currentAccount()
return null unless currentAccount
savedType = atom.config.get("core.#{currentAccount.id}.removeType")
return savedType if savedType
archiveCategory = CategoryStore.getStandardCategory("archive")
return @Type.Archive if archiveCategory
if currentAccount.provider is "gmail"
return @Type.Archive
else
return @Type.Trash
_currentAccount: -> AccountStore.current() # To stub in testing
# In the case of folders, "removing" means moving the message to a
# particular folder
removalFolder: ->
if @removeType() is @Type.Trash
CategoryStore.getStandardCategory("trash")
else if @removeType() is @Type.Archive
CategoryStore.getStandardCategory("archive")
# In the case of labels, "removing" means removing the current label and
# applying a new label indicating it's in the "removed" state.
removalLabelToAdd: ->
if @removeType() is @Type.Trash
CategoryStore.getStandardCategory("trash")
else if @removeType() is @Type.Archive
CategoryStore.getStandardCategory("all")
getRemovalTask: (threads=[], focusedMailViewFilter) ->
threads = [threads] unless threads instanceof Array
account = @_currentAccount()
return null unless account
if account.usesFolders()
removalFolder = @removalFolder()
if removalFolder
return new ChangeFolderTask
folder: removalFolder
threads: threads
else
@_notifyFolderRemovalError()
return null
else if account.usesLabels()
viewCategoryId = focusedMailViewFilter.categoryId()
currentLabel = CategoryStore.byId(viewCategoryId)
currentLabel ?= CategoryStore.getStandardCategory("inbox")
params = {threads}
params.labelsToRemove = [currentLabel]
removalLabelToAdd = @removalLabelToAdd()
if removalLabelToAdd
params.labelsToAdd = [removalLabelToAdd]
return new ChangeLabelsTask(params)
else
throw new Error("Invalid organizationUnit")
_notifyFolderRemovalError: ->
# In the onboarding flow, users should have already created their
# Removal folder. This should only happen for legacy users or if
# there's an error somewhere.
if @removeType() is @Type.Trash
msg = "There is no Trash folder. Please create a folder called 'Trash' and try again."
else if @removeType() is @Type.Archive
msg = "We can't archive your messages because you have no 'Archive' folder. Please create a folder called 'Archive' and try again"
Actions.postNotification
type: 'error'
tag: 'noRemovalFolder'
sticky: true
message: msg
module.exports = new RemoveThreadHelper()