diff --git a/internal_packages/composer/lib/composer-header.jsx b/internal_packages/composer/lib/composer-header.jsx index 492db91e5..88d3a15d0 100644 --- a/internal_packages/composer/lib/composer-header.jsx +++ b/internal_packages/composer/lib/composer-header.jsx @@ -2,7 +2,7 @@ import _ from 'underscore'; import React from 'react'; import ReactDOM from 'react-dom'; import AccountContactField from './account-contact-field'; -import {Utils, Actions, AccountStore} from 'nylas-exports'; +import {Utils, DraftHelpers, Actions, AccountStore} from 'nylas-exports'; import {InjectedComponent, KeyCommandsRegion, ParticipantsTextField, ListensToFluxStore} from 'nylas-component-kit'; import CollapsedParticipants from './collapsed-participants'; @@ -105,7 +105,7 @@ export default class ComposerHeader extends React.Component { if (_.isEmpty(this.props.draft.subject)) { return true; } - if (Utils.isForwardedMessage(this.props.draft)) { + if (DraftHelpers.isForwardedMessage(this.props.draft)) { return true; } if (this.props.draft.replyToMessageId) { @@ -153,7 +153,7 @@ export default class ComposerHeader extends React.Component { if (isDropping) { this.setState({ participantsFocused: true, - enabledFields: [...Fields.ParticipantFields, Fields.Subject], + enabledFields: [...Fields.ParticipantFields, Fields.From, Fields.Subject], }) } } diff --git a/internal_packages/composer/lib/composer-view.jsx b/internal_packages/composer/lib/composer-view.jsx index c765ade1a..59e730fab 100644 --- a/internal_packages/composer/lib/composer-view.jsx +++ b/internal_packages/composer/lib/composer-view.jsx @@ -7,10 +7,10 @@ import { Utils, Actions, DraftStore, - ContactStore, - QuotedHTMLTransformer, + UndoManager, + DraftHelpers, FileDownloadStore, - ExtensionRegistry, + QuotedHTMLTransformer, } from 'nylas-exports'; import { @@ -55,7 +55,7 @@ export default class ComposerView extends React.Component { constructor(props) { super(props) this.state = { - showQuotedText: Utils.isForwardedMessage(props.draft), + showQuotedText: DraftHelpers.isForwardedMessage(props.draft), } } @@ -65,14 +65,14 @@ export default class ComposerView extends React.Component { } } - componentWillReceiveProps(newProps) { - if (newProps.session !== this.props.session) { + componentWillReceiveProps(nextProps) { + if (nextProps.session !== this.props.session) { this._teardownForProps(); - this._setupForProps(newProps); + this._setupForProps(nextProps); } - if (Utils.isForwardedMessage(this.props.draft) !== Utils.isForwardedMessage(newProps.draft)) { + if (DraftHelpers.isForwardedMessage(this.props.draft) !== DraftHelpers.isForwardedMessage(nextProps.draft)) { this.setState({ - showQuotedText: Utils.isForwardedMessage(newProps.draft), + showQuotedText: DraftHelpers.isForwardedMessage(nextProps.draft), }); } } @@ -507,56 +507,19 @@ export default class ComposerView extends React.Component { } const dialog = remote.dialog; + const {session} = this.props + const {errors, warnings} = session.validateDraftForSending() - const {to, cc, bcc, body, files, uploads} = this.props.draft; - const allRecipients = [].concat(to, cc, bcc); - let dealbreaker = null; - - for (const contact of allRecipients) { - if (!ContactStore.isValidContact(contact)) { - dealbreaker = `${contact.email} is not a valid email address - please remove or edit it before sending.` - } - } - if (allRecipients.length === 0) { - dealbreaker = 'You need to provide one or more recipients before sending the message.'; - } - - if (dealbreaker) { + if (errors.length > 0) { dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', buttons: ['Edit Message', 'Cancel'], message: 'Cannot Send', - detail: dealbreaker, + detail: errors[0], }); return false; } - const bodyIsEmpty = body === this.props.session.draftPristineBody(); - const forwarded = Utils.isForwardedMessage(this.props.draft); - const hasAttachment = (files || []).length > 0 || (uploads || []).length > 0; - - let warnings = []; - - if (this.props.draft.subject.length === 0) { - warnings.push('without a subject line'); - } - - if (this._mentionsAttachment(this.props.draft.body) && !hasAttachment) { - warnings.push('without an attachment'); - } - - if (bodyIsEmpty && !forwarded && !hasAttachment) { - warnings.push('without a body'); - } - - // Check third party warnings added via Composer extensions - for (const extension of ExtensionRegistry.Composer.extensions()) { - if (!extension.warningsForSending) { - continue; - } - warnings = warnings.concat(extension.warningsForSending({draft: this.props.draft})); - } - if ((warnings.length > 0) && (!options.force)) { const response = dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', @@ -569,7 +532,6 @@ export default class ComposerView extends React.Component { } return false; } - return true; } @@ -585,15 +547,62 @@ export default class ComposerView extends React.Component { Actions.selectAttachment({messageClientId: this.props.draft.clientId}); } - _mentionsAttachment = (body) => { - let cleaned = QuotedHTMLTransformer.removeQuotedHTML(body.toLowerCase().trim()); - const signatureIndex = cleaned.indexOf(''); - if (signatureIndex !== -1) { - cleaned = cleaned.substr(0, signatureIndex - 1); + undo = (event) => { + event.preventDefault(); + event.stopPropagation(); + + const historyItem = this.undoManager.undo() || {}; + if (!historyItem.state) { + return; } - return (cleaned.indexOf("attach") >= 0); + + this._recoveredSelection = historyItem.currentSelection; + this._applyChanges(historyItem.state, {fromUndoManager: true}); + this._recoveredSelection = null; } + redo = (event) => { + event.preventDefault(); + event.stopPropagation(); + const historyItem = this.undoManager.redo() || {} + if (!historyItem.state) { + return; + } + this._recoveredSelection = historyItem.currentSelection; + this._applyChanges(historyItem.state, {fromUndoManager: true}); + this._recoveredSelection = null; + } + + _getSelections = () => { + const bodyComponent = this.refs[Fields.Body]; + return { + currentSelection: bodyComponent.getCurrentSelection ? bodyComponent.getCurrentSelection() : null, + previousSelection: bodyComponent.getPreviousSelection ? bodyComponent.getPreviousSelection() : null, + } + } + + _saveToHistory = (selections) => { + const {previousSelection, currentSelection} = selections || this._getSelections(); + + const historyItem = { + previousSelection, + currentSelection, + state: { + body: _.clone(this.props.draft.body), + subject: _.clone(this.props.draft.subject), + to: _.clone(this.props.draft.to), + cc: _.clone(this.props.draft.cc), + bcc: _.clone(this.props.draft.bcc), + }, + } + + const lastState = this.undoManager.current() + if (lastState) { + lastState.currentSelection = historyItem.previousSelection; + } + + this.undoManager.saveToHistory(historyItem); + } render() { const dropCoverDisplay = this.state.isDropping ? 'block' : 'none'; diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index 7ac71f51d..3f4eb2c1e 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -77,8 +77,8 @@ describe "ComposerView", -> @draft = draft spyOn(ContactStore, "searchContacts").andCallFake (email) => return _.filter(users, (u) u.email.toLowerCase() is email.toLowerCase()) - spyOn(ContactStore, "isValidContact").andCallFake (contact) => - return contact.email.indexOf('@') > 0 + spyOn(Contact.prototype, "isValid").andCallFake (contact) -> + return @email.indexOf('@') > 0 afterEach -> ComposerEditor.containerRequired = undefined diff --git a/internal_packages/message-list/lib/message-item-body.cjsx b/internal_packages/message-list/lib/message-item-body.cjsx index fb1608254..74ea545c6 100644 --- a/internal_packages/message-list/lib/message-item-body.cjsx +++ b/internal_packages/message-list/lib/message-item-body.cjsx @@ -1,7 +1,7 @@ React = require 'react' _ = require 'underscore' EmailFrame = require('./email-frame').default -{Utils, +{DraftHelpers, CanvasUtils, NylasAPI, MessageUtils, @@ -21,7 +21,7 @@ class MessageItemBody extends React.Component constructor: (@props) -> @_unmounted = false @state = - showQuotedText: Utils.isForwardedMessage(@props.message) + showQuotedText: DraftHelpers.isForwardedMessage(@props.message) processedBody: null error: null diff --git a/internal_packages/thread-list/lib/thread-list-icon.cjsx b/internal_packages/thread-list/lib/thread-list-icon.cjsx index 639d4a337..268ae2cea 100644 --- a/internal_packages/thread-list/lib/thread-list-icon.cjsx +++ b/internal_packages/thread-list/lib/thread-list-icon.cjsx @@ -1,7 +1,7 @@ _ = require 'underscore' React = require 'react' -{Actions, - Utils, +{DraftHelpers, + Actions, Thread, ChangeStarredTask, AccountStore} = require 'nylas-exports' @@ -25,7 +25,7 @@ class ThreadListIcon extends React.Component last = msgs[msgs.length - 1] if msgs.length > 1 and last.from[0]?.isMe() - if Utils.isForwardedMessage(last) + if DraftHelpers.isForwardedMessage(last) return 'thread-icon-forwarded thread-icon-star-on-hover' else return 'thread-icon-replied thread-icon-star-on-hover' diff --git a/spec/models/contact-spec.coffee b/spec/models/contact-spec.coffee index 1d17554e2..035e366e5 100644 --- a/spec/models/contact-spec.coffee +++ b/spec/models/contact-spec.coffee @@ -154,3 +154,24 @@ describe "Contact", -> spyOn(AccountStore, 'accountForEmail').andReturn(acct) expect(c1.isMe()).toBe(true) expect(AccountStore.accountForEmail).toHaveBeenCalled() + + describe 'isValid', -> + it "should return true for a variety of valid contacts", -> + expect((new Contact(name: 'Ben', email: 'ben@nylas.com')).isValid()).toBe(true) + expect((new Contact(email: 'ben@nylas.com')).isValid()).toBe(true) + expect((new Contact(email: 'ben+123@nylas.com')).isValid()).toBe(true) + + it "should return false if the contact has no email", -> + expect((new Contact(name: 'Ben')).isValid()).toBe(false) + + it "should return false if the contact has an email that is not valid", -> + expect((new Contact(name: 'Ben', email:'Ben ')).isValid()).toBe(false) + expect((new Contact(name: 'Ben', email:'')).isValid()).toBe(false) + expect((new Contact(name: 'Ben', email:'"ben@nylas.com"')).isValid()).toBe(false) + + it "returns false if the email doesn't satisfy the regex", -> + expect((new Contact(name: "test", email: "foo")).isValid()).toBe false + + it "returns false if the email doesn't match", -> + expect((new Contact(name: "test", email: "foo@")).isValid()).toBe false + diff --git a/spec/stores/contact-store-spec.coffee b/spec/stores/contact-store-spec.coffee index 5d4b852c7..fe294a0c8 100644 --- a/spec/stores/contact-store-spec.coffee +++ b/spec/stores/contact-store-spec.coffee @@ -117,34 +117,17 @@ describe "ContactStore", -> expect(results.length).toBe 6 describe 'isValidContact', -> - it "should return true for a variety of valid contacts", -> - expect(ContactStore.isValidContact(new Contact(name: 'Ben', email: 'ben@nylas.com'))).toBe(true) - expect(ContactStore.isValidContact(new Contact(email: 'ben@nylas.com'))).toBe(true) - expect(ContactStore.isValidContact(new Contact(email: 'ben+123@nylas.com'))).toBe(true) + it "should call contact.isValid", -> + contact = new Contact() + spyOn(contact, 'isValid').andReturn(true) + expect(ContactStore.isValidContact(contact)).toBe(true) it "should return false for non-Contact objects", -> expect(ContactStore.isValidContact({name: 'Ben', email: 'ben@nylas.com'})).toBe(false) - it "should return false if the contact has no email", -> - expect(ContactStore.isValidContact(new Contact(name: 'Ben'))).toBe(false) - - it "should return false if the contact has an email that is not valid", -> - expect(ContactStore.isValidContact(new Contact(name: 'Ben', email:'Ben '))).toBe(false) - expect(ContactStore.isValidContact(new Contact(name: 'Ben', email:''))).toBe(false) - expect(ContactStore.isValidContact(new Contact(name: 'Ben', email:'"ben@nylas.com"'))).toBe(false) - it "returns false if we're not passed a contact", -> expect(ContactStore.isValidContact()).toBe false - it "returns false if the contact doesn't have an email", -> - expect(ContactStore.isValidContact(new Contact(name: "test"))).toBe false - - it "returns false if the email doesn't satisfy the regex", -> - expect(ContactStore.isValidContact(new Contact(name: "test", email: "foo"))).toBe false - - it "returns false if the email doesn't match", -> - expect(ContactStore.isValidContact(new Contact(name: "test", email: "foo@"))).toBe false - describe 'parseContactsInString', -> testCases = # Single contact test cases diff --git a/spec/stores/draft-helpers-spec.es6 b/spec/stores/draft-helpers-spec.es6 new file mode 100644 index 000000000..440853ea3 --- /dev/null +++ b/spec/stores/draft-helpers-spec.es6 @@ -0,0 +1,37 @@ +import { + Actions, + Message, + DraftHelpers, + SyncbackDraftFilesTask, +} from 'nylas-exports'; + +describe('DraftHelpers', function describeBlock() { + describe('prepareForSyncback', () => { + beforeEach(() => { + spyOn(DraftHelpers, 'applyExtensionTransformsToDraft').andCallFake((draft) => Promise.resolve(draft)) + spyOn(Actions, 'queueTask') + }); + + it('queues tasks to upload files and send the draft', () => { + const draft = new Message({ + clientId: "local-123", + threadId: "thread-123", + replyToMessageId: "message-123", + uploads: ['stub'], + }); + const session = { + ensureCorrectAccount() { return Promise.resolve() }, + draft() { return draft }, + } + runs(() => { + DraftHelpers.prepareDraftForSyncback(session); + }); + waitsFor(() => Actions.queueTask.calls.length > 0); + runs(() => { + const saveAttachments = Actions.queueTask.calls[0].args[0]; + expect(saveAttachments instanceof SyncbackDraftFilesTask).toBe(true); + expect(saveAttachments.draftClientId).toBe(draft.clientId); + }); + }); + }); +}); diff --git a/spec/stores/draft-store-spec.es6 b/spec/stores/draft-store-spec.es6 index be60964c1..6a66e4ccd 100644 --- a/spec/stores/draft-store-spec.es6 +++ b/spec/stores/draft-store-spec.es6 @@ -5,15 +5,14 @@ import { Message, Account, DraftStore, + DraftHelpers, DatabaseStore, SoundRegistry, - SendDraftTask, DestroyDraftTask, ComposerExtension, ExtensionRegistry, FocusedContentStore, DatabaseTransaction, - SyncbackDraftFilesTask, } from 'nylas-exports'; import {remote} from 'electron'; @@ -323,7 +322,7 @@ describe('DraftStore', function draftStore() { DraftStore._draftSessions[this.draft.clientId] = session; spyOn(DraftStore, "_doneWithSession").andCallThrough(); - spyOn(DraftStore, "_prepareForSyncback").andReturn(Promise.resolve()); + spyOn(DraftHelpers, "prepareDraftForSyncback").andReturn(Promise.resolve()); spyOn(DraftStore, "trigger"); spyOn(SoundRegistry, "playSound"); spyOn(Actions, "queueTask"); @@ -408,25 +407,10 @@ describe('DraftStore', function draftStore() { }); }); - it("queues tasks to upload files and send the draft", () => { - runs(() => { - DraftStore._onSendDraft(this.draft.clientId); - }); - waitsFor(() => Actions.queueTask.calls.length > 0); - runs(() => { - const saveAttachments = Actions.queueTask.calls[0].args[0]; - expect(saveAttachments instanceof SyncbackDraftFilesTask).toBe(true); - expect(saveAttachments.draftClientId).toBe(this.draft.clientId); - const sendDraft = Actions.queueTask.calls[1].args[0]; - expect(sendDraft instanceof SendDraftTask).toBe(true); - expect(sendDraft.draftClientId).toBe(this.draft.clientId); - }); - }); - it("resets the sending state if there's an error", () => { spyOn(NylasEnv, "isMainWindow").andReturn(false); DraftStore._draftsSending[this.draft.clientId] = true; - Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId}); + Actions.sendDraftFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId}); expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false); expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId); }); @@ -437,7 +421,7 @@ describe('DraftStore', function draftStore() { spyOn(remote.dialog, "showMessageBox"); spyOn(Actions, "composePopoutDraft"); DraftStore._draftsSending[this.draft.clientId] = true; - Actions.draftSendingFailed({threadId: 't1', errorMessage: "boohoo", draftClientId: this.draft.clientId}); + Actions.sendDraftFailed({threadId: 't1', errorMessage: "boohoo", draftClientId: this.draft.clientId}); advanceClock(200); expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false); expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId); @@ -452,7 +436,7 @@ describe('DraftStore', function draftStore() { spyOn(FocusedContentStore, "focused").andReturn({id: "t1"}); spyOn(Actions, "composePopoutDraft"); DraftStore._draftsSending[this.draft.clientId] = true; - Actions.draftSendingFailed({threadId: 't2', errorMessage: "boohoo", draftClientId: this.draft.clientId}); + Actions.sendDraftFailed({threadId: 't2', errorMessage: "boohoo", draftClientId: this.draft.clientId}); advanceClock(200); expect(Actions.composePopoutDraft).toHaveBeenCalled(); const call = Actions.composePopoutDraft.calls[0]; @@ -465,7 +449,7 @@ describe('DraftStore', function draftStore() { spyOn(Actions, "composePopoutDraft"); DraftStore._draftsSending[this.draft.clientId] = true; spyOn(FocusedContentStore, "focused").andReturn(null); - Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId}); + Actions.sendDraftFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId}); advanceClock(200); expect(Actions.composePopoutDraft).toHaveBeenCalled(); const call = Actions.composePopoutDraft.calls[0]; diff --git a/spec/tasks/send-draft-task-spec.es6 b/spec/tasks/send-draft-task-spec.es6 index 940b2d8dd..39b2d26f2 100644 --- a/spec/tasks/send-draft-task-spec.es6 +++ b/spec/tasks/send-draft-task-spec.es6 @@ -222,7 +222,7 @@ describe('SendDraftTask', function sendDraftTask() { describe("when there are errors", () => { beforeEach(() => { - spyOn(Actions, 'draftSendingFailed'); + spyOn(Actions, 'sendDraftFailed'); jasmine.unspy(NylasAPI, "makeRequest"); }); @@ -238,7 +238,7 @@ describe('SendDraftTask', function sendDraftTask() { waitsForPromise(() => this.task.performRemote().then((status) => { expect(status[0]).toBe(Task.Status.Failed); expect(status[1]).toBe(thrownError); - expect(Actions.draftSendingFailed).toHaveBeenCalled(); + expect(Actions.sendDraftFailed).toHaveBeenCalled(); expect(NylasEnv.reportError).toHaveBeenCalled(); })); }); @@ -300,7 +300,7 @@ describe('SendDraftTask', function sendDraftTask() { waitsForPromise(() => this.task.performRemote().then((status) => { expect(status[0]).toBe(Task.Status.Failed); expect(status[1]).toBe(thrownError); - expect(Actions.draftSendingFailed).toHaveBeenCalled(); + expect(Actions.sendDraftFailed).toHaveBeenCalled(); })); }); @@ -312,7 +312,7 @@ describe('SendDraftTask', function sendDraftTask() { waitsForPromise(() => this.task.performRemote().then((status) => { expect(status[0]).toBe(Task.Status.Failed); expect(status[1]).toBe(thrownError); - expect(Actions.draftSendingFailed).toHaveBeenCalled(); + expect(Actions.sendDraftFailed).toHaveBeenCalled(); })); }); @@ -341,9 +341,9 @@ describe('SendDraftTask', function sendDraftTask() { waitsForPromise(() => this.task.performRemote().then((status) => { expect(status[0]).toBe(Task.Status.Failed); expect(status[1]).toBe(thrownError); - expect(Actions.draftSendingFailed).toHaveBeenCalled(); + expect(Actions.sendDraftFailed).toHaveBeenCalled(); - const msg = Actions.draftSendingFailed.calls[0].args[0].errorMessage; + const msg = Actions.sendDraftFailed.calls[0].args[0].errorMessage; expect(withoutWhitespace(msg)).toEqual(withoutWhitespace(expectedMessage)); })); }); @@ -365,9 +365,9 @@ describe('SendDraftTask', function sendDraftTask() { waitsForPromise(() => this.task.performRemote().then((status) => { expect(status[0]).toBe(Task.Status.Failed); expect(status[1]).toBe(thrownError); - expect(Actions.draftSendingFailed).toHaveBeenCalled(); + expect(Actions.sendDraftFailed).toHaveBeenCalled(); - const msg = Actions.draftSendingFailed.calls[0].args[0].errorMessage; + const msg = Actions.sendDraftFailed.calls[0].args[0].errorMessage; expect(withoutWhitespace(msg)).toEqual(withoutWhitespace(expectedMessage)); })); }); diff --git a/src/components/lazy-rendered-list.jsx b/src/components/lazy-rendered-list.jsx index 29ca468de..5f8b9205c 100644 --- a/src/components/lazy-rendered-list.jsx +++ b/src/components/lazy-rendered-list.jsx @@ -4,7 +4,7 @@ import {findDOMNode} from 'react-dom' const MIN_RANGE_SIZE = 2 -function getRange({total, itemHeight, containerHeight, scrollTop}) { +function getRange({total, itemHeight, containerHeight, scrollTop = 0} = {}) { const itemsPerBody = Math.floor((containerHeight) / itemHeight); const start = Math.max(0, Math.floor(scrollTop / itemHeight) - (itemsPerBody * 2)); const end = Math.max(MIN_RANGE_SIZE, Math.min(start + (4 * itemsPerBody), total)); @@ -22,6 +22,7 @@ class LazyRenderedList extends Component { } static defaultProps = { + items: [], itemHeight: 30, containerHeight: 150, BufferTag: 'div', @@ -29,7 +30,7 @@ class LazyRenderedList extends Component { constructor(props) { super(props) - this.state = {start: 0, end: MIN_RANGE_SIZE} + this.state = this.getRangeState(props) } componentWillReceiveProps(nextProps) { @@ -40,9 +41,13 @@ class LazyRenderedList extends Component { this.updateRangeState(this.props) } - updateRangeState({itemHeight, items, containerHeight}) { + getRangeState({items, itemHeight, containerHeight, scrollTop}) { + return getRange({total: items.length, itemHeight, containerHeight, scrollTop}) + } + + updateRangeState(props) { const {scrollTop} = findDOMNode(this) - this.setState(getRange({total: items.length, itemHeight, containerHeight, scrollTop})) + this.setState(this.getRangeState({...props, scrollTop})) } renderItems() { diff --git a/src/components/table/table-data-source.es6 b/src/components/table/table-data-source.es6 index b3d3db03c..fad553246 100644 --- a/src/components/table/table-data-source.es6 +++ b/src/components/table/table-data-source.es6 @@ -121,6 +121,15 @@ export default class TableDataSource { return {...this._tableData} } + filterRows(filterFn) { + const rows = this.rows() + const nextRows = rows.filter(filterFn) + return new TableDataSource({ + ...this._tableData, + rows: nextRows, + }) + } + /** * Adds column * diff --git a/src/flux/actions.coffee b/src/flux/actions.coffee index dbf6f798b..71b81b22a 100644 --- a/src/flux/actions.coffee +++ b/src/flux/actions.coffee @@ -83,8 +83,8 @@ class Actions Recieves the clientId of the message that was sent ### @sendDraftSuccess: ActionScopeGlobal + @sendDraftFailed: ActionScopeGlobal @sendToAllWindows: ActionScopeGlobal - @draftSendingFailed: ActionScopeGlobal ### Public: Queue a {Task} object to the {TaskQueue}. @@ -367,7 +367,7 @@ class Actions ``` ### @sendDraft: ActionScopeWindow - @sendDrafts: ActionScopeWindow + @sendManyDrafts: ActionScopeWindow @ensureDraftSynced: ActionScopeWindow ### diff --git a/src/flux/models/contact.coffee b/src/flux/models/contact.coffee index 9ee8d0ba9..ce02a09b2 100644 --- a/src/flux/models/contact.coffee +++ b/src/flux/models/contact.coffee @@ -91,7 +91,17 @@ class Contact extends Model json['name'] ||= json['email'] json + # Public: Returns true if the contact provided is a {Contact} instance and + # contains a properly formatted email address. + # isValid: -> + return false unless @email + + # The email regexp must match the /entire/ email address + result = RegExpUtils.emailRegex().exec(@email) + if result and result instanceof Array + return result[0] is @email + else return false @email.match(RegExpUtils.emailRegex()) != null # Public: Returns true if the contact is the current user, false otherwise. diff --git a/src/flux/models/message.es6 b/src/flux/models/message.es6 index 583760326..d1021fb4c 100644 --- a/src/flux/models/message.es6 +++ b/src/flux/models/message.es6 @@ -4,11 +4,12 @@ import moment from 'moment' import File from './file' import Utils from './utils' import Event from './event' -import Category from './category' import Contact from './contact' +import Category from './category' import Attributes from '../attributes' import ModelWithMetadata from './model-with-metadata' + /** Public: The Message model represents a Message object served by the Nylas Platform API. For more information about Messages on the Nylas Platform, read the diff --git a/src/flux/models/utils.coffee b/src/flux/models/utils.coffee index 9315abbd2..aa3055e68 100644 --- a/src/flux/models/utils.coffee +++ b/src/flux/models/utils.coffee @@ -223,25 +223,6 @@ Utils = else return "#{prefix} #{subject}" - # Returns true if the message contains "Forwarded" or "Fwd" in the first - # 250 characters. A strong indicator that the quoted text should be - # shown. Needs to be limited to first 250 to prevent replies to - # forwarded messages from also being expanded. - isForwardedMessage: ({body, subject} = {}) -> - bodyForwarded = false - bodyFwd = false - subjectFwd = false - - if body - indexForwarded = body.search(/forwarded/i) - bodyForwarded = indexForwarded >= 0 and indexForwarded < 250 - indexFwd = body.search(/fwd/i) - bodyFwd = indexFwd >= 0 and indexFwd < 250 - if subject - subjectFwd = subject[0...3].toLowerCase() is "fwd" - - return bodyForwarded or bodyFwd or subjectFwd - # True of all arguments have the same domains emailsHaveSameDomain: (args...) -> return false if args.length < 2 diff --git a/src/flux/stores/contact-store.coffee b/src/flux/stores/contact-store.coffee index fcbea6815..200830515 100644 --- a/src/flux/stores/contact-store.coffee +++ b/src/flux/stores/contact-store.coffee @@ -80,18 +80,9 @@ class ContactStore extends NylasStore return Promise.resolve(results) - # Public: Returns true if the contact provided is a {Contact} instance and - # contains a properly formatted email address. - # isValidContact: (contact) => return false unless contact instanceof Contact - return false unless contact.email - - # The email regexp must match the /entire/ email address - result = RegExpUtils.emailRegex().exec(contact.email) - if result and result instanceof Array - return result[0] is contact.email - else return false + return contact.isValid() parseContactsInString: (contactString, options={}) => {skipNameLookup} = options diff --git a/src/flux/stores/draft-editing-session.coffee b/src/flux/stores/draft-editing-session.coffee index 4619f8de7..35f0f640e 100644 --- a/src/flux/stores/draft-editing-session.coffee +++ b/src/flux/stores/draft-editing-session.coffee @@ -1,13 +1,16 @@ Message = require('../models/message').default Actions = require '../actions' +NylasAPI = require '../nylas-api' +AccountStore = require './account-store' +ContactStore = require './contact-store' DatabaseStore = require './database-store' UndoStack = require '../../undo-stack' -ExtensionRegistry = require('../../extension-registry') +DraftHelpers = require '../stores/draft-helpers' +ExtensionRegistry = require '../../extension-registry' {Listener, Publisher} = require '../modules/reflux-coffee' SyncbackDraftTask = require('../tasks/syncback-draft-task').default CoffeeHelpers = require '../coffee-helpers' DraftStore = null - _ = require 'underscore' MetadataChangePrefix = 'metadata.' @@ -143,6 +146,64 @@ class DraftEditingSession @changes.teardown() @_destroyed = true + validateDraftForSending: => + warnings = [] + errors = [] + allRecipients = [].concat(@_draft.to, @_draft.cc, @_draft.bcc) + bodyIsEmpty = @_draft.body is @draftPristineBody() + forwarded = DraftHelpers.isForwardedMessage(@_draft) + hasAttachment = @_draft.files?.length > 0 or @_draft.uploads?.length > 0 + + for contact in allRecipients + if not ContactStore.isValidContact(contact) + errors.push("#{contact.email} is not a valid email address - please remove or edit it before sending.") + + if allRecipients.length is 0 + errors.push('You need to provide one or more recipients before sending the message.') + + if errors.length > 0 + return {errors, warnings} + + if @_draft.subject.length is 0 + warnings.push('without a subject line') + + if DraftHelpers.messageMentionsAttachment(@_draft) and not hasAttachment + warnings.push('without an attachment') + + if bodyIsEmpty and not forwarded and not hasAttachment + warnings.push('without a body') + + ## Check third party warnings added via Composer extensions + for extension in ExtensionRegistry.Composer.extensions() + continue if not extension.warningsForSending + warnings = warnings.concat(extension.warningsForSending({draft: @_draft})) + + return {errors, warnings} + + # This function makes sure the draft is attached to a valid account, and changes + # it's accountId if the from address does not match the account for the from + # address + # + # If the account is updated it makes a request to delete the draft with the + # old accountId + ensureCorrectAccount: ({noSyncback} = {}) => + account = AccountStore.accountForEmail(@_draft.from[0].email) + if !account + return Promise.reject(new Error("DraftEditingSession::ensureCorrectAccount - you can only send drafts from a configured account.")) + + if account.id isnt @_draft.accountId + NylasAPI.makeDraftDeletionRequest(@_draft) + @changes.add({ + accountId: account.id, + version: null, + serverId: null, + threadId: null, + replyToMessageId: null, + }) + return @changes.commit({noSyncback}) + .thenReturn(@) + return Promise.resolve(@) + _setDraft: (draft) -> if !draft.body? throw new Error("DraftEditingSession._setDraft - new draft has no body!") diff --git a/src/flux/stores/draft-helpers.es6 b/src/flux/stores/draft-helpers.es6 new file mode 100644 index 000000000..f6817d793 --- /dev/null +++ b/src/flux/stores/draft-helpers.es6 @@ -0,0 +1,96 @@ +import _ from 'underscore' +import Actions from '../actions' +import DatabaseStore from './database-store' +import * as ExtensionRegistry from '../../extension-registry' +import SyncbackDraftFilesTask from '../tasks/syncback-draft-files-task' +import QuotedHTMLTransformer from '../../services/quoted-html-transformer' + + +export const AllowedTransformFields = ['to', 'from', 'cc', 'bcc', 'subject', 'body'] + +/** + * Returns true if the message contains "Forwarded" or "Fwd" in the first + * 250 characters. A strong indicator that the quoted text should be + * shown. Needs to be limited to first 250 to prevent replies to + * forwarded messages from also being expanded. +*/ +export function isForwardedMessage({body, subject} = {}) { + let bodyFwd = false + let bodyForwarded = false + let subjectFwd = false + + if (body) { + const indexFwd = body.search(/fwd/i) + const indexForwarded = body.search(/forwarded/i) + bodyForwarded = indexForwarded >= 0 && indexForwarded < 250 + bodyFwd = indexFwd >= 0 && indexFwd < 250 + } + if (subject) { + subjectFwd = subject.slice(0, 3).toLowerCase() === "fwd" + } + + return bodyForwarded || bodyFwd || subjectFwd +} + +export function messageMentionsAttachment({body} = {}) { + if (body == null) { throw new Error('DraftHelpers::messageMentionsAttachment - Message has no body loaded') } + let cleaned = QuotedHTMLTransformer.removeQuotedHTML(body.toLowerCase().trim()); + const signatureIndex = cleaned.indexOf(''); + if (signatureIndex !== -1) { + cleaned = cleaned.substr(0, signatureIndex - 1); + } + return (cleaned.indexOf("attach") >= 0); +} + +export function queueDraftFileUploads(draft) { + if (draft.files.length > 0 || draft.uploads.length > 0) { + Actions.queueTask(new SyncbackDraftFilesTask(draft.clientId)) + } +} + +export function applyExtensionTransformsToDraft(draft) { + let latestTransformed = draft + const extensions = ExtensionRegistry.Composer.extensions() + const transformPromise = ( + Promise.each(extensions, (ext) => { + const extApply = ext.applyTransformsToDraft + const extUnapply = ext.unapplyTransformsToDraft + + if (!extApply || !extUnapply) { + return Promise.resolve() + } + + return Promise.resolve(extUnapply({draft: latestTransformed})).then((cleaned) => { + const base = cleaned === 'unnecessary' ? latestTransformed : cleaned; + return Promise.resolve(extApply({draft: base})).then((transformed) => ( + Promise.resolve(extUnapply({draft: transformed.clone()})).then((reverted) => { + const untransformed = reverted === 'unnecessary' ? base : reverted; + if (!_.isEqual(_.pick(untransformed, AllowedTransformFields), _.pick(base, AllowedTransformFields))) { + console.log("-- BEFORE --") + console.log(base.body) + console.log("-- TRANSFORMED --") + console.log(transformed.body) + console.log("-- UNTRANSFORMED (should match BEFORE) --") + console.log(untransformed.body) + NylasEnv.reportError(new Error(`Extension ${ext.name} applied a transform to the draft that it could not reverse.`)) + } + latestTransformed = transformed + return Promise.resolve() + }) + )) + }) + }) + ) + return transformPromise + .thenReturn(latestTransformed) +} + +export function prepareDraftForSyncback(session) { + return session.ensureCorrectAccount({noSyncback: true}) + .then(() => applyExtensionTransformsToDraft(session.draft())) + .then((transformed) => ( + DatabaseStore.inTransaction((t) => t.persistModel(transformed)) + .then(() => Promise.resolve(queueDraftFileUploads(transformed))) + .thenReturn(transformed) + )) +} diff --git a/src/flux/stores/draft-store.coffee b/src/flux/stores/draft-store.coffee index 4a9dd6173..0ab971e4d 100644 --- a/src/flux/stores/draft-store.coffee +++ b/src/flux/stores/draft-store.coffee @@ -4,6 +4,7 @@ _ = require 'underscore' NylasAPI = require '../nylas-api' DraftEditingSession = require './draft-editing-session' +DraftHelpers = require './draft-helpers' DraftFactory = require './draft-factory' DatabaseStore = require './database-store' AccountStore = require './account-store' @@ -17,7 +18,6 @@ SyncbackDraftTask = require('../tasks/syncback-draft-task').default DestroyDraftTask = require('../tasks/destroy-draft-task').default Thread = require('../models/thread').default -Contact = require '../models/contact' Message = require('../models/message').default Actions = require '../actions' @@ -55,7 +55,7 @@ class DraftStore @listenTo Actions.sendDraftSuccess, => @trigger() @listenTo Actions.composePopoutDraft, @_onPopoutDraftClientId @listenTo Actions.composeNewBlankDraft, @_onPopoutBlankDraft - @listenTo Actions.draftSendingFailed, @_onDraftSendingFailed + @listenTo Actions.sendDraftFailed, @_onSendDraftFailed @listenTo Actions.sendQuickReply, @_onSendQuickReply if NylasEnv.isMainWindow() @@ -66,7 +66,6 @@ class DraftStore # window. @listenTo Actions.ensureDraftSynced, @_onEnsureDraftSynced @listenTo Actions.sendDraft, @_onSendDraft - @listenTo Actions.sendDrafts, @_onSendDrafts @listenTo Actions.destroyDraft, @_onDestroyDraft @listenTo Actions.removeFile, @_onRemoveFile @@ -335,92 +334,21 @@ class DraftStore _onEnsureDraftSynced: (draftClientId) => @sessionForClientId(draftClientId).then (session) => - @_prepareForSyncback(session).then => - @_queueDraftAssetTasks(session.draft()) + DraftHelpers.prepareDraftForSyncback(session) + .then (preparedDraft) => Actions.queueTask(new SyncbackDraftTask(draftClientId)) _onSendDraft: (draftClientId) => - @_sendDraft(draftClientId) - .then => - if @_isPopout() - NylasEnv.close() - - _onSendDrafts: (draftClientIds) => - Promise.each(draftClientIds, (draftClientId) => - @_sendDraft(draftClientId) - ).then => - if @_isPopout() - NylasEnv.close() - - _sendDraft: (draftClientId) => @_draftsSending[draftClientId] = true - @sessionForClientId(draftClientId).then (session) => - @_prepareForSyncback(session).then => - if NylasEnv.config.get("core.sending.sounds") - SoundRegistry.playSound('hit-send') - @_queueDraftAssetTasks(session.draft()) + DraftHelpers.prepareDraftForSyncback(session) + .then (preparedDraft) => Actions.queueTask(new SendDraftTask(draftClientId)) @_doneWithSession(session) - Promise.resolve() - - _queueDraftAssetTasks: (draft) => - if draft.files.length > 0 or draft.uploads.length > 0 - Actions.queueTask(new SyncbackDraftFilesTask(draft.clientId)) - - _isPopout: -> - NylasEnv.getWindowType() is "composer" - - _prepareForSyncback: (session) => - draft = session.draft() - - # Make sure the draft is attached to a valid account, and change it's - # accountId if the from address does not match the current account. - account = AccountStore.accountForEmail(draft.from[0].email) - unless account - return Promise.reject(new Error("DraftStore::_prepareForSyncback - you can only send drafts from a configured account.")) - - if account.id isnt draft.accountId - NylasAPI.makeDraftDeletionRequest(draft) - session.changes.add({ - accountId: account.id - version: null - serverId: null - threadId: null - replyToMessageId: null - }) - - # Run draft transformations registered by third-party plugins - allowedFields = ['to', 'from', 'cc', 'bcc', 'subject', 'body'] - - session.changes.commit(noSyncback: true).then => - draft = session.draft().clone() - - Promise.each @extensions(), (ext) -> - extApply = ext.applyTransformsToDraft - extUnapply = ext.unapplyTransformsToDraft - unless extApply and extUnapply - return Promise.resolve() - - Promise.resolve(extUnapply({draft})).then (cleaned) => - cleaned = draft if cleaned is 'unnecessary' - Promise.resolve(extApply({draft: cleaned})).then (transformed) => - Promise.resolve(extUnapply({draft: transformed.clone()})).then (untransformed) => - untransformed = cleaned if untransformed is 'unnecessary' - - if not _.isEqual(_.pick(untransformed, allowedFields), _.pick(cleaned, allowedFields)) - console.log("-- BEFORE --") - console.log(draft.body) - console.log("-- TRANSFORMED --") - console.log(transformed.body) - console.log("-- UNTRANSFORMED (should match BEFORE) --") - console.log(untransformed.body) - NylasEnv.reportError(new Error("Extension #{ext.name} applied a tranform to the draft that it could not reverse.")) - draft = transformed - - .then => - DatabaseStore.inTransaction (t) => - t.persistModel(draft) + if NylasEnv.config.get("core.sending.sounds") + SoundRegistry.playSound('hit-send') + if @_isPopout() + NylasEnv.close() __testExtensionTransforms: -> clientId = NylasEnv.getWindowProps().draftClientId @@ -436,7 +364,7 @@ class DraftStore session.changes.add({files}) session.changes.commit() - _onDraftSendingFailed: ({draftClientId, threadId, errorMessage}) -> + _onSendDraftFailed: ({draftClientId, threadId, errorMessage}) -> @_draftsSending[draftClientId] = false @trigger(draftClientId) if NylasEnv.isMainWindow() @@ -447,6 +375,9 @@ class DraftStore @_notifyUserOfError({draftClientId, threadId, errorMessage}) , 100 + _isPopout: -> + NylasEnv.getWindowType() is "composer" + _notifyUserOfError: ({draftClientId, threadId, errorMessage}) -> focusedThread = FocusedContentStore.focused('thread') if threadId and focusedThread?.id is threadId diff --git a/src/flux/stores/task-queue-status-store.coffee b/src/flux/stores/task-queue-status-store.coffee index 5e7b39c9f..d6e573c0d 100644 --- a/src/flux/stores/task-queue-status-store.coffee +++ b/src/flux/stores/task-queue-status-store.coffee @@ -18,16 +18,16 @@ class TaskQueueStatusStore extends NylasStore query = DatabaseStore.findJSONBlob(TaskQueue.JSONBlobStorageKey) Rx.Observable.fromQuery(query).subscribe (json) => @_queue = json || [] - @_waitingLocals = @_waitingLocals.filter ({taskId, resolve}) => - task = _.findWhere(@_queue, {id: taskId}) - if not task or task.queueState.localComplete - resolve() + @_waitingLocals = @_waitingLocals.filter ({task, resolve}) => + queuedTask = _.findWhere(@_queue, {id: task.id}) + if not queuedTask or queuedTask.queueState.localComplete + resolve(task) return false return true - @_waitingRemotes = @_waitingRemotes.filter ({taskId, resolve}) => - task = _.findWhere(@_queue, {id: taskId}) - if not task - resolve() + @_waitingRemotes = @_waitingRemotes.filter ({task, resolve}) => + queuedTask = _.findWhere(@_queue, {id: task.id}) + if not queuedTask + resolve(task) return false return true @trigger() @@ -37,11 +37,11 @@ class TaskQueueStatusStore extends NylasStore waitForPerformLocal: (task) => new Promise (resolve, reject) => - @_waitingLocals.push({taskId: task.id, resolve: resolve}) + @_waitingLocals.push({task, resolve}) waitForPerformRemote: (task) => new Promise (resolve, reject) => - @_waitingRemotes.push({taskId: task.id, resolve: resolve}) + @_waitingRemotes.push({task, resolve}) tasksMatching: (type, matching = {}) -> type = type.name unless _.isString(type) diff --git a/src/flux/tasks/send-draft-task.es6 b/src/flux/tasks/send-draft-task.es6 index c8f84cb9e..17a8d2cfd 100644 --- a/src/flux/tasks/send-draft-task.es6 +++ b/src/flux/tasks/send-draft-task.es6 @@ -24,11 +24,12 @@ try { export default class SendDraftTask extends BaseDraftTask { - constructor(draftClientId) { + constructor(draftClientId, {playSound = true, emitError = true} = {}) { super(draftClientId); - this.uploaded = []; this.draft = null; this.message = null; + this.emitError = emitError + this.playSound = playSound } label() { @@ -183,7 +184,7 @@ export default class SendDraftTask extends BaseDraftTask { NylasAPI.makeDraftDeletionRequest(this.draft); // Play the sending sound - if (NylasEnv.config.get("core.sending.sounds")) { + if (this.playSound && NylasEnv.config.get("core.sending.sounds")) { SoundRegistry.playSound('send'); } @@ -215,11 +216,13 @@ export default class SendDraftTask extends BaseDraftTask { } } - Actions.draftSendingFailed({ - threadId: this.draft.threadId, - draftClientId: this.draft.clientId, - errorMessage: message, - }); + if (this.emitError) { + Actions.sendDraftFailed({ + threadId: this.draft.threadId, + draftClientId: this.draft.clientId, + errorMessage: message, + }); + } NylasEnv.reportError(err); return Promise.resolve([Task.Status.Failed, err]); diff --git a/src/flux/tasks/syncback-metadata-task.es6 b/src/flux/tasks/syncback-metadata-task.es6 index 8309ac18f..8f0aaf2b0 100644 --- a/src/flux/tasks/syncback-metadata-task.es6 +++ b/src/flux/tasks/syncback-metadata-task.es6 @@ -14,10 +14,14 @@ export default class SyncbackMetadataTask extends SyncbackModelTask { } getRequestData = (model) => { + if (!model.serverId) { + throw new Error(`Can't syncback metadata for a ${this.modelClassName} instance that doesn't have a serverId`) + } + const metadata = model.metadataObjectForPluginId(this.pluginId); return { - path: `/metadata/${model.id}?client_id=${this.pluginId}`, + path: `/metadata/${model.serverId}?client_id=${this.pluginId}`, method: 'POST', body: { object_id: model.serverId, diff --git a/src/flux/tasks/syncback-model-task.es6 b/src/flux/tasks/syncback-model-task.es6 index 13b27bf2a..332c9e3f1 100644 --- a/src/flux/tasks/syncback-model-task.es6 +++ b/src/flux/tasks/syncback-model-task.es6 @@ -49,12 +49,15 @@ export default class SyncbackModelTask extends Task { }; makeRequest = (model) => { - const options = _.extend({ - accountId: model.accountId, - returnsModel: false, - }, this.getRequestData(model)); - - return NylasAPI.makeRequest(options); + try { + const options = _.extend({ + accountId: model.accountId, + returnsModel: false, + }, this.getRequestData(model)); + return NylasAPI.makeRequest(options); + } catch (error) { + return Promise.reject(error) + } }; getRequestData = (model) => { diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index 9e5b09dc9..d6964b607 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -89,6 +89,7 @@ class NylasExports @lazyLoad "Task", 'flux/tasks/task' @lazyLoad "TaskFactory", 'flux/tasks/task-factory' @lazyLoadAndRegisterTask "EventRSVPTask", 'event-rsvp-task' + @lazyLoadAndRegisterTask "BaseDraftTask", 'base-draft-task' @lazyLoadAndRegisterTask "SendDraftTask", 'send-draft-task' @lazyLoadAndRegisterTask "MultiSendToIndividualTask", 'multi-send-to-individual-task' @lazyLoadAndRegisterTask "MultiSendSessionCloseTask", 'multi-send-session-close-task' @@ -166,10 +167,11 @@ class NylasExports @lazyLoad "CanvasUtils", 'canvas-utils' @lazyLoad "RegExpUtils", 'regexp-utils' @lazyLoad "MenuHelpers", 'menu-helpers' - @lazyLoad "MessageUtils", 'flux/models/message-utils' @lazyLoad "DeprecateUtils", 'deprecate-utils' @lazyLoad "VirtualDOMUtils", 'virtual-dom-utils' @lazyLoad "NylasSpellchecker", 'nylas-spellchecker' + @lazyLoad "DraftHelpers", 'flux/stores/draft-helpers' + @lazyLoad "MessageUtils", 'flux/models/message-utils' @lazyLoad "EditorAPI", 'components/contenteditable/editor-api' # Services diff --git a/src/pro b/src/pro index cfd64332c..dc09d78e0 160000 --- a/src/pro +++ b/src/pro @@ -1 +1 @@ -Subproject commit cfd64332c29ecfe965e2b556c77ba10c2a88dca5 +Subproject commit dc09d78e0bad22597b5bba6511091102e26bbaef