diff --git a/packages/client-app/internal_packages/category-picker/lib/category-picker-popover.jsx b/packages/client-app/internal_packages/category-picker/lib/category-picker-popover.jsx index b46d182e1..6e71d775d 100644 --- a/packages/client-app/internal_packages/category-picker/lib/category-picker-popover.jsx +++ b/packages/client-app/internal_packages/category-picker/lib/category-picker-popover.jsx @@ -98,6 +98,7 @@ export default class CategoryPickerPopover extends Component { } const item = category.toJSON() item.category = category + item.displayName = category.displayName item.backgroundColor = LabelColorizer.backgroundColorDark(category) item.usage = usageCount[category.id] || 0 item.numThreads = numThreads @@ -316,7 +317,7 @@ export default class CategoryPickerPopover extends Component {
{icon}
- +
) diff --git a/packages/client-app/spec/models/category-spec.coffee b/packages/client-app/spec/models/category-spec.coffee index 0b838e267..b4d539d02 100644 --- a/packages/client-app/spec/models/category-spec.coffee +++ b/packages/client-app/spec/models/category-spec.coffee @@ -21,9 +21,9 @@ describe 'Category', -> describe 'fromJSON', -> it "should strip the INBOX. prefix from FastMail folders", -> - foo = (new Category()).fromJSON({display_name: 'INBOX.Foo'}) + foo = (new Category()).fromJSON({displayName: 'INBOX.Foo'}) expect(foo.displayName).toEqual('Foo') - foo = (new Category()).fromJSON({display_name: 'INBOX'}) + foo = (new Category()).fromJSON({displayName: 'INBOX'}) expect(foo.displayName).toEqual('Inbox') describe 'category types', -> diff --git a/packages/client-app/spec/models/thread-spec.coffee b/packages/client-app/spec/models/thread-spec.coffee index 95fa20eab..a4a06e82b 100644 --- a/packages/client-app/spec/models/thread-spec.coffee +++ b/packages/client-app/spec/models/thread-spec.coffee @@ -9,7 +9,7 @@ describe 'Thread', -> describe 'serialization performance', -> xit '1,000,000 iterations', -> iterations = 0 - json = '[{"client_id":"local-76c370af-65de","server_id":"f0vkowp7zxt7djue7ifylb940","object":"thread","account_id":"1r6w6qiq3sb0o9fiwin6v87dd","snippet":"http://itunestandc.tumblr.com/tagged/itunes-terms-and-conditions/chrono _______________________________________________ http://www.macgroup.com/mailman/listinfo/smartfriends-chat","subject":"iTunes Terms And Conditions as you\'ve never seen them before","unread":true,"starred":false,"version":1,"folders":[],"labels":[{"server_id":"8cf4fn20k9pjjhjawrv3xrxo0","name":"all","display_name":"All Mail","id":"8cf4fn20k9pjjhjawrv3xrxo0"},{"server_id":"f1lq8faw8vv06m67y8f3xdf84","name":"inbox","display_name":"Inbox","id":"f1lq8faw8vv06m67y8f3xdf84"}],"participants":[{"name":"Andrew Stadler","email":"stadler@gmail.com","thirdPartyData":{}},{"name":"Smart Friends™ Chat","email":"smartfriends-chat@macgroup.com","thirdPartyData":{}}],"has_attachments":false,"last_message_received_timestamp":1446600615,"id":"f0vkowp7zxt7djue7ifylb940"}]' + json = '[{"client_id":"local-76c370af-65de","server_id":"f0vkowp7zxt7djue7ifylb940","object":"thread","account_id":"1r6w6qiq3sb0o9fiwin6v87dd","snippet":"http://itunestandc.tumblr.com/tagged/itunes-terms-and-conditions/chrono _______________________________________________ http://www.macgroup.com/mailman/listinfo/smartfriends-chat","subject":"iTunes Terms And Conditions as you\'ve never seen them before","unread":true,"starred":false,"version":1,"folders":[],"labels":[{"server_id":"8cf4fn20k9pjjhjawrv3xrxo0","name":"all","displayName":"All Mail","id":"8cf4fn20k9pjjhjawrv3xrxo0"},{"server_id":"f1lq8faw8vv06m67y8f3xdf84","name":"inbox","display_name":"Inbox","id":"f1lq8faw8vv06m67y8f3xdf84"}],"participants":[{"name":"Andrew Stadler","email":"stadler@gmail.com","thirdPartyData":{}},{"name":"Smart Friends™ Chat","email":"smartfriends-chat@macgroup.com","thirdPartyData":{}}],"has_attachments":false,"last_message_received_timestamp":1446600615,"id":"f0vkowp7zxt7djue7ifylb940"}]' start = Date.now() while iterations < 1000000 if _.isString(json) diff --git a/packages/client-app/spec/tasks/destroy-category-task-spec.coffee b/packages/client-app/spec/tasks/destroy-category-task-spec.coffee index 6f1c33c58..9faf6be94 100644 --- a/packages/client-app/spec/tasks/destroy-category-task-spec.coffee +++ b/packages/client-app/spec/tasks/destroy-category-task-spec.coffee @@ -20,7 +20,7 @@ xdescribe "DestroyCategoryTask", -> fn.calls[0].args[0].accountId nameOf = (fn) -> - fn.calls[0].args[0].body.display_name + fn.calls[0].args[0].body.displayName makeAccount = ({usesFolders, usesLabels} = {}) -> spyOn(AccountStore, "accountForId").andReturn { diff --git a/packages/client-app/src/flux/action-bridge-cpp.es6 b/packages/client-app/src/flux/action-bridge-cpp.es6 index 132ff18e5..60be9563e 100644 --- a/packages/client-app/src/flux/action-bridge-cpp.es6 +++ b/packages/client-app/src/flux/action-bridge-cpp.es6 @@ -1,18 +1,10 @@ -import _ from 'underscore'; import net from 'net'; import fs from 'fs'; -import Actions from './actions'; import DatabaseStore from './stores/database-store'; import DatabaseChangeRecord from './stores/database-change-record'; import Utils from './models/utils'; -const Message = { - DATABASE_STORE_TRIGGER: 'db-store-trigger', -}; - -const printToConsole = false; - class ActionBridgeCPP { constructor() { @@ -27,10 +19,12 @@ class ActionBridgeCPP { console.info(err); } + this.clients = []; + // This server listens on a Unix socket at /var/run/mysocket const unixServer = net.createServer((c) => { - // Do something with the client connection console.log('client connected'); + this.clients.push(c); c.on('data', (d) => { this.onIncomingMessage(d.toString()); }); @@ -43,9 +37,8 @@ class ActionBridgeCPP { c.on('end', () => { console.log('client disconnected'); + this.clients = this.clients.filter((o) => o !== c); }); - // c.write('hello\r\n'); - // c.pipe(c); }); unixServer.listen('/tmp/cmail.sock', () => { @@ -77,6 +70,19 @@ class ActionBridgeCPP { } } + onTellClients(json) { + const msg = JSON.stringify(json, Utils.registeredObjectReplacer); + const headerBuffer = new Buffer(4); + const contentBuffer = Buffer.from(msg); + headerBuffer.fill(0); + headerBuffer.writeUInt32LE(contentBuffer.length, 0); + + for (const c of this.clients) { + c.write(headerBuffer); + c.write(contentBuffer); + } + } + onBeforeUnload(readyToUnload) { // Unfortunately, if you call ipc.send and then immediately close the window, // Electron won't actually send the message. To work around this, we wait an diff --git a/packages/client-app/src/flux/models/category.es6 b/packages/client-app/src/flux/models/category.es6 index 160db8985..5f9c68241 100644 --- a/packages/client-app/src/flux/models/category.es6 +++ b/packages/client-app/src/flux/models/category.es6 @@ -87,14 +87,13 @@ export default class Category extends Model { queryable: true, modelKey: 'path', }), - imapName: Attributes.String({ - modelKey: 'imapName', - jsonKey: 'imap_name', - }), syncProgress: Attributes.Object({ modelKey: 'syncProgress', jsonKey: 'sync_progress', }), + _refcount: Attributes.Number({ + modelKey: '_refcount', + }), }); static Types = { diff --git a/packages/client-app/src/flux/models/message.es6 b/packages/client-app/src/flux/models/message.es6 index cc661b90a..78f5335a2 100644 --- a/packages/client-app/src/flux/models/message.es6 +++ b/packages/client-app/src/flux/models/message.es6 @@ -147,6 +147,10 @@ export default class Message extends ModelWithMetadata { modelKey: 'subject', }), + folderImapUID: Attributes.Number({ + modelKey: 'folderImapUID', + }), + draft: Attributes.Boolean({ modelKey: 'draft', queryable: true, diff --git a/packages/client-app/src/flux/stores/recently-read-store.es6 b/packages/client-app/src/flux/stores/recently-read-store.es6 index c60e12f1a..23d5d2e14 100644 --- a/packages/client-app/src/flux/stores/recently-read-store.es6 +++ b/packages/client-app/src/flux/stores/recently-read-store.es6 @@ -30,16 +30,14 @@ class RecentlyReadStore extends NylasStore { tasks.filter(task => task instanceof ChangeUnreadTask - ).forEach(({threads}) => { - const threadIds = threads.map(t => (t.id ? t.id : t)); + ).forEach(({threadIds}) => { 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)); + ).forEach(({threadIds}) => { this.ids = this.ids.filter(id => !threadIds.includes(id)); changed = true; }); diff --git a/packages/client-app/src/flux/stores/task-queue.es6 b/packages/client-app/src/flux/stores/task-queue.es6 index 5ab4734fe..fbbcaebef 100644 --- a/packages/client-app/src/flux/stores/task-queue.es6 +++ b/packages/client-app/src/flux/stores/task-queue.es6 @@ -124,20 +124,14 @@ class TaskQueue extends NylasStore { console.log(task); throw new Error("Tasks must have an ID prior to being queued. Check that your Task constructor is calling `super`"); } - if (!task.queueState) { - console.log(task); - throw new Error("Tasks must have a queueState prior to being queued. Check that your Task constructor is calling `super`"); - } task.sequentialId = ++this._currentSequentialId; - task.runLocal().then(() => { - DatabaseStore.inTransaction((t) => { - return t.persistModel(task); - }); - }); + task.status = 'local'; + + NylasEnv.actionBridgeCpp.onTellClients({type: 'task-queued', task: task}); } enqueueUndoOfTaskId = (taskId) => { - const task = this._queue.find(t => t.id == taskId) || this._completed.find(t => t.id == taskId); + const task = this._queue.find(t => t.id === taskId) || this._completed.find(t => t.id === taskId); if (task) { this.enqueue(task.createUndoTask()); } diff --git a/packages/client-app/src/flux/tasks/change-folder-task.es6 b/packages/client-app/src/flux/tasks/change-folder-task.es6 index 1b6dcc7d5..4ba4a2ecf 100644 --- a/packages/client-app/src/flux/tasks/change-folder-task.es6 +++ b/packages/client-app/src/flux/tasks/change-folder-task.es6 @@ -1,11 +1,5 @@ -import _ from 'underscore'; -import Thread from '../models/thread'; import Category from '../models/category'; -import Message from '../models/message'; -import Actions from '../actions' -import DatabaseStore from '../stores/database-store'; import ChangeMailTask from './change-mail-task'; -import SyncbackCategoryTask from './syncback-category-task'; // Public: Create a new task to apply labels to a message or thread. // @@ -34,10 +28,6 @@ export default class ChangeFolderTask extends ChangeMailTask { return "Moving to folder"; } - categoriesToAdd() { - return [this.folder]; - } - description() { if (this.taskDescription) { return this.taskDescription; @@ -48,26 +38,22 @@ export default class ChangeFolderTask extends ChangeMailTask { folderText = ` to ${this.folder.displayName}`; } - if (this.threads.length > 1) { - return `Moved ${this.threads.length} threads${folderText}`; - } else if (this.messages.length > 1) { - return `Moved ${this.messages.length} messages${folderText}`; + if (this.threadIds.length > 1) { + return `Moved ${this.threadIds.length} threads${folderText}`; + } else if (this.messageIds.length > 1) { + return `Moved ${this.messageIds.length} messages${folderText}`; } return `Moved${folderText}`; } - isDependentOnTask(other) { - return super.isDependentOnTask(other) || (other instanceof SyncbackCategoryTask); - } - performLocal() { if (!this.folder) { return Promise.reject(new Error("Must specify a `folder`")) } - if (this.threads.length > 0 && this.messages.length > 0) { + if (this.threadIds.length > 0 && this.messageIds.length > 0) { return Promise.reject(new Error("ChangeFolderTask: You can move `threads` or `messages` but not both")) } - if (this.threads.length === 0 && this.messages.length === 0) { + if (this.threadIds.length === 0 && this.messageIds.length === 0) { return Promise.reject(new Error("ChangeFolderTask: You must provide a `threads` or `messages` Array of models or IDs.")) } @@ -77,64 +63,4 @@ export default class ChangeFolderTask extends ChangeMailTask { _isArchive() { return this.folder.name === "archive" || this.folder.name === "all" } - - recordUserEvent() { - if (this.source === "Mail Rules") { - return - } - Actions.recordUserEvent("Threads Moved to Folder", { - source: this.source, - isArchive: this._isArchive(), - folderType: this.folder.name || "custom", - folderDisplayName: this.folder.displayName, - numThreads: this.threads.length, - numMessages: this.messages.length, - description: this.description(), - isUndo: this._isUndoTask, - }) - } - - retrieveModels() { - return Promise.props({ - folder: DatabaseStore.modelify(Category, [this.folder]), - threads: DatabaseStore.modelify(Thread, this.threads), - messages: DatabaseStore.modelify(Message, this.messages), - - }).then(({folder, threads, messages}) => { - // Remove any objects we weren't able to find. This can happen pretty easily - // if (you undo an action && other things have happened.) - this.folder = folder[0]; - this.threads = _.compact(threads); - this.messages = _.compact(messages); - - if (!this.folder) { - return Promise.reject(new Error("The specified folder could not be found.")); - } - return Promise.resolve(); - }); - } - - processNestedMessages() { - return false; - } - - changesToModel(model) { - if (model instanceof Thread) { - return {categories: [this.folder]} - } - if (model instanceof Message) { - return {categories: [this.folder]} - } - return null; - } - - requestBodyForModel(model) { - if (model instanceof Thread) { - return {folder: model.folders[0] ? model.folders[0].id : null}; - } - if (model instanceof Message) { - return {folder: model.folder ? model.folder.id : null}; - } - return null; - } } diff --git a/packages/client-app/src/flux/tasks/change-labels-task.es6 b/packages/client-app/src/flux/tasks/change-labels-task.es6 index c0deecd2e..e782a6583 100644 --- a/packages/client-app/src/flux/tasks/change-labels-task.es6 +++ b/packages/client-app/src/flux/tasks/change-labels-task.es6 @@ -1,13 +1,5 @@ -import _ from 'underscore'; -import Thread from '../models/thread'; -import Message from '../models/message'; -import Actions from '../actions' import Category from '../models/category'; -import DatabaseStore from '../stores/database-store'; -import CategoryStore from '../stores/category-store'; -import AccountStore from '../stores/account-store'; import ChangeMailTask from './change-mail-task'; -import SyncbackCategoryTask from './syncback-category-task'; // Public: Create a new task to apply labels to a message or thread. // @@ -44,8 +36,8 @@ export default class ChangeLabelsTask extends ChangeMailTask { } let countString = ""; - if (this.threads.length > 1) { - countString = ` ${this.threads.length} threads`; + if (this.threadIds.length > 1) { + countString = ` ${this.threadIds.length} threads`; } const removed = this.labelsToRemove[0]; @@ -88,163 +80,8 @@ export default class ChangeLabelsTask extends ChangeMailTask { return `Changed labels${countString ? ' on' : ''}${countString}`; } - isDependentOnTask(other) { - return super.isDependentOnTask(other) || (other instanceof SyncbackCategoryTask); - } - - // In Gmail all threads /must/ belong to either All Mail, Trash and Spam, and - // they are mutually exclusive, so we need to make sure that any add/remove - // label operation still guarantees that constraint - _ensureAndUpdateLabels(account, existingLabelsToAdd, existingLabelsToRemove = {}) { - const labelsToAdd = existingLabelsToAdd; - let labelsToRemove = existingLabelsToRemove; - - const setToAdd = new Set(_.compact(_.pluck(labelsToAdd, 'name'))); - const setToRemove = new Set(_.compact(_.pluck(labelsToRemove, 'name'))); - - if (setToRemove.has('all')) { - if (!setToAdd.has('spam') && !setToAdd.has('trash')) { - labelsToRemove = _.reject(labelsToRemove, label => label.name === 'all'); - } - } else if (setToAdd.has('all')) { - if (!setToRemove.has('trash')) { - labelsToRemove.push(CategoryStore.getTrashCategory(account)); - } - if (!setToRemove.has('spam')) { - labelsToRemove.push(CategoryStore.getSpamCategory(account)); - } - } - - if (setToRemove.has('trash')) { - if (!setToAdd.has('spam') && !setToAdd.has('all')) { - labelsToAdd.push(CategoryStore.getAllMailCategory(account)); - } - } else if (setToAdd.has('trash')) { - if (!setToRemove.has('all')) { - labelsToRemove.push(CategoryStore.getAllMailCategory(account)) - } - if (!setToRemove.has('spam')) { - labelsToRemove.push(CategoryStore.getSpamCategory(account)) - } - } - - if (setToRemove.has('spam')) { - if (!setToAdd.has('trash') && !setToAdd.has('all')) { - labelsToAdd.push(CategoryStore.getAllMailCategory(account)); - } - } else if (setToAdd.has('spam')) { - if (!setToRemove.has('all')) { - labelsToRemove.push(CategoryStore.getAllMailCategory(account)) - } - if (!setToRemove.has('trash')) { - labelsToRemove.push(CategoryStore.getTrashCategory(account)) - } - } - - // This should technically not be possible, but we like to keep it safe - return { - labelsToAdd: _.compact(labelsToAdd), - labelsToRemove: _.compact(labelsToRemove), - }; - } - - performLocal() { - if (this.messages.length > 0) { - return Promise.reject(new Error("ChangeLabelsTask: N1 does not support viewing or changing labels on individual messages.")) - } - if (this.labelsToAdd.length === 0 && this.labelsToRemove.length === 0) { - return Promise.reject(new Error("ChangeLabelsTask: Must specify `labelsToAdd` or `labelsToRemove`")) - } - if (this.threads.length > 0 && this.messages.length > 0) { - return Promise.reject(new Error("ChangeLabelsTask: You can move `threads` or `messages` but not both")) - } - if (this.threads.length === 0 && this.messages.length === 0) { - return Promise.reject(new Error("ChangeLabelsTask: You must provide a `threads` or `messages` Array of models or IDs.")) - } - - return super.performLocal(); - } - _isArchive() { const toAdd = this.labelsToAdd.map(l => l.name) return toAdd.includes("all") || toAdd.includes("archive") } - - recordUserEvent() { - if (this.source === "Mail Rules") { - return - } - Actions.recordUserEvent("Threads Changed Labels", { - source: this.source, - isArchive: this._isArchive(), - labelTypesToAdd: this.labelsToAdd.map(l => l.name || "custom"), - labelTypesToRemove: this.labelsToRemove.map(l => l.name || "custom"), - labelDisplayNamesToAdd: this.labelsToAdd.map(l => l.displayName), - labelDisplayNamesToRemove: this.labelsToRemove.map(l => l.displayName), - numThreads: this.threads.length, - numMessages: this.messages.length, - description: this.description(), - isUndo: this._isUndoTask, - }) - } - - retrieveModels() { - // Convert arrays of IDs or models to models. - // modelify returns immediately if (no work is required) - return Promise.props({ - labelsToAdd: DatabaseStore.modelify(Category, this.labelsToAdd), - labelsToRemove: DatabaseStore.modelify(Category, this.labelsToRemove), - threads: DatabaseStore.modelify(Thread, this.threads), - messages: DatabaseStore.modelify(Message, this.messages), - - }).then(({labelsToAdd, labelsToRemove, threads, messages}) => { - if (_.any([].concat(labelsToAdd, labelsToRemove), _.isUndefined)) { - return Promise.reject(new Error("One or more of the specified labels could not be found.")) - } - const account = AccountStore.accountForItems(threads); - if (!account) { - return Promise.reject(new Error("ChangeLabelsTask: You must provide a set of `threads` from the same Account")) - } - // In Gmail all threads /must/ belong to either All Mail, Trash and Spam, and - // they are mutually exclusive, so we need to make sure that any add/remove - // label operation still guarantees that constraint - const updated = this._ensureAndUpdateLabels(account, labelsToAdd, labelsToRemove) - - // Remove any objects we weren't able to find. This can happen pretty easily - // if (you undo an action && other things have happened.) - this.labelsToAdd = updated.labelsToAdd; - this.labelsToRemove = updated.labelsToRemove; - this.threads = _.compact(threads); - this.messages = _.compact(messages); - - // The base class does the heavy lifting and calls changesToModel - return Promise.resolve(); - }); - } - - processNestedMessages() { - return false; - } - - changesToModel(model) { - const labelsToRemoveIds = _.pluck(this.labelsToRemove, 'id') - - let labels = _.reject(model.labels, ({id}) => labelsToRemoveIds.includes(id)); - labels = labels.concat(this.labelsToAdd); - labels = _.uniq(labels, false, label => label.id); - return {labels}; - } - - requestBodyForModel(model) { - const folder = model.labels.find(l => l.object === 'folder') - const labels = model.labels.filter(l => l.object === 'label') - - if (folder) { - return { - folder: folder.id, - labels: labels.map(l => l.id), - } - } - return {labels}; - } } diff --git a/packages/client-app/src/flux/tasks/change-mail-task.es6 b/packages/client-app/src/flux/tasks/change-mail-task.es6 index a81a074be..abf42a098 100644 --- a/packages/client-app/src/flux/tasks/change-mail-task.es6 +++ b/packages/client-app/src/flux/tasks/change-mail-task.es6 @@ -2,10 +2,6 @@ import _ from 'underscore'; import Task from './task'; import Thread from '../models/thread'; import Message from '../models/message'; -import NylasAPI from '../nylas-api'; -import DatabaseStore from '../stores/database-store'; -import EnsureMessageInSentFolderTask from './ensure-message-in-sent-folder-task' -import BaseDraftTask from './base-draft-task' /* Public: The ChangeMailTask is a base class for all tasks that modify sets @@ -30,164 +26,18 @@ export default class ChangeMailTask extends Task { constructor({threads, thread, messages, message} = {}) { super(); - this.threads = threads || []; + const t = threads || []; if (thread) { - this.threads.push(thread); + t.push(thread); } - this.messages = messages || []; + const m = messages || []; if (message) { - this.messages.push(message); - } - } - - // Functions for subclasses - - // Public: Override this method and return an object with key-value pairs - // representing changed values. For example, if (your task sets unread:) - // false, return {unread: false}. - // - // - `model` an individual {Thread} or {Message} - // - // Returns an object whos key-value pairs represent the desired changed - // object. - changesToModel() { - throw new Error("You must override this method."); - } - - // Public: Override this method and return an object that will be the - // request body used for saving changes to `model`. - // - // - `model` an individual {Thread} or {Message} - // - // Returns an object that will be passed as the `body` to the actual API - // `request` object - requestBodyForModel() { - throw new Error("You must override this method."); - } - - // Public: Override to indicate whether actions need to be taken for all - // messages of each thread. - // - // Generally, you cannot provide both messages and threads at the same - // time. However, ChangeMailTask runs for provided threads first and then - // messages. Override and return true, and you will receive - // `changesToModel` for messages in changed threads, and any changes you - // make will be written to the database and undone during undo. - // - // Note that API requests are only made for threads if (threads are) - // present. - processNestedMessages() { - return false; - } - - // Public: Returns categories that this task will add to the set of threads - // Must be overriden - categoriesToAdd() { - return []; - } - - // Public: Returns categories that this task will remove the set of threads - // Must be overriden - categoriesToRemove() { - return []; - } - - // Public: Subclasses should override `performLocal` and call super once - // they've prepared the data they need and verified that requirements are - // met. - - // See {Task::performLocal} for more usage info - - performLocal() { - if (this._isUndoTask && !this._restoreValues) { - return Promise.reject(new Error("ChangeMailTask: No _restoreValues provided for undo task.")) - } - // Lock the models with the optimistic change tracker so they aren't reverted - // while the user is seeing our optimistic changes. - if (!this._isReverting) { - this._lockAll(); + m.push(message); } - return DatabaseStore.inTransaction((t) => { - return this.retrieveModels().then(() => { - return this._performLocalThreads(t) - }).then(() => { - return this._performLocalMessages(t) - }) - }).then(() => { - try { - this.recordUserEvent() - } catch (err) { - NylasEnv.reportError(err); - // don't throw - } - }); - } - - recordUserEvent() { - throw new Error("Override recordUserEvent") - } - - retrieveModels() { - // Note: Currently, *ALL* subclasses must use `DatabaseStore.modelify` - // to convert `threads` and `messages` from models or ids to models. - return Promise.resolve(); - } - - _performLocalThreads(transaction) { - const changed = this._applyChanges(this.threads); - const changedIds = _.pluck(changed, 'id'); - - if (changed.length === 0) { - return Promise.resolve(); - } - - return transaction.persistModels(changed).then(() => { - if (!this.processNestedMessages()) { - return Promise.resolve(); - } - return DatabaseStore.findAll(Message).where(Message.attributes.threadId.in(changedIds)).then((messages) => { - this.messages = [].concat(messages, this.messages); - return Promise.resolve() - }) - }); - } - - _performLocalMessages(transaction) { - const changed = this._applyChanges(this.messages); - return (changed.length > 0) ? transaction.persistModels(changed) : Promise.resolve(); - } - - _applyChanges(modelArray) { - const changed = []; - - if (this._shouldChangeBackwards()) { - modelArray.forEach((model, idx) => { - if (this._restoreValues[model.id]) { - const updated = _.extend(model.clone(), this._restoreValues[model.id]); - modelArray[idx] = updated; - changed.push(updated); - } - }); - } else { - this._restoreValues = this._restoreValues || {}; - modelArray.forEach((model, idx) => { - const fieldsNew = this.changesToModel(model); - const fieldsCurrent = _.pick(model, Object.keys(fieldsNew)); - if (!_.isEqual(fieldsCurrent, fieldsNew)) { - this._restoreValues[model.id] = fieldsCurrent; - const updated = _.extend(model.clone(), fieldsNew); - modelArray[idx] = updated; - changed.push(updated); - } - }); - } - - return changed; - } - - _shouldChangeBackwards() { - return this._isReverting || this._isUndoTask; + // we actually only keep a small bit of data now + this.threadIds = t.map(i => i.id); + this.messageIds = m.map(i => i.id); } // Task lifecycle @@ -215,50 +65,19 @@ export default class ChangeMailTask extends Task { } createIdenticalTask() { - const task = new this.constructor(this); - - // Never give the undo task the Model objects - make it look them up! - // This ensures that they never revert other fields - const toIds = (arr) => _.map(arr, v => (_.isString(v) ? v : v.id)); - task.threads = toIds(this.threads); - task.messages = (this.threads.length > 0) ? [] : toIds(this.messages); - return task; + return new this.constructor(this); } objectIds() { - return [].concat(this.threads, this.messages).map((v) => - (_.isString(v) ? v : v.id) - ); + return [].concat(this.threadIds, this.messageIds); } objectClass() { - return (this.threads && this.threads.length) ? Thread : Message; - } - - objectArray() { - return (this.threads && this.threads.length) ? this.threads : this.messages; + return (this.threadIds && this.threadIds.length) ? Thread : Message; } numberOfImpactedItems() { - return this.objectArray().length; - } - - // Helpers used in subclasses - - _lockAll() { - const klass = this.objectClass(); - this._locked = this._locked || {}; - for (const item of this.objectArray()) { - this._locked[item.id] = this._locked[item.id] || 0; - this._locked[item.id] += 1; - NylasAPI.incrementRemoteChangeLock(klass, item.id); - } - } - - _removeLock(item) { - const klass = this.objectClass(); - NylasAPI.decrementRemoteChangeLock(klass, item.id); - this._locked[item.id] -= 1; + return this.objectIds().length; } } diff --git a/packages/client-app/src/flux/tasks/change-starred-task.es6 b/packages/client-app/src/flux/tasks/change-starred-task.es6 index 5d64f3fe0..1d46b6eb7 100644 --- a/packages/client-app/src/flux/tasks/change-starred-task.es6 +++ b/packages/client-app/src/flux/tasks/change-starred-task.es6 @@ -17,7 +17,7 @@ export default class ChangeStarredTask extends ChangeMailTask { } description() { - const count = this.threads.length; + const count = this.threadIds.length; const type = count > 1 ? "threads" : "thread"; if (this._isUndoTask) { @@ -32,7 +32,7 @@ export default class ChangeStarredTask extends ChangeMailTask { } performLocal() { - if (this.threads.length === 0) { + if (this.threadIds.length === 0) { return Promise.reject(new Error("ChangeStarredTask: You must provide a `threads` Array of models or IDs.")); } return super.performLocal(); @@ -45,26 +45,9 @@ export default class ChangeStarredTask extends ChangeMailTask { const eventName = this.unread ? "Starred" : "Unstarred"; Actions.recordUserEvent(`Threads ${eventName}`, { source: this.source, - numThreads: this.threads.length, + numThreads: this.threadIds.length, description: this.description(), isUndo: this._isUndoTask, }) } - - retrieveModels() { - return Promise.props({ - threads: DatabaseStore.modelify(Thread, this.threads), - }).then(({threads}) => { - this.threads = _.compact(threads); - return Promise.resolve(); - }) - } - - changesToModel(model) { - return {starred: this.starred}; - } - - requestBodyForModel(model) { - return {starred: model.starred}; - } } diff --git a/packages/client-app/src/flux/tasks/change-unread-task.es6 b/packages/client-app/src/flux/tasks/change-unread-task.es6 index eb75da23d..6a4c69d2d 100644 --- a/packages/client-app/src/flux/tasks/change-unread-task.es6 +++ b/packages/client-app/src/flux/tasks/change-unread-task.es6 @@ -18,7 +18,7 @@ export default class ChangeUnreadTask extends ChangeMailTask { } description() { - const count = this.threads.length; + const count = this.threadIds.length; const type = count > 1 ? 'threads' : 'thread'; if (this._isUndoTask) { @@ -38,47 +38,4 @@ export default class ChangeUnreadTask extends ChangeMailTask { } return this._canBeUndone } - - performLocal() { - if (this.threads.length === 0) { - return Promise.reject(new Error("ChangeUnreadTask: You must provide a `threads` Array of models or IDs.")) - } - return super.performLocal(); - } - - recordUserEvent() { - if (this.source === "Mail Rules") { - return - } - const eventName = this.unread ? "Unread" : "Read"; - Actions.recordUserEvent(`Threads Marked as ${eventName}`, { - source: this.source, - numThreads: this.threads.length, - description: this.description(), - isUndo: this._isUndoTask, - }) - } - - retrieveModels() { - // Convert arrays of IDs or models to models. - // modelify returns immediately if (no work is required) - return Promise.props({ - threads: DatabaseStore.modelify(Thread, this.threads), - }).then(({threads}) => { - this.threads = _.compact(threads); - return Promise.resolve(); - }); - } - - processNestedMessages() { - return true; - } - - changesToModel(model) { - return {unread: this.unread}; - } - - requestBodyForModel(model) { - return {unread: model.unread}; - } } diff --git a/packages/client-app/src/flux/tasks/task.es6 b/packages/client-app/src/flux/tasks/task.es6 index 457ff1336..82a9b11de 100644 --- a/packages/client-app/src/flux/tasks/task.es6 +++ b/packages/client-app/src/flux/tasks/task.es6 @@ -1,6 +1,7 @@ /* eslint no-unused-vars: 0*/ import _ from 'underscore'; import Model from '../models/model'; +import Attributes from '../attributes'; import {generateTempId} from '../models/utils'; import {PermanentErrorCodes} from '../nylas-api'; import {APIError} from '../errors'; @@ -13,10 +14,23 @@ const TaskStatus = { }; export default class Task extends Model { - static Status = TaskStatus; static SubclassesUseModelTable = Task; + static attributes = Object.assign({}, Model.attributes, { + version: Attributes.String({ + queryable: true, + modelKey: 'version', + }), + status: Attributes.String({ + queryable: true, + modelKey: 'status', + }), + accountId: Attributes.String({ + modelKey: 'accountId', + }), + }); + // Public: Override the constructor to pass initial args to your Task and // initialize instance variables. // @@ -26,147 +40,11 @@ export default class Task extends Model { // On construction, all Tasks instances are given a unique `id`. constructor() { super(); + this.version = 1; this._rememberedToCallSuper = true; this.id = generateTempId(); + this.accountId = "1"; //TODO BG hack this.sequentialId = null; // set when queued - this.queueState = { - isProcessing: false, - localError: null, - localComplete: false, - remoteError: null, - remoteAttempts: 0, - remoteComplete: false, - status: null, - }; - } - - // Private: This is a internal wrapper around `performLocal` - runLocal() { - if (!this._rememberedToCallSuper) { - throw new Error("Your must call `super` from your Task's constructors"); - } - - if (this.queueState.localComplete) { - return Promise.resolve(); - } - - try { - return this.performLocal() - .then(() => { - this.queueState.localComplete = true; - this.queueState.localError = null; - return Promise.resolve(); - }) - .catch(this._handleLocalError); - } catch (err) { - return this._handleLocalError(err); - } - } - - _handleLocalError = (err) => { - this.queueState.localError = err; - this.queueState.status = Task.Status.Failed; - NylasEnv.reportError(err); - return Promise.reject(err); - } - - - // HELPER METHODS - validateRequiredFields = (fields = []) => { - for (const field of fields) { - if (!this[field]) { - throw new Error(`Must pass ${field}`); - } - } - } - - // METHODS TO OBSERVE - // - // Public: **Required** | Override to perform local, optimistic updates. - // - // Most tasks will put code in here that updates the {DatabaseStore} - // - // You should also implement the rollback behavior inside of - // `performLocal` or in some helper method. It's common practice (but not - // automatic) for `performLocal` to be re-called at the end of an API - // failure from `performRemote`. - // - // That rollback behavior is also likely the same when you want to undo a - // task. It's common practice (but not automatic) for `createUndoTask` to - // set some flag that `performLocal` will recognize to implement the - // rollback behavior. - // - // `performLocal` will complete BEFORE the task actually enters the - // {TaskQueue}. - // - // if (you would like to do work after `performLocal` has run, you can use) - // {TaskQueue::waitForPerformLocal}. Pass it the task and it - // will return a Promise that resolves once the local action has - // completed. This is contained in the {TaskQueue} so you can - // listen to tasks across windows. - // - // ## Examples - // - // ### Simple Optimistic Updating - // - // ```js - // class MyTask extends Task { - // performLocal() { - // this.updatedModel = this._myModelUpdateCode() - // return DatabaseStore.inTransaction((t) => persistModel(this.updatedModel)); - // } - // } - // ``` - // - // ### Handling rollback on API failure - // - // ```js - // class MyTask extends Task - // performLocal() { - // if (this._reverting) { - // this.updatedModel = this._myModelRollbackCode(); - // } else { - // this.updatedModel = this._myModelUpdateCode(); - // } - // return DatabaseStore.inTransaction((t) => persistModel(this.updatedModel)); - // } - // performRemote() { - // return this._APIPutHelperMethod(this.updatedModel).catch((apiError) => { - // if (apiError.statusCode === 500) { - // this._reverting = true; - // return this.performLocal(); - // } - // } - // } - // } - // ``` - // - // ### Handling an undo task - // - // ```js - // class MyTask extends Task { - // performLocal() { - // if (this._isUndoTask) { - // this.updatedModel = this._myModelRollbackCode(); - // } else { - // this.updatedModel = this._myModelUpdateCode(); - // } - // return DatabaseStore.inTransaction((t) => persistModel(this.updatedModel)); - // } - // - // createUndoTask() { - // undoTask = this.createIdenticalTask(); - // undoTask._isUndoTask = true; - // return undoTask; - // } - // } - // ``` - // - // Also see the documentation on the required undo methods - // - // Returns a {Promise} that resolves when your updates are complete. - performLocal() { - return Promise.resolve(); } // Public: It's up to you to determine how you want to indicate whether @@ -196,7 +74,7 @@ export default class Task extends Model { // Public: Return a deep-cloned task to be used for an undo task createIdenticalTask() { const json = this.toJSON(); - delete json.queueState; + delete json.status; return (new this.constructor()).fromJSON(json); } @@ -224,7 +102,7 @@ export default class Task extends Model { // Private: Allows for serialization of tasks toJSON() { - return this; + return Object.assign(super.toJSON(), this); } // Private: Allows for deserialization of tasks