feat(hidden-messages): Filter trash/spam messages. Fixes #1135

Summary:
By default, the messages in a thread are now filtered to exclude
ones moved to trash or spam. You can choose to view those messages by clicking
the new bar in the message list.

When you view your spam or trash, we only show the messages on those threads
that have been marked as spam/trash.

Test Plan: Run a couple new tests

Reviewers: juan, evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2662
This commit is contained in:
Ben Gotow 2016-03-02 10:05:17 -08:00
parent a950b40175
commit f413386b80
16 changed files with 240 additions and 69 deletions

View file

@ -17,6 +17,7 @@
"object-curly-spacing": [0], "object-curly-spacing": [0],
"no-console": [0], "no-console": [0],
"no-loop-func": [0], "no-loop-func": [0],
"no-constant-condition": [0],
"new-cap": [2, {"capIsNew": false}], "new-cap": [2, {"capIsNew": false}],
"no-shadow": [1], "no-shadow": [1],
"quotes": [0], "quotes": [0],

View file

@ -1,4 +1,5 @@
MessageList = require "./message-list" MessageList = require "./message-list"
MessageListHiddenMessagesToggle = require './message-list-hidden-messages-toggle'
MessageToolbarItems = require "./message-toolbar-items" MessageToolbarItems = require "./message-toolbar-items"
{ComponentRegistry, {ComponentRegistry,
ExtensionRegistry, ExtensionRegistry,
@ -46,6 +47,9 @@ module.exports =
ComponentRegistry.register ThreadToggleUnreadButton, ComponentRegistry.register ThreadToggleUnreadButton,
role: 'message:Toolbar' role: 'message:Toolbar'
ComponentRegistry.register MessageListHiddenMessagesToggle,
role: 'MessageListHeaders'
ExtensionRegistry.MessageView.register AutolinkerExtension ExtensionRegistry.MessageView.register AutolinkerExtension
ExtensionRegistry.MessageView.register TrackingPixelsExtension ExtensionRegistry.MessageView.register TrackingPixelsExtension

View file

@ -0,0 +1,64 @@
import {
React,
Actions,
MessageStore,
FocusedPerspectiveStore,
} from 'nylas-exports';
export default class MessageListHiddenMessagesToggle extends React.Component {
static displayName = 'MessageListHiddenMessagesToggle';
constructor() {
super();
this.state = {
numberOfHiddenItems: MessageStore.numberOfHiddenItems(),
};
}
componentDidMount() {
this._unlisten = MessageStore.listen(() => {
this.setState({
numberOfHiddenItems: MessageStore.numberOfHiddenItems(),
});
});
}
componentWillUnmount() {
this._unlisten();
}
render() {
const {numberOfHiddenItems} = this.state;
if (numberOfHiddenItems === 0) {
return false;
}
const viewing = FocusedPerspectiveStore.current().categoriesSharedName();
let message = null;
if (MessageStore.CategoryNamesHiddenByDefault.includes(viewing)) {
if (numberOfHiddenItems > 1) {
message = `There are ${numberOfHiddenItems} more messages in this thread that are not in spam or trash.`;
} else {
message = `There is one more message in this thread that is not in spam or trash.`;
}
} else {
if (numberOfHiddenItems > 1) {
message = `${numberOfHiddenItems} messages in this thread are hidden because it was moved to trash or spam.`;
} else {
message = `One message in this thread is hidden because it was moved to trash or spam.`;
}
}
return (
<div className="show-hidden-messages">
{message}
<a onClick={function toggle() { Actions.toggleHiddenMessages() }}>Show all messages</a>
</div>
);
}
}
MessageListHiddenMessagesToggle.containerRequired = false;

View file

@ -17,7 +17,7 @@ MessageItemContainer = require './message-item-container'
{Spinner, {Spinner,
RetinaImg, RetinaImg,
MailLabel, MailLabelSet,
ScrollRegion, ScrollRegion,
MailImportantIcon, MailImportantIcon,
InjectedComponent, InjectedComponent,
@ -218,7 +218,7 @@ class MessageList extends React.Component
<MailImportantIcon thread={@state.currentThread}/> <MailImportantIcon thread={@state.currentThread}/>
<div style={flex: 1}> <div style={flex: 1}>
<span className="message-subject">{subject}</span> <span className="message-subject">{subject}</span>
{@_renderLabels()} <MailLabelSet thread={@state.currentThread} includeCurrentCategories={true} />
</div> </div>
{@_renderIcons()} {@_renderIcons()}
</div> </div>
@ -243,14 +243,6 @@ class MessageList extends React.Component
<RetinaImg name={"collapse.png"} title={"Collapse All"} mode={RetinaImg.Mode.ContentIsMask}/> <RetinaImg name={"collapse.png"} title={"Collapse All"} mode={RetinaImg.Mode.ContentIsMask}/>
</div> </div>
_renderLabels: =>
account = AccountStore.accountForId(@state.currentThread.accountId)
return false unless account.usesLabels()
labels = @state.currentThread.sortedCategories()
labels = _.reject labels, (l) -> l.name is 'important'
labels.map (label) =>
<MailLabel label={label} key={label.id} onRemove={ => @_onRemoveLabel(label) }/>
_renderReplyArea: => _renderReplyArea: =>
<div className="footer-reply-area-wrap" onClick={@_onClickReplyArea} key='reply-area'> <div className="footer-reply-area-wrap" onClick={@_onClickReplyArea} key='reply-area'>
<div className="footer-reply-area"> <div className="footer-reply-area">
@ -280,10 +272,6 @@ class MessageList extends React.Component
node = React.findDOMNode(@) node = React.findDOMNode(@)
Actions.printThread(@state.currentThread, node.innerHTML) Actions.printThread(@state.currentThread, node.innerHTML)
_onRemoveLabel: (label) =>
task = new ChangeLabelsTask(thread: @state.currentThread, labelsToRemove: [label])
Actions.queueTask(task)
_onClickReplyArea: => _onClickReplyArea: =>
return unless @state.currentThread return unless @state.currentThread
@_createReplyOrUpdateExistingDraft(@_replyType()) @_createReplyOrUpdateExistingDraft(@_replyType())

View file

@ -120,6 +120,17 @@ body.platform-win32 {
padding: 0; padding: 0;
order: 2; order: 2;
.show-hidden-messages {
background-color: darken(@background-secondary, 4%);
border: 1px solid darken(@background-secondary, 8%);
border-radius: @border-radius-base;
color: @text-color-very-subtle;
margin-bottom: @padding-large-vertical;
cursor: default;
padding: @padding-base-vertical @padding-base-horizontal;
a { float: right; }
}
.message-subject-wrap { .message-subject-wrap {
width: calc(~"100% - 12px"); width: calc(~"100% - 12px");
max-width: @message-max-width; max-width: @message-max-width;
@ -155,6 +166,9 @@ body.platform-win32 {
margin-left: @padding-small-horizontal; margin-left: @padding-small-horizontal;
} }
} }
.thread-injected-mail-labels {
vertical-align: top;
}
.message-list-headers { .message-list-headers {
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;

View file

@ -4,14 +4,11 @@ classNames = require 'classnames'
{ListTabular, {ListTabular,
RetinaImg, RetinaImg,
MailLabel, MailLabelSet,
MailImportantIcon, MailImportantIcon,
InjectedComponentSet} = require 'nylas-component-kit' InjectedComponentSet} = require 'nylas-component-kit'
{Thread, {Thread} = require 'nylas-exports'
AccountStore,
CategoryStore,
FocusedPerspectiveStore} = require 'nylas-exports'
{ThreadArchiveQuickAction, {ThreadArchiveQuickAction,
ThreadTrashQuickAction} = require './thread-list-quick-actions' ThreadTrashQuickAction} = require './thread-list-quick-actions'
@ -57,36 +54,16 @@ c2 = new ListTabular.Column
else else
<ThreadListParticipants thread={thread} /> <ThreadListParticipants thread={thread} />
c3LabelComponentCache = {}
c3 = new ListTabular.Column c3 = new ListTabular.Column
name: "Message" name: "Message"
flex: 4 flex: 4
resolver: (thread) => resolver: (thread) =>
attachment = [] attachment = []
labels = []
if thread.hasAttachments if thread.hasAttachments
attachment = <div className="thread-icon thread-icon-attachment"></div> attachment = <div className="thread-icon thread-icon-attachment"></div>
if AccountStore.accountForId(thread.accountId).usesLabels()
currentCategories = FocusedPerspectiveStore.current().categories() ? []
ignored = [].concat(currentCategories, CategoryStore.hiddenCategories(thread.accountId))
ignoredIds = _.pluck(ignored, 'id')
for label in (thread.sortedCategories())
continue if label.id in ignoredIds
c3LabelComponentCache[label.id] ?= <MailLabel label={label} key={label.id} />
labels.push c3LabelComponentCache[label.id]
<span className="details"> <span className="details">
<InjectedComponentSet <MailLabelSet thread={thread} />
inline
containersRequired={false}
children={labels}
matching={role: "ThreadList:Label"}
className="thread-injected-mail-labels"
exposedProps={thread: thread}/>
<span className="subject">{subject(thread.subject)}</span> <span className="subject">{subject(thread.subject)}</span>
<span className="snippet">{thread.snippet}</span> <span className="snippet">{thread.snippet}</span>
{attachment} {attachment}
@ -127,17 +104,6 @@ cNarrow = new ListTabular.Column
if hasDraft if hasDraft
pencil = <RetinaImg name="icon-draft-pencil.png" className="draft-icon" mode={RetinaImg.Mode.ContentPreserve} /> pencil = <RetinaImg name="icon-draft-pencil.png" className="draft-icon" mode={RetinaImg.Mode.ContentPreserve} />
labels = []
if AccountStore.accountForId(thread.accountId).usesLabels()
currentCategories = FocusedPerspectiveStore.current().categories() ? []
ignored = [].concat(currentCategories, CategoryStore.hiddenCategories(thread.accountId))
ignoredIds = _.pluck(ignored, 'id')
for label in (thread.sortedCategories())
continue if label.id in ignoredIds
c3LabelComponentCache[label.id] ?= <MailLabel label={label} key={label.id} />
labels.push c3LabelComponentCache[label.id]
<div> <div>
<div style={display: 'flex', alignItems: 'center'}> <div style={display: 'flex', alignItems: 'center'}>
<ThreadListIcon thread={thread} /> <ThreadListIcon thread={thread} />
@ -154,13 +120,7 @@ cNarrow = new ListTabular.Column
<div className="snippet-and-labels"> <div className="snippet-and-labels">
<div className="snippet">{thread.snippet}&nbsp;</div> <div className="snippet">{thread.snippet}&nbsp;</div>
<div style={flex: 1, flexShrink: 1}></div> <div style={flex: 1, flexShrink: 1}></div>
<InjectedComponentSet <MailLabelSet thread={thread} />
inline
containerRequired={false}
children={labels}
matching={role: "ThreadList:Label"}
className="thread-injected-mail-labels-narrow"
exposedProps={thread: thread}/>
</div> </div>
</div> </div>

View file

@ -446,6 +446,12 @@ body.platform-win32 {
padding: 1px 8px; padding: 1px 8px;
font-size: 0.8em; font-size: 0.8em;
line-height: 17px; line-height: 17px;
.inner {
position: inherit;
}
.x {
display: none;
}
} }
.snippet { .snippet {
font-size: @font-size-small; font-size: @font-size-small;

View file

@ -11,7 +11,7 @@ export function activate() {
ComponentRegistry.register(ToolbarSnooze, {role: 'message:Toolbar'}); ComponentRegistry.register(ToolbarSnooze, {role: 'message:Toolbar'});
ComponentRegistry.register(SnoozeQuickActionButton, {role: 'ThreadListQuickAction'}); ComponentRegistry.register(SnoozeQuickActionButton, {role: 'ThreadListQuickAction'});
ComponentRegistry.register(BulkThreadSnooze, {role: 'thread:BulkAction'}); ComponentRegistry.register(BulkThreadSnooze, {role: 'thread:BulkAction'});
ComponentRegistry.register(SnoozeMailLabel, {role: 'ThreadList:Label'}); ComponentRegistry.register(SnoozeMailLabel, {role: 'Thread:MailLabel'});
} }
export function deactivate() { export function deactivate() {

View file

@ -37,7 +37,6 @@ describe 'MailboxPerspective', ->
@perspective = MailboxPerspective.forCategories(@categories) @perspective = MailboxPerspective.forCategories(@categories)
describe 'canReceiveThreads', -> describe 'canReceiveThreads', ->
it 'returns true if the thread account ids are included in the current account ids', -> it 'returns true if the thread account ids are included in the current account ids', ->
expect(@perspective.canReceiveThreads(['a2'])).toBe true expect(@perspective.canReceiveThreads(['a2'])).toBe true
@ -52,6 +51,22 @@ describe 'MailboxPerspective', ->
) )
expect(@perspective.canReceiveThreads(['a2'])).toBe false expect(@perspective.canReceiveThreads(['a2'])).toBe false
describe 'categoriesSharedName', ->
it "returns the name if all the categories on the perspective have the same name", ->
expect(MailboxPerspective.forCategories([
new Category(name: 'c1', accountId: 'a1')
new Category(name: 'c1', accountId: 'a2')
]).categoriesSharedName()).toEqual('c1')
it "returns null if there are no categories", ->
expect(MailboxPerspective.forStarred(['a1', 'a2']).categoriesSharedName()).toEqual(null)
it "returns null if the categories have different names", ->
expect(MailboxPerspective.forCategories([
new Category(name: 'c1', accountId: 'a1')
new Category(name: 'c2', accountId: 'a2')
]).categoriesSharedName()).toEqual(null)
describe 'receiveThreads', -> describe 'receiveThreads', ->
# TODO # TODO

View file

@ -0,0 +1,68 @@
import React from 'react';
import FocusedPerspectiveStore from '../flux/stores/focused-perspective-store';
import CategoryStore from '../flux/stores/category-store';
import MessageStore from '../flux/stores/message-store';
import AccountStore from '../flux/stores/account-store';
import {MailLabel} from './mail-label';
import Actions from '../flux/actions';
import ChangeLabelsTask from '../flux/tasks/change-labels-task';
import InjectedComponentSet from './injected-component-set';
const LabelComponentCache = {};
export default class MailLabelSet extends React.Component {
static displayName = 'MailLabelSet';
static propTypes = {
thread: React.PropTypes.object.isRequired,
includeCurrentCategories: React.PropTypes.boolean,
};
_onRemoveLabel(label) {
const task = new ChangeLabelsTask({
thread: this.props.thread,
labelsToRemove: [label],
});
Actions.queueTask(task);
}
render() {
const {thread, includeCurrentCategories} = this.props;
const labels = [];
if (AccountStore.accountForId(thread.accountId).usesLabels()) {
const hidden = CategoryStore.hiddenCategories(thread.accountId);
let current = FocusedPerspectiveStore.current().categories();
if (includeCurrentCategories || !current) {
current = [];
}
const ignoredIds = [].concat(hidden, current).map(l=> l.id);
const ignoredNames = MessageStore.CategoryNamesHiddenByDefault;
for (const label of thread.sortedCategories()) {
if (ignoredNames.includes(label.name) || ignoredIds.includes(label.id)) {
continue;
}
if (LabelComponentCache[label.id] === undefined) {
LabelComponentCache[label.id] = (
<MailLabel
label={label}
key={label.id}
onRemove={()=> this._onRemoveLabel(label)}/>
);
}
labels.push(LabelComponentCache[label.id]);
}
}
return (<InjectedComponentSet
inline
containersRequired={false}
children={labels}
matching={{role: "Thread:MailLabel"}}
className="thread-injected-mail-labels"
exposedProps={{thread: thread}}/>
);
}
}

View file

@ -263,6 +263,12 @@ class Actions
### ###
@toggleMessageIdExpanded: ActionScopeWindow @toggleMessageIdExpanded: ActionScopeWindow
###
Public: Toggle whether messages from trash and spam are shown in the current
message view.
###
@toggleHiddenMessages: ActionScopeWindow
### ###
Public: This action toggles wether to collapse or expand all messages in a Public: This action toggles wether to collapse or expand all messages in a
thread depending on if there are currently collapsed messages. thread depending on if there are currently collapsed messages.

View file

@ -148,11 +148,10 @@ class Message extends ModelWithMetadata
modelKey: 'replyToMessageId' modelKey: 'replyToMessageId'
jsonKey: 'reply_to_message_id' jsonKey: 'reply_to_message_id'
'folder': Attributes.Object 'categories': Attributes.Collection
modelKey: 'folder' modelKey: 'categories'
itemClass: Category itemClass: Category
@naturalSortOrder: -> @naturalSortOrder: ->
Message.attributes.date.ascending() Message.attributes.date.ascending()
@ -174,6 +173,7 @@ class Message extends ModelWithMetadata
@files ||= [] @files ||= []
@uploads ||= [] @uploads ||= []
@events ||= [] @events ||= []
@categories ||= []
@ @
toJSON: (options) -> toJSON: (options) ->
@ -192,7 +192,12 @@ class Message extends ModelWithMetadata
if json.object? if json.object?
@draft = (json.object is 'draft') @draft = (json.object is 'draft')
for attr in ['to', 'from', 'cc', 'bcc', 'files'] if json['folder']
@categories = @constructor.attributes.categories.fromJSON([json['folder']])
else if json['labels']
@categories = @constructor.attributes.categories.fromJSON(json['labels'])
for attr in ['to', 'from', 'cc', 'bcc', 'files', 'categories']
values = @[attr] values = @[attr]
continue unless values and values instanceof Array continue unless values and values instanceof Array
item.accountId = @accountId for item in values item.accountId = @accountId for item in values

View file

@ -16,7 +16,7 @@ DatabaseTransaction = require './database-transaction'
{ipcRenderer} = require 'electron' {ipcRenderer} = require 'electron'
DatabaseVersion = 18 DatabaseVersion = 19
DatabasePhase = DatabasePhase =
Setup: 'setup' Setup: 'setup'
Ready: 'ready' Ready: 'ready'

View file

@ -5,6 +5,7 @@ Thread = require "../models/thread"
Utils = require '../models/utils' Utils = require '../models/utils'
DatabaseStore = require "./database-store" DatabaseStore = require "./database-store"
AccountStore = require "./account-store" AccountStore = require "./account-store"
FocusedPerspectiveStore = require './focused-perspective-store'
FocusedContentStore = require "./focused-content-store" FocusedContentStore = require "./focused-content-store"
ChangeUnreadTask = require '../tasks/change-unread-task' ChangeUnreadTask = require '../tasks/change-unread-task'
NylasAPI = require '../nylas-api' NylasAPI = require '../nylas-api'
@ -13,6 +14,8 @@ ExtensionRegistry = require '../../extension-registry'
async = require 'async' async = require 'async'
_ = require 'underscore' _ = require 'underscore'
CategoryNamesHiddenByDefault = ['spam', 'trash']
class MessageStore extends NylasStore class MessageStore extends NylasStore
constructor: -> constructor: ->
@ -22,7 +25,16 @@ class MessageStore extends NylasStore
########### PUBLIC ##################################################### ########### PUBLIC #####################################################
items: -> items: ->
@_items return @_items if @_showingHiddenItems
viewing = FocusedPerspectiveStore.current().categoriesSharedName()
viewingHidden = viewing in CategoryNamesHiddenByDefault
return @_items.filter (item) ->
inHidden = _.any item.categories, (cat) -> cat.name in CategoryNamesHiddenByDefault
return false if viewingHidden and not inHidden
return false if not viewingHidden and inHidden
return true
threadId: -> @_thread?.id threadId: -> @_thread?.id
@ -36,6 +48,9 @@ class MessageStore extends NylasStore
hasCollapsedItems: -> hasCollapsedItems: ->
_.size(@_itemsExpanded) < @_items.length _.size(@_itemsExpanded) < @_items.length
numberOfHiddenItems: ->
@_items.length - @items().length
itemClientIds: -> itemClientIds: ->
_.pluck(@_items, "clientId") _.pluck(@_items, "clientId")
@ -79,6 +94,7 @@ class MessageStore extends NylasStore
@_items = [] @_items = []
@_itemsExpanded = {} @_itemsExpanded = {}
@_itemsLoading = false @_itemsLoading = false
@_showingHiddenItems = false
@_thread = null @_thread = null
@_inflight = {} @_inflight = {}
@ -88,6 +104,11 @@ class MessageStore extends NylasStore
@listenTo FocusedContentStore, @_onFocusChanged @listenTo FocusedContentStore, @_onFocusChanged
@listenTo Actions.toggleMessageIdExpanded, @_onToggleMessageIdExpanded @listenTo Actions.toggleMessageIdExpanded, @_onToggleMessageIdExpanded
@listenTo Actions.toggleAllMessagesExpanded, @_onToggleAllMessagesExpanded @listenTo Actions.toggleAllMessagesExpanded, @_onToggleAllMessagesExpanded
@listenTo Actions.toggleHiddenMessages, @_onToggleHiddenMessages
@listenTo FocusedPerspectiveStore, @_onPerspectiveChanged
_onPerspectiveChanged: =>
@trigger()
_onDataChanged: (change) => _onDataChanged: (change) =>
return unless @_thread return unless @_thread
@ -151,6 +172,7 @@ class MessageStore extends NylasStore
@_thread = focused @_thread = focused
@_items = [] @_items = []
@_itemsLoading = true @_itemsLoading = true
@_showingHiddenItems = false
@_itemsExpanded = {} @_itemsExpanded = {}
@trigger() @trigger()
@ -187,6 +209,13 @@ class MessageStore extends NylasStore
@_items[...-1].forEach @_collapseItem @_items[...-1].forEach @_collapseItem
@trigger() @trigger()
_onToggleHiddenMessages: =>
@_showingHiddenItems = !@_showingHiddenItems
@_expandItemsToDefault()
@_fetchExpandedBodies(@_items)
@_fetchExpandedAttachments(@_items)
@trigger()
_onToggleMessageIdExpanded: (id) => _onToggleMessageIdExpanded: (id) =>
item = _.findWhere(@_items, {id}) item = _.findWhere(@_items, {id})
return unless item return unless item
@ -269,8 +298,9 @@ class MessageStore extends NylasStore
# Expand all unread messages, all drafts, and the last message # Expand all unread messages, all drafts, and the last message
_expandItemsToDefault: -> _expandItemsToDefault: ->
for item, idx in @_items visibleItems = @items()
if item.unread or item.draft or idx is @_items.length - 1 for item, idx in visibleItems
if item.unread or item.draft or idx is visibleItems.length - 1
@_itemsExpanded[item.id] = "default" @_itemsExpanded[item.id] = "default"
_fetchMessages: -> _fetchMessages: ->
@ -327,4 +357,6 @@ store.unregisterExtension = deprecate(
store, store,
store.unregisterExtension store.unregisterExtension
) )
store.CategoryNamesHiddenByDefault = CategoryNamesHiddenByDefault
module.exports = store module.exports = store

View file

@ -44,6 +44,7 @@ class NylasComponentKit
@loadFrom "MailLabel", "mail-label" @loadFrom "MailLabel", "mail-label"
@loadFrom "LabelColorizer", "mail-label" @loadFrom "LabelColorizer", "mail-label"
@load "MailLabelSet", "mail-label-set"
@load "MailImportantIcon", 'mail-important-icon' @load "MailImportantIcon", 'mail-important-icon'
@loadFrom "FormItem", "generated-form" @loadFrom "FormItem", "generated-form"

View file

@ -81,6 +81,13 @@ class MailboxPerspective
categories: => categories: =>
[] []
categoriesSharedName: =>
cats = @categories()
return null unless cats and cats.length > 0
name = cats[0].name
return null unless _.every cats, (cat) -> cat.name is name
return name
category: => category: =>
return null unless @categories().length is 1 return null unless @categories().length is 1
return @categories()[0] return @categories()[0]
@ -282,7 +289,7 @@ class CategoryMailboxPerspective extends MailboxPerspective
@_categories @_categories
isInbox: => isInbox: =>
@_categories[0].name is 'inbox' @categoriesSharedName() is 'inbox'
canReceiveThreads: => canReceiveThreads: =>
super and not _.any @_categories, (c) -> c.isLockedCategory() super and not _.any @_categories, (c) -> c.isLockedCategory()