diff --git a/internal_packages/composer/lib/collapsed-participants.cjsx b/internal_packages/composer/lib/collapsed-participants.cjsx index f5358d642..d5e6d76db 100644 --- a/internal_packages/composer/lib/collapsed-participants.cjsx +++ b/internal_packages/composer/lib/collapsed-participants.cjsx @@ -36,6 +36,14 @@ class CollapsedParticipants extends React.Component componentDidUpdate: -> @_setNumHiddenParticipants() + componentWillReceiveProps: -> + # Always re-evaluate the hidden participant count when the participant set changes + @setState({ + numToDisplay: 999 + numRemaining: 0 + numBccRemaining: 0 + }) + render: -> contacts = @props.to.concat(@props.cc).map(@_collapsedContact) bcc = @props.bcc.map(@_collapsedBccContact) diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 29a030330..0c6666dfe 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -107,7 +107,10 @@ class ComposerView extends React.Component @_applyFieldFocus() focus: => - @_applyFieldFocus() + if not @state.focusedField + @setState(focusedField: @_initiallyFocusedField(@_proxy.draft())) + else + @_applyFieldFocus() _keymapHandlers: -> 'composer:send-message': => @_onPrimarySend() diff --git a/internal_packages/message-list/lib/message-controls.cjsx b/internal_packages/message-list/lib/message-controls.cjsx index d89900a9a..35eaa1927 100644 --- a/internal_packages/message-list/lib/message-controls.cjsx +++ b/internal_packages/message-list/lib/message-controls.cjsx @@ -19,6 +19,7 @@ class MessageControls extends React.Component primaryItem={} primaryTitle={items[0].name} primaryClick={items[0].select} + closeOnMenuClick={true} menu={@_dropdownMenu(items[1..-1])}/>
@@ -65,10 +66,12 @@ class MessageControls extends React.Component /> _onReply: => - Actions.composeReply(thread: @props.thread, message: @props.message) + {thread, message} = @props + Actions.composeReply({thread, message, type: 'reply', behavior: 'prefer-existing-if-pristine'}) _onReplyAll: => - Actions.composeReplyAll(thread: @props.thread, message: @props.message) + {thread, message} = @props + Actions.composeReply({thread, message, type: 'reply-all', behavior: 'prefer-existing-if-pristine'}) _onForward: => Actions.composeForward(thread: @props.thread, message: @props.message) diff --git a/internal_packages/message-list/lib/message-list.cjsx b/internal_packages/message-list/lib/message-list.cjsx index c127d4863..2b4a49cfa 100755 --- a/internal_packages/message-list/lib/message-list.cjsx +++ b/internal_packages/message-list/lib/message-list.cjsx @@ -76,6 +76,10 @@ class MessageList extends React.Component componentDidMount: => @_unsubscribers = [] @_unsubscribers.push MessageStore.listen @_onChange + @_unsubscribers.push Actions.focusDraft.listen ({draftClientId}) => + draftEl = @_getMessageContainer(draftClientId) + return unless draftEl + @_focusDraft(draftEl) componentWillUnmount: => unsubscribe() for unsubscribe in @_unsubscribers @@ -92,8 +96,20 @@ class MessageList extends React.Component @_focusDraft(@_getMessageContainer(newDraftClientIds[0])) _globalKeymapHandlers: -> - 'application:reply': => @_createReplyOrUpdateExistingDraft('reply') - 'application:reply-all': => @_createReplyOrUpdateExistingDraft('reply-all') + 'application:reply': => + Actions.composeReply({ + thread: @state.currentThread, + message: @_lastMessage(), + type: 'reply', + behavior: 'prefer-existing', + }) + 'application:reply-all': => + Actions.composeReply({ + thread: @state.currentThread, + message: @_lastMessage(), + type: 'reply-all', + behavior: 'prefer-existing', + }) 'application:forward': => @_onForward() 'application:print-thread': => @_onPrintThread() 'core:messages-page-up': => @_onScrollByPage(-1) @@ -119,68 +135,6 @@ class MessageList extends React.Component @_draftScrollInProgress = false }) - _createReplyOrUpdateExistingDraft: (type) => - unless type in ['reply', 'reply-all'] - throw new Error("_createReplyOrUpdateExistingDraft called with #{type}, not reply or reply-all") - - last = _.last(@state.messages ? []) - - return unless @state.currentThread and last - - # If the last message on the thread is already a draft, fetch the message it's - # in reply to and the draft session and change the participants. - if last.draft is true - data = - session: DraftStore.sessionForClientId(last.clientId) - replyToMessage: Promise.resolve(@state.messages[@state.messages.length - 2]) - type: type - - if last.replyToMessageId - msg = _.findWhere(@state.messages, {id: last.replyToMessageId}) - if msg - data.replyToMessage = Promise.resolve(msg) - else - data.replyToMessage = DatabaseStore.find(Message, last.replyToMessageId) - - Promise.props(data).then @_updateExistingDraft, (err) => - # This can happen if the draft was deleted and the update hadn't reached - # our component yet, but it's very rare. This is here to silence the error. - Promise.resolve() - else - if type is 'reply' - Actions.composeReply(thread: @state.currentThread, message: last) - else - Actions.composeReplyAll(thread: @state.currentThread, message: last) - - _updateExistingDraft: ({type, session, replyToMessage}) => - return unless replyToMessage and session - draft = session.draft() - updated = {to: [].concat(draft.to), cc: [].concat(draft.cc)} - - replySet = replyToMessage.participantsForReply() - replyAllSet = replyToMessage.participantsForReplyAll() - - if type is 'reply' - targetSet = replySet - - # Remove participants present in the reply-all set and not the reply set - for key in ['to', 'cc'] - updated[key] = _.reject updated[key], (contact) -> - inReplySet = _.findWhere(replySet[key], {email: contact.email}) - inReplyAllSet = _.findWhere(replyAllSet[key], {email: contact.email}) - return inReplyAllSet and not inReplySet - else - # Add participants present in the reply-all set and not on the draft - # Switching to reply-all shouldn't really ever remove anyone. - targetSet = replyAllSet - - for key in ['to', 'cc'] - for contact in targetSet[key] - updated[key].push(contact) unless _.findWhere(updated[key], {email: contact.email}) - - session.changes.add(updated) - @_focusDraft(@_getMessageContainer(draft.clientId)) - _onForward: => return unless @state.currentThread Actions.composeForward(thread: @state.currentThread) @@ -260,13 +214,16 @@ class MessageList extends React.Component
+ _lastMessage: => + _.last(_.filter((@state.messages ? []), (m) -> not m.draft)) + # Returns either "reply" or "reply-all" _replyType: => defaultReplyType = NylasEnv.config.get('core.sending.defaultReplyType') - lastMsg = _.last(_.filter((@state.messages ? []), (m) -> not m.draft)) - return 'reply' unless lastMsg + lastMessage = @_lastMessage() + return 'reply' unless lastMessage - if lastMsg.canReplyAll() + if lastMessage.canReplyAll() if defaultReplyType is 'reply-all' return 'reply-all' else @@ -283,7 +240,12 @@ class MessageList extends React.Component _onClickReplyArea: => return unless @state.currentThread - @_createReplyOrUpdateExistingDraft(@_replyType()) + Actions.composeReply({ + thread: @state.currentThread, + message: @_lastMessage(), + type: @_replyType(), + behavior: 'prefer-existing-if-pristine', + }) _messageElements: => elements = [] diff --git a/internal_packages/message-list/spec/message-list-spec.cjsx b/internal_packages/message-list/spec/message-list-spec.cjsx index 00af9d313..232869ae0 100644 --- a/internal_packages/message-list/spec/message-list-spec.cjsx +++ b/internal_packages/message-list/spec/message-list-spec.cjsx @@ -274,104 +274,6 @@ describe "MessageList", -> cs = TestUtils.scryRenderedDOMComponentsWithClass(@messageList, "footer-reply-area") expect(cs.length).toBe 0 - describe "reply behavior (_createReplyOrUpdateExistingDraft)", -> - beforeEach -> - @messageList.setState(currentThread: test_thread) - - it "should throw an exception unless you provide `reply` or `reply-all`", -> - expect( => @messageList._createReplyOrUpdateExistingDraft('lala')).toThrow() - - describe "when there is already a draft at the bottom of the thread", -> - beforeEach -> - @replyToMessage = new Message - id: "reply-id", - threadId: test_thread.id - accountId : TEST_ACCOUNT_ID - date: new Date() - @draft = new Message - id: "666", - draft: true, - date: new Date() - replyToMessage: @replyToMessage.id - accountId : TEST_ACCOUNT_ID - - spyOn(@messageList, '_focusDraft') - spyOn(@replyToMessage, 'participantsForReplyAll').andCallFake -> - {to: [user_3], cc: [user_2, user_4] } - spyOn(@replyToMessage, 'participantsForReply').andCallFake -> - {to: [user_3], cc: [] } - - MessageStore._items = [@replyToMessage, @draft] - MessageStore._thread = test_thread - MessageStore.trigger() - - @sessionStub = - draft: => @draft - changes: - add: jasmine.createSpy('session.changes.add') - spyOn(DraftStore, 'sessionForClientId').andCallFake => - Promise.resolve(@sessionStub) - - it "should not fire a composer action", -> - spyOn(Actions, 'composeReplyAll') - @messageList._createReplyOrUpdateExistingDraft('reply-all') - advanceClock() - expect(Actions.composeReplyAll).not.toHaveBeenCalled() - - it "should focus the existing draft", -> - @messageList._createReplyOrUpdateExistingDraft('reply-all') - advanceClock() - expect(@messageList._focusDraft).toHaveBeenCalled() - - describe "when reply-all is passed", -> - it "should add missing participants", -> - @draft.to = [ user_3 ] - @draft.cc = [] - @messageList._createReplyOrUpdateExistingDraft('reply-all') - advanceClock() - expect(@sessionStub.changes.add).toHaveBeenCalledWith({to: [user_3], cc: [user_2, user_4]}) - - it "should not blow away other participants who have been added to the draft", -> - user_random_a = new Contact(email: 'other-guy-a@gmail.com') - user_random_b = new Contact(email: 'other-guy-b@gmail.com') - @draft.to = [ user_3, user_random_a ] - @draft.cc = [ user_random_b ] - @messageList._createReplyOrUpdateExistingDraft('reply-all') - advanceClock() - expect(@sessionStub.changes.add).toHaveBeenCalledWith({to: [user_3, user_random_a], cc: [user_random_b, user_2, user_4]}) - - describe "when reply is passed", -> - it "should remove participants present in the reply-all participant set and not in the reply set", -> - @draft.to = [ user_3 ] - @draft.cc = [ user_2, user_4 ] - @messageList._createReplyOrUpdateExistingDraft('reply') - advanceClock() - expect(@sessionStub.changes.add).toHaveBeenCalledWith({to: [user_3], cc: []}) - - it "should not blow away other participants who have been added to the draft", -> - user_random_a = new Contact(email: 'other-guy-a@gmail.com') - user_random_b = new Contact(email: 'other-guy-b@gmail.com') - @draft.to = [ user_3, user_random_a ] - @draft.cc = [ user_2, user_4, user_random_b ] - @messageList._createReplyOrUpdateExistingDraft('reply') - advanceClock() - expect(@sessionStub.changes.add).toHaveBeenCalledWith({to: [user_3, user_random_a], cc: [user_random_b]}) - - describe "when there is not an existing draft at the bottom of the thread", -> - beforeEach -> - MessageStore._items = [m5, m3] - MessageStore._thread = test_thread - MessageStore.trigger() - - it "should fire a composer action based on the reply type", -> - spyOn(Actions, 'composeReplyAll') - @messageList._createReplyOrUpdateExistingDraft('reply-all') - expect(Actions.composeReplyAll).toHaveBeenCalledWith(thread: test_thread, message: m3) - - spyOn(Actions, 'composeReply') - @messageList._createReplyOrUpdateExistingDraft('reply') - expect(Actions.composeReply).toHaveBeenCalledWith(thread: test_thread, message: m3) - describe "Message minification", -> beforeEach -> @messageList.MINIFY_THRESHOLD = 3 diff --git a/internal_packages/thread-list/lib/thread-list-context-menu.es6 b/internal_packages/thread-list/lib/thread-list-context-menu.es6 index d478a9620..4495d0dec 100644 --- a/internal_packages/thread-list/lib/thread-list-context-menu.es6 +++ b/internal_packages/thread-list/lib/thread-list-context-menu.es6 @@ -47,7 +47,12 @@ export default class ThreadListContextMenu { return { label: "Reply", click: () => { - Actions.composeReply({threadId: this.threadIds[0], popout: true}); + Actions.composeReply({ + threadId: this.threadIds[0], + popout: true, + type: 'reply', + behavior: 'prefer-existing-if-pristine', + }); }, } } @@ -62,7 +67,12 @@ export default class ThreadListContextMenu { return { label: "Reply All", click: () => { - Actions.composeReplyAll({threadId: this.threadIds[0], popout: true}); + Actions.composeReply({ + threadId: this.threadIds[0], + popout: true, + type: 'reply-all', + behavior: 'prefer-existing-if-pristine', + }); }, } } diff --git a/spec/stores/draft-factory-spec.es6 b/spec/stores/draft-factory-spec.es6 new file mode 100644 index 000000000..935c39f04 --- /dev/null +++ b/spec/stores/draft-factory-spec.es6 @@ -0,0 +1,610 @@ +import _ from 'underscore'; + +import { + File, + Actions, + Thread, + Contact, + Message, + DraftStore, + AccountStore, + DatabaseStore, + DatabaseTransaction, + SanitizeTransformer, + InlineStyleTransformer, +} from 'nylas-exports'; + +import DraftFactory from '../../src/flux/stores/draft-factory'; + +let msgFromMe = null; +let fakeThread = null; +let fakeMessage1 = null; +let fakeMessage2 = null; +let msgWithReplyTo = null; +let fakeMessageWithFiles = null; +let msgWithReplyToDuplicates = null; +let account = null; + +describe("DraftFactory", () => { + beforeEach(() => { + // Out of the scope of these specs + spyOn(InlineStyleTransformer, 'run').andCallFake((input) => Promise.resolve(input)); + spyOn(SanitizeTransformer, 'run').andCallFake((input) => Promise.resolve(input)); + + account = AccountStore.accounts()[0]; + + fakeThread = new Thread({ + id: 'fake-thread-id', + accountId: account.id, + subject: 'Fake Subject', + }); + + fakeMessage1 = new Message({ + id: 'fake-message-1', + accountId: account.id, + to: [new Contact({email: 'ben@nylas.com'}), new Contact({email: 'evan@nylas.com'})], + cc: [new Contact({email: 'mg@nylas.com'}), account.me()], + bcc: [new Contact({email: 'recruiting@nylas.com'})], + from: [new Contact({email: 'customer@example.com', name: 'Customer'})], + threadId: 'fake-thread-id', + body: 'Fake Message 1', + subject: 'Fake Subject', + date: new Date(1415814587), + }); + + fakeMessage2 = new Message({ + id: 'fake-message-2', + accountId: account.id, + to: [new Contact({email: 'customer@example.com'})], + from: [new Contact({email: 'ben@nylas.com'})], + threadId: 'fake-thread-id', + body: 'Fake Message 2', + subject: 'Re: Fake Subject', + date: new Date(1415814587), + }); + + fakeMessageWithFiles = new Message({ + id: 'fake-message-with-files', + accountId: account.id, + to: [new Contact({email: 'ben@nylas.com'}), new Contact({email: 'evan@nylas.com'})], + cc: [new Contact({email: 'mg@nylas.com'}), account.me()], + bcc: [new Contact({email: 'recruiting@nylas.com'})], + from: [new Contact({email: 'customer@example.com', name: 'Customer'})], + files: [new File({filename: "test.jpg"}), new File({filename: "test.pdj"})], + threadId: 'fake-thread-id', + body: 'Fake Message 1', + subject: 'Fake Subject', + date: new Date(1415814587), + }); + + msgFromMe = new Message({ + id: 'fake-message-3', + accountId: account.id, + to: [new Contact({email: '1@1.com'}), new Contact({email: '2@2.com'})], + cc: [new Contact({email: '3@3.com'}), new Contact({email: '4@4.com'})], + bcc: [new Contact({email: '5@5.com'}), new Contact({email: '6@6.com'})], + from: [account.me()], + threadId: 'fake-thread-id', + body: 'Fake Message 2', + subject: 'Re: Fake Subject', + date: new Date(1415814587), + }); + + msgWithReplyTo = new Message({ + id: 'fake-message-reply-to', + accountId: account.id, + to: [new Contact({email: '1@1.com'}), new Contact({email: '2@2.com'})], + cc: [new Contact({email: '3@3.com'}), new Contact({email: '4@4.com'})], + bcc: [new Contact({email: '5@5.com'}), new Contact({email: '6@6.com'})], + replyTo: [new Contact({email: 'reply-to@5.com'}), new Contact({email: 'reply-to@6.com'})], + from: [new Contact({email: 'from@5.com'})], + threadId: 'fake-thread-id', + body: 'Fake Message 2', + subject: 'Re: Fake Subject', + date: new Date(1415814587), + }); + + msgWithReplyToDuplicates = new Message({ + id: 'fake-message-reply-to-duplicates', + accountId: account.id, + to: [new Contact({email: '1@1.com'}), new Contact({email: '2@2.com'})], + cc: [new Contact({email: '1@1.com'}), new Contact({email: '4@4.com'})], + from: [new Contact({email: 'reply-to@5.com'})], + replyTo: [new Contact({email: 'reply-to@5.com'})], + threadId: 'fake-thread-id', + body: 'Fake Message Duplicates', + subject: 'Re: Fake Subject', + date: new Date(1415814587), + }); + }); + + describe("creating drafts", () => { + describe("createDraftForReply", () => { + it("should include quoted text", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { + expect(draft.body.indexOf('blockquote') > 0).toBe(true); + expect(draft.body.indexOf(fakeMessage1.body) > 0).toBe(true); + expect(draft.body.indexOf('gmail_quote') > 0).toBe(true); + + expect(draft.body.search(/On .+, at .+, Customer <customer@example.com> wrote/) > 0).toBe(true); + }); + }); + }); + + it("should address the message to the previous message's sender", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { + expect(draft.to).toEqual(fakeMessage1.from); + }); + }); + }); + + it("should set the replyToMessageId to the previous message's ids", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { + expect(draft.replyToMessageId).toEqual(fakeMessage1.id); + }); + }); + }); + + it("should sanitize the HTML", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then(() => { + expect(InlineStyleTransformer.run).toHaveBeenCalled(); + expect(SanitizeTransformer.run).toHaveBeenCalled(); + }); + }); + }); + + it("should make the subject the subject of the message, not the thread", () => { + fakeMessage1.subject = "OLD SUBJECT"; + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { + expect(draft.subject).toEqual("Re: OLD SUBJECT"); + }); + }); + }); + + it("should change the subject from Fwd: back to Re: if necessary", () => { + fakeMessage1.subject = "Fwd: This is my DRAFT"; + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { + expect(draft.subject).toEqual("Re: This is my DRAFT"); + }); + }); + }); + + it("should only include the sender's name if it was available", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage2, type: 'reply'}).then((draft) => { + expect(draft.body.search(/On .+, at .+, ben@nylas.com wrote:/) > 0).toBe(true); + }); + }); + }); + }); + + describe("type: reply", () => { + describe("when the message provided as context has one or more 'ReplyTo' recipients", () => { + it("addresses the draft to all of the message's 'ReplyTo' recipients", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: msgWithReplyTo, type: 'reply'}).then((draft) => { + expect(draft.to).toEqual(msgWithReplyTo.replyTo); + expect(draft.cc.length).toBe(0); + expect(draft.bcc.length).toBe(0); + }); + }); + }); + }); + + describe("when the message provided as context was sent by the current user", () => { + it("addresses the draft to all of the last messages's 'To' recipients", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: msgFromMe, type: 'reply'}).then((draft) => { + expect(draft.to).toEqual(msgFromMe.to); + expect(draft.cc.length).toBe(0); + expect(draft.bcc.length).toBe(0); + }); + }); + }); + }); + }); + + describe("type: reply-all", () => { + it("should include people in the cc field", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}).then((draft) => { + const ccEmails = draft.cc.map(cc => cc.email); + expect(ccEmails.sort()).toEqual([ 'ben@nylas.com', 'evan@nylas.com', 'mg@nylas.com']); + }); + }); + }); + + it("should not include people who were bcc'd on the previous message", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}).then((draft) => { + expect(draft.bcc).toEqual([]); + expect(draft.cc.indexOf(fakeMessage1.bcc[0])).toEqual(-1); + }); + }); + }); + + it("should not include you when you were cc'd on the previous message", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}).then((draft) => { + const ccEmails = draft.cc.map(cc => cc.email); + expect(ccEmails.indexOf(account.me().email)).toEqual(-1); + }); + }); + }); + + describe("when the message provided as context has one or more 'ReplyTo' recipients", () => { + it("addresses the draft to all of the message's 'ReplyTo' recipients", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: msgWithReplyTo, type: 'reply-all'}).then((draft) => { + expect(draft.to).toEqual(msgWithReplyTo.replyTo); + }); + }); + }); + + it("should not include the message's 'From' recipient in any field", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: msgWithReplyTo, type: 'reply-all'}).then((draft) => { + const all = [].concat(draft.to, draft.cc, draft.bcc); + const match = _.find(all, (c) => c.email === msgWithReplyTo.from[0].email); + expect(match).toEqual(undefined); + }); + }); + }); + }); + + describe("when the message provided has one or more 'ReplyTo' recipients and duplicates in the To/Cc fields", () => { + it("should unique the to/cc fields", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: msgWithReplyToDuplicates, type: 'reply-all'}).then((draft) => { + const ccEmails = draft.cc.map(cc => cc.email); + expect(ccEmails.sort()).toEqual(['1@1.com', '2@2.com', '4@4.com']); + const toEmails = draft.to.map(to => to.email); + expect(toEmails.sort()).toEqual(['reply-to@5.com']); + }); + }); + }); + }); + + describe("when the message provided as context was sent by the current user", () => { + it("addresses the draft to all of the last messages's recipients", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForReply({thread: fakeThread, message: msgFromMe, type: 'reply-all'}).then((draft) => { + expect(draft.to).toEqual(msgFromMe.to); + expect(draft.cc).toEqual(msgFromMe.cc); + expect(draft.bcc.length).toBe(0); + }); + }); + }); + }); + }); + + describe("onComposeForward", () => { + beforeEach(() => { + waitsForPromise(() => { + return DraftFactory.createDraftForForward({thread: fakeThread, message: fakeMessage2}).then((draft) => { + this.model = draft; + }); + }); + }); + + it("should include forwarded message text, in a div rather than a blockquote", () => { + expect(this.model.body.indexOf('gmail_quote') > 0).toBe(true); + expect(this.model.body.indexOf('blockquote') > 0).toBe(false); + expect(this.model.body.indexOf(fakeMessage2.body) > 0).toBe(true); + expect(this.model.body.indexOf('---------- Forwarded message ---------') > 0).toBe(true); + expect(this.model.body.indexOf('From: ben@nylas.com') > 0).toBe(true); + expect(this.model.body.indexOf('Subject: Re: Fake Subject') > 0).toBe(true); + expect(this.model.body.indexOf('To: customer@example.com') > 0).toBe(true); + }); + + it("should not address the message to anyone", () => { + expect(this.model.to).toEqual([]); + expect(this.model.cc).toEqual([]); + expect(this.model.bcc).toEqual([]); + }); + + it("should not set the replyToMessageId", () => { + expect(this.model.replyToMessageId).toEqual(undefined); + }); + + it("should sanitize the HTML", () => { + expect(InlineStyleTransformer.run).toHaveBeenCalled(); + expect(SanitizeTransformer.run).toHaveBeenCalled(); + }); + + it("should include the attached files", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForForward({thread: fakeThread, message: fakeMessageWithFiles}).then((draft) => { + expect(draft.files.length).toBe(2); + expect(draft.files[0].filename).toBe("test.jpg"); + }); + }); + }); + + it("should make the subject the subject of the message, not the thread", () => { + fakeMessage1.subject = "OLD SUBJECT"; + waitsForPromise(() => { + return DraftFactory.createDraftForForward({thread: fakeThread, message: fakeMessage1}).then((draft) => { + expect(draft.subject).toEqual("Fwd: OLD SUBJECT"); + }); + }); + }); + + it("should change the subject from Re: back to Fwd: if necessary", () => { + fakeMessage1.subject = "Re: This is my DRAFT"; + waitsForPromise(() => { + return DraftFactory.createDraftForForward({thread: fakeThread, message: fakeMessage1}).then((draft) => { + expect(draft.subject).toEqual("Fwd: This is my DRAFT"); + }); + }); + }); + }); + }); + + describe("createOrUpdateDraftForReply", () => { + it("should throw an exception unless you provide `reply` or `reply-all`", () => { + expect(() => + DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'wrong'}) + ).toThrow(); + }); + + describe("when there is already a draft in reply to the same message the thread", () => { + beforeEach(() => { + this.existingDraft = new Message({ + clientId: 'asd', + accountId: TEST_ACCOUNT_ID, + replyToMessageId: fakeMessage1.id, + threadId: fakeMessage1.threadId, + draft: true, + }); + this.sessionStub = { + changes: { + add: jasmine.createSpy('add'), + }, + }; + spyOn(Actions, 'focusDraft') + spyOn(DatabaseStore, 'run').andReturn(Promise.resolve([fakeMessage1, this.existingDraft])); + spyOn(DraftStore, 'sessionForClientId').andReturn(Promise.resolve(this.sessionStub)); + spyOn(DatabaseTransaction.prototype, 'persistModel').andReturn(Promise.resolve()); + + this.expectContactsEqual = (a, b) => { + expect(a.map(c => c.email).sort()).toEqual(b.map(c => c.email).sort()); + } + }); + + describe("when reply-all is passed", () => { + it("should add missing participants", () => { + this.existingDraft.to = fakeMessage1.participantsForReply().to; + this.existingDraft.cc = fakeMessage1.participantsForReply().cc; + DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all', behavior: 'prefer-existing'}) + advanceClock(); + advanceClock(); + + const {to, cc} = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; + this.expectContactsEqual(to, fakeMessage1.participantsForReplyAll().to); + this.expectContactsEqual(cc, fakeMessage1.participantsForReplyAll().cc); + }); + + it("should not blow away other participants who have been added to the draft", () => { + const randomA = new Contact({email: 'other-guy-a@gmail.com'}); + const randomB = new Contact({email: 'other-guy-b@gmail.com'}); + this.existingDraft.to = fakeMessage1.participantsForReply().to.concat([randomA]); + this.existingDraft.cc = fakeMessage1.participantsForReply().cc.concat([randomB]); + DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all', behavior: 'prefer-existing'}) + advanceClock(); + advanceClock(); + + const {to, cc} = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; + this.expectContactsEqual(to, fakeMessage1.participantsForReplyAll().to.concat([randomA])); + this.expectContactsEqual(cc, fakeMessage1.participantsForReplyAll().cc.concat([randomB])); + }); + }); + + describe("when reply is passed", () => { + it("should remove participants present in the reply-all participant set and not in the reply set", () => { + this.existingDraft.to = fakeMessage1.participantsForReplyAll().to; + this.existingDraft.cc = fakeMessage1.participantsForReplyAll().cc; + DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply', behavior: 'prefer-existing'}) + advanceClock(); + advanceClock(); + + const {to, cc} = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; + this.expectContactsEqual(to, fakeMessage1.participantsForReply().to); + this.expectContactsEqual(cc, fakeMessage1.participantsForReply().cc); + }); + + it("should not blow away other participants who have been added to the draft", () => { + const randomA = new Contact({email: 'other-guy-a@gmail.com'}); + const randomB = new Contact({email: 'other-guy-b@gmail.com'}); + this.existingDraft.to = fakeMessage1.participantsForReplyAll().to.concat([randomA]); + this.existingDraft.cc = fakeMessage1.participantsForReplyAll().cc.concat([randomB]); + DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply', behavior: 'prefer-existing'}) + advanceClock(); + advanceClock(); + const {to, cc} = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; + this.expectContactsEqual(to, fakeMessage1.participantsForReply().to.concat([randomA])); + this.expectContactsEqual(cc, fakeMessage1.participantsForReply().cc.concat([randomB])); + }); + }); + }); + + describe("when there is not an existing draft at the bottom of the thread", () => { + beforeEach(() => { + spyOn(Actions, 'focusDraft'); + spyOn(DatabaseStore, 'run').andCallFake(() => [fakeMessage1]); + spyOn(DraftFactory, 'createDraftForReply'); + }); + + it("should call through to createDraftForReply", () => { + DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}) + advanceClock(); + advanceClock(); + expect(DraftFactory.createDraftForReply).toHaveBeenCalledWith({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}) + + DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}) + advanceClock(); + advanceClock(); + expect(DraftFactory.createDraftForReply).toHaveBeenCalledWith({thread: fakeThread, message: fakeMessage1, type: 'reply'}) + }); + }); + }); + + + describe("_prepareBodyForQuoting", () => { + it("should transform inline styles and sanitize unsafe html", () => { + const input = "test 123"; + DraftFactory._prepareBodyForQuoting(input); + expect(InlineStyleTransformer.run).toHaveBeenCalledWith(input); + advanceClock(); + expect(SanitizeTransformer.run).toHaveBeenCalledWith(input, SanitizeTransformer.Preset.UnsafeOnly); + }); + }); + + describe("createDraftForMailto", () => { + describe("parameters in the URL", () => { + beforeEach(() => { + this.expected = "EmailSubjectLOLOL"; + }); + + it("works for lowercase", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForMailto('mailto:asdf@asdf.com?subject=' + this.expected).then((draft) => { + expect(draft.subject).toBe(this.expected); + }); + }); + }); + + it("works for title case", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForMailto('mailto:asdf@asdf.com?Subject=' + this.expected).then((draft) => { + expect(draft.subject).toBe(this.expected); + }); + }); + }); + + it("works for uppercase", () => { + waitsForPromise(() => { + return DraftFactory.createDraftForMailto('mailto:asdf@asdf.com?SUBJECT=' + this.expected).then((draft) => { + expect(draft.subject).toBe(this.expected); + }); + }); + }); + }); + + describe("should correctly instantiate drafts for a wide range of mailto URLs", () => { + const links = [ + 'mailto:', + 'mailto://bengotow@gmail.com', + 'mailto:bengotow@gmail.com', + 'mailto:mg%40nylas.com', + 'mailto:?subject=%1z2a', // fails uriDecode + 'mailto:?subject=%52z2a', // passes uriDecode + 'mailto:?subject=Martha Stewart', + 'mailto:?subject=Martha Stewart&cc=cc@nylas.com', + 'mailto:bengotow@gmail.com&subject=Martha Stewart&cc=cc@nylas.com', + 'mailto:bengotow@gmail.com?subject=Martha%20Stewart&cc=cc@nylas.com&bcc=bcc@nylas.com', + 'mailto:bengotow@gmail.com?subject=Martha%20Stewart&cc=cc@nylas.com&bcc=Ben ', + 'mailto:Ben Gotow ,Shawn ?subject=Yes this is really valid', + 'mailto:Ben%20Gotow%20,Shawn%20?subject=Yes%20this%20is%20really%20valid', + 'mailto:Reply ?subject=Nilas%20Message%20to%20Customers', + 'mailto:email@address.com?&subject=test&body=type%20your%0Amessage%20here', + 'mailto:?body=type%20your%0D%0Amessage%0D%0Ahere', + 'mailto:?subject=Issues%20%C2%B7%20atom/electron%20%C2%B7%20GitHub&body=https://github.com/atom/electron/issues?utf8=&q=is%253Aissue+is%253Aopen+123%0A%0A', + ]; + const expected = [ + new Message(), + new Message( + {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})]} + ), + new Message( + {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})]} + ), + new Message( + {to: [new Contact({name: 'mg@nylas.com', email: 'mg@nylas.com'})]} + ), + new Message( + {subject: '%1z2a'} + ), + new Message( + {subject: 'Rz2a'} + ), + new Message( + {subject: 'Martha Stewart'} + ), + new Message( + {cc: [new Contact({name: 'cc@nylas.com', email: 'cc@nylas.com'})], + subject: 'Martha Stewart'} + ), + new Message( + {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})], + cc: [new Contact({name: 'cc@nylas.com', email: 'cc@nylas.com'})], + subject: 'Martha Stewart'} + ), + new Message( + {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})], + cc: [new Contact({name: 'cc@nylas.com', email: 'cc@nylas.com'})], + bcc: [new Contact({name: 'bcc@nylas.com', email: 'bcc@nylas.com'})], + subject: 'Martha Stewart'} + ), + new Message( + {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})], + cc: [new Contact({name: 'cc@nylas.com', email: 'cc@nylas.com'})], + bcc: [new Contact({name: 'Ben', email: 'bcc@nylas.com'})], + subject: 'Martha Stewart'} + ), + new Message( + {to: [new Contact({name: 'Ben Gotow', email: 'bengotow@gmail.com'}), new Contact({name: 'Shawn', email: 'shawn@nylas.com'})], + subject: 'Yes this is really valid'} + ), + new Message( + {to: [new Contact({name: 'Ben Gotow', email: 'bengotow@gmail.com'}), new Contact({name: 'Shawn', email: 'shawn@nylas.com'})], + subject: 'Yes this is really valid'} + ), + new Message( + {to: [new Contact({name: 'Reply', email: 'd+AORGpRdj0KXKUPBE1LoI0a30F10Ahj3wu3olS-aDk5_7K5Wu6WqqqG8t1HxxhlZ4KEEw3WmrSdtobgUq57SkwsYAH6tG57IrNqcQR0K6XaqLM2nGNZ22D2k@docs.google.com'})], + subject: 'Nilas Message to Customers'} + ), + new Message( + {to: [new Contact({name: 'email@address.com', email: 'email@address.com'})], + subject: 'test', + body: 'type your\nmessage here'} + ), + new Message( + {to: [], + body: 'type your\r\nmessage\r\nhere'} + ), + new Message( + {to: [], + subject: 'Issues · atom/electron · GitHub', + body: 'https://github.com/atom/electron/issues?utf8=&q=is%3Aissue+is%3Aopen+123\n\n'}, + ), + ]; + + links.forEach((link, idx) => { + it(`works for ${link}`, () => { + waitsForPromise(() => { + return DraftFactory.createDraftForMailto(link).then((draft) => { + const expectedDraft = expected[idx]; + expect(draft.subject).toEqual(expectedDraft.subject); + if (expectedDraft.body) { expect(draft.body).toEqual(expectedDraft.body); } + ['to', 'cc', 'bcc'].forEach((attr) => { + expectedDraft[attr].forEach((expectedContact, jdx) => { + const actual = draft[attr][jdx]; + expect(actual instanceof Contact).toBe(true); + expect(actual.email).toEqual(expectedContact.email); + expect(actual.name).toEqual(expectedContact.name); + }); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/spec/stores/draft-store-spec.coffee b/spec/stores/draft-store-spec.coffee deleted file mode 100644 index 6219223c0..000000000 --- a/spec/stores/draft-store-spec.coffee +++ /dev/null @@ -1,1019 +0,0 @@ -{File, - Utils, - Thread, - Actions, - Contact, - Message, - Account, - DraftStore, - AccountStore, - DatabaseStore, - SoundRegistry, - SendDraftTask, - ChangeMailTask, - DestroyDraftTask, - ComposerExtension, - ExtensionRegistry, - FocusedContentStore, - DatabaseTransaction, - SanitizeTransformer, - SyncbackDraftFilesTask, - InlineStyleTransformer} = require 'nylas-exports' - -ModelQuery = require '../../src/flux/models/query' - -_ = require 'underscore' -{ipcRenderer} = require 'electron' - -msgFromMe = null -fakeThread = null -fakeMessages = null -fakeMessage1 = null -fakeMessage2 = null -msgWithReplyTo = null -messageWithStyleTags = null -fakeMessageWithFiles = null -msgWithReplyToDuplicates = null -account = null - -class TestExtension extends ComposerExtension - @prepareNewDraft: ({draft}) -> - draft.body = "Edited by TestExtension!" + draft.body - -describe "DraftStore", -> - beforeEach -> - spyOn(NylasEnv, 'newWindow').andCallFake -> - for id, session of DraftStore._draftSessions - if session.teardown - DraftStore._doneWithSession(session) - DraftStore._draftSessions = {} - - describe "creating drafts", -> - beforeEach -> - spyOn(DraftStore, "_prepareBodyForQuoting").andCallFake (body) -> - Promise.resolve(body) - spyOn(ipcRenderer, "send").andCallFake (message, body) -> - if message is "inline-style-parse" - # There needs to be a defer block in here so the promise - # responsible for handling the `inline-style-parse` can be - # properly set. If the whole path is synchronous instead of - # asynchrounous, the promise is not cleared properly. Doing this - # requires us to add `advanceClock` blocks. - _.defer -> DraftStore._onInlineStylesResult({}, body) - - account = AccountStore.accounts()[0] - - fakeThread = new Thread - id: 'fake-thread-id' - accountId: account.id - subject: 'Fake Subject' - - fakeMessage1 = new Message - id: 'fake-message-1' - accountId: account.id - to: [new Contact(email: 'ben@nylas.com'), new Contact(email: 'evan@nylas.com')] - cc: [new Contact(email: 'mg@nylas.com'), account.me()] - bcc: [new Contact(email: 'recruiting@nylas.com')] - from: [new Contact(email: 'customer@example.com', name: 'Customer')] - threadId: 'fake-thread-id' - body: 'Fake Message 1' - subject: 'Fake Subject' - date: new Date(1415814587) - - fakeMessage2 = new Message - id: 'fake-message-2' - accountId: account.id - to: [new Contact(email: 'customer@example.com')] - from: [new Contact(email: 'ben@nylas.com')] - threadId: 'fake-thread-id' - body: 'Fake Message 2' - subject: 'Re: Fake Subject' - date: new Date(1415814587) - - fakeMessageWithFiles = new Message - id: 'fake-message-with-files' - accountId: account.id - to: [new Contact(email: 'ben@nylas.com'), new Contact(email: 'evan@nylas.com')] - cc: [new Contact(email: 'mg@nylas.com'), account.me()] - bcc: [new Contact(email: 'recruiting@nylas.com')] - from: [new Contact(email: 'customer@example.com', name: 'Customer')] - files: [new File(filename: "test.jpg"), new File(filename: "test.pdj")] - threadId: 'fake-thread-id' - body: 'Fake Message 1' - subject: 'Fake Subject' - date: new Date(1415814587) - - msgFromMe = new Message - id: 'fake-message-3' - accountId: account.id - to: [new Contact(email: '1@1.com'), new Contact(email: '2@2.com')] - cc: [new Contact(email: '3@3.com'), new Contact(email: '4@4.com')] - bcc: [new Contact(email: '5@5.com'), new Contact(email: '6@6.com')] - from: [account.me()] - threadId: 'fake-thread-id' - body: 'Fake Message 2' - subject: 'Re: Fake Subject' - date: new Date(1415814587) - - msgWithReplyTo = new Message - id: 'fake-message-reply-to' - accountId: account.id - to: [new Contact(email: '1@1.com'), new Contact(email: '2@2.com')] - cc: [new Contact(email: '3@3.com'), new Contact(email: '4@4.com')] - bcc: [new Contact(email: '5@5.com'), new Contact(email: '6@6.com')] - replyTo: [new Contact(email: 'reply-to@5.com'), new Contact(email: 'reply-to@6.com')] - from: [new Contact(email: 'from@5.com')] - threadId: 'fake-thread-id' - body: 'Fake Message 2' - subject: 'Re: Fake Subject' - date: new Date(1415814587) - - msgWithReplyToDuplicates = new Message - id: 'fake-message-reply-to-duplicates' - accountId: account.id - to: [new Contact(email: '1@1.com'), new Contact(email: '2@2.com')] - cc: [new Contact(email: '1@1.com'), new Contact(email: '4@4.com')] - from: [new Contact(email: 'reply-to@5.com')] - replyTo: [new Contact(email: 'reply-to@5.com')] - threadId: 'fake-thread-id' - body: 'Fake Message Duplicates' - subject: 'Re: Fake Subject' - date: new Date(1415814587) - - messageWithStyleTags = new Message - id: 'message-with-style-tags' - accountId: account.id - to: [new Contact(email: 'ben@nylas.com'), new Contact(email: 'evan@nylas.com')] - cc: [new Contact(email: 'mg@nylas.com'), account.me()] - bcc: [new Contact(email: 'recruiting@nylas.com')] - from: [new Contact(email: 'customer@example.com', name: 'Customer')] - threadId: 'fake-thread-id' - body: '
Fake Message 1
' - subject: 'Fake Subject' - date: new Date(1415814587) - - fakeMessages = - 'fake-message-1': fakeMessage1 - 'fake-message-3': msgFromMe - 'fake-message-2': fakeMessage2 - 'fake-message-reply-to': msgWithReplyTo - 'fake-message-with-files': fakeMessageWithFiles - 'fake-message-reply-to-duplicates': msgWithReplyToDuplicates - 'message-with-style-tags': messageWithStyleTags - - spyOn(DatabaseStore, 'find').andCallFake (klass, id) -> - query = new ModelQuery(klass, {id}) - spyOn(query, 'then').andCallFake (fn) -> - return fn(fakeThread) if klass is Thread - return fn(fakeMessages[id]) if klass is Message - return fn(new Error('Not Stubbed')) - query - - spyOn(DatabaseStore, 'run').andCallFake (query) -> - return Promise.resolve(fakeMessage2) if query._klass is Message - return Promise.reject(new Error('Not Stubbed')) - - spyOn(DatabaseTransaction.prototype, 'persistModel').andCallFake -> Promise.resolve() - - afterEach -> - # Have to cleanup the DraftStoreProxy objects or we'll get a memory - # leak error - for id, session of DraftStore._draftSessions - DraftStore._doneWithSession(session) - - describe "onComposeReply", -> - beforeEach -> - runs -> - DraftStore._onComposeReply({threadId: fakeThread.id, messageId: fakeMessage1.id}) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - - it "should include quoted text", -> - expect(@model.body.indexOf('blockquote') > 0).toBe(true) - expect(@model.body.indexOf(fakeMessage1.body) > 0).toBe(true) - - it "should address the message to the previous message's sender", -> - expect(@model.to).toEqual(fakeMessage1.from) - - it "should set the replyToMessageId to the previous message's ids", -> - expect(@model.replyToMessageId).toEqual(fakeMessage1.id) - - it "should sanitize the HTML", -> - expect(DraftStore._prepareBodyForQuoting).toHaveBeenCalled() - - describe "onComposeReply", -> - describe "when the message provided as context has one or more 'ReplyTo' recipients", -> - it "addresses the draft to all of the message's 'ReplyTo' recipients", -> - runs -> - DraftStore._onComposeReply({threadId: fakeThread.id, messageId: msgWithReplyTo.id}) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - expect(@model.to).toEqual(msgWithReplyTo.replyTo) - expect(@model.cc.length).toBe 0 - expect(@model.bcc.length).toBe 0 - - describe "onComposeReply", -> - describe "when the message provided as context was sent by the current user", -> - it "addresses the draft to all of the last messages's 'To' recipients", -> - runs -> - DraftStore._onComposeReply({threadId: fakeThread.id, messageId: msgFromMe.id}) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - expect(@model.to).toEqual(msgFromMe.to) - expect(@model.cc.length).toBe 0 - expect(@model.bcc.length).toBe 0 - - describe "onComposeReplyAll", -> - beforeEach -> - runs -> - DraftStore._onComposeReplyAll({threadId: fakeThread.id, messageId: fakeMessage1.id}) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - - it "should include quoted text", -> - expect(@model.body.indexOf('blockquote') > 0).toBe(true) - expect(@model.body.indexOf(fakeMessage1.body) > 0).toBe(true) - - it "should address the message to the previous message's sender", -> - expect(@model.to).toEqual(fakeMessage1.from) - - it "should cc everyone who was on the previous message in to or cc", -> - ccEmails = @model.cc.map (cc) -> cc.email - expect(ccEmails.sort()).toEqual([ 'ben@nylas.com', 'evan@nylas.com', 'mg@nylas.com']) - - it "should not include people who were bcc'd on the previous message", -> - expect(@model.bcc).toEqual([]) - expect(@model.cc.indexOf(fakeMessage1.bcc[0])).toEqual(-1) - - it "should not include you when you were cc'd on the previous message", -> - ccEmails = @model.cc.map (cc) -> cc.email - expect(ccEmails.indexOf(account.me().email)).toEqual(-1) - - it "should set the replyToMessageId to the previous message's ids", -> - expect(@model.replyToMessageId).toEqual(fakeMessage1.id) - - it "should sanitize the HTML", -> - expect(DraftStore._prepareBodyForQuoting).toHaveBeenCalled() - - describe "onComposeReplyAll", -> - describe "when the message provided as context has one or more 'ReplyTo' recipients", -> - beforeEach -> - runs -> - DraftStore._onComposeReply({threadId: fakeThread.id, messageId: msgWithReplyTo.id}) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - - it "addresses the draft to all of the message's 'ReplyTo' recipients", -> - expect(@model.to).toEqual(msgWithReplyTo.replyTo) - - it "should not include the message's 'From' recipient in any field", -> - all = [].concat(@model.to, @model.cc, @model.bcc) - match = _.find all, (c) -> c.email is msgWithReplyTo.from[0].email - expect(match).toEqual(undefined) - - describe "onComposeReplyAll", -> - describe "when the message provided has one or more 'ReplyTo' recipients and duplicates in the To/Cc fields", -> - it "should unique the to/cc fields", -> - runs -> - DraftStore._onComposeReplyAll({threadId: fakeThread.id, messageId: msgWithReplyToDuplicates.id}) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - ccEmails = model.cc.map (cc) -> cc.email - expect(ccEmails.sort()).toEqual(['1@1.com', '2@2.com', '4@4.com']) - toEmails = model.to.map (to) -> to.email - expect(toEmails.sort()).toEqual(['reply-to@5.com']) - - describe "onComposeReplyAll", -> - describe "when the message provided as context was sent by the current user", -> - it "addresses the draft to all of the last messages's recipients", -> - runs -> - DraftStore._onComposeReplyAll({threadId: fakeThread.id, messageId: msgFromMe.id}) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - expect(@model.to).toEqual(msgFromMe.to) - expect(@model.cc).toEqual(msgFromMe.cc) - expect(@model.bcc.length).toBe 0 - - describe "forwarding with attachments", -> - it "should include the attached files", -> - runs -> - DraftStore._onComposeForward({threadId: fakeThread.id, messageId: fakeMessageWithFiles.id}) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - expect(@model.files.length).toBe 2 - expect(@model.files[0].filename).toBe "test.jpg" - - describe "onComposeForward", -> - beforeEach -> - runs -> - DraftStore._onComposeForward({threadId: fakeThread.id, messageId: fakeMessage1.id}) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - - it "should include quoted text, but in a div rather than a blockquote", -> - expect(@model.body.indexOf('gmail_quote') > 0).toBe(true) - expect(@model.body.indexOf('blockquote') > 0).toBe(false) - expect(@model.body.indexOf(fakeMessage1.body) > 0).toBe(true) - - it "should not address the message to anyone", -> - expect(@model.to).toEqual([]) - expect(@model.cc).toEqual([]) - expect(@model.bcc).toEqual([]) - - it "should not set the replyToMessageId", -> - expect(@model.replyToMessageId).toEqual(undefined) - - it "should sanitize the HTML", -> - expect(DraftStore._prepareBodyForQuoting).toHaveBeenCalled() - - describe "popout drafts", -> - beforeEach -> - spyOn(Actions, "composePopoutDraft") - - it "can popout a reply", -> - runs -> - DraftStore._onComposeReply({threadId: fakeThread.id, messageId: fakeMessage1.id, popout: true}).catch (error) -> throw new Error (error) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - expect(Actions.composePopoutDraft).toHaveBeenCalledWith(@model.clientId) - - it "can popout a forward", -> - runs -> - DraftStore._onComposeForward({threadId: fakeThread.id, messageId: fakeMessage1.id, popout: true}).catch (error) -> throw new Error (error) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - @model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - expect(Actions.composePopoutDraft).toHaveBeenCalledWith(@model.clientId) - - describe "_newMessageWithContext", -> - beforeEach -> - # A helper method that makes it easy to test _newMessageWithContext, which - # is asynchronous and whose output is a model persisted to the database. - @_callNewMessageWithContext = (context, attributesCallback, modelCallback) -> - waitsForPromise -> - DraftStore._newMessageWithContext(context, attributesCallback).then -> - model = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - modelCallback(model) if modelCallback - - it "should create a new message", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - {} - , (model) -> - expect(model.constructor).toBe(Message) - - it "should setup a draft session for the draftClientId, so that a subsequent request for the session's draft resolves immediately.", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - {} - , (model) -> - session = DraftStore.sessionForClientId(model.id).value() - expect(session.draft()).toBe(model) - - it "should set the subject of the new message automatically", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - {} - , (model) -> - expect(model.subject).toEqual("Re: Fake Subject") - - it "should apply attributes provided by the attributesCallback", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - subject: "Fwd: Fake subject" - to: [new Contact(email: 'weird@example.com')] - , (model) -> - expect(model.subject).toEqual("Fwd: Fake subject") - - describe "extensions", -> - beforeEach -> - ExtensionRegistry.Composer.register(TestExtension) - afterEach -> - ExtensionRegistry.Composer.unregister(TestExtension) - - it "should give extensions a chance to customize the draft via ext.prepareNewDraft", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - {} - , (model) -> - expect(model.body.indexOf("Edited by TestExtension!")).toBe(0) - - describe "context", -> - it "should accept `thread` or look up a thread when given `threadId`", -> - @_callNewMessageWithContext {thread: fakeThread} - , (thread, message) -> - expect(thread).toBe(fakeThread) - expect(DatabaseStore.find).not.toHaveBeenCalled() - {} - , (model) -> - - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - expect(thread).toBe(fakeThread) - expect(DatabaseStore.find).toHaveBeenCalled() - {} - , (model) -> - - it "should accept `message` or look up a message when given `messageId`", -> - @_callNewMessageWithContext {thread: fakeThread, message: fakeMessage1} - , (thread, message) -> - expect(message).toBe(fakeMessage1) - expect(DatabaseStore.find).not.toHaveBeenCalled() - {} - , (model) -> - - @_callNewMessageWithContext {thread: fakeThread, messageId: fakeMessage1.id} - , (thread, message) -> - expect(message).toBe(fakeMessage1) - expect(DatabaseStore.find).toHaveBeenCalled() - {} - , (model) -> - - - describe "when a reply-to message is provided by the attributesCallback", -> - it "should include quoted text in the new message", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - replyToMessage: fakeMessage1 - , (model) -> - expect(model.body.indexOf('gmail_quote') > 0).toBe(true) - expect(model.body.indexOf('Fake Message 1') > 0).toBe(true) - - it "should include the `On ... wrote:` line", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - replyToMessage: fakeMessage1 - , (model) -> - expect(model.body.search(/On .+, at .+, Customer <customer@example.com> wrote/) > 0).toBe(true) - - it "should make the subject the subject of the message, not the thread", -> - fakeMessage1.subject = "OLD SUBJECT" - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - replyToMessage: fakeMessage1 - , (model) -> - expect(model.subject).toEqual("Re: OLD SUBJECT") - - it "should change the subject from Fwd: back to Re: if necessary", -> - fakeMessage1.subject = "Fwd: This is my DRAFT" - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - replyToMessage: fakeMessage1 - , (model) -> - expect(model.subject).toEqual("Re: This is my DRAFT") - - it "should only include the sender's name if it was available", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - replyToMessage: fakeMessage2 - , (model) -> - expect(model.body.search(/On .+, at .+, ben@nylas.com wrote:/) > 0).toBe(true) - - describe "when a forward message is provided by the attributesCallback", -> - it "should include quoted text in the new message", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - forwardMessage: fakeMessage1 - , (model) -> - expect(model.body.indexOf('gmail_quote') > 0).toBe(true) - expect(model.body.indexOf('Fake Message 1') > 0).toBe(true) - - it "should include the `---------- Forwarded message ---------:` line", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - forwardMessage: fakeMessage1 - , (model) -> - expect(model.body.indexOf('---------- Forwarded message ---------') > 0).toBe(true) - - it "should make the subject the subject of the message, not the thread", -> - fakeMessage1.subject = "OLD SUBJECT" - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - forwardMessage: fakeMessage1 - , (model) -> - expect(model.subject).toEqual("Fwd: OLD SUBJECT") - - it "should change the subject from Re: back to Fwd: if necessary", -> - fakeMessage1.subject = "Re: This is my DRAFT" - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - forwardMessage: fakeMessage1 - , (model) -> - expect(model.subject).toEqual("Fwd: This is my DRAFT") - - it "should print the headers of the original message", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - forwardMessage: fakeMessage2 - , (model) -> - expect(model.body.indexOf('From: ben@nylas.com') > 0).toBe(true) - expect(model.body.indexOf('Subject: Re: Fake Subject') > 0).toBe(true) - expect(model.body.indexOf('To: customer@example.com') > 0).toBe(true) - - describe "attributesCallback", -> - describe "when a threadId is provided", -> - it "should receive the thread", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - expect(thread).toEqual(fakeThread) - {} - - it "should receive the last message in the fakeThread", -> - @_callNewMessageWithContext {threadId: fakeThread.id} - , (thread, message) -> - expect(message).toEqual(fakeMessage2) - {} - - describe "when a threadId and messageId are provided", -> - it "should receive the thread", -> - @_callNewMessageWithContext {threadId: fakeThread.id, messageId: fakeMessage1.id} - , (thread, message) -> - expect(thread).toEqual(fakeThread) - {} - - it "should receive the desired message in the thread", -> - @_callNewMessageWithContext {threadId: fakeThread.id, messageId: fakeMessage1.id} - , (thread, message) -> - expect(message).toEqual(fakeMessage1) - {} - - describe "sanitizing draft bodies", -> - it "should transform inline styles and sanitize unsafe html", -> - spyOn(InlineStyleTransformer, 'run').andCallFake (input) => Promise.resolve(input) - spyOn(SanitizeTransformer, 'run').andCallFake (input) => Promise.resolve(input) - - input = "test 123" - DraftStore._prepareBodyForQuoting(input) - expect(InlineStyleTransformer.run).toHaveBeenCalledWith(input) - advanceClock() - expect(SanitizeTransformer.run).toHaveBeenCalledWith(input, SanitizeTransformer.Preset.UnsafeOnly) - - describe "onDestroyDraft", -> - beforeEach -> - @draftSessionTeardown = jasmine.createSpy('draft teardown') - @session = - draft: -> - pristine: false - changes: - commit: -> Promise.resolve() - teardown: -> - teardown: @draftSessionTeardown - DraftStore._draftSessions = {"abc": @session} - spyOn(Actions, 'queueTask') - - it "should teardown the draft session, ensuring no more saves are made", -> - DraftStore._onDestroyDraft('abc') - expect(@draftSessionTeardown).toHaveBeenCalled() - - it "should not throw if the draft session is not in the window", -> - expect -> - DraftStore._onDestroyDraft('other') - .not.toThrow() - - it "should queue a destroy draft task", -> - DraftStore._onDestroyDraft('abc') - expect(Actions.queueTask).toHaveBeenCalled() - expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true) - - it "should clean up the draft session", -> - spyOn(DraftStore, '_doneWithSession') - DraftStore._onDestroyDraft('abc') - expect(DraftStore._doneWithSession).toHaveBeenCalledWith(@session) - - it "should close the window if it's a popout", -> - spyOn(NylasEnv, "close") - spyOn(DraftStore, "_isPopout").andReturn true - DraftStore._onDestroyDraft('abc') - expect(NylasEnv.close).toHaveBeenCalled() - - it "should NOT close the window if isn't a popout", -> - spyOn(NylasEnv, "close") - spyOn(DraftStore, "_isPopout").andReturn false - DraftStore._onDestroyDraft('abc') - expect(NylasEnv.close).not.toHaveBeenCalled() - - describe "before unloading", -> - it "should destroy pristine drafts", -> - DraftStore._draftSessions = {"abc": { - changes: {} - draft: -> - pristine: true - }} - - spyOn(Actions, 'queueTask') - DraftStore._onBeforeUnload() - expect(Actions.queueTask).toHaveBeenCalled() - expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true) - - describe "when drafts return unresolved commit promises", -> - beforeEach -> - @resolve = null - DraftStore._draftSessions = {"abc": { - changes: - commit: => new Promise (resolve, reject) => @resolve = resolve - draft: -> - pristine: false - }} - - it "should return false and call window.close itself", -> - callback = jasmine.createSpy('callback') - expect(DraftStore._onBeforeUnload(callback)).toBe(false) - expect(callback).not.toHaveBeenCalled() - @resolve() - advanceClock(1000) - expect(callback).toHaveBeenCalled() - - describe "when drafts return immediately fulfilled commit promises", -> - beforeEach -> - DraftStore._draftSessions = {"abc": { - changes: - commit: => Promise.resolve() - draft: -> - pristine: false - }} - - it "should still wait one tick before firing NylasEnv.close again", -> - callback = jasmine.createSpy('callback') - expect(DraftStore._onBeforeUnload(callback)).toBe(false) - expect(callback).not.toHaveBeenCalled() - advanceClock() - expect(callback).toHaveBeenCalled() - - describe "when there are no drafts", -> - beforeEach -> - DraftStore._draftSessions = {} - - it "should return true and allow the window to close", -> - expect(DraftStore._onBeforeUnload()).toBe(true) - - describe "sending a draft", -> - beforeEach -> - @draft = new Message - clientId: "local-123", - threadId: "thread-123", - replyToMessageId: "message-123" - uploads: ['stub'] - DraftStore._draftSessions = {} - DraftStore._draftsSending = {} - @forceCommit = false - proxy = - prepare: -> Promise.resolve(proxy) - teardown: -> - draft: => @draft - changes: - commit: ({force}={}) => - @forceCommit = force - Promise.resolve() - - DraftStore._draftSessions[@draft.clientId] = proxy - spyOn(DraftStore, "_doneWithSession").andCallThrough() - spyOn(DraftStore, "_prepareForSyncback").andReturn(Promise.resolve()) - spyOn(DraftStore, "trigger") - spyOn(SoundRegistry, "playSound") - spyOn(Actions, "queueTask") - - it "plays a sound immediately when sending draft", -> - spyOn(NylasEnv.config, "get").andReturn true - DraftStore._onSendDraft(@draft.clientId) - advanceClock() - expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds") - expect(SoundRegistry.playSound).toHaveBeenCalledWith("hit-send") - - it "doesn't plays a sound if the setting is off", -> - spyOn(NylasEnv.config, "get").andReturn false - DraftStore._onSendDraft(@draft.clientId) - advanceClock() - expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds") - expect(SoundRegistry.playSound).not.toHaveBeenCalled() - - it "sets the sending state when sending", -> - spyOn(NylasEnv, "isMainWindow").andReturn true - DraftStore._onSendDraft(@draft.clientId) - advanceClock() - expect(DraftStore.isSendingDraft(@draft.clientId)).toBe true - - # Since all changes haven't been applied yet, we want to ensure that - # no view of the draft renders the draft as if its sending, but with - # the wrong text. - it "does NOT trigger until the latest changes have been applied", -> - spyOn(NylasEnv, "isMainWindow").andReturn true - runs -> - DraftStore._onSendDraft(@draft.clientId) - expect(DraftStore.trigger).not.toHaveBeenCalled() - waitsFor -> - Actions.queueTask.calls.length > 0 - runs -> - # Normally, the session.changes.commit will persist to the - # Database. Since that's stubbed out, we need to manually invoke - # to database update event to get the trigger (which we want to - # test) to fire - DraftStore._onDataChanged - objectClass: "Message" - objects: [draft: true] - expect(DraftStore.isSendingDraft(@draft.clientId)).toBe true - expect(DraftStore.trigger).toHaveBeenCalled() - expect(DraftStore.trigger.calls.length).toBe 1 - - it "returns false if the draft hasn't been seen", -> - spyOn(NylasEnv, "isMainWindow").andReturn true - expect(DraftStore.isSendingDraft(@draft.clientId)).toBe false - - it "closes the window if it's a popout", -> - spyOn(NylasEnv, "getWindowType").andReturn "composer" - spyOn(NylasEnv, "isMainWindow").andReturn false - spyOn(NylasEnv, "close") - runs -> - DraftStore._onSendDraft(@draft.clientId) - waitsFor "N1 to close", -> - NylasEnv.close.calls.length > 0 - - it "doesn't close the window if it's inline", -> - spyOn(NylasEnv, "getWindowType").andReturn "other" - spyOn(NylasEnv, "isMainWindow").andReturn false - spyOn(NylasEnv, "close") - spyOn(DraftStore, "_isPopout").andCallThrough() - runs -> - DraftStore._onSendDraft(@draft.clientId) - waitsFor -> - DraftStore._isPopout.calls.length > 0 - runs -> - expect(NylasEnv.close).not.toHaveBeenCalled() - - it "queues tasks to upload files and send the draft", -> - runs -> - DraftStore._onSendDraft(@draft.clientId) - waitsFor -> - DraftStore._doneWithSession.calls.length > 0 - runs -> - expect(Actions.queueTask).toHaveBeenCalled() - saveAttachments = Actions.queueTask.calls[0].args[0] - expect(saveAttachments instanceof SyncbackDraftFilesTask).toBe true - expect(saveAttachments.draftClientId).toBe(@draft.clientId) - sendDraft = Actions.queueTask.calls[1].args[0] - expect(sendDraft instanceof SendDraftTask).toBe true - expect(sendDraft.draftClientId).toBe(@draft.clientId) - - it "resets the sending state if there's an error", -> - spyOn(NylasEnv, "isMainWindow").andReturn false - DraftStore._draftsSending[@draft.clientId] = true - Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId: @draft.clientId}) - expect(DraftStore.isSendingDraft(@draft.clientId)).toBe false - expect(DraftStore.trigger).toHaveBeenCalledWith(@draft.clientId) - - it "displays a popup in the main window if there's an error", -> - spyOn(NylasEnv, "isMainWindow").andReturn true - spyOn(FocusedContentStore, "focused").andReturn(id: "t1") - {remote} = require('electron') - spyOn(remote.dialog, "showMessageBox") - spyOn(Actions, "composePopoutDraft") - DraftStore._draftsSending[@draft.clientId] = true - Actions.draftSendingFailed({threadId: 't1', errorMessage: "boohoo", draftClientId: @draft.clientId}) - advanceClock(200) - expect(DraftStore.isSendingDraft(@draft.clientId)).toBe false - expect(DraftStore.trigger).toHaveBeenCalledWith(@draft.clientId) - expect(remote.dialog.showMessageBox).toHaveBeenCalled() - dialogArgs = remote.dialog.showMessageBox.mostRecentCall.args[1] - expect(dialogArgs.detail).toEqual("boohoo") - expect(Actions.composePopoutDraft).not.toHaveBeenCalled - - it "re-opens the draft if you're not looking at the thread", -> - spyOn(NylasEnv, "isMainWindow").andReturn true - spyOn(FocusedContentStore, "focused").andReturn(id: "t1") - spyOn(Actions, "composePopoutDraft") - DraftStore._draftsSending[@draft.clientId] = true - Actions.draftSendingFailed({threadId: 't2', errorMessage: "boohoo", draftClientId: @draft.clientId}) - advanceClock(200) - expect(Actions.composePopoutDraft).toHaveBeenCalled - call = Actions.composePopoutDraft.calls[0] - expect(call.args[0]).toBe @draft.clientId - expect(call.args[1]).toEqual {errorMessage: "boohoo"} - - it "re-opens the draft if there is no thread id", -> - spyOn(NylasEnv, "isMainWindow").andReturn true - spyOn(Actions, "composePopoutDraft") - DraftStore._draftsSending[@draft.clientId] = true - spyOn(FocusedContentStore, "focused").andReturn(null) - Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId: @draft.clientId}) - advanceClock(200) - expect(Actions.composePopoutDraft).toHaveBeenCalled - call = Actions.composePopoutDraft.calls[0] - expect(call.args[0]).toBe @draft.clientId - expect(call.args[1]).toEqual {errorMessage: "boohoo"} - - describe "session teardown", -> - beforeEach -> - spyOn(NylasEnv, 'isMainWindow').andReturn true - @draftTeardown = jasmine.createSpy('draft teardown') - @session = - draftClientId: "abc" - draft: -> - pristine: false - changes: - commit: -> Promise.resolve() - reset: -> - teardown: @draftTeardown - DraftStore._draftSessions = {"abc": @session} - DraftStore._doneWithSession(@session) - - it "removes from the list of draftSessions", -> - expect(DraftStore._draftSessions["abc"]).toBeUndefined() - - it "Calls teardown on the session", -> - expect(@draftTeardown).toHaveBeenCalled - - describe "mailto handling", -> - beforeEach -> - spyOn(NylasEnv, 'isMainWindow').andReturn true - - describe "extensions", -> - beforeEach -> - ExtensionRegistry.Composer.register(TestExtension) - afterEach -> - ExtensionRegistry.Composer.unregister(TestExtension) - - it "should give extensions a chance to customize the draft via ext.prepareNewDraft", -> - received = null - spyOn(DatabaseTransaction.prototype, 'persistModel').andCallFake (draft) -> - received = draft - Promise.resolve() - waitsForPromise -> - DraftStore._onHandleMailtoLink({}, 'mailto:bengotow@gmail.com').then -> - expect(received.body.indexOf("Edited by TestExtension!")).toBe(0) - - describe "when testing subject keys", -> - beforeEach -> - spyOn(DraftStore, '_finalizeAndPersistNewMessage').andCallFake (draft) -> - Promise.resolve({draftClientId: 123}) - - @expected = "EmailSubjectLOLOL" - - it "works for lowercase", -> - waitsForPromise => - DraftStore._onHandleMailtoLink({}, 'mailto:asdf@asdf.com?subject=' + @expected).then => - received = DraftStore._finalizeAndPersistNewMessage.mostRecentCall.args[0] - expect(received.subject).toBe(@expected) - - it "works for title case", -> - waitsForPromise => - DraftStore._onHandleMailtoLink({}, 'mailto:asdf@asdf.com?Subject=' + @expected).then => - received = DraftStore._finalizeAndPersistNewMessage.mostRecentCall.args[0] - expect(received.subject).toBe(@expected) - - it "works for uppercase", -> - waitsForPromise => - DraftStore._onHandleMailtoLink({}, 'mailto:asdf@asdf.com?SUBJECT=' + @expected).then => - received = DraftStore._finalizeAndPersistNewMessage.mostRecentCall.args[0] - expect(received.subject).toBe(@expected) - - describe "should correctly instantiate drafts for a wide range of mailto URLs", -> - beforeEach -> - spyOn(DatabaseTransaction.prototype, 'persistModel').andCallFake (draft) -> - Promise.resolve() - - links = [ - 'mailto:' - 'mailto://bengotow@gmail.com' - 'mailto:bengotow@gmail.com' - 'mailto:mg%40nylas.com' - 'mailto:?subject=%1z2a', # fails uriDecode - 'mailto:?subject=%52z2a', # passes uriDecode - 'mailto:?subject=Martha Stewart', - 'mailto:?subject=Martha Stewart&cc=cc@nylas.com', - 'mailto:bengotow@gmail.com&subject=Martha Stewart&cc=cc@nylas.com', - 'mailto:bengotow@gmail.com?subject=Martha%20Stewart&cc=cc@nylas.com&bcc=bcc@nylas.com', - 'mailto:bengotow@gmail.com?subject=Martha%20Stewart&cc=cc@nylas.com&bcc=Ben ', - 'mailto:Ben Gotow ,Shawn ?subject=Yes this is really valid', - 'mailto:Ben%20Gotow%20,Shawn%20?subject=Yes%20this%20is%20really%20valid', - 'mailto:Reply ?subject=Nilas%20Message%20to%20Customers', - 'mailto:email@address.com?&subject=test&body=type%20your%0Amessage%20here' - 'mailto:?body=type%20your%0D%0Amessage%0D%0Ahere' - 'mailto:?subject=Issues%20%C2%B7%20atom/electron%20%C2%B7%20GitHub&body=https://github.com/atom/electron/issues?utf8=&q=is%253Aissue+is%253Aopen+123%0A%0A' - ] - expected = [ - new Message(), - new Message( - to: [new Contact(name: 'bengotow@gmail.com', email: 'bengotow@gmail.com')] - ), - new Message( - to: [new Contact(name: 'bengotow@gmail.com', email: 'bengotow@gmail.com')] - ), - new Message( - to: [new Contact(name: 'mg@nylas.com', email: 'mg@nylas.com')] - ), - new Message( - subject: '%1z2a' - ), - new Message( - subject: 'Rz2a' - ), - new Message( - subject: 'Martha Stewart' - ), - new Message( - cc: [new Contact(name: 'cc@nylas.com', email: 'cc@nylas.com')], - subject: 'Martha Stewart' - ), - new Message( - to: [new Contact(name: 'bengotow@gmail.com', email: 'bengotow@gmail.com')], - cc: [new Contact(name: 'cc@nylas.com', email: 'cc@nylas.com')], - subject: 'Martha Stewart' - ), - new Message( - to: [new Contact(name: 'bengotow@gmail.com', email: 'bengotow@gmail.com')], - cc: [new Contact(name: 'cc@nylas.com', email: 'cc@nylas.com')], - bcc: [new Contact(name: 'bcc@nylas.com', email: 'bcc@nylas.com')], - subject: 'Martha Stewart' - ), - new Message( - to: [new Contact(name: 'bengotow@gmail.com', email: 'bengotow@gmail.com')], - cc: [new Contact(name: 'cc@nylas.com', email: 'cc@nylas.com')], - bcc: [new Contact(name: 'Ben', email: 'bcc@nylas.com')], - subject: 'Martha Stewart' - ), - new Message( - to: [new Contact(name: 'Ben Gotow', email: 'bengotow@gmail.com'), new Contact(name: 'Shawn', email: 'shawn@nylas.com')], - subject: 'Yes this is really valid' - ), - new Message( - to: [new Contact(name: 'Ben Gotow', email: 'bengotow@gmail.com'), new Contact(name: 'Shawn', email: 'shawn@nylas.com')], - subject: 'Yes this is really valid' - ), - new Message( - to: [new Contact(name: 'Reply', email: 'd+AORGpRdj0KXKUPBE1LoI0a30F10Ahj3wu3olS-aDk5_7K5Wu6WqqqG8t1HxxhlZ4KEEw3WmrSdtobgUq57SkwsYAH6tG57IrNqcQR0K6XaqLM2nGNZ22D2k@docs.google.com')], - subject: 'Nilas Message to Customers' - ), - new Message( - to: [new Contact(name: 'email@address.com', email: 'email@address.com')], - subject: 'test' - body: 'type your\nmessage here' - ), - new Message( - to: [], - body: 'type your\r\nmessage\r\nhere' - ), - new Message( - to: [], - subject: 'Issues · atom/electron · GitHub' - body: 'https://github.com/atom/electron/issues?utf8=&q=is%3Aissue+is%3Aopen+123\n\n' - ) - ] - - links.forEach (link, idx) -> - it "works for #{link}", -> - waitsForPromise -> - DraftStore._onHandleMailtoLink({}, link).then -> - expectedDraft = expected[idx] - received = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0] - expect(received['subject']).toEqual(expectedDraft['subject']) - expect(received['body']).toEqual(expectedDraft['body']) if expectedDraft['body'] - ['to', 'cc', 'bcc'].forEach (attr) -> - expectedDraft[attr].forEach (expected, jdx) -> - actual = received[attr][jdx] - expect(actual instanceof Contact).toBe(true) - expect(actual.email).toEqual(expected.email) - expect(actual.name).toEqual(expected.name) - - describe "mailfiles handling", -> - it "should popout a new draft", -> - defaultMe = new Contact() - spyOn(DraftStore, '_onPopoutDraftClientId') - spyOn(DatabaseTransaction.prototype, 'persistModel') - spyOn(Account.prototype, 'defaultMe').andReturn(defaultMe) - spyOn(Actions, 'addAttachment') - DraftStore._onHandleMailFiles({}, ['/Users/ben/file1.png', '/Users/ben/file2.png']) - waitsFor -> - DatabaseTransaction.prototype.persistModel.callCount > 0 - runs -> - {body, subject, from} = DatabaseTransaction.prototype.persistModel.calls[0].args[0] - expect({body, subject, from}).toEqual({body:'', subject:'', from: [defaultMe]}) - expect(DraftStore._onPopoutDraftClientId).toHaveBeenCalled() - - it "should call addAttachment for each provided file path", -> - spyOn(Actions, 'addAttachment') - DraftStore._onHandleMailFiles({}, ['/Users/ben/file1.png', '/Users/ben/file2.png']) - waitsFor -> - Actions.addAttachment.callCount is 2 - runs -> - expect(Actions.addAttachment.calls[0].args[0].filePath).toEqual('/Users/ben/file1.png') - expect(Actions.addAttachment.calls[1].args[0].filePath).toEqual('/Users/ben/file2.png') diff --git a/spec/stores/draft-store-spec.es6 b/spec/stores/draft-store-spec.es6 new file mode 100644 index 000000000..772877964 --- /dev/null +++ b/spec/stores/draft-store-spec.es6 @@ -0,0 +1,558 @@ +import { + Thread, + Actions, + Contact, + Message, + Account, + DraftStore, + DatabaseStore, + SoundRegistry, + SendDraftTask, + DestroyDraftTask, + ComposerExtension, + ExtensionRegistry, + FocusedContentStore, + DatabaseTransaction, + SyncbackDraftFilesTask, +} from 'nylas-exports'; + +import DraftFactory from '../../src/flux/stores/draft-factory'; + +class TestExtension extends ComposerExtension { + static prepareNewDraft({draft}) { + draft.body = "Edited by TestExtension!" + draft.body; + } +} + +describe("DraftStore", () => { + beforeEach(() => { + this.fakeThread = new Thread({id: 'fake-thread', clientId: 'fake-thread'}); + this.fakeMessage = new Message({id: 'fake-message', clientId: 'fake-message'}); + + spyOn(NylasEnv, 'newWindow').andCallFake(() => {}); + spyOn(DatabaseTransaction.prototype, "persistModel").andReturn(Promise.resolve()); + spyOn(DatabaseStore, 'run').andCallFake((query) => { + if (query._klass === Thread) { return Promise.resolve(this.fakeThread); } + if (query._klass === Message) { return Promise.resolve(this.fakeMessage); } + if (query._klass === Contact) { return Promise.resolve(null); } + return Promise.reject(new Error(`Not Stubbed for class ${query._klass.name}`)); + }); + + for (const draftClientId of Object.keys(DraftStore._draftSessions)) { + const sess = DraftStore._draftSessions[draftClientId]; + if (sess.teardown) { + DraftStore._doneWithSession(sess); + } + } + DraftStore._draftSessions = {}; + }); + + describe("creating and opening drafts", () => { + beforeEach(() => { + const draft = new Message({id: "A", subject: "B", clientId: "A", body: "123"}); + spyOn(DraftFactory, "createDraftForReply").andReturn(Promise.resolve(draft)); + spyOn(DraftFactory, "createOrUpdateDraftForReply").andReturn(Promise.resolve(draft)); + spyOn(DraftFactory, "createDraftForForward").andReturn(Promise.resolve(draft)); + spyOn(DraftFactory, "createDraft").andReturn(Promise.resolve(draft)); + }); + + it("should always attempt to focus the new draft", () => { + spyOn(Actions, 'focusDraft') + DraftStore._onComposeReply({ + threadId: this.fakeThread.id, + messageId: this.fakeMessage.id, + type: 'reply', + behavior: 'prefer-existing', + }); + advanceClock(); + advanceClock(); + expect(Actions.focusDraft).toHaveBeenCalled(); + }); + + describe("context", () => { + it("can accept IDs for thread and message arguments", () => { + DraftStore._onComposeReply({ + threadId: this.fakeThread.id, + messageId: this.fakeMessage.id, + type: 'reply', + behavior: 'prefer-existing', + }); + advanceClock(); + expect(DraftFactory.createOrUpdateDraftForReply).toHaveBeenCalledWith({ + thread: this.fakeThread, + message: this.fakeMessage, + type: 'reply', + behavior: 'prefer-existing', + }); + }); + + it("can accept models for thread and message arguments", () => { + DraftStore._onComposeReply({ + thread: this.fakeThread, + message: this.fakeMessage, + type: 'reply', + behavior: 'prefer-existing', + }); + advanceClock(); + expect(DraftFactory.createOrUpdateDraftForReply).toHaveBeenCalledWith({ + thread: this.fakeThread, + message: this.fakeMessage, + type: 'reply', + behavior: 'prefer-existing', + }); + }); + + it("can accept only a thread / threadId, and use the last message on the thread", () => { + DraftStore._onComposeReply({ + thread: this.fakeThread, + type: 'reply', + behavior: 'prefer-existing', + }); + advanceClock(); + expect(DraftFactory.createOrUpdateDraftForReply).toHaveBeenCalledWith({ + thread: this.fakeThread, + message: this.fakeMessage, + type: 'reply', + behavior: 'prefer-existing', + }); + }); + }); + + describe("popout behavior", () => { + it("can popout a reply", () => { + runs(() => { + DraftStore._onComposeReply({ + threadId: this.fakeThread.id, + messageId: this.fakeMessage.id, + type: 'reply', + popout: true} + ); + }); + waitsFor(() => { + return DatabaseTransaction.prototype.persistModel.callCount > 0; + }); + runs(() => { + expect(NylasEnv.newWindow).toHaveBeenCalledWith({ + title: 'Message', + windowType: "composer", + windowProps: { draftClientId: "A" }, + }); + }); + }); + + it("can popout a forward", () => { + runs(() => { + DraftStore._onComposeForward({ + threadId: this.fakeThread.id, + messageId: this.fakeMessage.id, + popout: true, + }); + }); + waitsFor(() => { + return DatabaseTransaction.prototype.persistModel.callCount > 0; + }); + runs(() => { + expect(NylasEnv.newWindow).toHaveBeenCalledWith({ + title: 'Message', + windowType: "composer", + windowProps: { draftClientId: "A" }, + }); + }); + }); + }); + }); + + describe("onDestroyDraft", () => { + beforeEach(() => { + this.draftSessionTeardown = jasmine.createSpy('draft teardown'); + this.session = + {draft() { + return {pristine: false}; + }, + changes: + {commit() { return Promise.resolve(); }, + teardown() {}, + }, + teardown: this.draftSessionTeardown, + }; + DraftStore._draftSessions = {"abc": this.session}; + spyOn(Actions, 'queueTask'); + }); + + it("should teardown the draft session, ensuring no more saves are made", () => { + DraftStore._onDestroyDraft('abc'); + expect(this.draftSessionTeardown).toHaveBeenCalled(); + }); + + it("should not throw if the draft session is not in the window", () => { + expect(() => DraftStore._onDestroyDraft('other')).not.toThrow(); + }); + + it("should queue a destroy draft task", () => { + DraftStore._onDestroyDraft('abc'); + expect(Actions.queueTask).toHaveBeenCalled(); + expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true); + }); + + it("should clean up the draft session", () => { + spyOn(DraftStore, '_doneWithSession'); + DraftStore._onDestroyDraft('abc'); + expect(DraftStore._doneWithSession).toHaveBeenCalledWith(this.session); + }); + + it("should close the window if it's a popout", () => { + spyOn(NylasEnv, "close"); + spyOn(DraftStore, "_isPopout").andReturn(true); + DraftStore._onDestroyDraft('abc'); + expect(NylasEnv.close).toHaveBeenCalled(); + }); + + it("should NOT close the window if isn't a popout", () => { + spyOn(NylasEnv, "close"); + spyOn(DraftStore, "_isPopout").andReturn(false); + DraftStore._onDestroyDraft('abc'); + expect(NylasEnv.close).not.toHaveBeenCalled(); + }); + }); + + describe("before unloading", () => { + it("should destroy pristine drafts", () => { + DraftStore._draftSessions = {"abc": { + changes: {}, + draft() { + return {pristine: true}; + }, + }}; + + spyOn(Actions, 'queueTask'); + DraftStore._onBeforeUnload(); + expect(Actions.queueTask).toHaveBeenCalled(); + expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true); + }); + + describe("when drafts return unresolved commit promises", () => { + beforeEach(() => { + this.resolve = null; + DraftStore._draftSessions = { + "abc": { + changes: { + commit: () => new Promise((resolve) => this.resolve = resolve), + }, + draft() { + return {pristine: false}; + }, + }, + }; + }); + + it("should return false and call window.close itself", () => { + const callback = jasmine.createSpy('callback'); + expect(DraftStore._onBeforeUnload(callback)).toBe(false); + expect(callback).not.toHaveBeenCalled(); + this.resolve(); + advanceClock(1000); + expect(callback).toHaveBeenCalled(); + }); + }); + + describe("when drafts return immediately fulfilled commit promises", () => { + beforeEach(() => { + DraftStore._draftSessions = {"abc": { + changes: + {commit: () => Promise.resolve()}, + draft() { + return {pristine: false}; + }, + }}; + }); + + it("should still wait one tick before firing NylasEnv.close again", () => { + const callback = jasmine.createSpy('callback'); + expect(DraftStore._onBeforeUnload(callback)).toBe(false); + expect(callback).not.toHaveBeenCalled(); + advanceClock(); + expect(callback).toHaveBeenCalled(); + }); + }); + + describe("when there are no drafts", () => { + beforeEach(() => { + DraftStore._draftSessions = {}; + }); + + it("should return true and allow the window to close", () => { + expect(DraftStore._onBeforeUnload()).toBe(true); + }); + }); + }); + + describe("sending a draft", () => { + beforeEach(() => { + this.draft = new Message({ + clientId: "local-123", + threadId: "thread-123", + replyToMessageId: "message-123", + uploads: ['stub'], + }); + DraftStore._draftSessions = {}; + DraftStore._draftsSending = {}; + this.forceCommit = false; + const proxy = { + prepare() { + return Promise.resolve(proxy); + }, + teardown() {}, + draft: () => this.draft, + changes: { + commit: ({force} = {}) => { + this.forceCommit = force; + return Promise.resolve(); + }, + }, + }; + + DraftStore._draftSessions[this.draft.clientId] = proxy; + spyOn(DraftStore, "_doneWithSession").andCallThrough(); + spyOn(DraftStore, "_prepareForSyncback").andReturn(Promise.resolve()); + spyOn(DraftStore, "trigger"); + spyOn(SoundRegistry, "playSound"); + spyOn(Actions, "queueTask"); + }); + + it("plays a sound immediately when sending draft", () => { + spyOn(NylasEnv.config, "get").andReturn(true); + DraftStore._onSendDraft(this.draft.clientId); + advanceClock(); + expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds"); + expect(SoundRegistry.playSound).toHaveBeenCalledWith("hit-send"); + }); + + it("doesn't plays a sound if the setting is off", () => { + spyOn(NylasEnv.config, "get").andReturn(false); + DraftStore._onSendDraft(this.draft.clientId); + advanceClock(); + expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds"); + expect(SoundRegistry.playSound).not.toHaveBeenCalled(); + }); + + it("sets the sending state when sending", () => { + spyOn(NylasEnv, "isMainWindow").andReturn(true); + DraftStore._onSendDraft(this.draft.clientId); + advanceClock(); + expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(true); + }); + + // Since all changes haven't been applied yet, we want to ensure that + // no view of the draft renders the draft as if its sending, but with + // the wrong text. + it("does NOT trigger until the latest changes have been applied", () => { + spyOn(NylasEnv, "isMainWindow").andReturn(true); + runs(() => { + DraftStore._onSendDraft(this.draft.clientId); + expect(DraftStore.trigger).not.toHaveBeenCalled(); + }); + waitsFor(() => { + return Actions.queueTask.calls.length > 0; + }); + runs(() => { + // Normally, the session.changes.commit will persist to the + // Database. Since that's stubbed out, we need to manually invoke + // to database update event to get the trigger (which we want to + // test) to fire + DraftStore._onDataChanged({ + objectClass: "Message", + objects: [{draft: true}], + }); + expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(true); + expect(DraftStore.trigger).toHaveBeenCalled(); + expect(DraftStore.trigger.calls.length).toBe(1); + }); + }); + + it("returns false if the draft hasn't been seen", () => { + spyOn(NylasEnv, "isMainWindow").andReturn(true); + expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false); + }); + + it("closes the window if it's a popout", () => { + spyOn(NylasEnv, "getWindowType").andReturn("composer"); + spyOn(NylasEnv, "isMainWindow").andReturn(false); + spyOn(NylasEnv, "close"); + runs(() => { + return DraftStore._onSendDraft(this.draft.clientId); + }); + waitsFor("N1 to close", () => NylasEnv.close.calls.length > 0); + }); + + it("doesn't close the window if it's inline", () => { + spyOn(NylasEnv, "getWindowType").andReturn("other"); + spyOn(NylasEnv, "isMainWindow").andReturn(false); + spyOn(NylasEnv, "close"); + spyOn(DraftStore, "_isPopout").andCallThrough(); + runs(() => { + DraftStore._onSendDraft(this.draft.clientId); + }); + waitsFor(() => DraftStore._isPopout.calls.length > 0); + runs(() => { + expect(NylasEnv.close).not.toHaveBeenCalled(); + }); + }); + + 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}); + expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false); + expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId); + }); + + it("displays a popup in the main window if there's an error", () => { + spyOn(NylasEnv, "isMainWindow").andReturn(true); + spyOn(FocusedContentStore, "focused").andReturn({id: "t1"}); + const {remote} = require('electron'); + spyOn(remote.dialog, "showMessageBox"); + spyOn(Actions, "composePopoutDraft"); + DraftStore._draftsSending[this.draft.clientId] = true; + Actions.draftSendingFailed({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); + expect(remote.dialog.showMessageBox).toHaveBeenCalled(); + const dialogArgs = remote.dialog.showMessageBox.mostRecentCall.args[1]; + expect(dialogArgs.detail).toEqual("boohoo"); + expect(Actions.composePopoutDraft).not.toHaveBeenCalled(); + }); + + it("re-opens the draft if you're not looking at the thread", () => { + spyOn(NylasEnv, "isMainWindow").andReturn(true); + 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}); + advanceClock(200); + expect(Actions.composePopoutDraft).toHaveBeenCalled(); + const call = Actions.composePopoutDraft.calls[0]; + expect(call.args[0]).toBe(this.draft.clientId); + expect(call.args[1]).toEqual({errorMessage: "boohoo"}); + }); + + it("re-opens the draft if there is no thread id", () => { + spyOn(NylasEnv, "isMainWindow").andReturn(true); + spyOn(Actions, "composePopoutDraft"); + DraftStore._draftsSending[this.draft.clientId] = true; + spyOn(FocusedContentStore, "focused").andReturn(null); + Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId}); + advanceClock(200); + expect(Actions.composePopoutDraft).toHaveBeenCalled(); + const call = Actions.composePopoutDraft.calls[0]; + expect(call.args[0]).toBe(this.draft.clientId); + expect(call.args[1]).toEqual({errorMessage: "boohoo"}); + }); + }); + + describe("session teardown", () => { + beforeEach(() => { + spyOn(NylasEnv, 'isMainWindow').andReturn(true); + this.draftTeardown = jasmine.createSpy('draft teardown'); + this.session = { + draftClientId: "abc", + draft() { + return {pristine: false}; + }, + changes: { + commit() { return Promise.resolve(); }, + reset() {}, + }, + teardown: this.draftTeardown, + }; + DraftStore._draftSessions = {"abc": this.session}; + DraftStore._doneWithSession(this.session); + }); + + it("removes from the list of draftSessions", () => { + expect(DraftStore._draftSessions.abc).toBeUndefined(); + }); + + it("Calls teardown on the session", () => { + expect(this.draftTeardown).toHaveBeenCalled(); + }); + }); + + describe("mailto handling", () => { + beforeEach(() => { + spyOn(NylasEnv, 'isMainWindow').andReturn(true); + }); + + describe("extensions", () => { + beforeEach(() => { + ExtensionRegistry.Composer.register(TestExtension); + }); + afterEach(() => { + ExtensionRegistry.Composer.unregister(TestExtension); + }); + + it("should give extensions a chance to customize the draft via ext.prepareNewDraft", () => { + waitsForPromise(() => { + return DraftStore._onHandleMailtoLink({}, 'mailto:bengotow@gmail.com').then(() => { + const received = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; + expect(received.body.indexOf("Edited by TestExtension!")).toBe(0); + }); + }); + }); + }); + + it("should call through to DraftFactory and popout a new draft", () => { + const draft = new Message({clientId: "A", body: '123'}); + spyOn(DraftFactory, 'createDraftForMailto').andReturn(Promise.resolve(draft)); + spyOn(DraftStore, '_onPopoutDraftClientId'); + waitsForPromise(() => { + return DraftStore._onHandleMailtoLink({}, 'mailto:bengotow@gmail.com').then(() => { + const received = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; + expect(received).toEqual(draft); + expect(DraftStore._onPopoutDraftClientId).toHaveBeenCalled(); + }); + }); + }); + }); + + describe("mailfiles handling", () => { + it("should popout a new draft", () => { + const defaultMe = new Contact(); + spyOn(DraftStore, '_onPopoutDraftClientId'); + spyOn(Account.prototype, 'defaultMe').andReturn(defaultMe); + spyOn(Actions, 'addAttachment'); + DraftStore._onHandleMailFiles({}, ['/Users/ben/file1.png', '/Users/ben/file2.png']); + waitsFor(() => DatabaseTransaction.prototype.persistModel.callCount > 0); + runs(() => { + const {body, subject, from} = DatabaseTransaction.prototype.persistModel.calls[0].args[0]; + expect({body, subject, from}).toEqual({body: '', subject: '', from: [defaultMe]}); + expect(DraftStore._onPopoutDraftClientId).toHaveBeenCalled(); + }); + }); + + it("should call addAttachment for each provided file path", () => { + spyOn(Actions, 'addAttachment'); + DraftStore._onHandleMailFiles({}, ['/Users/ben/file1.png', '/Users/ben/file2.png']); + waitsFor(() => Actions.addAttachment.callCount === 2); + runs(() => { + expect(Actions.addAttachment.calls[0].args[0].filePath).toEqual('/Users/ben/file1.png'); + expect(Actions.addAttachment.calls[1].args[0].filePath).toEqual('/Users/ben/file2.png'); + }); + }); + }); +}); diff --git a/src/flux/actions.coffee b/src/flux/actions.coffee index 7dba11da0..c50fcbb17 100644 --- a/src/flux/actions.coffee +++ b/src/flux/actions.coffee @@ -331,14 +331,6 @@ class Actions ### @composeForward: ActionScopeWindow - ### - Public: Create a new draft and "reply all" to the provided threadId and messageId. See - {::composeReply} for parameters and behavior. - - *Scope: Window* - ### - @composeReplyAll: ActionScopeWindow - ### Public: Pop out the draft with the provided ID so the user can edit it in another window. @@ -352,6 +344,8 @@ class Actions ### @composePopoutDraft: ActionScopeWindow + @focusDraft: ActionScopeWindow + ### Public: Open a new composer window for creating a new draft from scratch. diff --git a/src/flux/stores/draft-factory.coffee b/src/flux/stores/draft-factory.coffee new file mode 100644 index 000000000..e4e1f9e1e --- /dev/null +++ b/src/flux/stores/draft-factory.coffee @@ -0,0 +1,234 @@ +_ = require 'underscore' + +Actions = require '../actions' +DatabaseStore = require './database-store' +AccountStore = require './account-store' +ContactStore = require './contact-store' +MessageStore = require './message-store' +FocusedPerspectiveStore = require './focused-perspective-store' + +InlineStyleTransformer = require '../../services/inline-style-transformer' +SanitizeTransformer = require '../../services/sanitize-transformer' + +Thread = require '../models/thread' +Contact = require '../models/contact' +Message = require '../models/message' +Utils = require '../models/utils' +MessageUtils = require '../models/message-utils' + +{subjectWithPrefix} = require '../models/utils' +DOMUtils = require '../../dom-utils' + +class DraftFactory + createDraft: (fields = {}) => + account = @_accountForNewDraft() + Promise.resolve(new Message(_.extend({ + body: '' + subject: '' + clientId: Utils.generateTempId() + from: [account.defaultMe()] + date: (new Date) + draft: true + pristine: true + accountId: account.id + }, fields))) + + createDraftForMailto: (urlString) => + account = @_accountForNewDraft() + + try + urlString = decodeURI(urlString) + + [whole, to, queryString] = /mailto:\/*([^\?\&]*)((.|\n|\r)*)/.exec(urlString) + + if to.length > 0 and to.indexOf('@') is -1 + to = decodeURIComponent(to) + + # /many/ mailto links are malformed and do things like: + # &body=https://github.com/atom/electron/issues?utf8=&q=is%3Aissue+is%3Aopen+123&subject=... + # (note the unescaped ? and & in the URL). + # + # To account for these scenarios, we parse the query string manually and only + # split on params we expect to be there. (Jumping from &body= to &subject= + # in the above example.) We only decode values when they appear to be entirely + # URL encoded. (In the above example, decoding the body would cause the URL + # to fall apart.) + # + query = {} + query.to = to + + querySplit = /[&|?](subject|body|cc|to|from|bcc)+\s*=/gi + + openKey = null + openValueStart = null + + until match is null + match = querySplit.exec(queryString) + openValueEnd = match?.index || queryString.length + + if openKey + value = queryString.substr(openValueStart, openValueEnd - openValueStart) + valueIsntEscaped = value.indexOf('?') isnt -1 or value.indexOf('&') isnt -1 + try + value = decodeURIComponent(value) unless valueIsntEscaped + query[openKey] = value + + if match + openKey = match[1].toLowerCase() + openValueStart = querySplit.lastIndex + + contacts = {} + for attr in ['to', 'cc', 'bcc'] + if query[attr] + contacts[attr] = ContactStore.parseContactsInString(query[attr]) + + Promise.props(contacts).then (contacts) => + @createDraft(_.extend(query, contacts)) + + createOrUpdateDraftForReply: ({message, thread, type, behavior}) => + unless type in ['reply', 'reply-all'] + throw new Error("createOrUpdateDraftForReply called with #{type}, not reply or reply-all") + + @candidateDraftForUpdating(message, behavior).then (existingDraft) => + if existingDraft + @updateDraftForReply(existingDraft, {message, thread, type}) + else + @createDraftForReply({message, thread, type}) + + createDraftForReply: ({message, thread, type}) => + @_prepareBodyForQuoting(message.body).then (body) => + if type is 'reply' + {to, cc} = message.participantsForReply() + else if type is 'reply-all' + {to, cc} = message.participantsForReplyAll() + + @createDraft( + subject: subjectWithPrefix(message.subject, 'Re:') + to: to, + cc: cc, + threadId: thread.id, + replyToMessageId: message.id, + body: """ +

