Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions app/internal_packages/account-sidebar/lib/sidebar-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
CategoryStore,
Actions,
RegExpUtils,
DragDropTypes,
localized,
TaskQueue,
} from 'mailspring-exports';
Expand Down Expand Up @@ -281,7 +282,7 @@ export default class SidebarItem {
onCollapseToggled: toggleItemCollapsed,

onDrop(item, event) {
const jsonString = event.dataTransfer.getData('mailspring-threads-data');
const jsonString = event.dataTransfer.getData(DragDropTypes.ThreadsDragType);
let jsonData = null;
try {
jsonData = JSON.parse(jsonString);
Expand All @@ -297,7 +298,7 @@ export default class SidebarItem {
shouldAcceptDrop(item, event) {
const target = item.perspective;
const current = FocusedPerspectiveStore.current();
if (!event.dataTransfer.types.includes('mailspring-threads-data')) {
if (!event.dataTransfer.types.includes(DragDropTypes.ThreadsDragType)) {
return false;
}
if (target.isEqual(current)) {
Expand All @@ -306,10 +307,7 @@ export default class SidebarItem {

// We can't inspect the drag payload until drop, so we use a dataTransfer
// type to encode the account IDs of threads currently being dragged.
const accountsType = event.dataTransfer.types.find((t) =>
t.startsWith('mailspring-accounts=')
);
const accountIds = (accountsType || '').replace('mailspring-accounts=', '').split(',');
const accountIds = DragDropTypes.accountIdsForDragTypes(event.dataTransfer.types);
return target.canReceiveThreadsFromAccountIds(accountIds);
},

Expand Down
18 changes: 15 additions & 3 deletions app/internal_packages/composer/lib/attachments-area.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import React from 'react';
import { Actions, AttachmentStore, Message } from 'mailspring-exports';
import { AttachmentItem } from 'mailspring-component-kit';
import { localized, Actions, AttachmentStore, Message } from 'mailspring-exports';
import { AttachmentItem, Spinner } from 'mailspring-component-kit';

export const AttachmentsArea: React.FunctionComponent<{ draft: Message }> = (props) => {
export const AttachmentsArea: React.FunctionComponent<{
draft: Message;
// True while files dropped on the composer are still being prepared. They
// aren't on the draft yet, so a placeholder stands in to show the drop was
// accepted.
attaching?: boolean;
}> = (props) => {
const { files, headerMessageId } = props.draft;

return (
Expand All @@ -20,6 +26,12 @@ export const AttachmentsArea: React.FunctionComponent<{ draft: Message }> = (pro
onRemoveAttachment={() => Actions.removeAttachment(headerMessageId, file)}
/>
))}
{props.attaching && (
<div className="attaching-messages">
<Spinner visible />
<span>{localized('Attaching…')}</span>
</div>
)}
</div>
);
};
80 changes: 78 additions & 2 deletions app/internal_packages/composer/lib/composer-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
DraftStore,
DraftEditingSession,
MessageWithEditorState,
DragDropTypes,
EmlUtils,
File,
} from 'mailspring-exports';
import { webUtils } from 'electron';
Expand Down Expand Up @@ -41,6 +43,7 @@ interface ComposerViewState {
quotedTextHidden: boolean;
quotedTextPresent: boolean;
isDropping: boolean;
attachingThreadCount: number;
}
// The ComposerView is a unique React component because it (currently) is a
// singleton. Normally, the React way to do things would be to re-render the
Expand Down Expand Up @@ -78,6 +81,7 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com

this.state = {
isDropping: false,
attachingThreadCount: 0,
quotedTextPresent: hasBlockquote(draft.bodyEditorState),
quotedTextHidden: hideQuotedTextByDefault(draft),
};
Expand Down Expand Up @@ -202,7 +206,7 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
</>
)}

<AttachmentsArea draft={draft} />
<AttachmentsArea draft={draft} attaching={this.state.attachingThreadCount > 0} />
</div>
<div className="composer-footer-region">
<InjectedComponentSet
Expand Down Expand Up @@ -278,7 +282,11 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
const hasNativeFile = event.dataTransfer.types.includes('Files');
const hasNonNativeFilePath = nonNativeFilePath !== null;

return hasNativeFile || hasNonNativeFilePath;
return hasNativeFile || hasNonNativeFilePath || this._hasThreadsForDrop(event);
};

_hasThreadsForDrop = (event: React.DragEvent<HTMLDivElement>) => {
return event.dataTransfer.types.includes(DragDropTypes.ThreadsDragType);
};

_nonNativeFilePathForDrop = (event: React.DragEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -313,6 +321,74 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
this._onFileReceived(uri);
event.preventDefault();
}

// dataTransfer is only valid for the duration of this handler, so read the
// payload now and hand the ids off to an async worker.
if (this._hasThreadsForDrop(event)) {
this._onThreadsReceived(event.dataTransfer.getData(DragDropTypes.ThreadsDragType));
event.preventDefault();
}
};

_onThreadsReceived = async (json: string) => {
let threadIds: string[] = [];
try {
threadIds = JSON.parse(json).threadIds || [];
} catch (err) {
return;
}
if (!threadIds.length) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failing Functional · Self-thread drop guard removed

The self-drop guard that was here in the first commit (threadIds = threadIds.filter((id) => id !== this.props.draft.threadId);) was removed in aabcf6c and not replaced. For a reply draft, draft.threadId is set, so dragging that same thread onto the composer now stages its newest non-draft message and attaches the conversation to itself — the "almost always an accident" case the earlier commit deliberately suppressed, and which the PR description still lists ("Filters out drops of threads onto themselves"). If this removal was intentional, update the description; otherwise restore the filter. New-compose drafts have a null threadId, so they're unaffected either way.

return;
}

// Fetching the raw message from the sync engine is a remote round trip, so
// show a placeholder in the attachments area until the files land.
const dropCount = threadIds.length;
this.setState((state) => ({ attachingThreadCount: state.attachingThreadCount + dropCount }));

let staged: Array<{ filePath: string }> = [];
let unavailableThreadIds: string[] = [];
try {
({ staged, unavailableThreadIds } = await EmlUtils.stageThreadsAsEml(threadIds));
} catch (err) {
AppEnv.reportError(err);
} finally {
if (this._mounted) {
this.setState((state) => ({
attachingThreadCount: state.attachingThreadCount - dropCount,
}));
}
}

if (!this._mounted) {
return;
}
for (const { filePath } of staged) {
Actions.addAttachment({
filePath,
headerMessageId: this.props.draft.headerMessageId,
// The attachment store copies the file into its own directory before
// this fires, so the staged copy is free to go.
onCreated: () => EmlUtils.discardStagedEml(filePath),
});
}

// A thread with nothing exportable in it and a fetch that failed are
// different problems, and only the second one is worth retrying.
const problems = [];
if (unavailableThreadIds.length) {
problems.push(
localized('One or more of the conversations have no message that can be attached.')
);
}
if (staged.length < threadIds.length - unavailableThreadIds.length) {
problems.push(
localized('One or more of the original messages could not be downloaded. Please try again.')
);
}
if (problems.length) {
AppEnv.showErrorDialog(problems.join('\n\n'));
}
};

