fix(drafts): Formalize draft factory, add reply "behaviors" #1722

Summary:
This diff implements a behavior change described in https://github.com/nylas/N1/issues/1722.

Reply buttons should prefer to focus an existing draft in reply to the same message, if one is pristine, altering it as necessary to switch between reply / reply-all. If no pristine reply is already there, it creates one.

Reply keyboard shortcuts should do the same, but more strictly - the shortcuts should switch between reply / reply-all for an existing draft regardless of whether it's pristine.

This diff also cleans up the DraftStore and moves all the draft creation itself to a new DraftFactory object. This makes it much easier to see what's going on in the DraftStore, and I also refactored away the "newMessageWithContext" method, which was breaking the logic for Reply vs Forward between a bunch of different helper methods and was hard to follow.

Test Plan: They're all wrecked. Will fix after concept is greenlighted

Reviewers: evan, juan

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D2776
This commit is contained in:
Ben Gotow 2016-03-22 15:47:51 -07:00
parent 7e6141fb47
commit c265cf0dfa
12 changed files with 1515 additions and 1464 deletions

View file

@ -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)

View file

@ -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()

View file

@ -19,6 +19,7 @@ class MessageControls extends React.Component
primaryItem={<RetinaImg name={items[0].image} mode={RetinaImg.Mode.ContentIsMask}/>}
primaryTitle={items[0].name}
primaryClick={items[0].select}
closeOnMenuClick={true}
menu={@_dropdownMenu(items[1..-1])}/>
<div className="message-actions-ellipsis" onClick={@_onShowActionsMenu}>
<RetinaImg name={"message-actions-ellipsis.png"} mode={RetinaImg.Mode.ContentIsMask}/>
@ -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)

View file

@ -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
</div>
</div>
_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 = []

View file

@ -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

View file

@ -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',
});
},
}
}

View file

@ -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 &lt;customer@example.com&gt; 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 <bcc@nylas.com>',
'mailto:Ben Gotow <bengotow@gmail.com>,Shawn <shawn@nylas.com>?subject=Yes this is really valid',
'mailto:Ben%20Gotow%20<bengotow@gmail.com>,Shawn%20<shawn@nylas.com>?subject=Yes%20this%20is%20really%20valid',
'mailto:Reply <d+AORGpRdj0KXKUPBE1LoI0a30F10Ahj3wu3olS-aDk5_7K5Wu6WqqqG8t1HxxhlZ4KEEw3WmrSdtobgUq57SkwsYAH6tG57IrNqcQR0K6XaqLM2nGNZ22D2k@docs.google.com>?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);
});
});
});
});
});
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -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');
});
});
});
});

View file

@ -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.

View file

@ -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: """
<br><br><div class="gmail_quote">
#{DOMUtils.escapeHTMLCharacters(message.replyAttributionLine())}
<br>
<blockquote class="gmail_quote"
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
#{body}
</blockquote>
</div>"""
)
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: """
<br><br><div class="gmail_quote">
---------- Forwarded message ---------
<br><br>
#{fields.join('<br>')}
<br><br>
#{body}
</div>"""
)
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("<img.*#{cidRE}[\\s\\S]*?>", "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()

View file

@ -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 """
<br><br><div class="gmail_quote">
#{DOMUtils.escapeHTMLCharacters(replyToMessage.replyAttributionLine())}
<br>
<blockquote class="gmail_quote"
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
#{body}
</blockquote>
</div>"""
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 """
<br><br><div class="gmail_quote">
---------- Forwarded message ---------
<br><br>
#{fields.join('<br>')}
<br><br>
#{body}
</div>"""
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("<img.*#{cidRE}[\\s\\S]*?>", "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]