+ #{DOMUtils.escapeHTMLCharacters(message.replyAttributionLine())} +
+
+ #{body} +
+
""" + ) + + createDraftForForward: ({thread, message}) => + contactsAsHtml = (cs) -> + DOMUtils.escapeHTMLCharacters(_.invoke(cs, "toString").join(", ")) + fields = [] + fields.push("From: #{contactsAsHtml(message.from)}") if message.from.length > 0 + fields.push("Subject: #{message.subject}") + fields.push("Date: #{message.formattedDate()}") + fields.push("To: #{contactsAsHtml(message.to)}") if message.to.length > 0 + fields.push("CC: #{contactsAsHtml(message.cc)}") if message.cc.length > 0 + fields.push("BCC: #{contactsAsHtml(message.bcc)}") if message.bcc.length > 0 + @_prepareBodyForQuoting(message.body).then (body) => + @createDraft( + subject: subjectWithPrefix(message.subject, 'Fwd:') + files: [].concat(message.files), + threadId: thread.id, + body: """ +

+ ---------- Forwarded message --------- +

+ #{fields.join('
')} +

+ #{body} +
""" + ) + + candidateDraftForUpdating: (message, behavior) => + if behavior not in ['prefer-existing-if-pristine', 'prefer-existing'] + return Promise.resolve(null) + + getMessages = DatabaseStore.findBy(Message, {threadId: message.threadId}) + if message.threadId is MessageStore.threadId() + getMessages = Promise.resolve(MessageStore.items()) + + getMessages.then (messages) => + candidateDrafts = messages.filter (other) => + other.replyToMessageId is message.id and other.draft is true + + if candidateDrafts.length is 0 + return Promise.resolve(null) + + if behavior is 'prefer-existing' + return Promise.resolve(candidateDrafts.pop()) + + else if behavior is 'prefer-existing-if-pristine' + DraftStore = require './draft-store' + return Promise.all(candidateDrafts.map (candidateDraft) => + DraftStore.sessionForClientId(candidateDraft.clientId) + ).then (sessions) => + for session in sessions + if session.draft().pristine + return Promise.resolve(session.draft()) + return Promise.resolve(null) + + + updateDraftForReply: (draft, {type, message}) => + unless message and draft + return Promise.reject("updateDraftForReply: Expected message and existing draft.") + + updated = {to: [].concat(draft.to), cc: [].concat(draft.cc)} + replySet = message.participantsForReply() + replyAllSet = message.participantsForReplyAll() + + if type is 'reply' + targetSet = replySet + + # Remove participants present in the reply-all set and not the reply set + for key in ['to', 'cc'] + updated[key] = _.reject updated[key], (contact) -> + inReplySet = _.findWhere(replySet[key], {email: contact.email}) + inReplyAllSet = _.findWhere(replyAllSet[key], {email: contact.email}) + return inReplyAllSet and not inReplySet + else + # Add participants present in the reply-all set and not on the draft + # Switching to reply-all shouldn't really ever remove anyone. + targetSet = replyAllSet + + for key in ['to', 'cc'] + for contact in targetSet[key] + updated[key].push(contact) unless _.findWhere(updated[key], {email: contact.email}) + + draft.to = updated.to + draft.cc = updated.cc + + DatabaseStore.inTransaction (t) => + t.persistModel(draft) + .thenReturn(draft) + + # Eventually we'll want a nicer solution for inline attachments + _prepareBodyForQuoting: (body="") => + ## Fix inline images + cidRE = MessageUtils.cidRegexString + + # Be sure to match over multiple lines with [\s\S]* + # Regex explanation here: https://regex101.com/r/vO6eN2/1 + re = new RegExp("", "igm") + body.replace(re, "") + + InlineStyleTransformer.run(body).then (body) => + SanitizeTransformer.run(body, SanitizeTransformer.Preset.UnsafeOnly) + + _accountForNewDraft: => + defAccountId = NylasEnv.config.get('core.sending.defaultAccountIdForSend') + account = AccountStore.accountForId(defAccountId) + if account + account + else + focusedAccountId = FocusedPerspectiveStore.current().accountIds[0] + if focusedAccountId + AccountStore.accountForId(focusedAccountId) + else + AccountStore.accounts()[0] + +module.exports = new DraftFactory() diff --git a/src/flux/stores/draft-store.coffee b/src/flux/stores/draft-store.coffee index 20ea268c0..694fc7639 100644 --- a/src/flux/stores/draft-store.coffee +++ b/src/flux/stores/draft-store.coffee @@ -1,16 +1,13 @@ _ = require 'underscore' -crypto = require 'crypto' -moment = require 'moment' {ipcRenderer} = require 'electron' NylasAPI = require '../nylas-api' DraftStoreProxy = require './draft-store-proxy' +DraftFactory = require './draft-factory' DatabaseStore = require './database-store' AccountStore = require './account-store' -ContactStore = require './contact-store' TaskQueueStatusStore = require './task-queue-status-store' -FocusedPerspectiveStore = require './focused-perspective-store' FocusedContentStore = require './focused-content-store' BaseDraftTask = require '../tasks/base-draft-task' @@ -19,23 +16,16 @@ SyncbackDraftFilesTask = require '../tasks/syncback-draft-files-task' SyncbackDraftTask = require '../tasks/syncback-draft-task' DestroyDraftTask = require '../tasks/destroy-draft-task' -InlineStyleTransformer = require '../../services/inline-style-transformer' -SanitizeTransformer = require '../../services/sanitize-transformer' - Thread = require '../models/thread' Contact = require '../models/contact' Message = require '../models/message' -Utils = require '../models/utils' -MessageUtils = require '../models/message-utils' Actions = require '../actions' TaskQueue = require './task-queue' SoundRegistry = require '../../sound-registry' -{subjectWithPrefix} = require '../models/utils' {Listener, Publisher} = require '../modules/reflux-coffee' CoffeeHelpers = require '../coffee-helpers' -DOMUtils = require '../../dom-utils' ExtensionRegistry = require '../../extension-registry' {deprecate} = require '../../deprecate-utils' @@ -62,7 +52,6 @@ class DraftStore @listenTo Actions.composeReply, @_onComposeReply @listenTo Actions.composeForward, @_onComposeForward - @listenTo Actions.composeReplyAll, @_onComposeReplyAll @listenTo Actions.sendDraftSuccess, => @trigger() @listenTo Actions.composePopoutDraft, @_onPopoutDraftClientId @listenTo Actions.composeNewBlankDraft, @_onPopoutBlankDraft @@ -201,14 +190,11 @@ class DraftStore return unless containsDraft @trigger(change) - _onSendQuickReply: (context, body) => - @_newMessageWithContext context, (thread, message) => - {to, cc} = message.participantsForReply() - return { - replyToMessage: message - to: to - } - .then ({draft}) => + _onSendQuickReply: ({thread, threadId, message, messageId}, body) => + Promise.props(@_modelifyContext({thread, threadId, message, messageId})) + .then ({message, thread}) => + DraftFactory.createDraftForReply({message, thread, type: 'reply'}) + .then (draft) => draft.body = body + "\n\n" + draft.body draft.pristine = false DatabaseStore.inTransaction (t) => @@ -216,28 +202,44 @@ class DraftStore .then => Actions.sendDraft(draft.clientId) - _onComposeReply: (context) => - @_newMessageWithContext context, (thread, message) => - {to, cc} = message.participantsForReply() - return { - replyToMessage: message - to: to - } + _onComposeReply: ({thread, threadId, message, messageId, popout, type, behavior}) => + Promise.props(@_modelifyContext({thread, threadId, message, messageId})) + .then ({message, thread}) => + DraftFactory.createOrUpdateDraftForReply({message, thread, type, behavior}) + .then (draft) => + @_finalizeAndPersistNewMessage(draft, {popout}) - _onComposeReplyAll: (context) => - @_newMessageWithContext context, (thread, message) => - {to, cc} = message.participantsForReplyAll() - return { - replyToMessage: message - to: to - cc: cc - } + _onComposeForward: ({thread, threadId, message, messageId, popout}) => + Promise.props(@_modelifyContext({thread, threadId, message, messageId})) + .then(DraftFactory.createDraftForForward) + .then (draft) => + @_finalizeAndPersistNewMessage(draft, {popout}) - _onComposeForward: (context) => - @_newMessageWithContext context, (thread, message) -> - forwardMessage: message + _modelifyContext: ({thread, threadId, message, messageId}) -> + queries = {} + if thread + throw new Error("newMessageWithContext: `thread` present, expected a Model. Maybe you wanted to pass `threadId`?") unless thread instanceof Thread + queries.thread = thread + else + queries.thread = DatabaseStore.find(Thread, threadId) - _finalizeAndPersistNewMessage: (draft) => + if message + throw new Error("newMessageWithContext: `message` present, expected a Model. Maybe you wanted to pass `messageId`?") unless message instanceof Message + queries.message = message + else if messageId? + queries.message = DatabaseStore + .find(Message, messageId) + .include(Message.attributes.body) + else + queries.message = DatabaseStore + .findBy(Message, {threadId: threadId ? thread.id}) + .order(Message.attributes.date.descending()) + .limit(1) + .include(Message.attributes.body) + + queries + + _finalizeAndPersistNewMessage: (draft, {popout} = {}) => # Give extensions an opportunity to perform additional setup to the draft for extension in @extensions() continue unless extension.prepareNewDraft @@ -250,163 +252,14 @@ class DraftStore DatabaseStore.inTransaction (t) => t.persistModel(draft) .then => - Promise.resolve(draftClientId: draft.clientId, draft: draft) - - _newMessageWithContext: (args, attributesCallback) => - # We accept all kinds of context. You can pass actual thread and message objects, - # or you can pass Ids and we'll look them up. Passing the object is preferable, - # and in most cases "the data is right there" anyway. Lookups add extra latency - # that feels bad. - queries = @_buildModelResolvers(args) - queries.attributesCallback = attributesCallback - - # Waits for the query promises to resolve and then resolve with a hash - # of their resolved values. *swoon* - Promise.props(queries) - .then @_prepareNewMessageAttributes - .then @_constructDraft - .then @_finalizeAndPersistNewMessage - .then ({draftClientId, draft}) => - Actions.composePopoutDraft(draftClientId) if args.popout - Promise.resolve({draftClientId, draft}) - - _buildModelResolvers: ({thread, threadId, message, messageId}) -> - queries = {} - if thread? - throw new Error("newMessageWithContext: `thread` present, expected a Model. Maybe you wanted to pass `threadId`?") unless thread instanceof Thread - queries.thread = thread - else - queries.thread = DatabaseStore.find(Thread, threadId) - - if message? - throw new Error("newMessageWithContext: `message` present, expected a Model. Maybe you wanted to pass `messageId`?") unless message instanceof Message - queries.message = message - else if messageId? - queries.message = DatabaseStore.find(Message, messageId) - queries.message.include(Message.attributes.body) - else - queries.message = @_lastMessageFromThreadId(threadId ? thread.id) - return queries - - _lastMessageFromThreadId: (threadId) -> - query = DatabaseStore.findBy(Message, {threadId: threadId ? thread.id}).order(Message.attributes.date.descending()).limit(1) - query.include(Message.attributes.body) - return query - - _constructDraft: ({attributes, thread}) => - account = AccountStore.accountForId(thread.accountId) - throw new Error("Cannot find #{thread.accountId}") unless account - return new Message _.extend {}, attributes, - from: [account.defaultMe()] - date: (new Date) - draft: true - pristine: true - threadId: thread.id - accountId: thread.accountId - - _prepareNewMessageAttributes: ({thread, message, attributesCallback}) => - attributes = attributesCallback(thread, message) - attributes.subject ?= subjectWithPrefix(thread.subject, 'Re:') - - # We set the clientID here so we have a unique id to use for shipping - # the body to the browser process. - attributes.clientId = Utils.generateTempId() - - @_prepareAttributesBody(attributes).then (body) -> - attributes.body = body - - if attributes.replyToMessage - msg = attributes.replyToMessage - attributes.subject = subjectWithPrefix(msg.subject, 'Re:') - attributes.replyToMessageId = msg.id - delete attributes.quotedMessage - - else if attributes.forwardMessage - msg = attributes.forwardMessage - - if msg.files?.length > 0 - attributes.files ?= [] - attributes.files = attributes.files.concat(msg.files) - - attributes.subject = subjectWithPrefix(msg.subject, 'Fwd:') - delete attributes.forwardedMessage - - return {attributes, thread} - - _prepareAttributesBody: (attributes) -> - if attributes.replyToMessage - replyToMessage = attributes.replyToMessage - @_prepareBodyForQuoting(replyToMessage.body).then (body) -> - return """ -