_onFileReceived = (filePath: string) => {
Expand Down
23 changes: 23 additions & 0 deletions app/internal_packages/composer/styles/composer.less
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,29 @@ body.platform-win32 {
}
}

// Placeholder shown while a thread dragged onto the composer is being fetched
// from the sync engine and written out as a .eml file. Indented to line up
// with .nylas-attachment-item, which it sits alongside.
.attaching-messages {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you align this with the real attachment items better?

Image

display: flex;
align-items: center;
margin: 0 0 @spacing-standard @spacing-standard;
color: @text-color-subtle;
font-size: @font-size-small;

.spinner {
width: auto;
margin-right: @spacing-half;

> div {
width: 6px;
height: 6px;
margin-right: 2px;
margin-left: 0;
}
}
}

// Overrides for the full-window popout composer
.composer-full-window {
width: 100%;
Expand Down
28 changes: 6 additions & 22 deletions app/internal_packages/message-list/lib/message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,29 +150,12 @@ class MessageList extends React.Component<Record<string, unknown>, MessageListSt
if (!message || !this.state.currentThread) {
return;
}
const pathModule = require('path');
const fs = require('fs');

// Use a unique subdirectory per operation so concurrent forwards don't
// race on the same file. The basename stays "Forwarded Message.eml" so
// the attachment has a clean display name.
const tempDir = pathModule.join(
require('@electron/remote').app.getPath('temp'),
`mailspring-fwd-${message.id}`
);
fs.mkdirSync(tempDir, { recursive: true });
const tempPath = pathModule.join(tempDir, 'Forwarded Message.eml');

const task = new GetMessageRFC2822Task({
messageId: message.id,
accountId: message.accountId,
filepath: tempPath,
const staged = await EmlUtils.stageMessageAsEml(message, {
filename: 'Forwarded Message.eml',
});
Actions.queueTask(task);
await TaskQueue.waitForPerformRemote(task);

// Verify the file was actually written before creating a draft
if (!fs.existsSync(tempPath)) {
// The fetch is remote and can fail without writing anything
if (!staged) {
AppEnv.showErrorDialog(
localized('Could not download the original message. Please try again.')
);
Expand All @@ -191,9 +174,10 @@ class MessageList extends React.Component<Record<string, unknown>, MessageListSt
await TaskQueue.waitForPerformLocal(syncTask);

Actions.addAttachment({
filePath: tempPath,
filePath: staged.filePath,
headerMessageId: draft.headerMessageId,
onCreated: () => {
EmlUtils.discardStagedEml(staged.filePath);
Actions.composePopoutDraft(draft.headerMessageId);
},
});
Expand Down
45 changes: 11 additions & 34 deletions app/internal_packages/thread-list/lib/thread-list-context-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,35 +165,17 @@ export default class ThreadListContextMenu {
label: localized('Forward as Attachment'),
click: async () => {
const thread = this.threads[0];
const messages = await DatabaseStore.findAll<Message>(Message, { threadId: thread.id })
.order(Message.attributes.date.descending())
.limit(1);
if (!messages.length) return;

const message = messages[0];
const pathModule = require('path');
const fs = require('fs');
const tempDir = pathModule.join(
require('@electron/remote').app.getPath('temp'),
`mailspring-fwd-${message.id}`
);
fs.mkdirSync(tempDir, { recursive: true });
const tempPath = pathModule.join(tempDir, 'Forwarded Message.eml');

const task = new GetMessageRFC2822Task({
messageId: message.id,
accountId: message.accountId,
filepath: tempPath,
const staged = await EmlUtils.stageThreadAsEml(thread.id, {
filename: 'Forwarded Message.eml',
});
Actions.queueTask(task);
await TaskQueue.waitForPerformRemote(task);

if (!fs.existsSync(tempPath)) {
if (!staged) {
AppEnv.showErrorDialog(
localized('Could not download the original message. Please try again.')
);
return;
}
const { message, filePath } = staged;

const account = AccountStore.accountForId(message.accountId);
const draft = await DraftFactory.createDraft({
Expand All @@ -207,9 +189,10 @@ export default class ThreadListContextMenu {
await TaskQueue.waitForPerformLocal(syncTask);

Actions.addAttachment({
filePath: tempPath,
filePath,
headerMessageId: draft.headerMessageId,
onCreated: () => {
EmlUtils.discardStagedEml(filePath);
Actions.composePopoutDraft(draft.headerMessageId);
},
});
Expand Down Expand Up @@ -340,9 +323,7 @@ export default class ThreadListContextMenu {
click: async () => {
if (this.threadIds.length === 1) {
const thread = this.threads[0];
const messages = await DatabaseStore.findAll<Message>(Message, { threadId: thread.id })
.order(Message.attributes.date.descending())
.limit(1);
const messages = await EmlUtils.newestExportableMessagesForThreadIds([thread.id]);
if (!messages.length) return;

const message = messages[0];
Expand All @@ -369,15 +350,11 @@ export default class ThreadListContextMenu {
const outputDir = selected[0];
const path = require('path');

for (const thread of this.threads) {
const messages = await DatabaseStore.findAll<Message>(Message, {
threadId: thread.id,
})
.order(Message.attributes.date.descending())
.limit(1);
if (!messages.length) continue;
const messages = await EmlUtils.newestExportableMessagesForThreadIds(
this.threads.map((t) => t.id)
);

const message = messages[0];
for (const message of messages) {
const filename = EmlUtils.defaultEmlFilename(message.subject);

const task = new GetMessageRFC2822Task({
Expand Down
8 changes: 6 additions & 2 deletions app/internal_packages/thread-list/lib/thread-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Actions,
Utils,
CanvasUtils,
DragDropTypes,
ChangeStarredTask,
ChangeFolderTask,
ChangeLabelsTask,
Expand Down Expand Up @@ -242,8 +243,11 @@ class ThreadList extends React.Component<

const canvas = CanvasUtils.canvasForDragging('threads', data.threadIds.length);
event.dataTransfer.setDragImage(canvas, 10, 10);
event.dataTransfer.setData('mailspring-threads-data', JSON.stringify(data));
event.dataTransfer.setData(`mailspring-accounts=${data.accountIds.join(',')}`, '1');
event.dataTransfer.setData(DragDropTypes.ThreadsDragType, JSON.stringify(data));
event.dataTransfer.setData(
`${DragDropTypes.AccountsDragTypePrefix}${data.accountIds.join(',')}`,
'1'
);
};

_onDragEnd = (event: React.DragEvent) => {};
Expand Down
Loading
Loading