mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-03-09 06:23:30 +08:00
feat(counts): Unread counts for all folders and labels across all accounts
Summary: This diff replaces the UnreadCountStore with a better approach that is able to track unread counts for all folders/labels without continuous (and cripplingly slow) SELECT COUNT(*) queries. When models are written to the database, we currently don't send out notifications with the "previous" state of those objects in the database. This makes it hard to determine how to update counters. (In the future, we may need to do this for live queries). Unfortunately, getting the "previous" state is going to be very hard, because multiple windows write to the database and the "previous" state we have might be outdated. We'd almost have to run a "SELECT" right before every "REPLACE INTO". I created an API that allows you to register observers around persistModel and unpersistModel. With this API, you can run queries before and after the database changes are made and pluck just the "before" state you're interested in. The `ThreadCountsStore` uses this API to determine the impact of persisting a set of threads on the unread counts of different labels. Before the threads are saved, it says "how much do these thread IDs contribute to unread counts currently?". After the write is complete it looks at the models and computes the difference between the old count impact and the new count impact, and updates the counters. I decided not to attach the unread count to the Label objects themselves because 1) they update frequently and 2) most things observing the DatabaseStore for categories do not care about counts, so they would be updating unnecessarily. The AccountSidebar now listens to the ThreadCountsStore as well as the CategoryStore, and there's a new preference in the General tab for turning off the counts. Test Plan: Tests are a work in progress, want to get feedback first! Reviewers: juan, evan Reviewed By: evan Differential Revision: https://phab.nylas.com/D2232
This commit is contained in:
parent
c1d023a572
commit
e1882ab61a
20 changed files with 679 additions and 295 deletions
|
@ -2,7 +2,7 @@ React = require 'react'
|
|||
classNames = require 'classnames'
|
||||
{Actions,
|
||||
Utils,
|
||||
UnreadCountStore,
|
||||
ThreadCountsStore,
|
||||
WorkspaceStore,
|
||||
AccountStore,
|
||||
FocusedMailViewStore,
|
||||
|
@ -17,29 +17,16 @@ class AccountSidebarMailViewItem extends React.Component
|
|||
@propTypes:
|
||||
select: React.PropTypes.bool
|
||||
item: React.PropTypes.object.isRequired
|
||||
itemUnreadCount: React.PropTypes.number
|
||||
mailView: React.PropTypes.object.isRequired
|
||||
|
||||
constructor: (@props) ->
|
||||
@state =
|
||||
unreadCount: UnreadCountStore.count() ? 0
|
||||
|
||||
componentWillMount: =>
|
||||
@_usub = UnreadCountStore.listen @_onUnreadCountChange
|
||||
|
||||
componentWillUnmount: =>
|
||||
@_usub()
|
||||
|
||||
_onUnreadCountChange: =>
|
||||
@setState unreadCount: UnreadCountStore.count()
|
||||
@state = {}
|
||||
|
||||
shouldComponentUpdate: (nextProps, nextState) =>
|
||||
!Utils.isEqualReact(@props, nextProps) or !Utils.isEqualReact(@state, nextState)
|
||||
|
||||
render: =>
|
||||
unread = []
|
||||
if @props.mailView.category?.name is "inbox" and @state.unreadCount > 0
|
||||
unread = <div className="unread item-count-box">{@state.unreadCount}</div>
|
||||
|
||||
containerClass = classNames
|
||||
'item': true
|
||||
'selected': @props.select
|
||||
|
@ -51,12 +38,17 @@ class AccountSidebarMailViewItem extends React.Component
|
|||
shouldAcceptDrop={@_shouldAcceptDrop}
|
||||
onDragStateChange={ ({isDropping}) => @setState({isDropping}) }
|
||||
onDrop={@_onDrop}>
|
||||
{unread}
|
||||
|
||||
{@_renderUnreadCount()}
|
||||
<div className="icon">{@_renderIcon()}</div>
|
||||
<div className="name">{@props.item.name}</div>
|
||||
</DropZone>
|
||||
|
||||
_renderUnreadCount: =>
|
||||
return false if @props.itemUnreadCount is 0
|
||||
className = 'item-count-box '
|
||||
className += @props.mailView.category?.name
|
||||
<div className={className}>{@props.itemUnreadCount}</div>
|
||||
|
||||
_renderIcon: ->
|
||||
<RetinaImg name={@props.mailView.iconName} fallback={'folder.png'} mode={RetinaImg.Mode.ContentIsMask} />
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
React = require 'react'
|
||||
_ = require 'underscore'
|
||||
{Actions, MailViewFilter, WorkspaceStore} = require("nylas-exports")
|
||||
{Actions, MailViewFilter, WorkspaceStore, ThreadCountsStore} = require("nylas-exports")
|
||||
{ScrollRegion, Flexbox} = require("nylas-component-kit")
|
||||
SidebarDividerItem = require("./account-sidebar-divider-item")
|
||||
SidebarSheetItem = require("./account-sidebar-sheet-item")
|
||||
|
@ -38,9 +38,12 @@ class AccountSidebar extends React.Component
|
|||
componentDidMount: =>
|
||||
@unsubscribers = []
|
||||
@unsubscribers.push AccountSidebarStore.listen @_onStoreChange
|
||||
@unsubscribers.push ThreadCountsStore.listen @_onStoreChange
|
||||
@configSubscription = NylasEnv.config.observe('core.workspace.showUnreadForAllCategories', @_onStoreChange)
|
||||
|
||||
componentWillUnmount: =>
|
||||
unsubscribe() for unsubscribe in @unsubscribers
|
||||
@configSubscription?.dispose()
|
||||
|
||||
render: =>
|
||||
<ScrollRegion style={flex:1} id="account-sidebar">
|
||||
|
@ -79,6 +82,12 @@ class AccountSidebar extends React.Component
|
|||
|
||||
components
|
||||
|
||||
_itemUnreadCount: (item) =>
|
||||
category = item.mailViewFilter.category
|
||||
if category and (category.name is 'inbox' or @state.unreadCountsForAll)
|
||||
return @state.unreadCounts[category.id]
|
||||
return 0
|
||||
|
||||
_itemComponent: (item) =>
|
||||
unless item instanceof WorkspaceStore.SidebarItem
|
||||
throw new Error("AccountSidebar:_itemComponents: sections contained an \
|
||||
|
@ -93,6 +102,7 @@ class AccountSidebar extends React.Component
|
|||
else if item.mailViewFilter
|
||||
<AccountSidebarMailViewItem
|
||||
item={item}
|
||||
itemUnreadCount={@_itemUnreadCount(item)}
|
||||
mailView={item.mailViewFilter}
|
||||
select={item.mailViewFilter.isEqual(@state.selected)} />
|
||||
|
||||
|
@ -117,6 +127,8 @@ class AccountSidebar extends React.Component
|
|||
_getStateFromStores: =>
|
||||
sections: AccountSidebarStore.sections()
|
||||
selected: AccountSidebarStore.selected()
|
||||
unreadCounts: ThreadCountsStore.unreadCounts()
|
||||
unreadCountsForAll: NylasEnv.config.get('core.workspace.showUnreadForAllCategories')
|
||||
|
||||
|
||||
module.exports = AccountSidebar
|
||||
|
|
|
@ -87,15 +87,21 @@
|
|||
order: 3;
|
||||
flex-shrink: 0;
|
||||
font-weight: @font-weight-semi-bold;
|
||||
color: @source-list-active-bg;
|
||||
color: fadeout(@text-color-subtle, 50%);
|
||||
margin-left: @padding-small-horizontal * 0.8;
|
||||
box-shadow: inset 0 0 1px @text-color-subtle;
|
||||
}
|
||||
.unread {
|
||||
.item-count-box.inbox {
|
||||
color: @source-list-active-bg;
|
||||
background: @source-list-active-color;
|
||||
box-shadow: none;
|
||||
}
|
||||
.count {
|
||||
background: #b4bbc3;
|
||||
.item-count-box.archive,
|
||||
.item-count-box.all,
|
||||
.item-count-box.spam {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: @source-list-active-bg;
|
||||
color: @source-list-active-color;
|
||||
|
|
|
@ -105,6 +105,11 @@ class WorkspaceSection extends React.Component
|
|||
keyPath="core.workspace.showImportant"
|
||||
config={@props.config} />
|
||||
|
||||
<ConfigSchemaItem
|
||||
configSchema={@props.configSchema.properties.workspace.properties.showUnreadForAllCategories}
|
||||
keyPath="core.workspace.showUnreadForAllCategories"
|
||||
config={@props.config} />
|
||||
|
||||
<div className="item">
|
||||
<input type="checkbox"
|
||||
id="dark"
|
||||
|
|
|
@ -2,7 +2,7 @@ import path from 'path';
|
|||
import remote from 'remote';
|
||||
import ipc from 'ipc';
|
||||
import NylasStore from 'nylas-store';
|
||||
import {UnreadCountStore, CanvasUtils} from 'nylas-exports';
|
||||
import {UnreadBadgeStore, CanvasUtils} from 'nylas-exports';
|
||||
const NativeImage = remote.require('native-image');
|
||||
const Menu = remote.require('menu');
|
||||
const {canvasWithSystemTrayIconAndText} = CanvasUtils;
|
||||
|
@ -49,10 +49,10 @@ class TrayStore extends NylasStore {
|
|||
|
||||
this._unreadIcon = NativeImage.createFromPath(UNREAD_ICON_PATH);
|
||||
this._baseIcon = NativeImage.createFromPath(BASE_ICON_PATH);
|
||||
this._unreadCount = UnreadCountStore.count() || 0;
|
||||
this._unreadCount = UnreadBadgeStore.count() || 0;
|
||||
this._menu = _buildMenu(platform);
|
||||
this._icon = this._getIconImg();
|
||||
this.listenTo(UnreadCountStore, this._onUnreadCountChanged);
|
||||
this.listenTo(UnreadBadgeStore, this._onUnreadCountChanged);
|
||||
}
|
||||
|
||||
unreadCount() {
|
||||
|
@ -96,7 +96,7 @@ class TrayStore extends NylasStore {
|
|||
}
|
||||
|
||||
_onUnreadCountChanged() {
|
||||
this._unreadCount = UnreadCountStore.count();
|
||||
this._unreadCount = UnreadBadgeStore.count();
|
||||
this._icon = this._getIconImg();
|
||||
this.trigger();
|
||||
}
|
||||
|
|
2
spec/fixtures/db-test-model.coffee
vendored
2
spec/fixtures/db-test-model.coffee
vendored
|
@ -108,7 +108,5 @@ TestModel.configureWithAdditionalSQLiteConfig = ->
|
|||
TestModel.additionalSQLiteConfig =
|
||||
setup: ->
|
||||
['CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_received_timestamp DESC, account_id, id)']
|
||||
writeModel: jasmine.createSpy('additionalWriteModel')
|
||||
deleteModel: jasmine.createSpy('additionalDeleteModel')
|
||||
|
||||
module.exports = TestModel
|
||||
|
|
|
@ -119,24 +119,14 @@ describe "DatabaseStore", ->
|
|||
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = 'b' ")
|
||||
|
||||
describe "persistModel", ->
|
||||
it "should cause the DatabaseStore to trigger with a change that contains the model", ->
|
||||
waitsForPromise ->
|
||||
DatabaseStore.persistModel(testModelInstance).then ->
|
||||
expect(DatabaseStore._accumulateAndTrigger).toHaveBeenCalled()
|
||||
|
||||
change = DatabaseStore._accumulateAndTrigger.mostRecentCall.args[0]
|
||||
expect(change).toEqual({objectClass: TestModel.name, objects: [testModelInstance], type:'persist'})
|
||||
.catch (err) ->
|
||||
console.log err
|
||||
|
||||
it "should call through to _writeModels", ->
|
||||
spyOn(DatabaseStore, '_writeModels').andReturn Promise.resolve()
|
||||
DatabaseStore.persistModel(testModelInstance)
|
||||
expect(DatabaseStore._writeModels.callCount).toBe(1)
|
||||
|
||||
it "should throw an exception if the model is not a subclass of Model", ->
|
||||
expect(-> DatabaseStore.persistModel({id: 'asd', subject: 'bla'})).toThrow()
|
||||
|
||||
it "should call through to persistModels", ->
|
||||
spyOn(DatabaseStore, 'persistModels').andReturn Promise.resolve()
|
||||
DatabaseStore.persistModel(testModelInstance)
|
||||
expect(DatabaseStore.persistModels.callCount).toBe(1)
|
||||
|
||||
describe "persistModels", ->
|
||||
it "should cause the DatabaseStore to trigger with a change that contains the models", ->
|
||||
waitsForPromise ->
|
||||
|
@ -152,6 +142,7 @@ describe "DatabaseStore", ->
|
|||
it "should call through to _writeModels after checking them", ->
|
||||
spyOn(DatabaseStore, '_writeModels').andReturn Promise.resolve()
|
||||
DatabaseStore.persistModels([testModelInstanceA, testModelInstanceB])
|
||||
advanceClock()
|
||||
expect(DatabaseStore._writeModels.callCount).toBe(1)
|
||||
|
||||
it "should throw an exception if the models are not the same class,\
|
||||
|
@ -161,6 +152,72 @@ describe "DatabaseStore", ->
|
|||
it "should throw an exception if the models are not a subclass of Model", ->
|
||||
expect(-> DatabaseStore.persistModels([{id: 'asd', subject: 'bla'}])).toThrow()
|
||||
|
||||
describe "mutationHooks", ->
|
||||
beforeEach ->
|
||||
@beforeShouldThrow = false
|
||||
@beforeShouldReject = false
|
||||
@beforeDatabaseChange = jasmine.createSpy('beforeDatabaseChange').andCallFake =>
|
||||
throw new Error("beforeShouldThrow") if @beforeShouldThrow
|
||||
new Promise (resolve, reject) =>
|
||||
setTimeout =>
|
||||
return resolve(new Error("beforeShouldReject")) if @beforeShouldReject
|
||||
resolve("value")
|
||||
, 1000
|
||||
|
||||
@afterDatabaseChange = jasmine.createSpy('afterDatabaseChange').andCallFake =>
|
||||
new Promise (resolve, reject) ->
|
||||
setTimeout(( => resolve()), 1000)
|
||||
|
||||
@hook = {@beforeDatabaseChange, @afterDatabaseChange}
|
||||
DatabaseStore.addMutationHook(@hook)
|
||||
|
||||
@writeModelsResolve = null
|
||||
spyOn(DatabaseStore, '_writeModels').andCallFake =>
|
||||
new Promise (resolve, reject) =>
|
||||
@writeModelsResolve = resolve
|
||||
|
||||
afterEach ->
|
||||
DatabaseStore.removeMutationHook(@hook)
|
||||
|
||||
it "should run pre-mutation hooks, wait to write models, and then run post-mutation hooks", ->
|
||||
DatabaseStore.persistModels([testModelInstanceA, testModelInstanceB])
|
||||
expect(@beforeDatabaseChange).toHaveBeenCalledWith(
|
||||
DatabaseStore._query,
|
||||
[testModelInstanceA, testModelInstanceB],
|
||||
[testModelInstanceA.id, testModelInstanceB.id],
|
||||
undefined
|
||||
)
|
||||
expect(DatabaseStore._writeModels).not.toHaveBeenCalled()
|
||||
advanceClock(1100)
|
||||
advanceClock()
|
||||
expect(DatabaseStore._writeModels).toHaveBeenCalled()
|
||||
expect(@afterDatabaseChange).not.toHaveBeenCalled()
|
||||
@writeModelsResolve()
|
||||
advanceClock()
|
||||
advanceClock()
|
||||
expect(@afterDatabaseChange).toHaveBeenCalledWith(
|
||||
DatabaseStore._query,
|
||||
[testModelInstanceA, testModelInstanceB],
|
||||
[testModelInstanceA.id, testModelInstanceB.id],
|
||||
"value"
|
||||
)
|
||||
|
||||
it "should carry on if a pre-mutation hook throws", ->
|
||||
@beforeShouldThrow = true
|
||||
DatabaseStore.persistModels([testModelInstanceA, testModelInstanceB])
|
||||
expect(@beforeDatabaseChange).toHaveBeenCalled()
|
||||
advanceClock()
|
||||
advanceClock()
|
||||
expect(DatabaseStore._writeModels).toHaveBeenCalled()
|
||||
|
||||
it "should carry on if a pre-mutation hook rejects", ->
|
||||
@beforeShouldReject = true
|
||||
DatabaseStore.persistModels([testModelInstanceA, testModelInstanceB])
|
||||
expect(@beforeDatabaseChange).toHaveBeenCalled()
|
||||
advanceClock()
|
||||
advanceClock()
|
||||
expect(DatabaseStore._writeModels).toHaveBeenCalled()
|
||||
|
||||
describe "unpersistModel", ->
|
||||
it "should delete the model by Id", -> waitsForPromise =>
|
||||
DatabaseStore.unpersistModel(testModelInstance).then =>
|
||||
|
@ -176,20 +233,6 @@ describe "DatabaseStore", ->
|
|||
change = DatabaseStore._accumulateAndTrigger.mostRecentCall.args[0]
|
||||
expect(change).toEqual({objectClass: TestModel.name, objects: [testModelInstance], type:'unpersist'})
|
||||
|
||||
describe "when the model provides additional sqlite config", ->
|
||||
beforeEach ->
|
||||
TestModel.configureWithAdditionalSQLiteConfig()
|
||||
|
||||
it "should call the deleteModel method and provide the model", ->
|
||||
waitsForPromise ->
|
||||
DatabaseStore.unpersistModel(testModelInstance).then ->
|
||||
expect(TestModel.additionalSQLiteConfig.deleteModel).toHaveBeenCalled()
|
||||
expect(TestModel.additionalSQLiteConfig.deleteModel.mostRecentCall.args[0]).toBe(testModelInstance)
|
||||
|
||||
it "should not fail if additional config is present, but deleteModel is not defined", ->
|
||||
delete TestModel.additionalSQLiteConfig['deleteModel']
|
||||
expect( => DatabaseStore.unpersistModel(testModelInstance)).not.toThrow()
|
||||
|
||||
describe "when the model has collection attributes", ->
|
||||
it "should delete all of the elements in the join tables", ->
|
||||
TestModel.configureWithCollectionAttribute()
|
||||
|
@ -315,20 +358,6 @@ describe "DatabaseStore", ->
|
|||
DatabaseStore._writeModels([@m])
|
||||
expect(@performed.length).toBe(1)
|
||||
|
||||
describe "when the model provides additional sqlite config", ->
|
||||
beforeEach ->
|
||||
TestModel.configureWithAdditionalSQLiteConfig()
|
||||
|
||||
it "should call the writeModel method and provide the model", ->
|
||||
@m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
|
||||
DatabaseStore._writeModels([@m])
|
||||
expect(TestModel.additionalSQLiteConfig.writeModel).toHaveBeenCalledWith(@m)
|
||||
|
||||
it "should not fail if additional config is present, but writeModel is not defined", ->
|
||||
delete TestModel.additionalSQLiteConfig['writeModel']
|
||||
@m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
|
||||
expect( => DatabaseStore._writeModels([@m])).not.toThrow()
|
||||
|
||||
describe "atomically", ->
|
||||
beforeEach ->
|
||||
DatabaseStore._atomicPromise = null
|
||||
|
|
246
spec/stores/thread-counts-store-spec.coffee
Normal file
246
spec/stores/thread-counts-store-spec.coffee
Normal file
|
@ -0,0 +1,246 @@
|
|||
_ = require 'underscore'
|
||||
DatabaseStore = require '../../src/flux/stores/database-store'
|
||||
ThreadCountsStore = require '../../src/flux/stores/thread-counts-store'
|
||||
Thread = require '../../src/flux/models/thread'
|
||||
Folder = require '../../src/flux/models/folder'
|
||||
Label = require '../../src/flux/models/label'
|
||||
Matcher = require '../../src/flux/attributes/matcher'
|
||||
|
||||
describe "ThreadCountsStore", ->
|
||||
describe "unreadCountForCategoryId", ->
|
||||
it "returns null if no count exists for the category id", ->
|
||||
expect(ThreadCountsStore.unreadCountForCategoryId('nan')).toBe(null)
|
||||
|
||||
it "returns the count plus any unsaved deltas", ->
|
||||
ThreadCountsStore._counts =
|
||||
'b': 3
|
||||
'a': 5
|
||||
ThreadCountsStore._deltas =
|
||||
'a': -1
|
||||
expect(ThreadCountsStore.unreadCountForCategoryId('a')).toBe(4)
|
||||
expect(ThreadCountsStore.unreadCountForCategoryId('b')).toBe(3)
|
||||
|
||||
describe "when the mutation observer reports count changes", ->
|
||||
it "should merge count deltas into existing count detlas", ->
|
||||
ThreadCountsStore._deltas =
|
||||
'l1': -1
|
||||
'l2': 2
|
||||
ThreadCountsStore._onCountsChanged({'l1': -1, 'l2': 1, 'l3': 2})
|
||||
expect(ThreadCountsStore._deltas).toEqual({
|
||||
'l1': -2,
|
||||
'l2': 3,
|
||||
'l3': 2
|
||||
})
|
||||
|
||||
it "should queue a save of the counts", ->
|
||||
spyOn(ThreadCountsStore, '_saveCountsSoon')
|
||||
ThreadCountsStore._onCountsChanged({'l1': -1, 'l2': 1, 'l3': 2})
|
||||
expect(ThreadCountsStore._saveCountsSoon).toHaveBeenCalled()
|
||||
|
||||
describe "when a folder or label is persisted", ->
|
||||
beforeEach ->
|
||||
@lExisting = new Label(id: "l1", name: "inbox", displayName: "Inbox")
|
||||
ThreadCountsStore._categories = [@lExisting]
|
||||
|
||||
@lCreated = new Label(id: "lNew", displayName: "Hi there!")
|
||||
@lUpdated = @lExisting.clone()
|
||||
@lUpdated.displayName = "Inbox Edited"
|
||||
|
||||
spyOn(ThreadCountsStore, '_fetchCountsMissing')
|
||||
|
||||
describe "in the work window", ->
|
||||
beforeEach ->
|
||||
spyOn(NylasEnv, 'isWorkWindow').andReturn(true)
|
||||
|
||||
it "should add or update it in it's local categories cache", ->
|
||||
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lCreated]})
|
||||
expect(ThreadCountsStore._categories).toEqual([@lExisting, @lCreated])
|
||||
|
||||
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lUpdated]})
|
||||
expect(ThreadCountsStore._categories).toEqual([@lUpdated, @lCreated])
|
||||
|
||||
ThreadCountsStore._categories = []
|
||||
|
||||
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lCreated, @lUpdated]})
|
||||
expect(ThreadCountsStore._categories).toEqual([@lCreated, @lUpdated])
|
||||
|
||||
it "should run _fetchCountsMissing", ->
|
||||
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lUpdated]})
|
||||
expect(ThreadCountsStore._fetchCountsMissing).toHaveBeenCalled()
|
||||
|
||||
describe "in other windows", ->
|
||||
beforeEach ->
|
||||
spyOn(NylasEnv, 'isWorkWindow').andReturn(false)
|
||||
|
||||
it "should do nothing", ->
|
||||
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lCreated]})
|
||||
expect(ThreadCountsStore._categories).toEqual([@lExisting])
|
||||
expect(ThreadCountsStore._fetchCountsMissing).not.toHaveBeenCalled()
|
||||
|
||||
describe "when counts are persisted", ->
|
||||
it "should update it's _counts cache and trigger", ->
|
||||
newCounts = {
|
||||
'abc': 1
|
||||
}
|
||||
spyOn(ThreadCountsStore, 'trigger')
|
||||
ThreadCountsStore._onDatabaseChanged({objectClass: 'JSONObject', objects: [{key: 'UnreadCounts', json: newCounts}]})
|
||||
expect(ThreadCountsStore._counts).toEqual(newCounts)
|
||||
expect(ThreadCountsStore.trigger).toHaveBeenCalled()
|
||||
|
||||
describe "_fetchCountsMissing", ->
|
||||
beforeEach ->
|
||||
ThreadCountsStore._categories = [
|
||||
new Label(id: "l1", name: "inbox", displayName: "Inbox", accountId: 'a1'),
|
||||
new Label(id: "l2", name: "archive", displayName: "Archive", accountId: 'a1'),
|
||||
new Label(id: "l3", displayName: "Happy Days", accountId: 'a1'),
|
||||
new Label(id: "l4", displayName: "Sad Days", accountId: 'a1')
|
||||
]
|
||||
ThreadCountsStore._counts =
|
||||
l1: 10
|
||||
l2: 0
|
||||
|
||||
it "should call _fetchCountForCategory for the first category not already in the counts cache", ->
|
||||
spyOn(ThreadCountsStore, '_fetchCountForCategory').andCallFake ->
|
||||
new Promise (resolve, reject) ->
|
||||
ThreadCountsStore._fetchCountsMissing()
|
||||
|
||||
calls = ThreadCountsStore._fetchCountForCategory.calls
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0].args[0]).toBe(ThreadCountsStore._categories[2])
|
||||
|
||||
describe "when the count promsie finishes", ->
|
||||
beforeEach ->
|
||||
@countResolve = null
|
||||
@countReject = null
|
||||
spyOn(ThreadCountsStore, '_fetchCountForCategory').andCallFake =>
|
||||
new Promise (resolve, reject) =>
|
||||
@countResolve = resolve
|
||||
@countReject = reject
|
||||
|
||||
it "should add it to the count cache", ->
|
||||
ThreadCountsStore._fetchCountsMissing()
|
||||
advanceClock()
|
||||
@countResolve(4)
|
||||
advanceClock()
|
||||
expect(ThreadCountsStore._counts).toEqual({
|
||||
l1: 10
|
||||
l2: 0
|
||||
l3: 4
|
||||
})
|
||||
|
||||
it "should call _fetchCountsMissing again to populate the next missing count", ->
|
||||
ThreadCountsStore._fetchCountsMissing()
|
||||
spyOn(ThreadCountsStore, '_fetchCountsMissing')
|
||||
advanceClock()
|
||||
@countResolve(4)
|
||||
advanceClock()
|
||||
expect(ThreadCountsStore._fetchCountsMissing).toHaveBeenCalled()
|
||||
|
||||
describe "when a count fails", ->
|
||||
it "should not immediately try to count any other categories", ->
|
||||
ThreadCountsStore._fetchCountsMissing()
|
||||
spyOn(ThreadCountsStore, '_fetchCountsMissing')
|
||||
spyOn(console, 'error')
|
||||
advanceClock()
|
||||
@countReject(new Error("Oh man something really bad."))
|
||||
advanceClock()
|
||||
expect(ThreadCountsStore._fetchCountsMissing).not.toHaveBeenCalled()
|
||||
|
||||
describe "_fetchCountForCategory", ->
|
||||
it "should make the appropriate label or folder database query", ->
|
||||
spyOn(DatabaseStore, 'count')
|
||||
Matcher.muid = 0
|
||||
ThreadCountsStore._fetchCountForCategory(new Label(id: 'l1', accountId: 'a1'))
|
||||
Matcher.muid = 0
|
||||
expect(DatabaseStore.count).toHaveBeenCalledWith(Thread, [
|
||||
Thread.attributes.accountId.equal('a1'),
|
||||
Thread.attributes.unread.equal(true),
|
||||
Thread.attributes.labels.contains('l1')
|
||||
])
|
||||
Matcher.muid = 0
|
||||
ThreadCountsStore._fetchCountForCategory(new Folder(id: 'l1', accountId: 'a1'))
|
||||
Matcher.muid = 0
|
||||
expect(DatabaseStore.count).toHaveBeenCalledWith(Thread, [
|
||||
Thread.attributes.accountId.equal('a1'),
|
||||
Thread.attributes.unread.equal(true),
|
||||
Thread.attributes.folders.contains('l1')
|
||||
])
|
||||
|
||||
describe "_saveCounts", ->
|
||||
beforeEach ->
|
||||
ThreadCountsStore._counts =
|
||||
'b': 3
|
||||
'a': 5
|
||||
ThreadCountsStore._deltas =
|
||||
'a': -1
|
||||
'c': 2
|
||||
|
||||
it "should merge the deltas into the counts and reset the deltas, ignoring any deltas for which the initial count has not been run", ->
|
||||
ThreadCountsStore._saveCounts()
|
||||
expect(ThreadCountsStore._counts).toEqual({
|
||||
'b': 3
|
||||
'a': 4
|
||||
})
|
||||
|
||||
it "should persist the new counts to the database", ->
|
||||
spyOn(DatabaseStore, 'persistJSONObject')
|
||||
ThreadCountsStore._saveCounts()
|
||||
expect(DatabaseStore.persistJSONObject).toHaveBeenCalledWith('UnreadCounts', ThreadCountsStore._counts)
|
||||
|
||||
describe "CategoryDatabaseMutationObserver", ->
|
||||
beforeEach ->
|
||||
@label1 = new Label(id: "l1", name: "inbox", displayName: "Inbox")
|
||||
@label2 = new Label(id: "l2", name: "archive", displayName: "Archive")
|
||||
@label3 = new Label(id: "l3", displayName: "Happy Days")
|
||||
@label4 = new Label(id: "l4", displayName: "Sad Days")
|
||||
|
||||
@threadA = new Thread
|
||||
id: "A"
|
||||
unread: true
|
||||
labels: [@label1, @label4]
|
||||
@threadB = new Thread
|
||||
id: "B"
|
||||
unread: true
|
||||
labels: [@label3]
|
||||
@threadC = new Thread
|
||||
id: "C"
|
||||
unread: false
|
||||
labels: [@label1, @label3]
|
||||
|
||||
describe "given a set of modifying models", ->
|
||||
it "should call countsDidChange with the folder / label membership deltas", ->
|
||||
queryResolves = []
|
||||
query = jasmine.createSpy('query').andCallFake =>
|
||||
new Promise (resolve, reject) ->
|
||||
queryResolves.push(resolve)
|
||||
|
||||
countsDidChange = jasmine.createSpy('countsDidChange')
|
||||
m = new ThreadCountsStore.CategoryDatabaseMutationObserver(countsDidChange)
|
||||
|
||||
beforePromise = m.beforeDatabaseChange(query, [@threadA, @threadB, @threadC], [@threadA.id, @threadB.id, @threadC.id])
|
||||
expect(query.callCount).toBe(2)
|
||||
expect(query.calls[0].args[0]).toEqual("SELECT `Thread`.id as id, `Thread-Label`.`value` as catId FROM `Thread` INNER JOIN `Thread-Label` ON `Thread`.`id` = `Thread-Label`.`id` WHERE `Thread`.id IN ('A','B','C') AND `Thread`.unread = 1")
|
||||
expect(query.calls[1].args[0]).toEqual("SELECT `Thread`.id as id, `Thread-Folder`.`value` as catId FROM `Thread` INNER JOIN `Thread-Folder` ON `Thread`.`id` = `Thread-Folder`.`id` WHERE `Thread`.id IN ('A','B','C') AND `Thread`.unread = 1")
|
||||
queryResolves[0]([
|
||||
{id: @threadA.id, catId: @label1.id},
|
||||
{id: @threadA.id, catId: @label3.id},
|
||||
{id: @threadB.id, catId: @label2.id},
|
||||
{id: @threadB.id, catId: @label3.id},
|
||||
])
|
||||
queryResolves[1]([])
|
||||
|
||||
waitsForPromise =>
|
||||
beforePromise.then (result) =>
|
||||
expect(result).toEqual({
|
||||
categories: {
|
||||
l1: -1,
|
||||
l3: -2,
|
||||
l2: -1
|
||||
}
|
||||
})
|
||||
m.afterDatabaseChange(query, [@threadA, @threadB, @threadC], [@threadA.id, @threadB.id, @threadC.id], result)
|
||||
expect(countsDidChange).toHaveBeenCalledWith({
|
||||
l3: -1,
|
||||
l2: -1,
|
||||
l4: 1
|
||||
})
|
15
spec/stores/unread-badge-store-spec.coffee
Normal file
15
spec/stores/unread-badge-store-spec.coffee
Normal file
|
@ -0,0 +1,15 @@
|
|||
Label = require '../../src/flux/models/label'
|
||||
UnreadBadgeStore = require '../../src/flux/stores/unread-badge-store'
|
||||
|
||||
describe "UnreadBadgeStore", ->
|
||||
describe "_setBadgeForCount", ->
|
||||
it "should set the badge correctly", ->
|
||||
spyOn(UnreadBadgeStore, '_setBadge')
|
||||
UnreadBadgeStore._setBadgeForCount(0)
|
||||
expect(UnreadBadgeStore._setBadge).toHaveBeenCalledWith("")
|
||||
UnreadBadgeStore._setBadgeForCount(1)
|
||||
expect(UnreadBadgeStore._setBadge).toHaveBeenCalledWith("1")
|
||||
UnreadBadgeStore._setBadgeForCount(100)
|
||||
expect(UnreadBadgeStore._setBadge).toHaveBeenCalledWith("100")
|
||||
UnreadBadgeStore._setBadgeForCount(1000)
|
||||
expect(UnreadBadgeStore._setBadge).toHaveBeenCalledWith("999+")
|
|
@ -1,75 +0,0 @@
|
|||
UnreadCountStore = require '../../src/flux/stores/unread-count-store'
|
||||
AccountStore = require '../../src/flux/stores/account-store'
|
||||
DatabaseStore = require '../../src/flux/stores/database-store'
|
||||
Folder = require '../../src/flux/models/folder'
|
||||
Label = require '../../src/flux/models/label'
|
||||
Thread = require '../../src/flux/models/thread'
|
||||
Category = require '../../src/flux/models/category'
|
||||
|
||||
describe "UnreadCountStore", ->
|
||||
describe "_fetchCount", ->
|
||||
beforeEach ->
|
||||
NylasEnv.testOrganizationUnit = 'folder'
|
||||
spyOn(DatabaseStore, 'findBy').andCallFake =>
|
||||
Promise.resolve(new Category({id: 'inbox-category-id'}))
|
||||
spyOn(DatabaseStore, 'count').andCallFake =>
|
||||
Promise.resolve(100)
|
||||
|
||||
it "should create the correct query when using folders", ->
|
||||
NylasEnv.testOrganizationUnit = 'folder'
|
||||
UnreadCountStore._fetchCount()
|
||||
advanceClock()
|
||||
expect(DatabaseStore.findBy).toHaveBeenCalledWith(Folder, {name: 'inbox', accountId: TEST_ACCOUNT_ID})
|
||||
|
||||
[Model, Matchers] = DatabaseStore.count.calls[0].args
|
||||
expect(Model).toBe(Thread)
|
||||
expect(Matchers[0].attr.modelKey).toBe('accountId')
|
||||
expect(Matchers[1].attr.modelKey).toBe('unread')
|
||||
expect(Matchers[1].val).toBe(true)
|
||||
expect(Matchers[2].attr.modelKey).toBe('folders')
|
||||
expect(Matchers[2].val).toBe('inbox-category-id')
|
||||
|
||||
it "should create the correct query when using labels", ->
|
||||
NylasEnv.testOrganizationUnit = 'label'
|
||||
UnreadCountStore._fetchCount()
|
||||
advanceClock()
|
||||
expect(DatabaseStore.findBy).toHaveBeenCalledWith(Label, {name: 'inbox', accountId: TEST_ACCOUNT_ID})
|
||||
|
||||
[Model, Matchers] = DatabaseStore.count.calls[0].args
|
||||
expect(Matchers[0].attr.modelKey).toBe('accountId')
|
||||
expect(Matchers[1].attr.modelKey).toBe('unread')
|
||||
expect(Matchers[1].val).toBe(true)
|
||||
expect(Matchers[2].attr.modelKey).toBe('labels')
|
||||
expect(Matchers[2].val).toBe('inbox-category-id')
|
||||
|
||||
it "should not trigger if the unread count is the same", ->
|
||||
spyOn(UnreadCountStore, 'trigger')
|
||||
UnreadCountStore._count = 100
|
||||
UnreadCountStore._fetchCount()
|
||||
advanceClock()
|
||||
expect(UnreadCountStore.trigger).not.toHaveBeenCalled()
|
||||
|
||||
UnreadCountStore._count = 101
|
||||
UnreadCountStore._fetchCount()
|
||||
advanceClock()
|
||||
expect(UnreadCountStore.trigger).toHaveBeenCalled()
|
||||
|
||||
it "should update the badge count", ->
|
||||
UnreadCountStore._count = 101
|
||||
spyOn(UnreadCountStore, '_updateBadgeForCount')
|
||||
UnreadCountStore._fetchCount()
|
||||
advanceClock()
|
||||
expect(UnreadCountStore._updateBadgeForCount).toHaveBeenCalled()
|
||||
|
||||
describe "_updateBadgeForCount", ->
|
||||
it "should set the badge correctly", ->
|
||||
spyOn(UnreadCountStore, '_setBadge')
|
||||
spyOn(NylasEnv, 'isMainWindow').andCallFake -> true
|
||||
UnreadCountStore._updateBadgeForCount(0)
|
||||
expect(UnreadCountStore._setBadge).toHaveBeenCalledWith("")
|
||||
UnreadCountStore._updateBadgeForCount(1)
|
||||
expect(UnreadCountStore._setBadge).toHaveBeenCalledWith("1")
|
||||
UnreadCountStore._updateBadgeForCount(100)
|
||||
expect(UnreadCountStore._setBadge).toHaveBeenCalledWith("100")
|
||||
UnreadCountStore._updateBadgeForCount(1000)
|
||||
expect(UnreadCountStore._setBadge).toHaveBeenCalledWith("999+")
|
|
@ -18,6 +18,10 @@ module.exports =
|
|||
type: 'boolean'
|
||||
default: true
|
||||
title: "Show Gmail-style important markers (Gmail Only)"
|
||||
showUnreadForAllCategories:
|
||||
type: 'boolean'
|
||||
default: false
|
||||
title: "Show unread counts for all folders / labels"
|
||||
disabledPackages:
|
||||
type: 'array'
|
||||
default: []
|
||||
|
|
|
@ -32,6 +32,10 @@ class Category extends Model
|
|||
modelKey: 'displayName'
|
||||
jsonKey: 'display_name'
|
||||
|
||||
'unread': Attributes.Number
|
||||
queryable: true
|
||||
modelKey: 'unread'
|
||||
|
||||
hue: ->
|
||||
return 0 unless @displayName
|
||||
hue = 0
|
||||
|
|
|
@ -90,7 +90,11 @@ module.exports =
|
|||
err = @validateListening(listenable)
|
||||
throw err if err
|
||||
@fetchInitialState listenable, defaultCallback
|
||||
desub = listenable.listen(@[callback] or callback, this)
|
||||
|
||||
resolvedCallback = @[callback] or callback
|
||||
if not resolvedCallback
|
||||
throw new Error("@listenTo called with undefined callback")
|
||||
desub = listenable.listen(resolvedCallback, this)
|
||||
|
||||
unsubscriber = ->
|
||||
index = subs.indexOf(subscriptionobj)
|
||||
|
@ -150,6 +154,9 @@ module.exports =
|
|||
@_emitter.setMaxListeners(50)
|
||||
|
||||
listen: (callback, bindContext) ->
|
||||
if not callback
|
||||
throw new Error("@listen called with undefined callback")
|
||||
|
||||
@setupEmitter()
|
||||
bindContext ?= @
|
||||
aborted = false
|
||||
|
|
|
@ -90,6 +90,8 @@ class DatabaseStore extends NylasStore
|
|||
else
|
||||
@_databasePath = path.join(NylasEnv.getConfigDirPath(),'edgehill.db')
|
||||
|
||||
@_databaseMutationHooks = []
|
||||
|
||||
# Listen to events from the application telling us when the database is ready,
|
||||
# should be closed so it can be deleted, etc.
|
||||
ipc.on('database-phase-change', @_onPhaseChange)
|
||||
|
@ -413,9 +415,7 @@ class DatabaseStore extends NylasStore
|
|||
persistModel: (model) =>
|
||||
unless model and model instanceof Model
|
||||
throw new Error("DatabaseStore::persistModel - You must pass an instance of the Model class.")
|
||||
|
||||
@_writeModels([model]).then =>
|
||||
@_accumulateAndTrigger({objectClass: model.constructor.name, objects: [model], type: 'persist'})
|
||||
@persistModels([model])
|
||||
|
||||
# Public: Asynchronously writes `models` to the cache and triggers a single change
|
||||
# event. Note: Models must be of the same class to be persisted in a batch operation.
|
||||
|
@ -429,10 +429,11 @@ class DatabaseStore extends NylasStore
|
|||
# callbacks failed
|
||||
persistModels: (models=[]) =>
|
||||
return Promise.resolve() if models.length is 0
|
||||
|
||||
klass = models[0].constructor
|
||||
ids = {}
|
||||
|
||||
if not models[0] instanceof Model
|
||||
unless models[0] instanceof Model
|
||||
throw new Error("DatabaseStore::persistModels - You must pass an array of items which descend from the Model class.")
|
||||
|
||||
for model in models
|
||||
|
@ -442,8 +443,15 @@ class DatabaseStore extends NylasStore
|
|||
throw new Error("DatabaseStore::persistModels - You must pass an array of models with different ids. ID #{model.id} is in the set multiple times.")
|
||||
ids[model.id] = true
|
||||
|
||||
@_writeModels(models).then =>
|
||||
@_accumulateAndTrigger({objectClass: models[0].constructor.name, objects: models, type: 'persist'})
|
||||
ids = Object.keys(ids)
|
||||
@_runMutationHooks('beforeDatabaseChange', models, ids).then (data) =>
|
||||
@_writeModels(models).then =>
|
||||
@_runMutationHooks('afterDatabaseChange', models, ids, data)
|
||||
@_accumulateAndTrigger({
|
||||
objectClass: models[0].constructor.name
|
||||
objects: models
|
||||
type: 'persist'
|
||||
})
|
||||
|
||||
# Public: Asynchronously removes `model` from the cache and triggers a change event.
|
||||
#
|
||||
|
@ -455,8 +463,14 @@ class DatabaseStore extends NylasStore
|
|||
# - rejects if any databse query fails or one of the triggering
|
||||
# callbacks failed
|
||||
unpersistModel: (model) =>
|
||||
@_deleteModel(model).then =>
|
||||
@_accumulateAndTrigger({objectClass: model.constructor.name, objects: [model], type: 'unpersist'})
|
||||
@_runMutationHooks('beforeDatabaseChange', [model], [model.id]).then (data) =>
|
||||
@_deleteModel(model).then =>
|
||||
@_runMutationHooks('afterDatabaseChange', [model], [model.id], data)
|
||||
@_accumulateAndTrigger({
|
||||
objectClass: model.constructor.name,
|
||||
objects: [model],
|
||||
type: 'unpersist'
|
||||
})
|
||||
|
||||
persistJSONObject: (key, json) ->
|
||||
jsonString = serializeRegisteredObjects(json)
|
||||
|
@ -469,6 +483,24 @@ class DatabaseStore extends NylasStore
|
|||
data = deserializeRegisteredObjects(results[0].data)
|
||||
Promise.resolve(data)
|
||||
|
||||
addMutationHook: ({beforeDatabaseChange, afterDatabaseChange}) ->
|
||||
throw new Error("DatabaseStore:addMutationHook - You must provide a beforeDatabaseChange function") unless beforeDatabaseChange
|
||||
throw new Error("DatabaseStore:addMutationHook - You must provide a afterDatabaseChange function") unless afterDatabaseChange
|
||||
@_databaseMutationHooks.push({beforeDatabaseChange, afterDatabaseChange})
|
||||
|
||||
removeMutationHook: (hook) ->
|
||||
@_databaseMutationHooks = _.without(@_databaseMutationHooks, hook)
|
||||
|
||||
_runMutationHooks: (selectorName, models, ids, data = []) ->
|
||||
beforePromises = @_databaseMutationHooks.map (hook, idx) =>
|
||||
Promise.try =>
|
||||
hook[selectorName](@_query, models, ids, data[idx])
|
||||
|
||||
Promise.all(beforePromises).catch (e) =>
|
||||
unless NylasEnv.inSpecMode()
|
||||
console.warn("DatabaseStore Hook: #{selectorName} failed", e)
|
||||
Promise.resolve([])
|
||||
|
||||
atomically: (fn) =>
|
||||
maxConcurrent = 1
|
||||
maxQueue = Infinity
|
||||
|
@ -545,7 +577,6 @@ class DatabaseStore extends NylasStore
|
|||
|
||||
klass = models[0].constructor
|
||||
attributes = _.values(klass.attributes)
|
||||
ids = []
|
||||
|
||||
columnAttributes = _.filter attributes, (attr) ->
|
||||
attr.queryable && attr.columnSQL && attr.jsonKey != 'id'
|
||||
|
@ -563,6 +594,7 @@ class DatabaseStore extends NylasStore
|
|||
# an array of the values and a corresponding question mark set
|
||||
values = []
|
||||
marks = []
|
||||
ids = []
|
||||
for model in models
|
||||
json = model.toJSON(joined: false)
|
||||
ids.push(model.id)
|
||||
|
@ -615,13 +647,6 @@ class DatabaseStore extends NylasStore
|
|||
if model[attr.modelKey]?
|
||||
promises.push @_query("REPLACE INTO `#{attr.modelTable}` (`id`, `value`) VALUES (?, ?)", [model.id, model[attr.modelKey]])
|
||||
|
||||
# For each model, execute any other code the model wants to run.
|
||||
# This allows model classes to do things like update a full-text table
|
||||
# that holds a composite of several fields
|
||||
if klass.additionalSQLiteConfig?.writeModel?
|
||||
for model in models
|
||||
promises = promises.concat klass.additionalSQLiteConfig.writeModel(model)
|
||||
|
||||
return Promise.all(promises)
|
||||
|
||||
# Fires the queries required to delete models to the DB
|
||||
|
@ -653,12 +678,6 @@ class DatabaseStore extends NylasStore
|
|||
joinedDataAttributes.forEach (attr) =>
|
||||
promises.push @_query("DELETE FROM `#{attr.modelTable}` WHERE `id` = ?", [model.id])
|
||||
|
||||
# Execute any other code the model wants to run.
|
||||
# This allows model classes to do things like update a full-text table
|
||||
# that holds a composite of several fields, or update entirely
|
||||
# separate database systems
|
||||
promises = promises.concat klass.additionalSQLiteConfig?.deleteModel?(model)
|
||||
|
||||
return Promise.all(promises)
|
||||
|
||||
|
||||
|
|
144
src/flux/stores/thread-counts-store.coffee
Normal file
144
src/flux/stores/thread-counts-store.coffee
Normal file
|
@ -0,0 +1,144 @@
|
|||
Reflux = require 'reflux'
|
||||
_ = require 'underscore'
|
||||
NylasStore = require 'nylas-store'
|
||||
CategoryStore = require './category-store'
|
||||
AccountStore = require './account-store'
|
||||
DatabaseStore = require './database-store'
|
||||
Actions = require '../actions'
|
||||
Thread = require '../models/thread'
|
||||
Folder = require '../models/folder'
|
||||
Label = require '../models/label'
|
||||
|
||||
class CategoryDatabaseMutationObserver
|
||||
constructor: (@_countsDidChange) ->
|
||||
|
||||
beforeDatabaseChange: (query, models, ids) =>
|
||||
if models[0].constructor.name is 'Thread'
|
||||
idString = "'" + ids.join("','") + "'"
|
||||
Promise.props
|
||||
labelData: query("SELECT `Thread`.id as id, `Thread-Label`.`value` as catId FROM `Thread` INNER JOIN `Thread-Label` ON `Thread`.`id` = `Thread-Label`.`id` WHERE `Thread`.id IN (#{idString}) AND `Thread`.unread = 1", [])
|
||||
folderData: query("SELECT `Thread`.id as id, `Thread-Folder`.`value` as catId FROM `Thread` INNER JOIN `Thread-Folder` ON `Thread`.`id` = `Thread-Folder`.`id` WHERE `Thread`.id IN (#{idString}) AND `Thread`.unread = 1", [])
|
||||
.then ({labelData, folderData}) =>
|
||||
categories = {}
|
||||
for collection in [labelData, folderData]
|
||||
for {id, catId} in collection
|
||||
categories[catId] ?= 0
|
||||
categories[catId] -= 1
|
||||
Promise.resolve({categories})
|
||||
else
|
||||
Promise.resolve()
|
||||
|
||||
afterDatabaseChange: (query, models, ids, beforeResolveValue) =>
|
||||
if models[0].constructor.name is 'Thread'
|
||||
{categories} = beforeResolveValue
|
||||
for thread in models
|
||||
continue unless thread.unread
|
||||
for collection in ['labels', 'folders']
|
||||
if thread[collection]
|
||||
for cat in thread[collection]
|
||||
categories[cat.id] ?= 0
|
||||
categories[cat.id] += 1
|
||||
|
||||
for key, val of categories
|
||||
delete categories[key] if val is 0
|
||||
|
||||
if Object.keys(categories).length > 0
|
||||
@_countsDidChange(categories)
|
||||
|
||||
Promise.resolve()
|
||||
|
||||
|
||||
class ThreadCountsStore extends NylasStore
|
||||
CategoryDatabaseMutationObserver: CategoryDatabaseMutationObserver
|
||||
|
||||
constructor: ->
|
||||
@_counts = {}
|
||||
@_deltas = {}
|
||||
@_categories = []
|
||||
@_saveCountsSoon ?= _.throttle(@_saveCounts, 1000)
|
||||
|
||||
@listenTo DatabaseStore, @_onDatabaseChanged
|
||||
DatabaseStore.findJSONObject('UnreadCounts').then (json) =>
|
||||
@_counts = json ? {}
|
||||
@trigger()
|
||||
|
||||
if NylasEnv.isWorkWindow()
|
||||
@_observer = new CategoryDatabaseMutationObserver(@_onCountsChanged)
|
||||
DatabaseStore.addMutationHook(@_observer)
|
||||
@_loadCategories().then =>
|
||||
@_fetchCountsMissing()
|
||||
|
||||
unreadCountForCategoryId: (catId) =>
|
||||
return null unless @_counts[catId]
|
||||
@_counts[catId] + (@_deltas[catId] || 0)
|
||||
|
||||
unreadCounts: =>
|
||||
@_counts
|
||||
|
||||
_onDatabaseChanged: (change) =>
|
||||
if NylasEnv.isWorkWindow()
|
||||
if change.objectClass in [Folder.name, Label.name]
|
||||
for obj in change.objects
|
||||
objIdx = _.findIndex @_categories, (cat) -> cat.id is obj.id
|
||||
if objIdx isnt -1
|
||||
@_categories[objIdx] = obj
|
||||
else
|
||||
@_categories.push(obj)
|
||||
@_fetchCountsMissing()
|
||||
|
||||
if change.objectClass is 'JSONObject' and change.objects[0].key is 'UnreadCounts'
|
||||
@_counts = change.objects[0].json ? {}
|
||||
@trigger()
|
||||
|
||||
_onCountsChanged: (metadata) =>
|
||||
for catId, unread of metadata
|
||||
@_deltas[catId] ?= 0
|
||||
@_deltas[catId] += unread
|
||||
@_saveCountsSoon()
|
||||
|
||||
_loadCategories: =>
|
||||
Promise.props({
|
||||
folders: DatabaseStore.findAll(Folder)
|
||||
labels: DatabaseStore.findAll(Label)
|
||||
}).then ({folders, labels}) =>
|
||||
@_categories = [].concat(folders, labels)
|
||||
Promise.resolve()
|
||||
|
||||
_fetchCountsMissing: =>
|
||||
# Find a category missing a count
|
||||
category = _.find @_categories, (cat) => !@_counts[cat.id]?
|
||||
return @_saveCountsSoon() unless category
|
||||
|
||||
# Fetch the count, populate it in the cache, and then call ourselves to
|
||||
# populate the next missing count
|
||||
@_fetchCountForCategory(category).then (unread) =>
|
||||
@_counts[category.id] = unread
|
||||
@_fetchCountsMissing()
|
||||
|
||||
# This method is not intended to return a promise and it
|
||||
# could cause strange chaining.
|
||||
return null
|
||||
|
||||
_saveCounts: =>
|
||||
for key, count of @_deltas
|
||||
continue if @_counts[key] is undefined
|
||||
@_counts[key] += count
|
||||
delete @_deltas[key]
|
||||
|
||||
DatabaseStore.persistJSONObject('UnreadCounts', @_counts)
|
||||
|
||||
_fetchCountForCategory: (cat) =>
|
||||
if cat instanceof Label
|
||||
categoryAttribute = Thread.attributes.labels
|
||||
else if cat instanceof Folder
|
||||
categoryAttribute = Thread.attributes.folders
|
||||
else
|
||||
throw new Error("Unexpected cat class")
|
||||
|
||||
DatabaseStore.count(Thread, [
|
||||
Thread.attributes.accountId.equal(cat.accountId),
|
||||
Thread.attributes.unread.equal(true),
|
||||
categoryAttribute.contains(cat.id)
|
||||
])
|
||||
|
||||
module.exports = new ThreadCountsStore
|
61
src/flux/stores/unread-badge-store.coffee
Normal file
61
src/flux/stores/unread-badge-store.coffee
Normal file
|
@ -0,0 +1,61 @@
|
|||
Reflux = require 'reflux'
|
||||
_ = require 'underscore'
|
||||
NylasStore = require 'nylas-store'
|
||||
CategoryStore = require './category-store'
|
||||
DatabaseStore = require './database-store'
|
||||
ThreadCountsStore = require './thread-counts-store'
|
||||
|
||||
class UnreadBadgeStore extends NylasStore
|
||||
|
||||
constructor: ->
|
||||
@listenTo CategoryStore, @_onCategoriesChanged
|
||||
@listenTo ThreadCountsStore, @_onCountsChanged
|
||||
@_category = CategoryStore.getStandardCategory('inbox')
|
||||
|
||||
NylasEnv.config.observe 'core.showUnreadBadge', (val) =>
|
||||
if val is true
|
||||
@_setBadgeForCount(@_count)
|
||||
else
|
||||
@_setBadge("")
|
||||
|
||||
@_updateCount()
|
||||
|
||||
# Public: Returns the number of unread threads in the user's mailbox
|
||||
count: ->
|
||||
@_count
|
||||
|
||||
_onCategoriesChanged: =>
|
||||
cat = CategoryStore.getStandardCategory('inbox')
|
||||
return if @_category and cat.id is @_category.id
|
||||
@_category = cat
|
||||
@_updateCount()
|
||||
|
||||
_onCountsChanged: =>
|
||||
@_updateCount()
|
||||
|
||||
_updateCount: =>
|
||||
return unless NylasEnv.isMainWindow()
|
||||
return unless @_category
|
||||
|
||||
count = ThreadCountsStore.unreadCountForCategoryId(@_category.id) ? 0
|
||||
return if @_count is count
|
||||
|
||||
@_count = count
|
||||
@_setBadgeForCount(count)
|
||||
@trigger()
|
||||
|
||||
_setBadgeForCount: (count) =>
|
||||
if count > 999
|
||||
@_setBadge("999+")
|
||||
else if count > 0
|
||||
@_setBadge("#{count}")
|
||||
else
|
||||
@_setBadge("")
|
||||
|
||||
_setBadge: (val) =>
|
||||
# NOTE: Do not underestimate how long this can take. It's a synchronous
|
||||
# remote call and can take ~50+msec.
|
||||
return if NylasEnv.config.get('core.showUnreadBadge') is false
|
||||
require('ipc').send('set-badge-value', val)
|
||||
|
||||
module.exports = new UnreadBadgeStore()
|
|
@ -1,92 +0,0 @@
|
|||
Reflux = require 'reflux'
|
||||
_ = require 'underscore'
|
||||
CategoryStore = require './category-store'
|
||||
AccountStore = require './account-store'
|
||||
DatabaseStore = require './database-store'
|
||||
Actions = require '../actions'
|
||||
Thread = require '../models/thread'
|
||||
Folder = require '../models/folder'
|
||||
Label = require '../models/label'
|
||||
|
||||
###
|
||||
Public: The UnreadCountStore exposes a simple API for getting the number of
|
||||
unread threads in the user's inbox. If you plugin needs the current unread count,
|
||||
it's more efficient to observe the UnreadCountStore than retrieve the value
|
||||
yourself from the database.
|
||||
###
|
||||
UnreadCountStore = Reflux.createStore
|
||||
init: ->
|
||||
@listenTo AccountStore, @_onAccountChanged
|
||||
@listenTo DatabaseStore, @_onDataChanged
|
||||
|
||||
NylasEnv.config.observe 'core.notifications.unreadBadge', (val) =>
|
||||
if val is true
|
||||
@_updateBadgeForCount()
|
||||
else
|
||||
@_setBadge("")
|
||||
|
||||
@_count = null
|
||||
@_fetchCountDebounced ?= _.debounce(@_fetchCount, 5000)
|
||||
_.defer => @_fetchCount()
|
||||
|
||||
# Public: Returns the number of unread threads in the user's mailbox
|
||||
count: ->
|
||||
@_count
|
||||
|
||||
_onAccountChanged: ->
|
||||
@_count = 0
|
||||
@_updateBadgeForCount(0)
|
||||
@trigger()
|
||||
@_fetchCount()
|
||||
|
||||
_onDataChanged: (change) ->
|
||||
if change && change.objectClass is Thread.name
|
||||
@_fetchCountDebounced()
|
||||
|
||||
_fetchCount: ->
|
||||
account = AccountStore.current()
|
||||
return @_setBadge("") unless account
|
||||
|
||||
if account.usesFolders()
|
||||
[CategoryClass, CategoryAttribute] = [Folder, Thread.attributes.folders]
|
||||
else if account.usesLabels()
|
||||
[CategoryClass, CategoryAttribute] = [Label, Thread.attributes.labels]
|
||||
else
|
||||
return
|
||||
|
||||
# Note: We can't use the convenience methods on CategoryStore to fetch the
|
||||
# category because it may not have been loaded yet
|
||||
DatabaseStore.findBy(CategoryClass, {name: 'inbox', accountId: account.id}).then (category) =>
|
||||
return unless category
|
||||
|
||||
matchers = [
|
||||
Thread.attributes.accountId.equal(account.id),
|
||||
Thread.attributes.unread.equal(true),
|
||||
CategoryAttribute.contains(category.id)
|
||||
]
|
||||
|
||||
DatabaseStore.count(Thread, matchers).then (count) =>
|
||||
return if @_count is count
|
||||
@_count = count
|
||||
@_updateBadgeForCount(count)
|
||||
@trigger()
|
||||
.catch (err) =>
|
||||
console.warn("Failed to fetch unread count: #{err}")
|
||||
|
||||
_updateBadgeForCount: (count) ->
|
||||
return unless NylasEnv.isMainWindow()
|
||||
return if NylasEnv.config.get('core.notifications.unreadBadge') is false
|
||||
if count > 999
|
||||
@_setBadge("999+")
|
||||
else if count > 0
|
||||
@_setBadge("#{count}")
|
||||
else
|
||||
@_setBadge("")
|
||||
|
||||
_setBadge: (val) ->
|
||||
# NOTE: Do not underestimate how long this can take. It's a synchronous
|
||||
# remote call and can take ~50+msec.
|
||||
ipc = require 'ipc'
|
||||
ipc.send('set-badge-value', val)
|
||||
|
||||
module.exports = UnreadCountStore
|
|
@ -104,7 +104,8 @@ class NylasExports
|
|||
@require "WorkspaceStore", 'flux/stores/workspace-store'
|
||||
@require "DraftCountStore", 'flux/stores/draft-count-store'
|
||||
@require "FileUploadStore", 'flux/stores/file-upload-store'
|
||||
@require "UnreadCountStore", 'flux/stores/unread-count-store'
|
||||
@require "ThreadCountsStore", 'flux/stores/thread-counts-store'
|
||||
@require "UnreadBadgeStore", 'flux/stores/unread-badge-store'
|
||||
@require "FileDownloadStore", 'flux/stores/file-download-store'
|
||||
@require "DraftStoreExtension", 'flux/stores/draft-store-extension'
|
||||
@require "FocusedContentStore", 'flux/stores/focused-content-store'
|
||||
|
|
|
@ -10,14 +10,6 @@ setupGlobals = ->
|
|||
trace: ->
|
||||
global.__defineGetter__ 'console', -> console
|
||||
|
||||
fs = require 'fs'
|
||||
fs.existsSync = (path) ->
|
||||
try
|
||||
fs.accessSync(path)
|
||||
return true
|
||||
catch
|
||||
return false
|
||||
|
||||
global.document =
|
||||
createElement: ->
|
||||
setAttribute: ->
|
||||
|
@ -32,7 +24,7 @@ setupGlobals = ->
|
|||
|
||||
global.emit = (event, args...) ->
|
||||
process.send({event, args})
|
||||
global.navigator = {userAgent: userAgent}
|
||||
global.navigator = {userAgent}
|
||||
global.window = global
|
||||
|
||||
handleEvents = ->
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
_ = require 'underscore'
|
||||
{fork} = require 'child_process'
|
||||
{Emitter} = require 'emissary'
|
||||
ChildProcess = require 'child_process'
|
||||
{Emitter} = require 'event-kit'
|
||||
Grim = require 'grim'
|
||||
|
||||
# Extended: Run a node script in a separate process.
|
||||
#
|
||||
# Used by the fuzzy-finder and [find in project](https://github.com/atom/atom/blob/master/src/scan-handler.coffee).
|
||||
#
|
||||
# For a real-world example, see the [scan-handler](https://github.com/atom/atom/blob/master/src/scan-handler.coffee)
|
||||
# and the [instantiation of the task](https://github.com/atom/atom/blob/4a20f13162f65afc816b512ad7201e528c3443d7/src/project.coffee#L245).
|
||||
#
|
||||
# ## Examples
|
||||
#
|
||||
# In your package code:
|
||||
|
@ -33,8 +38,6 @@ Grim = require 'grim'
|
|||
# ```
|
||||
module.exports =
|
||||
class Task
|
||||
Emitter.includeInto(this)
|
||||
|
||||
# Public: A helper method to easily launch and run a task once.
|
||||
#
|
||||
# * `taskPath` The {String} path to the CoffeeScript/JavaScript file which
|
||||
|
@ -61,12 +64,13 @@ class Task
|
|||
# * `taskPath` The {String} path to the CoffeeScript/JavaScript file that
|
||||
# exports a single {Function} to execute.
|
||||
constructor: (taskPath) ->
|
||||
coffeeCacheRequire = "require('#{require.resolve('./coffee-cache')}').register();"
|
||||
coffeeScriptRequire = "require('#{require.resolve('coffee-script')}').register();"
|
||||
@emitter = new Emitter
|
||||
|
||||
compileCacheRequire = "require('#{require.resolve('./compile-cache')}')"
|
||||
compileCachePath = require('./compile-cache').getCacheDirectory()
|
||||
taskBootstrapRequire = "require('#{require.resolve('./task-bootstrap')}');"
|
||||
bootstrap = """
|
||||
#{coffeeScriptRequire}
|
||||
#{coffeeCacheRequire}
|
||||
#{compileCacheRequire}.setCacheDirectory('#{compileCachePath}');
|
||||
#{taskBootstrapRequire}
|
||||
"""
|
||||
bootstrap = bootstrap.replace(/\\/g, "\\\\")
|
||||
|
@ -74,8 +78,8 @@ class Task
|
|||
taskPath = require.resolve(taskPath)
|
||||
taskPath = taskPath.replace(/\\/g, "\\\\")
|
||||
|
||||
env = _.extend({}, process.env, {taskPath, userAgent: process.env.userAgent})
|
||||
@childProcess = fork '--eval', [bootstrap], {env, silent: true}
|
||||
env = _.extend({}, process.env, {taskPath, userAgent: navigator.userAgent})
|
||||
@childProcess = ChildProcess.fork '--eval', [bootstrap], {env, silent: true}
|
||||
|
||||
@on "task:log", -> console.log(arguments...)
|
||||
@on "task:warn", -> console.warn(arguments...)
|
||||
|
@ -91,12 +95,16 @@ class Task
|
|||
handleEvents: ->
|
||||
@childProcess.removeAllListeners()
|
||||
@childProcess.on 'message', ({event, args}) =>
|
||||
@emit(event, args...) if @childProcess?
|
||||
@emitter.emit(event, args) if @childProcess?
|
||||
|
||||
# Catch the errors that happened before task-bootstrap.
|
||||
@childProcess.stdout.on 'data', (data) ->
|
||||
console.log data.toString()
|
||||
@childProcess.stderr.on 'data', (data) ->
|
||||
console.error data.toString()
|
||||
if @childProcess.stdout?
|
||||
@childProcess.stdout.removeAllListeners()
|
||||
@childProcess.stdout.on 'data', (data) -> console.log data.toString()
|
||||
|
||||
if @childProcess.stderr?
|
||||
@childProcess.stderr.removeAllListeners()
|
||||
@childProcess.stderr.on 'data', (data) -> console.error data.toString()
|
||||
|
||||
# Public: Starts the task.
|
||||
#
|
||||
|
@ -129,27 +137,35 @@ class Task
|
|||
throw new Error('Cannot send message to terminated process')
|
||||
undefined
|
||||
|
||||
# Public: Describe the function of the task. Each task should override this
|
||||
# to explain its individual function
|
||||
description: ->
|
||||
''
|
||||
|
||||
# Public: Call a function when an event is emitted by the child process
|
||||
#
|
||||
# * `eventName` The {String} name of the event to handle.
|
||||
# * `callback` The {Function} to call when the event is emitted.
|
||||
#
|
||||
# Returns a {Disposable} that can be used to stop listening for the event.
|
||||
on: (eventName, callback) -> Emitter::on.call(this, eventName, callback)
|
||||
on: (eventName, callback) -> @emitter.on eventName, (args) -> callback(args...)
|
||||
|
||||
once: (eventName, callback) ->
|
||||
disposable = @on eventName, (args...) ->
|
||||
disposable.dispose()
|
||||
callback(args...)
|
||||
|
||||
# Public: Forcefully stop the running task.
|
||||
#
|
||||
# No more events are emitted once this method is called.
|
||||
terminate: ->
|
||||
return unless @childProcess?
|
||||
return false unless @childProcess?
|
||||
|
||||
@childProcess.removeAllListeners()
|
||||
@childProcess.stdout?.removeAllListeners()
|
||||
@childProcess.stderr?.removeAllListeners()
|
||||
@childProcess.kill()
|
||||
@childProcess = null
|
||||
|
||||
undefined
|
||||
true
|
||||
|
||||
cancel: ->
|
||||
didForcefullyTerminate = @terminate()
|
||||
if didForcefullyTerminate
|
||||
@emitter.emit('task:cancelled')
|
||||
didForcefullyTerminate
|
||||
|
|
Loading…
Reference in a new issue