- #{DOMUtils.escapeHTMLCharacters(replyToMessage.replyAttributionLine())} -
-
- #{body} -
-
""" - - else if attributes.forwardMessage - forwardMessage = attributes.forwardMessage - contactsAsHtml = (cs) -> - DOMUtils.escapeHTMLCharacters(_.invoke(cs, "toString").join(", ")) - fields = [] - fields.push("From: #{contactsAsHtml(forwardMessage.from)}") if forwardMessage.from.length > 0 - fields.push("Subject: #{forwardMessage.subject}") - fields.push("Date: #{forwardMessage.formattedDate()}") - fields.push("To: #{contactsAsHtml(forwardMessage.to)}") if forwardMessage.to.length > 0 - fields.push("CC: #{contactsAsHtml(forwardMessage.cc)}") if forwardMessage.cc.length > 0 - fields.push("BCC: #{contactsAsHtml(forwardMessage.bcc)}") if forwardMessage.bcc.length > 0 - @_prepareBodyForQuoting(forwardMessage.body).then (body) -> - return """ -

- ---------- Forwarded message --------- -

- #{fields.join('
')} -

- #{body} -
""" - else return Promise.resolve("") - - # Eventually we'll want a nicer solution for inline attachments - _prepareBodyForQuoting: (body="") => - ## Fix inline images - cidRE = MessageUtils.cidRegexString - - # Be sure to match over multiple lines with [\s\S]* - # Regex explanation here: https://regex101.com/r/vO6eN2/1 - re = new RegExp("", "igm") - body.replace(re, "") - - InlineStyleTransformer.run(body).then (body) => - SanitizeTransformer.run(body, SanitizeTransformer.Preset.UnsafeOnly) - - _getAccountForNewMessage: => - defAccountId = NylasEnv.config.get('core.sending.defaultAccountIdForSend') - account = AccountStore.accountForId(defAccountId) - if account - account - else - focusedAccountId = FocusedPerspectiveStore.current().accountIds[0] - if focusedAccountId - AccountStore.accountForId(focusedAccountId) - else - AccountStore.accounts()[0] + @_onPopoutDraftClientId(draft.clientId) if popout + Actions.focusDraft({draftClientId: draft.clientId}) + .thenReturn({draftClientId: draft.clientId, draft: draft}) _onPopoutBlankDraft: => - account = @_getAccountForNewMessage() - - draft = new Message - body: "" - from: [account.defaultMe()] - date: (new Date) - draft: true - pristine: true - accountId: account.id - - @_finalizeAndPersistNewMessage(draft).then ({draftClientId}) => - @_onPopoutDraftClientId(draftClientId, {newDraft: true}) + DraftFactory.createDraft().then (draft) => + @_finalizeAndPersistNewMessage(draft).then ({draftClientId}) => + @_onPopoutDraftClientId(draftClientId, {newDraft: true}) _onPopoutDraftClientId: (draftClientId, options = {}) => if not draftClientId? @@ -431,82 +284,15 @@ class DraftStore windowProps: _.extend(options, {draftClientId}) _onHandleMailtoLink: (event, urlString) => - account = @_getAccountForNewMessage() - - try - urlString = decodeURI(urlString) - - [whole, to, queryString] = /mailto:\/*([^\?\&]*)((.|\n|\r)*)/.exec(urlString) - - if to.length > 0 and to.indexOf('@') is -1 - to = decodeURIComponent(to) - - # /many/ mailto links are malformed and do things like: - # &body=https://github.com/atom/electron/issues?utf8=&q=is%3Aissue+is%3Aopen+123&subject=... - # (note the unescaped ? and & in the URL). - # - # To account for these scenarios, we parse the query string manually and only - # split on params we expect to be there. (Jumping from &body= to &subject= - # in the above example.) We only decode values when they appear to be entirely - # URL encoded. (In the above example, decoding the body would cause the URL - # to fall apart.) - # - query = {} - query.to = to - - querySplit = /[&|?](subject|body|cc|to|from|bcc)+\s*=/gi - - openKey = null - openValueStart = null - - until match is null - match = querySplit.exec(queryString) - openValueEnd = match?.index || queryString.length - - if openKey - value = queryString.substr(openValueStart, openValueEnd - openValueStart) - valueIsntEscaped = value.indexOf('?') isnt -1 or value.indexOf('&') isnt -1 - try - value = decodeURIComponent(value) unless valueIsntEscaped - query[openKey] = value - - if match - openKey = match[1].toLowerCase() - openValueStart = querySplit.lastIndex - - draft = new Message - body: query.body || '' - subject: query.subject || '', - from: [account.defaultMe()] - date: (new Date) - draft: true - pristine: true - accountId: account.id - - contacts = {} - for attr in ['to', 'cc', 'bcc'] - if query[attr] - contacts[attr] = ContactStore.parseContactsInString(query[attr]) - - Promise.props(contacts).then (contacts) => - draft = _.extend(draft, contacts) - @_finalizeAndPersistNewMessage(draft).then ({draftClientId}) => - @_onPopoutDraftClientId(draftClientId) + DraftFactory.createDraftForMailto(urlString).then (draft) => + @_finalizeAndPersistNewMessage(draft, popout: true) _onHandleMailFiles: (event, paths) => - account = @_getAccountForNewMessage() - draft = new Message - body: '' - subject: '' - from: [account.defaultMe()] - date: (new Date) - draft: true - pristine: true - accountId: account.id - @_finalizeAndPersistNewMessage(draft).then ({draftClientId}) => + DraftFactory.createDraft().then (draft) => + @_finalizeAndPersistNewMessage(draft, popout: true) + .then ({draftClientId}) => for path in paths Actions.addAttachment({filePath: path, messageClientId: draftClientId}) - @_onPopoutDraftClientId(draftClientId) _onDestroyDraft: (draftClientId) => session = @_draftSessions[draftClientId]