mirror of
https://github.com/Foundry376/Mailspring.git
synced 2026-01-06 17:06:28 +08:00
fix(syncback): Bidirectional transforms, ready-to-send saved state
Summary:
This diff replaces "finalizeSessionBeforeSending" with a
plugin hook that is bidirectional and allows us to put the draft in
the "ready to send" state every time we save it, and restore it to
the "ready to edit" state every time a draft session is created to
edit it.
This diff also significantly restructures the draft tasks:
1. SyncbackDraftUploadsTask:
- ensures that `uploads` are converted to `files` and that any
existing files on the draft are part of the correct account.
1. SyncbackDraftTask:
- saves the draft, nothing else.
3. SendDraftTask
- sends the draft, nothing else.
- deletes the entire uploads directory for the draft
Test Plan: WIP
Reviewers: juan, evan
Reviewed By: evan
Differential Revision: https://phab.nylas.com/D2753
This commit is contained in:
parent
6b993d07bc
commit
552b66fbaf
39 changed files with 995 additions and 883 deletions
|
|
@ -35,12 +35,12 @@ class ProductsExtension extends ComposerExtension
|
|||
return ["with the word '#{word}'?"]
|
||||
return []
|
||||
|
||||
@finalizeSessionBeforeSending: ({session}) ->
|
||||
draft = session.draft()
|
||||
if @warningsForSending({draft})
|
||||
bodyWithWarning = draft.body += "<br>This email \
|
||||
contains competitor's product names \
|
||||
or trademarks used in context."
|
||||
return session.changes.add(body: bodyWithWarning)
|
||||
else return Promise.resolve()
|
||||
@applyTransformsToDraft: ({draft}) ->
|
||||
if @warningsForSending({draft})
|
||||
updated = draft.clone()
|
||||
updated.body += "<br>This email \
|
||||
contains competitor's product names \
|
||||
or trademarks used in context."
|
||||
return updated
|
||||
return draft
|
||||
```
|
||||
|
|
|
|||
|
|
@ -148,14 +148,11 @@ export default class SpellcheckComposerExtension extends ComposerExtension {
|
|||
});
|
||||
}
|
||||
|
||||
static finalizeSessionBeforeSending = ({session}) => {
|
||||
const body = session.draft().body;
|
||||
const clean = body.replace(/<\/?spelling[^>]*>/g, '');
|
||||
|
||||
if (body !== clean) {
|
||||
return session.changes.add({body: clean});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
static applyTransformsToDraft = ({draft}) => {
|
||||
const nextDraft = draft.clone();
|
||||
nextDraft.body = nextDraft.body.replace(/<\/?spelling[^>]*>/g, '');
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
static unapplyTransformsToDraft = () => 'unnecessary'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,28 +4,29 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
|
||||
import SpellcheckComposerExtension from '../lib/spellcheck-composer-extension';
|
||||
import {NylasSpellchecker} from 'nylas-exports';
|
||||
import {NylasSpellchecker, Message} from 'nylas-exports';
|
||||
|
||||
const initialHTML = fs.readFileSync(path.join(__dirname, 'fixtures', 'california-with-misspellings-before.html')).toString();
|
||||
const expectedHTML = fs.readFileSync(path.join(__dirname, 'fixtures', 'california-with-misspellings-after.html')).toString();
|
||||
const initialPath = path.join(__dirname, 'fixtures', 'california-with-misspellings-before.html');
|
||||
const initialHTML = fs.readFileSync(initialPath).toString();
|
||||
const expectedPath = path.join(__dirname, 'fixtures', 'california-with-misspellings-after.html');
|
||||
const expectedHTML = fs.readFileSync(expectedPath).toString();
|
||||
|
||||
describe("SpellcheckComposerExtension", ()=> {
|
||||
beforeEach(()=> {
|
||||
describe("SpellcheckComposerExtension", () => {
|
||||
beforeEach(() => {
|
||||
// Avoid differences between node-spellcheck on different platforms
|
||||
const spellings = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'california-spelling-lookup.json')));
|
||||
spyOn(NylasSpellchecker, 'isMisspelled').andCallFake(word=> spellings[word])
|
||||
const lookupPath = path.join(__dirname, 'fixtures', 'california-spelling-lookup.json');
|
||||
const spellings = JSON.parse(fs.readFileSync(lookupPath));
|
||||
spyOn(NylasSpellchecker, 'isMisspelled').andCallFake(word => spellings[word])
|
||||
});
|
||||
|
||||
describe("update", ()=> {
|
||||
it("correctly walks a DOM tree and surrounds mispelled words", ()=> {
|
||||
describe("update", () => {
|
||||
it("correctly walks a DOM tree and surrounds mispelled words", () => {
|
||||
const node = document.createElement('div');
|
||||
node.innerHTML = initialHTML;
|
||||
|
||||
const editor = {
|
||||
rootNode: node,
|
||||
whilePreservingSelection: (cb)=> {
|
||||
return cb();
|
||||
},
|
||||
whilePreservingSelection: (cb) => cb(),
|
||||
};
|
||||
|
||||
SpellcheckComposerExtension.update(editor);
|
||||
|
|
@ -33,24 +34,19 @@ describe("SpellcheckComposerExtension", ()=> {
|
|||
});
|
||||
});
|
||||
|
||||
describe("finalizeSessionBeforeSending", ()=> {
|
||||
it("removes the annotations it inserted", ()=> {
|
||||
const session = {
|
||||
draft: ()=> {
|
||||
return {
|
||||
body: expectedHTML,
|
||||
};
|
||||
},
|
||||
changes: {
|
||||
add: jasmine.createSpy('add').andReturn(Promise.resolve()),
|
||||
},
|
||||
};
|
||||
describe("applyTransformsToDraft", () => {
|
||||
it("removes the spelling annotations it inserted", () => {
|
||||
const draft = new Message({ body: expectedHTML });
|
||||
const out = SpellcheckComposerExtension.applyTransformsToDraft({draft});
|
||||
expect(out.body).toEqual(initialHTML);
|
||||
});
|
||||
});
|
||||
|
||||
waitsForPromise(()=> {
|
||||
return SpellcheckComposerExtension.finalizeSessionBeforeSending({session}).then(()=> {
|
||||
expect(session.changes.add).toHaveBeenCalledWith({body: initialHTML});
|
||||
});
|
||||
});
|
||||
describe("unapplyTransformsToDraft", () => {
|
||||
it("returns the magic no-op option", () => {
|
||||
const draft = new Message({ body: expectedHTML });
|
||||
const out = SpellcheckComposerExtension.unapplyTransformsToDraft({draft});
|
||||
expect(out).toEqual('unnecessary');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,12 +10,20 @@ class TemplatesComposerExtension extends ComposerExtension {
|
|||
return warnings;
|
||||
}
|
||||
|
||||
static finalizeSessionBeforeSending({session}) {
|
||||
const body = session.draft().body;
|
||||
const clean = body.replace(/<\/?code[^>]*>/g, '');
|
||||
if (body !== clean) {
|
||||
return session.changes.add({body: clean});
|
||||
}
|
||||
static applyTransformsToDraft = ({draft}) => {
|
||||
const nextDraft = draft.clone();
|
||||
nextDraft.body = nextDraft.body.replace(/<\/?code[^>]*>/g, (match) =>
|
||||
`<!-- ${match} -->`
|
||||
);
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
static unapplyTransformsToDraft = ({draft}) => {
|
||||
const nextDraft = draft.clone();
|
||||
nextDraft.body = nextDraft.body.replace(/<!-- (<\/?code[^>]*>) -->/g, (match, node) =>
|
||||
node
|
||||
);
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
static onClick({editor, event}) {
|
||||
|
|
@ -87,7 +95,7 @@ class TemplatesComposerExtension extends ComposerExtension {
|
|||
static onContentChanged({editor}) {
|
||||
const editableNode = editor.rootNode;
|
||||
const selection = editor.currentSelection().rawSelection;
|
||||
const isWithinNode = (node)=> {
|
||||
const isWithinNode = (node) => {
|
||||
let test = selection.baseNode;
|
||||
while (test !== editableNode) {
|
||||
if (test === node) { return true; }
|
||||
|
|
@ -97,19 +105,14 @@ class TemplatesComposerExtension extends ComposerExtension {
|
|||
};
|
||||
|
||||
const codeTags = editableNode.querySelectorAll('code.var.empty');
|
||||
return (() => {
|
||||
const result = [];
|
||||
for (let i = 0, codeTag; i < codeTags.length; i++) {
|
||||
codeTag = codeTags[i];
|
||||
codeTag.textContent = codeTag.textContent; // sets node contents to just its textContent, strips HTML
|
||||
result.push((() => {
|
||||
if (selection.containsNode(codeTag) || isWithinNode(codeTag)) {
|
||||
return codeTag.classList.remove('empty');
|
||||
}
|
||||
})());
|
||||
for (let i = 0, codeTag; i < codeTags.length; i++) {
|
||||
codeTag = codeTags[i];
|
||||
// sets node contents to just its textContent, strips HTML
|
||||
codeTag.textContent = codeTag.textContent;
|
||||
if (selection.containsNode(codeTag) || isWithinNode(codeTag)) {
|
||||
codeTag.classList.remove('empty');
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,33 +16,57 @@ class DraftBody {
|
|||
}
|
||||
|
||||
export default class LinkTrackingComposerExtension extends ComposerExtension {
|
||||
static finalizeSessionBeforeSending({session}) {
|
||||
const draft = session.draft();
|
||||
|
||||
static applyTransformsToDraft({draft}) {
|
||||
// grab message metadata, if any
|
||||
const metadata = draft.metadataForPluginId(PLUGIN_ID);
|
||||
const nextDraft = draft.clone();
|
||||
const metadata = nextDraft.metadataForPluginId(PLUGIN_ID);
|
||||
if (metadata) {
|
||||
const draftBody = new DraftBody(draft);
|
||||
const links = [];
|
||||
const messageUid = uuid.v4().replace(/-/g, "");
|
||||
|
||||
// loop through all <a href> elements, replace with redirect links and save mappings
|
||||
draftBody.unquoted = draftBody.unquoted.replace(RegExpUtils.urlLinkTagRegex(), (match, prefix, url, suffix, content, closingTag) => {
|
||||
const encoded = encodeURIComponent(url);
|
||||
// the links param is an index of the link array.
|
||||
const redirectUrl = `${PLUGIN_URL}/link/${draft.accountId}/${messageUid}/${links.length}?redirect=${encoded}`;
|
||||
links.push({url: url, click_count: 0, click_data: [], redirect_url: redirectUrl});
|
||||
return prefix + redirectUrl + suffix + content + closingTag;
|
||||
});
|
||||
// loop through all <a href> elements, replace with redirect links and save
|
||||
// mappings. The links component of the path is an index of the link array.
|
||||
draftBody.unquoted = draftBody.unquoted.replace(
|
||||
RegExpUtils.urlLinkTagRegex(),
|
||||
(match, prefix, url, suffix, content, closingTag) => {
|
||||
const encoded = encodeURIComponent(url);
|
||||
const redirectUrl = `${PLUGIN_URL}/link/${draft.accountId}/${messageUid}/${links.length}?redirect=${encoded}`;
|
||||
links.push({
|
||||
url,
|
||||
click_count: 0,
|
||||
click_data: [],
|
||||
redirect_url: redirectUrl,
|
||||
});
|
||||
return prefix + redirectUrl + suffix + content + closingTag;
|
||||
}
|
||||
);
|
||||
|
||||
// save the draft
|
||||
session.changes.add({body: draftBody.body});
|
||||
nextDraft.body = draftBody.body;
|
||||
|
||||
// save the link info to draft metadata
|
||||
metadata.uid = messageUid;
|
||||
metadata.links = links;
|
||||
|
||||
Actions.setMetadata(draft, PLUGIN_ID, metadata);
|
||||
}
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
static unapplyTransformsToDraft({draft}) {
|
||||
const nextDraft = draft.clone();
|
||||
const draftBody = new DraftBody(draft);
|
||||
draftBody.unquoted = draftBody.unquoted.replace(
|
||||
RegExpUtils.urlLinkTagRegex(),
|
||||
(match, prefix, url, suffix, content, closingTag) => {
|
||||
if (url.indexOf(PLUGIN_URL) !== -1) {
|
||||
const userURLEncoded = url.split('?redirect=')[1];
|
||||
return prefix + decodeURIComponent(userURLEncoded) + suffix + content + closingTag;
|
||||
}
|
||||
return prefix + url + suffix + content + closingTag;
|
||||
}
|
||||
)
|
||||
nextDraft.body = draftBody.body;
|
||||
return nextDraft;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,68 +18,71 @@ const replacedContent = (accountId, messageUid) => `TEST_BODY<br>
|
|||
<div href="stillhere"></div>
|
||||
http://www.stillhere.com`;
|
||||
|
||||
const quote = `<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;"> On Feb 25 2016, at 3:38 pm, Drew <drew@nylas.com> wrote: <br> twst </blockquote>`;
|
||||
const testBody = `<body>${testContent}${quote}</body>`;
|
||||
const replacedBody = (accountId, messageUid, unquoted) => `<body>${replacedContent(accountId, messageUid)}${unquoted ? "" : quote}</body>`;
|
||||
const quote = `<blockquote class="gmail_quote"> twst </blockquote>`;
|
||||
const testBody = `<head></head><body>${testContent}${quote}</body>`;
|
||||
|
||||
describe("Open tracking composer extension", () => {
|
||||
const replacedBody = (accountId, messageUid, unquoted) =>
|
||||
`<head></head><body>${replacedContent(accountId, messageUid)}${unquoted ? "" : quote}</body>`;
|
||||
|
||||
describe("Link tracking composer extension", () => {
|
||||
// Set up a draft, session that returns the draft, and metadata
|
||||
beforeEach(()=>{
|
||||
beforeEach(() => {
|
||||
this.draft = new Message({accountId: "test"});
|
||||
this.draft.body = testBody;
|
||||
this.session = {
|
||||
draft: () => this.draft,
|
||||
changes: jasmine.createSpyObj('changes', ['add', 'commit']),
|
||||
};
|
||||
});
|
||||
|
||||
it("takes no action if there is no metadata", ()=>{
|
||||
LinkTrackingComposerExtension.finalizeSessionBeforeSending({session: this.session});
|
||||
expect(this.session.changes.add).not.toHaveBeenCalled();
|
||||
expect(this.session.changes.commit).not.toHaveBeenCalled();
|
||||
describe("applyTransformsToDraft", () => {
|
||||
it("takes no action if there is no metadata", () => {
|
||||
const out = LinkTrackingComposerExtension.applyTransformsToDraft({draft: this.draft});
|
||||
expect(out.body).toEqual(this.draft.body);
|
||||
});
|
||||
|
||||
describe("With properly formatted metadata and correct params", () => {
|
||||
beforeEach(() => {
|
||||
this.metadata = {tracked: true};
|
||||
this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
|
||||
});
|
||||
|
||||
it("replaces links in the unquoted portion of the body", () => {
|
||||
const out = LinkTrackingComposerExtension.applyTransformsToDraft({draft: this.draft});
|
||||
const outUnquoted = QuotedHTMLTransformer.removeQuotedHTML(out.body);
|
||||
|
||||
expect(outUnquoted).toContain(replacedBody(this.draft.accountId, this.metadata.uid, true));
|
||||
expect(out.body).toContain(replacedBody(this.draft.accountId, this.metadata.uid, false));
|
||||
});
|
||||
|
||||
it("sets a uid and list of links on the metadata", () => {
|
||||
LinkTrackingComposerExtension.applyTransformsToDraft({draft: this.draft});
|
||||
|
||||
expect(this.metadata.uid).not.toBeUndefined();
|
||||
expect(this.metadata.links).not.toBeUndefined();
|
||||
expect(this.metadata.links.length).toEqual(2);
|
||||
|
||||
for (const link of this.metadata.links) {
|
||||
expect(link.click_count).toEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("With properly formatted metadata and correct params", () => {
|
||||
// Set metadata on the draft and call finalizeSessionBeforeSending
|
||||
beforeEach(()=>{
|
||||
describe("unapplyTransformsToDraft", () => {
|
||||
it("takes no action if there are no tracked links in the body", () => {
|
||||
const out = LinkTrackingComposerExtension.unapplyTransformsToDraft({
|
||||
draft: this.draft.clone(),
|
||||
});
|
||||
expect(out.body).toEqual(this.draft.body);
|
||||
});
|
||||
|
||||
it("replaces tracked links with the original links, restoring the body exactly", () => {
|
||||
this.metadata = {tracked: true};
|
||||
this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
|
||||
LinkTrackingComposerExtension.finalizeSessionBeforeSending({session: this.session});
|
||||
const withImg = LinkTrackingComposerExtension.applyTransformsToDraft({
|
||||
draft: this.draft.clone(),
|
||||
});
|
||||
const withoutImg = LinkTrackingComposerExtension.unapplyTransformsToDraft({
|
||||
draft: withImg.clone(),
|
||||
});
|
||||
expect(withoutImg.body).toEqual(this.draft.body);
|
||||
});
|
||||
|
||||
it("adds (but does not commit) the changes to the session", ()=>{
|
||||
expect(this.session.changes.add).toHaveBeenCalled();
|
||||
expect(this.session.changes.add.mostRecentCall.args[0].body).toBeDefined();
|
||||
expect(this.session.changes.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("On the unquoted body", () => {
|
||||
beforeEach(()=>{
|
||||
this.body = this.session.changes.add.mostRecentCall.args[0].body;
|
||||
this.unquoted = QuotedHTMLTransformer.removeQuotedHTML(this.body);
|
||||
|
||||
waitsFor(()=>this.metadata.uid)
|
||||
});
|
||||
|
||||
it("sets a uid and list of links on the metadata", ()=>{
|
||||
runs(() => {
|
||||
expect(this.metadata.uid).not.toBeUndefined();
|
||||
expect(this.metadata.links).not.toBeUndefined();
|
||||
expect(this.metadata.links.length).toEqual(2);
|
||||
|
||||
for (const link of this.metadata.links) {
|
||||
expect(link.click_count).toEqual(0);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it("replaces all the valid href URLs with redirects", ()=>{
|
||||
runs(() => {
|
||||
expect(this.unquoted).toContain(replacedBody(this.draft.accountId, this.metadata.uid, true));
|
||||
expect(this.body).toContain(replacedBody(this.draft.accountId, this.metadata.uid, false));
|
||||
})
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,27 +10,33 @@ class DraftBody {
|
|||
}
|
||||
|
||||
export default class OpenTrackingComposerExtension extends ComposerExtension {
|
||||
static finalizeSessionBeforeSending({session}) {
|
||||
const draft = session.draft();
|
||||
|
||||
static applyTransformsToDraft({draft}) {
|
||||
// grab message metadata, if any
|
||||
const nextDraft = draft.clone();
|
||||
const metadata = draft.metadataForPluginId(PLUGIN_ID);
|
||||
if (!metadata) {
|
||||
return;
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
if (!metadata.uid) {
|
||||
NylasEnv.reportError(new Error("Open tracking composer extension could not find 'uid' in metadata!"));
|
||||
return;
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
// insert a tracking pixel <img> into the message
|
||||
const serverUrl = `${PLUGIN_URL}/open/${draft.accountId}/${metadata.uid}`;
|
||||
const img = `<img width="0" height="0" style="border:0; width:0; height:0;" src="${serverUrl}">`;
|
||||
const img = `<img class="n1-open" width="0" height="0" style="border:0; width:0; height:0;" src="${serverUrl}">`;
|
||||
const draftBody = new DraftBody(draft);
|
||||
draftBody.unquoted = draftBody.unquoted + "<br>" + img;
|
||||
|
||||
// save the draft
|
||||
session.changes.add({body: draftBody.body});
|
||||
draftBody.unquoted = `${draftBody.unquoted}${img}`;
|
||||
nextDraft.body = draftBody.body;
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
static unapplyTransformsToDraft({draft}) {
|
||||
const nextDraft = draft.clone();
|
||||
nextDraft.body = draft.body.replace(/<img class="n1-open"[^>]*>/g, '');
|
||||
return nextDraft;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,58 +6,57 @@ const quote = `<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-l
|
|||
|
||||
describe("Open tracking composer extension", () => {
|
||||
// Set up a draft, session that returns the draft, and metadata
|
||||
beforeEach(()=>{
|
||||
this.draft = new Message();
|
||||
this.draft.body = `<body>TEST_BODY ${quote}</body>`;
|
||||
this.session = {
|
||||
draft: () => this.draft,
|
||||
changes: jasmine.createSpyObj('changes', ['add', 'commit']),
|
||||
};
|
||||
beforeEach(() => {
|
||||
this.draft = new Message({
|
||||
body: `<head></head><body>TEST_BODY ${quote}</body>`,
|
||||
});
|
||||
});
|
||||
|
||||
it("takes no action if there is no metadata", ()=>{
|
||||
OpenTrackingComposerExtension.finalizeSessionBeforeSending({session: this.session});
|
||||
expect(this.session.changes.add.calls.length).toEqual(0);
|
||||
expect(this.session.changes.commit.calls.length).toEqual(0);
|
||||
});
|
||||
|
||||
describe("With properly formatted metadata and correct params", () => {
|
||||
// Set metadata on the draft and call finalizeSessionBeforeSending
|
||||
beforeEach(()=>{
|
||||
this.metadata = {uid: "TEST_UID"};
|
||||
this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
|
||||
OpenTrackingComposerExtension.finalizeSessionBeforeSending({session: this.session});
|
||||
describe("applyTransformsToDraft", () => {
|
||||
it("takes no action if there is no metadata", () => {
|
||||
const out = OpenTrackingComposerExtension.applyTransformsToDraft({draft: this.draft});
|
||||
expect(out.body).toEqual(this.draft.body);
|
||||
});
|
||||
|
||||
it("adds (but does not commit) the changes to the session", ()=>{
|
||||
expect(this.session.changes.add).toHaveBeenCalled();
|
||||
expect(this.session.changes.add.mostRecentCall.args[0].body).toBeDefined();
|
||||
expect(this.session.changes.add.mostRecentCall.args[0].body).toContain("TEST_BODY");
|
||||
expect(this.session.changes.commit).not.toHaveBeenCalled();
|
||||
it("reports an error if the metadata is missing required fields", () => {
|
||||
this.draft.applyPluginMetadata(PLUGIN_ID, {});
|
||||
spyOn(NylasEnv, "reportError");
|
||||
OpenTrackingComposerExtension.applyTransformsToDraft({draft: this.draft});
|
||||
expect(NylasEnv.reportError).toHaveBeenCalled()
|
||||
});
|
||||
|
||||
describe("On the unquoted body", () => {
|
||||
beforeEach(()=>{
|
||||
const body = this.session.changes.add.mostRecentCall.args[0].body;
|
||||
this.unquoted = QuotedHTMLTransformer.removeQuotedHTML(body);
|
||||
describe("With properly formatted metadata and correct params", () => {
|
||||
beforeEach(() => {
|
||||
this.metadata = {uid: "TEST_UID"};
|
||||
this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
|
||||
const out = OpenTrackingComposerExtension.applyTransformsToDraft({draft: this.draft});
|
||||
this.unquoted = QuotedHTMLTransformer.removeQuotedHTML(out.body);
|
||||
});
|
||||
|
||||
it("appends an image to the body", ()=>{
|
||||
it("appends an image to the unquoted body", () => {
|
||||
expect(this.unquoted).toMatch(/<img .*?>/);
|
||||
});
|
||||
|
||||
it("has the right server URL", ()=>{
|
||||
it("has the right server URL", () => {
|
||||
const img = this.unquoted.match(/<img .*?>/)[0];
|
||||
expect(img).toContain(`${PLUGIN_URL}/open/${this.draft.accountId}/${this.metadata.uid}`);
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an error if the metadata is missing required fields", ()=>{
|
||||
this.draft.applyPluginMetadata(PLUGIN_ID, {});
|
||||
spyOn(NylasEnv, "reportError");
|
||||
OpenTrackingComposerExtension.finalizeSessionBeforeSending({session: this.session});
|
||||
expect(NylasEnv.reportError).toHaveBeenCalled()
|
||||
describe("unapplyTransformsToDraft", () => {
|
||||
it("takes no action if the img tag is missing", () => {
|
||||
const out = OpenTrackingComposerExtension.unapplyTransformsToDraft({draft: this.draft});
|
||||
expect(out.body).toEqual(this.draft.body);
|
||||
});
|
||||
|
||||
it("removes the image from the body and restore the body to it's exact original content", () => {
|
||||
this.metadata = {uid: "TEST_UID"};
|
||||
this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
|
||||
const withImg = OpenTrackingComposerExtension.applyTransformsToDraft({draft: this.draft});
|
||||
|
||||
const withoutImg = OpenTrackingComposerExtension.unapplyTransformsToDraft({draft: withImg});
|
||||
expect(withoutImg.body).toEqual(this.draft.body);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -32,36 +32,36 @@ class SendLaterButton extends Component {
|
|||
this._subscription.dispose();
|
||||
}
|
||||
|
||||
onSendLater = (formattedDate, dateLabel)=> {
|
||||
onSendLater = (formattedDate, dateLabel) => {
|
||||
SendLaterActions.sendLater(this.props.draftClientId, formattedDate, dateLabel);
|
||||
this.setState({scheduledDate: 'saving'});
|
||||
Actions.closePopover()
|
||||
};
|
||||
|
||||
onCancelSendLater = ()=> {
|
||||
onCancelSendLater = () => {
|
||||
SendLaterActions.cancelSendLater(this.props.draftClientId);
|
||||
Actions.closePopover()
|
||||
};
|
||||
|
||||
onClick = ()=> {
|
||||
onClick = () => {
|
||||
const buttonRect = React.findDOMNode(this).getBoundingClientRect()
|
||||
Actions.openPopover(
|
||||
<SendLaterPopover
|
||||
scheduledDate={this.state.scheduledDate}
|
||||
onSendLater={this.onSendLater}
|
||||
onCancelSendLater={this.onCancelSendLater} />,
|
||||
onCancelSendLater={this.onCancelSendLater}
|
||||
/>,
|
||||
{originRect: buttonRect, direction: 'up'}
|
||||
)
|
||||
};
|
||||
|
||||
onMessageChanged = (message)=> {
|
||||
onMessageChanged = (message) => {
|
||||
if (!message) return;
|
||||
const {scheduledDate} = this.state;
|
||||
const messageMetadata = message.metadataForPluginId(PLUGIN_ID) || {}
|
||||
const nextScheduledDate = messageMetadata.sendLaterDate
|
||||
|
||||
if (nextScheduledDate !== this.state.scheduledDate) {
|
||||
if (nextScheduledDate !== scheduledDate) {
|
||||
const isComposer = NylasEnv.isComposerWindow()
|
||||
const isFinishedSelecting = ((this.state.scheduledDate === 'saving') && (nextScheduledDate !== null));
|
||||
const isFinishedSelecting = ((scheduledDate === 'saving') && (nextScheduledDate !== null));
|
||||
if (isComposer && isFinishedSelecting) {
|
||||
NylasEnv.close();
|
||||
}
|
||||
|
|
@ -79,7 +79,8 @@ class SendLaterButton extends Component {
|
|||
<RetinaImg
|
||||
name="inline-loading-spinner.gif"
|
||||
mode={RetinaImg.Mode.ContentDark}
|
||||
style={{width: 14, height: 14}}/>
|
||||
style={{width: 14, height: 14}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -94,10 +95,10 @@ class SendLaterButton extends Component {
|
|||
}
|
||||
return (
|
||||
<button className={className} title="Send later…" onClick={this.onClick}>
|
||||
<RetinaImg name="icon-composer-sendlater.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
<RetinaImg name="icon-composer-sendlater.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
{dateInterpretation}
|
||||
<span> </span>
|
||||
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
Actions,
|
||||
Message,
|
||||
DatabaseStore,
|
||||
SyncbackDraftTask,
|
||||
} from 'nylas-exports'
|
||||
import SendLaterActions from './send-later-actions'
|
||||
import {PLUGIN_ID, PLUGIN_NAME} from './send-later-constants'
|
||||
|
|
@ -26,19 +25,17 @@ class SendLaterStore extends NylasStore {
|
|||
];
|
||||
}
|
||||
|
||||
setMetadata = (draftClientId, metadata)=> {
|
||||
setMetadata = (draftClientId, metadata) => {
|
||||
return DatabaseStore.modelify(Message, [draftClientId])
|
||||
.then((messages)=> {
|
||||
const {accountId} = messages[0];
|
||||
.then((messages) => {
|
||||
const message = messages[0];
|
||||
|
||||
return NylasAPI.authPlugin(this.pluginId, this.pluginName, accountId)
|
||||
.then(()=> {
|
||||
Actions.setMetadata(messages, this.pluginId, metadata);
|
||||
|
||||
// Important: Do not remove this unless N1 is syncing drafts by default.
|
||||
Actions.queueTask(new SyncbackDraftTask(draftClientId));
|
||||
return NylasAPI.authPlugin(this.pluginId, this.pluginName, message.accountId)
|
||||
.then(() => {
|
||||
Actions.setMetadata(message, this.pluginId, metadata);
|
||||
Actions.ensureDraftSynced(message.clientId);
|
||||
})
|
||||
.catch((error)=> {
|
||||
.catch((error) => {
|
||||
NylasEnv.reportError(error);
|
||||
NylasEnv.showErrorDialog(`Sorry, we were unable to schedule this message. ${error.message}`);
|
||||
});
|
||||
|
|
@ -61,17 +58,21 @@ class SendLaterStore extends NylasStore {
|
|||
}
|
||||
}
|
||||
|
||||
onSendLater = (draftClientId, sendLaterDate, dateLabel)=> {
|
||||
onSendLater = (draftClientId, sendLaterDate, dateLabel) => {
|
||||
this.recordAction(sendLaterDate, dateLabel)
|
||||
this.setMetadata(draftClientId, {sendLaterDate});
|
||||
this.setMetadata(draftClientId, {sendLaterDate}).then(() => {
|
||||
if (NylasEnv.isComposerWindow()) {
|
||||
Actions.closePopover();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onCancelSendLater = (draftClientId)=> {
|
||||
onCancelSendLater = (draftClientId) => {
|
||||
this.recordAction(null)
|
||||
this.setMetadata(draftClientId, {sendLaterDate: null});
|
||||
};
|
||||
|
||||
deactivate = ()=> {
|
||||
deactivate = () => {
|
||||
this.unsubscribers.forEach(unsub => unsub());
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,12 +71,10 @@ describe('SendLaterButton', ()=> {
|
|||
it('sets scheduled date to "saving" and dispatches action', ()=> {
|
||||
const button = makeButton()
|
||||
spyOn(button, 'setState')
|
||||
spyOn(Actions, 'closePopover')
|
||||
button.onSendLater({utc: ()=> 'utc'})
|
||||
|
||||
expect(SendLaterActions.sendLater).toHaveBeenCalled()
|
||||
expect(button.setState).toHaveBeenCalledWith({scheduledDate: 'saving'})
|
||||
expect(Actions.closePopover).toHaveBeenCalled()
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,13 +7,24 @@ import {
|
|||
import SendLaterStore from '../lib/send-later-store'
|
||||
|
||||
|
||||
describe('SendLaterStore', ()=> {
|
||||
beforeEach(()=> {
|
||||
describe('SendLaterStore', () => {
|
||||
beforeEach(() => {
|
||||
this.store = new SendLaterStore('plug-id', 'plug-name')
|
||||
});
|
||||
|
||||
describe('setMetadata', ()=> {
|
||||
beforeEach(()=> {
|
||||
describe('onSendLater', () => {
|
||||
it("should call setMetadata and then close the popover (if in a composer)", () => {
|
||||
spyOn(Actions, 'closePopover');
|
||||
spyOn(NylasEnv, 'isComposerWindow').andReturn(true);
|
||||
spyOn(this.store, 'setMetadata').andReturn(Promise.resolve());
|
||||
this.store.onSendLater('client-id', new Date(), 'asd');
|
||||
advanceClock();
|
||||
expect(Actions.closePopover).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMetadata', () => {
|
||||
beforeEach(() => {
|
||||
this.message = new Message({accountId: 123, clientId: 'c-1'})
|
||||
this.metadata = {sendLaterDate: 'the future'}
|
||||
spyOn(this.store, 'recordAction')
|
||||
|
|
@ -24,10 +35,10 @@ describe('SendLaterStore', ()=> {
|
|||
spyOn(NylasEnv, 'showErrorDialog')
|
||||
});
|
||||
|
||||
it('auths the plugin correctly', ()=> {
|
||||
waitsForPromise(()=> {
|
||||
it('auths the plugin correctly', () => {
|
||||
waitsForPromise(() => {
|
||||
return this.store.setMetadata('c-1', this.metadata)
|
||||
.then(()=> {
|
||||
.then(() => {
|
||||
expect(NylasAPI.authPlugin).toHaveBeenCalled()
|
||||
expect(NylasAPI.authPlugin).toHaveBeenCalledWith(
|
||||
'plug-id',
|
||||
|
|
@ -38,12 +49,12 @@ describe('SendLaterStore', ()=> {
|
|||
})
|
||||
});
|
||||
|
||||
it('sets the correct metadata', ()=> {
|
||||
waitsForPromise(()=> {
|
||||
it('sets the correct metadata', () => {
|
||||
waitsForPromise(() => {
|
||||
return this.store.setMetadata('c-1', this.metadata)
|
||||
.then(()=> {
|
||||
.then(() => {
|
||||
expect(Actions.setMetadata).toHaveBeenCalledWith(
|
||||
[this.message],
|
||||
this.message,
|
||||
'plug-id',
|
||||
this.metadata
|
||||
)
|
||||
|
|
@ -52,12 +63,12 @@ describe('SendLaterStore', ()=> {
|
|||
})
|
||||
});
|
||||
|
||||
it('displays dialog if an error occurs', ()=> {
|
||||
it('displays dialog if an error occurs', () => {
|
||||
jasmine.unspy(NylasAPI, 'authPlugin')
|
||||
spyOn(NylasAPI, 'authPlugin').andReturn(Promise.reject(new Error('Oh no!')))
|
||||
waitsForPromise(()=> {
|
||||
waitsForPromise(() => {
|
||||
return this.store.setMetadata('c-1', this.metadata)
|
||||
.finally(()=> {
|
||||
.finally(() => {
|
||||
expect(Actions.setMetadata).not.toHaveBeenCalled()
|
||||
expect(NylasEnv.reportError).toHaveBeenCalled()
|
||||
expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import React, {Component, PropTypes} from 'react'
|
|||
import {Flexbox} from 'nylas-component-kit'
|
||||
import {timestamp} from './formatting-utils'
|
||||
import SendingProgressBar from './sending-progress-bar'
|
||||
import SendingCancelButton from './sending-cancel-button'
|
||||
|
||||
export default class DraftListSendStatus extends Component {
|
||||
static displayName = 'DraftListSendStatus';
|
||||
|
|
@ -18,8 +17,10 @@ export default class DraftListSendStatus extends Component {
|
|||
if (draft.uploadTaskId) {
|
||||
return (
|
||||
<Flexbox style={{width: 150, whiteSpace: 'no-wrap'}}>
|
||||
<SendingProgressBar style={{flex: 1, marginRight: 10}} progress={draft.uploadProgress * 100} />
|
||||
<SendingCancelButton taskId={draft.uploadTaskId} />
|
||||
<SendingProgressBar
|
||||
style={{flex: 1, marginRight: 10}}
|
||||
progress={draft.uploadProgress * 100}
|
||||
/>
|
||||
</Flexbox>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class DraftListStore extends NylasStore
|
|||
|
||||
mailboxPerspective.accountIds.forEach (aid) =>
|
||||
OutboxStore.itemsForAccount(aid).forEach (task) =>
|
||||
draft = resultSet.modelWithId(task.draft.clientId)
|
||||
draft = resultSet.modelWithId(task.draftClientId)
|
||||
if draft
|
||||
draft = draft.clone()
|
||||
draft.uploadTaskId = task.id
|
||||
|
|
|
|||
|
|
@ -508,7 +508,7 @@ body.is-blurred {
|
|||
.sending-progress {
|
||||
display: block;
|
||||
height:7px;
|
||||
margin-top:10px;
|
||||
align-self: center;
|
||||
background-color: @background-primary;
|
||||
border-bottom:1px solid @border-color-divider;
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class DeveloperBarTask extends React.Component
|
|||
if @state.expanded
|
||||
# This could be a potentially large amount of JSON.
|
||||
# Do not render unless it's actually being displayed!
|
||||
details = <div className="task-details">{JSON.stringify(@props.task.toJSON())}</div>
|
||||
details = <div className="task-details">{JSON.stringify(@props.task.toJSON(), null, 2)}</div>
|
||||
|
||||
<div className={@_classNames()} onClick={=> @setState(expanded: not @state.expanded)}>
|
||||
<div className="task-summary">
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@
|
|||
|
||||
.task-details { display: none; }
|
||||
&.task-expanded{
|
||||
.task-details { display: block; }
|
||||
.task-details { display: block; white-space: pre; }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"event-kit": "^1.0.2",
|
||||
"fs-plus": "^2.3.2",
|
||||
"fstream": "0.1.24",
|
||||
"rimraf": "2.5.2",
|
||||
"grim": "1.5.0",
|
||||
"guid": "0.0.10",
|
||||
"immutable": "3.7.5",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ fs = require 'fs'
|
|||
Actions = require '../src/flux/actions'
|
||||
NylasAPI = require '../src/flux/nylas-api'
|
||||
Thread = require '../src/flux/models/thread'
|
||||
Message = require '../src/flux/models/message'
|
||||
AccountStore = require '../src/flux/stores/account-store'
|
||||
DatabaseStore = require '../src/flux/stores/database-store'
|
||||
DatabaseTransaction = require '../src/flux/stores/database-transaction'
|
||||
|
|
@ -334,3 +335,33 @@ describe "NylasAPI", ->
|
|||
verifyUpdate = _.partial(verifyUpdateHappened, klass)
|
||||
waitsForPromise =>
|
||||
NylasAPI._handleModelResponse(json).then verifyUpdate
|
||||
|
||||
describe "makeDraftDeletionRequest", ->
|
||||
it "should make an API request to delete the draft", ->
|
||||
draft = new Message(accountId: TEST_ACCOUNT_ID, draft: true, clientId: 'asd', serverId: 'asd')
|
||||
spyOn(NylasAPI, 'makeRequest')
|
||||
NylasAPI.makeDraftDeletionRequest(draft)
|
||||
expect(NylasAPI.makeRequest).toHaveBeenCalled()
|
||||
expect(NylasAPI.makeRequest.callCount).toBe 1
|
||||
req = NylasAPI.makeRequest.calls[0].args[0]
|
||||
expect(req.path).toBe "/drafts/#{draft.serverId}"
|
||||
expect(req.accountId).toBe TEST_ACCOUNT_ID
|
||||
expect(req.method).toBe "DELETE"
|
||||
expect(req.returnsModel).toBe false
|
||||
|
||||
it "should increment the change tracker, preventing any further deltas about the draft", ->
|
||||
draft = new Message(accountId: TEST_ACCOUNT_ID, draft: true, clientId: 'asd', serverId: 'asd')
|
||||
spyOn(NylasAPI, 'incrementRemoteChangeLock')
|
||||
NylasAPI.makeDraftDeletionRequest(draft)
|
||||
expect(NylasAPI.incrementRemoteChangeLock).toHaveBeenCalledWith(Message, draft.serverId)
|
||||
|
||||
it "should not return a promise or anything else, to avoid accidentally making things dependent on the request", ->
|
||||
draft = new Message(accountId: TEST_ACCOUNT_ID, draft: true, clientId: 'asd', serverId: 'asd')
|
||||
a = NylasAPI.makeDraftDeletionRequest(draft)
|
||||
expect(a).toBe(undefined)
|
||||
|
||||
it "should not do anything if the draft is missing a serverId", ->
|
||||
draft = new Message(accountId: TEST_ACCOUNT_ID, draft: true, clientId: 'asd', serverId: null)
|
||||
spyOn(NylasAPI, 'makeRequest')
|
||||
NylasAPI.makeDraftDeletionRequest(draft)
|
||||
expect(NylasAPI.makeRequest).not.toHaveBeenCalled()
|
||||
|
|
|
|||
|
|
@ -116,8 +116,10 @@ describe "DraftStoreProxy", ->
|
|||
it "should not make a query for the draft", ->
|
||||
expect(DatabaseStore.run).not.toHaveBeenCalled()
|
||||
|
||||
it "should immediately make the draft available", ->
|
||||
expect(@proxy.draft()).toEqual(@draft)
|
||||
it "prepare should resolve without querying for the draft", ->
|
||||
waitsForPromise => @proxy.prepare().then =>
|
||||
expect(@proxy.draft()).toEqual(@draft)
|
||||
expect(DatabaseStore.run).not.toHaveBeenCalled()
|
||||
|
||||
describe "teardown", ->
|
||||
it "should mark the session as destroyed", ->
|
||||
|
|
@ -131,7 +133,7 @@ describe "DraftStoreProxy", ->
|
|||
@draft = new Message(draft: true, body: '123', clientId: 'client-id')
|
||||
spyOn(DraftStoreProxy.prototype, "prepare")
|
||||
@proxy = new DraftStoreProxy('client-id')
|
||||
spyOn(@proxy, '_setDraft')
|
||||
spyOn(@proxy, '_setDraft').andCallThrough()
|
||||
spyOn(DatabaseStore, 'run').andCallFake (modelQuery) =>
|
||||
Promise.resolve(@draft)
|
||||
jasmine.unspy(DraftStoreProxy.prototype, "prepare")
|
||||
|
|
@ -167,6 +169,7 @@ describe "DraftStoreProxy", ->
|
|||
beforeEach ->
|
||||
@draft = new Message(draft: true, clientId: 'client-id', body: 'A', subject: 'initial')
|
||||
@proxy = new DraftStoreProxy('client-id', @draft)
|
||||
advanceClock()
|
||||
|
||||
spyOn(DatabaseTransaction.prototype, "persistModel").andReturn Promise.resolve()
|
||||
spyOn(Actions, "queueTask").andReturn Promise.resolve()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
FocusedContentStore,
|
||||
DatabaseTransaction,
|
||||
SanitizeTransformer,
|
||||
SyncbackDraftFilesTask,
|
||||
InlineStyleTransformer} = require 'nylas-exports'
|
||||
|
||||
ModelQuery = require '../../src/flux/models/query'
|
||||
|
|
@ -685,6 +686,7 @@ describe "DraftStore", ->
|
|||
|
||||
DraftStore._draftSessions[@draft.clientId] = proxy
|
||||
spyOn(DraftStore, "_doneWithSession").andCallThrough()
|
||||
spyOn(DraftStore, "_prepareForSyncback").andReturn(Promise.resolve())
|
||||
spyOn(DraftStore, "trigger")
|
||||
spyOn(SoundRegistry, "playSound")
|
||||
spyOn(Actions, "queueTask")
|
||||
|
|
@ -692,18 +694,21 @@ describe "DraftStore", ->
|
|||
it "plays a sound immediately when sending draft", ->
|
||||
spyOn(NylasEnv.config, "get").andReturn true
|
||||
DraftStore._onSendDraft(@draft.clientId)
|
||||
advanceClock()
|
||||
expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds")
|
||||
expect(SoundRegistry.playSound).toHaveBeenCalledWith("hit-send")
|
||||
|
||||
it "doesn't plays a sound if the setting is off", ->
|
||||
spyOn(NylasEnv.config, "get").andReturn false
|
||||
DraftStore._onSendDraft(@draft.clientId)
|
||||
advanceClock()
|
||||
expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds")
|
||||
expect(SoundRegistry.playSound).not.toHaveBeenCalled()
|
||||
|
||||
it "sets the sending state when sending", ->
|
||||
spyOn(NylasEnv, "isMainWindow").andReturn true
|
||||
DraftStore._onSendDraft(@draft.clientId)
|
||||
advanceClock()
|
||||
expect(DraftStore.isSendingDraft(@draft.clientId)).toBe true
|
||||
|
||||
# Since all changes haven't been applied yet, we want to ensure that
|
||||
|
|
@ -753,27 +758,19 @@ describe "DraftStore", ->
|
|||
runs ->
|
||||
expect(NylasEnv.close).not.toHaveBeenCalled()
|
||||
|
||||
it "queues the correct SendDraftTask", ->
|
||||
it "queues tasks to upload files and send the draft", ->
|
||||
runs ->
|
||||
DraftStore._onSendDraft(@draft.clientId)
|
||||
waitsFor ->
|
||||
DraftStore._doneWithSession.calls.length > 0
|
||||
runs ->
|
||||
expect(Actions.queueTask).toHaveBeenCalled()
|
||||
task = Actions.queueTask.calls[0].args[0]
|
||||
expect(task instanceof SendDraftTask).toBe true
|
||||
expect(task.draft).toBe @draft
|
||||
|
||||
it "queues a SendDraftTask", ->
|
||||
runs ->
|
||||
DraftStore._onSendDraft(@draft.clientId)
|
||||
waitsFor ->
|
||||
DraftStore._doneWithSession.calls.length > 0
|
||||
runs ->
|
||||
expect(Actions.queueTask).toHaveBeenCalled()
|
||||
task = Actions.queueTask.calls[0].args[0]
|
||||
expect(task instanceof SendDraftTask).toBe true
|
||||
expect(task.draft).toBe(@draft)
|
||||
saveAttachments = Actions.queueTask.calls[0].args[0]
|
||||
expect(saveAttachments instanceof SyncbackDraftFilesTask).toBe true
|
||||
expect(saveAttachments.draftClientId).toBe(@draft.clientId)
|
||||
sendDraft = Actions.queueTask.calls[1].args[0]
|
||||
expect(sendDraft instanceof SendDraftTask).toBe true
|
||||
expect(sendDraft.draftClientId).toBe(@draft.clientId)
|
||||
|
||||
it "resets the sending state if there's an error", ->
|
||||
spyOn(NylasEnv, "isMainWindow").andReturn false
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ describe 'FileUploadStore', ->
|
|||
.then =>
|
||||
expect(FileUploadStore._deleteUpload).toHaveBeenCalled()
|
||||
|
||||
describe "when a draft is sent", ->
|
||||
it "should delete its uploads directory", ->
|
||||
spyOn(FileUploadStore, '_deleteUploadsForClientId')
|
||||
Actions.sendDraftSuccess({messageClientId: '123'})
|
||||
expect(FileUploadStore._deleteUploadsForClientId).toHaveBeenCalledWith('123')
|
||||
|
||||
describe '_getFileStats', ->
|
||||
|
||||
it 'returns the correct stats', ->
|
||||
|
|
|
|||
117
spec/tasks/base-draft-task-spec.es6
Normal file
117
spec/tasks/base-draft-task-spec.es6
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import {
|
||||
Message,
|
||||
DatabaseStore,
|
||||
} from 'nylas-exports';
|
||||
|
||||
import BaseDraftTask from '../../src/flux/tasks/base-draft-task';
|
||||
|
||||
describe("BaseDraftTask", () => {
|
||||
describe("shouldDequeueOtherTask", () => {
|
||||
it("should dequeue instances of the same subclass for the same draft which are older", () => {
|
||||
class ATask extends BaseDraftTask {
|
||||
|
||||
}
|
||||
class BTask extends BaseDraftTask {
|
||||
|
||||
}
|
||||
|
||||
const A = new ATask('localid-A');
|
||||
A.sequentialId = 1;
|
||||
const B1 = new BTask('localid-A');
|
||||
B1.sequentialId = 2;
|
||||
const B2 = new BTask('localid-A');
|
||||
B2.sequentialId = 3;
|
||||
const BOther = new BTask('localid-other');
|
||||
BOther.sequentialId = 4;
|
||||
|
||||
expect(B1.shouldDequeueOtherTask(A)).toBe(false);
|
||||
expect(A.shouldDequeueOtherTask(B1)).toBe(false);
|
||||
|
||||
expect(B2.shouldDequeueOtherTask(B1)).toBe(true);
|
||||
expect(B1.shouldDequeueOtherTask(B2)).toBe(false);
|
||||
|
||||
expect(BOther.shouldDequeueOtherTask(B2)).toBe(false);
|
||||
expect(B2.shouldDequeueOtherTask(BOther)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDependentOnTask", () => {
|
||||
it("should always wait on older tasks for the same draft", () => {
|
||||
const A = new BaseDraftTask('localid-A');
|
||||
A.sequentialId = 1;
|
||||
const B = new BaseDraftTask('localid-A');
|
||||
B.sequentialId = 2;
|
||||
expect(B.isDependentOnTask(A)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not wait on newer tasks for the same draft", () => {
|
||||
const A = new BaseDraftTask('localid-A');
|
||||
A.sequentialId = 1;
|
||||
const B = new BaseDraftTask('localid-A');
|
||||
B.sequentialId = 2;
|
||||
expect(A.isDependentOnTask(B)).toBe(false)
|
||||
});
|
||||
|
||||
it("should not wait on older tasks for other drafts", () => {
|
||||
const A = new BaseDraftTask('localid-other');
|
||||
A.sequentialId = 1;
|
||||
const B = new BaseDraftTask('localid-A');
|
||||
B.sequentialId = 2;
|
||||
expect(A.isDependentOnTask(B)).toBe(false);
|
||||
expect(B.isDependentOnTask(A)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performLocal", () => {
|
||||
it("rejects if we we don't pass a draft", () => {
|
||||
const badTask = new BaseDraftTask(null)
|
||||
badTask.performLocal().then(() => {
|
||||
throw new Error("Shouldn't succeed")
|
||||
}).catch((err) => {
|
||||
expect(err.message).toBe("Attempt to call BaseDraftTask.performLocal without a draftClientId")
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshDraftReference", () => {
|
||||
it("should retrieve the draft by client ID, with the body, and assign it to @draft", () => {
|
||||
const draft = new Message({draft: true});
|
||||
const A = new BaseDraftTask('localid-other');
|
||||
spyOn(DatabaseStore, 'run').andReturn(Promise.resolve(draft));
|
||||
waitsForPromise(() => {
|
||||
return A.refreshDraftReference().then((resolvedValue) => {
|
||||
expect(A.draft).toEqual(draft);
|
||||
expect(resolvedValue).toEqual(draft);
|
||||
|
||||
const query = DatabaseStore.run.mostRecentCall.args[0];
|
||||
expect(query.sql()).toEqual("SELECT `Message`.`data`, IFNULL(`MessageBody`.`value`, '!NULLVALUE!') AS `body` FROM `Message` LEFT OUTER JOIN `MessageBody` ON `MessageBody`.`id` = `Message`.`id` WHERE `Message`.`client_id` = 'localid-other' ORDER BY `Message`.`date` ASC LIMIT 1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw a DraftNotFoundError error if it the response was no longer a draft", () => {
|
||||
const message = new Message({draft: false});
|
||||
const A = new BaseDraftTask('localid-other');
|
||||
spyOn(DatabaseStore, 'run').andReturn(Promise.resolve(message));
|
||||
waitsForPromise(() => {
|
||||
return A.refreshDraftReference().then(() => {
|
||||
throw new Error("Should not have resolved");
|
||||
}).catch((err) => {
|
||||
expect(err instanceof BaseDraftTask.DraftNotFoundError).toBe(true);
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw a DraftNotFoundError error if nothing was returned", () => {
|
||||
const A = new BaseDraftTask('localid-other');
|
||||
spyOn(DatabaseStore, 'run').andReturn(Promise.resolve(null));
|
||||
waitsForPromise(() => {
|
||||
return A.refreshDraftReference().then(() => {
|
||||
throw new Error("Should not have resolved");
|
||||
}).catch((err) => {
|
||||
expect(err instanceof BaseDraftTask.DraftNotFoundError).toBe(true);
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
_ = require 'underscore'
|
||||
fs = require 'fs'
|
||||
{APIError,
|
||||
Actions,
|
||||
DatabaseStore,
|
||||
|
|
@ -17,51 +16,27 @@ DBt = DatabaseTransaction.prototype
|
|||
|
||||
describe "SendDraftTask", ->
|
||||
|
||||
describe "isDependentOnTask", ->
|
||||
it "is not dependent on any pending SyncbackDraftTasks", ->
|
||||
draftA = new Message
|
||||
version: '1'
|
||||
clientId: 'localid-A'
|
||||
serverId: '1233123AEDF1'
|
||||
accountId: 'A12ADE'
|
||||
subject: 'New Draft'
|
||||
draft: true
|
||||
to:
|
||||
name: 'Dummy'
|
||||
email: 'dummy@nylas.com'
|
||||
|
||||
saveA = new SyncbackDraftTask(draftA, [])
|
||||
sendA = new SendDraftTask(draftA, [])
|
||||
|
||||
expect(sendA.isDependentOnTask(saveA)).toBe(false)
|
||||
|
||||
describe "performLocal", ->
|
||||
it "rejects if we we don't pass a draft", ->
|
||||
badTask = new SendDraftTask()
|
||||
badTask.performLocal().then ->
|
||||
describe "assertDraftValidity", ->
|
||||
it "rejects if there are still uploads on the draft", ->
|
||||
badTask = new SendDraftTask('1')
|
||||
badTask.draft = new Message(from: [new Contact(email: TEST_ACCOUNT_EMAIL)], accountId: TEST_ACCOUNT_ID, clientId: '1', uploads: ['123'])
|
||||
badTask.assertDraftValidity().then ->
|
||||
throw new Error("Shouldn't succeed")
|
||||
.catch (err) ->
|
||||
expect(err.message).toBe "SendDraftTask - must be provided a draft."
|
||||
|
||||
it "rejects if we we don't pass uploads", ->
|
||||
message = new Message(from: [new Contact(email: TEST_ACCOUNT_EMAIL)])
|
||||
message.uploads = null
|
||||
badTask = new SendDraftTask(message)
|
||||
badTask.performLocal().then ->
|
||||
throw new Error("Shouldn't succeed")
|
||||
.catch (err) ->
|
||||
expect(err.message).toBe "SendDraftTask - must be provided an array of uploads."
|
||||
expect(err.message).toBe "Files have been added since you started sending this draft. Double-check the draft and click 'Send' again.."
|
||||
|
||||
it "rejects if no from address is specified", ->
|
||||
badTask = new SendDraftTask(new Message(from: [], uploads: []))
|
||||
badTask.performLocal().then ->
|
||||
badTask = new SendDraftTask('1')
|
||||
badTask.draft = new Message(from: [], uploads: [], accountId: TEST_ACCOUNT_ID, clientId: '1')
|
||||
badTask.assertDraftValidity().then ->
|
||||
throw new Error("Shouldn't succeed")
|
||||
.catch (err) ->
|
||||
expect(err.message).toBe "SendDraftTask - you must populate `from` before sending."
|
||||
|
||||
it "rejects if the from address does not map to any account", ->
|
||||
badTask = new SendDraftTask(new Message(from: [new Contact(email: 'not-configured@nylas.com')], uploads: null))
|
||||
badTask.performLocal().then ->
|
||||
badTask = new SendDraftTask('1')
|
||||
badTask.draft = new Message(from: [new Contact(email: 'not-configured@nylas.com')], accountId: TEST_ACCOUNT_ID, clientId: '1')
|
||||
badTask.assertDraftValidity().then ->
|
||||
throw new Error("Shouldn't succeed")
|
||||
.catch (err) ->
|
||||
expect(err.message).toBe "SendDraftTask - you can only send drafts from a configured account."
|
||||
|
|
@ -84,13 +59,12 @@ describe "SendDraftTask", ->
|
|||
return Promise.resolve(@response)
|
||||
spyOn(NylasAPI, 'incrementRemoteChangeLock')
|
||||
spyOn(NylasAPI, 'decrementRemoteChangeLock')
|
||||
spyOn(NylasAPI, 'makeDraftDeletionRequest')
|
||||
spyOn(DBt, 'unpersistModel').andReturn Promise.resolve()
|
||||
spyOn(DBt, 'persistModel').andReturn Promise.resolve()
|
||||
spyOn(SoundRegistry, "playSound")
|
||||
spyOn(Actions, "postNotification")
|
||||
spyOn(Actions, "sendDraftSuccess")
|
||||
spyOn(Actions, "attachmentUploaded")
|
||||
spyOn(fs, 'createReadStream').andReturn "stub"
|
||||
|
||||
# The tests below are invoked twice, once with a new @draft and one with a
|
||||
# persisted @draft.
|
||||
|
|
@ -103,7 +77,7 @@ describe "SendDraftTask", ->
|
|||
expect(status).toBe Task.Status.Success
|
||||
|
||||
it "makes a send request with the correct data", ->
|
||||
waitsForPromise => @task._sendAndCreateMessage().then =>
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
expect(NylasAPI.makeRequest).toHaveBeenCalled()
|
||||
expect(NylasAPI.makeRequest.callCount).toBe(1)
|
||||
options = NylasAPI.makeRequest.mostRecentCall.args[0]
|
||||
|
|
@ -113,47 +87,43 @@ describe "SendDraftTask", ->
|
|||
expect(options.body).toEqual @draft.toJSON()
|
||||
|
||||
it "should pass returnsModel:false", ->
|
||||
waitsForPromise => @task._sendAndCreateMessage().then ->
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
expect(NylasAPI.makeRequest.calls.length).toBe(1)
|
||||
options = NylasAPI.makeRequest.mostRecentCall.args[0]
|
||||
expect(options.returnsModel).toBe(false)
|
||||
|
||||
it "should always send the draft body in the request body (joined attribute check)", ->
|
||||
waitsForPromise =>
|
||||
@task._sendAndCreateMessage().then =>
|
||||
expect(NylasAPI.makeRequest.calls.length).toBe(1)
|
||||
options = NylasAPI.makeRequest.mostRecentCall.args[0]
|
||||
expect(options.body.body).toBe('hello world')
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
expect(NylasAPI.makeRequest.calls.length).toBe(1)
|
||||
options = NylasAPI.makeRequest.mostRecentCall.args[0]
|
||||
expect(options.body.body).toBe('hello world')
|
||||
|
||||
describe "saving the sent message", ->
|
||||
it "should preserve the draft client id", ->
|
||||
waitsForPromise =>
|
||||
@task._sendAndCreateMessage().then =>
|
||||
expect(DBt.persistModel).toHaveBeenCalled()
|
||||
model = DBt.persistModel.mostRecentCall.args[0]
|
||||
expect(model.clientId).toEqual(@draft.clientId)
|
||||
expect(model.serverId).toEqual(@response.id)
|
||||
expect(model.draft).toEqual(false)
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
expect(DBt.persistModel).toHaveBeenCalled()
|
||||
model = DBt.persistModel.mostRecentCall.args[0]
|
||||
expect(model.clientId).toEqual(@draft.clientId)
|
||||
expect(model.serverId).toEqual(@response.id)
|
||||
expect(model.draft).toEqual(false)
|
||||
|
||||
it "should preserve metadata, but not version numbers", ->
|
||||
waitsForPromise =>
|
||||
@task._sendAndCreateMessage().then =>
|
||||
expect(DBt.persistModel).toHaveBeenCalled()
|
||||
model = DBt.persistModel.mostRecentCall.args[0]
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
expect(DBt.persistModel).toHaveBeenCalled()
|
||||
model = DBt.persistModel.mostRecentCall.args[0]
|
||||
|
||||
expect(model.pluginMetadata.length).toEqual(@draft.pluginMetadata.length)
|
||||
expect(model.pluginMetadata.length).toEqual(@draft.pluginMetadata.length)
|
||||
|
||||
for {pluginId, value, version} in @draft.pluginMetadata
|
||||
updated = model.metadataObjectForPluginId(pluginId)
|
||||
expect(updated.value).toEqual(value)
|
||||
expect(updated.version).toEqual(0)
|
||||
for {pluginId, value, version} in @draft.pluginMetadata
|
||||
updated = model.metadataObjectForPluginId(pluginId)
|
||||
expect(updated.value).toEqual(value)
|
||||
expect(updated.version).toEqual(0)
|
||||
|
||||
it "should notify the draft was sent", ->
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then =>
|
||||
args = Actions.sendDraftSuccess.calls[0].args[0]
|
||||
expect(args.message instanceof Message).toBe(true)
|
||||
expect(args.messageClientId).toBe(@draft.clientId)
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
args = Actions.sendDraftSuccess.calls[0].args[0]
|
||||
expect(args.message instanceof Message).toBe(true)
|
||||
expect(args.messageClientId).toBe(@draft.clientId)
|
||||
|
||||
it "should queue tasks to sync back the metadata on the new message", ->
|
||||
waitsForPromise =>
|
||||
|
|
@ -168,13 +138,13 @@ describe "SendDraftTask", ->
|
|||
|
||||
it "should play a sound", ->
|
||||
spyOn(NylasEnv.config, "get").andReturn true
|
||||
waitsForPromise => @task.performRemote().then ->
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds")
|
||||
expect(SoundRegistry.playSound).toHaveBeenCalledWith("send")
|
||||
|
||||
it "shouldn't play a sound if the config is disabled", ->
|
||||
spyOn(NylasEnv.config, "get").andReturn false
|
||||
waitsForPromise => @task.performRemote().then ->
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds")
|
||||
expect(SoundRegistry.playSound).not.toHaveBeenCalled()
|
||||
|
||||
|
|
@ -209,7 +179,7 @@ describe "SendDraftTask", ->
|
|||
|
||||
@draft.replyToMessageId = "reply-123"
|
||||
@draft.threadId = "thread-123"
|
||||
waitsForPromise => @task._sendAndCreateMessage(@draft).then =>
|
||||
waitsForPromise => @task.performRemote(@draft).then =>
|
||||
expect(NylasAPI.makeRequest).toHaveBeenCalled()
|
||||
expect(NylasAPI.makeRequest.callCount).toEqual 2
|
||||
req1 = NylasAPI.makeRequest.calls[0].args[0]
|
||||
|
|
@ -231,7 +201,7 @@ describe "SendDraftTask", ->
|
|||
|
||||
@draft.replyToMessageId = "reply-123"
|
||||
@draft.threadId = "thread-123"
|
||||
waitsForPromise => @task._sendAndCreateMessage(@draft).then =>
|
||||
waitsForPromise => @task.performRemote(@draft).then =>
|
||||
expect(NylasAPI.makeRequest).toHaveBeenCalled()
|
||||
expect(NylasAPI.makeRequest.callCount).toEqual 2
|
||||
req1 = NylasAPI.makeRequest.calls[0].args[0]
|
||||
|
|
@ -317,16 +287,14 @@ describe "SendDraftTask", ->
|
|||
describe "checking the promise chain halts on errors", ->
|
||||
beforeEach ->
|
||||
spyOn(NylasEnv, 'reportError')
|
||||
spyOn(@task,"_sendAndCreateMessage").andCallThrough()
|
||||
spyOn(@task,"_deleteRemoteDraft").andCallThrough()
|
||||
spyOn(@task,"_onSuccess").andCallThrough()
|
||||
spyOn(@task,"_onError").andCallThrough()
|
||||
spyOn(@task, "sendMessage").andCallThrough()
|
||||
spyOn(@task, "onSuccess").andCallThrough()
|
||||
spyOn(@task, "onError").andCallThrough()
|
||||
|
||||
@expectBlockedChain = =>
|
||||
expect(@task._sendAndCreateMessage).toHaveBeenCalled()
|
||||
expect(@task._deleteRemoteDraft).not.toHaveBeenCalled()
|
||||
expect(@task._onSuccess).not.toHaveBeenCalled()
|
||||
expect(@task._onError).toHaveBeenCalled()
|
||||
expect(@task.sendMessage).toHaveBeenCalled()
|
||||
expect(@task.onSuccess).not.toHaveBeenCalled()
|
||||
expect(@task.onError).toHaveBeenCalled()
|
||||
|
||||
it "halts on 500s", ->
|
||||
thrownError = new APIError(statusCode: 500, body: "err")
|
||||
|
|
@ -370,10 +338,9 @@ describe "SendDraftTask", ->
|
|||
waitsForPromise =>
|
||||
@task.performRemote().then (status) =>
|
||||
expect(status).toBe Task.Status.Success
|
||||
expect(@task._sendAndCreateMessage).toHaveBeenCalled()
|
||||
expect(@task._deleteRemoteDraft).toHaveBeenCalled()
|
||||
expect(@task._onSuccess).toHaveBeenCalled()
|
||||
expect(@task._onError).not.toHaveBeenCalled()
|
||||
expect(@task.sendMessage).toHaveBeenCalled()
|
||||
expect(@task.onSuccess).toHaveBeenCalled()
|
||||
expect(@task.onError).not.toHaveBeenCalled()
|
||||
|
||||
describe "with a new draft", ->
|
||||
beforeEach ->
|
||||
|
|
@ -391,9 +358,9 @@ describe "SendDraftTask", ->
|
|||
@draft.applyPluginMetadata('pluginIdB', {a: true, b: 2})
|
||||
@draft.metadataObjectForPluginId('pluginIdA').version = 2
|
||||
|
||||
@task = new SendDraftTask(@draft)
|
||||
@task = new SendDraftTask('client-id')
|
||||
@calledBody = "ERROR: The body wasn't included!"
|
||||
spyOn(DatabaseStore, "findBy").andCallFake =>
|
||||
spyOn(DatabaseStore, "run").andCallFake =>
|
||||
Promise.resolve(@draft)
|
||||
|
||||
sharedTests()
|
||||
|
|
@ -407,13 +374,6 @@ describe "SendDraftTask", ->
|
|||
expect(model.serverId).toBe @response.id
|
||||
expect(model.draft).toBe false
|
||||
|
||||
describe "deleteRemoteDraft", ->
|
||||
it "should not make an API request", ->
|
||||
waitsForPromise =>
|
||||
@task._deleteRemoteDraft(@draft).then =>
|
||||
expect(NylasAPI.makeRequest).not.toHaveBeenCalled()
|
||||
|
||||
|
||||
describe "with an existing persisted draft", ->
|
||||
beforeEach ->
|
||||
@draft = new Message
|
||||
|
|
@ -434,13 +394,18 @@ describe "SendDraftTask", ->
|
|||
@draft.applyPluginMetadata('pluginIdB', {a: true, b: 2})
|
||||
@draft.metadataObjectForPluginId('pluginIdA').version = 2
|
||||
|
||||
@task = new SendDraftTask(@draft)
|
||||
@task = new SendDraftTask('client-id')
|
||||
@calledBody = "ERROR: The body wasn't included!"
|
||||
spyOn(DatabaseStore, "findBy").andCallFake =>
|
||||
spyOn(DatabaseStore, "run").andCallFake =>
|
||||
Promise.resolve(@draft)
|
||||
|
||||
sharedTests()
|
||||
|
||||
it "should call makeDraftDeletionRequest to delete the draft after sending", ->
|
||||
@task.performLocal()
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
expect(NylasAPI.makeDraftDeletionRequest).toHaveBeenCalled()
|
||||
|
||||
it "should locally convert the existing draft to a message on send", ->
|
||||
expect(@draft.clientId).toBe @draft.clientId
|
||||
expect(@draft.serverId).toBe "server-123"
|
||||
|
|
@ -452,135 +417,3 @@ describe "SendDraftTask", ->
|
|||
expect(model.clientId).toBe @draft.clientId
|
||||
expect(model.serverId).toBe @response.id
|
||||
expect(model.draft).toBe false
|
||||
|
||||
describe "deleteRemoteDraft", ->
|
||||
it "should make an API request to delete the draft", ->
|
||||
@task.performLocal()
|
||||
waitsForPromise =>
|
||||
@task._deleteRemoteDraft(@draft).then =>
|
||||
expect(NylasAPI.makeRequest).toHaveBeenCalled()
|
||||
expect(NylasAPI.makeRequest.callCount).toBe 1
|
||||
req = NylasAPI.makeRequest.calls[0].args[0]
|
||||
expect(req.path).toBe "/drafts/#{@draft.serverId}"
|
||||
expect(req.accountId).toBe TEST_ACCOUNT_ID
|
||||
expect(req.method).toBe "DELETE"
|
||||
expect(req.returnsModel).toBe false
|
||||
|
||||
it "should increment the change tracker, preventing any further deltas about the draft", ->
|
||||
@task.performLocal()
|
||||
waitsForPromise =>
|
||||
@task._deleteRemoteDraft(@draft).then =>
|
||||
expect(NylasAPI.incrementRemoteChangeLock).toHaveBeenCalledWith(Message, @draft.serverId)
|
||||
|
||||
it "should continue if the request fails", ->
|
||||
jasmine.unspy(NylasAPI, "makeRequest")
|
||||
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
|
||||
Promise.reject(new APIError(body: "Boo", statusCode: 500))
|
||||
|
||||
@task.performLocal()
|
||||
waitsForPromise =>
|
||||
@task._deleteRemoteDraft(@draft)
|
||||
.then =>
|
||||
expect(NylasAPI.makeRequest).toHaveBeenCalled()
|
||||
expect(NylasAPI.makeRequest.callCount).toBe 1
|
||||
.catch =>
|
||||
throw new Error("Shouldn't fail the promise")
|
||||
|
||||
describe "with uploads", ->
|
||||
beforeEach ->
|
||||
@uploads = [
|
||||
{targetPath: '/test-file-1.png', size: 100},
|
||||
{targetPath: '/test-file-2.png', size: 100}
|
||||
]
|
||||
@draft = new Message
|
||||
version: 1
|
||||
clientId: 'client-id'
|
||||
accountId: TEST_ACCOUNT_ID
|
||||
from: [new Contact(email: TEST_ACCOUNT_EMAIL)]
|
||||
subject: 'New Draft'
|
||||
draft: true
|
||||
body: 'hello world'
|
||||
uploads: [].concat(@uploads)
|
||||
|
||||
@task = new SendDraftTask(@draft)
|
||||
jasmine.unspy(NylasAPI, 'makeRequest')
|
||||
|
||||
@resolves = []
|
||||
@resolveAll = =>
|
||||
resolve() for resolve in @resolves
|
||||
@resolves = []
|
||||
advanceClock()
|
||||
|
||||
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
|
||||
response = @response
|
||||
|
||||
if options.path is '/files'
|
||||
response = JSON.stringify([{
|
||||
id: '1234'
|
||||
account_id: TEST_ACCOUNT_ID
|
||||
filename: options.formData.file.options.filename
|
||||
}])
|
||||
|
||||
new Promise (resolve, reject) =>
|
||||
@resolves.push =>
|
||||
options.success?(response)
|
||||
resolve(response)
|
||||
|
||||
spyOn(DatabaseStore, 'findBy').andCallFake =>
|
||||
Promise.resolve(@draft)
|
||||
|
||||
it "should begin file uploads and not hit /send until they complete", ->
|
||||
@task.performRemote()
|
||||
advanceClock()
|
||||
|
||||
# uploads should be queued, but not the send
|
||||
expect(NylasAPI.makeRequest.callCount).toEqual(2)
|
||||
expect(NylasAPI.makeRequest.calls[0].args[0].formData).toEqual({ file : { value : 'stub', options : { filename : 'test-file-1.png' } } })
|
||||
expect(NylasAPI.makeRequest.calls[1].args[0].formData).toEqual({ file : { value : 'stub', options : { filename : 'test-file-2.png' } } })
|
||||
|
||||
# finish all uploads
|
||||
@resolveAll()
|
||||
|
||||
# send should now be queued
|
||||
expect(NylasAPI.makeRequest.callCount).toEqual(3)
|
||||
expect(NylasAPI.makeRequest.calls[2].args[0].path).toEqual('/send')
|
||||
|
||||
it "should convert the uploads to files", ->
|
||||
@task.performRemote()
|
||||
advanceClock()
|
||||
expect(@task.draft.files.length).toEqual(0)
|
||||
expect(@task.draft.uploads.length).toEqual(2)
|
||||
@resolves[0]()
|
||||
advanceClock()
|
||||
expect(@task.draft.files.length).toEqual(1)
|
||||
expect(@task.draft.uploads.length).toEqual(1)
|
||||
|
||||
{filename, accountId, id} = @task.draft.files[0]
|
||||
expect({filename, accountId, id}).toEqual({
|
||||
filename: 'test-file-1.png',
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
id: '1234'
|
||||
})
|
||||
|
||||
describe "cancel, during attachment upload", ->
|
||||
it "should make the task resolve early, before making the /send call", ->
|
||||
exitStatus = null
|
||||
@task.performRemote().then (status) => exitStatus = status
|
||||
advanceClock()
|
||||
@task.cancel()
|
||||
NylasAPI.makeRequest.reset()
|
||||
@resolveAll()
|
||||
advanceClock()
|
||||
expect(NylasAPI.makeRequest).not.toHaveBeenCalled()
|
||||
expect(exitStatus).toEqual(Task.Status.Continue)
|
||||
|
||||
describe "after the message sends", ->
|
||||
it "should notify the attachments were uploaded (so they can be deleted)", ->
|
||||
@task.performRemote()
|
||||
advanceClock()
|
||||
@resolveAll() # uploads
|
||||
@resolveAll() # send
|
||||
expect(Actions.attachmentUploaded).toHaveBeenCalled()
|
||||
expect(Actions.attachmentUploaded.callCount).toEqual(@uploads.length)
|
||||
for upload, idx in @uploads
|
||||
expect(Actions.attachmentUploaded.calls[idx].args[0]).toBe(upload)
|
||||
|
|
|
|||
95
spec/tasks/syncback-draft-files-task-spec.coffee
Normal file
95
spec/tasks/syncback-draft-files-task-spec.coffee
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
_ = require 'underscore'
|
||||
fs = require 'fs'
|
||||
{APIError,
|
||||
Actions,
|
||||
DatabaseStore,
|
||||
DatabaseTransaction,
|
||||
Message,
|
||||
Contact,
|
||||
Task,
|
||||
TaskQueue,
|
||||
SyncbackDraftFilesTask,
|
||||
NylasAPI,
|
||||
SoundRegistry} = require 'nylas-exports'
|
||||
|
||||
DBt = DatabaseTransaction.prototype
|
||||
|
||||
describe "SyncbackDraftFilesTask", ->
|
||||
describe "with uploads", ->
|
||||
beforeEach ->
|
||||
@uploads = [
|
||||
{targetPath: '/test-file-1.png', size: 100},
|
||||
{targetPath: '/test-file-2.png', size: 100}
|
||||
]
|
||||
@draft = new Message
|
||||
version: 1
|
||||
clientId: 'client-id'
|
||||
accountId: TEST_ACCOUNT_ID
|
||||
from: [new Contact(email: TEST_ACCOUNT_EMAIL)]
|
||||
subject: 'New Draft'
|
||||
draft: true
|
||||
body: 'hello world'
|
||||
uploads: [].concat(@uploads)
|
||||
|
||||
@task = new SyncbackDraftFilesTask(@draft.clientId)
|
||||
|
||||
@resolves = []
|
||||
@resolveAll = =>
|
||||
resolve() for resolve in @resolves
|
||||
@resolves = []
|
||||
advanceClock()
|
||||
|
||||
spyOn(DBt, 'persistModel')
|
||||
spyOn(fs, 'createReadStream').andReturn "stub"
|
||||
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
|
||||
response = @response
|
||||
|
||||
if options.path is '/files'
|
||||
response = JSON.stringify([{
|
||||
id: '1234'
|
||||
account_id: TEST_ACCOUNT_ID
|
||||
filename: options.formData.file.options.filename
|
||||
}])
|
||||
|
||||
new Promise (resolve, reject) =>
|
||||
@resolves.push =>
|
||||
options.success?(response)
|
||||
resolve(response)
|
||||
|
||||
spyOn(DatabaseStore, 'run').andCallFake =>
|
||||
Promise.resolve(@draft)
|
||||
|
||||
it "should begin file uploads and not resolve until they complete", ->
|
||||
taskPromise = @task.performRemote()
|
||||
advanceClock()
|
||||
|
||||
# uploads should be queued, but not the send
|
||||
expect(NylasAPI.makeRequest.callCount).toEqual(2)
|
||||
expect(NylasAPI.makeRequest.calls[0].args[0].formData).toEqual({ file : { value : 'stub', options : { filename : 'test-file-1.png' } } })
|
||||
expect(NylasAPI.makeRequest.calls[1].args[0].formData).toEqual({ file : { value : 'stub', options : { filename : 'test-file-2.png' } } })
|
||||
|
||||
# finish all uploads
|
||||
expect(taskPromise.isFulfilled()).toBe(false)
|
||||
@resolveAll()
|
||||
expect(taskPromise.isFulfilled()).toBe(true)
|
||||
|
||||
it "should update the draft, removing uploads and adding files", ->
|
||||
taskPromise = @task.performRemote()
|
||||
advanceClock()
|
||||
@resolveAll()
|
||||
advanceClock()
|
||||
expect(DBt.persistModel).toHaveBeenCalled()
|
||||
draft = DBt.persistModel.mostRecentCall.args[0]
|
||||
expect(draft.files.length).toBe(2)
|
||||
expect(draft.uploads.length).toBe(0)
|
||||
|
||||
it "should not interfere with other uploads added to the draft during task execution", ->
|
||||
taskPromise = @task.performRemote()
|
||||
advanceClock()
|
||||
@draft.uploads.push({targetPath: '/test-file-3.png', size: 100})
|
||||
@resolveAll()
|
||||
advanceClock()
|
||||
expect(DBt.persistModel).toHaveBeenCalled()
|
||||
draft = DBt.persistModel.mostRecentCall.args[0]
|
||||
expect(draft.files.length).toBe(2)
|
||||
expect(draft.uploads.length).toBe(1)
|
||||
|
|
@ -164,74 +164,30 @@ describe "SyncbackDraftTask", ->
|
|||
draft = remoteDraft()
|
||||
draft.pluginMetadata = [{pluginId: 1, value: {a: 1}}]
|
||||
task = new SyncbackDraftTask(draft.clientId)
|
||||
spyOn(task, 'getLatestLocalDraft').andReturn Promise.resolve(draft)
|
||||
spyOn(task, 'refreshDraftReference').andCallFake ->
|
||||
task.draft = draft
|
||||
Promise.resolve(draft)
|
||||
spyOn(Actions, 'queueTask')
|
||||
waitsForPromise =>
|
||||
task.updateLocalDraft(draft).then =>
|
||||
task.applyResponseToDraft(draft).then =>
|
||||
expect(Actions.queueTask).not.toHaveBeenCalled()
|
||||
|
||||
it "should save metadata associated to the draft when the draft is syncbacked for the first time", ->
|
||||
draft = localDraft()
|
||||
draft.pluginMetadata = [{pluginId: 1, value: {a: 1}}]
|
||||
task = new SyncbackDraftTask(draft.clientId)
|
||||
spyOn(task, 'getLatestLocalDraft').andReturn Promise.resolve(draft)
|
||||
spyOn(task, 'refreshDraftReference').andCallFake =>
|
||||
task.draft = draft
|
||||
Promise.resolve()
|
||||
spyOn(Actions, 'queueTask')
|
||||
waitsForPromise =>
|
||||
task.updateLocalDraft(draft).then =>
|
||||
task.applyResponseToDraft(draft).then =>
|
||||
metadataTask = Actions.queueTask.mostRecentCall.args[0]
|
||||
expect(metadataTask instanceof SyncbackMetadataTask).toBe true
|
||||
expect(metadataTask.clientId).toEqual draft.clientId
|
||||
expect(metadataTask.modelClassName).toEqual 'Message'
|
||||
expect(metadataTask.pluginId).toEqual 1
|
||||
|
||||
describe 'when `from` value does not match the account associated to the draft', ->
|
||||
beforeEach ->
|
||||
@serverId = 'remote123'
|
||||
@draft = remoteDraft()
|
||||
@draft.serverId = 'remote123'
|
||||
@draft.from = [{email: 'another@email.com'}]
|
||||
@task = new SyncbackDraftTask(@draft.clientId)
|
||||
jasmine.unspy(AccountStore, 'accountForEmail')
|
||||
spyOn(AccountStore, "accountForEmail").andReturn {id: 'other-account'}
|
||||
spyOn(Actions, "queueTask")
|
||||
spyOn(@task, 'getLatestLocalDraft').andReturn Promise.resolve(@draft)
|
||||
|
||||
it "should delete the remote draft if it was already saved", ->
|
||||
waitsForPromise =>
|
||||
@task.checkDraftFromMatchesAccount(@draft).then =>
|
||||
expect(NylasAPI.makeRequest).toHaveBeenCalled()
|
||||
params = NylasAPI.makeRequest.mostRecentCall.args[0]
|
||||
expect(params.method).toEqual "DELETE"
|
||||
expect(params.path).toEqual "/drafts/#{@serverId}"
|
||||
|
||||
it "should increment the change tracker for the deleted serverId, preventing any further deltas about the draft", ->
|
||||
waitsForPromise =>
|
||||
@task.checkDraftFromMatchesAccount(@draft).then =>
|
||||
expect(NylasAPI.incrementRemoteChangeLock).toHaveBeenCalledWith(Message, 'remote123')
|
||||
|
||||
it "should change the accountId and clear server fields", ->
|
||||
waitsForPromise =>
|
||||
@task.checkDraftFromMatchesAccount(@draft).then (updatedDraft) =>
|
||||
expect(updatedDraft.serverId).toBeUndefined()
|
||||
expect(updatedDraft.version).toBeUndefined()
|
||||
expect(updatedDraft.threadId).toBeUndefined()
|
||||
expect(updatedDraft.replyToMessageId).toBeUndefined()
|
||||
expect(updatedDraft.accountId).toEqual 'other-account'
|
||||
|
||||
it "should syncback any metadata associated with the original draft", ->
|
||||
@draft.pluginMetadata = [{pluginId: 1, value: {a: 1}}]
|
||||
@task = new SyncbackDraftTask(@draft.clientId)
|
||||
spyOn(@task, 'getLatestLocalDraft').andReturn Promise.resolve(@draft)
|
||||
spyOn(@task, 'saveDraft').andCallFake (d) -> Promise.resolve(d)
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then =>
|
||||
metadataTask = Actions.queueTask.mostRecentCall.args[0]
|
||||
expect(metadataTask instanceof SyncbackMetadataTask).toBe true
|
||||
expect(metadataTask.clientId).toEqual @draft.clientId
|
||||
expect(metadataTask.modelClassName).toEqual 'Message'
|
||||
expect(metadataTask.pluginId).toEqual 1
|
||||
|
||||
|
||||
describe "When the api throws errors", ->
|
||||
stubAPI = (code, method) ->
|
||||
spyOn(NylasAPI, "makeRequest").andCallFake (opts) ->
|
||||
|
|
@ -245,7 +201,9 @@ describe "SyncbackDraftTask", ->
|
|||
|
||||
beforeEach ->
|
||||
@task = new SyncbackDraftTask("removeDraftId")
|
||||
spyOn(@task, "getLatestLocalDraft").andCallFake -> Promise.resolve(remoteDraft())
|
||||
spyOn(@task, 'refreshDraftReference').andCallFake =>
|
||||
@task.draft = remoteDraft()
|
||||
Promise.resolve()
|
||||
|
||||
NylasAPI.PermanentErrorCodes.forEach (code) ->
|
||||
it "fails on API status code #{code}", ->
|
||||
|
|
@ -253,8 +211,8 @@ describe "SyncbackDraftTask", ->
|
|||
waitsForPromise =>
|
||||
@task.performRemote().then ([status, err]) =>
|
||||
expect(status).toBe Task.Status.Failed
|
||||
expect(@task.getLatestLocalDraft).toHaveBeenCalled()
|
||||
expect(@task.getLatestLocalDraft.calls.length).toBe 1
|
||||
expect(@task.refreshDraftReference).toHaveBeenCalled()
|
||||
expect(@task.refreshDraftReference.calls.length).toBe 1
|
||||
expect(err.statusCode).toBe code
|
||||
|
||||
[NylasAPI.TimeoutErrorCode].forEach (code) ->
|
||||
|
|
@ -269,5 +227,5 @@ describe "SyncbackDraftTask", ->
|
|||
waitsForPromise =>
|
||||
@task.performRemote().then ([status, err]) =>
|
||||
expect(status).toBe Task.Status.Failed
|
||||
expect(@task.getLatestLocalDraft).toHaveBeenCalled()
|
||||
expect(@task.getLatestLocalDraft.calls.length).toBe 1
|
||||
expect(@task.refreshDraftReference).toHaveBeenCalled()
|
||||
expect(@task.refreshDraftReference.calls.length).toBe 1
|
||||
|
|
|
|||
|
|
@ -376,6 +376,7 @@ class Actions
|
|||
```
|
||||
###
|
||||
@sendDraft: ActionScopeWindow
|
||||
@ensureDraftSynced: ActionScopeWindow
|
||||
|
||||
###
|
||||
Public: Destroys the draft with the given ID. This Action is handled by the {DraftStore},
|
||||
|
|
@ -471,7 +472,6 @@ class Actions
|
|||
@addAttachment: ActionScopeWindow
|
||||
@selectAttachment: ActionScopeWindow
|
||||
@removeAttachment: ActionScopeWindow
|
||||
@attachmentUploaded: ActionScopeWindow
|
||||
|
||||
@fetchAndOpenFile: ActionScopeWindow
|
||||
@fetchAndSaveFile: ActionScopeWindow
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ _ = require 'underscore'
|
|||
request = require 'request'
|
||||
Utils = require './models/utils'
|
||||
Account = require './models/account'
|
||||
Message = require './models/message'
|
||||
Actions = require './actions'
|
||||
{APIError} = require './errors'
|
||||
PriorityUICoordinator = require '../priority-ui-coordinator'
|
||||
|
|
@ -279,7 +280,7 @@ class NylasAPI
|
|||
# problems downstream when we try to write to the database.
|
||||
uniquedJSONs = _.uniq jsons, false, (model) -> model.id
|
||||
if uniquedJSONs.length < jsons.length
|
||||
console.warn("NylasAPI.handleModelResponse: called with non-unique object set. Maybe an API request returned the same object more than once?")
|
||||
console.warn("NylasAPI::handleModelResponse: called with non-unique object set. Maybe an API request returned the same object more than once?")
|
||||
|
||||
# Step 2: Filter out any objects we've locked (usually because we successfully)
|
||||
# deleted them moments ago.
|
||||
|
|
@ -375,6 +376,17 @@ class NylasAPI
|
|||
if requestSuccess
|
||||
requestSuccess(jsons)
|
||||
|
||||
makeDraftDeletionRequest: (draft) ->
|
||||
return unless draft.serverId
|
||||
@incrementRemoteChangeLock(Message, draft.serverId)
|
||||
@makeRequest
|
||||
path: "/drafts/#{draft.serverId}"
|
||||
accountId: draft.accountId
|
||||
method: "DELETE"
|
||||
body: {version: draft.version}
|
||||
returnsModel: false
|
||||
return
|
||||
|
||||
incrementRemoteChangeLock: (klass, id) ->
|
||||
@_lockTracker.increment(klass, id)
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ class DraftChangeSet
|
|||
|
||||
applyToModel: (model) =>
|
||||
if model
|
||||
model.fromJSON(@_saving)
|
||||
model.fromJSON(@_pending)
|
||||
model[key] = val for key, val of @_saving
|
||||
model[key] = val for key, val of @_pending
|
||||
model
|
||||
|
||||
###
|
||||
|
|
@ -92,8 +92,7 @@ class DraftStoreProxy
|
|||
@changes = new DraftChangeSet(@_changeSetTrigger, @_changeSetCommit)
|
||||
|
||||
if draft
|
||||
@_setDraft(draft)
|
||||
@_draftPromise = Promise.resolve(@)
|
||||
@_draftPromise = @_setDraft(draft)
|
||||
|
||||
@prepare()
|
||||
|
||||
|
|
@ -115,9 +114,7 @@ class DraftStoreProxy
|
|||
@_draftPromise ?= DatabaseStore.findBy(Message, clientId: @draftClientId).include(Message.attributes.body).then (draft) =>
|
||||
return Promise.reject(new Error("Draft has been destroyed.")) if @_destroyed
|
||||
return Promise.reject(new Error("Assertion Failure: Draft #{@draftClientId} not found.")) if not draft
|
||||
@_setDraft(draft)
|
||||
Promise.resolve(@)
|
||||
@_draftPromise
|
||||
return @_setDraft(draft)
|
||||
|
||||
teardown: ->
|
||||
@stopListeningToAll()
|
||||
|
|
@ -133,8 +130,20 @@ class DraftStoreProxy
|
|||
# to send with an empty body?"
|
||||
if draft.pristine
|
||||
@_draftPristineBody = draft.body
|
||||
@_draft = draft
|
||||
@trigger()
|
||||
|
||||
# Reverse draft transformations performed by third-party plugins when the draft
|
||||
# was last saved to disk
|
||||
DraftStore = require './draft-store'
|
||||
|
||||
return Promise.each DraftStore.extensions(), (ext) ->
|
||||
if ext.applyTransformsToDraft and ext.unapplyTransformsToDraft
|
||||
Promise.resolve(ext.unapplyTransformsToDraft({draft})).then (untransformed) ->
|
||||
unless untransformed is 'unnecessary'
|
||||
draft = untransformed
|
||||
.then =>
|
||||
@_draft = draft
|
||||
@trigger()
|
||||
Promise.resolve(@)
|
||||
|
||||
_onDraftChanged: (change) ->
|
||||
return if not change?
|
||||
|
|
@ -184,8 +193,7 @@ class DraftStoreProxy
|
|||
# once they have a serverId we sync them periodically here.
|
||||
#
|
||||
return unless @_draft.serverId
|
||||
|
||||
Actions.queueTask(new SyncbackDraftTask(@draftClientId))
|
||||
Actions.ensureDraftSynced(@draftClientId)
|
||||
|
||||
|
||||
DraftStoreProxy.DraftChangeSet = DraftChangeSet
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ moment = require 'moment'
|
|||
|
||||
{ipcRenderer} = require 'electron'
|
||||
|
||||
NylasAPI = require '../nylas-api'
|
||||
DraftStoreProxy = require './draft-store-proxy'
|
||||
DatabaseStore = require './database-store'
|
||||
AccountStore = require './account-store'
|
||||
|
|
@ -12,7 +13,10 @@ TaskQueueStatusStore = require './task-queue-status-store'
|
|||
FocusedPerspectiveStore = require './focused-perspective-store'
|
||||
FocusedContentStore = require './focused-content-store'
|
||||
|
||||
BaseDraftTask = require '../tasks/base-draft-task'
|
||||
SendDraftTask = require '../tasks/send-draft-task'
|
||||
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'
|
||||
|
|
@ -71,6 +75,7 @@ class DraftStore
|
|||
# Remember that these two actions only fire in the current window and
|
||||
# are picked up by the instance of the DraftStore in the current
|
||||
# window.
|
||||
@listenTo Actions.ensureDraftSynced, @_onEnsureDraftSynced
|
||||
@listenTo Actions.sendDraft, @_onSendDraft
|
||||
@listenTo Actions.destroyDraft, @_onDestroyDraft
|
||||
|
||||
|
|
@ -171,7 +176,7 @@ class DraftStore
|
|||
# window.close() within on onbeforeunload could do weird things.
|
||||
for key, session of @_draftSessions
|
||||
if session.draft()?.pristine
|
||||
Actions.queueTask(new DestroyDraftTask(draftClientId: session.draftClientId))
|
||||
Actions.queueTask(new DestroyDraftTask(session.draftClientId))
|
||||
else
|
||||
promises.push(session.changes.commit())
|
||||
|
||||
|
|
@ -492,55 +497,87 @@ class DraftStore
|
|||
if session
|
||||
@_doneWithSession(session)
|
||||
|
||||
# Stop any pending SendDraftTasks
|
||||
# Stop any pending tasks related ot the draft
|
||||
for task in TaskQueueStatusStore.queue()
|
||||
if task instanceof SendDraftTask and task.draft.clientId is draftClientId
|
||||
if task instanceof BaseDraftTask and task.draftClientId is draftClientId
|
||||
Actions.dequeueTask(task.id)
|
||||
|
||||
# Queue the task to destroy the draft
|
||||
Actions.queueTask(new DestroyDraftTask(draftClientId: draftClientId))
|
||||
Actions.queueTask(new DestroyDraftTask(draftClientId))
|
||||
|
||||
NylasEnv.close() if @_isPopout()
|
||||
|
||||
# The user request to send the draft
|
||||
_onSendDraft: (draftClientId) =>
|
||||
if NylasEnv.config.get("core.sending.sounds")
|
||||
SoundRegistry.playSound('hit-send')
|
||||
_onEnsureDraftSynced: (draftClientId) =>
|
||||
@sessionForClientId(draftClientId).then (session) =>
|
||||
@_prepareForSyncback(session).then =>
|
||||
Actions.queueTask(new SyncbackDraftFilesTask(draftClientId))
|
||||
Actions.queueTask(new SyncbackDraftTask(draftClientId))
|
||||
|
||||
_onSendDraft: (draftClientId) =>
|
||||
@_draftsSending[draftClientId] = true
|
||||
|
||||
# It's important NOT to call `trigger(draftClientId)` here. At this
|
||||
# point there are still unpersisted changes in the DraftStoreProxy. If
|
||||
# we `trigger`, we'll briefly display the wrong version of the draft
|
||||
# as if it was sending.
|
||||
@sessionForClientId(draftClientId)
|
||||
.then(@_runExtensionsBeforeSend)
|
||||
.then (session) =>
|
||||
# Immediately save any pending changes so we don't save after
|
||||
# sending
|
||||
#
|
||||
# We do NOT queue a final {SyncbackDraftTask} before sending because
|
||||
# we're going to send the full raw body with the Send are are about
|
||||
# to delete the draft anyway.
|
||||
#
|
||||
# We do, however, need to ensure that all of the pending changes are
|
||||
# committed to the Database since we'll look them up again just
|
||||
# before send.
|
||||
session.changes.commit(noSyncback: true).then =>
|
||||
draft = session.draft()
|
||||
Actions.queueTask(new SendDraftTask(draft))
|
||||
@sessionForClientId(draftClientId).then (session) =>
|
||||
@_prepareForSyncback(session).then =>
|
||||
if NylasEnv.config.get("core.sending.sounds")
|
||||
SoundRegistry.playSound('hit-send')
|
||||
Actions.queueTask(new SyncbackDraftFilesTask(draftClientId))
|
||||
Actions.queueTask(new SendDraftTask(draftClientId))
|
||||
@_doneWithSession(session)
|
||||
|
||||
NylasEnv.close() if @_isPopout()
|
||||
if @_isPopout()
|
||||
NylasEnv.close()
|
||||
|
||||
_isPopout: ->
|
||||
NylasEnv.getWindowType() is "composer"
|
||||
|
||||
# Give third-party plugins an opportunity to sanitize draft data
|
||||
_runExtensionsBeforeSend: (session) =>
|
||||
_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._finalizeForSending - 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']
|
||||
|
||||
Promise.each @extensions(), (ext) ->
|
||||
ext.finalizeSessionBeforeSending?({session})
|
||||
.return(session)
|
||||
extApply = ext.applyTransformsToDraft
|
||||
extUnapply = ext.unapplyTransformsToDraft
|
||||
unless extApply and extUnapply
|
||||
return Promise.resolve()
|
||||
|
||||
draft = session.draft().clone()
|
||||
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("An extension applied a tranform to the draft that it could not reverse."))
|
||||
session.changes.add(_.pick(transformed, allowedFields))
|
||||
|
||||
.then =>
|
||||
session.changes.commit(noSyncback: true)
|
||||
|
||||
_onRemoveFile: ({file, messageClientId}) =>
|
||||
@sessionForClientId(messageClientId).then (session) ->
|
||||
|
|
|
|||
|
|
@ -32,10 +32,12 @@ class FileUploadStore extends NylasStore
|
|||
@listenTo Actions.addAttachment, @_onAddAttachment
|
||||
@listenTo Actions.selectAttachment, @_onSelectAttachment
|
||||
@listenTo Actions.removeAttachment, @_onRemoveAttachment
|
||||
@listenTo Actions.attachmentUploaded, @_onAttachmentUploaded
|
||||
@listenTo DatabaseStore, @_onDataChanged
|
||||
|
||||
mkdirp.sync(UPLOAD_DIR)
|
||||
if NylasEnv.isMainWindow() or NylasEnv.inSpecMode()
|
||||
@listenTo Actions.sendDraftSuccess, ({messageClientId}) =>
|
||||
@_deleteUploadsForClientId(messageClientId)
|
||||
|
||||
# Handlers
|
||||
|
||||
|
|
@ -79,10 +81,6 @@ class FileUploadStore extends NylasStore
|
|||
|
||||
@_deleteUpload(upload).catch(@_onAttachFileError)
|
||||
|
||||
_onAttachmentUploaded: (upload) ->
|
||||
return Promise.resolve() unless upload
|
||||
@_deleteUpload(upload)
|
||||
|
||||
_onAttachFileError: (error) ->
|
||||
NylasEnv.showErrorDialog(error.message)
|
||||
|
||||
|
|
@ -136,6 +134,11 @@ class FileUploadStore extends NylasStore
|
|||
.catch ->
|
||||
Promise.reject(new Error("Error cleaning up file #{upload.filename}"))
|
||||
|
||||
_deleteUploadsForClientId: (messageClientId) =>
|
||||
rimraf = require('rimraf')
|
||||
rimraf path.join(UPLOAD_DIR, messageClientId), {disableGlob: true}, (err) =>
|
||||
console.warn(err) if err
|
||||
|
||||
_applySessionChanges: (messageClientId, changeFunction) =>
|
||||
DraftStore.sessionForClientId(messageClientId).then (session) =>
|
||||
uploads = changeFunction(session.draft().uploads)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import NylasStore from 'nylas-store';
|
||||
import SendDraftTask from '../tasks/send-draft-task';
|
||||
import SyncbackDraftTask from '../tasks/syncback-draft-task';
|
||||
import TaskQueueStatusStore from './task-queue-status-store';
|
||||
|
||||
class OutboxStore extends NylasStore {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._tasks = [];
|
||||
|
|
@ -12,9 +12,9 @@ class OutboxStore extends NylasStore {
|
|||
}
|
||||
|
||||
_populate() {
|
||||
const nextTasks = TaskQueueStatusStore.queue().filter((task)=> {
|
||||
return task instanceof SendDraftTask;
|
||||
});
|
||||
const nextTasks = TaskQueueStatusStore.queue().filter((task) =>
|
||||
(task instanceof SendDraftTask) || (task instanceof SyncbackDraftTask)
|
||||
);
|
||||
if ((this._tasks.length === 0) && (nextTasks.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -23,9 +23,7 @@ class OutboxStore extends NylasStore {
|
|||
}
|
||||
|
||||
itemsForAccount(accountId) {
|
||||
return this._tasks.filter((task)=> {
|
||||
return task.draft.accountId === accountId;
|
||||
});
|
||||
return this._tasks.filter((task) => task.draftAccountId === accountId);
|
||||
}
|
||||
}
|
||||
module.exports = new OutboxStore();
|
||||
|
|
|
|||
57
src/flux/tasks/base-draft-task.coffee
Normal file
57
src/flux/tasks/base-draft-task.coffee
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
_ = require 'underscore'
|
||||
DatabaseStore = require '../stores/database-store'
|
||||
Task = require './task'
|
||||
Message = require '../models/message'
|
||||
{APIError} = require '../errors'
|
||||
|
||||
class DraftNotFoundError extends Error
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
class BaseDraftTask extends Task
|
||||
|
||||
constructor: (@draftClientId) ->
|
||||
@draft = null
|
||||
super
|
||||
|
||||
shouldDequeueOtherTask: (other) ->
|
||||
isSameDraft = other.draftClientId is @draftClientId
|
||||
isOlderTask = other.sequentialId < @sequentialId
|
||||
isExactClass = other.constructor.name is @constructor.name
|
||||
return isSameDraft and isOlderTask and isExactClass
|
||||
|
||||
isDependentOnTask: (other) ->
|
||||
# Set this task to be dependent on any SyncbackDraftTasks and
|
||||
# SendDraftTasks for the same draft that were created first.
|
||||
# This, in conjunction with this method on SendDraftTask, ensures
|
||||
# that a send and a syncback never run at the same time for a draft.
|
||||
|
||||
# Require here rather than on top to avoid a circular dependency
|
||||
isSameDraft = other.draftClientId is @draftClientId
|
||||
isOlderTask = other.sequentialId < @sequentialId
|
||||
isSaveOrSend = other instanceof BaseDraftTask
|
||||
|
||||
return isSameDraft and isOlderTask and isSaveOrSend
|
||||
|
||||
performLocal: ->
|
||||
# SyncbackDraftTask does not do anything locally. You should persist your changes
|
||||
# to the local database directly or using a DraftStoreProxy, and then queue a
|
||||
# SyncbackDraftTask to send those changes to the server.
|
||||
if not @draftClientId
|
||||
errMsg = "Attempt to call #{@constructor.name}.performLocal without a draftClientId"
|
||||
return Promise.reject(new Error(errMsg))
|
||||
Promise.resolve()
|
||||
|
||||
refreshDraftReference: =>
|
||||
DatabaseStore
|
||||
.findBy(Message, clientId: @draftClientId)
|
||||
.include(Message.attributes.body)
|
||||
.then (message) =>
|
||||
unless message and message.draft
|
||||
return Promise.reject(new DraftNotFoundError())
|
||||
@draft = message
|
||||
return Promise.resolve(message)
|
||||
|
||||
|
||||
BaseDraftTask.DraftNotFoundError = DraftNotFoundError
|
||||
module.exports = BaseDraftTask
|
||||
|
|
@ -4,32 +4,21 @@ Message = require '../models/message'
|
|||
DatabaseStore = require '../stores/database-store'
|
||||
Actions = require '../actions'
|
||||
NylasAPI = require '../nylas-api'
|
||||
|
||||
SyncbackDraftTask = require './syncback-draft-task'
|
||||
SendDraftTask = require './send-draft-task'
|
||||
BaseDraftTask = require './base-draft-task'
|
||||
|
||||
module.exports =
|
||||
class DestroyDraftTask extends Task
|
||||
constructor: ({@draftClientId} = {}) ->
|
||||
super
|
||||
class DestroyDraftTask extends BaseDraftTask
|
||||
constructor: (@draftClientId) ->
|
||||
super(@draftClientId)
|
||||
|
||||
shouldDequeueOtherTask: (other) ->
|
||||
(other instanceof DestroyDraftTask and other.draftClientId is @draftClientId) or
|
||||
(other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId) or
|
||||
(other instanceof SendDraftTask and other.draftClientId is @draftClientId)
|
||||
|
||||
isDependentOnTask: (other) ->
|
||||
(other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId)
|
||||
other instanceof BaseDraftTask and other.draftClientId is @draftClientId
|
||||
|
||||
performLocal: ->
|
||||
unless @draftClientId
|
||||
return Promise.reject(new Error("Attempt to call DestroyDraftTask.performLocal without draftClientId"))
|
||||
|
||||
DatabaseStore.findBy(Message, clientId: @draftClientId).include(Message.attributes.body).then (draft) =>
|
||||
return Promise.resolve() unless draft
|
||||
@draft = draft
|
||||
super
|
||||
@refreshDraftReference().then =>
|
||||
DatabaseStore.inTransaction (t) =>
|
||||
t.unpersistModel(draft)
|
||||
t.unpersistModel(@draft)
|
||||
|
||||
performRemote: ->
|
||||
# We don't need to do anything if we weren't able to find the draft
|
||||
|
|
|
|||
|
|
@ -3,76 +3,45 @@ fs = require 'fs'
|
|||
path = require 'path'
|
||||
Task = require './task'
|
||||
Actions = require '../actions'
|
||||
File = require '../models/file'
|
||||
Message = require '../models/message'
|
||||
NylasAPI = require '../nylas-api'
|
||||
TaskQueue = require '../stores/task-queue'
|
||||
{APIError} = require '../errors'
|
||||
SoundRegistry = require '../../sound-registry'
|
||||
DatabaseStore = require '../stores/database-store'
|
||||
AccountStore = require '../stores/account-store'
|
||||
BaseDraftTask = require './base-draft-task'
|
||||
SyncbackMetadataTask = require './syncback-metadata-task'
|
||||
SyncbackDraftTask = require './syncback-draft-task'
|
||||
|
||||
class MultiRequestProgressMonitor
|
||||
|
||||
constructor: ->
|
||||
@_requests = {}
|
||||
@_expected = {}
|
||||
|
||||
add: (filepath, filesize, request) =>
|
||||
@_requests[filepath] = request
|
||||
@_expected[filepath] = filesize ? fs.statSync(filepath)["size"] ? 0
|
||||
|
||||
remove: (filepath) =>
|
||||
delete @_requests[filepath]
|
||||
delete @_expected[filepath]
|
||||
|
||||
requests: =>
|
||||
_.values(@_requests)
|
||||
|
||||
value: =>
|
||||
sent = 0
|
||||
expected = 1
|
||||
for filepath, request of @_requests
|
||||
sent += request.req?.connection?._bytesDispatched ? 0
|
||||
expected += @_expected[filepath]
|
||||
|
||||
return sent / expected
|
||||
|
||||
module.exports =
|
||||
class SendDraftTask extends Task
|
||||
class SendDraftTask extends BaseDraftTask
|
||||
|
||||
constructor: (@draft) ->
|
||||
constructor: (@draftClientId) ->
|
||||
@uploaded = []
|
||||
@draft = null
|
||||
@message = null
|
||||
super
|
||||
|
||||
label: ->
|
||||
"Sending message..."
|
||||
|
||||
shouldDequeueOtherTask: (other) ->
|
||||
# A new send action should knock any other sends that are not
|
||||
# currently executing out of the queue. It should also knock out
|
||||
# any SyncbackDraftTasks - running these concurrently with a send
|
||||
# results in weird behavior.
|
||||
(other instanceof SendDraftTask and other.draft.clientId is @draft.clientId) or
|
||||
(other instanceof SyncbackDraftTask and other.draftClientId is @draft.clientId)
|
||||
performRemote: ->
|
||||
@refreshDraftReference()
|
||||
.then(@assertDraftValidity)
|
||||
.then(@sendMessage)
|
||||
.then (responseJSON) =>
|
||||
@message = new Message().fromJSON(responseJSON)
|
||||
@message.clientId = @draft.clientId
|
||||
@message.draft = false
|
||||
@message.clonePluginMetadataFrom(@draft)
|
||||
|
||||
DatabaseStore.inTransaction (t) =>
|
||||
@refreshDraftReference().then =>
|
||||
t.persistModel(@message)
|
||||
|
||||
isDependentOnTask: (other) ->
|
||||
# Set this task to be dependent on any SyncbackDraftTasks for the
|
||||
# same draft that were created first, to ensure this task does not
|
||||
# execute at the same time as a syncback. Works in conjunction with
|
||||
# similar restrictions in this method on the SyncbackDraftTask.
|
||||
other instanceof SyncbackDraftTask and
|
||||
other.draftClientId is @draft.clientId and
|
||||
other.sequentialId < @sequentialId
|
||||
.then(@onSuccess)
|
||||
.catch(@onError)
|
||||
|
||||
performLocal: ->
|
||||
unless @draft and @draft instanceof Message
|
||||
return Promise.reject(new Error("SendDraftTask - must be provided a draft."))
|
||||
unless @draft.uploads and @draft.uploads instanceof Array
|
||||
return Promise.reject(new Error("SendDraftTask - must be provided an array of uploads."))
|
||||
assertDraftValidity: =>
|
||||
unless @draft.from[0]
|
||||
return Promise.reject(new Error("SendDraftTask - you must populate `from` before sending."))
|
||||
|
||||
|
|
@ -80,74 +49,17 @@ class SendDraftTask extends Task
|
|||
unless account
|
||||
return Promise.reject(new Error("SendDraftTask - you can only send drafts from a configured account."))
|
||||
|
||||
if account.id isnt @draft.accountId
|
||||
@draft.accountId = account.id
|
||||
delete @draft.serverId
|
||||
delete @draft.version
|
||||
delete @draft.threadId
|
||||
delete @draft.replyToMessageId
|
||||
unless @draft.accountId is account.id
|
||||
return Promise.reject(new Error("The from address has changed since you started sending this draft. Double-check the draft and click 'Send' again."))
|
||||
|
||||
Promise.resolve()
|
||||
if @draft.uploads and @draft.uploads.length > 0
|
||||
return Promise.reject(new Error("Files have been added since you started sending this draft. Double-check the draft and click 'Send' again.."))
|
||||
|
||||
performRemote: ->
|
||||
@_uploadAttachments().then =>
|
||||
return Promise.resolve(Task.Status.Continue) if @_cancelled
|
||||
@_sendAndCreateMessage()
|
||||
.then(@_deleteRemoteDraft)
|
||||
.then(@_onSuccess)
|
||||
.catch(@_onError)
|
||||
|
||||
cancel: =>
|
||||
# Note that you can only cancel during the uploadAttachments phase. Once
|
||||
# we hit sendAndCreateMessage, nothing checks the cancelled bit and
|
||||
# performRemote will continue through to success.
|
||||
@_cancelled = true
|
||||
for request in @_attachmentUploadsMonitor.requests()
|
||||
request.abort()
|
||||
@
|
||||
|
||||
_uploadAttachments: =>
|
||||
@_attachmentUploadsMonitor = new MultiRequestProgressMonitor()
|
||||
Object.defineProperty(@, 'progress', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: => @_attachmentUploadsMonitor.value()
|
||||
})
|
||||
|
||||
Promise.all @draft.uploads.map (upload) =>
|
||||
{targetPath, size} = upload
|
||||
|
||||
formData =
|
||||
file: # Must be named `file` as per the Nylas API spec
|
||||
value: fs.createReadStream(targetPath)
|
||||
options:
|
||||
filename: path.basename(targetPath)
|
||||
|
||||
NylasAPI.makeRequest
|
||||
path: "/files"
|
||||
accountId: @draft.accountId
|
||||
method: "POST"
|
||||
json: false
|
||||
formData: formData
|
||||
started: (req) =>
|
||||
@_attachmentUploadsMonitor.add(targetPath, size, req)
|
||||
timeout: 20 * 60 * 1000
|
||||
.finally =>
|
||||
@_attachmentUploadsMonitor.remove(targetPath)
|
||||
.then (rawResponseString) =>
|
||||
json = JSON.parse(rawResponseString)
|
||||
file = (new File).fromJSON(json[0])
|
||||
@uploaded.push(upload)
|
||||
@draft.uploads.splice(@draft.uploads.indexOf(upload), 1)
|
||||
@draft.files.push(file)
|
||||
|
||||
# Note: We don't actually delete uploaded files until send completes,
|
||||
# because it's possible for the app to quit without saving state and
|
||||
# need to re-upload the file.
|
||||
return Promise.resolve()
|
||||
|
||||
# This function returns a promise that resolves to the draft when the draft has
|
||||
# been sent successfully.
|
||||
_sendAndCreateMessage: =>
|
||||
sendMessage: =>
|
||||
NylasAPI.makeRequest
|
||||
path: "/send"
|
||||
accountId: @draft.accountId
|
||||
|
|
@ -160,58 +72,18 @@ class SendDraftTask extends Task
|
|||
# If the message you're "replying to" were deleted
|
||||
if err.message?.indexOf('Invalid message public id') is 0
|
||||
@draft.replyToMessageId = null
|
||||
return @_sendAndCreateMessage()
|
||||
return @sendMessage()
|
||||
|
||||
# If the thread was deleted
|
||||
else if err.message?.indexOf('Invalid thread') is 0
|
||||
@draft.threadId = null
|
||||
@draft.replyToMessageId = null
|
||||
return @_sendAndCreateMessage()
|
||||
return @sendMessage()
|
||||
|
||||
else
|
||||
return Promise.reject(err)
|
||||
|
||||
.then (newMessageJSON) =>
|
||||
@message = new Message().fromJSON(newMessageJSON)
|
||||
@message.clientId = @draft.clientId
|
||||
@message.draft = false
|
||||
# Create new metadata objs on the message based on the existing ones in the draft
|
||||
@message.clonePluginMetadataFrom(@draft)
|
||||
|
||||
return DatabaseStore.inTransaction (t) =>
|
||||
DatabaseStore.findBy(Message, {clientId: @draft.clientId})
|
||||
.then (draft) =>
|
||||
t.persistModel(@message).then =>
|
||||
Promise.resolve(draft)
|
||||
|
||||
|
||||
# We DON'T need to delete the local draft because we turn it into a message
|
||||
# by writing the new message into the database with the same clientId.
|
||||
#
|
||||
# We DO, need to make sure that the remote draft has been cleaned up.
|
||||
#
|
||||
_deleteRemoteDraft: ({accountId, version, serverId}) =>
|
||||
return Promise.resolve() unless serverId
|
||||
NylasAPI.incrementRemoteChangeLock(Message, serverId)
|
||||
NylasAPI.makeRequest
|
||||
path: "/drafts/#{serverId}"
|
||||
accountId: accountId
|
||||
method: "DELETE"
|
||||
body: {version}
|
||||
returnsModel: false
|
||||
.catch APIError, (err) =>
|
||||
# If the draft failed to delete remotely, we don't really care. It
|
||||
# shouldn't stop the send draft task from continuing.
|
||||
|
||||
# Deliberately do not decrement the change count so that deltas about
|
||||
# this (deleted) draft are ignored.
|
||||
Promise.resolve()
|
||||
|
||||
_onSuccess: =>
|
||||
# Delete attachments from the uploads folder
|
||||
for upload in @uploaded
|
||||
Actions.attachmentUploaded(upload)
|
||||
|
||||
onSuccess: =>
|
||||
# Queue a task to save metadata on the message
|
||||
@message.pluginMetadata.forEach((m)=>
|
||||
task = new SyncbackMetadataTask(@message.clientId, @message.constructor.name, m.pluginId)
|
||||
|
|
@ -219,6 +91,7 @@ class SendDraftTask extends Task
|
|||
)
|
||||
|
||||
Actions.sendDraftSuccess(message: @message, messageClientId: @message.clientId)
|
||||
NylasAPI.makeDraftDeletionRequest(@draft)
|
||||
|
||||
# Play the sending sound
|
||||
if NylasEnv.config.get("core.sending.sounds")
|
||||
|
|
@ -226,10 +99,16 @@ class SendDraftTask extends Task
|
|||
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
|
||||
_onError: (err) =>
|
||||
if err instanceof APIError and not (err.statusCode in NylasAPI.PermanentErrorCodes)
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
else
|
||||
onError: (err) =>
|
||||
if err instanceof BaseDraftTask.DraftNotFoundError
|
||||
return Promise.resolve(Task.Status.Continue)
|
||||
|
||||
message = err.message
|
||||
|
||||
if err instanceof APIError
|
||||
if err.statusCode not in NylasAPI.PermanentErrorCodes
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
|
||||
message = "Sorry, this message could not be sent. Please try again, and make sure your message is addressed correctly and is not too large."
|
||||
if err.statusCode is 402 and err.body.message
|
||||
if err.body.message.indexOf('at least one recipient') isnt -1
|
||||
|
|
@ -239,9 +118,9 @@ class SendDraftTask extends Task
|
|||
if err.body.server_error
|
||||
message += "\n\n" + err.body.server_error
|
||||
|
||||
Actions.draftSendingFailed
|
||||
threadId: @draft.threadId
|
||||
draftClientId: @draft.clientId,
|
||||
errorMessage: message
|
||||
NylasEnv.reportError(err)
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
Actions.draftSendingFailed
|
||||
threadId: @draft.threadId
|
||||
draftClientId: @draft.clientId,
|
||||
errorMessage: message
|
||||
NylasEnv.reportError(err)
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
|
|
|
|||
87
src/flux/tasks/syncback-draft-files-task.coffee
Normal file
87
src/flux/tasks/syncback-draft-files-task.coffee
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
_ = require 'underscore'
|
||||
fs = require 'fs'
|
||||
path = require 'path'
|
||||
|
||||
Task = require './task'
|
||||
Actions = require '../actions'
|
||||
{APIError} = require '../errors'
|
||||
File = require '../models/file'
|
||||
NylasAPI = require '../nylas-api'
|
||||
Message = require '../models/message'
|
||||
BaseDraftTask = require './base-draft-task'
|
||||
DatabaseStore = require '../stores/database-store'
|
||||
MultiRequestProgressMonitor = require '../../multi-request-progress-monitor'
|
||||
|
||||
module.exports =
|
||||
class SyncbackDraftFilesTask extends BaseDraftTask
|
||||
|
||||
constructor: (@draftClientId) ->
|
||||
super(@draftClientId)
|
||||
@_appliedUploads = null
|
||||
@_appliedFiles = null
|
||||
|
||||
label: ->
|
||||
"Uploading attachments..."
|
||||
|
||||
performRemote: ->
|
||||
@refreshDraftReference()
|
||||
.then(@uploadAttachments)
|
||||
.then(@applyChangesToDraft)
|
||||
.thenReturn(Task.Status.Success)
|
||||
.catch (err) =>
|
||||
if err instanceof BaseDraftTask.DraftNotFoundError
|
||||
return Promise.resolve(Task.Status.Continue)
|
||||
if err instanceof APIError and not (err.statusCode in NylasAPI.PermanentErrorCodes)
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
|
||||
uploadAttachments: =>
|
||||
@_attachmentUploadsMonitor = new MultiRequestProgressMonitor()
|
||||
Object.defineProperty(@, 'progress', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: => @_attachmentUploadsMonitor.value()
|
||||
})
|
||||
|
||||
uploaded = [].concat(@draft.uploads)
|
||||
Promise.all(uploaded.map(@uploadAttachment)).then (files) =>
|
||||
# Note: We don't actually delete uploaded files until send completes,
|
||||
# because it's possible for the app to quit without saving state and
|
||||
# need to re-upload the file.
|
||||
@_appliedUploads = uploaded
|
||||
@_appliedFiles = files
|
||||
|
||||
uploadAttachment: (upload) =>
|
||||
{targetPath, size} = upload
|
||||
|
||||
formData =
|
||||
file: # Must be named `file` as per the Nylas API spec
|
||||
value: fs.createReadStream(targetPath)
|
||||
options:
|
||||
filename: path.basename(targetPath)
|
||||
|
||||
NylasAPI.makeRequest
|
||||
path: "/files"
|
||||
accountId: @draft.accountId
|
||||
method: "POST"
|
||||
json: false
|
||||
formData: formData
|
||||
started: (req) =>
|
||||
@_attachmentUploadsMonitor.add(targetPath, size, req)
|
||||
timeout: 20 * 60 * 1000
|
||||
.finally =>
|
||||
@_attachmentUploadsMonitor.remove(targetPath)
|
||||
.then (rawResponseString) =>
|
||||
json = JSON.parse(rawResponseString)
|
||||
file = (new File).fromJSON(json[0])
|
||||
Promise.resolve(file)
|
||||
|
||||
applyChangesToDraft: =>
|
||||
DatabaseStore.inTransaction (t) =>
|
||||
@refreshDraftReference().then =>
|
||||
@draft.files = @draft.files.concat(@_appliedFiles)
|
||||
if @draft.uploads instanceof Array
|
||||
uploadedPaths = @_appliedUploads.map (upload) => upload.targetPath
|
||||
@draft.uploads = @draft.uploads.filter (upload) =>
|
||||
upload.targetPath not in uploadedPaths
|
||||
t.persistModel(@draft)
|
||||
|
|
@ -1,137 +1,70 @@
|
|||
_ = require 'underscore'
|
||||
|
||||
Actions = require '../actions'
|
||||
AccountStore = require '../stores/account-store'
|
||||
DatabaseStore = require '../stores/database-store'
|
||||
TaskQueueStatusStore = require '../stores/task-queue-status-store'
|
||||
NylasAPI = require '../nylas-api'
|
||||
fs = require 'fs'
|
||||
path = require 'path'
|
||||
|
||||
Task = require './task'
|
||||
Actions = require '../actions'
|
||||
DatabaseStore = require '../stores/database-store'
|
||||
TaskQueueStatusStore = require '../stores/task-queue-status-store'
|
||||
MultiRequestProgressMonitor = require '../../multi-request-progress-monitor'
|
||||
NylasAPI = require '../nylas-api'
|
||||
|
||||
BaseDraftTask = require './base-draft-task'
|
||||
SyncbackMetadataTask = require './syncback-metadata-task'
|
||||
{APIError} = require '../errors'
|
||||
Message = require '../models/message'
|
||||
Account = require '../models/account'
|
||||
|
||||
class DraftNotFoundError extends Error
|
||||
|
||||
module.exports =
|
||||
class SyncbackDraftTask extends Task
|
||||
|
||||
constructor: (@draftClientId) ->
|
||||
super
|
||||
|
||||
shouldDequeueOtherTask: (other) ->
|
||||
# A new syncback action should knock any other syncbacks that are
|
||||
# not currently executing out of the queue.
|
||||
other instanceof SyncbackDraftTask and
|
||||
other.draftClientId is @draftClientId and
|
||||
other.sequentialId <= @sequentialId
|
||||
|
||||
isDependentOnTask: (other) ->
|
||||
# Set this task to be dependent on any SyncbackDraftTasks and
|
||||
# SendDraftTasks for the same draft that were created first.
|
||||
# This, in conjunction with this method on SendDraftTask, ensures
|
||||
# that a send and a syncback never run at the same time for a draft.
|
||||
|
||||
# Require here rather than on top to avoid a circular dependency
|
||||
SendDraftTask = require './send-draft-task'
|
||||
|
||||
(other instanceof SyncbackDraftTask and
|
||||
other.draftClientId is @draftClientId and
|
||||
other.sequentialId < @sequentialId) or
|
||||
(other instanceof SendDraftTask and
|
||||
other.draft.clientId is @draftClientId and
|
||||
other.sequentialId < @sequentialId)
|
||||
|
||||
performLocal: ->
|
||||
# SyncbackDraftTask does not do anything locally. You should persist your changes
|
||||
# to the local database directly or using a DraftStoreProxy, and then queue a
|
||||
# SyncbackDraftTask to send those changes to the server.
|
||||
if not @draftClientId
|
||||
errMsg = "Attempt to call SyncbackDraftTask.performLocal without @draftClientId"
|
||||
return Promise.reject(new Error(errMsg))
|
||||
Promise.resolve()
|
||||
class SyncbackDraftTask extends BaseDraftTask
|
||||
|
||||
performRemote: ->
|
||||
@getLatestLocalDraft().then (draft) =>
|
||||
return Promise.resolve() unless draft
|
||||
@refreshDraftReference()
|
||||
.then =>
|
||||
if @draft.serverId
|
||||
requestPath = "/drafts/#{@draft.serverId}"
|
||||
requestMethod = 'PUT'
|
||||
else
|
||||
requestPath = "/drafts"
|
||||
requestMethod = 'POST'
|
||||
|
||||
@checkDraftFromMatchesAccount(draft)
|
||||
.then(@saveDraft)
|
||||
.then(@updateLocalDraft)
|
||||
NylasAPI.makeRequest
|
||||
accountId: @draft.accountId
|
||||
path: requestPath
|
||||
method: requestMethod
|
||||
body: @draft.toJSON()
|
||||
returnsModel: false
|
||||
.then(@applyResponseToDraft)
|
||||
.thenReturn(Task.Status.Success)
|
||||
.catch (err) =>
|
||||
if err instanceof APIError and not (err.statusCode in NylasAPI.PermanentErrorCodes)
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
|
||||
saveDraft: (draft) =>
|
||||
if draft.serverId
|
||||
path = "/drafts/#{draft.serverId}"
|
||||
method = 'PUT'
|
||||
else
|
||||
path = "/drafts"
|
||||
method = 'POST'
|
||||
.catch (err) =>
|
||||
if err instanceof DraftNotFoundError
|
||||
return Promise.resolve(Task.Status.Continue)
|
||||
if err instanceof APIError and not (err.statusCode in NylasAPI.PermanentErrorCodes)
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
|
||||
NylasAPI.makeRequest
|
||||
accountId: draft.accountId
|
||||
path: path
|
||||
method: method
|
||||
body: draft.toJSON()
|
||||
returnsModel: false
|
||||
|
||||
updateLocalDraft: ({version, id, thread_id}) =>
|
||||
applyResponseToDraft: (response) =>
|
||||
# Important: There could be a significant delay between us initiating the save
|
||||
# and getting JSON back from the server. Our local copy of the draft may have
|
||||
# already changed more.
|
||||
#
|
||||
# The only fields we want to update from the server are the `id` and `version`.
|
||||
#
|
||||
draftIsNew = false
|
||||
draftWasCreated = false
|
||||
|
||||
DatabaseStore.inTransaction (t) =>
|
||||
@getLatestLocalDraft().then (draft) =>
|
||||
# Draft may have been deleted. Oh well.
|
||||
return Promise.resolve() unless draft
|
||||
if draft.serverId isnt id
|
||||
draft.threadId = thread_id
|
||||
draft.serverId = id
|
||||
draftIsNew = true
|
||||
draft.version = version
|
||||
t.persistModel(draft).then =>
|
||||
Promise.resolve(draft)
|
||||
.then (draft) =>
|
||||
if draftIsNew
|
||||
for {pluginId, value} in draft.pluginMetadata
|
||||
task = new SyncbackMetadataTask(@draftClientId, draft.constructor.name, pluginId)
|
||||
@refreshDraftReference().then =>
|
||||
if @draft.serverId isnt response.id
|
||||
@draft.threadId = response.thread_id
|
||||
@draft.serverId = response.id
|
||||
draftWasCreated = true
|
||||
@draft.version = response.version
|
||||
t.persistModel(@draft)
|
||||
|
||||
.then =>
|
||||
if draftWasCreated
|
||||
for {pluginId, value} in @draft.pluginMetadata
|
||||
task = new SyncbackMetadataTask(@draftClientId, @draft.constructor.name, pluginId)
|
||||
Actions.queueTask(task)
|
||||
return true
|
||||
|
||||
getLatestLocalDraft: =>
|
||||
DatabaseStore.findBy(Message, clientId: @draftClientId).include(Message.attributes.body)
|
||||
.then (message) ->
|
||||
if not message?.draft
|
||||
return Promise.resolve()
|
||||
return Promise.resolve(message)
|
||||
|
||||
checkDraftFromMatchesAccount: (draft) ->
|
||||
account = AccountStore.accountForEmail(draft.from[0].email)
|
||||
if draft.accountId is account.id
|
||||
return Promise.resolve(draft)
|
||||
else
|
||||
if draft.serverId
|
||||
NylasAPI.incrementRemoteChangeLock(Message, draft.serverId)
|
||||
NylasAPI.makeRequest
|
||||
path: "/drafts/#{draft.serverId}"
|
||||
accountId: draft.accountId
|
||||
method: "DELETE"
|
||||
body: {version: draft.version}
|
||||
returnsModel: false
|
||||
|
||||
draft.accountId = account.id
|
||||
delete draft.serverId
|
||||
delete draft.version
|
||||
delete draft.threadId
|
||||
delete draft.replyToMessageId
|
||||
DatabaseStore.inTransaction (t) =>
|
||||
t.persistModel(draft)
|
||||
.thenReturn(draft)
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ class NylasExports
|
|||
@require "SyncbackCategoryTask", 'flux/tasks/syncback-category-task'
|
||||
@require "DestroyCategoryTask", 'flux/tasks/destroy-category-task'
|
||||
@require "ChangeUnreadTask", 'flux/tasks/change-unread-task'
|
||||
@require "SyncbackDraftFilesTask", 'flux/tasks/syncback-draft-files-task'
|
||||
@require "SyncbackDraftTask", 'flux/tasks/syncback-draft-task'
|
||||
@require "ChangeStarredTask", 'flux/tasks/change-starred-task'
|
||||
@require "DestroyModelTask", 'flux/tasks/destroy-model-task'
|
||||
|
|
|
|||
27
src/multi-request-progress-monitor.coffee
Normal file
27
src/multi-request-progress-monitor.coffee
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
class MultiRequestProgressMonitor
|
||||
|
||||
constructor: ->
|
||||
@_requests = {}
|
||||
@_expected = {}
|
||||
|
||||
add: (filepath, filesize, request) =>
|
||||
@_requests[filepath] = request
|
||||
@_expected[filepath] = filesize ? fs.statSync(filepath)["size"] ? 0
|
||||
|
||||
remove: (filepath) =>
|
||||
delete @_requests[filepath]
|
||||
delete @_expected[filepath]
|
||||
|
||||
requests: =>
|
||||
_.values(@_requests)
|
||||
|
||||
value: =>
|
||||
sent = 0
|
||||
expected = 1
|
||||
for filepath, request of @_requests
|
||||
sent += request.req?.connection?._bytesDispatched ? 0
|
||||
expected += @_expected[filepath]
|
||||
|
||||
return sent / expected
|
||||
|
||||
module.exports = MultiRequestProgressMonitor
|
||||
Loading…
Add table
Reference in a new issue