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