mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-12-29 11:52:34 +08:00
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:
parent
7e6141fb47
commit
c265cf0dfa
12 changed files with 1515 additions and 1464 deletions
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
610
spec/stores/draft-factory-spec.es6
Normal file
610
spec/stores/draft-factory-spec.es6
Normal 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 <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 <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
558
spec/stores/draft-store-spec.es6
Normal file
558
spec/stores/draft-store-spec.es6
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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.
|
||||
|
||||
|
|
234
src/flux/stores/draft-factory.coffee
Normal file
234
src/flux/stores/draft-factory.coffee
Normal 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()
|
|
@ -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]
|
||||
|
|
Loading…
Reference in a new issue