Merge branch 'feat/do-ux-p2-compose-fix' into feat/desktop-offline

This commit is contained in:
Linus Rath
2026-11-04 13:28:56 +01:00
67 changed files with 2058 additions and 188 deletions
@@ -253,6 +253,12 @@ describe('recipient fields (SR-06, CV-02)', () => {
expect(screen.getByRole('list', { name: 'to:' })).toBeInTheDocument();
});
// compose-p2-18: the ring outside the input was cut by the field's scroller.
it("draws the To field's keyboard focus ring inside it", () => {
render(<EmailComposer initialData={DRAFT} />);
expect(screen.getByRole('combobox', { name: 'to:' })).toHaveClass('focus-inset');
});
it('labels the subject field', () => {
render(<EmailComposer initialData={DRAFT} />);
expect(screen.getByLabelText('subject_label')).toBe(screen.getByTestId('composer-subject'));
@@ -394,6 +400,21 @@ describe('moving a recipient by keyboard (MO-07)', () => {
expect(liveRegion()).toHaveTextContent('recipient_moved:Anna Berg|cc');
});
// compose-p2-16: a click did nothing visible, and Escape then closed the composer.
it('opens the chip menu on a click, and Escape closes only the menu', async () => {
const onClose = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT, to: 'anna@example.com' }} onClose={onClose} />);
const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
fireEvent.click(chip.querySelector('.truncate')!);
const move = await screen.findByTestId('recipient-move-bcc');
fireEvent.keyDown(move, { key: 'Escape' });
await waitFor(() => expect(screen.queryByTestId('recipient-move-bcc')).toBeNull());
expect(onClose).not.toHaveBeenCalled();
expect(screen.getAllByRole('listitem')).toHaveLength(1);
});
it('opens the chip menu with Shift+F10 too', async () => {
render(<EmailComposer initialData={{ ...DRAFT, to: 'anna@example.com' }} />);
const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
@@ -259,6 +259,16 @@ describe('attachment list', () => {
expect(row.className).toContain('max-h-[');
});
// compose-p2-11: 5.25rem showed two rows of 2rem chips and half a third.
it('is as high as whole rows of chips, so none is cut in half', () => {
render(<EmailComposer initialData={DRAFT} />);
const heights = [...chipRow().className.matchAll(/max-h-\[([\d.]+)rem\]/g)].map((m) => Number(m[1]));
expect(heights.length).toBeGreaterThan(0);
// n rows of 2rem chips with 0.5rem gaps: 2.5n - 0.5.
for (const height of heights) expect(Number.isInteger((height + 0.5) / 2.5)).toBe(true);
});
it('says how many files and how much they weigh', () => {
render(<EmailComposer initialData={DRAFT} />);
@@ -1,5 +1,5 @@
import React, { StrictMode, useState } from 'react';
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextIntlClientProvider } from 'next-intl';
import { EmailComposer, type ComposerDraftData } from '@/components/email/email-composer';
@@ -134,9 +134,10 @@ async function openComposer(strict = false) {
add: async (...files: File[]) => {
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files } }); await advance();
},
dialog: (control: Control) => {
// The bin asks nothing (compose-p2-10).
discard: (control: Control) => {
fireEvent.click(screen.getByTestId(control === 'toolbar' ? 'composer-discard' : 'composer-close'));
return within(screen.getByRole('alertdialog'));
expect(screen.queryByRole('alertdialog')).toBeNull();
},
reopen: async () => { act(() => reopen()); await advance(); },
};
@@ -167,7 +168,7 @@ describe('explicit Discard with pending attachment uploads', () => {
void upload.mock.results[0].value.then(settled, settled);
expect([...resources.files.values()].map(f => f.name)).toEqual(['A.txt']);
expect(resources.progress.size).toBe(1);
fireEvent.click(c.dialog(control).getByRole('button', { name: /^Discard$/ })); await advance();
c.discard(control); await advance();
await c.reopen(); await c.add(file('B.txt')); await complete(1);
expectNewComposer();
@@ -197,17 +198,6 @@ describe('explicit Discard with pending attachment uploads', () => {
expect(resources.files.size).toBe(0); expect(resources.progress.size).toBe(0);
expectNewComposer(); expect(c.closed).toHaveBeenCalledOnce();
});
it('keeps active and queued uploads when the close/discard dialog is cancelled', async () => {
const uploaded = vi.spyOn(emailHooks.onAfterAttachmentUpload, 'emit');
const c = await openComposer(strict); await c.add(file('A.txt'), file('C.txt'));
const dialog = c.dialog(control);
expect(UploadXHR.instances[0].abort).not.toHaveBeenCalled();
fireEvent.click(dialog.getByRole('button', { name: /^Cancel$/ })); await advance();
await complete(0); expect(UploadXHR.instances[1].body?.name).toBe('C.txt'); await complete(1);
expect(uploaded.mock.calls.map(([attachment]) => attachment.name)).toEqual(['A.txt', 'C.txt']);
expect(c.closed).not.toHaveBeenCalled(); expect(resources.files.size).toBe(0); expect(resources.progress.size).toBe(0);
});
});
it.each(['saveFile', 'getFile', 'deleteFile'] as const)('cancels while asynchronous %s is pending', async method => {
@@ -234,7 +224,7 @@ describe('explicit Discard with pending attachment uploads', () => {
await c.add(file('A.txt'), file('C.txt'));
if (method === 'deleteFile') await complete(0);
expect(entered).toHaveBeenCalledOnce();
fireEvent.click(c.dialog('toolbar').getByRole('button', { name: /^Discard$/ })); await advance();
c.discard('toolbar'); await advance();
await c.reopen();
await act(async () => { gate.resolve(); }); await advance();
await c.add(file('B.txt')); await complete(UploadXHR.instances.findIndex(xhr => xhr.body?.name === 'B.txt'));
@@ -1,4 +1,4 @@
import { render, screen, fireEvent, within } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
@@ -7,6 +7,8 @@ import { EmailComposer } from '../email-composer';
const editorKeyDown = vi.hoisted(() => vi.fn());
const toastError = vi.hoisted(() => vi.fn());
const discardWithUndo = vi.hoisted(() => vi.fn());
vi.mock('@/lib/compose/draft-close-toast', () => ({ discardWithUndo, showDraftSavedToast: vi.fn() }));
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', {
@@ -209,27 +211,56 @@ const REOPENED_DRAFT = {
describe('composer Discard button', () => {
beforeEach(() => {
discardWithUndo.mockClear();
toastError.mockClear();
updateSetting.mockClear();
editorKeyDown.mockClear();
});
it('deletes a reopened draft without unsaved changes after confirming', async () => {
// compose-p2-10: no question; "Draft discarded" with Undo, as the toast's Discard.
it('closes at once and deletes a reopened draft once Undo is over', () => {
const onDiscardDraft = vi.fn();
const onClose = vi.fn();
render(<EmailComposer initialData={REOPENED_DRAFT} onDiscardDraft={onDiscardDraft} onClose={onClose} />);
fireEvent.click(screen.getByText('discard'));
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('discard_draft_title')).toBeInTheDocument();
// The user already chose to discard; there is nothing to save.
expect(within(dialog).queryByText('save')).toBeNull();
fireEvent.click(within(dialog).getByText('discard'));
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(onClose).toHaveBeenCalledTimes(1);
expect(discardWithUndo).toHaveBeenCalledTimes(1);
const [commit, labels] = discardWithUndo.mock.calls[0];
expect(labels).toEqual({ discarded: 'draft_discarded', undo: 'undo' });
// Not before the Undo time is over.
expect(onDiscardDraft).not.toHaveBeenCalled();
commit();
expect(onDiscardDraft).toHaveBeenCalledWith('draft-1', { accountId: undefined, localAccountId: undefined });
});
it('brings the text back on Undo, and deletes nothing', () => {
const onDiscardDraft = vi.fn();
const onRestoreDiscarded = vi.fn();
render(<EmailComposer initialData={REOPENED_DRAFT} onDiscardDraft={onDiscardDraft} onRestoreDiscarded={onRestoreDiscarded} onClose={() => {}} />);
fireEvent.click(screen.getByText('discard'));
const restore = discardWithUndo.mock.calls[0][2] as () => void;
restore();
expect(onRestoreDiscarded).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Lunch', draftId: 'draft-1', to: 'bob@example.com' }));
expect(onDiscardDraft).not.toHaveBeenCalled();
});
it('has the main window say it for a composer in its own window', () => {
const onDraftDiscarded = vi.fn();
const onDiscardDraft = vi.fn();
render(<EmailComposer initialData={REOPENED_DRAFT} onDiscardDraft={onDiscardDraft} ownWindow={{ onDraftDiscarded }} onClose={() => {}} />);
fireEvent.click(screen.getByText('discard'));
expect(onDraftDiscarded).toHaveBeenCalledWith('draft-1', { accountId: undefined, localAccountId: undefined });
expect(discardWithUndo).not.toHaveBeenCalled();
expect(onDiscardDraft).not.toHaveBeenCalled();
});
it('closes an empty, never saved composer without asking', () => {
const onDiscardDraft = vi.fn();
const onClose = vi.fn();
@@ -1,4 +1,4 @@
import { act, createEvent, fireEvent, render, screen } from '@testing-library/react';
import { act, createEvent, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
@@ -332,7 +332,7 @@ describe('composer-send-menu', () => {
});
// compose-23: a menu with "Schedule send" as its only item.
it('opens the schedule dialog straight from the arrow when it is the only entry', () => {
it('opens the schedule dialog straight from the arrow when it is the only entry', async () => {
useAuthStore.setState({
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
});
@@ -343,7 +343,24 @@ describe('composer-send-menu', () => {
fireEvent.click(arrow);
expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
expect(screen.getByRole('dialog')).toHaveTextContent('schedule_send_description');
// After the send checks, which found nothing here (compose-p2-14).
expect(await screen.findByRole('dialog')).toHaveTextContent('schedule_send_description');
});
// compose-p2-14: the checks opened on top of the picked time and said
// "Send anyway" though nothing was sent then.
it('runs the send checks before the schedule dialog, and says "Schedule anyway"', async () => {
useAuthStore.setState({
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
});
render(<EmailComposer initialData={{ ...DRAFT, subject: '' }} onClose={vi.fn()} />);
fireEvent.click(screen.getAllByRole('button', { name: 'schedule_send' })[0]);
const warnings = await screen.findByRole('alertdialog');
expect(screen.queryByText('schedule_send_description')).toBeNull();
fireEvent.click(within(warnings).getByRole('button', { name: 'schedule_anyway' }));
expect(await screen.findByText('schedule_send_description')).toBeInTheDocument();
});
it('keeps a plain Send button without plugin entries or scheduled send', () => {
@@ -1,4 +1,4 @@
import { render, act } from '@testing-library/react';
import { render, act, fireEvent, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
@@ -9,17 +9,27 @@ import { useSettingsStore } from '@/stores/settings-store';
const toastError = vi.hoisted(() => vi.fn());
const editorFocus = vi.hoisted(() => vi.fn());
const caret = vi.hoisted(() => ({ textEnd: null as number | null, selected: [] as number[], focused: 0 }));
// Reports a stub editor as ready, the way the real one does once mounted.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ onEditorReady }: { onEditorReady?: (editor: unknown) => void }) => {
React.useEffect(() => {
onEditorReady?.({ commands: { focus: editorFocus, setContent: () => {} }, isDestroyed: false });
const chain = {
setTextSelection: (pos: number) => { caret.selected.push(pos); return chain; },
focus: () => { caret.focused += 1; return chain; },
run: () => true,
};
onEditorReady?.({ commands: { focus: editorFocus, setContent: () => {} }, isDestroyed: false, isEditable: true, state: {}, chain: () => chain, on: () => {}, off: () => {} });
}, [onEditorReady]);
return React.createElement('div', { 'data-testid': 'rich-text-editor' });
},
}));
vi.mock('@/components/email/reply-quote-collapse', async (importOriginal) => ({
...(await importOriginal<typeof import('@/components/email/reply-quote-collapse')>()),
userTextEnd: () => caret.textEnd,
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
@@ -234,3 +244,41 @@ describe('composer initial focus', () => {
expect(document.activeElement?.tagName).toBe('TEXTAREA');
});
});
// compose-p2-9: the white space under the text is the message too.
describe('a click in the empty space under the text', () => {
beforeEach(() => {
editorFocus.mockClear();
caret.textEnd = null;
caret.selected = [];
caret.focused = 0;
useSettingsStore.setState({ plainTextMode: false });
});
const emptySpace = () => screen.getByTestId('rich-text-editor').parentElement!.parentElement!;
it('puts the caret at the end of the text above the folded quote', () => {
caret.textEnd = 7;
render(<EmailComposer initialData={{ ...DATA, mode: 'reply' }} mode="reply" />);
editorFocus.mockClear();
const event = fireEvent.mouseDown(emptySpace(), { button: 0 });
expect(event).toBe(false);
expect(caret.selected).toEqual([7]);
expect(caret.focused).toBe(1);
});
it('puts the caret at the end of the body when nothing is folded', () => {
render(<EmailComposer initialData={{ ...DATA, mode: 'compose' }} mode="compose" />);
fireEvent.mouseDown(emptySpace(), { button: 0 });
expect(editorFocus).toHaveBeenCalledWith('end');
});
it('leaves clicks on buttons alone', () => {
caret.textEnd = 7;
render(<EmailComposer initialData={{ ...DATA, mode: 'reply' }} mode="reply" />);
const button = document.createElement('button');
emptySpace().appendChild(button);
fireEvent.mouseDown(button, { button: 0 });
expect(caret.selected).toEqual([]);
});
});
@@ -147,8 +147,9 @@ vi.mock('@/stores/contact-store', () => {
// ─── Misc dependency mocks ────────────────────────────────────────────────────
const toastSuccess = vi.hoisted(() => ({ calls: [] as unknown[][] }));
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
toast: { info: () => {}, error: () => {}, success: (...args: unknown[]) => { toastSuccess.calls.push(args); } },
}));
vi.mock('@/lib/plugin-hooks', () => ({
@@ -341,6 +342,17 @@ describe('applying a template to a reply (#911)', () => {
expect(screen.queryByTestId('quick-save-template')).not.toBeInTheDocument();
});
// compose-p2-15: the dialog closed with no sign the template was kept.
it('says "Template saved"', async () => {
useTemplateStore.setState({ templates: [] });
toastSuccess.calls = [];
await openReply();
const dialog = await openNameDialog();
fireEvent.change(within(dialog).getByLabelText('template_name'), { target: { value: 'Quick answer' } });
fireEvent.click(within(dialog).getByText('save'));
await waitFor(() => expect(toastSuccess.calls).toContainEqual(['template_saved']));
});
it('carries the name over into the full editor', async () => {
await openReply();
const dialog = await openNameDialog();
@@ -232,6 +232,8 @@ describe('full screen', () => {
expect(root).toHaveAttribute('data-maximized');
expect(root.className).toMatch(/(^|\s)fixed(\s|$)/);
expect(root.className).toContain('inset-0');
// The folder sidebar sits at z-50 (compose-p2-5): at 40 it covered the composer's left part.
expect(root.className).toMatch(/(^|\s)z-50(\s|$)/);
fireEvent.click(screen.getByRole('button', { name: 'restore' }));
expect(root).not.toHaveAttribute('data-maximized');
@@ -5,7 +5,7 @@ import Paragraph from '@tiptap/extension-paragraph';
import { trailingParagraphBreakRule } from '../paragraph-breaks';
import { QuotedHtml, buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html';
import { SignatureBlock, buildSignatureBlock } from '../signature-block';
import { ReplyQuoteCollapse, markReplyQuoteHeader } from '../reply-quote-collapse';
import { ReplyQuoteCollapse, markReplyQuoteHeader, userTextEnd } from '../reply-quote-collapse';
const HEADER = markReplyQuoteHeader('<div>On Monday, Bob wrote:<br></div>');
const ORIGINAL = '<p>Original message</p><img src="cid:logo"><blockquote>Earlier reply</blockquote>';
@@ -168,3 +168,49 @@ describe('reply quote visibility', () => {
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
});
});
// compose-p2-3: a click under the folded quote used to put the caret on the
// empty last line, below the hidden original, where the text went unseen.
describe('a click in a reply with a folded quote', () => {
const click = (e: Editor, pos: number) => {
let handled = false;
e.view.someProp('handleClick', (handler) => { handled = handler(e.view, pos, new MouseEvent('click')) || handled; return handled; });
return handled;
};
const lastPos = (e: Editor) => e.state.doc.content.size - 1;
it('puts the caret at the end of the text above the quote', () => {
const e = mount();
expect(e.state.doc.lastChild?.type.name).toBe('paragraph');
expect(click(e, lastPos(e))).toBe(true);
expect(e.state.selection.from).toBe(1 + 'My response'.length);
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
});
it('keeps the click where it was once the quote is shown', () => {
const e = mount();
toggle().click();
expect(click(e, lastPos(e))).toBe(false);
});
it('keeps a click on a line the user can see below the quote', () => {
const e = mount(`<p>My response</p>${HEADER}${QUOTE}<p>Postscript</p>`);
expect(click(e, lastPos(e) - 2)).toBe(false);
});
it('keeps a click in the text above the quote', () => {
const e = mount();
expect(click(e, 3)).toBe(false);
});
it.each(['above', 'below'])('ends the text before a signature %s the quote', (position) => {
const signature = buildSignatureBlock('<b>My signature</b>');
const e = mount(`<p>My response</p>${position === 'above' ? signature : ''}${HEADER}${QUOTE}${position === 'below' ? signature : ''}`);
expect(userTextEnd(e.state)).toBe(1 + 'My response'.length);
});
it('has no end of text in a body with neither quote nor signature', () => {
const e = mount('<p>Just text</p>');
expect(userTextEnd(e.state)).toBeNull();
});
});
@@ -29,11 +29,50 @@ describe('Insert link', () => {
fireEvent.click(screen.getByTitle('link'));
expect(prompt).not.toHaveBeenCalled();
const dialog = screen.getByRole('dialog', { name: 'insert_link' });
fireEvent.change(dialog.querySelector('input')!, { target: { value: 'example.com/docs' } });
screen.getByRole('dialog', { name: 'insert_link' });
expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs');
fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(editor.getHTML()).toContain('href="https://example.com/docs"');
expect(editor.getHTML()).toContain('<a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com/docs">docs</a> here');
});
// compose-p2-7: with nothing selected Save used to add nothing.
it('inserts the text as a link when nothing is selected', async () => {
const editor = await setup('<p>See it</p>');
act(() => { editor.commands.setTextSelection(4); });
fireEvent.click(screen.getByTitle('link'));
expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('');
fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'our docs' } });
fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(editor.getHTML()).toMatch(/See<a [^>]*href="https:\/\/example\.com\/docs"[^>]*>our docs<\/a> it/);
});
it('shows the address as the link when the text is left empty', async () => {
const editor = await setup('<p>See it</p>');
act(() => { editor.commands.setTextSelection(4); });
fireEvent.click(screen.getByTitle('link'));
fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'https://example.com' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(editor.getHTML()).toMatch(/See<a [^>]*href="https:\/\/example\.com"[^>]*>https:\/\/example\.com<\/a> it/);
});
it('edits the text of a link the caret is in', async () => {
const editor = await setup('<p><a href="https://example.com">docs</a> here</p>');
act(() => { editor.commands.setTextSelection(2); });
fireEvent.click(screen.getByTitle('link'));
expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs');
expect(screen.getByRole('textbox', { name: 'link' })).toHaveValue('https://example.com');
fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'the docs' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(editor.getHTML()).toMatch(/<a [^>]*href="https:\/\/example\.com"[^>]*>the docs<\/a> here/);
});
it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => {
@@ -0,0 +1,54 @@
// compose-p2-6: on a phone the conversation listed a reply draft as an
// ordinary message, expanded at the top with Reply, Reply all and Forward.
// It is marked Draft and offers Edit and Discard instead.
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
const base = {
threadId: 't1', mailboxIds: { inbox: true }, to: [{ email: 'me@example.org' }],
subject: 's', size: 1, hasAttachment: false, htmlBody: [], textBody: [], attachments: [], bodyValues: {},
};
const received = { ...base, id: 'e1', keywords: { $seen: true }, from: [{ name: 'Ann', email: 'ann@t.example' }], receivedAt: '2026-11-01T10:00:00Z', preview: 'question' };
const draft = { ...base, id: 'd1', keywords: { $seen: true, $draft: true }, from: [{ name: 'Me', email: 'me@example.org' }], receivedAt: '2026-11-02T10:00:00Z', preview: 'my answer' };
function renderView(handlers: Record<string, unknown>) {
render(
<ThreadConversationView
thread={{ threadId: 't1', emails: [draft, received], latestEmail: draft } as never}
emails={[draft, received] as never}
onBack={() => {}}
onReply={() => {}}
onReplyAll={() => {}}
onForward={() => {}}
{...handlers}
/>,
);
}
describe('a draft in a phone conversation', () => {
it('is marked Draft, and the newest received message is the one open', () => {
renderView({});
expect(screen.getAllByTestId('conversation-draft-label')).toHaveLength(1);
// Only the received message is open, with its Reply.
expect(screen.getAllByRole('button', { name: /email_viewer.reply$/ })).toHaveLength(1);
expect(screen.queryByRole('button', { name: /email_viewer.edit_draft/ })).toBeNull();
});
it('offers Edit and Discard instead of Reply once opened', () => {
const onEditDraft = vi.fn();
const onDiscardDraft = vi.fn();
renderView({ onEditDraft, onDiscardDraft });
fireEvent.click(screen.getByText('my answer'));
expect(screen.getAllByRole('button', { name: /email_viewer.reply$/ })).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: /email_viewer.edit_draft/ }));
fireEvent.click(screen.getByRole('button', { name: /email_composer.discard/ }));
expect(onEditDraft).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
expect(onDiscardDraft).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
});
@@ -85,3 +85,18 @@ describe('quick reply wording', () => {
expect(onReply).toHaveBeenCalledWith('Thanks, will do');
});
});
// compose-p2-17: the toasts lift over the box by its height (toast.tsx).
describe('quick reply box and the toasts', () => {
it('marks the box and gives the page its height, and takes both back when it goes', async () => {
const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(96);
const { unmount } = render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} onReply={vi.fn()} />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
expect(document.querySelector('[data-quick-reply-box]')).not.toBeNull();
expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('96px');
unmount();
expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('');
height.mockRestore();
});
});
@@ -0,0 +1,82 @@
import { render, act, fireEvent, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { clearAllHooks } from '@/lib/plugin-hooks';
import { useAuthStore } from '@/stores/auth-store';
// compose-p2-6: a reply saved as a draft was nowhere to be seen on the
// message it answers. The reader shows it under the message, marked Draft,
// with Edit and Discard, where the quick reply box would be.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key}:${JSON.stringify(values)}` : key,
useLocale: () => 'en',
useFormatter: () => ({
dateTime: (d: Date | string) => String(d),
relativeTime: (d: Date | string) => String(d),
number: (n: number) => String(n),
}),
}));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { EmailViewer } = await import('../email-viewer');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const message = {
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Ben Carter', email: 'ben.carter@example.com' }], to: [{ name: 'Me', email: 'me@example.org' }],
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
bodyValues: { '1': { value: 'Plain body' } },
} as unknown as Email;
beforeEach(() => {
clearAllHooks();
useAuthStore.setState({ identities: [{ id: 'me', email: 'me@example.org', name: 'Me' }] } as never);
});
async function renderViewer(replyDraft: { preview?: string; onEdit: () => void; onDiscard: () => void } | null) {
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} onReply={vi.fn()} replyDraft={replyDraft} />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
}
describe('a reply draft under its message', () => {
it('shows Draft with its text, Edit and Discard, instead of the quick reply box', async () => {
const onEdit = vi.fn();
const onDiscard = vi.fn();
await renderViewer({ preview: 'my answer', onEdit, onDiscard });
const card = screen.getByTestId('reply-draft');
expect(card.textContent).toContain('draft');
expect(card.textContent).toContain('my answer');
expect(screen.queryByPlaceholderText(/^quick_reply_to/)).toBeNull();
fireEvent.click(screen.getByTestId('reply-draft-edit'));
fireEvent.click(screen.getByTestId('reply-draft-discard'));
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onDiscard).toHaveBeenCalledTimes(1);
});
// compose-p2-17: toasts lift over it as over the quick reply box.
it('counts as the quick reply box for the toasts', async () => {
await renderViewer({ preview: 'my answer', onEdit: vi.fn(), onDiscard: vi.fn() });
expect(screen.getByTestId('reply-draft')).toHaveAttribute('data-quick-reply-box');
});
it('keeps the quick reply box when there is none', async () => {
await renderViewer(null);
expect(screen.queryByTestId('reply-draft')).toBeNull();
expect(screen.getByPlaceholderText(/^quick_reply_to/)).toBeTruthy();
});
});
+99 -24
View File
@@ -70,10 +70,10 @@ import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
import { isEditableEventTarget } from "@/lib/keyboard";
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { markReplyQuoteHeader } from "@/components/email/reply-quote-collapse";
import { markReplyQuoteHeader, userTextEnd } from "@/components/email/reply-quote-collapse";
import { attachmentIcon } from "@/components/email/attachment-chips";
import { attachmentsForDisplay } from "@/lib/compose/attachment-order";
import { showDraftSavedToast } from "@/lib/compose/draft-close-toast";
import { discardWithUndo, showDraftSavedToast } from "@/lib/compose/draft-close-toast";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
import { containsEmbeddedSignature } from "@/components/email/signature-block";
import {
@@ -412,6 +412,11 @@ interface EmailComposerProps {
*/
onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void;
onSaveState?: (data: ComposerDraftData) => void;
/**
* Undo after the bin: opens a composer again with what this one held. The
* draft on the server is still there then.
*/
onRestoreDiscarded?: (data: ComposerDraftData) => void;
/**
* Offers "Open in new window". Called in the click, since a browser only
* opens a window for a user action: the host opens it and answers with what
@@ -445,6 +450,8 @@ interface EmailComposerProps {
* this one is gone before a toast here could be read.
*/
onDraftKept?: (draftId: string, where: { accountId?: string; localAccountId?: string }) => void;
/** The bin closed the window: another window says "Draft discarded" with Undo, and deletes it after. */
onDraftDiscarded?: (draftId: string, where: { accountId?: string; localAccountId?: string }) => void;
};
/** The subject as typed, for a host that shows it (a Pro compose tab's title). */
onSubjectChange?: (subject: string) => void;
@@ -607,6 +614,7 @@ export function EmailComposer({
requestCloseRef,
onDiscardDraft,
onSaveState,
onRestoreDiscarded,
popOut,
popOutToMainLabel,
ownWindow,
@@ -1260,7 +1268,9 @@ export function EmailComposer({
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
// What the checks before sending found (no subject, no text, placeholders,
// a missing attachment, open proofreading issues), asked about in one dialog.
const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string } | null>(null);
// `schedule`: asked before the schedule dialog, which opens on "Schedule anyway".
const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string; schedule?: boolean } | null>(null);
const checkThenScheduleRef = useRef<() => void>(() => {});
// Open proofreading issues, once a checker asked to warn about them.
const proofreadingConfirmedRef = useRef(false);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
@@ -1456,6 +1466,19 @@ export function EmailComposer({
// when the user switches identity in "above quote" mode without rebuilding
// the whole body (which would lose user edits to the surrounding draft).
const editorRef = useRef<Editor | null>(null);
// The white space under the text is the message too (compose-p2-9): a
// click there puts the caret at the end of the user's text, above a folded
// quote and the signature, instead of dropping the focus.
const focusBodyFromEmptySpace = (event: React.MouseEvent<HTMLElement>) => {
const editor = editorRef.current;
const target = event.target as HTMLElement;
if (event.button !== 0 || !editor || editor.isDestroyed || !editor.isEditable) return;
if (target.closest('.ProseMirror, button, input, textarea, select, a, label, [role="toolbar"], [role="dialog"], [data-plugin-slot]')) return;
event.preventDefault();
const end = userTextEnd(editor.state);
if (end === null) editor.commands.focus('end');
else editor.chain().setTextSelection(end).focus().run();
};
// Set while the composer itself swaps the signature in the editor.
const signatureSwapRef = useRef(false);
// Set once a reply's body got its initial focus, so switching formats later
@@ -1546,9 +1569,11 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision, autoSignatureEnabled]);
// The send checks run first, so they don't open on top of the picked time
// saying "Send anyway" when nothing is sent now (compose-p2-14).
const openScheduleDialog = useCallback(() => {
setShowScheduleDialog(true);
closeSendMenu();
checkThenScheduleRef.current();
}, [closeSendMenu]);
// `autoSelectReplyIdentity` fused two behaviours: matching one of the user's
@@ -1843,9 +1868,13 @@ export function EmailComposer({
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
// "Save as template" keeps it with the account the message is written from.
// It says so: the dialog closing was the only sign before (compose-p2-15).
const addTemplate = useCallback(
(data: Parameters<ReturnType<typeof personalTemplatesFor>['add']>[0]) => personalTemplatesFor(composingAccountId).add(data),
[composingAccountId],
(data: Parameters<ReturnType<typeof personalTemplatesFor>['add']>[0]) => {
personalTemplatesFor(composingAccountId).add(data);
toast.success(tSend('template_saved'));
},
[composingAccountId, tSend],
);
// The admin may leave users only organisation and team templates.
const composingTemplates = useTemplateSources(composingAccountId);
@@ -3837,10 +3866,12 @@ export function EmailComposer({
else editor.commands.focus();
};
const handleSend = async ({ warningsConfirmed = false, delayedUntil }: {
const handleSend = async ({ warningsConfirmed = false, delayedUntil, checkOnly = false }: {
/** "Send anyway" in the warnings dialog: don't check those again. */
warningsConfirmed?: boolean;
delayedUntil?: string;
/** Only the checks, then the schedule dialog instead of a send. */
checkOnly?: boolean;
} = {}) => {
if (isSendingRef.current) return;
@@ -3987,10 +4018,14 @@ export function EmailComposer({
}
if (warnings.length > 0) {
setSendWarnings({ items: warnings, delayedUntil });
setSendWarnings({ items: warnings, delayedUntil, ...(checkOnly ? { schedule: true } : {}) });
return;
}
}
if (checkOnly) {
setShowScheduleDialog(true);
return;
}
// Checks other features register (lib/outgoing/send-pipeline.ts), such
// as the external-recipient confirm. They may ask the user, so a second
@@ -4475,6 +4510,8 @@ export function EmailComposer({
};
checkThenScheduleRef.current = () => void handleSend({ checkOnly: true });
// A host can hand a follow-up to requestCloseRef - "close this session, then
// do that". It runs only once the composer really closes, never when the
// guard dialog is cancelled, and never before onClose has had its say.
@@ -4688,7 +4725,9 @@ export function EmailComposer({
</button>
);
const handleDiscardAndClose = () => {
// `keepDraft`: the bin's discard, which deletes the draft once its Undo is
// over, not here.
const handleDiscardAndClose = ({ keepDraft = false }: { keepDraft?: boolean } = {}) => {
cancelSaveAndClose();
sendCancelledRef.current = true;
explicitCloseRef.current = true;
@@ -4706,7 +4745,7 @@ export function EmailComposer({
// From belongs to: switching From doesn't move the draft until it is
// saved again, and the same id names another message on another server.
const discardedDraftId = draftIdRef.current;
if (discardedDraftId && onDiscardDraft) {
if (discardedDraftId && onDiscardDraft && !keepDraft) {
onDiscardDraft(discardedDraftId, {
accountId: draftAccountIdRef.current,
localAccountId: draftLocalAccountIdRef.current,
@@ -4742,6 +4781,11 @@ export function EmailComposer({
// Discard deletes the draft, also one that has no unsaved changes, such as
// a scheduled message reopened with "Cancel and edit". It used to share the
// X button's path, which closed such a composer and kept the draft.
// It asks nothing (compose-p2-10): the composer goes at once with "Draft
// discarded · Undo", as Discard on the "Draft saved" toast does, and the
// draft is deleted once Undo is no longer offered. Undo brings the composer
// back with its text where the host can; elsewhere the last saved draft
// stays in Drafts. A window of its own has the main window say it.
const handleDiscardClick = () => {
if (saveCloseAttemptRef.current?.saving) return;
cancelSaveAndClose();
@@ -4750,7 +4794,22 @@ export function EmailComposer({
return;
}
afterCloseRef.current = null;
openCloseDialog('discard');
const draftId = draftIdRef.current;
const where = { accountId: draftAccountIdRef.current, localAccountId: draftLocalAccountIdRef.current };
const snapshot = snapshotStateRef.current();
const discard = onDiscardDraft;
const restore = onRestoreDiscarded;
const windowDiscarded = ownWindowRef.current?.onDraftDiscarded;
handleDiscardAndClose({ keepDraft: true });
if (windowDiscarded) {
if (draftId) windowDiscarded(draftId, where);
return;
}
discardWithUndo(
() => { if (draftId) discard?.(draftId, where); },
{ discarded: tUx('draft_discarded'), undo: tUx('undo') },
restore ? () => restore(snapshot) : undefined,
);
};
// X, Escape, another message, a host's tab: one rule, as drafts save
@@ -4952,7 +5011,8 @@ export function EmailComposer({
"flex h-full bg-background relative",
className,
// Over everything but dialogs (z-60) and toasts; below the desktop app's window controls.
maximized && "fixed inset-0 z-40 h-auto pt-[var(--titlebar-row-height)]",
// At the folder sidebar's z-50 and after it in the page, so over it (compose-p2-5).
maximized && "fixed inset-0 z-50 h-auto pt-[var(--titlebar-row-height)]",
)}
data-maximized={maximized || undefined}
onKeyDownCapture={handleComposerKeyDown}
@@ -5505,7 +5565,7 @@ export function EmailComposer({
</div>
</div>
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")}>
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")} onMouseDown={focusBodyFromEmptySpace}>
{wantsPluginDraft && (
<PluginSlot name="composer-above-body" className="w-full" extraProps={pluginDraftProps} />
)}
@@ -5637,12 +5697,13 @@ export function EmailComposer({
</p>
)}
<div
// Every file shows, wrapping. Past about two rows the list
// scrolls by itself: it sits outside the body's scroller and
// can't shrink, and a few dozen files pushed Send off the
// bottom. Files still uploading or failed come first, so their
// progress and errors are never out of sight.
className="flex flex-wrap gap-2 max-h-[5.25rem] overflow-y-auto overscroll-contain"
// Every file shows, wrapping. Past four rows (two in a short
// window) the list scrolls by itself: it sits outside the
// body's scroller and can't shrink, and a few dozen files pushed
// Send off the bottom. The height is whole rows of 2rem chips
// with 0.5rem gaps, so no chip is cut in half (compose-p2-11);
// the line above says how many there are. Failed files first.
className="flex flex-wrap gap-2 max-h-[4.5rem] [@media(min-height:700px)]:max-h-[9.5rem] overflow-y-auto overscroll-contain [scrollbar-gutter:stable]"
data-testid="composer-attachments"
>
{attachmentsForDisplay(attachments).map(({ att, index }) => {
@@ -6145,7 +6206,8 @@ export function EmailComposer({
<ScheduleSendDialog
isOpen={showScheduleDialog}
onClose={() => setShowScheduleDialog(false)}
onSchedule={(delayedUntil) => handleSend({ delayedUntil })}
// Checked before the dialog opened (openScheduleDialog).
onSchedule={(delayedUntil) => handleSend({ delayedUntil, warningsConfirmed: true })}
supported={!!composerClient?.hasDelayedSend()}
maxDelayedSend={composerClient?.getMaxDelayedSend() ?? 0}
disabled={!canSend || isSending || isSavingAndClosing}
@@ -6156,6 +6218,7 @@ export function EmailComposer({
<SendWarningsDialog
warnings={sendWarnings?.items ?? null}
scheduling={sendWarnings?.schedule === true}
onBack={() => {
const first = sendWarnings?.items[0];
setSendWarnings(null);
@@ -6173,7 +6236,7 @@ export function EmailComposer({
}}
onSendAnyway={(dontAskAgain) => {
if (!sendWarnings) return;
const { items, delayedUntil } = sendWarnings;
const { items, delayedUntil, schedule } = sendWarnings;
for (const warning of items) {
if (warning.kind === 'subject') {
emptySubjectConfirmedRef.current = true;
@@ -6188,7 +6251,8 @@ export function EmailComposer({
}
}
setSendWarnings(null);
handleSend({ warningsConfirmed: true, delayedUntil });
if (schedule) setShowScheduleDialog(true);
else handleSend({ warningsConfirmed: true, delayedUntil });
}}
/>
@@ -6204,7 +6268,7 @@ export function EmailComposer({
<Button variant="outline" onClick={() => dismissCloseDialog()}>
{t('cancel')}
</Button>
<Button variant="destructive" onClick={handleDiscardAndClose}>
<Button variant="destructive" onClick={() => handleDiscardAndClose()}>
{t('discard')}
</Button>
{closeDialogKind === 'close' && (
@@ -6817,6 +6881,14 @@ export function RecipientChipInput({
!isEditing && "focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
)}
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
// A click opens the chip's menu too (address, Edit, Move to,
// Remove), as Gmail and Outlook do; it did nothing visible.
onClick={isEditing ? undefined : (e) => {
if (e.target instanceof Element && e.target.closest('button')) return;
// Not on to the field, whose click puts the caret in the input.
e.stopPropagation();
openChipMenu(i);
}}
data-invalid={isInvalid || undefined}
title={isInvalid ? t('invalid_recipient') : undefined}
role="listitem"
@@ -6933,7 +7005,10 @@ export function RecipientChipInput({
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}
className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
// focus-inset: the keyboard focus ring goes inside the box. Drawn
// outside, the field's own scroller cut it to a line along the top
// and the end (compose-p2-18, a pop-out focuses To from a key).
className="focus-inset flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
role="combobox"
aria-expanded={activeAutoField === field && autocompleteResults.length > 0}
aria-autocomplete="list"
+49 -2
View File
@@ -304,6 +304,11 @@ interface EmailViewerProps {
mvInlineComposer?: React.ReactNode;
/** A reply or forward to this message is open under it: no quick reply box as well. */
replyComposerOpen?: boolean;
/**
* A reply to this message saved as a draft (compose-p2-6): shown under it,
* marked Draft, with Edit and Discard, in place of the quick reply box.
*/
replyDraft?: { preview?: string; onEdit: () => void; onDiscard: () => void } | null;
onEditDraft?: () => void;
onCancelScheduledForEdit?: () => void;
/** Stop a scheduled message from going out; it goes back to Drafts. */
@@ -1376,6 +1381,7 @@ export function EmailViewer({
onReplyAllConversationEmail,
mvInlineComposer,
replyComposerOpen,
replyDraft,
onEditDraft,
onCancelScheduledForEdit,
onCancelScheduledSend,
@@ -1400,6 +1406,7 @@ export function EmailViewer({
const tWindows = useTranslations('pwaWindows');
const tComposer = useTranslations('email_composer');
const tWords = useTranslations('compose_words');
const tList = useTranslations('email_list');
const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
@@ -3514,8 +3521,25 @@ export function EmailViewer({
const latestSrcDocRef = useRef<string | null>(null);
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
// Not on spam: a reply confirms the address to the sender.
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
const showReplyDraft = !!email && !!replyDraft && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled;
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !showReplyDraft && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
&& (!effectiveEmailContent.isHtml || iframeReady);
// Toasts sit above the quick reply box, as above a composer's footer
// (compose-p2-17): its height for toast.tsx, as it grows when focused.
useEffect(() => {
// The reply draft's card takes the box's place, and its ref.
const box = quickReplyBoxRef.current;
if (!(showQuickReply || showReplyDraft) || !box || typeof ResizeObserver === 'undefined') return;
const root = document.documentElement;
const update = () => root.style.setProperty('--quick-reply-height', `${box.offsetHeight}px`);
update();
const observer = new ResizeObserver(update);
observer.observe(box);
return () => {
observer.disconnect();
root.style.removeProperty('--quick-reply-height');
};
}, [showQuickReply, showReplyDraft]);
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
@@ -6190,8 +6214,31 @@ ${printWatermark ? printWatermark.html : ''}
</div>
)}
{/* The reply already started on this message, as Gmail shows it. */}
{showReplyDraft && replyDraft && (
<div ref={quickReplyBoxRef} data-quick-reply-box="" data-testid="reply-draft" className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-border bg-muted/30 px-3 py-2">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-destructive shrink-0">
<File className="w-4 h-4" />
{tList('draft')}
</span>
<span dir="auto" className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{replyDraft.preview}</span>
<span className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={replyDraft.onEdit} data-testid="reply-draft-edit">
<Pencil className="w-4 h-4" />
{t('edit_draft')}
</Button>
<Button variant="ghost" size="sm" className="gap-1.5" onClick={replyDraft.onDiscard} data-testid="reply-draft-discard">
<Trash2 className="w-4 h-4" />
{tComposer('discard')}
</Button>
</span>
</div>
</div>
)}
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{showQuickReply && (<div ref={quickReplyBoxRef} className="bg-background border-t border-border px-6 mt-auto max-sm:scroll-mb-16" style={{ paddingBlock: 'var(--density-header-py)' }}>
{showQuickReply && (<div ref={quickReplyBoxRef} data-quick-reply-box="" className="bg-background border-t border-border px-6 mt-auto max-sm:scroll-mb-16" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
export interface LinkDialogProps {
isOpen: boolean;
onClose: () => void;
/** `text` is what the link shows; empty means the address itself. */
onSubmit: (link: { text: string; href: string }) => void;
/** The selected text, or the link's own text when editing one. */
defaultText: string;
defaultHref: string;
/** Offers "Remove link" when editing a link. */
onRemove?: () => void;
}
/**
* Insert link (compose-p2-7): "Text" and "Link", as Gmail and Outlook ask. The
* selection fills Text; with nothing selected the text (or the address) goes
* in as a new link.
*/
export function LinkDialog({ isOpen, onClose, onSubmit, defaultText, defaultHref, onRemove }: LinkDialogProps) {
const t = useTranslations("email_composer.toolbar");
const tCommon = useTranslations("common");
const id = useId();
const [text, setText] = useState(defaultText);
const [href, setHref] = useState(defaultHref);
const textRef = useRef<HTMLInputElement>(null);
const hrefRef = useRef<HTMLInputElement>(null);
// With text already there, the address is what is left to give.
const firstField = defaultText ? hrefRef : textRef;
useEffect(() => {
if (!isOpen) return;
setText(defaultText);
setHref(defaultHref);
}, [isOpen, defaultText, defaultHref]);
const address = href.trim();
const error = /\s/.test(address) ? t("link_invalid") : null;
const canSubmit = address.length > 0 && !error;
const errorId = `${id}-error`;
const submit = (event?: React.FormEvent) => {
event?.preventDefault();
if (!canSubmit) return;
try {
onSubmit({ text, href: address });
} finally {
onClose();
}
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title={defaultHref ? t("edit_link") : t("insert_link")}
showClose={false}
initialFocus={firstField}
onSubmit={submit}
footer={
<>
{onRemove && (
<Button
type="button"
variant="ghost"
className="me-auto"
onClick={() => {
try {
onRemove();
} finally {
onClose();
}
}}
>
{t("remove_link")}
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
{tCommon("cancel")}
</Button>
<Button type="submit" variant="default" disabled={!canSubmit}>
{tCommon("save")}
</Button>
</>
}
>
<div className="mt-4 space-y-3">
<div className="space-y-1.5">
<label htmlFor={`${id}-text`} className="block text-sm font-medium text-foreground">{t("link_text")}</label>
<Input id={`${id}-text`} ref={textRef} type="text" value={text} onChange={(e) => setText(e.target.value)} />
</div>
<div className="space-y-1.5">
<label htmlFor={`${id}-href`} className="block text-sm font-medium text-foreground">{t("link")}</label>
<Input
id={`${id}-href`}
ref={hrefRef}
type="text"
value={href}
onChange={(e) => setHref(e.target.value)}
placeholder={t("link_address")}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={error ? "border-destructive focus-visible:border-destructive" : undefined}
/>
{error && (
<p id={errorId} role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</div>
</div>
</Dialog>
);
}
+57 -1
View File
@@ -2,7 +2,7 @@
import { Extension } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { Plugin, PluginKey, Selection } from '@tiptap/pm/state';
import { Plugin, PluginKey, Selection, TextSelection, type EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
export const REPLY_QUOTE_START_ATTRIBUTE = 'data-reply-quote-start';
@@ -59,6 +59,55 @@ function quoteRanges(doc: PMNode): QuoteRange[] {
return ranges;
}
const isSignature = (node: PMNode) => node.type.name === 'signatureBlock' || Boolean(node.attrs['data-signature-block']);
function collapsedRanges(state: EditorState): QuoteRange[] {
const expanded = quoteKey.getState(state)?.expanded ?? new Set<number>();
return quoteRanges(state.doc).filter((range) => !expanded.has(range.from));
}
/**
* Where the user's own text ends: the end of the last paragraph above the
* folded quote or the signature, whichever comes first. Null when the body
* has neither, so the end of the body is the end of the text.
*/
export function userTextEnd(state: EditorState): number | null {
const ranges = quoteRanges(state.doc);
let boundary: number | null = collapsedRanges(state)[0]?.from ?? null;
state.doc.forEach((node, pos) => {
if (isSignature(node) && (boundary === null || pos < boundary)) boundary = pos;
});
if (boundary === null) return null;
let end: number | null = null;
state.doc.forEach((node, pos) => {
if (pos + node.nodeSize > boundary! || !node.isTextblock || isSignature(node)) return;
if (ranges.some((range) => pos >= range.from && pos < range.to)) return;
end = pos + node.nodeSize - 1;
});
return end;
}
/**
* A click that lands under a folded quote (on the empty line the editor keeps
* at the end) would write below the hidden original, where the user can't see
* it. It goes to the end of their own text above the quote instead
* (compose-p2-3). Lines the user can see below the quote keep their click.
*/
function clickBelowCollapsedQuote(state: EditorState, pos: number): boolean {
const ranges = collapsedRanges(state);
const first = ranges[0];
if (!first || pos < first.from) return false;
const $pos = state.doc.resolve(pos);
const topPos = $pos.depth > 0 ? $pos.before(1) : pos;
let hiddenOrEmpty = true;
state.doc.forEach((node, nodePos) => {
if (nodePos < first.from || nodePos > topPos) return;
if (ranges.some((range) => nodePos >= range.from && nodePos < range.to)) return;
if (!node.isTextblock || isSignature(node) || node.content.size > 0) hiddenOrEmpty = false;
});
return hiddenOrEmpty;
}
/** Visibility is a view decoration, never a document edit or an undo step. */
export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string }>({
name: 'replyQuoteCollapse',
@@ -107,6 +156,13 @@ export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string
},
},
props: {
handleClick(view, pos) {
if (!clickBelowCollapsedQuote(view.state, pos)) return false;
const end = userTextEnd(view.state);
if (end === null) return false;
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, end)));
return true;
},
decorations(state) {
const expanded = quoteKey.getState(state)?.expanded ?? new Set<number>();
const decorations: Decoration[] = [];
+37 -23
View File
@@ -32,7 +32,8 @@ import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle
import { MentionChipCard, setMentionRecipients } from "@/components/email/mention-chip";
import { mentionSuggestions, type MentionCandidate, type MentionContact } from "@/lib/recipient-mentions";
import { cn } from "@/lib/utils";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { getMarkRange } from "@tiptap/core";
import { LinkDialog } from "@/components/email/link-dialog";
import { ImageAltDialog } from "@/components/email/image-alt-dialog";
/** A typed address without a scheme ("example.com") becomes a web link. */
@@ -741,26 +742,43 @@ export function RichTextEditor({
if (editor) setMentionRecipients(editor, mentionCandidates ? mentionCandidates.map((c) => c.email) : null);
}, [editor, mentionCandidates]);
// Insert link asks for the address in the app's own dialog; on a link it
// starts from the current address and offers "Remove link".
// The selection is kept from when the dialog opened: the dialog takes the
// focus, and with it the editor's selection.
const [linkDialog, setLinkDialog] = useState<{ href: string; from: number; to: number } | null>(null);
// Insert link asks for the text and the address in the app's own dialog
// (compose-p2-7); on a link it starts from the link's text and address and
// offers "Remove link". The range is kept from when the dialog opened: the
// dialog takes the focus, and with it the editor's selection.
const [linkDialog, setLinkDialog] = useState<{ href: string; text: string; from: number; to: number } | null>(null);
const addLink = useCallback(() => {
if (!editor) return;
const previousUrl = editor.getAttributes("link").href as string | undefined;
const { from, to } = editor.state.selection;
setLinkDialog({ href: previousUrl ?? "", from, to });
let { from, to } = editor.state.selection;
// The caret in a link edits the whole link.
const linkType = editor.schema.marks.link;
if (previousUrl && from === to && linkType) {
const range = getMarkRange(editor.state.doc.resolve(from), linkType);
if (range) ({ from, to } = range);
}
const text = editor.state.doc.textBetween(from, to, " ");
setLinkDialog({ href: previousUrl ?? "", text, from, to });
}, [editor]);
addLinkRef.current = addLink;
const applyLink = useCallback((url: string) => {
const applyLink = useCallback(({ text, href }: { text: string; href: string }) => {
if (!editor || !linkDialog) return;
editor
.chain()
.focus()
.setTextSelection({ from: linkDialog.from, to: linkDialog.to })
const label = text.trim() ? text : href;
const { from, to } = linkDialog;
const chain = editor.chain().focus();
if (from === to || label !== linkDialog.text) {
// Nothing selected, or the text changed: the text goes in as the link.
chain
.insertContentAt({ from, to }, { type: "text", text: label, marks: [{ type: "link", attrs: { href } }] })
.setTextSelection(from + label.length)
.unsetMark("link")
.run();
return;
}
chain
.setTextSelection({ from, to })
.extendMarkRange("link")
.setLink({ href: url })
.setLink({ href })
.run();
}, [editor, linkDialog]);
const removeLink = useCallback(() => {
@@ -776,7 +794,6 @@ export function RichTextEditor({
const tToolbar = useTranslations("email_composer.toolbar");
const tWindows = useTranslations("compose_windows");
const tCommon = useTranslations("common");
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [colorMenuOpen, setColorMenuOpen] = useState(false);
@@ -1465,16 +1482,13 @@ export function RichTextEditor({
document.body
)}
<PromptDialog
<LinkDialog
isOpen={!!linkDialog}
onClose={() => setLinkDialog(null)}
title={linkDialog?.href ? tToolbar("edit_link") : tToolbar("insert_link")}
placeholder={tToolbar("link_address")}
defaultValue={linkDialog?.href ?? ""}
confirmText={tCommon("save")}
validate={(value) => (/\s/.test(value) ? tToolbar("link_invalid") : null)}
onSubmit={(value) => applyLink(withLinkScheme(value))}
extraAction={linkDialog?.href ? { label: tToolbar("remove_link"), onClick: removeLink } : undefined}
defaultText={linkDialog?.text ?? ""}
defaultHref={linkDialog?.href ?? ""}
onSubmit={({ text, href }) => applyLink({ text, href: withLinkScheme(href) })}
onRemove={linkDialog?.href ? removeLink : undefined}
/>
<ImageAltDialog
+7 -2
View File
@@ -20,6 +20,8 @@ interface SendWarningsDialogProps {
onBack: () => void;
/** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */
onSendAnyway: (dontAskAgain: boolean) => void;
/** Asked before Schedule send: the button says "Schedule anyway". */
scheduling?: boolean;
}
/** The warnings that can be switched off in Settings (subject and placeholders). */
@@ -30,7 +32,7 @@ const HAS_SETTING = new Set<SendWarning["kind"]>(["subject", "placeholders"]);
* own title and text, several are listed in one dialog with one "Send anyway"
* (two dialogs in a row asked about each in turn).
*/
export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarningsDialogProps) {
export function SendWarningsDialog({ warnings, onBack, onSendAnyway, scheduling = false }: SendWarningsDialogProps) {
const t = useTranslations("email_composer");
const tSend = useTranslations("compose_send");
const tProofreading = useTranslations("proofreading");
@@ -89,7 +91,7 @@ export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarni
backLabel = tProofreading("send_warning.review");
sendLabel = tProofreading("send_warning.send");
} else {
title = tSend("warnings_title");
title = tSend(scheduling ? "schedule_warnings_title" : "warnings_title");
body = (
<ul className="mt-3 space-y-2 text-sm text-foreground" data-testid="send-warnings-list">
{items.map((w) => (
@@ -113,6 +115,9 @@ export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarni
);
}
// Nothing goes out now: the message is scheduled (compose-p2-14).
if (scheduling) sendLabel = tSend("schedule_anyway");
return (
<Dialog
isOpen={items.length > 0}
+60 -8
View File
@@ -49,6 +49,8 @@ import {
Download,
Eye,
CalendarClock,
Pencil,
Trash2,
} from "@/components/icons";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { useTranslations } from "next-intl";
@@ -91,6 +93,9 @@ interface ThreadConversationViewProps {
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
/** A draft in the conversation (compose-p2-6): Edit and Discard take Reply's place. */
onEditDraft?: (email: Email) => void;
onDiscardDraft?: (email: Email) => void;
onDownloadAttachment?: ConversationAttachmentHandler;
/** Routing captured when this conversation was opened. */
blobSource?: EmailBlobContext;
@@ -196,6 +201,8 @@ export function ThreadConversationView({
onReply,
onReplyAll,
onForward,
onEditDraft,
onDiscardDraft,
onDownloadAttachment,
blobSource,
onMarkAsRead,
@@ -219,12 +226,12 @@ export function ThreadConversationView({
if (emails.length > 0) {
const idsToExpand = new Set<string>();
// Always expand most recent
idsToExpand.add(emails[0].id);
// Always expand the most recent message; a draft isn't one.
idsToExpand.add((emails.find((e) => !e.keywords?.$draft) ?? emails[0]).id);
// Also expand all unread emails
emails.forEach(email => {
if (!email.keywords?.$seen) {
if (!email.keywords?.$seen && !email.keywords?.$draft) {
idsToExpand.add(email.id);
}
});
@@ -280,7 +287,9 @@ export function ThreadConversationView({
<div className="flex-1 overflow-y-auto">
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => (
{emails.map((email, index) => {
const draft = !!email.keywords?.$draft;
return (
<EmailCard
key={email.id}
email={email}
@@ -290,15 +299,19 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => externalContent.allow(email.id)}
onTrustSender={externalContent.trustSender(email)}
onReply={onReply ? () => onReply(email) : undefined}
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
onForward={onForward ? () => onForward(email) : undefined}
isDraft={draft}
onEditDraft={draft && onEditDraft ? () => onEditDraft(email) : undefined}
onDiscardDraft={draft && onDiscardDraft ? () => onDiscardDraft(email) : undefined}
onReply={!draft && onReply ? () => onReply(email) : undefined}
onReplyAll={!draft && onReplyAll ? () => onReplyAll(email) : undefined}
onForward={!draft && onForward ? () => onForward(email) : undefined}
onDownloadAttachment={onDownloadAttachment}
blobSource={blobSource}
onMarkAsRead={onMarkAsRead}
mailboxRole={mailboxRole}
/>
))}
);
})}
</div>
</div>
</div>
@@ -317,6 +330,9 @@ interface EmailCardProps {
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
isDraft?: boolean;
onEditDraft?: () => void;
onDiscardDraft?: () => void;
onDownloadAttachment?: ConversationAttachmentHandler;
/** Routing captured when this conversation was opened. */
blobSource?: EmailBlobContext;
@@ -355,6 +371,9 @@ function EmailCard({
onReply,
onReplyAll,
onForward,
isDraft = false,
onEditDraft,
onDiscardDraft,
onDownloadAttachment,
blobSource,
onMarkAsRead,
@@ -410,6 +429,11 @@ function EmailCard({
</span>
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
<ReceivedLabelChip email={email} />
{isDraft && (
<span data-testid="conversation-draft-label" className="text-xs font-medium text-destructive shrink-0">
{t('email_list.draft')}
</span>
)}
{email.isScheduled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary shrink-0">
<CalendarClock className="w-3.5 h-3.5" />
@@ -475,6 +499,34 @@ function EmailCard({
in the single reader's More panel on phones. Long names (Arabic
"Reply all") wrap to a second line instead of being cut. */}
<div className="px-4 pb-4 flex flex-wrap gap-2">
{onEditDraft && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onEditDraft();
}}
className="flex-auto px-2 whitespace-nowrap"
>
<Pencil className="w-4 h-4 me-1.5 shrink-0" />
{t("email_viewer.edit_draft")}
</Button>
)}
{onDiscardDraft && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDiscardDraft();
}}
className="flex-auto px-2 whitespace-nowrap"
>
<Trash2 className="w-4 h-4 me-1.5 shrink-0" />
{t("email_composer.discard")}
</Button>
)}
{onReply && (
<Button
variant="outline"
@@ -0,0 +1,245 @@
import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useToastStore } from '@/stores/toast-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// One composer at a time (compose-p2-1, compose-p2-2). Another one opening
// while a composer is on screen closes that one the way its X does (save,
// "Draft saved"), then opens. It never crashes and never drops the text.
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
// What the stub composer's close does: 'saves' closes and lets the next one
// open, 'fails' keeps the composer (a save the server refused).
const stub = vi.hoisted(() => ({
close: 'none' as 'none' | 'saves' | 'fails',
closes: [] as string[],
mounts: [] as string[],
restore: null as null | ((data: Record<string, unknown>) => void),
}));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onCompose }: { onCompose: () => void }) => <button onClick={onCompose}>stub compose</button>,
}));
// As the real composer: it hands its state (its own mode, untouched) to the
// host as it unmounts without an explicit close, and offers its close.
vi.mock('@/components/email/email-composer', async () => {
const { useEffect, useRef } = await import('react');
return {
EmailComposer: ({ mode, onSaveState, onClose, requestCloseRef, onRestoreDiscarded }: {
mode: string;
onRestoreDiscarded?: (data: Record<string, unknown>) => void;
onSaveState: (data: Record<string, unknown>) => void;
onClose: () => void;
requestCloseRef?: { current: unknown };
}) => {
const closed = useRef(false);
useEffect(() => {
stub.mounts.push(mode);
stub.restore = onRestoreDiscarded ?? null;
// The old host moved the composer between dock and pane for ever
// (React error 185 in the browser); stop it here instead of hanging.
if (stub.mounts.length > 40) throw new Error('composer remount loop');
if (requestCloseRef && stub.close !== 'none') {
requestCloseRef.current = (afterClose?: () => void) => {
stub.closes.push(mode);
if (stub.close === 'fails') return;
closed.current = true;
onClose();
afterClose?.();
};
}
return () => {
if (requestCloseRef) requestCloseRef.current = null;
if (closed.current) return;
onSaveState({ to: '', cc: '', bcc: '', subject: '', body: '', mode, draftId: null, unedited: true });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div data-testid="stub-composer" data-mode={mode}>stub composer</div>;
},
};
});
vi.mock('@/components/search/mv-search-form', () => ({
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
}));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ onReply, onReplyAll, onForward }: { onReply: () => void; onReplyAll: () => void; onForward: () => void }) => (
<div data-testid="viewer">
message viewer
<button onClick={() => onReply()}>stub reply</button>
<button onClick={() => onReplyAll()}>stub reply all</button>
<button onClick={() => onForward()}>stub forward</button>
</div>
),
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => device.current,
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({
EmailList: () => null,
}));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
const MESSAGE = {
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }],
subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10,
hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {},
} as never;
beforeEach(() => {
device.current = { isMobile: false, isTablet: false, isDesktop: true };
stub.close = 'none';
stub.closes = [];
stub.mounts = [];
stub.restore = null;
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
const client = {
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
getEmail: vi.fn().mockResolvedValue(null),
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
useEmailStore.setState({
emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
selectedEmailKeys: new Set(), unreadSection: null,
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
} as never);
useToastStore.getState().clearToasts();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
useToastStore.getState().clearToasts();
});
const composerMode = () => screen.getByTestId('stub-composer').getAttribute('data-mode');
describe('Compose while a reply is docked (compose-p2-1)', () => {
for (const action of ['stub reply', 'stub forward']) {
it(`opens a new message in the pane after ${action}`, async () => {
render(<MailApp />);
fireEvent.click(await screen.findByText(action));
const reply = await screen.findByTestId('stub-composer');
expect(reply.closest('[data-reply-dock]')).not.toBeNull();
fireEvent.click(screen.getByText('stub compose'));
await waitFor(() => expect(composerMode()).toBe('compose'));
// The new message takes the pane, not the dock, and stays put.
expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).toBeNull();
const mounted = stub.mounts.length;
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
expect(stub.mounts.length).toBe(mounted);
expect(composerMode()).toBe('compose');
});
}
});
describe('another composer while one is open (compose-p2-2)', () => {
it('closes the reply through its own close (save, "Draft saved") before Compose opens', async () => {
stub.close = 'saves';
render(<MailApp />);
fireEvent.click(await screen.findByText('stub reply'));
await screen.findByTestId('stub-composer');
fireEvent.click(screen.getByText('stub compose'));
await waitFor(() => expect(composerMode()).toBe('compose'));
expect(stub.closes).toEqual(['reply']);
});
for (const [action, mode] of [['stub reply all', 'replyAll'], ['stub forward', 'forward']] as const) {
it(`closes the reply through its own close before ${action} opens`, async () => {
stub.close = 'saves';
render(<MailApp />);
fireEvent.click(await screen.findByText('stub reply'));
await screen.findByTestId('stub-composer');
fireEvent.click(screen.getByText(action));
await waitFor(() => expect(composerMode()).toBe(mode));
expect(stub.closes).toEqual(['reply']);
});
}
it('closes a new message before another new message opens', async () => {
stub.close = 'saves';
render(<MailApp />);
fireEvent.click(screen.getByText('stub compose'));
await screen.findByTestId('stub-composer');
const mounted = stub.mounts.length;
fireEvent.click(screen.getByText('stub compose'));
await waitFor(() => expect(stub.mounts.length).toBe(mounted + 1));
expect(stub.closes).toEqual(['compose']);
});
it('keeps the open composer when its save fails', async () => {
stub.close = 'fails';
render(<MailApp />);
fireEvent.click(await screen.findByText('stub reply'));
await screen.findByTestId('stub-composer');
const mounted = stub.mounts.length;
fireEvent.click(screen.getByText('stub compose'));
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
expect(stub.closes).toEqual(['reply']);
expect(stub.mounts.length).toBe(mounted);
expect(composerMode()).toBe('reply');
});
});
// compose-p2-10: Undo after the bin brings the composer back.
describe('Undo after the bin', () => {
it('opens the discarded text again, closing whatever is open first', async () => {
stub.close = 'saves';
render(<MailApp />);
fireEvent.click(screen.getByText('stub compose'));
await screen.findByTestId('stub-composer');
const restore = stub.restore!;
act(() => restore({ to: 'bob@example.org', cc: '', bcc: '', subject: 'Back', body: 'kept', mode: 'forward', draftId: 'd1' }));
await waitFor(() => expect(composerMode()).toBe('forward'));
expect(stub.closes).toEqual(['compose']);
});
});
@@ -0,0 +1,160 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useToastStore } from '@/stores/toast-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// Drafts reopen where people look for them (compose-p2-6, compose-p2-8):
// a click on a draft opens it in the composer, and Reply on a message with a
// reply draft opens that draft instead of starting a second one.
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
const DRAFT_ROW = vi.hoisted(() => ({
id: 'd1', threadId: 't1', mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true },
from: [{ email: 'a@example.org' }], to: [{ email: 'ann@example.org' }],
subject: 'Re: Plans', receivedAt: '2026-11-02T10:00:00Z', preview: 'my answer', size: 10, hasAttachment: false,
}));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/email/email-composer', () => ({
EmailComposer: ({ mode, initialData }: { mode: string; initialData?: { draftId?: string } | null }) => (
<div data-testid="stub-composer" data-mode={mode} data-draft={initialData?.draftId ?? ''}>stub composer</div>
),
}));
vi.mock('@/components/search/mv-search-form', () => ({
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
}));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ onReply, replyDraft }: { onReply: () => void; replyDraft?: { preview?: string; onEdit: () => void } | null }) => (
<div data-testid="viewer">
message viewer
<button onClick={() => onReply()}>stub reply</button>
{replyDraft && <button onClick={replyDraft.onEdit}>stub reply draft: {replyDraft.preview}</button>}
</div>
),
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => device.current,
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({
EmailList: ({ onEmailSelect }: { onEmailSelect: (email: unknown) => void }) => (
<button onClick={() => onEmailSelect(DRAFT_ROW)}>stub draft row</button>
),
}));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
const drafts = { id: 'drafts', name: 'Drafts', role: 'drafts', totalEmails: 1, unreadEmails: 0 } as Mailbox;
const MESSAGE = {
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }],
subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10,
hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, messageId: ['plans@example.org'],
} as never;
const FULL_DRAFT = {
...DRAFT_ROW, textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], bodyValues: { 1: { value: 'my answer' } },
inReplyTo: ['plans@example.org'],
};
type Client = Record<string, unknown> & { getEmail: ReturnType<typeof vi.fn> };
let client: Client;
beforeEach(() => {
device.current = { isMobile: false, isTablet: false, isDesktop: true };
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
client = {
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
getEmail: vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT } : null)),
getThread: vi.fn(async () => ({ id: 't1', emailIds: ['m1', 'd1'] })),
getSomeEmails: vi.fn(async () => [{ ...(MESSAGE as object) }, { ...DRAFT_ROW }]),
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
useEmailStore.setState({
emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, drafts],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
selectedEmailKeys: new Set(), unreadSection: null,
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
} as never);
useToastStore.getState().clearToasts();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
useToastStore.getState().clearToasts();
});
describe('a draft in the list (compose-p2-8)', () => {
it('opens in the composer on a click', async () => {
render(<MailApp />);
fireEvent.click(await screen.findByText('stub draft row'));
const composer = await screen.findByTestId('stub-composer');
expect(composer).toHaveAttribute('data-draft', 'd1');
expect(composer).toHaveAttribute('data-mode', 'compose');
});
});
describe('a message with a reply draft (compose-p2-6)', () => {
it('shows the draft under the message', async () => {
render(<MailApp />);
expect(await screen.findByText('stub reply draft: my answer')).toBeInTheDocument();
});
it('opens that draft on Reply instead of a new reply', async () => {
render(<MailApp />);
await screen.findByText('stub reply draft: my answer');
fireEvent.click(screen.getByText('stub reply'));
await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', 'd1'));
// Under the message it answers, as a new reply is, not over it.
expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).not.toBeNull();
});
it('starts a new reply when the conversation has no reply to this message', async () => {
client.getEmail = vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT, inReplyTo: ['other@example.org'] } : null));
render(<MailApp />);
await waitFor(() => expect(client.getEmail).toHaveBeenCalled());
expect(screen.queryByText(/stub reply draft/)).toBeNull();
fireEvent.click(screen.getByText('stub reply'));
await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-mode', 'reply'));
expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', '');
});
});
@@ -114,11 +114,26 @@ describe('undo send toast', () => {
await waitFor(() => expect(undoToast()).toBeDefined());
const toast = undoToast()!;
expect(toast.title).toBe('email_viewer.sending_in');
// 30 s is the setting, but only 10 s are left of the window.
expect(toast.duration).toBeLessThan(10_000);
// 30 s is the setting, but only 10 s are left of the window. The toast
// itself stays a moment longer, to say "Sent" once Undo is over.
expect(toast.duration).toBeLessThanOrEqual(10_000);
expect(toast.pauseOnHover).toBe(false);
});
// compose-p2-12: once Undo is over the toast says the message went.
it('turns into "Sent" with View before the server releases the message', async () => {
render(<MailApp />);
sendDelayedBy(2_500);
await waitFor(() => expect(undoToast()).toBeDefined());
const id = undoToast()!.id;
await waitFor(() => {
const toast = useToastStore.getState().toasts.find((t) => t.id === id);
expect(toast?.title).toBe('compose_send.sent');
expect(toast?.action?.label).toBe('compose_send.view');
}, { timeout: 3000 });
});
// compose-23: "Email scheduled for sending" with "Undo send" said neither
// when it goes nor that the button cancels it.
it('says when a scheduled message goes, with Cancel and Send now', async () => {
+218 -42
View File
@@ -43,9 +43,10 @@ import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
import { toast } from "@/stores/toast-store";
import { discardWithUndo } from "@/lib/compose/draft-close-toast";
import { findReplyDraft, threadDrafts } from "@/lib/reply-drafts";
import { cancelFailureToast, partlyRefusedWarning, rescheduleFailureToast, rescheduledToast } from "@/lib/send-failure-message";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { startSendProgress } from "@/lib/outgoing/send-progress-toast";
import { runUndoSendCountdown, startSendProgress, UNDO_TOAST_SLACK_MS } from "@/lib/outgoing/send-progress-toast";
import { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client";
import { subscribePushStateChanges } from "@/lib/push-events";
import { createMailPushListener } from "@/lib/mail-push-listener";
@@ -305,7 +306,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
composerSessionRef.current = composerSessionId;
const composerHostRef = useRef('pane');
// The compose session docked under its message (dockedReply).
const dockedSessionRef = useRef<number | null>(null);
const [dockedSession, setDockedSession] = useState<number | null>(null);
// A reply draft opened again from its message (compose-p2-6): it docks too.
const [resumedReplySession, setResumedReplySession] = useState<number | null>(null);
const showComposerRef = useRef(showComposer);
showComposerRef.current = showComposer;
// Fields a plugin filled in through onBeforeCompose for the composer about
@@ -320,6 +323,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// already uses the same seam), so entry points that replace the session can
// ask before discarding unsaved text.
const composerRequestCloseRef = useRef<((afterClose?: () => void) => void) | null>(null);
// One composer at a time (compose-p2-2). Whatever opens a composer closes
// the one on screen first, the way its X does: it saves and says "Draft
// saved" with Discard, then the new one opens. When that save fails the old
// composer stays with its question, and this never settles: nothing opens.
const closeOpenComposer = useCallback(() => new Promise<void>((resolve) => {
const requestClose = composerRequestCloseRef.current;
if (showComposerRef.current && requestClose) requestClose(resolve);
else resolve();
}), []);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -358,6 +370,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
threadId: string; emailId: string; startedAt: number; promise: Promise<Email[] | null>;
} | null>(null);
const mvConversationOpenSeqRef = useRef(0);
// compose-p2-6: a reply to the open message saved as a draft. The reader
// shows it under the message, and Reply opens it again. The conversation
// fetch notes the thread's drafts; a scan reads them fresh after a composer
// closed or a draft went.
const [replyDraft, setReplyDraft] = useState<{ emailId: string; draft: Email; source: DraftSource } | null>(null);
const [replyDraftScan, setReplyDraftScan] = useState<{ emailId: string; n: number } | null>(null);
const conversationDraftsRef = useRef<{ threadId: string; emailId: string; drafts: Email[] } | null>(null);
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
@@ -1177,12 +1196,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
onCompose: () => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
void handleCompose();
},
onFocusSearch: () => {
if (isScheduledView) return;
@@ -2859,11 +2873,51 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setPendingDraft(null);
}, []);
// A new message, from every Compose and New message there is.
const handleCompose = async (onOpen?: () => void) => {
await closeOpenComposer();
await withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
onOpen?.();
});
};
// A minimised composer's edits live only in pendingDraft. Before an
// automatic sign-out it is opened again and saves them itself.
useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true));
// Reply on a message with a reply draft opens that draft (compose-p2-6).
// After a composer closed (it may have saved that very draft under a new
// id) the drafts are read fresh.
const resumeReplyDraft = async (open: Email | null, composerWasOpen: boolean): Promise<boolean> => {
if (!client || !open || open.keywords?.$draft || mvShell || isScheduledView) return false;
let found = replyDraft?.emailId === open.id ? replyDraft : null;
// A phone's conversation holds its drafts in full.
const phoneConversation = isMobile && conversationEmails.some((e) => e.id === open.id);
if (composerWasOpen || phoneConversation) {
const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client);
if (!threadClient) return false;
const drafts = phoneConversation && !composerWasOpen
? conversationEmails
: await threadDrafts(threadClient, open.threadId, accountId).catch(() => []);
const draft = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId));
found = draft ? {
emailId: open.id, draft,
source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined },
} : null;
}
if (!found) return false;
await handleEditDraft(found.draft, found.source, { underMessage: true });
return true;
};
const handleReply = async (draftText?: string) => {
const composerWasOpen = showComposer;
await closeOpenComposer();
if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return;
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReply.transform(selectedEmail);
@@ -2895,13 +2949,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
* undone or cancelled - the account it was sent from (a group or shared one
* for mail sent from its identity) and the login that sent it.
*/
const handleEditDraft = async (email?: Email, source?: DraftSource) => {
// `underMessage`: a reply draft resumed from the message it answers docks
// under that message, as a new reply does (compose-p2-6).
const handleEditDraft = async (email?: Email, source?: DraftSource, { underMessage = false }: { underMessage?: boolean } = {}) => {
if (!client) return;
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
? email
: selectedEmail;
let draft = draftCandidate;
if (!draft) return;
await closeOpenComposer();
// The login and account the draft lives in (resolveDraftSource). The
// composer removes the old version there when it saves, sends or
@@ -2943,7 +3000,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
setComposerSessionId(id => id + 1);
const nextSession = composerSessionRef.current + 1;
setComposerSessionId(nextSession);
setResumedReplySession(underMessage ? nextSession : null);
setPendingDraft(composerDataFromDraft(draft, {
accountId,
localAccountId,
@@ -3063,10 +3122,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A message scheduled for later says when it goes, and offers Cancel
// where a send-delay window offers Undo send.
toast.success(isUndoWindow
const undoToastId = toast.success(isUndoWindow
? t('email_viewer.sending_in', { seconds: Math.round(remainingMs / 1000) })
: t('compose_send.scheduled_for', { when: formatScheduledSendAt(pending.sendAt, useSettingsStore.getState().timeFormat) }), {
duration: undoDurationMs,
// An undo window turns into "Sent" as Undo ends (runUndoSendCountdown).
duration: isUndoWindow ? undoDurationMs + UNDO_TOAST_SLACK_MS : undoDurationMs,
pauseOnHover: false,
secondaryAction: (pending.emailId && pending.identityId)
? {
@@ -3138,6 +3198,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
});
// Counts down, then "Sent" with View (compose-p2-12).
if (isUndoWindow) {
const sentId = pending.emailId;
const viewable = sentId && undoClient === client;
const ownerAccountId = pending.submissionAccountId && pending.submissionAccountId !== client.getAccountId() ? pending.submissionAccountId : undefined;
runUndoSendCountdown(undoToastId, {
sendAt: Date.parse(pending.sendAt),
undoEndsAt: Date.now() + undoDurationMs,
maxSeconds: sendDelaySeconds,
sendingIn: (seconds) => t('email_viewer.sending_in', { seconds }),
sent: t('compose_send.sent'),
view: viewable
? { label: t('compose_send.view'), onClick: () => void applyMailDeepLinkRef.current({ kind: 'message', id: sentId, ownerAccountId }) }
: undefined,
});
}
const timer = setTimeout(() => {
const current = useEmailStore.getState().pendingUndoSend;
if (current?.submissionId === pending.submissionId) {
@@ -3156,6 +3233,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t, tComposer, tViewer]);
const handleReplyAll = async (draftText?: string) => {
const composerWasOpen = showComposer;
await closeOpenComposer();
if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return;
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReplyAll.transform(selectedEmail);
@@ -3245,6 +3325,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [isEmbedded]);
const handleForward = async () => {
await closeOpenComposer();
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToForward.transform(selectedEmail);
@@ -3291,6 +3372,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// unselected row. See the list context-menu wiring below.
const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => {
if (!email) return;
await closeOpenComposer();
email = await emailHooks.onBeforeComposeOpenToForwardAsAttachment.transform(email);
// Same filename options "Export as .eml" uses (see emailFilenameOptions
// in email-viewer.tsx), so the two actions produce consistent filenames
@@ -3359,6 +3441,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const handleBatchForwardAsAttachment = async () => {
const selected = resolveSelectedEmails(useEmailStore.getState());
if (selected.length < 2) return; // menu only offers this for >1; guards future wiring
await closeOpenComposer();
const {
emailDownloadTemplate,
@@ -4750,6 +4833,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId;
const annotated = annotateScheduledEmails(emails, scheduledSubmissionByEmailId);
conversationDraftsRef.current = {
threadId: ref.threadId, emailId: ref.id,
drafts: annotated.filter((e) => e.id !== ref.id && e.keywords?.$draft),
};
return annotated
.filter((e) => e.id === ref.id || !e.keywords?.$draft)
.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
@@ -4779,6 +4866,62 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message, not on every list refresh
}, [mvConversationThreadId, mvConversationRef?.id, mvBlobSource.client, mvBlobSource.accountId, client]);
// The open message's reply draft (compose-p2-6). Not in Mountain View, which
// has its own conversation, nor on a phone, whose conversation lists drafts.
const replyDraftMessageIds = String(selectedEmail?.messageId ?? '');
useEffect(() => {
const open = selectedEmail;
if (!client || !open || open.keywords?.$draft || mvShell || isMobile || isScheduledView || !replyDraftMessageIds) {
setReplyDraft(null);
return;
}
const fresh = replyDraftScan?.emailId === open.id;
const noted = conversationDraftsRef.current;
const known = noted && noted.threadId === open.threadId && noted.emailId === open.id ? noted.drafts : null;
if (!fresh && !known) {
// The conversation isn't here yet: keep what this message had.
setReplyDraft((prev) => (prev?.emailId === open.id ? prev : null));
return;
}
if (!fresh && known?.length === 0) {
setReplyDraft(null);
return;
}
const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client);
if (!threadClient) return;
let cancelled = false;
void (async () => {
const drafts = fresh ? await threadDrafts(threadClient, open.threadId, accountId).catch(() => []) : known ?? [];
const found = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId));
if (cancelled) return;
setReplyDraft(found ? {
emailId: open.id,
draft: { ...found, sourceClientAccountId: open.sourceClientAccountId, sourceAccountId: open.sourceAccountId },
source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined },
} : null);
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message and what can change its drafts
}, [selectedEmail?.id, replyDraftMessageIds, mvConversationResult, replyDraftScan, client, mvShell, isMobile, isScheduledView]);
// A composer closing may have saved, sent or discarded a reply draft.
const scanReplyDrafts = () => {
const open = useEmailStore.getState().selectedEmail;
if (open) setReplyDraftScan((prev) => ({ emailId: open.id, n: (prev?.n ?? 0) + 1 }));
};
const composerWasOpenRef = useRef(showComposer);
useEffect(() => {
if (composerWasOpenRef.current && !showComposer) scanReplyDrafts();
composerWasOpenRef.current = showComposer;
}, [showComposer]);
const discardReplyDraft = () => {
const kept = replyDraft;
if (!kept) return;
setReplyDraft(null);
discardWithUndo(() => {
void handleDiscardDraft(kept.draft.id, kept.source).then(scanReplyDrafts);
}, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setReplyDraft(kept));
};
const patchMvConversation = (id: string, patch: (e: Email) => Email) =>
setMvConversationResult(prev => prev ? {
...prev, emails: prev.emails.map(e => e.id === id ? patch(e) : e),
@@ -4852,14 +4995,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onImportEmail: handleImportEmailFromContextMenu,
onRefreshMailboxes: handleRefreshMailboxes,
onCompose: () => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) {
setSidebarOpen(false);
setActiveView('viewer');
}
void handleCompose(() => {
if (isMobile) setSidebarOpen(false);
});
},
onSidebarClose: () => setSidebarOpen(false),
@@ -4957,9 +5094,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Decided as it opens and kept for the session: moving it would mount it
// anew when the message goes away (another folder opened meanwhile).
const dockCandidate = !mvShell && !isMobile && showComposer && !isEmbedded && !floatingComposer
&& (pendingDraft?.mode ?? composerMode) !== 'compose';
if (dockCandidate && selectedEmail && !selectedEmail.keywords?.$draft) dockedSessionRef.current = composerSessionId;
const dockedReply = dockCandidate && dockedSessionRef.current === composerSessionId;
&& ((pendingDraft?.mode ?? composerMode) !== 'compose' || resumedReplySession === composerSessionId);
const dockOnOpen = dockCandidate && Boolean(selectedEmail) && !selectedEmail?.keywords?.$draft;
if (dockOnOpen && dockedSession !== composerSessionId) setDockedSession(composerSessionId);
const dockedReply = dockCandidate && (dockOnOpen || dockedSession === composerSessionId);
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane';
composerHostRef.current = composerHost;
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
@@ -5189,6 +5327,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Handle reply from conversation view
const handleConversationReply = async (email: Email) => {
const composerWasOpen = showComposer;
await closeOpenComposer();
if (await resumeReplyDraft(email, composerWasOpen)) return;
email = await emailHooks.onBeforeComposeOpenToReply.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
@@ -5201,6 +5342,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
const handleConversationReplyAll = async (email: Email) => {
const composerWasOpen = showComposer;
await closeOpenComposer();
if (await resumeReplyDraft(email, composerWasOpen)) return;
email = await emailHooks.onBeforeComposeOpenToReplyAll.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
@@ -5213,6 +5357,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
const handleConversationForward = async (email: Email) => {
await closeOpenComposer();
email = await emailHooks.onBeforeComposeOpenToForward.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'forward');
@@ -5310,6 +5455,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
sessionKey={`inline-${composerSessionId}`}
sourceEmailId={answeredEmailId}
onSaveState={(data) => {
// A composer whose session another one has replaced: its state
// belongs to no composer on screen. Taken as the new session's,
// it turned a new message into the old reply and moved it between
// the dock and the pane for ever (React error 185, compose-p2-1).
// Whatever opens a composer closes the old one first, which saves it.
if (composerSessionRef.current !== composerSessionId) return;
setComposerPrefill(null);
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
suppressComposerStateSaveSessionRef.current = null;
@@ -5341,6 +5492,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const outcome = await handleEmailSend(data);
setPendingDraft(null);
setComposerPrefill(null);
scanReplyDrafts();
return outcome;
}}
onSendFailed={(state) => {
@@ -5371,7 +5523,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
requestCloseRef={composerRequestCloseRef}
onDiscardDraft={(draftId, draft) => {
handleDiscardDraft(draftId, draft);
setPendingDraft(null);
// The bin and the toasts' Discard delete once their Undo is over,
// when another composer may be open: its draft stays.
if (composerSessionRef.current === composerSessionId) setPendingDraft(null);
}}
onRestoreDiscarded={(data) => {
void closeOpenComposer().then(() => {
startFreshComposerSession();
setPendingDraft(data);
setComposerMode(data.mode);
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
}}
popOut={popoutsOn && !isEmbedded ? composerPopOut : undefined}
/>
@@ -5853,7 +6016,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
onRescheduleScheduled={(email) => setRescheduleTarget(email)}
onCancelScheduledSend={handleCancelScheduledSend}
onEmailSelect={handleEmailSelect}
// A click (or Enter) on a draft opens it in the composer, as
// Gmail and Outlook do (compose-p2-8). j/k still read it.
// Mountain View and Scheduled keep their own ways.
onEmailSelect={!mvShell && !isScheduledView && !isEmbedded ? (email) => {
if (email.keywords?.$draft) void handleEditDraft(email).catch(reportDraftOpenFailure);
else void handleEmailSelect(email);
} : handleEmailSelect}
onOpenSearchResult={handleEmailSelect}
onEmailPrefetch={handleEmailPrefetch}
onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => {
@@ -5916,12 +6085,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
onClearSearch={() => void handleClearSearch()}
onCompose={() => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
void handleCompose();
}}
onMarkAsRead={async (email, read) => {
await markReadWithUndo(email, read).catch(toastUpdateFailure);
@@ -5964,12 +6128,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
button no longer sits on the list's rows. */}
{!mvShell && (isMobile || isEmbedded) && <Button
onClick={() => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
void handleCompose();
}}
className={cn(
// Under the drawer's backdrop (z-40), which comes earlier in the page.
@@ -6097,6 +6256,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onReply={handleConversationReply}
onReplyAll={handleConversationReplyAll}
onForward={handleConversationForward}
onEditDraft={(draft) => {
void handleEditDraft(draft, {
accountId: conversationBlobSource?.accountId,
localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined,
}).catch(reportDraftOpenFailure);
}}
onDiscardDraft={(draft) => {
const before = conversationEmails;
setConversationEmails(before.filter((e) => e.id !== draft.id));
discardWithUndo(() => {
void handleDiscardDraft(draft.id, {
accountId: conversationBlobSource?.accountId,
localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined,
});
}, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setConversationEmails(before));
}}
blobSource={conversationBlobSource}
onDownloadAttachment={handleDownloadConversationAttachment}
onMarkAsRead={async (emailId, read) => {
@@ -6219,6 +6394,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} : undefined}
mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined}
replyComposerOpen={dockedReply}
replyDraft={replyDraft && selectedEmail && replyDraft.emailId === selectedEmail.id ? {
preview: replyDraft.draft.preview,
onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source, { underMessage: true }).catch(reportDraftOpenFailure),
onDiscard: discardReplyDraft,
} : null}
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
isFullscreen={fullscreenReading}
@@ -6244,11 +6424,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}}
onCompose={() => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
});
void handleCompose();
}}
emptyPaneContent={emptyPaneBesideList && selectedEmailKeys.size > 0 && !isScheduledView && !mvShell
? <div ref={setSelectionPaneHost} className="m-auto p-8" />
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIdentityStore } from '@/stores/identity-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useSettingsStore } from '@/stores/settings-store';
import { emailHooks } from '@/lib/plugin-hooks';
// The composer's window: when it tells the opening window to let go, and what
@@ -610,11 +611,51 @@ describe('a send held for undo', () => {
expect(closeSelf).not.toHaveBeenCalled();
const sent = heard.find((message) => message.type === 'sent') as Extract<PopoutMessage, { type: 'sent' }> | undefined;
expect(sent?.notice.pendingUndoSend ?? null).toBeNull();
// compose-p2-13: the main window asks for the Scheduled count again then.
expect(sent?.notice.releasesAt).toBe(sendAt);
});
// The server's time runs past the delay: "Sending in 6 seconds" for 5 s.
it('never counts from more than the delay the user picked', async () => {
const before = useSettingsStore.getState().sendDelaySeconds;
useSettingsStore.setState({ sendDelaySeconds: 5 } as never);
await sendFromWindow(5.8);
expect(screen.getByTestId('popout-sending')).toHaveAttribute('data-seconds', '5');
useSettingsStore.setState({ sendDelaySeconds: before } as never);
});
// compose-24: the window sat with a greyed Send until the server answered.
it('says "Sending…" as soon as Send goes through, before the server answers', async () => {
let answer!: () => void;
useEmailStore.setState({ sendEmail: vi.fn(() => new Promise((resolve) => { answer = () => resolve({}); })) } as never);
sendAt = new Date(Date.now() + 5000).toISOString();
window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
render(<PopoutComposeApp />);
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
let done: Promise<unknown> = Promise.resolve();
act(() => { done = (composer as unknown as Sending).onSend(DATA); });
expect(screen.getByTestId('popout-sending').textContent).toMatch(/sending$/);
await act(async () => { answer(); await done; });
});
it('shows the composer again with its text when the send fails', async () => {
useEmailStore.setState({ sendEmail: vi.fn(async () => { throw new Error('refused'); }) } as never);
sendAt = new Date(Date.now() + 5000).toISOString();
window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
render(<PopoutComposeApp />);
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
await act(async () => {
await expect((composer as unknown as Sending).onSend(DATA)).rejects.toThrow('refused');
});
expect(screen.queryByTestId('popout-sending')).toBeNull();
expect(screen.getByTestId('composer').closest('.hidden')).toBeNull();
});
it('closes once the undo time is over', async () => {
await sendFromWindow(1.2);
expect(closeSelf).not.toHaveBeenCalled();
// As the main window's toast: "Sent" once Undo is over (compose-24).
await waitFor(() => expect(screen.getByTestId('popout-sending').textContent).toMatch(/(^|\.)sent$/), { timeout: 3000 });
await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 });
});
@@ -629,7 +670,8 @@ describe('a send held for undo', () => {
it('a message scheduled for later still goes to the main window, and the window closes', async () => {
await sendFromWindow(3600);
expect(screen.queryByTestId('popout-sending')).toBeNull();
// "Sending…" may still show as the window goes, but no Undo here.
expect(screen.queryByRole('button', { name: 'undo_send' })).toBeNull();
expect(closeSelf).toHaveBeenCalled();
const sent = heard.find((message) => message.type === 'sent') as Extract<PopoutMessage, { type: 'sent' }> | undefined;
expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1');
@@ -309,6 +309,23 @@ describe('PopoutHost', () => {
expect(useToastStore.getState().toasts).toHaveLength(1);
});
// compose-p2-13: the Scheduled count kept a pop-out's undo-held send
// until the page was reloaded, since nothing reports the release.
it('asks for the Scheduled count again once a send held in a pop-out has gone', async () => {
const refreshScheduledMetadata = vi.fn(async () => {});
useEmailStore.setState({ refreshScheduledMetadata: refreshScheduledMetadata as never });
render(<PopoutHost />);
await settle();
popout.post({ type: 'sent', requestId: 'r1', notice: { accountId: 'alice', scheduled: true, releasesAt: new Date(Date.now() + 100).toISOString() } });
popout.post({ type: 'sent', requestId: 'r2', notice: { accountId: 'alice', scheduled: true, releasesAt: '2030-01-01T00:00:00Z' } });
await settle();
expect(refreshScheduledMetadata).not.toHaveBeenCalled();
await settle(700);
// Once, for the held one; the one scheduled for 2030 stays in the count.
expect(refreshScheduledMetadata).toHaveBeenCalledTimes(1);
expect(refreshScheduledMetadata).toHaveBeenCalledWith(client);
});
it('says once that a closed pop-out kept its draft, with Discard', async () => {
render(<><PopoutHost /><PopoutHost /></>);
await settle();
@@ -320,6 +337,18 @@ describe('PopoutHost', () => {
expect(toasts[0].action?.label).toBe('email_composer.discard');
});
// compose-p2-10: the window's bin.
it('says a pop-out discarded its draft, with Undo', async () => {
render(<PopoutHost />);
await settle();
popout.post({ type: 'draft-kept', requestId: 'k2', notice: { draftId: 'd1', localAccountId: 'alice', discarded: true } });
await settle();
const toasts = useToastStore.getState().toasts;
expect(toasts).toHaveLength(1);
expect(toasts[0].title).toBe('compose_ux.draft_discarded');
expect(toasts[0].action?.label).toBe('compose_ux.undo');
});
it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => {
render(<PopoutHost />);
await settle();
+44 -6
View File
@@ -11,6 +11,8 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePluginStore } from "@/stores/plugin-store";
import { toast } from "@/stores/toast-store";
import { useSettingsStore } from "@/stores/settings-store";
import { secondsUntilSend } from "@/lib/outgoing/send-progress-toast";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { debug } from "@/lib/debug";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
@@ -68,6 +70,17 @@ interface ReopenTarget {
ownerAccountId?: string;
}
/** "Sending…" or "Sent", where the composer was, with the send icon. */
function SendStatus({ text, seconds, children }: { text: string; seconds?: number; children?: React.ReactNode }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending" data-seconds={seconds}>
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<p role="status" className="text-sm text-foreground">{text}</p>
{children}
</div>
);
}
/**
* A send held for undo, shown in the window it was sent from: that's where the
* user is looking, and quick compose is used with the main window out of sight.
@@ -80,6 +93,7 @@ function SendingWithUndo({ held, onUndone, onGone }: {
onGone: () => void;
}) {
const tViewer = useTranslations("email_viewer");
const tSend = useTranslations("compose_send");
const [now, setNow] = useState(() => Date.now());
const [busy, setBusy] = useState(false);
const sendAt = Date.parse(held.sendAt);
@@ -123,17 +137,18 @@ function SendingWithUndo({ held, onUndone, onGone }: {
onUndone(held.isSmime ? null : restored);
};
const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000));
// As the main window's toast counts: never past the delay the user picked.
const seconds = secondsUntilSend(sendAt, now, useSettingsStore.getState().sendDelaySeconds);
// Once Undo is over, "Sent" for the moment before the window goes, as the
// main window's toast says it.
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending">
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<p role="status" className="text-sm text-foreground">{tViewer("sending_in", { seconds })}</p>
<SendStatus text={over && !busy ? tSend("sent") : tViewer("sending_in", { seconds })} seconds={over ? undefined : seconds}>
{!over && (
<Button onClick={() => void undo()} disabled={busy} data-testid="popout-undo-send">
{tViewer("undo_send")}
</Button>
)}
</div>
</SendStatus>
);
}
@@ -324,7 +339,11 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
: t("email_composer.new_message"),
);
const handleSend = useCallback(async (data: SendData) => {
// "Sending…" from the moment Send goes through, as the main window says it
// (compose-24); the composer waited greyed out for the server before. It
// stays mounted behind, and comes back with the text if the send fails.
const [sending, setSending] = useState(false);
const sendFromWindow = useCallback(async (data: SendData) => {
if (!client) return false;
const sendClient = data.localAccountId
? (useAuthStore.getState().getClientForAccount(data.localAccountId) ?? client)
@@ -381,6 +400,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
filingError: !!result.filingError,
rejectedRecipients,
pendingUndoSend: keepHere ? null : withLogin,
releasesAt: keepHere?.sendAt,
});
// No main window to say so, and this is the only notice these get (#1123).
if (!taken && (result.filingError || rejectedRecipients)) {
@@ -389,6 +409,16 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
await new Promise((resolve) => setTimeout(resolve, WARNING_READ_MS));
}
}, [client, mode, payload.sourceEmailId, payload.sourceEmailSource, t]);
const handleSend = useCallback(async (data: SendData) => {
if (!client) return false;
if (!data.delayedUntil) setSending(true);
try {
return await sendFromWindow(data);
} catch (error) {
setSending(false);
throw error;
}
}, [client, sendFromWindow]);
const handleDiscardDraft = useCallback(async (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => {
if (!client) return;
@@ -491,6 +521,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
onDraftKept: (draftId: string, where: { accountId?: string; localAccountId?: string }) => {
announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined });
},
onDraftDiscarded: (draftId: string, where: { accountId?: string; localAccountId?: string }) => {
announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined, discarded: true });
},
onDraftSaved: (draftId: string, where: { accountId?: string; localAccountId?: string } = {}) => {
const active = useAuthStore.getState().activeAccountId;
announceMailChanged(active);
@@ -510,6 +543,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
if (held) return <SendingWithUndo held={held} onUndone={heldUndone} onGone={heldGone} />;
return (
<>
{sending && <SendStatus text={t("compose_send.sending")} />}
<div className={sending ? "hidden" : "contents"}>
<ErrorBoundary fallback={ComposerErrorFallback}>
<EmailComposer
mode={mode}
@@ -532,6 +568,8 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
className="min-h-0 flex-1"
/>
</ErrorBoundary>
</div>
</>
);
}
+32 -5
View File
@@ -8,7 +8,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { showDraftSavedToast } from "@/lib/compose/draft-close-toast";
import { discardWithUndo, showDraftSavedToast } from "@/lib/compose/draft-close-toast";
import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel";
import { takeOverMoved } from "@/lib/popout/moved-undo";
import { keepRescuedDraft } from "@/lib/popout/rescued-drafts";
@@ -24,6 +24,10 @@ const LIST_CHANGE_DEBOUNCE_MS = 400;
const CLAIM_HOLD_MS = 15_000;
/** How long a window remembers which draft id a rescue became, for a reload of the pop-out. */
const RESCUE_MEMORY_MS = 60_000;
/** The Scheduled count is asked again this long after the server let a held send go. */
const RELEASE_REFRESH_MS = 3000;
/** Longer holds are messages scheduled for later, which stay in the count. */
const UNDO_HOLD_MS = 120_000;
/** The sessions this window can lend. Empty until it is signed in, and in the demo. */
function shareableSessions(): SharedAccountSession[] {
@@ -112,8 +116,26 @@ export function PopoutHost() {
}, REFRESH_THROTTLE_MS);
};
// The Scheduled count holds a send while the server holds it. Nothing
// reports the release (compose-p2-13): ask again once it is out.
const releaseTimers = new Set<ReturnType<typeof setTimeout>>();
const refreshScheduled = () => {
const client = useAuthStore.getState().client;
if (client) void useEmailStore.getState().refreshScheduledMetadata(client);
};
const refreshScheduledAfter = (releasesAt: string) => {
const wait = Date.parse(releasesAt) - Date.now();
if (!Number.isFinite(wait) || wait > UNDO_HOLD_MS) return;
const timer = setTimeout(() => {
releaseTimers.delete(timer);
refreshScheduled();
}, Math.max(0, wait) + RELEASE_REFRESH_MS);
releaseTimers.add(timer);
};
const takeOverSent = (notice: PopoutSentNotice) => {
const translate = tRef.current;
if (notice.releasesAt) refreshScheduledAfter(notice.releasesAt);
if (notice.filingError) toast.warning(translate("email_composer.send_filing_warning"));
if (notice.rejectedRecipients) {
toast.warning(translate("email_composer.send_some_recipients_rejected"), { message: notice.rejectedRecipients, duration: 15000 });
@@ -180,10 +202,10 @@ export function PopoutHost() {
break;
case "draft-kept": {
// Said here, as the pop-out closes with its Escape or X.
const { draftId, accountId, localAccountId } = message.notice;
const { draftId, accountId, localAccountId, discarded } = message.notice;
claimOnce(`bulwark-popout-kept:${message.requestId}`, () => {
const tr = tRef.current;
showDraftSavedToast(() => {
const discard = () => {
const auth = useAuthStore.getState();
const login = localAccountId ? auth.getClientForAccount(localAccountId) : auth.client;
if (!login) {
@@ -191,12 +213,16 @@ export function PopoutHost() {
return;
}
void login.deleteEmail(draftId, accountId).catch((error: unknown) => console.error("Failed to discard draft:", error));
}, {
};
const labels = {
saved: tr("email_composer.draft_saved"),
discard: tr("email_composer.discard"),
discarded: tr("compose_ux.draft_discarded"),
undo: tr("compose_ux.undo"),
});
};
// The window's bin: Undo leaves the draft in Drafts.
if (discarded) discardWithUndo(discard, labels);
else showDraftSavedToast(discard, labels);
});
break;
}
@@ -272,6 +298,7 @@ export function PopoutHost() {
stopAccounts();
if (refreshTimer) clearTimeout(refreshTimer);
if (listTimer) clearTimeout(listTimer);
for (const timer of releaseTimers) clearTimeout(timer);
channel?.close();
};
}, [available]);
@@ -43,6 +43,14 @@ describe('toast layout', () => {
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20');
});
// compose-p2-17: "Draft saved" covered the quick reply box.
it('sits over the quick reply box, by its height', () => {
render(<Toasts />);
const container = toastContainer();
expect(container).toHaveClass('[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
expect(container).toHaveClass('max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
});
it('sits above a registered bottom bar on the desktop too, such as a form footer', () => {
render(<Toasts />);
const container = toastContainer();
+3
View File
@@ -475,10 +475,13 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
// lifted over a registered bottom bar (a form's Save/Cancel footer)
// and over an open composer's footer.
"fixed bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))] end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
// And over the reader's quick reply box (compose-p2-17).
"[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]",
// Phone: full width with 12px margins, above the bottom controls
// (the tab bar, or whichever bar registered with useBottomControls).
"max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]",
"max-sm:[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,3.75rem)+0.75rem))]",
"max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]",
// Out of the way of the focused element: the top end corner.
dodge && "top-5 bottom-auto!",
held && "invisible"
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { answersMessage, findReplyDraft, threadDrafts } from '@/lib/reply-drafts';
import type { Email } from '@/lib/jmap/types';
const message = { id: 'm1', messageId: ['abc@example.org'] as unknown as string };
const draft = (id: string, extra: Partial<Email> = {}) => ({
id, threadId: 't1', keywords: { $draft: true }, receivedAt: '2026-11-02T10:00:00Z', ...extra,
}) as Email;
describe('answersMessage', () => {
it('matches a draft whose In-Reply-To names the message, brackets or not', () => {
expect(answersMessage(draft('d1', { inReplyTo: ['<abc@example.org>'] }), message)).toBe(true);
expect(answersMessage(draft('d1', { inReplyTo: ['abc@example.org'] }), { messageId: 'abc@example.org' })).toBe(true);
});
it('leaves out a reply to another message, and a sent message', () => {
expect(answersMessage(draft('d1', { inReplyTo: ['other@example.org'] }), message)).toBe(false);
expect(answersMessage({ inReplyTo: ['abc@example.org'], keywords: {} }, message)).toBe(false);
});
});
describe('findReplyDraft', () => {
it('reads list rows without In-Reply-To in full and picks the newest that answers', async () => {
const full: Record<string, Email> = {
d1: draft('d1', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-01T10:00:00Z' }),
d2: draft('d2', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-03T10:00:00Z' }),
d3: draft('d3', { inReplyTo: ['other@example.org'], receivedAt: '2026-11-04T10:00:00Z' }),
};
const getEmail = vi.fn(async (id: string) => full[id] ?? null);
const rows = Object.values(full).map(({ inReplyTo: _drop, ...row }) => row as Email);
const found = await findReplyDraft(rows, message, getEmail);
expect(found?.id).toBe('d2');
expect(getEmail).toHaveBeenCalledWith('d3');
expect(getEmail).not.toHaveBeenCalledWith('d1');
});
it('finds nothing for a message without a Message-ID', async () => {
const getEmail = vi.fn();
expect(await findReplyDraft([draft('d1')], { id: 'm1' }, getEmail)).toBeNull();
expect(getEmail).not.toHaveBeenCalled();
});
});
describe('threadDrafts', () => {
it('returns the drafts of a conversation, and asks nothing more of a single message', async () => {
const client = {
getThread: vi.fn(async (id: string) => (id === 't1' ? { emailIds: ['m1', 'd1'] } : { emailIds: ['x'] })),
getSomeEmails: vi.fn(async () => [{ id: 'm1', keywords: {} }, draft('d1')] as Email[]),
};
expect((await threadDrafts(client, 't1', 'acc')).map((e) => e.id)).toEqual(['d1']);
expect(await threadDrafts(client, 't2', 'acc')).toEqual([]);
expect(client.getSomeEmails).toHaveBeenCalledTimes(1);
});
});
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { attachmentsForDisplay } from '../attachment-order';
describe('the order of the composer attachment chips', () => {
it('puts failed, then uploading files first, and keeps the rest in the order added', () => {
it('puts failed files first, and keeps the rest, uploading or not, in the order added', () => {
const files = [
{ name: 'a.pdf' },
{ name: 'b.pdf', uploading: true },
@@ -11,8 +11,8 @@ describe('the order of the composer attachment chips', () => {
{ name: 'broken.png', error: 'failed' },
];
const shown = attachmentsForDisplay(files);
expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'b.pdf', 'big.zip', 'a.pdf', 'c.pdf']);
expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'a.pdf', 'b.pdf', 'c.pdf', 'big.zip']);
// Each keeps its place in the composer's list, for Remove.
expect(shown.map(({ index }) => index)).toEqual([4, 1, 3, 0, 2]);
expect(shown.map(({ index }) => index)).toEqual([4, 0, 1, 2, 3]);
});
});
+5 -4
View File
@@ -1,13 +1,14 @@
/**
* The composer's attachment chips in the order they're shown: failed first,
* then still uploading, then the rest, each group in the order added. The
* list scrolls past about two rows, so the files that need an eye on them
* are the ones always in view. `index` is the place in the composer's list.
* then the rest in the order added. A file still uploading keeps its place:
* moved to the front, it jumped to the end once done and went out of sight
* past the rows on screen (compose-p2-11). `index` is the place in the
* composer's list.
*/
export function attachmentsForDisplay<T extends { error?: unknown; uploading?: boolean }>(
attachments: readonly T[],
): Array<{ att: T; index: number }> {
const rank = (att: T) => (att.error ? 0 : att.uploading ? 1 : 2);
const rank = (att: T) => (att.error ? 0 : 1);
return attachments
.map((att, index) => ({ att, index }))
.sort((a, b) => rank(a.att) - rank(b.att) || a.index - b.index);
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { toast, useToastStore } from '@/stores/toast-store';
import { runUndoSendCountdown } from '../send-progress-toast';
// compose-p2-12: "Sending in 5 seconds" stayed the same for 5 s and then
// went, with no word that the message went.
const T0 = Date.parse('2026-11-04T10:00:00Z');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(T0);
useToastStore.setState({ toasts: [] });
});
afterEach(() => {
vi.useRealTimers();
});
function start(view?: () => void, maxSeconds = 5) {
const id = toast.success('Sending in 5 seconds', {
duration: 5000,
pauseOnHover: false,
action: { label: 'Undo', onClick: () => {} },
secondaryAction: { label: 'Send now', onClick: () => {} },
});
const stop = runUndoSendCountdown(id, {
sendAt: T0 + 5600,
undoEndsAt: T0 + 4600,
maxSeconds,
sendingIn: (s) => `Sending in ${s} seconds`,
sent: 'Sent',
view: view ? { label: 'View', onClick: view } : undefined,
});
const current = () => useToastStore.getState().toasts.find((t) => t.id === id);
return { id, stop, current };
}
describe('the undo send toast', () => {
it('counts down, never above the delay the user picked', () => {
const { current } = start();
// 5.6 s left would round up to 6; the setting says 5.
expect(current()?.title).toBe('Sending in 5 seconds');
vi.advanceTimersByTime(1000);
expect(current()?.title).toBe('Sending in 5 seconds');
vi.advanceTimersByTime(1000);
expect(current()?.title).toBe('Sending in 4 seconds');
vi.advanceTimersByTime(1500);
expect(current()?.title).toBe('Sending in 3 seconds');
});
it('turns into "Sent" with View as Undo ends, with a fresh time to read it', () => {
const view = vi.fn();
const { current } = start(view);
vi.advanceTimersByTime(4600);
const shown = current()!;
expect(shown.title).toBe('Sent');
expect(shown.action?.label).toBe('View');
expect(shown.secondaryAction).toBeUndefined();
expect(shown.pauseOnHover).toBeUndefined();
expect(shown.duration).not.toBe(5000);
shown.action!.onClick();
expect(view).toHaveBeenCalled();
});
it('says "Sent" without View when the message can\'t be opened from here', () => {
const { current } = start(undefined);
vi.advanceTimersByTime(4600);
expect(current()?.title).toBe('Sent');
expect(current()?.action).toBeUndefined();
});
it('leaves a toast that is gone (Undo, Send now) alone', () => {
const { id, current } = start();
toast.dismiss(id);
vi.advanceTimersByTime(6000);
expect(current()).toBeUndefined();
expect(useToastStore.getState().toasts).toHaveLength(0);
});
});
+76 -1
View File
@@ -4,7 +4,7 @@
* stay on screen, greyed and without a word, until the server answered.
*/
import { toast } from '@/stores/toast-store';
import { toast, useToastStore } from '@/stores/toast-store';
import { announce } from '@/lib/announce';
import { effectiveToastDuration } from '@/lib/toast-duration';
@@ -46,3 +46,78 @@ export function startSendProgress(labels: SendProgressLabels): SendProgress {
},
};
}
/** How long past the end of Undo the undo toast is given, so the change to "Sent" comes first. */
export const UNDO_TOAST_SLACK_MS = 1000;
export interface UndoCountdownOptions {
/** When the server lets the message go (ms since epoch). */
sendAt: number;
/** When Undo stops being offered (ms since epoch); the toast turns into "Sent" then. */
undoEndsAt: number;
/** The delay the user picked, in seconds: the count never shows more. */
maxSeconds?: number;
sendingIn: (seconds: number) => string;
sent: string;
/** View on "Sent": opens the message. Without it there is no View. */
view?: { label: string; onClick: () => void };
now?: () => number;
}
/**
* The undo-send toast `id`, counting down to the send ("Sending in 5
* seconds", 4, 3…), then "Sent" with View for the usual time, as Gmail does
* (compose-p2-12). It used to show the same number until it went, with no
* word that the message went. Stops by itself once the toast is gone (Undo,
* Send now, closed). Returns a stop for the caller.
*/
/**
* Whole seconds until a held send goes, at least 1 and never more than the
* delay the user picked: the server's time runs a moment past it, which
* showed "Sending in 6 seconds" for a 5 s delay.
*/
export function secondsUntilSend(sendAt: number, now: number, maxSeconds?: number): number {
const left = Math.max(1, Math.ceil((sendAt - now) / 1000));
return maxSeconds && maxSeconds > 0 ? Math.min(left, maxSeconds) : left;
}
export function runUndoSendCountdown(id: string, options: UndoCountdownOptions): () => void {
const now = options.now ?? Date.now;
const present = () => useToastStore.getState().toasts.some((t) => t.id === id);
const secondsLeft = () => secondsUntilSend(options.sendAt, now(), options.maxSeconds);
let shown = -1;
const tick = () => {
if (!present()) return stop();
const seconds = secondsLeft();
if (seconds === shown) return;
shown = seconds;
toast.update(id, { title: options.sendingIn(seconds) });
};
const finish = () => {
stop();
const current = useToastStore.getState().toasts.find((t) => t.id === id);
if (!current) return;
const action = options.view;
let duration = effectiveToastDuration({ type: 'success', action });
// A new duration starts the toast's countdown afresh; the same one wouldn't.
if (duration === current.duration) duration += 1;
toast.update(id, {
type: 'success',
title: options.sent,
message: undefined,
action,
secondaryAction: undefined,
pauseOnHover: undefined,
duration,
});
announce(options.sent);
};
const interval = setInterval(tick, 250);
const timer = setTimeout(finish, Math.max(0, options.undoEndsAt - now()));
function stop() {
clearInterval(interval);
clearTimeout(timer);
}
tick();
return stop;
}
+4
View File
@@ -53,6 +53,8 @@ export interface PopoutSentNotice {
localAccountId?: string;
submissionAccountId?: string;
} | null;
/** A send held for undo in the pop-out itself: when the server lets it go. */
releasesAt?: string;
}
/**
@@ -127,6 +129,8 @@ export interface DraftKeptNotice {
accountId?: string;
/** The login it was saved through. */
localAccountId?: string;
/** Thrown away with the bin: "Draft discarded" with Undo, deleted after. */
discarded?: boolean;
}
export interface PopoutChannel {
+58
View File
@@ -0,0 +1,58 @@
import type { Email } from '@/lib/jmap/types';
import { stripMessageIdBrackets } from '@/lib/email-threading';
/**
* A reply saved as a draft, found from the message it answers (compose-p2-6).
* The reader marks that message with it, and Reply opens it again instead of
* starting a second draft, as Gmail and Outlook do.
*/
/** The message's own Message-IDs, bare. JMAP sends a list; older code a string. */
function messageIdsOf(email: Pick<Email, 'messageId'>): string[] {
const raw = email.messageId as unknown;
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
return list.filter((id): id is string => typeof id === 'string').map(stripMessageIdBrackets).filter(Boolean);
}
/** Whether `draft` is a reply to `email` (its In-Reply-To names it). */
export function answersMessage(draft: Pick<Email, 'inReplyTo' | 'keywords'>, email: Pick<Email, 'messageId'>): boolean {
if (!draft.keywords?.$draft) return false;
const ids = messageIdsOf(email);
if (ids.length === 0) return false;
return (draft.inReplyTo ?? []).some((id) => ids.includes(stripMessageIdBrackets(id)));
}
const newestFirst = (a: Pick<Email, 'receivedAt'>, b: Pick<Email, 'receivedAt'>) =>
(b.receivedAt ?? '').localeCompare(a.receivedAt ?? '');
/**
* The newest of a conversation's drafts that answers `email`, in full. List
* rows carry no In-Reply-To, so a draft without it is read once more through
* `getEmail`; one that has it (a full fetch) is taken as it is.
*/
export async function findReplyDraft(
drafts: readonly Email[],
email: Pick<Email, 'id' | 'messageId'>,
getEmail: (id: string) => Promise<Email | null>,
): Promise<Email | null> {
if (messageIdsOf(email).length === 0) return null;
const candidates = drafts.filter((d) => d.id !== email.id && d.keywords?.$draft).sort(newestFirst);
for (const candidate of candidates) {
const full = candidate.inReplyTo !== undefined ? candidate : await getEmail(candidate.id).catch(() => null);
if (full && answersMessage(full, email)) return full;
}
return null;
}
/** A conversation's drafts, read fresh (after a composer closed or a draft went). */
export async function threadDrafts(
client: { getThread(threadId: string, accountId?: string): Promise<{ emailIds: string[] } | null>; getSomeEmails(ids: string[], accountId?: string): Promise<Email[]> },
threadId: string,
accountId: string | undefined,
): Promise<Email[]> {
const thread = await client.getThread(threadId, accountId);
const ids = thread?.emailIds ?? [];
if (ids.length < 2) return [];
const members = await client.getSomeEmails(ids, accountId);
return members.filter((e) => e.keywords?.$draft);
}
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "جدولة الإرسال",
"schedule_send_description": "اختر متى يجب على الخادم إرسال هذه الرسالة.",
"schedule_send_description": "اختر متى يتم إرسالها.",
"schedule_preset": {
"this_morning": "هذا الصباح",
"this_afternoon": "بعد ظهر اليوم",
@@ -1162,6 +1162,7 @@
"edit_link": "تعديل الرابط",
"remove_link": "إزالة الرابط",
"link_address": "عنوان الرابط",
"link_text": "النص",
"link_invalid": "يجب ألا يحتوي العنوان على مسافات",
"table": "جدول",
"clear_formatting": "مسح التنسيق",
@@ -4056,6 +4057,9 @@
"sending": "جارٍ الإرسال…",
"sent": "تم الإرسال",
"view": "عرض",
"template_saved": "تم حفظ القالب",
"schedule_anyway": "الجدولة على أي حال",
"schedule_warnings_title": "هل تريد جدولة هذه الرسالة على أي حال؟",
"picker_button": "إضافة من جهات الاتصال",
"picker_title": "إضافة مستلمين",
"tab_contacts": "جهات الاتصال",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "Programa l'enviament",
"schedule_send_description": "Trieu quan ha d'alliberar el servidor aquest missatge.",
"schedule_send_description": "Tria quan s'ha d'enviar.",
"schedule_preset": {
"this_morning": "Aquest matí",
"this_afternoon": "Aquesta tarda",
@@ -1162,6 +1162,7 @@
"edit_link": "Edita l'enllaç",
"remove_link": "Treu l'enllaç",
"link_address": "Adreça de l'enllaç",
"link_text": "Text",
"link_invalid": "L'adreça no pot tenir espais",
"table": "Taula",
"clear_formatting": "Neteja el format",
@@ -4056,6 +4057,9 @@
"sending": "S'està enviant…",
"sent": "Enviat",
"view": "Mostra",
"template_saved": "Plantilla desada",
"schedule_anyway": "Programa igualment",
"schedule_warnings_title": "Vols programar aquest missatge igualment?",
"picker_button": "Afegeix des dels contactes",
"picker_title": "Afegeix destinataris",
"tab_contacts": "Contactes",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Zadejte URL",
"sending": "Odesílání...",
"schedule_send": "Naplánovat odeslání",
"schedule_send_description": "Vyberte, kdy má server tuto zprávu odeslat.",
"schedule_send_description": "Vyberte, kdy se má odeslat.",
"schedule_preset": {
"this_morning": "Dnes ráno",
"this_afternoon": "Dnes odpoledne",
@@ -1162,6 +1162,7 @@
"edit_link": "Upravit odkaz",
"remove_link": "Odebrat odkaz",
"link_address": "Adresa odkazu",
"link_text": "Text",
"link_invalid": "Adresa nesmí obsahovat mezery",
"table": "Tabulka",
"clear_formatting": "Vymazat formátování",
@@ -4056,6 +4057,9 @@
"sending": "Odesílání…",
"sent": "Odesláno",
"view": "Zobrazit",
"template_saved": "Šablona uložena",
"schedule_anyway": "Přesto naplánovat",
"schedule_warnings_title": "Přesto tuto zprávu naplánovat?",
"picker_button": "Přidat z kontaktů",
"picker_title": "Přidat příjemce",
"tab_contacts": "Kontakty",
+5 -1
View File
@@ -1111,7 +1111,7 @@
},
"link_url_prompt": "Indtast URL'en",
"schedule_send": "Planlæg afsendelse",
"schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.",
"schedule_send_description": "Vælg hvornår den skal sendes.",
"schedule_preset": {
"this_morning": "I dag formiddag",
"this_afternoon": "I eftermiddag",
@@ -1162,6 +1162,7 @@
"edit_link": "Rediger link",
"remove_link": "Fjern link",
"link_address": "Linkadresse",
"link_text": "Tekst",
"link_invalid": "Adressen må ikke indeholde mellemrum",
"table": "Tabel",
"clear_formatting": "Ryd formatering",
@@ -4056,6 +4057,9 @@
"sending": "Sender…",
"sent": "Sendt",
"view": "Vis",
"template_saved": "Skabelon gemt",
"schedule_anyway": "Planlæg alligevel",
"schedule_warnings_title": "Planlæg denne besked alligevel?",
"picker_button": "Tilføj fra kontakter",
"picker_title": "Tilføj modtagere",
"tab_contacts": "Kontakter",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "URL eingeben",
"sending": "Wird gesendet...",
"schedule_send": "Senden planen",
"schedule_send_description": "Wählen Sie, wann der Server diese Nachricht freigeben soll.",
"schedule_send_description": "Wählen Sie, wann gesendet werden soll.",
"schedule_preset": {
"this_morning": "Heute früh",
"this_afternoon": "Heute Nachmittag",
@@ -1162,6 +1162,7 @@
"edit_link": "Link bearbeiten",
"remove_link": "Link entfernen",
"link_address": "Linkadresse",
"link_text": "Text",
"link_invalid": "Die Adresse darf keine Leerzeichen enthalten",
"table": "Tabelle",
"clear_formatting": "Formatierung löschen",
@@ -4056,6 +4057,9 @@
"sending": "Wird gesendet…",
"sent": "Gesendet",
"view": "Anzeigen",
"template_saved": "Vorlage gespeichert",
"schedule_anyway": "Trotzdem planen",
"schedule_warnings_title": "Diese Nachricht trotzdem planen?",
"picker_button": "Aus Kontakten hinzufügen",
"picker_title": "Empfänger hinzufügen",
"tab_contacts": "Kontakte",
+5 -1
View File
@@ -1113,7 +1113,7 @@
"unfinished": ["TODO", "TBD", "TBA", "TBC", "FIXME", "XXX"]
},
"schedule_send": "Schedule send",
"schedule_send_description": "Choose when the server should release this message.",
"schedule_send_description": "Pick when to send it.",
"schedule_preset": {
"this_morning": "This morning",
"this_afternoon": "This afternoon",
@@ -1164,6 +1164,7 @@
"edit_link": "Edit link",
"remove_link": "Remove link",
"link_address": "Link address",
"link_text": "Text",
"link_invalid": "The address can't contain spaces",
"table": "Table",
"clear_formatting": "Clear formatting",
@@ -4056,6 +4057,9 @@
"sending": "Sending…",
"sent": "Sent",
"view": "View",
"template_saved": "Template saved",
"schedule_anyway": "Schedule anyway",
"schedule_warnings_title": "Schedule this message anyway?",
"picker_button": "Add from contacts",
"picker_title": "Add recipients",
"tab_contacts": "Contacts",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Introduce la URL",
"sending": "Enviando...",
"schedule_send": "Programar envío",
"schedule_send_description": "Elige cuándo debe liberar el servidor este mensaje.",
"schedule_send_description": "Elige cuándo enviarlo.",
"schedule_preset": {
"this_morning": "Esta mañana",
"this_afternoon": "Esta tarde",
@@ -1162,6 +1162,7 @@
"edit_link": "Editar enlace",
"remove_link": "Quitar enlace",
"link_address": "Dirección del enlace",
"link_text": "Texto",
"link_invalid": "La dirección no puede contener espacios",
"table": "Tabla",
"clear_formatting": "Borrar formato",
@@ -4056,6 +4057,9 @@
"sending": "Enviando…",
"sent": "Enviado",
"view": "Ver",
"template_saved": "Plantilla guardada",
"schedule_anyway": "Programar de todos modos",
"schedule_warnings_title": "¿Programar este mensaje de todos modos?",
"picker_button": "Añadir desde contactos",
"picker_title": "Añadir destinatarios",
"tab_contacts": "Contactos",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "Ajasta saatmine",
"schedule_send_description": "Vali, millal server peaks selle sõnumi välja saatma.",
"schedule_send_description": "Vali, millal see saata.",
"schedule_preset": {
"this_morning": "Täna hommikul",
"this_afternoon": "Täna pärastlõunal",
@@ -1162,6 +1162,7 @@
"edit_link": "Muuda linki",
"remove_link": "Eemalda link",
"link_address": "Lingi aadress",
"link_text": "Tekst",
"link_invalid": "Aadressis ei tohi olla tühikuid",
"table": "Tabel",
"clear_formatting": "Eemalda vormindus",
@@ -4056,6 +4057,9 @@
"sending": "Saatmine…",
"sent": "Saadetud",
"view": "Vaata",
"template_saved": "Mall salvestatud",
"schedule_anyway": "Ajasta ikkagi",
"schedule_warnings_title": "Kas ajastada see kiri ikkagi?",
"picker_button": "Lisa kontaktidest",
"picker_title": "Lisa saajaid",
"tab_contacts": "Kontaktid",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "زمان‌بندی ارسال",
"schedule_send_description": "زمان انتشار پیام توسط سرور را انتخاب کنید.",
"schedule_send_description": "زمان ارسال را انتخاب کنید.",
"schedule_preset": {
"this_morning": "امروز صبح",
"this_afternoon": "امروز بعدازظهر",
@@ -1162,6 +1162,7 @@
"edit_link": "ویرایش پیوند",
"remove_link": "حذف پیوند",
"link_address": "نشانی پیوند",
"link_text": "متن",
"link_invalid": "نشانی نباید فاصله داشته باشد",
"table": "جدول",
"clear_formatting": "پاک کردن قالب‌بندی",
@@ -4056,6 +4057,9 @@
"sending": "در حال ارسال…",
"sent": "ارسال شد",
"view": "مشاهده",
"template_saved": "الگو ذخیره شد",
"schedule_anyway": "زمان‌بندی به هر حال",
"schedule_warnings_title": "این پیام به هر حال زمان‌بندی شود؟",
"picker_button": "افزودن از مخاطبین",
"picker_title": "افزودن گیرندگان",
"tab_contacts": "مخاطبین",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Saisissez l'URL",
"sending": "Envoi...",
"schedule_send": "Planifier l’envoi",
"schedule_send_description": "Choisissez quand le serveur doit libérer ce message.",
"schedule_send_description": "Choisissez quand l’envoyer.",
"schedule_preset": {
"this_morning": "Ce matin",
"this_afternoon": "Cet après-midi",
@@ -1162,6 +1162,7 @@
"edit_link": "Modifier le lien",
"remove_link": "Retirer le lien",
"link_address": "Adresse du lien",
"link_text": "Texte",
"link_invalid": "L'adresse ne peut pas contenir d'espaces",
"table": "Tableau",
"clear_formatting": "Effacer la mise en forme",
@@ -4056,6 +4057,9 @@
"sending": "Envoi…",
"sent": "Envoyé",
"view": "Afficher",
"template_saved": "Modèle enregistré",
"schedule_anyway": "Planifier quand même",
"schedule_warnings_title": "Planifier ce message quand même ?",
"picker_button": "Ajouter depuis les contacts",
"picker_title": "Ajouter des destinataires",
"tab_contacts": "Contacts",
+5 -1
View File
@@ -1055,7 +1055,7 @@
"unfinished": []
},
"schedule_send": "תזמן שליחה",
"schedule_send_description": "בחר מתי השרת צריך לשחרר הודעה זו.",
"schedule_send_description": "בחר מתי לשלוח אותה.",
"schedule_preset": {
"this_morning": "הבוקר",
"this_afternoon": "היום אחר הצהריים",
@@ -1106,6 +1106,7 @@
"edit_link": "עריכת קישור",
"remove_link": "הסרת קישור",
"link_address": "כתובת הקישור",
"link_text": "טקסט",
"link_invalid": "הכתובת לא יכולה להכיל רווחים",
"table": "טבלה",
"clear_formatting": "ניקוי עיצוב",
@@ -3950,6 +3951,9 @@
"sending": "שולח…",
"sent": "נשלח",
"view": "הצג",
"template_saved": "התבנית נשמרה",
"schedule_anyway": "תזמן בכל זאת",
"schedule_warnings_title": "לתזמן את ההודעה בכל זאת?",
"picker_button": "הוסף מאנשי הקשר",
"picker_title": "הוספת נמענים",
"tab_contacts": "אנשי קשר",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "Késleltetett küldés",
"schedule_send_description": "Válaszd ki, mikor küldje el a szerver az üzenetet.",
"schedule_send_description": "Válaszd ki, mikor menjen el.",
"schedule_preset": {
"this_morning": "Ma reggel",
"this_afternoon": "Ma délután",
@@ -1162,6 +1162,7 @@
"edit_link": "Hivatkozás szerkesztése",
"remove_link": "Hivatkozás eltávolítása",
"link_address": "Hivatkozás címe",
"link_text": "Szöveg",
"link_invalid": "A cím nem tartalmazhat szóközt",
"table": "Táblázat",
"clear_formatting": "Formázás törlése",
@@ -4056,6 +4057,9 @@
"sending": "Küldés…",
"sent": "Elküldve",
"view": "Megtekintés",
"template_saved": "Sablon mentve",
"schedule_anyway": "Időzítés így is",
"schedule_warnings_title": "Időzíti így is ezt az üzenetet?",
"picker_button": "Hozzáadás a névjegyekből",
"picker_title": "Címzettek hozzáadása",
"tab_contacts": "Névjegyek",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso...",
"schedule_send": "Programma invio",
"schedule_send_description": "Scegli quando il server deve rilasciare questo messaggio.",
"schedule_send_description": "Scegli quando inviarlo.",
"schedule_preset": {
"this_morning": "Stamattina",
"this_afternoon": "Oggi pomeriggio",
@@ -1162,6 +1162,7 @@
"edit_link": "Modifica link",
"remove_link": "Rimuovi link",
"link_address": "Indirizzo del link",
"link_text": "Testo",
"link_invalid": "L'indirizzo non può contenere spazi",
"table": "Tabella",
"clear_formatting": "Cancella formattazione",
@@ -4056,6 +4057,9 @@
"sending": "Invio in corso…",
"sent": "Inviato",
"view": "Visualizza",
"template_saved": "Modello salvato",
"schedule_anyway": "Programma comunque",
"schedule_warnings_title": "Programmare comunque questo messaggio?",
"picker_button": "Aggiungi dai contatti",
"picker_title": "Aggiungi destinatari",
"tab_contacts": "Contatti",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "URLを入力してください",
"sending": "送信中...",
"schedule_send": "送信を予約",
"schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。",
"schedule_send_description": "送信する日時を選んでください。",
"schedule_preset": {
"this_morning": "今朝",
"this_afternoon": "今日の午後",
@@ -1162,6 +1162,7 @@
"edit_link": "リンクを編集",
"remove_link": "リンクを解除",
"link_address": "リンク先のアドレス",
"link_text": "テキスト",
"link_invalid": "アドレスに空白は使えません",
"table": "表",
"clear_formatting": "書式をクリア",
@@ -4056,6 +4057,9 @@
"sending": "送信中…",
"sent": "送信しました",
"view": "表示",
"template_saved": "テンプレートを保存しました",
"schedule_anyway": "そのまま予約",
"schedule_warnings_title": "このメッセージをそのまま予約しますか?",
"picker_button": "連絡先から追加",
"picker_title": "宛先を追加",
"tab_contacts": "連絡先",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "URL을 입력하세요",
"sending": "전송 중...",
"schedule_send": "보내기 예약",
"schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.",
"schedule_send_description": "보낼 시간을 고르세요.",
"schedule_preset": {
"this_morning": "오늘 아침",
"this_afternoon": "오늘 오후",
@@ -1162,6 +1162,7 @@
"edit_link": "링크 편집",
"remove_link": "링크 제거",
"link_address": "링크 주소",
"link_text": "텍스트",
"link_invalid": "주소에 공백을 넣을 수 없어요",
"table": "표",
"clear_formatting": "서식 지우기",
@@ -4056,6 +4057,9 @@
"sending": "보내는 중…",
"sent": "보냈어요",
"view": "보기",
"template_saved": "템플릿을 저장했습니다",
"schedule_anyway": "그래도 예약하기",
"schedule_warnings_title": "이 메일을 그래도 예약할까요?",
"picker_button": "연락처에서 추가",
"picker_title": "받는 사람 추가",
"tab_contacts": "연락처",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Ievadiet URL",
"sending": "Sūta...",
"schedule_send": "Ieplānot sūtīšanu",
"schedule_send_description": "Izvēlieties, kad serverim jānosūta šis ziņojums.",
"schedule_send_description": "Izvēlieties, kad to nosūtīt.",
"schedule_preset": {
"this_morning": "Šorīt",
"this_afternoon": "Šodien pēcpusdienā",
@@ -1162,6 +1162,7 @@
"edit_link": "Rediģēt saiti",
"remove_link": "Noņemt saiti",
"link_address": "Saites adrese",
"link_text": "Teksts",
"link_invalid": "Adresē nedrīkst būt atstarpju",
"table": "Tabula",
"clear_formatting": "Notīrīt formatējumu",
@@ -4056,6 +4057,9 @@
"sending": "Sūta…",
"sent": "Nosūtīts",
"view": "Skatīt",
"template_saved": "Veidne saglabāta",
"schedule_anyway": "Ieplānot tik un tā",
"schedule_warnings_title": "Vai tomēr ieplānot šo vēstuli?",
"picker_button": "Pievienot no kontaktiem",
"picker_title": "Pievienot saņēmējus",
"tab_contacts": "Kontakti",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "Хуваарь илгээх",
"schedule_send_description": "Сервер энэ мессежийг хэзээ гаргахыг сонгоно уу.",
"schedule_send_description": "Хэзээ илгээхээ сонгоно уу.",
"schedule_preset": {
"this_morning": "Өнөө өглөө",
"this_afternoon": "Өнөөдөр үдээс хойш",
@@ -1162,6 +1162,7 @@
"edit_link": "Холбоос засах",
"remove_link": "Холбоос хасах",
"link_address": "Холбоосын хаяг",
"link_text": "Текст",
"link_invalid": "Хаяг хоосон зай агуулж болохгүй",
"table": "Хүснэгт",
"clear_formatting": "Форматыг арилгах",
@@ -4056,6 +4057,9 @@
"sending": "Илгээж байна…",
"sent": "Илгээгдлээ",
"view": "Харах",
"template_saved": "Загвар хадгалагдлаа",
"schedule_anyway": "Ямар ч байсан хуваарьт оруулах",
"schedule_warnings_title": "Энэ захиаг ямар ч байсан хуваарьт оруулах уу?",
"picker_button": "Харилцагчдаас нэмэх",
"picker_title": "Хүлээн авагч нэмэх",
"tab_contacts": "Харилцагчид",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "Planlegg sending",
"schedule_send_description": "Velg når serveren skal sende denne meldingen.",
"schedule_send_description": "Velg når den skal sendes.",
"schedule_preset": {
"this_morning": "I dag tidlig",
"this_afternoon": "I ettermiddag",
@@ -1162,6 +1162,7 @@
"edit_link": "Rediger lenke",
"remove_link": "Fjern lenke",
"link_address": "Lenkeadresse",
"link_text": "Tekst",
"link_invalid": "Adressen kan ikke inneholde mellomrom",
"table": "Tabell",
"clear_formatting": "Fjern formatering",
@@ -4056,6 +4057,9 @@
"sending": "Sender…",
"sent": "Sendt",
"view": "Vis",
"template_saved": "Mal lagret",
"schedule_anyway": "Planlegg likevel",
"schedule_warnings_title": "Planlegge denne meldingen likevel?",
"picker_button": "Legg til fra kontakter",
"picker_title": "Legg til mottakere",
"tab_contacts": "Kontakter",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden...",
"schedule_send": "Verzenden plannen",
"schedule_send_description": "Kies wanneer de server dit bericht moet vrijgeven.",
"schedule_send_description": "Kies wanneer het verzonden wordt.",
"schedule_preset": {
"this_morning": "Vanochtend",
"this_afternoon": "Vanmiddag",
@@ -1162,6 +1162,7 @@
"edit_link": "Link bewerken",
"remove_link": "Link verwijderen",
"link_address": "Linkadres",
"link_text": "Tekst",
"link_invalid": "Het adres mag geen spaties bevatten",
"table": "Tabel",
"clear_formatting": "Opmaak wissen",
@@ -4056,6 +4057,9 @@
"sending": "Bezig met verzenden…",
"sent": "Verzonden",
"view": "Bekijken",
"template_saved": "Sjabloon opgeslagen",
"schedule_anyway": "Toch plannen",
"schedule_warnings_title": "Dit bericht toch plannen?",
"picker_button": "Toevoegen uit contacten",
"picker_title": "Ontvangers toevoegen",
"tab_contacts": "Contacten",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie...",
"schedule_send": "Zaplanuj wysyłkę",
"schedule_send_description": "Wybierz, kiedy serwer ma wysłać tę wiadomość.",
"schedule_send_description": "Wybierz, kiedy ją wysłać.",
"schedule_preset": {
"this_morning": "Dziś rano",
"this_afternoon": "Dziś po południu",
@@ -1162,6 +1162,7 @@
"edit_link": "Edytuj link",
"remove_link": "Usuń link",
"link_address": "Adres linku",
"link_text": "Tekst",
"link_invalid": "Adres nie może zawierać spacji",
"table": "Tabela",
"clear_formatting": "Wyczyść formatowanie",
@@ -4056,6 +4057,9 @@
"sending": "Wysyłanie…",
"sent": "Wysłano",
"view": "Pokaż",
"template_saved": "Szablon zapisany",
"schedule_anyway": "Zaplanuj mimo to",
"schedule_warnings_title": "Zaplanować tę wiadomość mimo to?",
"picker_button": "Dodaj z kontaktów",
"picker_title": "Dodaj odbiorców",
"tab_contacts": "Kontakty",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Insira a URL",
"sending": "Enviando...",
"schedule_send": "Agendar envio",
"schedule_send_description": "Escolha quando o servidor deve liberar esta mensagem.",
"schedule_send_description": "Escolha quando enviar.",
"schedule_preset": {
"this_morning": "Esta manhã",
"this_afternoon": "Esta tarde",
@@ -1162,6 +1162,7 @@
"edit_link": "Editar link",
"remove_link": "Remover link",
"link_address": "Endereço do link",
"link_text": "Texto",
"link_invalid": "O endereço não pode conter espaços",
"table": "Tabela",
"clear_formatting": "Limpar formatação",
@@ -4056,6 +4057,9 @@
"sending": "Enviando…",
"sent": "Enviado",
"view": "Ver",
"template_saved": "Modelo guardado",
"schedule_anyway": "Agendar mesmo assim",
"schedule_warnings_title": "Agendar esta mensagem mesmo assim?",
"picker_button": "Adicionar dos contatos",
"picker_title": "Adicionar destinatários",
"tab_contacts": "Contatos",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "Programează trimiterea",
"schedule_send_description": "Alegeți momentul în care serverul ar trebui să transmită acest mesaj.",
"schedule_send_description": "Alegeți când să fie trimis.",
"schedule_preset": {
"this_morning": "Azi dimineață",
"this_afternoon": "Azi după-amiază",
@@ -1162,6 +1162,7 @@
"edit_link": "Editează linkul",
"remove_link": "Elimină linkul",
"link_address": "Adresa linkului",
"link_text": "Text",
"link_invalid": "Adresa nu poate conține spații",
"table": "Tabel",
"clear_formatting": "Șterge formatarea",
@@ -4056,6 +4057,9 @@
"sending": "Se trimite…",
"sent": "Trimis",
"view": "Vezi",
"template_saved": "Șablon salvat",
"schedule_anyway": "Programează oricum",
"schedule_warnings_title": "Programați totuși acest mesaj?",
"picker_button": "Adaugă din contacte",
"picker_title": "Adaugă destinatari",
"tab_contacts": "Contacte",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Введите URL",
"sending": "Отправка...",
"schedule_send": "Запланировать отправку",
"schedule_send_description": "Выберите, когда сервер должен отправить это сообщение.",
"schedule_send_description": "Выберите, когда отправить.",
"schedule_preset": {
"this_morning": "Сегодня утром",
"this_afternoon": "Сегодня днём",
@@ -1162,6 +1162,7 @@
"edit_link": "Изменить ссылку",
"remove_link": "Убрать ссылку",
"link_address": "Адрес ссылки",
"link_text": "Текст",
"link_invalid": "В адресе не должно быть пробелов",
"table": "Таблица",
"clear_formatting": "Очистить форматирование",
@@ -4056,6 +4057,9 @@
"sending": "Отправка…",
"sent": "Отправлено",
"view": "Открыть",
"template_saved": "Шаблон сохранён",
"schedule_anyway": "Всё равно запланировать",
"schedule_warnings_title": "Всё равно запланировать это письмо?",
"picker_button": "Добавить из контактов",
"picker_title": "Добавить получателей",
"tab_contacts": "Контакты",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "Naplánovať odoslanie",
"schedule_send_description": "Vyberte, kedy má server túto správu odoslať.",
"schedule_send_description": "Vyberte, kedy sa má odoslať.",
"schedule_preset": {
"this_morning": "Dnes ráno",
"this_afternoon": "Dnes popoludní",
@@ -1162,6 +1162,7 @@
"edit_link": "Upraviť odkaz",
"remove_link": "Odstrániť odkaz",
"link_address": "Adresa odkazu",
"link_text": "Text",
"link_invalid": "Adresa nesmie obsahovať medzery",
"table": "Tabuľka",
"clear_formatting": "Vymazať formátovanie",
@@ -4056,6 +4057,9 @@
"sending": "Odosielanie…",
"sent": "Odoslané",
"view": "Zobraziť",
"template_saved": "Šablóna uložená",
"schedule_anyway": "Naplánovať aj tak",
"schedule_warnings_title": "Naplánovať túto správu aj tak?",
"picker_button": "Pridať z kontaktov",
"picker_title": "Pridať príjemcov",
"tab_contacts": "Kontakty",
+5 -1
View File
@@ -1233,6 +1233,9 @@
"sending": "กำลังส่ง…",
"sent": "ส่งแล้ว",
"view": "ดู",
"template_saved": "บันทึกเทมเพลตแล้ว",
"schedule_anyway": "กำหนดเวลาต่อไป",
"schedule_warnings_title": "กำหนดเวลาส่งข้อความนี้ต่อไปหรือไม่",
"picker_button": "เพิ่มจากผู้ติดต่อ",
"picker_title": "เพิ่มผู้รับ",
"tab_contacts": "ผู้ติดต่อ",
@@ -1501,7 +1504,7 @@
"unfinished": []
},
"schedule_send": "กำหนดเวลาส่ง",
"schedule_send_description": "เลือกเวลาที่ต้องการให้เซิร์ฟเวอร์ส่งข้อความนี้",
"schedule_send_description": "เลือกเวลาที่จะส่ง",
"schedule_preset": {
"this_morning": "เช้านี้",
"this_afternoon": "บ่ายนี้",
@@ -1552,6 +1555,7 @@
"edit_link": "แก้ไขลิงก์",
"remove_link": "นำลิงก์ออก",
"link_address": "ที่อยู่ลิงก์",
"link_text": "ข้อความ",
"link_invalid": "ที่อยู่ต้องไม่มีช่องว่าง",
"table": "ตาราง",
"clear_formatting": "ล้างการจัดรูปแบบ",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "Göndermeyi zamanla",
"schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.",
"schedule_send_description": "Ne zaman gönderileceğini seçin.",
"schedule_preset": {
"this_morning": "Bu sabah",
"this_afternoon": "Bu öğleden sonra",
@@ -1162,6 +1162,7 @@
"edit_link": "Bağlantıyı düzenle",
"remove_link": "Bağlantıyı kaldır",
"link_address": "Bağlantı adresi",
"link_text": "Metin",
"link_invalid": "Adres boşluk içeremez",
"table": "Tablo",
"clear_formatting": "Biçimlendirmeyi temizle",
@@ -4056,6 +4057,9 @@
"sending": "Gönderiliyor…",
"sent": "Gönderildi",
"view": "Görüntüle",
"template_saved": "Şablon kaydedildi",
"schedule_anyway": "Yine de zamanla",
"schedule_warnings_title": "Bu ileti yine de zamanlansın mı?",
"picker_button": "Kişilerden ekle",
"picker_title": "Alıcı ekle",
"tab_contacts": "Kişiler",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "Введіть URL",
"sending": "Надсилання...",
"schedule_send": "Запланувати надсилання",
"schedule_send_description": "Виберіть, коли сервер має надіслати це повідомлення.",
"schedule_send_description": "Виберіть, коли надіслати.",
"schedule_preset": {
"this_morning": "Сьогодні вранці",
"this_afternoon": "Сьогодні вдень",
@@ -1162,6 +1162,7 @@
"edit_link": "Змінити посилання",
"remove_link": "Прибрати посилання",
"link_address": "Адреса посилання",
"link_text": "Текст",
"link_invalid": "Адреса не може містити пробілів",
"table": "Таблиця",
"clear_formatting": "Очистити форматування",
@@ -4056,6 +4057,9 @@
"sending": "Надсилання…",
"sent": "Надіслано",
"view": "Переглянути",
"template_saved": "Шаблон збережено",
"schedule_anyway": "Усе одно запланувати",
"schedule_warnings_title": "Усе одно запланувати цей лист?",
"picker_button": "Додати з контактів",
"picker_title": "Додати одержувачів",
"tab_contacts": "Контакти",
+5 -1
View File
@@ -1125,7 +1125,7 @@
]
},
"schedule_send": "Lên lịch gửi",
"schedule_send_description": "Chọn thời điểm máy chủ sẽ gửi thư này.",
"schedule_send_description": "Chọn thời điểm gửi.",
"schedule_preset": {
"this_morning": "Sáng nay",
"this_afternoon": "Chiều nay",
@@ -1176,6 +1176,7 @@
"edit_link": "Chỉnh sửa liên kết",
"remove_link": "Xóa liên kết",
"link_address": "Địa chỉ liên kết",
"link_text": "Văn bản",
"link_invalid": "Địa chỉ không được chứa dấu cách",
"table": "Bảng",
"clear_formatting": "Xóa định dạng",
@@ -4068,6 +4069,9 @@
"sending": "Đang gửi…",
"sent": "Đã gửi",
"view": "Xem",
"template_saved": "Đã lưu mẫu",
"schedule_anyway": "Vẫn lên lịch",
"schedule_warnings_title": "Vẫn lên lịch gửi thư này?",
"picker_button": "Thêm từ danh bạ",
"picker_title": "Thêm người nhận",
"tab_contacts": "Danh bạ",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"unfinished": []
},
"schedule_send": "排程寄送",
"schedule_send_description": "選擇伺服器要在何時送出這封郵件。",
"schedule_send_description": "選擇寄送時間。",
"schedule_preset": {
"this_morning": "今天早上",
"this_afternoon": "今天下午",
@@ -1162,6 +1162,7 @@
"edit_link": "編輯連結",
"remove_link": "移除連結",
"link_address": "連結網址",
"link_text": "文字",
"link_invalid": "網址中不能包含空格",
"table": "表格",
"clear_formatting": "清除格式",
@@ -4056,6 +4057,9 @@
"sending": "正在傳送…",
"sent": "已傳送",
"view": "檢視",
"template_saved": "範本已儲存",
"schedule_anyway": "仍然排程",
"schedule_warnings_title": "仍然排程這封郵件?",
"picker_button": "從聯絡人新增",
"picker_title": "新增收件者",
"tab_contacts": "聯絡人",
+5 -1
View File
@@ -1111,7 +1111,7 @@
"link_url_prompt": "输入 URL",
"sending": "发送中...",
"schedule_send": "计划发送",
"schedule_send_description": "选择服务器应何时发送此邮件。",
"schedule_send_description": "选择发送时间。",
"schedule_preset": {
"this_morning": "今天早上",
"this_afternoon": "今天下午",
@@ -1162,6 +1162,7 @@
"edit_link": "编辑链接",
"remove_link": "移除链接",
"link_address": "链接地址",
"link_text": "文本",
"link_invalid": "地址中不能包含空格",
"table": "表格",
"clear_formatting": "清除格式",
@@ -4056,6 +4057,9 @@
"sending": "正在发送…",
"sent": "已发送",
"view": "查看",
"template_saved": "模板已保存",
"schedule_anyway": "仍然计划发送",
"schedule_warnings_title": "仍然计划发送这封邮件?",
"picker_button": "从联系人添加",
"picker_title": "添加收件人",
"tab_contacts": "联系人",