feat(unread/spam): New items in the sidebar for unread and spam

Summary:
Adds a new unified "Spam" folder and a unified "Unread" view,
which shows all the messages in your inbox which are unread.

Test Plan: Run tests

Reviewers: evan, juan

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D2901
This commit is contained in:
Ben Gotow 2016-04-19 11:32:33 -07:00
parent 0e2a875f4d
commit 237bad59d8
22 changed files with 268 additions and 66 deletions

View file

@ -130,6 +130,14 @@ class SidebarItem
id += "-#{opts.name}" if opts.name
@forPerspective(id, perspective, opts)
@forUnread: (accountIds, opts = {}) ->
categories = accountIds.map (accId) =>
CategoryStore.getStandardCategory(accId, 'inbox')
perspective = MailboxPerspective.forUnread(categories)
id = 'Unread'
id += "-#{opts.name}" if opts.name
@forPerspective(id, perspective, opts)
@forDrafts: (accountIds, opts = {}) ->
perspective = MailboxPerspective.forDrafts(accountIds)
id = "Drafts-#{opts.name}"

View file

@ -37,12 +37,13 @@ class SidebarSection
.reject(cats, (cat) -> cat.name is 'drafts')
.map (cat) => SidebarItem.forCategories([cat], editable: false, deletable: false)
unreadItem = SidebarItem.forUnread([account.id])
starredItem = SidebarItem.forStarred([account.id])
draftsItem = SidebarItem.forDrafts([account.id])
snoozedItem = SidebarItem.forSnoozed([account.id])
# Order correctly: Inbox, Starred, rest... , Drafts
items.splice(1, 0, starredItem, snoozedItem)
# Order correctly: Inbox, Unread, Starred, rest... , Drafts
items.splice(1, 0, unreadItem, starredItem, snoozedItem)
items.push(draftsItem)
return {
@ -60,6 +61,7 @@ class SidebarSection
'sent',
['archive', 'all'],
'trash'
'spam'
]
items = []
@ -83,6 +85,9 @@ class SidebarSection
starredItem = SidebarItem.forStarred(accountIds,
children: accounts.map (acc) -> SidebarItem.forStarred([acc.id], name: acc.label)
)
unreadItem = SidebarItem.forUnread(accountIds,
children: accounts.map (acc) -> SidebarItem.forUnread([acc.id], name: acc.label)
)
draftsItem = SidebarItem.forDrafts(accountIds,
children: accounts.map (acc) -> SidebarItem.forDrafts([acc.id], name: acc.label)
)
@ -90,8 +95,8 @@ class SidebarSection
children: accounts.map (acc) -> SidebarItem.forSnoozed([acc.id], name: acc.label)
)
# Order correctly: Inbox, Starred, rest... , Drafts
items.splice(1, 0, starredItem, snoozedItem)
# Order correctly: Inbox, Unread, Starred, rest... , Drafts
items.splice(1, 0, unreadItem, starredItem, snoozedItem)
items.push(draftsItem)
return {

View file

@ -38,7 +38,7 @@ class DraftListStore extends NylasStore
.where(draft: true, accountId: mailboxPerspective.accountIds)
.page(0, 1)
subscription = new MutableQuerySubscription(query, {asResultSet: true})
subscription = new MutableQuerySubscription(query, {emitResultSet: true})
$resultSet = Rx.Observable.fromNamedQuerySubscription('draft-list', subscription)
$resultSet = Rx.Observable.combineLatest [
$resultSet,

View file

@ -15,8 +15,8 @@ class ThreadTrashButton extends React.Component
thread: React.PropTypes.object.isRequired
render: =>
canTrashThreads = FocusedPerspectiveStore.current().canTrashThreads([@props.thread])
return <span /> unless canTrashThreads
allowed = FocusedPerspectiveStore.current().canMoveThreadsTo([@props.thread], 'trash')
return <span /> unless allowed
<button className="btn btn-toolbar"
style={order: -106}

View file

@ -11,6 +11,7 @@ SelectedItemsStack = require './selected-items-stack'
DownButton,
TrashButton,
ArchiveButton,
MarkAsSpamButton,
ToggleUnreadButton,
ToggleStarredButton} = require "./thread-toolbar-buttons"
@ -45,6 +46,9 @@ module.exports =
ComponentRegistry.register TrashButton,
role: 'ThreadActionsToolbarButton'
ComponentRegistry.register MarkAsSpamButton,
role: 'ThreadActionsToolbarButton'
ComponentRegistry.register ToggleStarredButton,
role: 'ThreadActionsToolbarButton'
@ -58,6 +62,7 @@ module.exports =
ComponentRegistry.unregister MessageListToolbar
ComponentRegistry.unregister ArchiveButton
ComponentRegistry.unregister TrashButton
ComponentRegistry.unregister MarkAsSpamButton
ComponentRegistry.unregister ToggleUnreadButton
ComponentRegistry.unregister ToggleStarredButton
ComponentRegistry.unregister UpButton

View file

@ -92,8 +92,8 @@ export default class ThreadListContextMenu {
archiveItem() {
const perspective = FocusedPerspectiveStore.current()
const canArchiveThreads = perspective.canArchiveThreads(this.threads)
if (!canArchiveThreads) {
const allowed = perspective.canArchiveThreads(this.threads)
if (!allowed) {
return null
}
return {
@ -109,8 +109,8 @@ export default class ThreadListContextMenu {
trashItem() {
const perspective = FocusedPerspectiveStore.current()
const canTrashThreads = perspective.canTrashThreads(this.threads)
if (!canTrashThreads) {
const allowed = perspective.canMoveThreadsTo(this.threads, 'trash')
if (!allowed) {
return null
}
return {

View file

@ -60,7 +60,7 @@ _flatMapJoiningMessages = ($threadsResultSet) =>
_observableForThreadMessages = (id, initialModels) ->
subscription = new QuerySubscription(DatabaseStore.findAll(Message, threadId: id), {
asResultSet: true,
emitResultSet: true,
initialModels: initialModels
})
Rx.Observable.fromNamedQuerySubscription('message-'+id, subscription)

View file

@ -11,8 +11,8 @@ class ThreadArchiveQuickAction extends React.Component
thread: React.PropTypes.object
render: =>
canArchiveThreads = FocusedPerspectiveStore.current().canArchiveThreads([@props.thread])
return <span /> unless canArchiveThreads
allowed = FocusedPerspectiveStore.current().canArchiveThreads([@props.thread])
return <span /> unless allowed
<div
key="archive"
@ -38,8 +38,8 @@ class ThreadTrashQuickAction extends React.Component
thread: React.PropTypes.object
render: =>
canTrashThreads = FocusedPerspectiveStore.current().canTrashThreads([@props.thread])
return <span /> unless canTrashThreads
allowed = FocusedPerspectiveStore.current().canMoveThreadsTo([@props.thread], 'trash')
return <span /> unless allowed
<div
key="remove"

View file

@ -18,8 +18,8 @@ class ArchiveButton extends React.Component
items: React.PropTypes.array.isRequired
render: ->
canArchiveThreads = FocusedPerspectiveStore.current().canArchiveThreads(@props.items)
return <span /> unless canArchiveThreads
allowed = FocusedPerspectiveStore.current().canArchiveThreads(@props.items)
return <span /> unless allowed
<button
tabIndex={-1}
@ -38,6 +38,7 @@ class ArchiveButton extends React.Component
event.stopPropagation()
return
class TrashButton extends React.Component
@displayName: 'TrashButton'
@containerRequired: false
@ -46,8 +47,8 @@ class TrashButton extends React.Component
items: React.PropTypes.array.isRequired
render: ->
canTrashThreads = FocusedPerspectiveStore.current().canTrashThreads(@props.items)
return <span /> unless canTrashThreads
allowed = FocusedPerspectiveStore.current().canMoveThreadsTo(@props.items, 'trash')
return <span /> unless allowed
<button tabIndex={-1}
style={order:-106}
@ -66,6 +67,34 @@ class TrashButton extends React.Component
return
class MarkAsSpamButton extends React.Component
@displayName: 'MarkAsSpamButton'
@containerRequired: false
@propTypes:
items: React.PropTypes.array.isRequired
render: ->
allowed = FocusedPerspectiveStore.current().canMoveThreadsTo(@props.items, 'spam')
return <span /> unless allowed
<button tabIndex={-1}
style={order:-105}
className="btn btn-toolbar"
title="Mark as Spam"
onClick={@_onClick}>
<RetinaImg name="toolbar-spam.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
_onClick: (event) =>
tasks = TaskFactory.tasksForMarkingAsSpam
threads: @props.items
Actions.queueTasks(tasks)
Actions.popSheet()
event.stopPropagation()
return
class ToggleStarredButton extends React.Component
@displayName: 'ToggleStarredButton'
@containerRequired: false
@ -83,7 +112,7 @@ class ToggleStarredButton extends React.Component
imageName = "toolbar-star.png"
<button tabIndex={-1}
style={order:-104}
style={order:-103}
className="btn btn-toolbar"
title={title}
onClick={@_onStar}>
@ -109,7 +138,7 @@ class ToggleUnreadButton extends React.Component
fragment = if postClickUnreadState then "unread" else "read"
<button tabIndex={-1}
style={order:-105}
style={order:-104}
className="btn btn-toolbar"
title="Mark as #{fragment}"
onClick={@_onClick}>
@ -205,6 +234,7 @@ module.exports = {
UpButton,
DownButton,
TrashButton,
MarkAsSpamButton,
ArchiveButton,
ToggleStarredButton,
ToggleUnreadButton

View file

@ -13,7 +13,7 @@ import SearchActions from './search-actions'
class SearchQuerySubscription extends MutableQuerySubscription {
constructor(searchQuery, accountIds) {
super(null, {asResultSet: true})
super(null, {emitResultSet: true})
this._searchQuery = searchQuery
this._accountIds = accountIds
@ -152,19 +152,11 @@ class SearchQuerySubscription extends MutableQuerySubscription {
this.resetData()
}
cleanup() {
onLastCallbackRemoved() {
this.reportSearchMetrics();
this._connections.forEach((conn) => conn.end())
this._unsubscribers.forEach((unsub) => unsub())
}
removeCallback(callback) {
super.removeCallback(callback)
if (this.callbackCount() === 0) {
this.reportSearchMetrics()
this.cleanup()
}
}
}
export default SearchQuerySubscription

View file

@ -11,7 +11,7 @@ proxyquire = require 'proxyquire'
Utils,
} = require 'nylas-exports'
ParticipantsTextField = proxyquire '../src/components/participants-text-field',
ParticipantsTextField = proxyquire '../../src/components/participants-text-field',
'nylas-exports': {Contact, ContactStore}
participant1 = new Contact

View file

@ -63,35 +63,38 @@ describe('MailboxPerspective', ()=> {
});
});
describe('canTrashThreads', ()=> {
it('returns false if the perspective is trash', ()=> {
describe('canMoveThreadsTo', ()=> {
it('returns false if the perspective is the target folder', ()=> {
const accounts = [
{canTrashThreads: () => true},
{canTrashThreads: () => true},
{id: 'a'},
{id: 'b'},
]
spyOn(AccountStore, 'accountsForItems').andReturn(accounts)
spyOn(this.perspective, 'isTrash').andReturn(true)
expect(this.perspective.canTrashThreads()).toBe(false)
spyOn(this.perspective, 'categoriesSharedName').andReturn('trash')
expect(this.perspective.canMoveThreadsTo([], 'trash')).toBe(false)
});
it('returns false if one of the accounts associated with the threads cannot archive', ()=> {
it('returns false if one of the accounts associated with the threads does not have the folder', ()=> {
const accounts = [
{canTrashThreads: () => true},
{canTrashThreads: () => false},
{id: 'a'},
{id: 'b'},
]
spyOn(CategoryStore, 'getStandardCategory').andReturn(null)
spyOn(AccountStore, 'accountsForItems').andReturn(accounts)
spyOn(this.perspective, 'isTrash').andReturn(false)
expect(this.perspective.canTrashThreads()).toBe(false)
spyOn(this.perspective, 'categoriesSharedName').andReturn('inbox')
expect(this.perspective.canMoveThreadsTo([], 'trash')).toBe(false)
});
it('returns true otherwise', ()=> {
const accounts = [
{canTrashThreads: () => true},
{canTrashThreads: () => true},
{id: 'a'},
{id: 'b'},
]
const category = {id: 'cat'};
spyOn(CategoryStore, 'getStandardCategory').andReturn(category)
spyOn(AccountStore, 'accountsForItems').andReturn(accounts)
spyOn(this.perspective, 'isTrash').andReturn(false)
expect(this.perspective.canTrashThreads()).toBe(true)
spyOn(this.perspective, 'categoriesSharedName').andReturn('inbox')
expect(this.perspective.canMoveThreadsTo([], 'trash')).toBe(true)
});
});

View file

@ -17,7 +17,7 @@ export default class QuerySubscription {
if (this._query) {
if (this._query._count) {
throw new Error("QuerySubscriptionPool::add - You cannot listen to count queries.")
throw new Error("QuerySubscription::constructor - You cannot listen to count queries.")
}
this._query.finalize();
@ -62,6 +62,13 @@ export default class QuerySubscription {
throw new Error("QuerySubscription:removeCallback - expects a function, received #{callback}")
}
this._callbacks = _.without(this._callbacks, callback);
if (this.callbackCount() === 0) {
this.onLastCallbackRemoved()
}
}
onLastCallbackRemoved = () => {
}
callbackCount = () => {
@ -270,7 +277,7 @@ export default class QuerySubscription {
return;
}
if (this._options.asResultSet) {
if (this._options.emitResultSet) {
this._set.setQuery(this._query);
this._lastResult = this._set.immutableClone();
} else {

View file

@ -271,9 +271,10 @@ class ModelQuery
limit += " OFFSET #{@_range.offset}"
distinct = if @_distinct then ' DISTINCT' else ''
allMatchers = @matchersFlattened()
joins = @_matchers.filter (matcher) -> matcher.attr instanceof AttributeCollection
if joins.length is 1 and @_canSubselectForJoin(joins[0])
joins = allMatchers.filter (matcher) -> matcher.attr instanceof AttributeCollection
if joins.length is 1 and @_canSubselectForJoin(joins[0], allMatchers)
subSql = @_subselectSQL(joins[0], @_matchers, order, limit)
return "SELECT#{distinct} #{result} FROM `#{@_klass.name}` WHERE `id` IN (#{subSql}) #{order}"
else
@ -287,13 +288,13 @@ class ModelQuery
#
# Note: This is currently only intended for use in the thread list
#
_canSubselectForJoin: (matcher) ->
_canSubselectForJoin: (matcher, allMatchers) ->
joinAttribute = matcher.attribute()
return unless @_range.limit?
allMatchersOnJoinTable = _.every @_matchers, (m) ->
m is matcher or joinAttribute.joinQueryableBy.indexOf(m.attr.modelKey) isnt -1
allMatchersOnJoinTable = _.every allMatchers, (m) ->
m is matcher or joinAttribute.joinQueryableBy.indexOf(m.attr.modelKey) isnt -1 or m.attr.modelKey is 'id'
allOrdersOnJoinTable = _.every @_orders, (o) ->
joinAttribute.joinQueryableBy.indexOf(o.attr.modelKey) isnt -1
@ -364,6 +365,18 @@ class ModelQuery
matchers: ->
@_matchers
matchersFlattened: ->
all = []
traverse = (matchers) ->
return unless matchers instanceof Array
for m in matchers
if m.children
traverse(m.children)
else
all.push(m)
traverse(@_matchers)
all
matcherValueForModelKey: (key) ->
matcher = _.find @_matchers, (m) -> m.attr.modelKey = key
matcher?.val

View file

@ -0,0 +1,48 @@
import MutableQuerySubscription from './mutable-query-subscription';
import DatabaseStore from '../stores/database-store';
import RecentlyReadStore from '../stores/recently-read-store';
import Matcher from '../attributes/matcher';
import Thread from '../models/thread';
const buildQuery = (categoryIds) => {
const unreadMatchers = new Matcher.And([
Thread.attributes.categories.containsAny(categoryIds),
Thread.attributes.unread.equal(true),
Thread.attributes.inAllMail.equal(true),
]);
const query = DatabaseStore.findAll(Thread).limit(0);
// The "Unread" view shows all threads which are unread. When you read a thread,
// it doesn't disappear until you leave the view and come back. This behavior
// is implemented by keeping track of messages being rea and manually
// whitelisting them in the query.
if (RecentlyReadStore.ids.length === 0) {
query.where(unreadMatchers);
} else {
query.where(new Matcher.Or([
unreadMatchers,
Thread.attributes.id.in(RecentlyReadStore.ids),
]));
}
return query;
}
export default class UnreadQuerySubscription extends MutableQuerySubscription {
constructor(categoryIds) {
super(buildQuery(categoryIds), {emitResultSet: true})
this._categoryIds = categoryIds;
this._unlisten = RecentlyReadStore.listen(this.onRecentlyReadChanged);
}
onRecentlyReadChanged = () => {
const {limit, offset} = this._query.range()
this._query = buildQuery(this._categoryIds).limit(limit).offset(offset);
}
onLastCallbackRemoved = () => {
this._unlisten();
}
}

View file

@ -235,7 +235,8 @@ FileDownloadStore = Reflux.createStore
defaultPath = @_defaultSaveDir()
options = {
defaultPath,
properties: ['openDirectory'],
title: 'Save Into...',
properties: ['openDirectory', 'createDirectory'],
}
NylasEnv.showOpenDialog options, (selected) =>

View file

@ -0,0 +1,53 @@
import NylasStore from 'nylas-store';
import ChangeUnreadTask from '../tasks/change-unread-task';
import ChangeLabelsTask from '../tasks/change-labels-task';
import ChangeFolderTask from '../tasks/change-folder-task';
import Actions from '../actions';
// The "Unread" view shows all threads which are unread. When you read a thread,
// it doesn't disappear until you leave the view and come back. This behavior
// is implemented by keeping track of messages being rea and manually
// whitelisting them in the query.
class RecentlyReadStore extends NylasStore {
constructor() {
super();
this.ids = [];
this.listenTo(Actions.focusMailboxPerspective, () => {
this.ids = [];
this.trigger();
});
this.listenTo(Actions.queueTasks, (tasks) => {
this.tasksQueued(tasks);
});
this.listenTo(Actions.queueTask, (task) => {
this.tasksQueued([task]);
});
}
tasksQueued(tasks) {
let changed = false;
tasks.filter(task =>
task instanceof ChangeUnreadTask
).forEach(({threads}) => {
const threadIds = threads.map(t => t.id ? t.id : t);
this.ids = this.ids.concat(threadIds);
changed = true;
});
tasks.filter(task =>
task instanceof ChangeLabelsTask || task instanceof ChangeFolderTask
).forEach(({threads}) => {
const threadIds = threads.map(t => t.id ? t.id : t);
this.ids = this.ids.filter(id => !threadIds.includes(id));
changed = true;
});
if (changed) {
this.trigger();
}
}
}
module.exports = new RecentlyReadStore();

View file

@ -7,10 +7,14 @@ CategoryStore = require './flux/stores/category-store'
DatabaseStore = require './flux/stores/database-store'
OutboxStore = require './flux/stores/outbox-store'
ThreadCountsStore = require './flux/stores/thread-counts-store'
RecentlyReadStore = require './flux/stores/recently-read-store'
MutableQuerySubscription = require './flux/models/mutable-query-subscription'
UnreadQuerySubscription = require './flux/models/unread-query-subscription'
Matcher = require './flux/attributes/matcher'
Thread = require './flux/models/thread'
Category = require './flux/models/category'
Actions = require './flux/actions'
ChangeUnreadTask = null
# This is a class cluster. Subclasses are not for external use!
# https://developer.apple.com/library/ios/documentation/General/Conceptual/CocoaEncyclopedia/ClassClusters/ClassClusters.html
@ -41,6 +45,9 @@ class MailboxPerspective
@forStarred: (accountsOrIds) ->
new StarredMailboxPerspective(accountsOrIds)
@forUnread: (categories) ->
new UnreadMailboxPerspective(categories)
@forInbox: (accountsOrIds) =>
@forStandardCategories(accountsOrIds, 'inbox')
@ -49,6 +56,9 @@ class MailboxPerspective
if json.type is CategoryMailboxPerspective.name
categories = JSON.parse(json.serializedCategories, Utils.registeredObjectReviver)
return @forCategories(categories)
else if json.type is UnreadMailboxPerspective.name
categories = JSON.parse(json.serializedCategories, Utils.registeredObjectReviver)
return @forUnread(categories)
else if json.type is StarredMailboxPerspective.name
return @forStarred(json.accountIds)
else if json.type is DraftsMailboxPerspective.name
@ -131,14 +141,14 @@ class MailboxPerspective
throw new Error("receiveThreads: Not implemented in base class.")
canArchiveThreads: (threads) =>
return false if @isArchive()
accounts = AccountStore.accountsForItems(threads)
accountsCanArchiveThreads = _.every(accounts, (acc) -> acc.canArchiveThreads())
return (not @isArchive()) and accountsCanArchiveThreads
return _.every(accounts, (acc) -> acc.canArchiveThreads())
canTrashThreads: (threads) =>
accounts = AccountStore.accountsForItems(threads)
accountCanTrashThreads = _.every(accounts, (acc) -> acc.canTrashThreads())
return (not @isTrash()) and accountCanTrashThreads
canMoveThreadsTo: (threads, standardCategoryName) =>
return false if @categoriesSharedName() is standardCategoryName
return _.every AccountStore.accountsForItems(threads), (acc) ->
CategoryStore.getStandardCategory(acc, standardCategoryName)?
tasksForRemovingItems: (threads) =>
if not threads instanceof Array
@ -180,7 +190,7 @@ class StarredMailboxPerspective extends MailboxPerspective
Thread.attributes.inAllMail.equal(true),
]).limit(0)
return new MutableQuerySubscription(query, {asResultSet: true})
return new MutableQuerySubscription(query, {emitResultSet: true})
canReceiveThreadsFromAccountIds: =>
super
@ -205,7 +215,7 @@ class EmptyMailboxPerspective extends MailboxPerspective
# index so this returns zero items nearly instantly. In the future, we might
# want to make a Query.forNothing() to go along with MailboxPerspective.forNothing()
query = DatabaseStore.findAll(Thread).where(lastMessageReceivedTimestamp: -1).limit(0)
return new MutableQuerySubscription(query, {asResultSet: true})
return new MutableQuerySubscription(query, {emitResultSet: true})
canReceiveThreadsFromAccountIds: =>
false
@ -253,7 +263,7 @@ class CategoryMailboxPerspective extends MailboxPerspective
# can be /much/ slower and we shouldn't do it if we know we don't need it.
query.distinct()
return new MutableQuerySubscription(query, {asResultSet: true})
return new MutableQuerySubscription(query, {emitResultSet: true})
unreadCount: =>
sum = 0
@ -344,4 +354,31 @@ class CategoryMailboxPerspective extends MailboxPerspective
)
class UnreadMailboxPerspective extends CategoryMailboxPerspective
constructor: (categories) ->
super(categories)
@name = "Unread"
@iconName = "unread.png"
@
threads: =>
return new UnreadQuerySubscription(_.pluck(@categories(), 'id'))
unreadCount: =>
0
receiveThreads: (threadsOrIds) =>
super(threadsOrIds)
ChangeUnreadTask ?= require './flux/tasks/change-unread-task'
task = new ChangeUnreadTask({threads:threadsOrIds, unread: true})
Actions.queueTask(task)
tasksForRemovingItems: (threads, ruleset) =>
ChangeUnreadTask ?= require './flux/tasks/change-unread-task'
tasks = super(threads, ruleset)
tasks.push new ChangeUnreadTask({threads, unread: false})
return tasks
module.exports = MailboxPerspective

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB