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

This commit is contained in:
Linus Rath
2026-11-04 14:07:47 +01:00
149 changed files with 6821 additions and 574 deletions
+4 -2
View File
@@ -593,8 +593,10 @@ optgroup {
animation: toast-exit 0.28s cubic-bezier(0.06, 0.71, 0.55, 1) forwards;
}
/* The reader toolbar with no room for its labels: icons only, the names stay in the tooltips. */
[data-toolbar-compact] [data-toolbar-label] {
/* The reader toolbar with no room for its labels: icons only, the names stay in the tooltips.
"secondary" first keeps the names of Reply, Archive, Delete and Move (mail-p2-15). */
[data-toolbar-compact="secondary"] [data-toolbar-label]:not([data-toolbar-label="primary"]),
[data-toolbar-compact="all"] [data-toolbar-label] {
display: none !important;
}
@@ -82,13 +82,13 @@ function viewer() {
describe('the viewer and the Archive action gate', () => {
it('shows the Archive button while the gate is on', () => {
viewer();
expect(screen.getByTitle('tooltips.archive')).toBeInTheDocument();
expect(screen.getByTitle('archive (E)')).toBeInTheDocument();
});
it('has no Archive button with the gate off, Delete still there', () => {
setArchiveGate(false);
viewer();
expect(screen.queryByTitle('tooltips.archive')).toBeNull();
expect(screen.getByTitle('tooltips.delete')).toBeInTheDocument();
expect(screen.queryByTitle('archive (E)')).toBeNull();
expect(screen.getByTitle('delete (Del)')).toBeInTheDocument();
});
});
@@ -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();
@@ -0,0 +1,237 @@
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';
// mail-p2-2: a reply docked under its message starts compact: no Subject
// line, no From line when there's only one address to send from, and the
// formatting bar folded behind its button. Full screen shows them again.
// ─── Heavy component mocks (mirrors composer-mobile-formatting.test.tsx) ──────
const toastError = vi.hoisted(() => vi.fn());
// Shows whether the composer asked for the toolbar to be folded away.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ toolbarPlacement }: { toolbarPlacement?: string }) =>
React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-toolbar': toolbarPlacement ?? 'top' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
const updateSetting = vi.fn();
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: (...args: unknown[]) => updateSetting(...args),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: toastError, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
const DATA = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Re: Lunch',
body: '<p>Hello there</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'reply' as const,
draftId: null,
};
const hidden = (el: Element | null) => !!el && el.className.split(/\s+/).includes('hidden');
describe('a reply docked under its message', () => {
beforeEach(() => {
toastError.mockClear();
updateSetting.mockClear();
});
it('starts compact, with the formatting bar behind its button', () => {
const { container } = render(<EmailComposer initialData={DATA} docked />);
expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(true);
expect(hidden(container.querySelector('[data-composer-from]'))).toBe(true);
const editor = screen.getByTestId('rich-text-editor');
expect(editor).toHaveAttribute('data-toolbar', 'hidden');
fireEvent.click(container.querySelector('[data-dock-formatting-toggle]')!);
expect(editor).toHaveAttribute('data-toolbar', 'top');
});
it('shows everything in full screen', () => {
const { container } = render(<EmailComposer initialData={DATA} docked />);
fireEvent.click(screen.getByRole('button', { name: 'maximize' }));
expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(false);
expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-toolbar', 'top');
});
it('keeps every line when not docked', () => {
const { container } = render(<EmailComposer initialData={DATA} />);
expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(false);
expect(hidden(container.querySelector('[data-composer-from]'))).toBe(false);
});
});
@@ -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');
@@ -0,0 +1,130 @@
import { render, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useSettingsStore } from '@/stores/settings-store';
// mail-p2-4: in the default layout's conversation every message has the same
// header (sender, short date with the full one on hover, Reply, Reply all and
// Forward), the subject line has no date of its own, and the newest message
// is open, also when it is your own reply.
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, conversationOpenPlan, conversationShortDate } = await import('../email-viewer');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
function mail(n: number, overrides: Partial<Email> = {}): Email {
return {
id: `m${n}`, blobId: `b${n}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: `Sender ${n}`, email: `s${n}@example.com` }], to: [{ name: 'Me', email: 'me@example.com' }],
subject: 'Offsite agenda', receivedAt: `2026-01-0${n}T10:00:00Z`, sentAt: `2026-01-0${n}T10:00:00Z`, preview: `Preview ${n}`,
hasAttachment: false, size: 100, ...overrides,
} as unknown as Email;
}
function withBody(e: Email, text: string): Email {
return {
...e, textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], attachments: [],
bodyValues: { '1': { value: text, isTruncated: false } },
} as unknown as Email;
}
const expandedIds = (c: HTMLElement) =>
[...c.querySelectorAll('[data-mv-conversation-expanded]')].map((e) => e.getAttribute('data-email-id'));
describe('conversation headers in the default layout', () => {
beforeEach(() => {
useSettingsStore.setState({ interfaceLayout: 'default', markAsReadDelay: 0, timeFormat: '24h' });
});
it('gives the open message the date and Reply, Reply all, Forward on its sender line', () => {
const open = withBody(mail(3), 'Newest body');
const onReply = vi.fn();
const onReplyAll = vi.fn();
const onForward = vi.fn();
const { container } = render(
<EmailViewer email={open} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
mvConversation={[mail(1), mail(2), open]} conversationPane
onReply={onReply} onReplyAll={onReplyAll} onForward={onForward} />,
);
const header = container.querySelector('[data-mv-open-header]') as HTMLElement;
const actions = header.querySelector('[data-mv-sender-actions]') as HTMLElement;
expect(actions).not.toBeNull();
fireEvent.click(within(actions).getByRole('button', { name: 'reply' }));
fireEvent.click(within(actions).getByRole('button', { name: 'reply_all' }));
fireEvent.click(within(actions).getByRole('button', { name: 'forward' }));
expect(onReply).toHaveBeenCalled();
expect(onReplyAll).toHaveBeenCalled();
expect(onForward).toHaveBeenCalled();
const date = actions.querySelector('time[data-conversation-date]') as HTMLElement;
expect(date.getAttribute('title')!.length).toBeGreaterThan(date.textContent!.length);
// No second date on the subject line.
expect(container.querySelectorAll('time[data-conversation-date]').length).toBe(3);
expect(container.querySelector('.sm\\:block .whitespace-nowrap')).toBeNull();
});
it('gives an unfolded older message Forward too, in the same short date style', () => {
const open = withBody(mail(3), 'Newest body');
const onForward = vi.fn();
const older = withBody(mail(2, { keywords: {} }), 'Older body');
const { container } = render(
<EmailViewer email={open} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
mvConversation={[mail(1), older, open]} conversationPane
onReplyConversationEmail={vi.fn()} onReplyAllConversationEmail={vi.fn()} onForwardConversationEmail={onForward} />,
);
const card = container.querySelector('[data-mv-conversation-expanded][data-email-id="m2"]') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: 'forward' }));
expect(onForward).toHaveBeenCalledWith(expect.objectContaining({ id: 'm2' }));
// Not Gmail's "(n days ago)".
expect(card.querySelector('time')!.textContent).not.toMatch(/\(/);
});
it('opens your own newer reply and folds the read message you clicked', () => {
const open = withBody(mail(2), 'Their message');
const mine = mail(3, { from: [{ name: 'Me', email: 'me@example.com' }], mailboxIds: { sent: true } });
const { container } = render(
<EmailViewer email={open} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
mvConversation={[mail(1), open, mine]} conversationPane />,
);
expect(expandedIds(container)).toEqual(['m3']);
expect(container.querySelector('[data-mv-open-folded]')).not.toBeNull();
expect(container.querySelector('[data-mv-open-header]')).toBeNull();
});
});
describe('conversationOpenPlan', () => {
it('keeps the open message when it is the newest', () => {
expect(conversationOpenPlan([mail(1), mail(2)], 'm2')).toEqual({ expanded: [], foldOpen: false, newestId: null });
});
it('keeps an unread open message open beside the newest', () => {
const plan = conversationOpenPlan([mail(1, { keywords: {} }), mail(2)], 'm1');
expect(plan).toEqual({ expanded: ['m2'], foldOpen: false, newestId: 'm2' });
});
it('never picks a draft or a scheduled message as the newest', () => {
const plan = conversationOpenPlan([mail(1), mail(2, { keywords: { $draft: true, $seen: true } }), mail(3, { isScheduled: true })], 'm1');
expect(plan.newestId).toBeNull();
});
});
describe('conversationShortDate', () => {
const now = new Date('2026-11-04T12:00:00Z');
it('has no relative part and no year this year', () => {
const text = conversationShortDate('2026-10-21T13:40:00Z', '24h', now);
expect(text).not.toMatch(/2026|ago|\(/);
expect(text).toMatch(/21/);
});
it('has the year for older mail', () => {
expect(conversationShortDate('2025-10-21T13:40:00Z', '24h', now)).toMatch(/2025/);
});
});
@@ -125,6 +125,25 @@ describe('Restore in Trash', () => {
expect(onMoveToMailbox).toHaveBeenCalledWith('inbox');
});
// Pass 2, mail-9: Trash's Delete removes for good, so it says so, and
// reporting spam from the bin makes no sense.
it('says Delete forever and offers no Report spam', () => {
renderMenu({ email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash' });
expect(screen.getByTestId('ctx-delete').textContent).toContain('delete_forever');
expect(screen.queryByTestId('ctx-spam')).toBeNull();
fireEvent.click(screen.getByTestId('ctx-more'));
expect(screen.queryByTestId('ctx-spam')).toBeNull();
});
it('says Delete forever for a selection in Trash too', () => {
renderMenu({
email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', isMultiSelect: true, selectedCount: 2,
batchEmails: [trashed, { ...trashed, id: 'e2' } as Email], onBatchDelete: vi.fn(), onBatchMarkAsSpam: vi.fn(),
});
expect(screen.getByTestId('ctx-delete').textContent).toContain('delete_forever');
expect(screen.queryByTestId('ctx-spam')).toBeNull();
});
it('is in the selection menu too', () => {
rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }, { id: 'e2', mailboxIds: { work: true } }], mailboxes);
const onBatchMoveToMailbox = vi.fn();
@@ -47,6 +47,15 @@ describe('selection bar', () => {
await waitFor(() => expect(batchMarkAsSpam).toHaveBeenCalledWith(expect.anything(), ['one', 'two']));
});
it('counts conversations in its toasts, as its title does (mail-18)', async () => {
const { toast } = await import('@/stores/toast-store');
const batchArchive = vi.fn().mockResolvedValue(true);
useEmailStore.setState({ batchArchive });
render(<EmailList emails={useEmailStore.getState().emails} />);
fireEvent.click(screen.getByTestId('batch-archive'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('mail_p2_list.conversations_archived', expect.anything()));
});
it('opens the selection’s menu from ⋮, with Move to and Forward as attachments', () => {
render(<EmailList emails={useEmailStore.getState().emails} />);
fireEvent.click(screen.getByTestId('batch-more'));
@@ -99,6 +108,43 @@ describe('selection bar: Move, read state and the reading pane', () => {
});
});
describe('select all in the folder (mail-p2-11)', () => {
it('offers the rest of the folder once every loaded row is ticked, and ticks it', async () => {
const third = { ...useEmailStore.getState().emails[0], id: 'three', threadId: 't-three', blobId: 'b-three' } as Email;
const loadMoreEmails = vi.fn(async () => {
useEmailStore.setState({ emails: [...useEmailStore.getState().emails, third], hasMoreEmails: false });
});
useEmailStore.setState({ hasMoreEmails: true, loadMoreEmails, totalEmails: 3 });
render(<EmailList emails={useEmailStore.getState().emails} />);
const offer = screen.getByTestId('select-all-in-view');
expect(offer.textContent).toBe('mail_p2_list.select_all_in_folder');
fireEvent.click(offer.querySelector('button')!);
await waitFor(() => expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['one', 'three', 'two']));
expect(loadMoreEmails).toHaveBeenCalledTimes(1);
});
it('offers nothing while only some loaded rows are ticked', () => {
useEmailStore.setState({ hasMoreEmails: true, selectedEmailKeys: new Set(['one']) });
render(<EmailList emails={useEmailStore.getState().emails} />);
expect(screen.queryByTestId('select-all-in-view')).toBeNull();
});
});
describe('the reading pane for a selection (mail-p2-15)', () => {
it('names its buttons in words, while the bar beside the list keeps icons', () => {
const host = document.createElement('div');
document.body.appendChild(host);
render(<EmailList emails={useEmailStore.getState().emails} selectionPaneHost={host} />);
const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement;
for (const id of ['archive', 'delete', 'move', 'mark-read', 'tag']) {
const button = pane.querySelector(`[data-testid="pane-batch-${id}"]`) as HTMLElement;
expect(button.textContent?.trim()).toBe(button.getAttribute('aria-label'));
}
expect(screen.getByTestId('batch-archive').textContent).toBe('');
host.remove();
});
});
describe('selection bar and rows from the keyboard', () => {
it('keeps the collapsed bar out of the Tab order', () => {
useEmailStore.setState({ selectedEmailKeys: new Set() });
@@ -54,24 +54,38 @@ beforeEach(() => {
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox' });
});
// mail-15: the default density shows one preview line, so more rows fit;
// comfortable keeps two.
// mail-15: the default density puts the preview after the subject, on the
// subject's line, so about ten rows fit at 1366×768 (they were about 96 px,
// seven on screen); comfortable keeps two preview lines, and a phone keeps
// the preview under the subject.
describe('list row preview lines', () => {
it.each([
['regular', 'line-clamp-1'],
['comfortable', 'line-clamp-2'],
] as const)('a %s row clamps its preview with %s', (density, clamp) => {
useSettingsStore.setState({ density });
renderRow([makeEmail()]);
expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp);
});
const onSubjectLine = () => {
const preview = screen.getByText(PREVIEW, { exact: false });
return preview.closest('[data-row-preview]') !== null;
};
it.each([
['regular', 'line-clamp-1'],
['comfortable', 'line-clamp-2'],
] as const)('a %s conversation row clamps its preview with %s', (density, clamp) => {
useSettingsStore.setState({ density });
renderRow([makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]);
expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp);
['message', [makeEmail()]],
['conversation', [makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]],
] as const)('a regular %s row shows its preview on the subject line', (_kind, emails) => {
useSettingsStore.setState({ density: 'regular' });
renderRow([...emails]);
expect(onSubjectLine()).toBe(true);
expect(document.querySelector('p.line-clamp-1')).toBeNull();
});
it('a comfortable row keeps two preview lines of its own', () => {
useSettingsStore.setState({ density: 'comfortable' });
renderRow([makeEmail()]);
expect(onSubjectLine()).toBe(false);
expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-2');
});
it('a regular row on a phone keeps the preview under the subject', () => {
useUIStore.setState({ isMobile: true });
useSettingsStore.setState({ density: 'regular' });
renderRow([makeEmail()]);
expect(onSubjectLine()).toBe(false);
expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-1');
});
});
@@ -29,6 +29,15 @@ describe('list header ⋮', () => {
fireEvent.click(screen.getByText('mark_folder_read'));
expect(onMarkFolderRead).toHaveBeenCalledTimes(1);
});
// p2-14: "Empty folder" read the same in Trash and Spam.
it('names Empty after Trash or Spam', () => {
const onEmptyFolder = vi.fn();
render(<MailFolderMenu folderRole="junk" onEmptyFolder={onEmptyFolder} />);
fireEvent.click(screen.getByTestId('mail-folder-menu'));
expect(screen.getByTestId('mail-empty-folder').textContent).toContain('empty_spam');
fireEvent.click(screen.getByTestId('mail-empty-folder'));
expect(onEmptyFolder).toHaveBeenCalledTimes(1);
});
it('carries Refresh and Sort, which have no buttons of their own', () => {
const onRefresh = vi.fn();
render(<MailFolderMenu folderRole="inbox" onRefresh={onRefresh} onMarkAllFoldersRead={vi.fn()} />);
@@ -37,8 +37,10 @@ describe('MoveFolderList', () => {
const onPick = vi.fn();
render(<MoveFolderList model={buildMoveMenu(short, { currentMailboxId: 'inbox' })} onPick={onPick} />);
// The i18n mock returns the key: the role's name, not the server's.
expect(screen.queryByText('Sent Items')).toBeNull();
expect(screen.getByText('mailboxes.sent')).toBeTruthy();
expect(screen.queryByText('Junk Mail')).toBeNull();
expect(screen.getByText('mailboxes.archive')).toBeTruthy();
// Sent is no Move target (pass 2 p2-18).
expect(screen.queryByText('mailboxes.sent')).toBeNull();
fireEvent.click(screen.getByText('mailboxes.spam'));
expect(onPick).toHaveBeenCalledWith('junk');
});
@@ -160,10 +160,10 @@ describe('MvListToolbar overflow menu', () => {
render(<MvListToolbar loadedCount={1} onRefresh={() => {}} onEmptyFolder={() => {}} />);
open();
expect(screen.queryByText('empty_folder')).toBeNull();
for (const label of ['empty_folder', 'empty_trash', 'empty_spam']) expect(screen.queryByText(label)).toBeNull();
});
it('offers it in the bin', () => {
it('offers it in the bin, as Empty Trash', () => {
useEmailStore.setState({
mailboxes: [mailbox('trash', 'Trash', 'trash')],
selectedMailbox: 'trash',
@@ -172,7 +172,7 @@ describe('MvListToolbar overflow menu', () => {
render(<MvListToolbar loadedCount={1} onRefresh={() => {}} onEmptyFolder={onEmptyFolder} />);
open();
fireEvent.click(screen.getByText('empty_folder'));
fireEvent.click(screen.getByText('empty_trash'));
expect(onEmptyFolder).toHaveBeenCalledOnce();
});
});
@@ -56,7 +56,7 @@ async function viewer(folder: string, h = handlers()) {
return h;
}
const archiveButton = () => document.querySelector('button[title="tooltips.archive"]');
const archiveButton = () => document.querySelector('button[title="archive (E)"]');
beforeEach(() => {
useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false, quickReplyEnabled: true });
@@ -74,7 +74,7 @@ describe('reader toolbar in Spam', () => {
it('has no quick reply', async () => {
await viewer('junk');
expect(screen.queryByPlaceholderText('quick_reply_placeholder')).toBeNull();
expect(screen.queryByPlaceholderText(/^quick_reply/)).toBeNull();
});
});
@@ -86,6 +86,20 @@ describe('reader toolbar in Trash', () => {
expect(h.onMoveToMailbox).toHaveBeenCalledWith('inbox');
});
// Pass 2, mail-9: the button said "Move back to Inbox" while the row
// menu and ⋮ said "Restore", for the same action.
it('names Restore as the menus do', async () => {
await viewer('trash');
const button = screen.getByTestId('reader-restore');
expect(button.getAttribute('title')).toBe('restore');
expect(button.getAttribute('aria-label')).toBe('restore');
});
it('offers no Report spam', async () => {
await viewer('trash');
expect(document.querySelector('button[title="spam.button_title"]')).toBeNull();
});
it('says Delete forever', async () => {
await viewer('trash');
expect(screen.getByTestId('reader-delete').textContent).toContain('delete_forever');
@@ -99,7 +113,7 @@ describe('reader toolbar in the Inbox', () => {
expect(screen.queryByTestId('reader-restore')).toBeNull();
expect(screen.queryByTestId('reader-not-spam')).toBeNull();
expect(screen.getByTestId('reader-delete').textContent).not.toContain('delete_forever');
expect(screen.getByPlaceholderText('quick_reply_placeholder')).toBeTruthy();
expect(screen.getByPlaceholderText(/^quick_reply/)).toBeTruthy();
});
});
@@ -96,6 +96,24 @@ describe('reader toolbar overflow', () => {
expect(labels).toEqual(expect.arrayContaining(['reply', 'reply_all', 'forward', 'archive', 'delete', 'move']));
});
it('keeps the names of Reply, Archive, Delete and Move while only those fit (mail-p2-15)', () => {
// 600 px: every label needs 400 per group, the main four's 280, none 250.
const spies = [
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
const step = this.closest('[data-toolbar-compact]')?.getAttribute('data-toolbar-compact');
return step === 'all' ? 250 : step === 'secondary' ? 280 : 400;
}),
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(600),
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(40),
];
restore = () => spies.forEach((spy) => spy.mockRestore());
const { container } = viewer();
expect(toolbar(container).getAttribute('data-toolbar-compact')).toBe('secondary');
expect(hiddenPriorities(container)).toEqual([]);
const kept = Array.from(container.querySelectorAll('[data-toolbar-label="primary"]')).map((label) => label.textContent);
expect(kept.sort()).toEqual(['archive', 'delete', 'move', 'reply']);
});
it('then hides the optional buttons, never Reply, Reply all, Forward, Archive or Move', () => {
restore = layout(500, 400);
const { container } = viewer();
@@ -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 () => {
@@ -47,7 +47,13 @@ describe('SearchResultCount', () => {
expect(container.textContent).toBe('advanced_search.results_found {"count":10}');
});
it('keeps "10+ results" while more can load', () => {
it('counts conversations while more can load and the list groups them', () => {
const { container } = render(<SearchResultCount emails={results} hasMore />);
expect(container.textContent).toBe('email_list.conversations_count_plus {"count":3}');
});
it('keeps "10+ results" while more can load and conversations are off', () => {
useSettingsStore.setState({ disableThreading: true });
const { container } = render(<SearchResultCount emails={results} hasMore />);
expect(container.textContent).toBe('advanced_search.results_found_more {"count":10}');
});
@@ -0,0 +1,63 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { MAIL_SHORTCUTS } from '@/lib/keyboard-shortcuts/mail';
import { messageActionShortcut } from '../message-action-shortcuts';
// mail-p2-13: Reply all read "Reply all (a)" in the toolbar, "Shift + R" in
// the menus and "R" in the help; Delete "# or Del" against "Del". The three
// places now name the same key.
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 email = {
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z',
preview: '', hasAttachment: false, size: 1, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
} as unknown as Email;
const helpKey = (description: string) =>
MAIL_SHORTCUTS.actions.find((entry) => entry.description === description)!.key;
describe('shortcut hints', () => {
it('give Reply all and Delete the same key in the help as in the menus', () => {
expect(helpKey('shortcuts.actions.reply_all')).toBe(messageActionShortcut('reply_all', { singleKeys: true }));
expect(helpKey('shortcuts.actions.delete')).toBe(messageActionShortcut('delete', { singleKeys: true }));
});
it('name the menus\' keys in the toolbar tooltips', () => {
const { container } = render(
<EmailViewer
email={email}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
onReply={vi.fn()}
onReplyAll={vi.fn()}
onForward={vi.fn()}
onArchive={vi.fn()}
onDelete={vi.fn()}
onMoveToMailbox={vi.fn()}
/>,
);
const titles = Array.from(container.querySelectorAll<HTMLElement>('button[title]')).map((b) => b.title);
expect(titles).toContain('reply_all (Shift + R)');
expect(titles).toContain('delete (Del)');
expect(titles).toContain('reply (R)');
});
});
@@ -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' }));
});
});
@@ -0,0 +1,48 @@
// mail-p2-6: the phone listed a conversation newest first while every other
// layout reads it oldest first. It now reads oldest first too, with the
// newest message (your own reply as well) open at the bottom.
import { describe, expect, it, vi } from 'vitest';
import { render } 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 mail = (n: number, extra: Record<string, unknown> = {}) => ({
id: `e${n}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: `Sender ${n}`, email: `s${n}@t.example` }], to: [{ email: 'me@example.org' }],
subject: 'Lunch', receivedAt: `2026-10-2${n}T10:00:00Z`, sentAt: `2026-10-2${n}T10:00:00Z`, size: 1, preview: `hi ${n}`, hasAttachment: false,
htmlBody: [], textBody: [], attachments: [], bodyValues: {}, ...extra,
});
function renderView(emails: ReturnType<typeof mail>[]) {
return render(
<ThreadConversationView
thread={{ threadId: 't1', emails, latestEmail: emails[0] } as never}
emails={emails as never}
onBack={() => {}}
mailboxRole="inbox"
/>,
);
}
const order = (c: HTMLElement) => [...c.querySelectorAll('[data-thread-email-id]')].map((e) => e.getAttribute('data-thread-email-id'));
describe('phone conversation order', () => {
it('lists the messages oldest first, as the thread arrives newest first', () => {
const { container } = renderView([mail(3), mail(2), mail(1)]);
expect(order(container)).toEqual(['e1', 'e2', 'e3']);
});
it('opens the newest message, your own reply too, and not a draft after it', () => {
const mine = mail(3, { from: [{ name: 'Me', email: 'me@example.org' }], mailboxIds: { sent: true } });
const draft = mail(4, { keywords: { $draft: true, $seen: true } });
const { container } = renderView([draft, mine, mail(2), mail(1)]);
const expanded = [...container.querySelectorAll('[data-thread-email-id]')]
.filter((el) => el.querySelector('[aria-expanded="true"]'))
.map((el) => el.getAttribute('data-thread-email-id'));
expect(expanded).toEqual(['e3']);
});
});
@@ -93,7 +93,9 @@ describe('message list rows under the bottom reading pane (#262)', () => {
it('keeps stacked rows in the split layout', () => {
useSettingsStore.setState({ mailLayout: 'split' });
const { container } = renderEmails([makeEmail()]);
expect(container.querySelector('p')).not.toBeNull();
// Stacked: a preview line of its own, or the preview after the subject
// on the subject's line at the regular density (mail-15).
expect(container.querySelector('p, [data-row-preview]')).not.toBeNull();
});
describe('as columns', () => {
@@ -0,0 +1,66 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
// mail-7: "Show details" stayed open for every message opened after it.
// mail-29: a mail received before it was sent showed a delivery time.
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() }),
}));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const make = (id: string, sentAt: string, receivedAt: string) => ({
id, blobId: `b-${id}`, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Sam', email: 'sam@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
subject: `Message ${id}`, sentAt, receivedAt, preview: 'Hello',
hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
}) as unknown as Email;
const viewer = (email: Email) => (
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" isLoading={false} />
);
describe('message details panel', () => {
it('closes when another message opens', () => {
const first = make('e1', '2026-11-03T10:00:00Z', '2026-11-03T10:00:05Z');
const second = make('e2', '2026-11-03T11:00:00Z', '2026-11-03T11:00:05Z');
const { rerender } = render(viewer(first));
act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
expect(screen.queryByText('details.recipients_routing')).not.toBeNull();
rerender(viewer(second));
expect(screen.queryByText('details.recipients_routing')).toBeNull();
// And back on the first one it is closed too: it was opened for that view.
rerender(viewer(first));
expect(screen.queryByText('details.recipients_routing')).toBeNull();
});
it('shows no delivery time for a mail received before it was sent', () => {
render(viewer(make('e3', '2026-11-03T21:40:00Z', '2026-11-03T20:40:00Z')));
act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
expect(screen.queryByText(/details\.delivery_time/i)).toBeNull();
expect(document.body.textContent).not.toMatch(/delivery_time/);
});
it('still shows a real delivery time', () => {
render(viewer(make('e4', '2026-11-03T20:40:00Z', '2026-11-03T21:40:00Z')));
act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
expect(document.body.textContent).toMatch(/delivery_time/);
});
});
@@ -63,6 +63,22 @@ describe('quick reply', () => {
});
});
// mail-p2-8: Undo send on a quick reply brings its text back into this box.
describe('quick reply after Undo send', () => {
it('takes the undone text back into the box of the message it answered', async () => {
const { useQuickReplyRestoreStore } = await import('@/stores/quick-reply-restore-store');
useQuickReplyRestoreStore.setState({ pending: null });
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement;
act(() => { useQuickReplyRestoreStore.getState().restore('other', 'Not for this one'); });
expect(box.value).toBe('');
act(() => { useQuickReplyRestoreStore.getState().restore('m1', 'My answer'); });
await waitFor(() => expect(box.value).toBe('My answer'));
expect(useQuickReplyRestoreStore.getState().pending).toBeNull();
});
});
describe('automatic sign-out while the external recipient question is open', () => {
it('answers Back and keeps the typed reply as a draft, without asking again', async () => {
const { useExternalRecipientConfirmStore } = await import('@/stores/external-recipient-confirm-store');
@@ -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();
});
});
+5
View File
@@ -26,6 +26,7 @@ export function BatchTagButton({
disabled,
scope = null,
testId = "batch-tag",
showLabel = false,
}: {
title: string;
/** Tags every selected message carries. */
@@ -40,6 +41,8 @@ export function BatchTagButton({
scope?: TagScope | null;
/** The reading pane's copy has its own (mail-10). */
testId?: string;
/** Name the button in words beside the icon, as the reading pane does (mail-p2-15). */
showLabel?: boolean;
}) {
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
@@ -101,8 +104,10 @@ export function BatchTagButton({
aria-haspopup="menu"
aria-expanded={isOpen}
disabled={disabled}
{...(showLabel ? { size: "sm" as const, title: "", className: "gap-1.5" } : {})}
>
<Tag />
{showLabel && <span data-selection-label="">{title}</span>}
</IconButton>
{isOpen &&
createPortal(
+121 -33
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 {
@@ -386,7 +386,10 @@ interface EmailComposerProps {
labelHeaders?: Record<string, string>;
/** The id plugins know this composer by, for onAfterEmailSend. */
composerId?: string;
}) => void | false | Promise<void | false>;
},
/** The composer as it was at Send, to bring it back as it was after Undo send (mail-p2-8). */
state?: ComposerDraftData,
) => void | false | Promise<void | false>;
/**
* onSend failed. A host that closes the composer as soon as onSend starts
* gets the message back here, to open it again: it must never vanish.
@@ -412,6 +415,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
@@ -421,6 +429,12 @@ interface EmailComposerProps {
popOut?: () => ComposerPopOut | null;
/** The move goes back to the main window: the button says that, and shows at any width. */
popOutToMainLabel?: string;
/**
* Docked under the message it answers (mail-p2-2): the composer starts
* compact, without the Subject line, the From line when there's nothing to
* pick, and the formatting bar until asked for. Full screen shows them all.
*/
docked?: boolean;
/**
* This composer is all a window shows (lib/popout). It closes itself once a
* message has gone. Closing the window with unsaved text hands the draft to
@@ -445,6 +459,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,8 +623,10 @@ export function EmailComposer({
requestCloseRef,
onDiscardDraft,
onSaveState,
onRestoreDiscarded,
popOut,
popOutToMainLabel,
docked = false,
ownWindow,
onSubjectChange,
className,
@@ -1250,6 +1268,8 @@ export function EmailComposer({
// button or Escape puts it back. Not in a window of its own, which is all composer.
const [maximized, setMaximized] = useState(false);
const canMaximize = !ownWindow && !mvWindow;
const dockCompact = docked && !maximized;
const [showDockFormatting, setShowDockFormatting] = useState(false);
// "close" (X, Escape): save or discard. "discard" (the Discard button): the
// user already chose, so only confirm it.
const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close');
@@ -1260,7 +1280,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 +1478,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 +1581,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 +1880,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 +3878,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 +4030,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
@@ -4381,7 +4428,7 @@ export function EmailComposer({
labelHeaders: labelled.labelHeaders,
delayedUntil: effectiveDelayedUntil,
composerId: pluginComposerIdRef.current ?? undefined,
});
}, handedToHost);
// Not when nothing went: no host, or one that sent nothing.
if (draftElsewhere && onSend && outcome !== false) await removeDraftVersion(finalDraftId!, draftLogin, draftAccountAtSend);
@@ -4475,6 +4522,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 +4737,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 +4757,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 +4793,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 +4806,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 +5023,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}
@@ -5170,7 +5242,7 @@ export function EmailComposer({
>
<div ref={composerFieldsRef} className="shrink-0 space-y-0 border-b">
{/* From field */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50", dockCompact && identities.length <= 1 && !fromOverrideEnabled && "hidden")} data-composer-from="">
<span className="text-sm text-muted-foreground min-w-12 md:min-w-16 shrink-0 whitespace-nowrap">{t('from')}:</span>
<div className={cn("flex-1 flex items-center gap-1 min-w-0", fromOverrideEnabled && "flex-wrap")}>
{fromOverrideEnabled ? (
@@ -5472,7 +5544,7 @@ export function EmailComposer({
)}
{/* Subject field */}
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5">
<div className={cn("flex flex-wrap items-center gap-2 px-4 py-2.5", dockCompact && "hidden")} data-composer-subject="">
<label htmlFor={`${fieldIdBase}-subject`} className="text-sm text-muted-foreground min-w-12 md:min-w-16 shrink-0 whitespace-nowrap">{t('subject_label')}</label>
<Input
id={`${fieldIdBase}-subject`}
@@ -5505,7 +5577,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} />
)}
@@ -5571,7 +5643,7 @@ export function EmailComposer({
ariaDescribedBy={`${fieldIdBase}-body-error`}
defaultTextStyle={composeStyle}
collapseToolbarOnMobile={phoneLayout && !showFormattingOnMobile}
toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'}
toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : dockCompact && !showDockFormatting ? 'hidden' : 'top'}
onEditorReady={handleEditorReady}
mentionCandidates={recipientMentionsEnabled ? mentionCandidates : undefined}
findMentionContacts={recipientMentionsEnabled && mentionAddsRecipientTo !== 'off' ? findMentionContacts : undefined}
@@ -5637,12 +5709,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 }) => {
@@ -5853,16 +5926,17 @@ export function EmailComposer({
<Button
variant="ghost"
size="icon"
onClick={() => mvWindow ? setMvShowFormatting((v) => !v) : setShowFormattingOnMobile((v) => !v)}
onClick={() => mvWindow ? setMvShowFormatting((v) => !v) : dockCompact ? setShowDockFormatting((v) => !v) : setShowFormattingOnMobile((v) => !v)}
className={cn(
"h-9 w-9 max-md:h-11 max-md:w-11",
!mvWindow && "md:hidden",
(mvWindow ? mvShowFormatting : showFormattingOnMobile) && "bg-accent text-accent-foreground",
!mvWindow && !dockCompact && "md:hidden",
(mvWindow ? mvShowFormatting : dockCompact ? showDockFormatting : showFormattingOnMobile) && "bg-accent text-accent-foreground",
)}
data-mv-formatting-toggle={mvWindow ? "" : undefined}
data-dock-formatting-toggle={dockCompact ? "" : undefined}
title={t('formatting')}
aria-label={t('formatting')}
aria-pressed={mvWindow ? mvShowFormatting : showFormattingOnMobile}
aria-pressed={mvWindow ? mvShowFormatting : dockCompact ? showDockFormatting : showFormattingOnMobile}
>
<Type className="w-4 h-4" />
</Button>
@@ -6145,7 +6219,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 +6231,7 @@ export function EmailComposer({
<SendWarningsDialog
warnings={sendWarnings?.items ?? null}
scheduling={sendWarnings?.schedule === true}
onBack={() => {
const first = sendWarnings?.items[0];
setSendWarnings(null);
@@ -6173,7 +6249,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 +6264,8 @@ export function EmailComposer({
}
}
setSendWarnings(null);
handleSend({ warningsConfirmed: true, delayedUntil });
if (schedule) setShowScheduleDialog(true);
else handleSend({ warningsConfirmed: true, delayedUntil });
}}
/>
@@ -6204,7 +6281,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 +6894,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 +7018,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"
+7 -4
View File
@@ -2,8 +2,9 @@
import { useMemo } from "react";
import { useLocale, useTranslations } from "next-intl";
import { spamApplicableIn } from "@/lib/spam-applicable";
import { useSettingsStore } from "@/stores/settings-store";
import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
import { useEmailStore, removalFolder, canRemoveFromFolder, bulkDeleteIsPermanent } from "@/stores/email-store";
import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
import { Email, Mailbox } from "@/lib/jmap/types";
import {
@@ -248,8 +249,10 @@ export function EmailContextMenu({
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
// in Sent, Drafts and Scheduled.
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
// in Sent, Drafts and Scheduled, and in Trash, where it is already thrown out.
const spamApplicable = spamApplicableIn(currentMailboxRole);
const deleteForever = bulkDeleteIsPermanent({ role: currentMailboxRole });
const tActions = useTranslations("reader_actions");
const isScheduled = email.isScheduled === true;
// One report per selected message, behind one confirm (enterprise pack).
const batchPhishing = usePhishingReport(showBatchActions ? (batchEmails ?? []) : [], currentMailboxRole);
@@ -397,7 +400,7 @@ export function EmailContextMenu({
{/* Delete */}
<ContextMenuItem
icon={Trash2}
label={t("delete")}
label={deleteForever ? tActions("delete_forever") : t("delete")}
testId="ctx-delete"
shortcut={shortcutOf("delete")}
onClick={() => handleAction(onBatchDelete!)}
+107 -19
View File
@@ -1,5 +1,6 @@
"use client";
import { spamApplicableIn } from "@/lib/spam-applicable";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { MemoizedThreadListItem, listMailboxRole } from "./thread-list-item";
import { ListColumnsHeader } from "./list-columns";
@@ -25,6 +26,7 @@ import { usePhishingReport } from "@/hooks/use-phishing-report";
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { batchToastTitle, selectionRowCount } from "@/lib/selection-toast";
import { Spinner } from "@/components/ui/spinner";
import { EmptyState } from "@/components/ui/empty-state";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
@@ -37,7 +39,7 @@ import { useOpenEmailInWindow } from "@/hooks/use-popouts-available";
import { useIsDesktop } from "@/hooks/use-media-query";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useSettingsStore } from "@/stores/settings-store";
import { useListRowShape } from "@/hooks/use-list-row-shape";
import { useListRowShape, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape";
import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton";
import type { Density } from "@/lib/density";
import { useUIStore } from "@/stores/ui-store";
@@ -142,6 +144,9 @@ type ListItem =
| { kind: 'header'; section: 'unread' | 'rest' }
| { kind: 'unread-empty' };
/** The most messages "Select all N conversations" loads to tick them (mail-p2-11). */
const SELECT_ALL_LIMIT = 5000;
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
const BATCH_TOOLBAR_WIDE = 640;
@@ -206,6 +211,7 @@ export function EmailList({
const tViewer = useTranslations('email_viewer');
const tSidebar = useTranslations('sidebar');
const tListExtra = useTranslations('mail_list_extra');
const tRoot = useTranslations();
const tUnread = useTranslations('account_unread');
const mountainViewList = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
const isDesktopPane = useIsDesktop();
@@ -283,7 +289,7 @@ export function EmailList({
mailboxes.find(m => m.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
// Spam is pointless on your own outgoing mail, as in the context menu.
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(effectiveMailboxRole || '');
const spamApplicable = spamApplicableIn(effectiveMailboxRole);
const disableThreading = useSettingsStore((state) => state.disableThreading);
// The order the current folder view was fetched in (#718), so thread
@@ -486,7 +492,11 @@ export function EmailList({
// up after a jump down (scrollbar drag, End). So guess per row.
const latest = item?.kind === 'row' ? item.thread.latestEmail : undefined;
let size = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
if (showPreview && density !== 'extra-compact') {
if (showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: mountainViewList })) {
// The preview follows the subject on its line, and the subject line
// drops its margin (mail-15).
size -= 4;
} else if (showPreview && density !== 'extra-compact') {
// A mail without a preview draws a one-line "No preview available",
// a line (23px) shorter than a real one. Only comfortable rows show a
// second preview line (mail-15).
@@ -497,7 +507,7 @@ export function EmailList({
const hasCode = !!latest && showVerificationCodes && !!listVerificationCode(latest);
if (latest && (hasCode || (onOpenAttachment && listRowShowsChips(latest, loadAttachments)))) size += 28;
return size;
}, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments]);
}, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments, isMobile, mountainViewList]);
// Stable per list, so the virtualizer does not rebuild every row's
// measurement on each scroll render.
@@ -571,6 +581,9 @@ export function EmailList({
() => (hasSelection ? resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }) : []),
[hasSelection, emails, threadEmailsCache, selectedEmailKeys],
);
// The rows the selection covers: what the bar, the pane and the toasts
// count (mail-18). A conversation row ticks all its loaded messages.
const selectionRows = selectionRowCount(selectedEmailsForRemoval, selectedEmailKeys.size, disableThreading);
// Report phishing for the selection, one report per message (enterprise pack).
const selectionPhishing = usePhishingReport(selectedEmailsForRemoval, effectiveMailboxRole);
const tPhishing = useTranslations('phishing_report');
@@ -680,11 +693,11 @@ export function EmailList({
if (!client || isProcessing) return;
if (!(await confirmMailRemoval('archive', selectedEmailKeys.size, confirmDialog, tRemove))) return;
setIsProcessing(true);
const count = selectedEmailKeys.size;
const rows = selectionRows;
const restore = restoreBatchesForSelection(client);
try {
await runBatchEmailAction(() => batchArchive(client), {
success: tNotifications('emails_archived', { count }),
success: batchToastTitle(tRoot, 'archive', rows, disableThreading),
error: tNotifications('error_archiving'),
describeError: (error) => error instanceof ArchiveMailboxNotFoundError
? tViewer('archive_mailbox_not_found')
@@ -699,7 +712,7 @@ export function EmailList({
const handleBatchMoveToMailbox = async (mailboxId: string) => {
if (!client || isProcessing) return;
setIsProcessing(true);
const count = selectedEmailKeys.size;
const rows = selectionRows;
// Undo puts the messages back; those a mixed-account list moved to
// another account move back the same way (the receipts).
const restore = restoreBatchesForSelection(client);
@@ -707,10 +720,8 @@ export function EmailList({
try {
const destination = mailboxes.find((m) => m.id === mailboxId);
await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId, undefined, receipts), {
// "3 emails moved to Family" (mail-18).
success: destination
? tListExtra('moved_count_to', { count, mailbox: getMailboxPath(destination, mailboxes) })
: tNotifications('emails_moved', { count }),
// "3 conversations moved to Family" (mail-18).
success: batchToastTitle(tRoot, 'move', rows, disableThreading, destination ? getMailboxPath(destination, mailboxes) : undefined),
error: tNotifications('move_failed'),
undo: undoMoveAction(client, restore, undo, receipts),
});
@@ -744,11 +755,11 @@ export function EmailList({
}
setIsProcessing(true);
const count = selectedEmailKeys.size;
const rows = selectionRows;
const restore = isInTrash ? null : restoreBatchesForSelection(client);
try {
await runBatchEmailAction(() => batchDelete(client, isInTrash), {
success: tNotifications('emails_deleted', { count }),
success: batchToastTitle(tRoot, isInTrash ? 'delete' : 'trash', rows, disableThreading),
error: tNotifications('error_deleting'),
undo: undoMoveAction(client, restore, undo),
});
@@ -773,11 +784,17 @@ export function EmailList({
// reading pane. The pane's copies get their own test ids.
const selectionButtons = (where: 'bar' | 'pane') => {
const testId = (id: string) => (where === 'bar' ? id : `pane-${id}`);
// The pane has room: its buttons say what they do (mail-p2-15). The
// bar beside the list stays icons with tooltips.
const labelled = where === 'pane';
const shape = labelled ? { size: 'sm' as const, title: '', className: 'gap-1.5' } : {};
const name = (label: string) => (labelled ? <span data-selection-label="">{label}</span> : null);
return (
<>
{archiveActionEnabled && effectiveMailboxRole !== 'archive' && (
<IconButton label={tContextMenu('archive')} onClick={() => void handleBatchArchive()} disabled={isProcessing} data-testid={testId('batch-archive')}>
<IconButton label={tContextMenu('archive')} onClick={() => void handleBatchArchive()} disabled={isProcessing} data-testid={testId('batch-archive')} {...shape}>
<Archive />
{name(tContextMenu('archive'))}
</IconButton>
)}
<IconButton
@@ -785,9 +802,11 @@ export function EmailList({
onClick={handleBatchDelete}
disabled={isProcessing}
data-testid={testId('batch-delete')}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
{...shape}
className={cn("text-destructive hover:text-destructive hover:bg-destructive/10", labelled && "gap-1.5")}
>
<Trash2 />
{name(t('batch_actions.delete'))}
</IconButton>
{!moveMenuIsEmpty(selectionMoveModel) && (
<IconButton
@@ -797,8 +816,10 @@ export function EmailList({
onClick={(e) => moveMenu.openContextMenuFrom(e.currentTarget, null, 'end')}
disabled={isProcessing}
data-testid={testId('batch-move')}
{...shape}
>
<FolderInput />
{name(tContextMenu('move_to'))}
</IconButton>
)}
<IconButton
@@ -806,10 +827,13 @@ export function EmailList({
onClick={() => handleBatchMarkAsRead(selectionHasUnread)}
disabled={isProcessing}
data-testid={testId('batch-mark-read')}
{...shape}
>
{selectionHasUnread ? <MailOpen /> : <Mail />}
{name(selectionHasUnread ? t('batch_actions.mark_read') : t('batch_actions.mark_unread'))}
</IconButton>
<BatchTagButton
showLabel={labelled}
key={hasSelection ? 'selection' : 'none'}
title={tContextMenu('tag')}
selectedIds={selectionTags.all}
@@ -827,11 +851,62 @@ export function EmailList({
// shows them, or emails when conversations are off.
const selectionTitle = disableThreading
? t('batch_actions.selected_messages', { count: selectedEmailKeys.size })
: tListExtra('selected_conversations', { count: selectedEmailKeys.size });
: tListExtra('selected_conversations', { count: selectionRows });
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isEmptyableFolder = currentMailbox?.role === 'trash' || currentMailbox?.role === 'junk';
// Every loaded row ticked and more on the server: offer the rest, as Gmail
// does (mail-p2-11). Taking it loads the remaining pages and ticks them,
// so the batch actions, their Undo and their counts work as for any
// selection. At most SELECT_ALL_LIMIT messages are loaded for it.
const [selectingAll, setSelectingAll] = useState(false);
const allLoadedSelected = hasSelection && emails.length > 0 && emails.every((e) => selectedEmailKeys.has(emailKeyFor(e)));
const offerSelectAll = allLoadedSelected && footerHasMore && !isScheduledView && !hideBatchToolbar && !!client
&& emails.length < SELECT_ALL_LIMIT;
const inPlainFolder = !!currentMailbox && !searchQuery.trim() && isFilterEmpty(searchFilters) && !isUnifiedView;
const viewTotal = Math.max(
inPlainFolder
? (disableThreading ? currentMailbox.totalEmails : (currentMailbox.totalThreads || currentMailbox.totalEmails))
: totalEmails,
selectionRows + 1,
);
const selectAllLabel = inPlainFolder
? tRoot(disableThreading ? 'mail_p2_list.select_all_emails_in_folder' : 'mail_p2_list.select_all_in_folder', {
count: viewTotal,
folder: localizeMailboxName(currentMailbox.role, currentMailbox.name, (k) => tSidebar(`mailboxes.${k}`)),
})
: tRoot(disableThreading ? 'mail_p2_list.select_all_emails' : 'mail_p2_list.select_all_conversations', { count: viewTotal });
const handleSelectAllInView = async () => {
if (!client || selectingAll) return;
const view = () => {
const state = useEmailStore.getState();
return `${state.selectedMailbox}|${state.searchQuery}|${state.isUnifiedView}|${state.unifiedRole}`;
};
const startedIn = view();
setSelectingAll(true);
try {
for (let pages = 0; pages < 1000; pages++) {
const state = useEmailStore.getState();
if (view() !== startedIn || state.selectedEmailKeys.size === 0) return;
if (!state.hasMoreEmails || state.emails.length >= SELECT_ALL_LIMIT) break;
if (state.isLoadingMore) {
await new Promise((resolve) => setTimeout(resolve, 50));
continue;
}
const before = state.emails.length;
await state.loadMoreEmails(client);
const after = useEmailStore.getState();
if (after.emails.length === before && !after.isLoadingMore) break;
}
// Another folder opened or the selection was cleared meanwhile: leave it.
if (view() !== startedIn || useEmailStore.getState().selectedEmailKeys.size === 0) return;
useEmailStore.getState().selectAllEmails();
} finally {
setSelectingAll(false);
}
};
const handleEmptyFolder = async () => {
if (!client || isProcessing || !currentMailbox) return;
@@ -1106,7 +1181,7 @@ export function EmailList({
inert={!(hasSelection && !isScheduledView && !hideBatchToolbar)}
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
hasSelection && !isScheduledView && !hideBatchToolbar ? (offerSelectAll || selectingAll ? "max-h-28 opacity-100" : "max-h-16 opacity-100") : "max-h-0 opacity-0"
)}
>
{/* Only drawn while shown: collapsed it would still render every button. */}
@@ -1115,7 +1190,7 @@ export function EmailList({
<div className="flex items-center gap-2 min-w-0 animate-in fade-in slide-in-from-left-3 duration-300">
{/* "5 conversations": the bar is narrow beside its buttons. */}
<span className="text-sm font-medium text-foreground whitespace-nowrap truncate" title={selectionTitle}>
{disableThreading ? selectionTitle : tListExtra('conversation_count', { count: selectedEmailKeys.size })}
{disableThreading ? selectionTitle : tListExtra('conversation_count', { count: selectionRows })}
</span>
</div>
<div className={cn("flex items-center flex-shrink-0 animate-in fade-in slide-in-from-right-3 duration-300", batchNarrow ? "gap-0" : "gap-0.5")}>
@@ -1202,6 +1277,19 @@ export function EmailList({
</div>
</div>
)}
{hasSelection && !isScheduledView && !hideBatchToolbar && (offerSelectAll || selectingAll) && (
<div className="px-4 py-1.5 border-b border-border bg-accent/20 text-center text-sm" data-testid="select-all-in-view">
<button
type="button"
onClick={() => void handleSelectAllInView()}
disabled={selectingAll}
aria-busy={selectingAll || undefined}
className="text-primary font-medium hover:underline disabled:no-underline disabled:opacity-70"
>
{selectingAll ? tRoot('mail_p2_list.selecting_all') : selectAllLabel}
</button>
</div>
)}
</div>
{/* Advanced Search Filter Chips */}
@@ -1430,7 +1518,7 @@ export function EmailList({
<CheckSquare className="w-10 h-10 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-4">{selectionTitle}</h2>
<div className="flex items-center justify-center gap-1">
<div className="flex flex-wrap items-center justify-center gap-1">
{selectionButtons('pane')}
<IconButton
label={t('batch_actions.more')}
+308 -59
View File
@@ -1,6 +1,7 @@
"use client";
import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react";
import { spamApplicableIn } from "@/lib/spam-applicable";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
@@ -38,6 +39,8 @@ import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-m
import { EmailContextMenu } from "./email-context-menu";
import { rememberedTrashOrigin } from "@/lib/trash-origin";
import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions";
import { useMessageActionShortcut } from "./message-action-shortcuts";
import { useQuickReplyRestoreStore } from "@/stores/quick-reply-restore-store";
import { PhishingMarkBanner } from "./phishing-mark-banner";
import { turnOffLinks } from "@/lib/phishing-links";
import { usePhishingMarked } from "@/hooks/use-phishing-report";
@@ -100,7 +103,7 @@ import {
Minus,
ShieldCheck,
ShieldAlert,
RotateCcw,
Undo,
Copy,
Brain,
Keyboard,
@@ -146,7 +149,6 @@ import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore, resolveEmailBlobContext, emailWriteSource, bulkDeleteIsPermanent, type EmailBlobContext } from "@/stores/email-store";
import { trashRestoreTarget } from "@/lib/trash-restore-target";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { useListSelection } from "@/lib/plugin-sandbox/assistant/b.list-selection";
import { readability } from "@/lib/plugin-sandbox/assistant/b.projection";
import { useThemeStore } from "@/stores/theme-store";
@@ -231,6 +233,8 @@ const TOOLBAR_PRIORITY_ACTIONS: Record<string, MessageActionKey> = {
const REQUIRED_TOOLBAR_PRIORITIES = [5, 3, 2, 4, 1] as const;
/** Set on the toolbar while it shows icons without their labels. */
const TOOLBAR_COMPACT_ATTRIBUTE = 'data-toolbar-compact';
/** Which labels the reader toolbar has dropped: none, all but the main four, or all. */
type ToolbarCompact = false | 'secondary' | 'all';
const NO_ACTION_KEYS: ReadonlySet<MessageActionKey> = new Set();
const NO_PLUGIN_KEYS: ReadonlySet<string> = new Set();
@@ -300,10 +304,17 @@ interface EmailViewerProps {
/** Mountain View: reply to one message of the conversation. */
onReplyConversationEmail?: (email: Email) => void;
onReplyAllConversationEmail?: (email: Email) => void;
/** Forward on a message of the conversation. */
onForwardConversationEmail?: (email: Email) => void;
/** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */
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. */
@@ -781,11 +792,82 @@ function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string {
return ago ? `${when} (${ago})` : when;
}
/**
* The date on a conversation message in the default layout: the time today,
* "Oct 21, 13:40" this year, "Oct 21, 2025" before. The full date is on hover.
*/
export function conversationShortDate(date: Date | string, timeFormat: '12h' | '24h', now: Date = new Date()): string {
const d = typeof date === 'string' ? new Date(date) : date;
const zone = getEffectiveTimeZone();
const day = Number.isNaN(d.getTime()) ? null : getWallClock(d, zone);
const today = getWallClock(now, zone);
if (day && day.year === today.year && day.month === today.month && day.day === today.day) return formatDateTime(d, timeFormat);
if (day && day.year === today.year) return formatDateTime(d, timeFormat, { month: 'short', day: 'numeric' });
return formatDateTime(d, timeFormat, { year: 'numeric', month: 'short', day: 'numeric', dateOnly: true });
}
function conversationFullDate(date: Date | string, timeFormat: '12h' | '24h'): string {
return formatDateTime(date, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
/**
* The date on a conversation message's sender line: Mountain View keeps
* Gmail's "Oct 21, 2026, 13:40 (14 days ago)", the phone the list's date, the
* default layout the short date. Every style has the full date on hover.
*/
/**
* How a conversation opens around the open message (mail-p2-4): its unread
* messages unfolded, as Gmail does, and the newest one too, also when it is
* your own reply. The open message folds when a newer one is shown and it had
* been read already. Drafts and messages still waiting to be sent are never
* "the newest".
*/
export function conversationOpenPlan(conversation: Email[], openId: string): { expanded: string[]; foldOpen: boolean; newestId: string | null } {
const expanded = conversation.filter((e) => e.id !== openId && !e.keywords?.$seen).map((e) => e.id);
const open = conversation.find((e) => e.id === openId);
const candidates = conversation.filter((e) => !e.keywords?.$draft && !e.isScheduled && !e.sentEcho);
const newest = candidates[candidates.length - 1];
if (!open || !newest || newest.id === openId) return { expanded, foldOpen: false, newestId: null };
if (!expanded.includes(newest.id)) expanded.push(newest.id);
return { expanded, foldOpen: !!open.keywords?.$seen, newestId: newest.id };
}
/**
* The reading pane's scroll position that shows the message being answered
* above a docked reply: its end at the pane's bottom edge, or its start at
* the top when the whole message doesn't fit.
*/
export function replyScrollTop(pane: HTMLElement, end: HTMLElement, start: HTMLElement | null): number {
const paneTop = pane.getBoundingClientRect().top;
const endY = end.getBoundingClientRect().top - paneTop + pane.scrollTop;
const startY = start ? start.getBoundingClientRect().top - paneTop + pane.scrollTop : 0;
const toEnd = endY - pane.clientHeight;
return Math.max(0, startY > toEnd ? toEnd : startY);
}
/** A button's tooltip with the key the menus and the shortcut help show (mail-p2-13). */
function useKeyTip() {
const shortcut = useMessageActionShortcut();
return (label: string, action: MessageActionKey) => {
const key = shortcut(action);
return key ? `${label} (${key})` : label;
};
}
function ConversationDate({ date, timeFormat, variant, className }: { date: string; timeFormat: '12h' | '24h'; variant: 'mv' | 'list' | 'short'; className?: string }) {
const shown = variant === 'mv' ? mvSenderDate(date, timeFormat) : variant === 'list' ? formatDate(date) : conversationShortDate(date, timeFormat);
return (
<time dateTime={date} title={conversationFullDate(date, timeFormat)} className={cn("text-xs text-muted-foreground whitespace-nowrap", className ?? "ms-auto ps-2")} data-conversation-date="">
{shown}
</time>
);
}
/**
* One message of a Mountain View conversation, folded to its sender, date and
* snippet. Clicking it unfolds the message in place.
*/
function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean }) {
function MvConversationCard({ email, onOpen, timeFormat, compact, shortDates }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean; shortDates?: boolean }) {
const tSidebar = useTranslations('sidebar');
const sender = email.from?.[0];
const senderWarning = useSenderWarning(email);
@@ -810,9 +892,7 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema
{email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')}
</span>
)}
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
<ConversationDate date={emailDisplayDate(email)} timeFormat={timeFormat} variant={compact ? 'list' : shortDates ? 'short' : 'mv'} />
</span>
<span className="block text-sm text-muted-foreground truncate">{cleanPreview(email.preview)}</span>
</span>
@@ -872,22 +952,28 @@ export function MvExpandedConversationCard({
onMarkRead,
onReply,
onReplyAll,
onForward,
onDownloadAttachment,
blobSource,
compact,
shortDates,
}: {
email: Email;
timeFormat: '12h' | '24h';
compact?: boolean;
/** The default layout's short date instead of Gmail's long one. */
shortDates?: boolean;
onCollapse: (email: Email) => void;
onLoadBody?: (email: Email) => void;
onMarkRead?: (email: Email) => void;
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
onDownloadAttachment?: ConversationAttachmentHandler;
blobSource?: EmailBlobContext;
}) {
const t = useTranslations('email_viewer');
const keyTip = useKeyTip();
const tSidebar = useTranslations('sidebar');
const externalContent = useConversationExternalContent();
const sender = email.from?.[0];
@@ -940,9 +1026,7 @@ export function MvExpandedConversationCard({
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} className="self-center" />
<ReceivedLabelChip email={email} className="self-center" />
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
<ConversationDate date={emailDisplayDate(email)} timeFormat={timeFormat} variant={compact ? 'list' : shortDates ? 'short' : 'mv'} />
</span>
<button
type="button"
@@ -959,14 +1043,14 @@ export function MvExpandedConversationCard({
{showDetails ? <ChevronUp className="w-3 h-3 flex-shrink-0" /> : <ChevronDown className="w-3 h-3 flex-shrink-0" />}
</button>
</span>
{(onReply || onReplyAll) && !email.keywords?.$draft && !email.isScheduled && (
{(onReply || onReplyAll || onForward) && !email.keywords?.$draft && !email.isScheduled && (
<span className="flex items-center gap-1 -my-2 -me-2 flex-shrink-0" data-mv-conversation-actions="">
{onReply && (
<button
type="button"
onClick={() => onReply(email)}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t('tooltips.reply')}
title={keyTip(t('reply'), 'reply')}
aria-label={t('reply')}
>
<Reply className="w-5 h-5" />
@@ -977,12 +1061,23 @@ export function MvExpandedConversationCard({
type="button"
onClick={() => onReplyAll(email)}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t('tooltips.reply_all')}
title={keyTip(t('reply_all'), 'reply_all')}
aria-label={t('reply_all')}
>
<ReplyAll className="w-5 h-5" />
</button>
)}
{onForward && (
<button
type="button"
onClick={() => onForward(email)}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={keyTip(t('forward'), 'forward')}
aria-label={t('forward')}
>
<Forward className="w-5 h-5" />
</button>
)}
</span>
)}
</div>
@@ -1022,9 +1117,10 @@ export function MvExpandedConversationCard({
* run of folded messages before it shows only its first and last message
* around a count, as Gmail does, and the count unfolds the rest.
*/
export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, expandedIds, renderExpanded, compact }: {
export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, expandedIds, renderExpanded, compact, shortDates }: {
emails: Email[];
compact?: boolean;
shortDates?: boolean;
/** A folded message was clicked. */
onOpen?: (email: Email) => void;
timeFormat: '12h' | '24h';
@@ -1038,7 +1134,7 @@ export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun,
const isExpanded = (e: Email) => !!renderExpanded && !!expandedIds?.has(e.id);
const renderOne = (e: Email) => isExpanded(e)
? <Fragment key={e.id}>{renderExpanded!(e)}</Fragment>
: <MvConversationCard key={e.id} email={e} onOpen={onOpen} timeFormat={timeFormat} compact={compact} />;
: <MvConversationCard key={e.id} email={e} onOpen={onOpen} timeFormat={timeFormat} compact={compact} shortDates={shortDates} />;
const middle = emails.slice(1, -1);
if (!collapseLongRun || showAll || emails.length <= 3 || middle.some(isExpanded)) {
return <>{emails.map(renderOne)}</>;
@@ -1374,8 +1470,10 @@ export function EmailViewer({
onMarkConversationEmailRead,
onReplyConversationEmail,
onReplyAllConversationEmail,
onForwardConversationEmail,
mvInlineComposer,
replyComposerOpen,
replyDraft,
onEditDraft,
onCancelScheduledForEdit,
onCancelScheduledSend,
@@ -1400,6 +1498,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');
@@ -1491,14 +1590,13 @@ export function EmailViewer({
// destroys the mail, Delete says "Delete forever".
const isInTrashFolder = currentMailboxRole === 'trash';
const tActions = useTranslations('reader_actions');
const tSidebarNames = useTranslations('sidebar');
// Read for the re-render: bulkDeleteIsPermanent reads them from the store.
useSettingsStore((state) => state.deleteAction);
useSettingsStore((state) => state.permanentlyDeleteJunk);
const deleteIsPermanent = bulkDeleteIsPermanent({ role: currentMailboxRole });
// Marking your own outgoing mail as spam makes no sense - hide the action
// in Sent, Drafts and Scheduled.
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
const spamApplicable = spamApplicableIn(currentMailboxRole);
// Detect if the email is a draft
const isDraft = email?.keywords?.['$draft'] === true;
@@ -1537,6 +1635,11 @@ export function EmailViewer({
const mvExpandedIdsRef = useRef(mvExpandedIds);
mvExpandedIdsRef.current = mvExpandedIds;
const mvExpandedThreadRef = useRef<string | null>(null);
// The open message folds too, but only inside a conversation.
const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false);
useEffect(() => setMvOpenCollapsed(false), [email?.id]);
// The newest message to unfold and scroll to once the conversation is drawn.
const [mvNewestToShow, setMvNewestToShow] = useState<string | null>(null);
useEffect(() => {
if (!mvConversation || mvConversation.length === 0 || !email) {
mvExpandedThreadRef.current = null;
@@ -1545,8 +1648,36 @@ export function EmailViewer({
const key = mvConversation[0].threadId;
if (mvExpandedThreadRef.current === key) return;
mvExpandedThreadRef.current = key;
setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id)));
const plan = conversationOpenPlan(mvConversation, email.id);
setMvExpandedIds(new Set(plan.expanded));
if (plan.foldOpen) setMvOpenCollapsed(true);
setMvNewestToShow(plan.newestId);
}, [mvConversation, email]);
useEffect(() => {
if (!mvNewestToShow) return;
const target = mvNewestToShow;
setMvNewestToShow(null);
const frame = requestAnimationFrame(() => {
const card = document.querySelector<HTMLElement>(`[data-mv-conversation-expanded][data-email-id="${CSS.escape(target)}"]`);
const pane = card?.closest<HTMLElement>('[data-reader-scroll]');
if (!card || !pane) return;
// Bring the newest message's start into view when it's below the fold.
const cardTop = card.getBoundingClientRect().top - pane.getBoundingClientRect().top;
if (cardTop > pane.clientHeight - 96) pane.scrollTop += cardTop - 8;
});
return () => cancelAnimationFrame(frame);
}, [mvNewestToShow]);
// A reply just sent joins the end of the conversation, folded like the
// others: bring it into view so it's seen to be there (mail-p2-1).
const lastEchoId = mvConversation?.filter((e) => e.sentEcho).at(-1)?.id ?? null;
useEffect(() => {
if (!lastEchoId) return;
const frame = requestAnimationFrame(() => {
document.querySelector<HTMLElement>(`[data-mv-conversation-card][data-email-id="${CSS.escape(lastEchoId)}"]`)
?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
});
return () => cancelAnimationFrame(frame);
}, [lastEchoId]);
const toggleMvExpanded = useCallback((target: Email) => {
emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id));
setMvExpandedIds((prev) => {
@@ -1556,10 +1687,11 @@ export function EmailViewer({
return next;
});
}, []);
// The open message folds too, but only inside a conversation.
const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false);
useEffect(() => setMvOpenCollapsed(false), [email?.id]);
const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed;
// The default layout's conversation (not Mountain View): the open message
// gets the same header as the others, with its date and its reply actions
// on the sender's line, and the subject line no date (mail-p2-4).
const inConversation = mvOpenIndex >= 0 && !mvViewer;
// Folding or unfolding the open message of a conversation, told to plugins
// like any other conversation message (onThreadExpand).
const setMvOpenFolded = (folded: boolean) => {
@@ -1579,6 +1711,8 @@ export function EmailViewer({
onMarkRead={onMarkConversationEmailRead}
onReply={onReplyConversationEmail}
onReplyAll={onReplyAllConversationEmail}
onForward={onForwardConversationEmail}
shortDates={!mvViewer}
blobSource={target.sourceClientAccountId || target.sourceAccountId
? resolveEmailBlobContext(target, client)
: blobSource ?? resolveEmailBlobContext(target, client)}
@@ -1590,6 +1724,13 @@ export function EmailViewer({
const simpleMode = useSettingsStore((state) => state.simpleMode);
// Simple mode always names the toolbar's buttons in words.
const showToolbarLabels = (showToolbarLabelsSetting || simpleMode) && interfaceLayout !== 'mountain-view';
// Tooltips name the same key the message menus and the shortcut help do
// (mail-p2-13), and none when single keys are off.
const messageActionShortcut = useMessageActionShortcut();
const withShortcut = (label: string, action: MessageActionKey) => {
const key = messageActionShortcut(action);
return key ? `${label} (${key})` : label;
};
// Inside a Pro pane, `isMobile` above is pane-width based: overlays that
// would go viewport-fixed must instead cover just the pane (via
// PaneOverlay + absolute positioning), and viewport CSS breakpoints like
@@ -1661,7 +1802,13 @@ export function EmailViewer({
}, [onRescheduleScheduled, t, tComposer, scheduledSendAt, timeFormat]);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false);
// The details panel belongs to the message it was opened on: the next
// message opens without it (mail-7).
const shownEmailId = email?.id ?? null;
const [details, setDetails] = useState<{ emailId: string | null; open: boolean }>({ emailId: shownEmailId, open: false });
if (details.emailId !== shownEmailId) setDetails({ emailId: shownEmailId, open: false });
const showFullHeaders = details.open && details.emailId === shownEmailId;
const setShowFullHeaders = (open: boolean) => setDetails({ emailId: shownEmailId, open });
const [isDownloadingAll, setIsDownloadingAll] = useState(false);
const [imageThumbUrls, setImageThumbUrls] = useState<Record<string, string>>({});
const [allowExternalContent, setAllowExternalContent] = useState(false);
@@ -1854,7 +2001,7 @@ export function EmailViewer({
// looked like (see layoutKey), and the width it last had: an open that
// brings back a toolbar already measured at this width needs no measuring.
// A loading message and a loaded one can differ, so a few are kept.
const measuredToolbarRef = useRef(new Map<string, { hidden: Set<number>; compact: boolean }>());
const measuredToolbarRef = useRef(new Map<string, { hidden: Set<number>; compact: ToolbarCompact }>());
const toolbarWidthRef = useRef<number | undefined>(undefined);
const currentTagIds = getEmailTagIds(email?.keywords);
const sortedTagIds = sortTagIds(currentTagIds);
@@ -2117,7 +2264,8 @@ export function EmailViewer({
const key = layoutKey(items);
const known = toolbarWidthRef.current !== undefined ? measuredToolbarRef.current.get(key) : undefined;
if (known) {
el.toggleAttribute(TOOLBAR_COMPACT_ATTRIBUTE, known.compact);
if (known.compact) el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, known.compact);
else el.removeAttribute(TOOLBAR_COMPACT_ATTRIBUTE);
for (const item of items) item.style.display = known.hidden.has(Number(item.dataset.overflowPriority)) ? 'none' : '';
applyHidden(known.hidden);
return;
@@ -2135,10 +2283,13 @@ export function EmailViewer({
const containerWidth = el.clientWidth;
const overflowNow = () => leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1);
let overflow = overflowNow();
// The labels go before any button does (icons keep their tooltips).
const compact = overflow > 0;
if (compact) {
el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, '');
// The labels go before any button does (icons keep their tooltips):
// first all but Reply, Archive, Delete and Move, then those too.
let compact: ToolbarCompact = false;
for (const step of ['secondary', 'all'] as const) {
if (overflow <= 0) break;
compact = step;
el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, step);
overflow = overflowNow();
}
const hidden = new Set<number>();
@@ -2382,6 +2533,23 @@ export function EmailViewer({
setEmbeddedEmailAttachments(NO_POSTAL_ATTACHMENTS);
}, [email?.id, externalContentPolicy]);
// Undo send on a quick reply puts its text back here, where it was
// written (mail-p2-8). After the reset above, which runs on the same open.
const quickReplyRestoreWaiting = useQuickReplyRestoreStore((s) => !!email && s.pending?.emailId === email.id);
useEffect(() => {
if (!quickReplyRestoreWaiting || !email) return;
const text = useQuickReplyRestoreStore.getState().take(email.id);
if (text === null) return;
setQuickReplyText(text);
setIsQuickReplyFocused(true);
requestAnimationFrame(() => {
const box = quickReplyRef.current;
if (!box) return;
box.focus();
box.setSelectionRange(box.value.length, box.value.length);
});
}, [quickReplyRestoreWaiting, email]);
// Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin
// can fetch the raw message via api.jmap.fetchBlob, decrypt/verify it, and
// return a replaced body through the onRenderEmailBody hook. The host stays
@@ -3514,8 +3682,46 @@ 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
// A reply docks under the message it answers: keep that message in view
// above it, its end just over the dock, or its start when it is taller
// than what is left of the pane (mail-p2-2).
useEffect(() => {
if (!replyComposerOpen || !email) return;
// The message answered is read while answering: unfold it.
setMvOpenCollapsed(false);
let frame = 0;
const place = () => {
const pane = document.querySelector<HTMLElement>('[data-reader-scroll]');
const end = pane?.querySelector<HTMLElement>('[data-open-message-end]');
if (!pane || !end) return;
const start = pane.querySelector<HTMLElement>('[data-mv-open-header]') ?? pane.querySelector<HTMLElement>('[data-mv-open-folded]');
pane.scrollTop = replyScrollTop(pane, end, start);
};
// Once the dock has taken its room, and again when its height settles.
frame = requestAnimationFrame(() => { frame = requestAnimationFrame(place); });
const timer = setTimeout(place, 250);
return () => { cancelAnimationFrame(frame); clearTimeout(timer); };
}, [replyComposerOpen, email?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- once per reply opened
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.
@@ -4474,10 +4680,10 @@ ${printWatermark ? printWatermark.html : ''}
data-overflow-item
data-overflow-priority="1"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.reply')}
title={withShortcut(t('reply'), 'reply')}
>
<Reply className="w-4 h-4" />
{showToolbarLabels && <span data-toolbar-label="" className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reply')}</span>}
{showToolbarLabels && <span data-toolbar-label="primary" className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reply')}</span>}
</Button>
<Button
variant="ghost"
@@ -4486,7 +4692,7 @@ ${printWatermark ? printWatermark.html : ''}
data-overflow-item
data-overflow-priority="2"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
title={t('tooltips.reply_all')}
title={withShortcut(t('reply_all'), 'reply_all')}
>
<ReplyAll className="w-4 h-4" />
{showToolbarLabels && <span data-toolbar-label="" className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reply_all')}</span>}
@@ -4498,7 +4704,7 @@ ${printWatermark ? printWatermark.html : ''}
data-overflow-item
data-overflow-priority="3"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.forward')}
title={withShortcut(t('forward'), 'forward')}
>
<Forward className="w-4 h-4" />
{showToolbarLabels && <span data-toolbar-label="" className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('forward')}</span>}
@@ -4535,7 +4741,7 @@ ${printWatermark ? printWatermark.html : ''}
title={t('spam.not_spam_title')}
>
<ShieldCheck className={cn("h-4 w-4", !mvViewer && "text-emerald-600 dark:text-emerald-400")} />
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('spam.not_spam_title')}</span>}
{showToolbarLabels && <span data-toolbar-label="primary" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('spam.not_spam_title')}</span>}
</Button>
)}
{isInTrashFolder && restoreTarget && onMoveToMailbox && (
@@ -4548,10 +4754,11 @@ ${printWatermark ? printWatermark.html : ''}
data-overflow-action=""
data-testid="reader-restore"
className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-1")}
title={tActions('restore_to', { folder: localizeMailboxName(restoreTarget.role, restoreTarget.name, (key) => tSidebarNames(`mailboxes.${key}`)) })}
title={tActions('restore')}
aria-label={tActions('restore')}
>
<RotateCcw className="w-4 h-4" />
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{tActions('restore')}</span>}
<Undo className="w-4 h-4" />
{showToolbarLabels && <span data-toolbar-label="primary" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{tActions('restore')}</span>}
</Button>
)}
{archiveActionEnabled && !isInJunkFolder && !isInTrashFolder && (
@@ -4562,10 +4769,10 @@ ${printWatermark ? printWatermark.html : ''}
data-overflow-item
data-overflow-priority="4"
className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-1")}
title={t('tooltips.archive')}
title={withShortcut(t('archive'), 'archive')}
>
<Archive className="w-4 h-4" />
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('archive')}</span>}
{showToolbarLabels && <span data-toolbar-label="primary" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('archive')}</span>}
</Button>
)}
{/* Delete */}
@@ -4574,11 +4781,11 @@ ${printWatermark ? printWatermark.html : ''}
size="sm"
onClick={onDelete}
className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-3")}
title={deleteIsPermanent ? tActions('delete_forever') : t('tooltips.delete')}
title={deleteIsPermanent ? tActions('delete_forever') : withShortcut(t('delete'), 'delete')}
data-testid="reader-delete"
>
<Trash2 className="w-4 h-4" />
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{deleteIsPermanent ? tActions('delete_forever') : t('delete')}</span>}
{showToolbarLabels && <span data-toolbar-label="primary" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{deleteIsPermanent ? tActions('delete_forever') : t('delete')}</span>}
</Button>
{/* Move to folder */}
{canMoveAnywhere && onMoveToMailbox && (
@@ -4598,7 +4805,7 @@ ${printWatermark ? printWatermark.html : ''}
aria-expanded={moveMenuOpen}
>
<FolderInput className="w-4 h-4" />
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('move')}</span>}
{showToolbarLabels && <span data-toolbar-label="primary" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('move')}</span>}
</Button>
<ContextMenu ref={moveMenu.menuRef} isOpen={moveMenuOpen} position={moveMenu.contextMenu.position} onClose={closeMoveMenu} label={t('move_to')}>
<div className="w-64 max-w-full" data-reader-move-menu="">
@@ -5092,7 +5299,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender.
Mountain View puts it on the sender's row instead. The size is
in the details. */}
<div className={cn("hidden flex-shrink-0 text-end", !mvViewer && "sm:block")}>
<div className={cn("hidden flex-shrink-0 text-end", !mvViewer && !inConversation && "sm:block")}>
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
@@ -5115,7 +5322,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Email Content Area. Mountain View reads the conversation on one plain
sheet, so the reply buttons under the message sit on the page colour
rather than on a tinted strip. */}
<div className={cn("flex-1 overflow-auto overscroll-contain", mvViewer || mvMobileViewer ? "bg-background" : "bg-muted/30", isMobile && !mvMobileViewer && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
<div data-reader-scroll="" className={cn("flex-1 overflow-auto overscroll-contain", mvViewer || mvMobileViewer ? "bg-background" : "bg-muted/30", isMobile && !mvMobileViewer && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
<div className="min-h-full flex flex-col">
{/* Above the messages of the open conversation. */}
@@ -5126,14 +5333,14 @@ ${printWatermark ? printWatermark.html : ''}
{/* Mountain View: the conversation's earlier messages, folded. */}
{mvBefore.length > 0 && (
<div className="bg-background" data-mv-conversation-before="">
<MvConversationRun emails={mvBefore} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} collapseLongRun />
<MvConversationRun emails={mvBefore} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} shortDates={!mvViewer} collapseLongRun />
</div>
)}
{/* Mountain View: the open message folded like the rest of the conversation. */}
{mvOpenFolded && email ? (
<div className="bg-background" data-mv-open-folded="">
<MvConversationCard email={email} onOpen={() => setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
<MvConversationCard email={email} onOpen={() => setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} shortDates={!mvViewer} />
</div>
) : mvAwaitingConversation ? (
<div className={cn("flex items-start py-3 animate-pulse", mvMobileViewer ? "gap-3 px-4" : "gap-4 px-6")} data-mv-conversation-pending="">
@@ -5193,6 +5400,9 @@ ${printWatermark ? printWatermark.html : ''}
{mvViewer && sender?.email && sender?.name && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
{inConversation && sender?.email && sender?.name && (
<span className="text-xs text-muted-foreground truncate min-w-0">&lt;<AddressText name={sender.name} email={sender.email} onViewContact={handleViewContactSidebar} />&gt;</span>
)}
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<ReceivedLabelChip email={email} />
@@ -5210,12 +5420,12 @@ ${printWatermark ? printWatermark.html : ''}
}}
/>
)}
{mvViewer && (
{(mvViewer || inConversation) && (
<span className="ms-auto ps-2 flex items-center gap-1 -my-2 -me-2" data-mv-sender-actions="">
<span className="text-xs text-muted-foreground whitespace-nowrap me-1" data-mv-sender-date="">
{mvSenderDate(emailDisplayDate(email), timeFormat)}
<span className="me-1" data-mv-sender-date="">
<ConversationDate date={emailDisplayDate(email)} timeFormat={timeFormat} variant={mvViewer ? 'mv' : 'short'} className="" />
</span>
<button
{mvViewer && (<button
type="button"
onClick={onToggleStar}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
@@ -5224,13 +5434,13 @@ ${printWatermark ? printWatermark.html : ''}
aria-pressed={!!isStarred}
>
<Star className={cn("w-5 h-5", isStarred && "fill-amber-400 text-amber-400")} />
</button>
</button>)}
{!isDraft && !isScheduled && (
<button
type="button"
onClick={() => onReply?.()}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t('tooltips.reply')}
title={withShortcut(t('reply'), 'reply')}
aria-label={t('reply')}
>
<Reply className="w-5 h-5" />
@@ -5241,17 +5451,28 @@ ${printWatermark ? printWatermark.html : ''}
type="button"
onClick={onReplyAll}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t('tooltips.reply_all')}
title={withShortcut(t('reply_all'), 'reply_all')}
aria-label={t('reply_all')}
>
<ReplyAll className="w-5 h-5" />
</button>
)}
{inConversation && !isDraft && !isScheduled && onForward && (
<button
type="button"
onClick={() => onForward()}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={withShortcut(t('forward'), 'forward')}
aria-label={t('forward')}
>
<Forward className="w-5 h-5" />
</button>
)}
</span>
)}
</div>
{/* Email address under name (on the name's line under Mountain View) */}
{!mvViewer && sender?.email && sender?.name && (
{/* Email address under name (on the name's line under Mountain View and in a conversation) */}
{!mvViewer && !inConversation && sender?.email && sender?.name && (
<div className="text-sm text-muted-foreground mt-0.5 truncate"><AddressText name={sender.name} email={sender.email} onViewContact={handleViewContactSidebar} /></div>
)}
</div>
@@ -5462,7 +5683,7 @@ ${printWatermark ? printWatermark.html : ''}
type="button"
onClick={() => onReply?.()}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t('tooltips.reply')}
title={withShortcut(t('reply'), 'reply')}
aria-label={t('reply')}
>
<Reply className="w-5 h-5" />
@@ -5472,7 +5693,7 @@ ${printWatermark ? printWatermark.html : ''}
type="button"
onClick={onReplyAll}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t('tooltips.reply_all')}
title={withShortcut(t('reply_all'), 'reply_all')}
aria-label={t('reply_all')}
>
<ReplyAll className="w-5 h-5" />
@@ -5502,8 +5723,10 @@ ${printWatermark ? printWatermark.html : ''}
};
const replyToDifferent = !!email.replyTo?.length &&
(!email.from || email.replyTo[0].email !== email.from[0]?.email);
// Received before it was sent means a wrong clock somewhere, not a
// delivery time (mail-29).
const deliveryDeltaMs = email.sentAt && email.receivedAt
? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime())
? new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime()
: 0;
const formatDelta = (diff: number) => {
const minutes = Math.floor(diff / 60000);
@@ -6155,10 +6378,13 @@ ${printWatermark ? printWatermark.html : ''}
<PluginSlot name="email-footer" extraProps={pluginFooterProps} />
{/* Where the open message ends: a reply docked under it scrolls to here. */}
<div data-open-message-end="" aria-hidden="true" />
{/* Mountain View: the conversation's later messages, folded. */}
{mvAfter.length > 0 && (
<div className="mt-4 border-b border-border bg-background" data-mv-conversation-after="">
<MvConversationRun emails={mvAfter} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} />
<MvConversationRun emails={mvAfter} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} shortDates={!mvViewer} />
</div>
)}
@@ -6190,8 +6416,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>
);
}
+8 -1
View File
@@ -11,6 +11,7 @@ import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { isDefaultOrder, sanitizeSortLevels, type SortLevel } from "@/lib/message-list-order";
import { emptyFolderLabelKey } from "@/lib/empty-folder-text";
/** The orders the list header offers; Settings has the full editor. */
export const SORT_CHOICES = [
@@ -185,6 +186,7 @@ export const MailFolderMenu = memo(function MailFolderMenu({
onMarkFolderRead={onMarkFolderRead}
onMarkAllFoldersRead={onMarkAllFoldersRead}
onEmptyFolder={onEmptyFolder}
folderRole={folderRole}
onPicked={menu.closeContextMenu}
/>
</ContextMenu>
@@ -197,14 +199,18 @@ export function MailFolderMenuItems({
onMarkFolderRead,
onMarkAllFoldersRead,
onEmptyFolder,
folderRole,
onPicked,
}: {
onMarkFolderRead?: () => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: () => void;
/** Names the entry "Empty Trash" or "Empty Spam". */
folderRole?: string | null;
onPicked: () => void;
}) {
const tFolder = useTranslations("mailbox_context_menu");
const tRoot = useTranslations();
return (
<>
<ContextMenuItem
@@ -225,8 +231,9 @@ export function MailFolderMenuItems({
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderX}
label={tFolder("empty_folder")}
label={tRoot(emptyFolderLabelKey(folderRole) ?? "mailbox_context_menu.empty_folder")}
destructive
testId="mail-empty-folder"
onClick={() => { onPicked(); onEmptyFolder(); }}
/>
</>
+9 -4
View File
@@ -2,8 +2,9 @@
import type { ComponentType } from "react";
import { useTranslations } from "next-intl";
import { spamApplicableIn } from "@/lib/spam-applicable";
import { useSettingsStore } from "@/stores/settings-store";
import { emailLinkSource } from "@/stores/email-store";
import { emailLinkSource, bulkDeleteIsPermanent } from "@/stores/email-store";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
import { buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
@@ -161,6 +162,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
const tPhishing = useTranslations("phishing_report");
const tWindows = useTranslations("pwaWindows");
const tRecover = useTranslations("recover_deleted");
const tActions = useTranslations("reader_actions");
const copyLink = useCopyLink();
const archiveEnabled = useArchiveActionEnabled();
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
@@ -175,8 +177,11 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
const isDraft = email.keywords?.["$draft"] === true;
const isScheduled = email.isScheduled === true;
const inJunk = o.currentMailboxRole === "junk";
// Marking your own outgoing mail as spam makes no sense.
const spamApplicable = !["sent", "drafts", "scheduled"].includes(o.currentMailboxRole || "");
// Marking your own outgoing mail as spam makes no sense, nor reporting
// what already sits in the bin.
const spamApplicable = spamApplicableIn(o.currentMailboxRole);
// Deleting from Trash (or with deletes set to be permanent) can't be undone.
const deleteForever = bulkDeleteIsPermanent({ role: o.currentMailboxRole });
const item = (
key: MessageActionKey,
@@ -243,7 +248,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
? [item("restore", Undo, tRecover("restore"), o.onRestore, { testId: "ctx-restore" }), SEPARATOR]
: [];
const archive = archiveEnabled ? [item("archive", Archive, t("archive"), o.onArchive)] : [];
const del = item("delete", Trash2, t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" });
const del = item("delete", Trash2, deleteForever ? tActions("delete_forever") : t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" });
const read = item("read", isUnread ? MailOpen : Mail, isUnread ? t("mark_read") : t("mark_unread"), () => o.onMarkAsRead?.(isUnread), {
disabled: false,
testId: isUnread ? "ctx-mark-read" : "ctx-mark-unread",
+4 -2
View File
@@ -18,6 +18,7 @@ import {
RefreshCw,
Fish,
} from "@/components/icons";
import { spamApplicableIn } from "@/lib/spam-applicable";
import { usePhishingReport } from "@/hooks/use-phishing-report";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
@@ -115,6 +116,7 @@ export function MvListToolbar({
const tBatch = useTranslations("email_list.batch_actions");
const archiveActionEnabled = useArchiveActionEnabled();
const tFolder = useTranslations("mailbox_context_menu");
const tP2 = useTranslations("mail_p2_folders");
const tMenu = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const tCommon = useTranslations("common");
@@ -200,7 +202,7 @@ export function MvListToolbar({
const isInJunk = currentRole === "junk";
const isInTrash = currentRole === "trash";
// Marking your own drafts or sent mail as spam is meaningless.
const spamApplicable = !["sent", "drafts", "scheduled"].includes(currentRole ?? "");
const spamApplicable = spamApplicableIn(currentRole);
// Report phishing for the selection, one report per message (enterprise pack).
const selectionForPhishing = useEmailStore(useShallow((state) => resolveSelectedEmails(state)));
const phishing = usePhishingReport(selectionForPhishing, currentRole);
@@ -429,7 +431,7 @@ export function MvListToolbar({
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderX}
label={tFolder("empty_folder")}
label={isInTrash ? tP2("empty_trash") : tP2("empty_spam")}
destructive
onClick={() => {
closeMenu();
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { useTranslations } from "next-intl";
import { CloudOff, RefreshCw } from "@/components/icons";
import { Button } from "@/components/ui/button";
/**
* The reading pane when a message couldn't be fetched: offline (in a browser
* that keeps no mail on the device), or another failure. A short line and
* Retry, instead of an empty pane that says nothing.
*/
export function ReaderOpenFailure({ offline, onRetry }: { offline: boolean; onRetry: () => void }) {
const t = useTranslations("mail_p2_desk");
return (
<div role="status" data-reader-open-failure={offline ? "offline" : "failed"} className="m-auto flex max-w-sm flex-col items-center gap-4 p-8 text-center">
<CloudOff className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-foreground">{t(offline ? "reader_offline" : "reader_failed")}</p>
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="me-2 h-4 w-4" aria-hidden="true" />
{t("retry")}
</Button>
</div>
);
}
+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. */
@@ -747,26 +748,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(() => {
@@ -782,7 +800,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);
@@ -1477,16 +1494,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
+3
View File
@@ -18,6 +18,9 @@ export function SearchResultCount({ emails, hasMore }: { emails: Email[]; hasMor
() => (disableThreading ? emails.length : groupEmailsByThread(emails).length),
[emails, disableThreading],
);
// More to load: the rows are conversations when the list groups them, so
// "100+ results" above them reads wrong as well (mail-16).
if (hasMore && !disableThreading) return <>{t("email_list.conversations_count_plus", { count: conversations })}</>;
if (hasMore) return <>{t("advanced_search.results_found_more", { count: emails.length })}</>;
if (conversations === emails.length) return <>{t("advanced_search.results_found", { count: emails.length })}</>;
return <>{t("mail_list_extra.search_count", { conversations, messages: emails.length })}</>;
+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}
+101 -17
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,
@@ -204,7 +211,11 @@ export function ThreadConversationView({
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContent = useConversationExternalContent();
const emailIdList = emails.map((email) => email.id).join(',');
// Oldest first, the newest at the bottom, as on every other layout and in
// Gmail (mail-p2-6). The thread arrives newest first.
const ordered = useMemo(() => oldestFirst(emails), [emails]);
const newestId = newestReadable(ordered)?.id ?? null;
const emailIdList = ordered.map((email) => email.id).join(',');
const { isMobile: summaryOnPhone, isCompact: summaryOnTablet } = useDeviceDetection();
const threadSummaryProps = useMemo(
() => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [], deviceType: summaryOnPhone ? 'mobile' : summaryOnTablet ? 'tablet' : 'desktop' }),
@@ -213,28 +224,41 @@ export function ThreadConversationView({
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const cardsRef = useRef<HTMLDivElement>(null);
const scrolledThreadRef = useRef<string | null>(null);
// Auto-expand most recent email AND all unread emails when thread opens
// Auto-expand the newest email (your own reply too) AND all unread emails
// when the thread opens.
useEffect(() => {
if (emails.length > 0) {
if (ordered.length > 0) {
const idsToExpand = new Set<string>();
// Always expand most recent
idsToExpand.add(emails[0].id);
if (newestId) idsToExpand.add(newestId);
// Also expand all unread emails
emails.forEach(email => {
if (!email.keywords?.$seen) {
ordered.forEach(email => {
if (!email.keywords?.$seen && !email.keywords?.$draft) {
idsToExpand.add(email.id);
}
});
setExpandedIds(idsToExpand);
}
}, [emails]);
}, [ordered, newestId]);
// Once per conversation, show the newest message: it is at the bottom.
useEffect(() => {
const threadId = ordered[0]?.threadId ?? null;
if (!newestId || !threadId || scrolledThreadRef.current === threadId) return;
scrolledThreadRef.current = threadId;
const frame = requestAnimationFrame(() => {
cardsRef.current?.querySelector<HTMLElement>(`[data-thread-email-id="${CSS.escape(newestId)}"]`)?.scrollIntoView?.({ block: 'start' });
});
return () => cancelAnimationFrame(frame);
}, [ordered, newestId]);
const toggleExpanded = (emailId: string) => {
const target = emails.find((e) => e.id === emailId);
const target = ordered.find((e) => e.id === emailId);
if (target) emitThreadExpand(target.threadId, [target], !expandedIds.has(emailId));
setExpandedIds(prev => {
const next = new Set(prev);
@@ -279,32 +303,53 @@ export function ThreadConversationView({
{/* Email Cards */}
<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) => (
<div ref={cardsRef} className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{ordered.map((email) => {
const draft = !!email.keywords?.$draft;
return (
<div key={email.id} data-thread-email-id={email.id} className="scroll-mt-16">
<EmailCard
key={email.id}
email={email}
isExpanded={expandedIds.has(email.id)}
isLatest={index === 0}
isLatest={email.id === newestId}
allowExternal={externalContent.isAllowed(email)}
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>
</div>
);
}
/** A conversation's messages from the oldest to the newest. */
export function oldestFirst(emails: Email[]): Email[] {
return [...emails].sort((a, b) => (Date.parse(a.receivedAt) || 0) - (Date.parse(b.receivedAt) || 0));
}
/** The newest message that isn't a draft or waiting to be sent. */
export function newestReadable(ordered: Email[]): Email | undefined {
for (let i = ordered.length - 1; i >= 0; i--) {
const e = ordered[i];
if (!e.keywords?.$draft && !e.isScheduled) return e;
}
return ordered[ordered.length - 1];
}
// Individual email card component
interface EmailCardProps {
email: Email;
@@ -317,6 +362,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 +403,9 @@ function EmailCard({
onReply,
onReplyAll,
onForward,
isDraft = false,
onEditDraft,
onDiscardDraft,
onDownloadAttachment,
blobSource,
onMarkAsRead,
@@ -410,6 +461,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 +531,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"
+20 -7
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useCallback } from "react";
import { spamApplicableIn } from "@/lib/spam-applicable";
import { cleanPreview, formatDate } from "@/lib/utils";
import { rowA11yProps, rowLabel } from "./list-a11y";
import { emailListDate } from "@/lib/email-date";
@@ -16,7 +17,7 @@ import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, Messa
import type { AppIcon } from "@/components/icons";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
import { useListRowShape, LIST_COLUMN_CLASS, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape";
import { SizeCell } from "./list-columns";
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
import { useUIStore } from "@/stores/ui-store";
@@ -498,6 +499,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
// together.
const mvRow = interfaceLayout === 'mountain-view' && oneLineRow;
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
const previewOnSubjectLine = showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: interfaceLayout === 'mountain-view' });
const trimmedPreview = cleanPreview(email.preview);
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Search hits carry server snippets with the matched terms marked; they
@@ -971,7 +973,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="mb-1 flex min-w-0 items-center gap-1.5">
<div className={cn("flex min-w-0 items-center gap-1.5", !previewOnSubjectLine && "mb-1")}>
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
@@ -986,11 +988,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
: "font-normal text-foreground/90"
)}>
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || tNoSubject)}</bdi>
{previewOnSubjectLine && (
<span data-row-preview="" className="font-normal text-muted-foreground">
{' – '}<span dir="auto">{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || t('no_preview_available'))}</span>
</span>
)}
</span>
<PluginRowBadges email={email} />
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
{showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
<p dir="auto" className={cn(
"text-sm leading-relaxed",
@@ -1031,7 +1038,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
spamApplicable={spamApplicableIn(currentMailboxRole)}
mailboxRole={currentMailboxRole ?? null}
pinToFirstLine={mvRow}
/>
@@ -1092,6 +1099,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
// together.
const mvRow = interfaceLayout === 'mountain-view' && oneLineRow;
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
const previewOnSubjectLine = showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: interfaceLayout === 'mountain-view' });
const trimmedPreview = cleanPreview(latestEmail.preview);
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// In a search the matched mail need not be the thread's latest one: show
@@ -1759,7 +1767,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className="mb-1 flex min-w-0 items-center gap-1.5">
<div className={cn("flex min-w-0 items-center gap-1.5", !previewOnSubjectLine && "mb-1")}>
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
@@ -1774,11 +1782,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: "font-normal text-foreground/90"
)}>
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || tNoSubject)}</bdi>
{previewOnSubjectLine && (
<span data-row-preview="" className="font-normal text-muted-foreground">
{' – '}<span dir="auto">{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || tEmailViewer('no_preview_available'))}</span>
</span>
)}
</span>
<PluginRowBadges email={latestEmail} />
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
{showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
<p dir="auto" className={cn(
"text-sm leading-relaxed",
@@ -1823,7 +1836,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
spamApplicable={spamApplicableIn(currentMailboxRole)}
mailboxRole={currentMailboxRole ?? null}
pinToFirstLine={mvRow}
/>
+3 -26
View File
@@ -1,13 +1,9 @@
"use client";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
import { useAppBadge } from "@/hooks/use-app-badge";
import { useShallow } from "zustand/react/shallow";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { totalInboxUnread } from "@/lib/account-inbox-unread";
import { useAllInboxesUnread } from "@/hooks/use-all-inboxes-unread";
/**
* Badges the browser-tab favicon, and — where supported — the installed PWA's
@@ -31,33 +27,14 @@ import { totalInboxUnread } from "@/lib/account-inbox-unread";
* Renders nothing.
*/
export function FaviconBadge() {
// The store's canonical inbox selector. `role === 'inbox'` alone is not
// enough: shared and group inboxes ship in the same `mailboxes` array, so on
// a delegated setup the first match can be somebody else's inbox.
const inboxUnread = useEmailStore(
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
);
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
const allAccounts = useSettingsStore((s) => s.unreadBadgeAllAccounts);
// With several accounts the badge counts the inbox of each (on by default):
// new mail in an account that is not on screen still shows on the icon and
// in the desktop tray. The other accounts' folder lists are what the
// account switcher reads (useProMultiAccountMailboxes); one not loaded yet
// adds nothing.
const othersUnread = useOtherAccountsUnread(allAccounts);
const count = inboxUnread + othersUnread;
// in the desktop tray.
const count = useAllInboxesUnread();
useFaviconBadge(count, enabled);
useAppBadge(count, enabled);
return null;
}
function useOtherAccountsUnread(enabled: boolean): number {
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const otherIds = useAccountStore(
useShallow((s) => (enabled ? s.accounts.filter((a) => a.isConnected && a.id !== activeAccountId).map((a) => a.id) : [])),
);
const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
return totalInboxUnread(otherIds, accountMailboxes);
}
@@ -12,7 +12,7 @@ const { MailboxContextMenu } = await import('../mailbox-context-menu');
const folder = { id: 'p', name: 'Projects', sortOrder: 1, totalEmails: 2, unreadEmails: 0, myRights: {} } as unknown as Mailbox;
describe('MailboxContextMenu', () => {
it('ends with Empty folder and Delete folder', () => {
it('ends with Delete folder', () => {
render(
<MailboxContextMenu
target={{ kind: 'mailbox', mailbox: folder, hasChildren: false }}
@@ -28,7 +28,8 @@ describe('MailboxContextMenu', () => {
/>,
);
const labels = screen.getAllByRole('menuitem').map((item) => item.textContent);
expect(labels.slice(-2)).toEqual(['empty_folder_generic', 'delete_folder']);
// A folder of your own has no Empty: it is offered on Trash and Spam only.
expect(labels.slice(-1)).toEqual(['delete_folder']);
expect(labels).toContain('refresh');
});
});
@@ -63,7 +63,7 @@ describe('folder menu states', () => {
const folder = box('f', 2);
renderMenu(folder, [folder]);
expect(screen.getByText('offline_header')).toBeInTheDocument();
for (const name of ['new_subfolder', 'rename', 'menu_import', 'refresh', 'empty_folder_generic', 'delete_folder']) {
for (const name of ['new_subfolder', 'rename', 'menu_import', 'refresh', 'delete_folder']) {
expect(item(name).disabled).toBe(true);
}
// No mail kept on this device: marking read would fail too.
@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// Usability test 2026-11-04, mail-20 and pass 2 p2-14: Trash and Spam
// showed Rename and Delete folder greyed out, "Empty folder" instead of
// "Empty Trash", and Rules and Mute notifications; every folder, the Inbox
// included, offered "Empty folder".
vi.mock('@/components/email/rules-menu', () => ({ FolderRulesSubMenu: () => <div role="menuitem">rules</div> }));
const { MailboxContextMenu } = await import('../mailbox-context-menu');
const box = (id: string, role: string | null) =>
({ id, name: id, role, parentId: null, sortOrder: 1, totalEmails: 5, unreadEmails: 0, myRights: {} }) as unknown as Mailbox;
function labels(mailbox: Mailbox) {
render(
<MailboxContextMenu
target={{ kind: 'mailbox', mailbox, hasChildren: false }}
position={{ x: 0, y: 0 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={[mailbox]}
onMarkFolderRead={vi.fn()}
onCreateSubfolder={vi.fn()}
onRenameFolder={vi.fn()}
onDeleteFolder={vi.fn()}
onEmptyFolder={vi.fn()}
onImportEmail={vi.fn()}
onRefresh={vi.fn()}
notifications={{
isMailboxMuted: () => false,
isAccountMuted: () => false,
hasFolderSettings: () => false,
setMailboxMuted: vi.fn(),
setAccountMuted: vi.fn(),
resetFolderSettings: vi.fn(),
}}
/>,
);
return screen.getAllByRole('menuitem').map((item) => item.textContent);
}
describe('folder menu of Trash and Spam', () => {
it('Trash: Empty Trash last, no rename, delete, rules or mute', () => {
const items = labels(box('trash', 'trash'));
expect(items[items.length - 1]).toBe('empty_trash');
for (const gone of ['rename', 'delete_folder', 'rules', 'mute_notifications', 'empty_folder', 'empty_folder_generic']) {
expect(items).not.toContain(gone);
}
});
it('Spam: Empty Spam last, no rename, delete, rules or mute', () => {
const items = labels(box('junk', 'junk'));
expect(items[items.length - 1]).toBe('empty_spam');
for (const gone of ['rename', 'delete_folder', 'rules', 'mute_notifications']) expect(items).not.toContain(gone);
});
it('Inbox: no Empty, no rename or delete, but rules and mute', () => {
const items = labels(box('inbox', 'inbox'));
for (const gone of ['empty_folder', 'empty_folder_generic', 'empty_trash', 'empty_spam', 'rename', 'delete_folder']) {
expect(items).not.toContain(gone);
}
expect(items).toContain('rules');
expect(items).toContain('mute_notifications');
});
it('a folder of your own: rename and delete, no Empty', () => {
const items = labels(box('family', null));
expect(items).toContain('rename');
expect(items[items.length - 1]).toBe('delete_folder');
for (const gone of ['empty_folder', 'empty_folder_generic', 'empty_trash', 'empty_spam']) expect(items).not.toContain(gone);
});
});
@@ -63,3 +63,29 @@ describe.each([
expect(link.textContent).not.toContain('2');
});
});
// Usability test 2026-11-04, mail-6: with two accounts the rail said 10 while
// 30 were unread in all; the favicon already counted every account.
describe('Mail entry with several accounts', () => {
const twoAccounts = async () => {
const { useAccountStore } = await import('@/stores/account-store');
const { useAuthStore } = await import('@/stores/auth-store');
useAccountStore.setState({ accounts: [{ id: 'acc-a', isConnected: true }, { id: 'acc-b', isConnected: true }] } as never);
useAuthStore.setState({ activeAccountId: 'acc-a' } as never);
useEmailStore.setState({ accountMailboxes: { 'acc-b': [{ id: 'b-in', role: 'inbox', unreadEmails: 5 }] } } as never);
};
it('counts the other accounts\' inboxes too', async () => {
await twoAccounts();
useSettingsStore.setState({ unreadBadgeAllAccounts: true });
render(<NavigationRail orientation="vertical" collapsed />);
expect(screen.getAllByRole('link', { name: 'mail, 7 unread' }).length).toBeGreaterThan(0);
});
it('counts the open account only when Notifications says so', async () => {
await twoAccounts();
useSettingsStore.setState({ unreadBadgeAllAccounts: false });
render(<NavigationRail orientation="vertical" collapsed />);
expect(screen.getAllByRole('link', { name: 'mail, 2 unread' }).length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,124 @@
import { act, render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// Usability test 2026-11-04, pass 2 p2-9: folded to icons (600 to 1024 px),
// the folder column showed eight identical folder icons, no unread marks,
// and no way to reach subfolders without unfolding it.
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/contexts/drag-drop-context', () => ({
useDragDropContext: () => ({ isDragging: false }),
}));
vi.mock('@/hooks/use-mailbox-drop', () => ({
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
}));
vi.mock('@/hooks/use-tag-drop', () => ({
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
}));
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
const { Sidebar } = await import('../sidebar');
const { useAuthStore } = await import('@/stores/auth-store');
const { useSettingsStore } = await import('@/stores/settings-store');
const { useUIStore } = await import('@/stores/ui-store');
const box = (id: string, name: string, role: string | null, extra: Partial<Mailbox> = {}) => ({
id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, ...extra,
}) as unknown as Mailbox;
const mailboxes: Mailbox[] = [
box('inbox', 'Inbox', 'inbox', { totalEmails: 120, unreadEmails: 4 }),
box('trash', 'Trash', 'trash'),
box('news', 'Newsletters', null, { totalEmails: 30, unreadEmails: 2 }),
box('receipts', 'Receipts', null, { totalEmails: 5 }),
box('y2026', '2026', null, { parentId: 'receipts', totalEmails: 3 }),
];
const peek = () => document.querySelector('[data-folder-peek]');
describe('folded folder column (default layout)', () => {
beforeEach(() => {
localStorage.clear();
useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never);
useSettingsStore.setState({ hideAccountSwitcher: true, interfaceLayout: 'default', openOnHover: true, unreadCountStyle: 'count' } as never);
useUIStore.setState({ sidebarCollapsed: true } as never);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
useUIStore.setState({ sidebarCollapsed: false } as never);
});
it('tells folders of one\'s own apart by their first letter', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
const initials = screen.getAllByTestId('folder-initial').map((el) => el.textContent);
expect(initials).toEqual(expect.arrayContaining(['N', 'R']));
// System folders keep their own icons.
expect(initials).toHaveLength(2);
});
it('shows the unread count on the icon', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
const inbox = document.querySelector('[data-mailbox-id="inbox"]')!;
expect(inbox.querySelector('[data-testid="folded-unread"]')?.textContent).toBe('4');
const trash = document.querySelector('[data-mailbox-id="trash"]')!;
expect(trash.querySelector('[data-testid="folded-unread"]')).toBeNull();
});
it('shows a dot when counts are dots', () => {
useSettingsStore.setState({ unreadCountStyle: 'dot' } as never);
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
const mark = document.querySelector('[data-mailbox-id="news"] [data-testid="folded-unread"]');
expect(mark).not.toBeNull();
expect(mark!.textContent).toBe('');
});
it('opens the whole tree from All folders, keyboard included, and Escape folds it back', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
expect(screen.queryByText('Receipts')).toBeNull();
fireEvent.click(screen.getByTestId('folded-all-folders'));
expect(peek()).not.toBeNull();
expect(screen.getByText('Receipts')).toBeTruthy();
// The focus is in the tree, on the open folder.
expect(document.activeElement?.closest('[data-mailbox-id]')?.getAttribute('data-mailbox-id')).toBe('inbox');
// Subfolders are reachable: Receipts > 2026.
expect(screen.getByText('2026')).toBeTruthy();
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
expect(peek()).toBeNull();
});
it('folds the tree away after a pick', () => {
const { rerender } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
fireEvent.click(screen.getByTestId('folded-all-folders'));
expect(peek()).not.toBeNull();
rerender(<Sidebar mailboxes={mailboxes} selectedMailbox="news" />);
expect(peek()).toBeNull();
});
it('folds it away on a press outside', () => {
render(<><Sidebar mailboxes={mailboxes} selectedMailbox="inbox" /><button type="button">elsewhere</button></>);
fireEvent.click(screen.getByTestId('folded-all-folders'));
fireEvent.pointerDown(screen.getByText('elsewhere'));
expect(peek()).toBeNull();
});
it('floats out after the pointer rests on it, not at once, and not with hover opening off', () => {
vi.useFakeTimers();
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
fireEvent.mouseEnter(container.firstElementChild!);
expect(peek()).toBeNull();
act(() => { vi.advanceTimersByTime(400); });
expect(peek()).not.toBeNull();
fireEvent.mouseLeave(container.firstElementChild!);
expect(peek()).toBeNull();
useSettingsStore.setState({ openOnHover: false } as never);
fireEvent.mouseEnter(container.firstElementChild!);
act(() => { vi.advanceTimersByTime(400); });
expect(peek()).toBeNull();
});
});
+36 -25
View File
@@ -159,6 +159,7 @@ export function MailboxContextMenu({
notifications,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
const tP2 = useTranslations("mail_p2_folders");
const tSidebar = useTranslations("sidebar");
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
@@ -251,6 +252,8 @@ export function MailboxContextMenu({
}
const mailbox = target.mailbox;
// Emptying is offered where people expect it, as "Empty Trash" and "Empty
// Spam"; rules and notifications mean nothing there (mail-20, pass 2 p2-14).
const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
const isSystem =
!!mailbox.role &&
@@ -305,13 +308,16 @@ export function MailboxContextMenu({
disabled={!onCreateSubfolder || !canCreateChild || offline}
testId="mailbox-new-subfolder"
/>
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename || offline}
testId="mailbox-rename"
/>
{/* The server's own folders can't be renamed or deleted: not shown greyed out. */}
{!isSystem && (
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename || offline}
testId="mailbox-rename"
/>
)}
{onShareFolder && (
<ContextMenuItem
icon={Users}
@@ -321,8 +327,8 @@ export function MailboxContextMenu({
testId="mailbox-share"
/>
)}
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} disabled={offline} />
{notifications && (() => {
{!isTrashOrJunk && <FolderRulesSubMenu mailbox={mailbox} onClose={onClose} disabled={offline} />}
{notifications && !isTrashOrJunk && (() => {
const muted = notifications.isMailboxMuted(mailbox);
return (
<ContextMenuItem
@@ -373,23 +379,28 @@ export function MailboxContextMenu({
/>
{/* Destructive entries last (R6). */}
<ContextMenuSeparator />
{(isTrashOrJunk || !isSystem) && <ContextMenuSeparator />}
<ContextMenuItem
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
destructive
/>
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete || offline}
destructive
testId="mailbox-delete"
/>
{isTrashOrJunk && (
<ContextMenuItem
icon={FolderX}
label={mailbox.role === "trash" ? tP2("empty_trash") : tP2("empty_spam")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
destructive
testId="mailbox-empty"
/>
)}
{!isSystem && (
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete || offline}
destructive
testId="mailbox-delete"
/>
)}
</ContextMenu>
);
}
+4 -3
View File
@@ -12,7 +12,6 @@ import { useShallow } from "zustand/react/shallow";
import { usePathname, Link, useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps";
@@ -34,6 +33,7 @@ import { toUnicodeEmail } from "@/lib/idn";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
import { NotificationBell } from "@/components/notifications/notification-center";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import { useAllInboxesUnread } from "@/hooks/use-all-inboxes-unread";
import { writeRailHints, type RailHint } from "@/lib/boot-skeleton/hints";
interface NavItem {
@@ -260,7 +260,6 @@ export function NavigationRail({
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar })));
const { mailboxes } = useEmailStore(useShallow((state) => ({ mailboxes: state.mailboxes })));
const client = useAuthStore((s) => s.client);
const supportsFiles = client?.supportsFiles() ?? false;
const supportsContacts = client?.supportsContacts() ?? false;
@@ -277,7 +276,9 @@ export function NavigationRail({
const visibleSidebarApps = useResolvedSidebarApps();
// Pages plugins added (api.ui.setPages); listed after the built-in apps.
const pluginPages = usePluginPages();
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
// Every signed-in account's inbox, as the favicon and the tab title count
// it (mail-6), unless Notifications says to count the open one only.
const inboxUnread = useAllInboxesUnread();
const isStalwartAdmin = useStalwartAdmin();
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
+119 -7
View File
@@ -22,6 +22,7 @@ import {
ChevronUp,
Folder,
FolderOpen,
FolderSync,
User,
Users,
Palmtree,
@@ -172,6 +173,8 @@ interface SidebarProps {
const ROW_PX_BASE = FOLDER_ROW_PX_BASE;
const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT;
const INDENT_STEP = 12;
/** How long the pointer rests on the folded column before the tree floats out. */
const RAIL_HOVER_DELAY_MS = 350;
// Icons and colours follow the JMAP role only.
const getIconForMailbox = (role?: string, hasChildren?: boolean, isExpanded?: boolean, id?: string) => {
@@ -497,8 +500,22 @@ function SidebarRow({
)}
title={isCollapsed ? label : undefined}
>
<span className="flex items-center justify-center flex-shrink-0 w-4 h-4">
<span className="relative flex items-center justify-center flex-shrink-0 w-4 h-4">
{icon}
{/* Folded to icons, the count rides on the icon (p2-9); the name says it already. */}
{isCollapsed && unreadForName > 0 && (
unreadCountStyle === 'dot' ? (
<span aria-hidden="true" data-testid="folded-unread" className="absolute -top-0.5 -end-1 w-2 h-2 rounded-full bg-primary ring-2 ring-secondary" />
) : (
<span
aria-hidden="true"
data-testid="folded-unread"
className="absolute -top-2 -end-3 min-w-4 h-4 px-1 rounded-full bg-primary text-primary-foreground text-[0.625rem] leading-4 font-semibold tabular-nums text-center ring-2 ring-secondary"
>
{unreadForName > 99 ? '99+' : unreadForName}
</span>
)
)}
</span>
{!isCollapsed && (
<>
@@ -548,6 +565,20 @@ function SidebarRow({
);
}
/** A folder of one's own on the folded strip: its first letter in a small box. */
function FolderInitial({ name, className }: { name: string; className?: string }) {
const initial = Array.from(name.trim())[0]?.toLocaleUpperCase() ?? '?';
return (
<span
aria-hidden="true"
data-testid="folder-initial"
className={cn(className, "grid place-items-center rounded-[0.25rem] border border-current text-[0.625rem] font-semibold leading-none")}
>
{initial}
</span>
);
}
/** A folder the server gave a role (inbox, sent, trash...), as opposed to one the user made. */
const isSystemFolder = (node: MailboxNode) => !!node.role;
/** Key of the single-account "Your folders" section in the collapsed set. */
@@ -709,6 +740,9 @@ function MailboxTreeItem({
const isVirtualNode = node.id.startsWith('shared-');
const isSelected = selectedMailbox === node.id;
const label = sharedAccountName ?? localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
// Folded to icons, eight folders of one's own were eight identical folder
// icons (p2-9): each shows its first letter instead.
const letterIcon = isCollapsed && !node.role && !sharedAccountName && !isVirtualNode;
// Only for the active login's folders: its client tells a missing
// accountId (own mailbox) apart.
const muted = useMutedMailbox(isVirtualNode || dragAccountId || pickOnly ? null : node);
@@ -752,7 +786,9 @@ function MailboxTreeItem({
return (
<>
<SidebarRow
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, sharedAccountName ? undefined : node.role)} />}
icon={letterIcon
? <FolderInitial name={label} className={getIconClass(isSelected, false, false)} />
: <Icon className={getIconClass(isSelected, isVirtualNode, colorful, sharedAccountName ? undefined : node.role)} />}
label={label}
testRole={node.role}
testName={node.name}
@@ -1177,8 +1213,23 @@ function SidebarImpl({
// floats rather than widening the column, or every hover would reflow the
// list behind it.
const [railHovered, setRailHovered] = useState(false);
const hoverExpanded = mvShell && sidebarCollapsed && railHovered;
// The default layout's folded column floats the whole tree out too, so
// subfolders and folder names are a hover or a click away (p2-9). A hover
// waits a moment there, so passing over the column on the way to the list
// doesn't open it; "All folders" opens it by click, tap or keyboard.
const [railOpen, setRailOpen] = useState(false);
const railHoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const allFoldersRef = useRef<HTMLButtonElement>(null);
const hoverExpanded = sidebarCollapsed && !isDrawer && (railHovered || railOpen);
const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer;
const closeRailPeek = useCallback(() => {
if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
railHoverTimer.current = null;
setRailHovered(false);
setRailOpen(false);
}, []);
useEffect(() => () => { if (railHoverTimer.current) clearTimeout(railHoverTimer.current); }, []);
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId })));
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
@@ -1276,6 +1327,31 @@ function SidebarImpl({
crossAccountActive, restoring: restoringAccounts, scope: unifiedScope, accounts,
}), [crossAccountActive, restoringAccounts, accounts, unifiedScope]);
const t = useTranslations('sidebar');
const tP2 = useTranslations('mail_p2_folders');
// A pick in the floated tree folds it away again (the hover peek of the
// default layout, and the one "All folders" opened).
const pickedRef = useRef({ mailbox: selectedMailbox, keyword: selectedKeyword });
useEffect(() => {
const before = pickedRef.current;
pickedRef.current = { mailbox: selectedMailbox, keyword: selectedKeyword };
if (before.mailbox === selectedMailbox && before.keyword === selectedKeyword) return;
if (railOpen || !mvShell) closeRailPeek();
}, [selectedMailbox, selectedKeyword, railOpen, mvShell, closeRailPeek]);
// Opened from "All folders": the focus goes to the open folder in the tree,
// and a press anywhere outside folds it away.
useEffect(() => {
if (!railOpen) return;
const root = rootRef.current;
const target = root?.querySelector<HTMLElement>('nav [aria-current="page"]')
?? root?.querySelector<HTMLElement>('nav button:not([disabled])');
target?.focus();
const onPointerDown = (e: PointerEvent) => {
if (root && !root.contains(e.target as Node)) closeRailPeek();
};
document.addEventListener('pointerdown', onPointerDown, true);
return () => document.removeEventListener('pointerdown', onPointerDown, true);
}, [railOpen, closeRailPeek]);
useEffect(() => {
const stored = localStorage.getItem('expandedMailboxes');
@@ -1902,10 +1978,32 @@ function SidebarImpl({
return (
<div
ref={rootRef}
// The peek of the collapsed rail; not with hover opening turned off.
onMouseEnter={mvShell && sidebarCollapsed ? () => { if (hoverOpens()) setRailHovered(true); } : undefined}
onMouseLeave={mvShell && sidebarCollapsed ? () => setRailHovered(false) : undefined}
data-mv-rail-peek={hoverExpanded ? "" : undefined}
onMouseEnter={sidebarCollapsed && !isDrawer ? () => {
if (!hoverOpens()) return;
if (mvShell) { setRailHovered(true); return; }
if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
railHoverTimer.current = setTimeout(() => setRailHovered(true), RAIL_HOVER_DELAY_MS);
} : undefined}
onMouseLeave={sidebarCollapsed && !isDrawer ? () => {
if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
railHoverTimer.current = null;
setRailHovered(false);
} : undefined}
onKeyDown={railOpen || railHovered ? (e) => {
if (e.key !== 'Escape' || e.defaultPrevented) return;
e.preventDefault();
const hadFocus = rootRef.current?.contains(document.activeElement);
closeRailPeek();
if (hadFocus) requestAnimationFrame(() => allFoldersRef.current?.focus());
} : undefined}
onBlur={railOpen ? (e) => {
const next = e.relatedTarget as Node | null;
if (next && !e.currentTarget.contains(next)) closeRailPeek();
} : undefined}
data-mv-rail-peek={hoverExpanded && mvShell ? "" : undefined}
data-folder-peek={hoverExpanded && !mvShell ? "" : undefined}
className={cn(
"relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden",
// Mountain View's navigation sits on the canvas, with no rule between
@@ -1914,7 +2012,7 @@ function SidebarImpl({
"max-desk:w-full",
// Mountain View's collapsed column is 72px (--mv-rail-w), wide enough
// for its round compose button; the default layout's rail is 48px.
isCollapsed && !mvShell ? "desk:w-12" : "desk:w-full",
isCollapsed && !mvShell ? "desk:w-12" : hoverExpanded && !mvShell ? "desk:w-64" : "desk:w-full",
// Floats out of the rail's own column rather than widening it, so the
// message list underneath never moves while the pointer passes over.
// Width comes from --mv-rail-peek-w, keyed on data-mv-rail-peek.
@@ -2288,6 +2386,20 @@ function SidebarImpl({
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</nav>
{/* Folded: the whole tree, subfolders included, one click away (p2-9). */}
{isCollapsed && !mvShell && (
<div className="flex justify-center border-t border-border py-2">
<IconButton
ref={allFoldersRef}
label={tP2("all_folders")}
data-testid="folded-all-folders"
onClick={() => setRailOpen(true)}
>
<FolderSync />
</IconButton>
</div>
)}
<MailboxContextMenu
target={mailboxContextMenu.data}
position={mailboxContextMenu.position}
@@ -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', '');
});
});
@@ -0,0 +1,217 @@
import React, { Suspense, lazy } from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, 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 { useQuickReplyRestoreStore } from '@/stores/quick-reply-restore-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { clearAllHooks } from '@/lib/plugin-hooks';
import { forgetEmailPrefetches } from '@/lib/email-prefetch';
// mail-p2-1: a quick reply that went out shows in the open conversation at
// once. mail-p2-8: during its Undo window it isn't counted in Sent or
// Scheduled and the message isn't marked answered; Undo takes it out of the
// conversation and puts the text back into the quick reply box, leaving no
// draft behind.
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
type ViewerProps = { mvConversation?: { id: string }[]; onQuickReply?: (body: string) => Promise<boolean | void>; onReply?: () => void };
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ mvConversation, onQuickReply, onReply }: ViewerProps) => (
<div>
<div data-testid="reader">{mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation'}</div>
<button type="button" onClick={() => void onQuickReply?.('Thanks, see you there')}>quick send</button>
<button type="button" onClick={() => onReply?.()}>reply</button>
</div>
),
}));
type ComposerProps = {
mode: string;
initialData?: { draftId?: string | null; body?: string } | null;
onSend?: (data: Record<string, unknown>, state?: Record<string, unknown>) => Promise<unknown>;
};
vi.mock('@/components/email/email-composer', () => ({
EmailComposer: ({ mode, initialData, onSend }: ComposerProps) => (
<div data-testid="composer" data-mode={mode} data-draft={initialData?.draftId ?? ''} data-body={initialData?.body ?? ''}>
<button type="button" onClick={() => void onSend?.(
{ to: ['sam@example.org'], cc: [], bcc: [], subject: 'Re: Lunch tomorrow?', body: 'Works for me' },
{ mode: 'reply', body: '<p>Works for me</p>', to: 'sam@example.org', cc: '', bcc: '', subject: 'Re: Lunch tomorrow?', draftId: 'd-1', attachments: [] },
)}>composer send</button>
</div>
),
}));
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ scheduledTotal, mailboxes }: { scheduledTotal?: number; mailboxes: Mailbox[] }) => (
<div data-testid="sidebar-counts">{`scheduled ${scheduledTotal} sent ${mailboxes.find((m) => m.role === 'sent')?.totalEmails}`}</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-top-bar', () => ({ MvTopBar: () => 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: () => <input aria-label="stub search field" data-search-input /> }));
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' }), getCachedConfig: () => null }));
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: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
function mailbox(id: string, role: string, totalEmails = 3): Mailbox {
return {
id, name: role, role, sortOrder: 0, totalEmails, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, isSubscribed: true,
} as Mailbox;
}
const row = (id: string, minute: number, extra: Partial<Email> = {}) => ({
id, threadId: 't-m1', subject: 'Lunch tomorrow?', receivedAt: new Date(Date.UTC(2026, 9, 31, 10, minute)).toISOString(),
keywords: { $seen: true }, mailboxIds: { inbox: true }, from: [{ name: 'Sam', email: 'sam@example.org' }],
to: [{ name: 'Me', email: 'a@example.org' }], ...extra,
}) as unknown as Email;
let client: Record<string, ReturnType<typeof vi.fn> | (() => unknown)>;
let sendEmail: ReturnType<typeof vi.fn>;
let markEmailKeyword: ReturnType<typeof vi.fn>;
let cancelUndoSend: ReturnType<typeof vi.fn>;
let folders: Mailbox[];
beforeEach(() => {
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) });
useToastStore.setState({ toasts: [] });
useQuickReplyRestoreStore.setState({ pending: null });
clearAllHooks();
forgetEmailPrefetches();
folders = [mailbox('inbox', 'inbox'), mailbox('sent', 'sent', 51), mailbox('drafts', 'drafts')];
const thread = [row('m0', 0, { mailboxIds: { inbox: true } }), row('m1', 30)];
client = {
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => true, getSession: () => null,
getMailboxes: vi.fn().mockResolvedValue(folders),
getThread: vi.fn(async (threadId: string) => ({ id: threadId, emailIds: ['m0', 'm1'] })),
getSomeEmails: vi.fn(async (ids: string[]) => thread.filter((e) => ids.includes(e.id))),
getEmail: vi.fn(async (id: string) => ({ ...thread.find((e) => e.id === id)!, bodyValues: {} })),
deleteEmail: vi.fn().mockResolvedValue(undefined),
markAsRead: vi.fn().mockResolvedValue(undefined),
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({
enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', disableThreading: false,
sendDelaySeconds: 5, sendConfirmation: false, placeholderWarningEnabled: false, attachmentReminderEnabled: false,
} as never);
sendEmail = vi.fn(async () => {
const sendAt = new Date(Date.now() + 5000).toISOString();
useEmailStore.setState({
pendingUndoSend: {
submissionId: 'sub-1', emailId: 'sent-1', sendAt, isSmime: false,
countsBefore: { sent: 51, scheduled: 0 },
},
} as never);
// The server files it in Sent and lists its submission at once.
useEmailStore.setState({ scheduledTotal: 1, mailboxes: folders.map((m) => m.role === 'sent' ? { ...m, totalEmails: 52 } : m) } as never);
return { scheduled: true, emailId: 'sent-1', emailSubmissionId: 'sub-1', sendAt };
});
markEmailKeyword = vi.fn().mockResolvedValue(undefined);
cancelUndoSend = vi.fn(async () => {
useEmailStore.setState({ pendingUndoSend: null } as never);
return { ...row('sent-1', 40), id: 'sent-1', mailboxIds: { drafts: true }, keywords: { $draft: true } };
});
const emails = [row('m1', 30)];
useEmailStore.setState({
emails, selectedEmail: emails[0], selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: folders,
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', scheduledTotal: 0, pendingUndoSend: null,
selectedEmailKeys: new Set(), unreadSection: null, listRows: 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),
fetchEmailContent: vi.fn().mockResolvedValue(undefined),
sendEmail, markEmailKeyword, cancelUndoSend,
} as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('a quick reply in its Undo window', () => {
it('shows in the conversation at once, counts nowhere and answers nothing yet; Undo puts it back in the box', async () => {
render(<MailApp />);
const reader = await screen.findByTestId('reader', {}, { timeout: 5000 });
await waitFor(() => expect(reader).toHaveTextContent('m0,m1'));
fireEvent.click(screen.getByRole('button', { name: 'quick send' }));
await waitFor(() => expect(sendEmail).toHaveBeenCalled());
await waitFor(() => expect(reader).toHaveTextContent('m0,m1,sent-1'));
expect(markEmailKeyword).not.toHaveBeenCalled();
expect(screen.getByTestId('sidebar-counts')).toHaveTextContent('scheduled 0 sent 51');
const undo = await waitFor(() => {
const toast = useToastStore.getState().toasts.find((t) => t.action);
expect(toast).toBeTruthy();
return toast!;
});
await act(async () => { undo.action!.onClick(); });
await waitFor(() => expect(cancelUndoSend).toHaveBeenCalled());
await waitFor(() => expect(reader).toHaveTextContent(/^m0,m1$/));
await waitFor(() => expect(useQuickReplyRestoreStore.getState().pending).toEqual({ emailId: 'm1', text: 'Thanks, see you there' }));
expect(client.deleteEmail).toHaveBeenCalledWith('sent-1', undefined);
expect(markEmailKeyword).not.toHaveBeenCalled();
});
it('brings an undone reply back into the dock, as it was', async () => {
render(<MailApp />);
await screen.findByTestId('reader', {}, { timeout: 5000 });
fireEvent.click(screen.getByRole('button', { name: 'reply' }));
const composer = await screen.findByTestId('composer');
expect(composer.closest('[data-reply-dock]')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'composer send' }));
await waitFor(() => expect(sendEmail).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('composer')).toBeNull());
expect(markEmailKeyword).not.toHaveBeenCalled();
const undo = await waitFor(() => {
const toast = useToastStore.getState().toasts.find((t) => t.action);
expect(toast).toBeTruthy();
return toast!;
});
await act(async () => { undo.action!.onClick(); });
const back = await screen.findByTestId('composer');
expect(back).toHaveAttribute('data-mode', 'reply');
expect(back).toHaveAttribute('data-draft', 'sent-1');
expect(back).toHaveAttribute('data-body', '<p>Works for me</p>');
expect(back.closest('[data-reply-dock]')).not.toBeNull();
});
});
@@ -37,8 +37,8 @@ vi.mock('@/components/ui/welcome-banner', () => ({
useWelcomeBannerDue: () => false,
}));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ emptyPaneContent, emptyPaneFooter }: { emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => (
<div data-testid="viewer">{emptyPaneContent ?? <p>no conversation selected</p>}{emptyPaneFooter}</div>
EmailViewer: ({ email, emptyPaneContent, emptyPaneFooter }: { email?: { id: string } | null; emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => (
<div data-testid="viewer">{email ? <p>reading {email.id}</p> : <>{emptyPaneContent ?? <p>no conversation selected</p>}{emptyPaneFooter}</>}</div>
),
}));
vi.mock('@/components/email/email-list', () => ({
@@ -94,7 +94,7 @@ beforeEach(() => {
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,
selectedEmailKeys: new Set(), unreadSection: null, listRows: 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),
@@ -138,6 +138,35 @@ describe('the reading pane for a selection', () => {
expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true);
});
it('replaces the open message once other rows are ticked too (mail-p2-3)', () => {
useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm2']) } as never);
render(<MailApp />);
expect(screen.queryByText('reading m1')).toBeNull();
expect(listProps.host).toBeInstanceOf(HTMLElement);
expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true);
});
it('replaces the open message when another single row is ticked', () => {
useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m2']) } as never);
render(<MailApp />);
expect(screen.queryByText('reading m1')).toBeNull();
});
it('keeps the open message when only its own row is ticked', () => {
useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1']) } as never);
render(<MailApp />);
expect(screen.getByText('reading m1')).toBeTruthy();
});
it('keeps the open message when its conversation row is ticked', () => {
useEmailStore.setState({
selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm1-reply']),
listRows: [{ key: 'm1', members: ['m1', 'm1-reply'] }, { key: 'm2', members: ['m2'] }],
} as never);
render(<MailApp />);
expect(screen.getByText('reading m1')).toBeTruthy();
});
it('says nothing is selected without a selection', () => {
render(<MailApp />);
expect(screen.getByText('no conversation selected')).toBeTruthy();
@@ -145,6 +174,18 @@ describe('the reading pane for a selection', () => {
});
});
describe('the list header checkbox (mail-p2-11)', () => {
it('ticks every loaded row on the first click and clears them on the second', () => {
const second = { ...message, id: 'm2', threadId: 't-m2', subject: 'm2' } as Email;
useEmailStore.setState({ emails: [message, second] } as never);
render(<MailApp />);
fireEvent.click(screen.getByRole('button', { name: /^email_list\.batch_actions\.select/ }));
expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['m1', 'm2']);
fireEvent.click(screen.getByRole('button', { name: 'email_list.batch_actions.clear_selection' }));
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
});
});
describe('the new folder dialogs', () => {
it.each([
['stub new folder', 'mail_list_extra.new_folder_title'],
@@ -0,0 +1,179 @@
import React, { Suspense, lazy } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, 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';
import { clearAllHooks } from '@/lib/plugin-hooks';
import { dispatchApiCall } from '@/lib/plugin-sandbox/host-api';
import { resetOpenEmail } from '@/lib/plugin-sandbox/open-email';
import type { InstalledPlugin } from '@/lib/plugin-types';
// pass-2 mail-p2-7: in a browser tab that keeps no mail on the device, a click
// on a message while offline left "No conversation selected" and the keys did
// nothing. The reader now says the message needs a connection, with Retry, and
// a key that changes mail says you're offline. The message is opened the way a
// plugin opens one, which goes through the same open as a click.
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: (props: { email: unknown; emptyPaneContent?: React.ReactNode }) => (props.email ? <div>reader</div> : <div>{props.emptyPaneContent ?? 'empty pane'}</div>),
}));
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
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/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: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
function mailbox(id: string, role: string | undefined, accountId: string, shared = false): Mailbox {
return {
id: shared ? `${accountId}:${id}` : id, originalId: id, name: role ?? id, role, accountId, isShared: shared,
sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
} as Mailbox;
}
// The active login's folders: its own account `cf` and the group `ce`.
const folders = [mailbox('a', 'inbox', 'cf'), mailbox('a', 'inbox', 'ce', true)];
// The group's Inbox message, as Email/get gives it.
const groupMessage = {
id: 'eaaaaab', threadId: 'c', subject: 'GRP one', receivedAt: '2026-10-26T10:00:00Z',
keywords: { $seen: true }, mailboxIds: { a: true }, bodyValues: {},
} as unknown as Email;
let client: Record<string, unknown>;
const plugin = {
id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook',
permissions: ['email:read'], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true,
} as InstalledPlugin;
/** The group account has `eaaaaab`, the own account nothing. */
let failing = true;
const getEmail = vi.fn(async (id: string, accountId?: string) => {
// Offline the real client logs the failure and answers with no message.
if (failing && navigator.onLine === false) return null;
if (failing) throw new TypeError('Failed to fetch');
return id === 'eaaaaab' && accountId === 'ce' ? { ...groupMessage } : null;
});
beforeEach(() => {
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) });
useToastStore.setState({ toasts: [] });
clearAllHooks();
resetOpenEmail();
getEmail.mockClear();
client = {
getAccountId: () => 'cf', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
getMailboxes: vi.fn().mockResolvedValue(folders),
getEmail,
getSharedAccounts: () => ['cf', 'ce'].map((id) => ({ id, name: id, isPrimary: id === 'cf', capabilities: { mail: true } })),
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', archiveMode: 'single', deleteAction: 'trash' } as never);
useEmailStore.setState({
emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null, mailboxes: folders,
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '',
selectedEmailKeys: new Set(),
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);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const setOnline = (online: boolean) => {
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online });
window.dispatchEvent(new Event(online ? 'online' : 'offline'));
};
describe('a message that cannot be fetched', () => {
afterEach(() => setOnline(true));
it('says offline that it needs a connection, and Retry opens it once back online', async () => {
failing = true;
render(<MailApp />);
await screen.findByText('message list', {}, { timeout: 5000 });
act(() => setOnline(false));
await act(async () => {
await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {});
});
expect(await screen.findByText('reader_offline')).toBeTruthy();
failing = false;
act(() => setOnline(true));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'retry' }));
await new Promise((r) => setTimeout(r, 20));
});
expect(await screen.findByText('reader')).toBeTruthy();
expect(screen.queryByText('reader_offline')).toBeNull();
});
it('says it could not be loaded when online', async () => {
failing = true;
render(<MailApp />);
await screen.findByText('message list', {}, { timeout: 5000 });
await act(async () => {
await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {});
});
expect(await screen.findByText('reader_failed')).toBeTruthy();
});
it('answers a key that changes mail with a note that it is offline', async () => {
render(<MailApp />);
await screen.findByText('message list', {}, { timeout: 5000 });
act(() => setOnline(false));
failing = true;
await act(async () => {
await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {});
});
await screen.findByText('reader_offline');
// No Archive folder here: Delete stands in for every key that changes mail.
fireEvent.keyDown(document.body, { key: '#', code: 'Digit3', shiftKey: true });
expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('action_offline');
});
});
@@ -0,0 +1,58 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// mail-p2-2 / compose-p2-4: the docked reply took 62 % of the reading pane
// (at least 18 rem), so the message it answered was out of sight, and on a
// short window the reply itself had two lines to type in. It now grows with
// its text up to half of a tall pane; on a short pane it takes the pane under
// the reader's toolbar, with "Show the message" to read and come back.
const { ReplyDock, dockLayout } = await import('../reply-dock');
function renderInPane(height: number, readerTop = 110) {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.hasAttribute('data-pane')) return { top: 0, height, width: 800, left: 0, right: 800, bottom: height, x: 0, y: 0, toJSON() {} } as DOMRect;
if (this.hasAttribute('data-reader-scroll')) return { top: readerTop, height: 0, width: 800, left: 0, right: 800, bottom: readerTop, x: 0, y: readerTop, toJSON() {} } as DOMRect;
return { top: 0, height: 0, width: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} } as DOMRect;
});
return render(
<div data-pane="">
<div data-reader-scroll="" />
<ReplyDock><textarea aria-label="reply text" /></ReplyDock>
</div>,
);
}
afterEach(() => vi.restoreAllMocks());
describe('the reply dock', () => {
it('takes at most half of a tall pane, sized by its content', () => {
const { container } = renderInPane(768);
const dock = container.querySelector('[data-reply-dock]') as HTMLElement;
expect(dock.getAttribute('data-dock-mode')).toBe('share');
expect(dock.style.maxHeight).toBe('384px');
expect(dock.style.height).toBe('');
expect(dock.className).not.toMatch(/basis-/);
expect(screen.queryByText('show_message')).toBeNull();
});
it('takes a short pane under the reader toolbar, and folds to show the message', () => {
const { container } = renderInPane(600, 110);
const dock = container.querySelector('[data-reply-dock]') as HTMLElement;
expect(dock.getAttribute('data-dock-mode')).toBe('takeover');
expect(dock.style.height).toBe('490px');
const editor = screen.getByLabelText('reply text');
act(() => { fireEvent.click(screen.getByText('show_message')); });
expect(dock.style.height).toBe('');
// Folded, the reply stays mounted with its text.
expect(editor.isConnected).toBe(true);
expect(editor.parentElement!.className).toMatch(/\bhidden\b/);
act(() => { fireEvent.click(screen.getByText('back_to_reply')); });
expect(dock.style.height).toBe('490px');
});
it('chooses by the pane height', () => {
expect(dockLayout(699).mode).toBe('takeover');
expect(dockLayout(700)).toEqual({ mode: 'share', maxHeight: 350 });
});
});
@@ -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 () => {
+478 -93
View File
@@ -11,6 +11,19 @@ import { productName } from "@/lib/product-name";
import { MessageListTabs, useMessageListPanelProps } from "@/components/email/message-list-tabs";
import dynamic from "next/dynamic";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ReplyDock } from "@/components/mail/reply-dock";
import { addEcho, isUndoWindow, mergeEchoes, sentEcho, settleSent, withoutHeldSend } from "@/lib/held-send";
import { useQuickReplyRestoreStore } from "@/stores/quick-reply-restore-store";
/** What waits with a send in its Undo window (mail-p2-8). */
type HeldSendExtras = {
emailId?: string;
/** Marks the answered message; runs once the message really goes. */
answer?: () => Promise<void>;
origin?:
| { kind: 'quick-reply'; emailId: string; text: string }
| { kind: 'composer'; state: ComposerDraftData; sourceEmailId?: string };
};
// Neither the viewer (5k+ lines, postal-mime/dompurify) nor the composer (the
// entire TipTap/ProseMirror stack) is needed to paint the mail list, and both
@@ -43,13 +56,15 @@ 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";
import { keywordRestorePoints, markSelectionReadWithUndo, recordQuietUndo, runBatchEmailAction, toastWithUndo, undoKeywordAction, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { batchToastTitle, selectionRowCount } from "@/lib/selection-toast";
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster";
import { CalendarEventNotificationToaster } from "@/components/layout/calendar-event-notification-toaster";
@@ -57,7 +72,9 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInP
import { useRouter } from '@/i18n/navigation';
import { useSettingsStore } from "@/stores/settings-store";
import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout";
import { useOfflineStore } from "@/lib/offline/state";
import { isOfflineNow, useOfflineStore } from "@/lib/offline/state";
import { useConnectionState } from "@/hooks/use-connection-state";
import { ReaderOpenFailure } from "@/components/email/reader-open-failure";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore, sidebarFolded, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store";
@@ -65,6 +82,7 @@ import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-quer
import { usePaneSize } from "@/hooks/use-pane-size";
import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
import { guardOfflineMailChanges } from "@/lib/offline-key-guard";
import { useDesktopCommands } from "@/lib/desktop/commands";
import { useDesktopPrefs } from "@/lib/desktop/preferences";
import { hasDesktopWindows, popoutsAvailable } from "@/lib/popout/support";
@@ -96,6 +114,7 @@ import {
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { ScheduleSendDialog } from "@/components/email/schedule-send-dialog";
import { MailFolderMenu, MailFolderMenuItems } from "@/components/email/mail-list-menus";
import { emptyFolderPrompt } from "@/lib/empty-folder-text";
import { ContextMenuItem } from "@/components/ui/context-menu";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
@@ -152,7 +171,8 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { ToolbarButton, ToolbarSearchAction } from "@/components/ui/toolbar";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { mailTitleContext } from "@/lib/tab-title";
import { mailTitleContext, mailboxTitleLine } from "@/lib/tab-title";
import { useOtherAccountsUnread } from "@/hooks/use-all-inboxes-unread";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
@@ -258,6 +278,11 @@ const UNIFIED_LIST_PUSH = {
/** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */
type DraftRequest = ParsedMailto & { initialFiles?: File[]; unedited?: boolean };
/** No network, or this login's mail server can't be reached. */
function offlineNow(client: { getServerUrl?: () => string }): boolean {
return (typeof navigator !== 'undefined' && navigator.onLine === false) || isOfflineNow(client.getServerUrl?.());
}
export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('mail', routeSegments);
@@ -304,7 +329,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
@@ -319,11 +346,23 @@ 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();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// A message that couldn't be fetched: the reader says so, with Retry (mail-p2-7).
const [openFailure, setOpenFailure] = useState<{ target: { id: string } & Partial<Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'>>; offline: boolean } | null>(null);
const tP2Desk = useTranslations('mail_p2_desk');
// The message the palette's "Move to…" was picked for.
const [moveDialogEmail, setMoveDialogEmail] = useState<Email | null>(null);
// The empty reading pane's slot for a selection; the list draws into it (mail-10).
@@ -357,6 +396,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);
@@ -1065,9 +1111,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (shortcutsTargetSelection && client) {
const count = selectedEmailKeys.size;
if (!(await confirmMailRemoval('archive', count, confirmDialog, tRemove))) return;
// Counted as the selection bar counts: conversations (mail-18).
const rows = selectionRowCount(resolveSelectedEmails(useEmailStore.getState()), count, disableThreading);
const restore = restoreBatchesForSelection(client);
await runBatchEmailAction(() => batchArchive(client), {
success: t('notifications.emails_archived', { count }),
success: batchToastTitle(t, 'archive', rows, disableThreading),
undo: undoMoveAction(client, restore, undo),
error: t('notifications.error_archiving'),
describeError: (error) => error instanceof ArchiveMailboxNotFoundError
@@ -1104,10 +1152,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} else if (!(await confirmMailRemoval('delete', selectedEmailKeys.size, confirmDialog, tRemove))) {
return;
}
const count = selectedEmailKeys.size;
const rows = selectionRowCount(resolveSelectedEmails(useEmailStore.getState()), selectedEmailKeys.size, disableThreading);
const restore = permanent ? null : restoreBatchesForSelection(client);
await runBatchEmailAction(() => batchDelete(client, permanent), {
success: t('notifications.emails_deleted', { count }),
success: batchToastTitle(t, permanent ? 'delete' : 'trash', rows, disableThreading),
error: t('notifications.error_deleting'),
undo: undoMoveAction(client, restore, undo),
});
@@ -1176,12 +1224,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;
@@ -1286,7 +1329,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane]);
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane, disableThreading]);
// Initialize keyboard shortcuts
// Mountain View keeps the ticked rows while a message is read (#1231). The
@@ -1306,14 +1349,27 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return state.tabs.find((tab) => tab.id === id)?.kind === 'calendar';
});
const mailShortcutsApply = !isEmbedded || isFocusedProTab;
// Offline with no mail kept on this device, a key that changes mail can't
// run: say so rather than do nothing (mail-p2-7).
const connection = useConnectionState();
const canChangeMailRef = useRef(connection.canChangeMail);
canChangeMailRef.current = connection.canChangeMail;
const shortcutHandlers = useMemo(
() => guardOfflineMailChanges(keyboardHandlers, () => canChangeMailRef.current, () => toast.info(tP2Desk('action_offline'))),
// eslint-disable-next-line react-hooks/exhaustive-deps -- tP2Desk is stable per locale
[keyboardHandlers],
);
// Another folder: the line about the message that didn't open goes.
useEffect(() => setOpenFailure(null), [selectedMailbox]);
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer && mailShortcutsApply,
singleKeyShortcuts,
emails: activeEmails,
selectedEmailId: selectedEmail?.id,
// The message that didn't open is still what the keys are about.
selectedEmailId: selectedEmail?.id ?? openFailure?.target.id,
selectionCount: selectedEmailKeys.size,
mountainViewKeys: mvShell,
handlers: keyboardHandlers,
handlers: shortcutHandlers,
});
const anyTabHandlers = useMemo(() => ({
onShowHelp: keyboardHandlers.onShowHelp,
@@ -1498,6 +1554,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// The subject is kept out until the policy has loaded: a gate that keeps
// subjects out of browser history must not let one through while unknown.
const subjectInTabTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled('tabTitleSubjectEnabled'));
const otherAccountsUnread = useOtherAccountsUnread();
const titleContext = useMemo(() => {
// Composing email
const composer = showComposer
@@ -1517,12 +1574,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined;
if (mailbox) {
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
const unreadCount = mailbox.unreadEmails || 0;
mailboxLine = unreadCount > 0 ? `${mailboxName} (${unreadCount})` : mailboxName;
// The open account's own Inbox also counts the other accounts' (mail-6).
mailboxLine = mailboxTitleLine({
name: mailboxName,
unread: mailbox.unreadEmails || 0,
otherUnread: otherAccountsUnread,
ownInbox: mailbox.role === 'inbox' && !mailbox.isShared && !isUnifiedView,
}, t);
}
return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle });
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]);
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle, otherAccountsUnread, isUnifiedView]);
useDocumentTitle(titleContext);
// The Window menu names this window by its folder, never by the open message (desktop-p2-5).
const windowView = useMemo(() => {
@@ -2626,7 +2688,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
headers?: Record<string, string>;
labelHeaders?: Record<string, string>;
composerId?: string;
}) => {
}, composerState?: ComposerDraftData) => {
// Nothing is sent: the composer keeps what only goes once the mail is out.
if (!client) return false;
@@ -2706,13 +2768,29 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// This runs before the scheduled early-return: the undo-send delay is a
// HOLDFOR submission, so `scheduled` is true for every delayed send and
// the flag was never set. (#985)
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) {
// In the Undo window the flag waits until the message really goes, and
// Undo brings the composer back as it was, where it was (mail-p2-8).
const answeredSource = answeredEmailSource;
const markAnswered = async () => {
if (!originalEmailId || !(effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) return;
const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered';
try {
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredEmailSource);
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredSource);
} catch (e) {
debug.error(`Failed to set ${keyword} keyword:`, e);
}
};
const held = holdSend(result, {
answer: markAnswered,
origin: composerState ? { kind: 'composer', state: composerState, sourceEmailId: originalEmailId } : undefined,
});
if (!held) await markAnswered();
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
const replied = composerSourceEmail?.id === originalEmailId ? composerSourceEmail : null;
echoSentReply(replied, {
emailId: result.emailId, to: data.to, cc: data.cc, subject: data.subject, text: data.body,
fromEmail: data.fromEmail, fromName: data.fromName,
});
}
const refreshRepliedThreadAfterSend = async (emailId: string | undefined) => {
@@ -2858,11 +2936,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);
@@ -2894,13 +3012,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
@@ -2942,7 +3063,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,
@@ -3032,6 +3155,69 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
);
};
// A send in its Undo window (mail-p2-8): what waits until it really goes
// (the answered flag), and where Undo puts it back. Keyed by submission.
const heldSendsRef = useRef(new Map<string, HeldSendExtras>());
const releaseHeldSend = (submissionId: string) => {
const held = heldSendsRef.current.get(submissionId);
if (!held) return;
heldSendsRef.current.delete(submissionId);
void held.answer?.();
};
/** True when the send waits in its Undo window; `extras` then wait with it. */
const holdSend = (result: SendEmailResult, extras: Omit<HeldSendExtras, 'emailId'>): boolean => {
if (!result.scheduled || !result.emailSubmissionId || !isUndoWindow(result.sendAt)) return false;
const submissionId = result.emailSubmissionId;
heldSendsRef.current.set(submissionId, { ...extras, emailId: result.emailId });
const remainingMs = Math.max(Date.parse(result.sendAt!) - Date.now(), 0);
setTimeout(() => releaseHeldSend(submissionId), remainingMs);
return true;
};
// Undo: the message goes back where it was written, as it was.
const restoreUndoneSend = async (
held: HeldSendExtras | undefined,
restored: Email | null | undefined,
pending: { submissionAccountId?: string },
undoLogin: string | undefined,
undoClient: NonNullable<typeof client>,
): Promise<boolean> => {
const origin = held?.origin;
if (!origin || !restored) return false;
const openNow = useEmailStore.getState().selectedEmail;
const reopen = async (emailId: string): Promise<boolean> => {
if (openNow?.id === emailId) return true;
const listed = useEmailStore.getState().emails.find((e) => e.id === emailId);
if (!listed) return false;
return (await handleEmailSelect(listed)) === 'opened';
};
if (origin.kind === 'quick-reply') {
if (!(await reopen(origin.emailId))) return false;
// Back in the quick reply box, so the draft the server made of it goes.
try {
await undoClient.deleteEmail(restored.id, pending.submissionAccountId);
} catch (error) {
debug.error('Undid a quick reply, but removing its draft failed:', error);
}
useQuickReplyRestoreStore.getState().restore(origin.emailId, origin.text);
return true;
}
const state: ComposerDraftData = {
...origin.state,
draftId: restored.id,
draftAccountId: pending.submissionAccountId,
draftLocalAccountId: undoLogin,
};
// A reply docks under its message again; with that message gone from
// view it opens in the pane, still as a reply.
if (origin.sourceEmailId && state.mode !== 'compose' && !(await reopen(origin.sourceEmailId))) selectEmail(null);
startFreshComposerSession();
setPendingDraft(state);
setComposerMode(state.mode);
setShowComposer(true);
if (isMobile) setActiveView('viewer');
return true;
};
useEffect(() => {
if (!pendingUndoSend || !client) return;
if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return;
@@ -3062,10 +3248,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)
? {
@@ -3083,6 +3270,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
pending.submissionAccountId,
);
clearPendingUndoSend();
releaseHeldSend(pending.submissionId);
// The recipients are checked again for the release.
const refused = partlyRefusedWarning(result, tComposer);
if (refused) toast.warning(refused);
@@ -3120,10 +3308,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
// The send is undone; opening the draft or refreshing the list
// failing is no reason to say otherwise.
const held = heldSendsRef.current.get(pending.submissionId);
heldSendsRef.current.delete(pending.submissionId);
dropEcho(pending.emailId);
try {
// Never left the server: plugins don't hear about it.
cancelAnnouncedSend(pending.submissionId);
if (restored && !pending.isSmime) {
if (!pending.isSmime && await restoreUndoneSend(held, restored, pending, undoLogin, undoClient)) {
// Back where it was written.
} else if (restored && !pending.isSmime) {
// Back in the Drafts of the account it was sent from, through
// the login that sent it.
await handleEditDraft(restored, { accountId: pending.submissionAccountId, localAccountId: undoLogin });
@@ -3137,6 +3330,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) {
@@ -3155,6 +3365,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);
@@ -3244,6 +3457,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);
@@ -3290,6 +3504,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
@@ -3358,6 +3573,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,
@@ -4106,12 +4322,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
// "Empty Trash", with how many messages go (p2-14).
const prompt = emptyFolderPrompt(mailbox, emptyFolderMovesToTrash(mailbox));
const confirmed = await confirmDialog({
title: tCtxMenu('email_list.empty_folder.confirm_title'),
message: emptyFolderMovesToTrash(mailbox)
? tCtxMenu('email_list.empty_folder.confirm_message_trash')
: tCtxMenu('email_list.empty_folder.confirm_message'),
confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
title: tCtxMenu(prompt.title),
message: tCtxMenu(prompt.message, prompt.values),
confirmText: tCtxMenu(prompt.confirm),
variant: "destructive",
});
if (!confirmed) return;
@@ -4643,13 +4859,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// account so the flag lands on shared/group-mailbox messages instead of
// being dropped against the reaching account. (#281) Runs before the
// scheduled early-return so a delayed (undo-send) reply is flagged too. (#985)
{
// In the Undo window it waits until the reply really goes (mail-p2-8).
const markAnswered = async () => {
try {
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, '$answered', originalSource);
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
}
};
const held = holdSend(result, {
answer: markAnswered,
origin: { kind: 'quick-reply', emailId: originalEmailId, text: body },
});
if (!held) await markAnswered();
echoSentReply(selectedEmail, {
emailId: result.emailId, to: recipients, subject: labelled.subject, text: finalBody,
fromEmail: headerFromEmail, fromName: headerFromName,
});
const refreshInlineRepliedThread = async () => {
const emailState = useEmailStore.getState();
@@ -4749,6 +4975,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));
@@ -4778,6 +5008,102 @@ 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]);
// mail-p2-1: a reply that went out shows at the end of the open
// conversation at once, then the server's reading of the thread replaces
// it (a moment later, and again after a few seconds for a server that
// threads new mail late).
const confirmConversation = async (ref: Email) => {
if (!client || useEmailStore.getState().selectedEmail?.threadId !== ref.threadId) return;
mvConversationLoadRef.current = null;
const fresh = await loadMvConversation(ref, client);
if (!fresh || useEmailStore.getState().selectedEmail?.threadId !== ref.threadId) return;
// A reply read back in its Undo window carries its send time; once that
// has passed it went out, whatever the scheduled list said then.
const settled = settleSent(fresh);
setMvConversationResult((prev) => prev && prev.threadId === ref.threadId
? { ...prev, emails: mergeEchoes(settled, prev.emails) }
: prev);
};
const echoSentReply = (replied: Email | null | undefined, sent: {
emailId?: string; to: string[]; cc?: string[]; subject: string; text: string; fromEmail?: string; fromName?: string;
}) => {
if (!replied || !client || !readerConversationView) return;
const open = useEmailStore.getState().selectedEmail;
if (!open || open.threadId !== replied.threadId) return;
const echo = sentEcho({ ...sent, threadId: replied.threadId });
setMvConversationResult((prev) => {
if (prev && prev.threadId === replied.threadId) return { ...prev, emails: addEcho(prev.emails, echo) };
if (prev) return prev;
// A message alone so far: it becomes a conversation of two.
return { emails: [open, echo], source: resolveEmailBlobContext(open, client), threadId: replied.threadId };
});
for (const delay of [2000, 9000]) setTimeout(() => { void confirmConversation(open); }, delay);
};
const dropEcho = (emailId: string | undefined) => {
if (!emailId) return;
setMvConversationResult((prev) => {
if (!prev || !prev.emails.some((e) => e.id === emailId)) return prev;
const emails = prev.emails.filter((e) => e.id !== emailId);
return emails.length > 1 ? { ...prev, emails } : null;
});
};
// 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),
@@ -4851,14 +5177,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),
@@ -4943,6 +5263,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
: "Inbox";
})();
// A send in its Undo window isn't counted in Sent or Scheduled yet (mail-p2-8).
const heldSendView = withoutHeldSend(mailboxes, scheduledTotal, pendingUndoSend);
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile;
// Under Mountain View on desktop the composer floats over the list in a
@@ -4956,13 +5278,29 @@ 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;
// Rows ticked beyond the open message take the reading pane (mail-p2-3):
// the selection bar and the keys act on the ticked rows, so a reader
// toolbar acting on the one open message would be a second, different set.
// Unticking brings the message back. Mountain View keeps the message while
// rows are ticked (#1231); the focus layout can't tick rows while reading.
const openRowKeys = (() => {
if (!selectedEmail) return null;
const key = emailKeyFor(selectedEmail);
// A conversation row ticks all its messages: the open one's whole row.
const row = useEmailStore.getState().listRows?.find((r) => r.key === key || r.members.includes(key));
return new Set(row ? [key, ...row.members] : [key]);
})();
const selectionTakesReader = !isMobile && !isFocusedMailLayout && !mvShell && !isScheduledView
&& selectedEmailKeys.size > 0
&& !(openRowKeys && [...selectedEmailKeys].every((key) => openRowKeys.has(key)));
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
|| Boolean(selectedEmail && !(mvShell && cursorOnly) && !selectionTakesReader) || deepLinkPane !== null;
// A linked message on a phone opens over the list, as one picked there does.
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
const shouldCollapseListPane = !isMobile && isFocusedMailLayout && hasViewerContent;
@@ -4987,6 +5325,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
): Promise<OpenEmailOutcome> => {
if (!client || !email) return new Error('the mail page is not ready');
setCursorOnly(false);
setOpenFailure(null);
// If composing, close the composer the way its X does: it keeps the
// draft and says so. Mountain View's compose window stays open while
@@ -5111,11 +5450,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Mark-as-read logic is now handled by useEffect
return 'opened';
}
// The client answers a failed fetch with no message. Offline that
// says nothing about the message: the reader says it needs a connection.
if (offlineNow(client)) {
abandonOpenEmail(null);
setOpenFailure({ target: email, offline: true });
return new Error('offline');
}
abandonOpenEmail(previousEmail);
return 'missing';
} catch (error) {
console.error('Failed to fetch email content:', error);
if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail);
if (openSeq === emailOpenSeqRef.current) {
// Offline the reader says why instead of going blank or back to the
// last message; another failure says so too when there's nothing to go back to.
const offline = offlineNow(client);
abandonOpenEmail(offline ? null : previousEmail);
if (offline || !previousEmail) setOpenFailure({ target: email, offline });
}
return error instanceof Error ? error : new Error(String(error));
}
};
@@ -5188,6 +5540,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');
@@ -5200,6 +5555,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');
@@ -5212,6 +5570,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');
@@ -5307,8 +5666,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
prefill={composerPrefill}
initialFiles={composerInitialFiles}
sessionKey={`inline-${composerSessionId}`}
docked={composerHost === 'dock'}
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;
@@ -5336,10 +5702,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
setPendingDraft(data);
}}
onSend={async (data) => {
const outcome = await handleEmailSend(data);
onSend={async (data, state) => {
const outcome = await handleEmailSend(data, state);
setPendingDraft(null);
setComposerPrefill(null);
scanReplyDrafts();
return outcome;
}}
onSendFailed={(state) => {
@@ -5370,7 +5737,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}
/>
@@ -5520,10 +5898,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
>
<ErrorBoundary fallback={SidebarErrorFallback}>
<Sidebar
mailboxes={mailboxes}
mailboxes={heldSendView.mailboxes}
selectedMailbox={selectedMailbox}
selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
scheduledTotal={heldSendView.scheduledTotal}
scheduledTotalByAccount={scheduledTotalByAccount}
scheduledAccountScope={scheduledAccountScope}
showScheduledMailbox={delayedSendSupported}
@@ -5639,6 +6017,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
folderRole={conversationRole}
onPicked={close}
/>
</>
@@ -5688,24 +6067,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Select / Select All toggle */}
<ToolbarButton
onClick={() => {
if (selectedEmailKeys.size > 0) {
if (selectedEmailKeys.size === activeEmails.length) {
clearSelection();
} else {
selectAllEmails();
}
} else if (activeEmails.length > 0) {
// The open message when it is in this list, else the
// first row - as an email, so the selection is keyed by
// its owning account and not by a colliding id.
const openKey = selectedEmail ? emailKeyFor(selectedEmail) : null;
const target = activeEmails.find((e) => emailKeyFor(e) === openKey) ?? activeEmails[0];
toggleEmailSelection(target);
}
// One click ticks every loaded row, a second clears
// them, as Gmail's box does (mail-p2-11). The bar then
// offers the rest of the folder.
if (selectedEmailKeys.size > 0) clearSelection();
else if (activeEmails.length > 0) selectAllEmails();
}}
disabled={isScheduledView}
active={selectedEmailKeys.size > 0}
label={selectedEmailKeys.size > 0 ? (selectedEmailKeys.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
label={selectedEmailKeys.size > 0 ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
>
{selectedEmailKeys.size > 0 ? <CheckSquare /> : <Square />}
@@ -5852,7 +6222,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) => {
@@ -5915,12 +6291,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);
@@ -5963,12 +6334,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.
@@ -6096,6 +6462,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) => {
@@ -6140,9 +6522,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<>
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={selectedEmail}
email={selectionTakesReader ? null : selectedEmail}
blobSource={resolveEmailBlobContext(selectedEmail, client)}
isLoading={isLoadingEmail || deepLinkPane !== null}
isLoading={!selectionTakesReader && (isLoadingEmail || deepLinkPane !== null)}
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
@@ -6216,8 +6598,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const full = e.bodyValues ? e : await handleLoadConversationEmail(e);
await handleConversationReplyAll(full ?? e);
} : undefined}
onForwardConversationEmail={readerConversationView ? async (e) => {
const full = e.bodyValues ? e : await handleLoadConversationEmail(e);
await handleConversationForward(full ?? e);
} : 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}
@@ -6243,15 +6634,13 @@ 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" />
: undefined}
: openFailure && !isLoadingEmail
? <ReaderOpenFailure offline={openFailure.offline} onRetry={() => void handleEmailSelect(openFailure.target)} />
: undefined}
emptyPaneFooter={emptyPaneBesideList ? <WelcomeBanner variant="pane" /> : undefined}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
@@ -6271,11 +6660,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</ErrorBoundary>
</>
)}
{dockedReply && (
<div className="flex basis-[62%] min-h-[18rem] shrink-0 flex-col border-t border-border" data-reply-dock="">
{composerNode}
</div>
)}
{dockedReply && <ReplyDock>{composerNode}</ReplyDock>}
</>
)}
</section>
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronUp } from "@/components/icons";
import { cn } from "@/lib/utils";
/**
* Below this reading-pane height a docked reply can't share the pane with
* the message: an 800×600 window or a 1366×768 laptop at 150 % left two
* lines to type in and none of the message (compose-p2-4).
*/
export const DOCK_SHARE_MIN_PANE = 700;
/**
* How the reply docked under its message takes the reading pane
* (mail-p2-2, compose-p2-4).
*
* - A tall pane: the reply starts as high as its content (the compact
* composer: To, a few lines, the bottom bar) and grows with the text up to
* half the pane, so the message stays readable above it.
* - A short pane: the reply takes the pane, with "Show the message" to fold it
* to a bar and read the message, and back.
*/
export function dockLayout(paneHeight: number): { mode: 'share' | 'takeover'; maxHeight?: number } {
if (paneHeight > 0 && paneHeight < DOCK_SHARE_MIN_PANE) return { mode: 'takeover' };
return { mode: 'share', maxHeight: paneHeight > 0 ? Math.round(paneHeight / 2) : undefined };
}
export function ReplyDock({ children }: { children: ReactNode }) {
const t = useTranslations("mail_p2_conv");
const ref = useRef<HTMLDivElement>(null);
const [paneHeight, setPaneHeight] = useState(0);
// The reader's toolbar and subject above its scrolling part: they stay.
const [readerChrome, setReaderChrome] = useState(0);
const [showMessage, setShowMessage] = useState(false);
useLayoutEffect(() => {
const pane = ref.current?.parentElement;
if (!pane) return;
const measure = () => {
const paneBox = pane.getBoundingClientRect();
setPaneHeight(Math.round(paneBox.height));
const scroll = pane.querySelector<HTMLElement>("[data-reader-scroll]");
setReaderChrome(scroll ? Math.max(0, Math.round(scroll.getBoundingClientRect().top - paneBox.top)) : 0);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(pane);
return () => ro.disconnect();
}, []);
const layout = dockLayout(paneHeight);
const takeover = layout.mode === "takeover";
const folded = takeover && showMessage;
return (
<div
ref={ref}
data-reply-dock=""
data-dock-mode={layout.mode}
className="flex min-h-0 shrink-0 flex-col border-t border-border bg-background"
style={takeover
? (folded ? undefined : { height: Math.max(paneHeight - readerChrome, 0) })
: { maxHeight: layout.maxHeight ?? "50%" }}
>
{takeover && (
<button
type="button"
onClick={() => setShowMessage((v) => !v)}
aria-expanded={showMessage}
data-dock-show-message=""
className="flex shrink-0 items-center justify-center gap-1.5 border-b border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
{showMessage ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
{showMessage ? t("back_to_reply") : t("show_message")}
</button>
)}
{/* Folded, the composer stays mounted, with its text and its undo history. */}
<div className={cn("flex min-h-0 flex-1 flex-col", folded && "hidden")}>
{children}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { OfflineBanner } from '../offline-banner';
import { useAuthStore } from '@/stores/auth-store';
import { useOfflineStore } from '@/lib/offline/state';
// pass-2 mail-p2-7: in a browser tab that keeps no mail on the device, going
// offline showed no bar anywhere; a click on a message then did nothing.
const setOnline = (online: boolean) => {
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online });
window.dispatchEvent(new Event(online ? 'online' : 'offline'));
};
beforeEach(() => {
useAuthStore.setState({ activeAccountId: 'acc', serverUrl: 'https://mail.example.org' });
useOfflineStore.setState({ enabled: [], outbox: [], conflicts: [], areaConflicts: [], localDrafts: [], browserOffline: false, downServers: [], startedOffline: false });
});
afterEach(() => {
cleanup();
setOnline(true);
});
describe('the offline bar for an account that keeps no mail here', () => {
it('says that reading and changing mail needs a connection, and goes once online', () => {
render(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
act(() => setOnline(false));
expect(screen.getByTestId('offline-banner-no-store').textContent).toBe('offline_bar');
act(() => setOnline(true));
expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
});
it('leaves an account with offline mode its own bar', () => {
useOfflineStore.setState({ enabled: ['acc'], browserOffline: true });
render(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
expect(screen.getByTestId('offline-banner')).toBeTruthy();
});
it('shows nothing when signed out', () => {
useAuthStore.setState({ activeAccountId: null });
act(() => setOnline(false));
render(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
});
});
@@ -63,7 +63,8 @@ describe('the notice that the desktop app keeps mail on this device', () => {
render(<OfflineHost />);
offlineNotify({ kind: 'default-on' });
expect(useToastStore.getState().toasts).toHaveLength(0);
expect(firstStartNoticesWaiting().map((n) => n.text)).toEqual(['default_on']);
// One line now (pass-2 mail-p2-19): the text that fits it, with Open settings.
expect(firstStartNoticesWaiting().map((n) => n.text)).toEqual(['offline_default_on']);
expect(firstStartNoticesWaiting()[0].action?.label).toBe('open_settings');
});
});
+20
View File
@@ -6,6 +6,7 @@ import { CloudOff, X } from '@/components/icons';
import { Dialog } from '@/components/ui/dialog';
import { IconButton } from '@/components/ui/icon-button';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
import { useConnectionState } from '@/hooks/use-connection-state';
import { IS_LITE } from '@/lib/lite';
import { serverKey, useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
@@ -20,6 +21,9 @@ import { OutboxList } from './outbox-list';
*
* It can be hidden until what it says changes (going offline again, another
* message waiting), for this session of the window.
*
* An account that keeps no mail here gets a one-line bar while offline too,
* so a message that doesn't open isn't a mystery (mail-p2-7).
*/
const HIDDEN_KEY = 'bulwark.offlineBanner.hidden';
@@ -49,7 +53,23 @@ export function OfflineBanner() {
const [hidden, setHidden] = useState<string | null>(hiddenFor);
const [banner, setBanner] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(banner);
const connection = useConnectionState();
const tP2 = useTranslations('mail_p2_desk');
const offline = browserOffline || serverDown;
if (!active) {
if (!account || !connection.offline) return null;
return (
<div
role="status"
data-testid="offline-banner-no-store"
data-window-top-row=""
className="flex shrink-0 items-center gap-2 border-b border-border bg-muted px-3 py-2 text-sm text-foreground sm:px-4"
>
<CloudOff className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate" title={tP2('offline_bar')}>{tP2('offline_bar')}</span>
</div>
);
}
const says = `${browserOffline ? 'offline' : serverDown ? 'down' : 'online'}|${waiting}|${conflicts}|${waitingDrafts}|${refusedDrafts}`;
if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && refusedDrafts === 0 && waitingDrafts === 0 && !open)) return null;
const hide = () => {
+5 -1
View File
@@ -28,6 +28,9 @@ export function OfflineHost() {
const tStorage = useTranslations('offlineStorage');
const tStorageRef = useRef(tStorage);
tStorageRef.current = tStorage;
const tNotice = useTranslations('mail_p2_desk');
const tNoticeRef = useRef(tNotice);
tNoticeRef.current = tNotice;
const tKey = useTranslations('keyProtection');
const tKeyRef = useRef(tKey);
tKeyRef.current = tKey;
@@ -79,7 +82,8 @@ export function OfflineHost() {
break;
case 'default-on':
// A first-start notice, not a toast: it stays until closed, and covers nothing.
showFirstStartNotice({ id: 'offline-default-on', text: tStorageRef.current('default_on'), action: openStorage });
// One line in the bar; its button says where to change it.
showFirstStartNotice({ id: 'offline-default-on', text: tNoticeRef.current('offline_default_on'), action: openStorage });
break;
case 'legacy-stranded':
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
@@ -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');
@@ -24,7 +24,7 @@ const DRAFT: RescuedDraft = {
const original = { auth: useAuthStore.getState(), email: useEmailStore.getState(), accounts: useAccountStore.getState() };
let heard: PopoutMessage[];
let popout: NonNullable<ReturnType<typeof openPopoutChannel>>;
let client: { createDraft: ReturnType<typeof vi.fn>; getAuthHeader: () => string; isOfflineOnly: () => boolean };
let client: { createDraft: ReturnType<typeof vi.fn>; deleteEmail: ReturnType<typeof vi.fn>; getAuthHeader: () => string; isOfflineOnly: () => boolean };
let lockRequests: string[];
// Rounds, not one long wait: a message and its answer are two hops, and a busy
@@ -63,7 +63,7 @@ beforeEach(() => {
sessionStorage.clear();
heard = [];
popout = openPopoutChannel((message) => heard.push(message))!;
client = { createDraft: vi.fn(async () => 'new'), getAuthHeader: () => 'Bearer main', isOfflineOnly: () => false };
client = { createDraft: vi.fn(async () => 'new'), deleteEmail: vi.fn(async () => {}), getAuthHeader: () => 'Bearer main', isOfflineOnly: () => false };
useAccountStore.setState({
accounts: [{ id: 'alice', serverUrl: 'https://mail.example.test', username: 'alice', authMode: 'oauth', cookieSlot: 0 } as never],
activeAccountId: 'alice',
@@ -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,22 @@ 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');
// Leaving the page ends the Undo: the draft is deleted then, not left behind.
expect(client.deleteEmail).not.toHaveBeenCalled();
await act(async () => { window.dispatchEvent(new Event('pagehide')); });
expect(client.deleteEmail).toHaveBeenCalledWith('d1', undefined);
});
it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => {
render(<PopoutHost />);
await settle();
@@ -514,7 +547,8 @@ describe('a move a message window made', () => {
});
expect(taken).toBe(true);
const toasts = useToastStore.getState().toasts;
expect(toasts.map((toast) => toast.title)).toEqual(['notifications.email_archived']);
// The main window's own word for it (mail-18).
expect(toasts.map((toast) => toast.title)).toEqual(['mail_list_extra.archived']);
expect(toasts[0].action?.label).toBe('notifications.undo');
expect(undoHistory()).toHaveLength(1);
await act(async () => {
@@ -94,6 +94,13 @@ describe('a message opened from a bulwark:// link', () => {
});
describe('PopoutMessageApp', () => {
// pass-2 mail-p2-5: the window showed one message of a conversation.
it('reads the message in its conversation, as the reading pane does', async () => {
render(<PopoutMessageApp id="m/1" />);
await settle();
expect((view as unknown as { conversation?: boolean }).conversation).toBe(true);
});
it('opens the message of the address, through its login and account', async () => {
render(<PopoutMessageApp id="rendered-for-another-id" />);
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>
</>
);
}
+33 -6
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 });
@@ -171,7 +193,7 @@ export function PopoutHost() {
// Undo for what a message window archived, deleted or moved: it has closed.
claimOnce(`bulwark-popout-moved:${message.requestId}`, () => {
channel?.post({ type: "moved-ack", requestId: message.requestId });
takeOverMoved(message.notice, (key, values) => tRef.current(`notifications.${key}`, values));
takeOverMoved(message.notice, (key, values) => tRef.current(key, values));
refreshList();
});
break;
@@ -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]);
+1
View File
@@ -198,6 +198,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email
liveState={liveState}
onMailChanged={handleMailChanged}
noAutomaticActions={fromLink}
conversation
className="min-h-0 w-full flex-1"
/>
</>
@@ -0,0 +1,161 @@
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
/**
* A message window (desktop app, installed app) showed only the message it was
* opened with, and r, e, # and ? did nothing there: its shortcuts waited for a
* Pro tab to be in front, and a window has no tabs (pass-2 mail-p2-5).
*/
const viewerProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: (props: Record<string, unknown>) => {
viewerProps.current = props;
return null;
},
}));
vi.mock('@/components/error', () => ({
ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
EmailViewerErrorFallback: () => null,
}));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/lib/popout/window-role', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/popout/window-role')>(),
isPopoutWindow: () => true,
}));
vi.mock('@/components/email/move-to-dialog', () => ({ MoveToDialog: () => null }));
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
const help = vi.hoisted(() => ({ open: false }));
vi.mock('@/components/keyboard-shortcuts-modal', () => ({
KeyboardShortcutsModal: ({ isOpen }: { isOpen: boolean }) => {
help.open = isOpen;
return null;
},
}));
function selectorStore<T extends object>(state: T) {
const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
return hook;
}
vi.mock('@/stores/auth-store', () => ({
useAuthStore: selectorStore({ client: null, activeAccountId: 'dso', getClientForAccount: () => undefined }),
}));
vi.mock('@/stores/email-store', () => ({
resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client }),
useEmailStore: selectorStore({
fetchEmailContent: async () => null,
deleteEmail: async () => {},
markAsRead: async () => {},
toggleStar: async () => {},
moveToMailbox: async () => {},
setEmailKeywordsLocal: () => {},
mailboxes: [],
emails: [],
selectedEmail: null,
}),
}));
vi.mock('@/stores/identity-store', () => ({
useIdentityStore: selectorStore({ identities: [{ id: 'b', email: 'dso@example.org', name: 'Dso' }] }),
}));
const settings = vi.hoisted(() => ({ state: { singleKeyShortcuts: true, disableThreading: false } }));
vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore(settings.state) }));
vi.mock('@/stores/pro-tab-store', () => ({
useProTabStore: selectorStore({ openComposeTab: () => {}, closeTab: () => {}, updateTabTitle: () => {} }),
}));
import { ProEmailView } from '../pro-email-tab-body';
const OLDER = {
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ email: 'aylin@example.org', name: 'Aylin' }], subject: 'Question about hiring plan',
receivedAt: '2026-10-20T08:00:00Z', preview: 'First',
};
const NEWER = {
...OLDER, id: 'm2', from: [{ email: 'robin@example.org', name: 'Robin' }], subject: 'Re: Question about hiring plan',
receivedAt: '2026-10-21T08:00:00Z', preview: 'Second', keywords: {},
};
const BODY = {
bodyValues: { '1': { value: 'Hi' } },
textBody: [{ partId: '1', type: 'text/plain' }],
htmlBody: [{ partId: '1', type: 'text/plain' }],
attachments: [],
};
function makeClient() {
return {
getEmail: vi.fn(async (id: string) => (id === 'm1' ? { ...OLDER, ...BODY } : { ...NEWER, ...BODY })),
getThread: vi.fn(async () => ({ id: 't1', emailIds: ['m2', 'm1'] })),
// Not in date order: the window sorts.
getSomeEmails: vi.fn(async () => [NEWER, OLDER]),
markAsRead: vi.fn(async () => {}),
getUsername: () => 'dso@example.org',
getAccountId: () => 'a',
};
}
describe('a message window', () => {
beforeEach(() => {
viewerProps.current = null;
help.open = false;
settings.state.disableThreading = false;
});
async function open() {
const client = makeClient();
const onOpenCompose = vi.fn(async (_data: Record<string, unknown>) => true);
render(<ProEmailView emailId="m1" client={client as never} clientAccountId="dso" onOpenCompose={onOpenCompose} onClose={vi.fn()} conversation />);
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
return { client, onOpenCompose };
}
it('shows the whole conversation, oldest first', async () => {
const { client } = await open();
await waitFor(() => expect((viewerProps.current?.mvConversation as Array<{ id: string }> | undefined)?.map((e) => e.id)).toEqual(['m1', 'm2']));
expect(viewerProps.current?.conversationPane).toBe(true);
expect(client.getThread).toHaveBeenCalledWith('t1', 'a');
});
it('keeps one message with "Show emails as individual messages"', async () => {
settings.state.disableThreading = true;
const { client } = await open();
await act(async () => { await new Promise((r) => setTimeout(r, 5)); });
expect(viewerProps.current?.mvConversation).toBeUndefined();
expect(client.getThread).not.toHaveBeenCalled();
});
it('replies to the message of the conversation whose button was pressed', async () => {
const { onOpenCompose } = await open();
await waitFor(() => expect(viewerProps.current?.mvConversation).toBeTruthy());
await act(async () => {
await (viewerProps.current!.onReplyConversationEmail as (e: unknown) => Promise<void>)(NEWER);
});
await waitFor(() => expect(onOpenCompose).toHaveBeenCalled());
expect(onOpenCompose.mock.calls[0][0]).toMatchObject({ mode: 'reply', sourceEmailId: 'm2' });
});
it('marks an unfolded unread message read on the server', async () => {
const { client } = await open();
await waitFor(() => expect(viewerProps.current?.mvConversation).toBeTruthy());
await act(async () => {
(viewerProps.current!.onMarkConversationEmailRead as (e: unknown) => void)(NEWER);
await new Promise((r) => setTimeout(r, 5));
});
expect(client.markAsRead).toHaveBeenCalledWith('m2', true, 'a');
});
it('answers r and ? with no Pro tab around it', async () => {
const { onOpenCompose } = await open();
fireEvent.keyDown(document.body, { key: 'r', code: 'KeyR' });
await waitFor(() => expect(onOpenCompose).toHaveBeenCalledTimes(1));
expect(onOpenCompose.mock.calls[0][0]).toMatchObject({ mode: 'reply', sourceEmailId: 'm1' });
fireEvent.keyDown(document.body, { key: '?', code: 'Slash', shiftKey: true });
await waitFor(() => expect(help.open).toBe(true));
});
});
+80 -31
View File
@@ -41,10 +41,13 @@ import { isFilePreviewable } from '@/lib/file-preview';
import { useDesktopCommands } from '@/lib/desktop/commands';
import { isPopoutWindow } from '@/lib/popout/window-role';
import { offerMoveUndo } from '@/lib/popout/moved-undo';
import { useViewConversation } from './use-view-conversation';
import dynamic from 'next/dynamic';
// Only needed when the device policy turns downloads off.
const FilePreviewModal = dynamic(() => import('@/components/files/file-preview-modal').then((m) => m.FilePreviewModal), { ssr: false });
// Only a message window opens the shortcuts help from here.
const KeyboardShortcutsModal = dynamic(() => import('@/components/keyboard-shortcuts-modal').then((m) => m.KeyboardShortcutsModal), { ssr: false });
interface ProEmailTabBodyProps {
tabId: string;
@@ -86,6 +89,11 @@ interface ProEmailViewProps {
onMailChanged?: () => void;
/** See EmailViewer. */
noAutomaticActions?: boolean;
/**
* Show the message inside its conversation, as the mail page's reading
* pane does (a message window). "Show emails as individual messages" keeps one.
*/
conversation?: boolean;
/**
* The message left this view: deleted, archived, moved, or reopened in the
* composer. The host decides what that means (close the tab / clear the
@@ -152,7 +160,7 @@ function buildReplyContext(email: Email, source: EmailWriteSource): ProReplyCont
* Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's
* reading pane.
*/
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, onClose, className }: ProEmailViewProps) {
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, conversation, onClose, className }: ProEmailViewProps) {
const t = useTranslations();
const tNotifications = useTranslations('notifications');
const tRemove = useTranslations('email_list.confirm_remove');
@@ -295,17 +303,21 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
return mb?.role;
}, [email, mailboxes]);
const handleReply = useCallback((draftText?: string) => {
if (!email) return;
const replyToMessage = useCallback((target: Email, draftText?: string) => {
openComposeTab({
mode: 'reply',
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
replyTo: buildReplyContext(target, writeSource),
sourceEmailId: target.id,
sourceEmailSource: writeSource,
initialDraftText: draftText,
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
}, { source: email, body: draftText ?? '' });
}, [email, openComposeTab, t, writeSource]);
title: buildReplySubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
}, { source: target, body: draftText ?? '' });
}, [openComposeTab, t, writeSource]);
const handleReply = useCallback((draftText?: string) => {
if (!email) return;
replyToMessage(email, draftText);
}, [email, replyToMessage]);
// A notification's Reply, once this tab has the message (mail-app.tsx).
const pendingTabReply = useNotificationTabReply((s) => s.pending);
@@ -315,27 +327,40 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (text !== null) handleReply(text);
}, [email, pendingTabReply, handleReply]);
const handleReplyAll = useCallback(() => {
if (!email) return;
const replyAllToMessage = useCallback((target: Email) => {
openComposeTab({
mode: 'replyAll',
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
replyTo: buildReplyContext(target, writeSource),
sourceEmailId: target.id,
sourceEmailSource: writeSource,
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
}, { source: email });
}, [email, openComposeTab, t, writeSource]);
title: buildReplySubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
}, { source: target });
}, [openComposeTab, t, writeSource]);
const handleReplyAll = useCallback(() => {
if (!email) return;
replyAllToMessage(email);
}, [email, replyAllToMessage]);
// A message window reads the message in its conversation (mail-p2-5).
const disableThreading = useSettingsStore((s) => s.disableThreading);
const conversationView = !!conversation && !disableThreading;
const viewConversation = useViewConversation({ enabled: conversationView, email, source: blobSource, onChanged: onMailChanged });
const forwardMessage = useCallback((target: Email) => {
openComposeTab({
mode: 'forward',
replyTo: buildReplyContext(target, writeSource),
sourceEmailId: target.id,
sourceEmailSource: writeSource,
title: buildForwardSubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
}, { source: target });
}, [openComposeTab, t, writeSource]);
const handleForward = useCallback(() => {
if (!email) return;
openComposeTab({
mode: 'forward',
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
}, { source: email });
}, [email, openComposeTab, t, writeSource]);
forwardMessage(email);
}, [email, forwardMessage]);
// Mirrors handleForward, but attaches the original as a message/rfc822
// file instead of quoting it inline - see lib/forward-as-attachment.ts.
@@ -392,14 +417,14 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
try {
// false: not deleted (a plugin stopped it), so the tab stays open.
if (await deleteEmail(client, email.id)) {
await offerMoveUndo('delete', client, restore, tNotifications, isPopoutWindow());
await offerMoveUndo('delete', client, restore, t, isPopoutWindow());
onClose();
}
} catch (err) {
console.error('Delete failed:', err);
toast.error(tNotifications('error_deleting'));
}
}, [client, email, mailboxes, deleteEmail, onClose, tNotifications, confirmDialog, tRemove]);
}, [client, email, mailboxes, deleteEmail, onClose, t, tNotifications, confirmDialog, tRemove]);
const handleArchive = useCallback(async () => {
if (!client || !email) return;
@@ -409,12 +434,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
const restore = restoreBatchesFor([email], client);
try {
if (!(await moveToMailbox(client, email.id, archiveMb.id, emailKeyFor(email)))) return;
await offerMoveUndo('archive', client, restore, tNotifications, isPopoutWindow());
await offerMoveUndo('archive', client, restore, t, isPopoutWindow());
onClose();
} catch (err) {
console.error('Archive failed:', err);
}
}, [client, email, mailboxes, moveToMailbox, onClose, tNotifications, confirmDialog, tRemove]);
}, [client, email, mailboxes, moveToMailbox, onClose, t, confirmDialog, tRemove]);
const handleToggleStar = useCallback(async () => {
if (!client || !email) return;
@@ -510,17 +535,18 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
const restore = restoreBatchesFor([email], client);
try {
if (await moveToMailbox(client, email.id, mailboxId, emailKeyFor(email))) {
await offerMoveUndo('move', client, restore, tNotifications, isPopoutWindow());
await offerMoveUndo('move', client, restore, t, isPopoutWindow());
onClose();
}
} catch (err) {
console.error('Move failed:', err);
}
}, [client, email, moveToMailbox, onClose, tNotifications]);
}, [client, email, moveToMailbox, onClose, t]);
// This view's message answers the same keys as a message in the mail tab,
// while its tab is the one in front. c comes from the mail page, as on any tab.
const isFocusedTab = useIsFocusedProTab();
const [showShortcuts, setShowShortcuts] = useState(false);
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
const shortcutEmails = useMemo(() => (email ? [email] : []), [email]);
const shortcutHandlers = useMemo(() => ({
@@ -532,9 +558,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
onDelete: () => void handleDelete(),
onMarkAsUnread: email ? () => void handleMarkAsRead(email.id, false) : undefined,
onMarkAsRead: email ? () => void handleMarkAsRead(email.id, true) : undefined,
}), [email, handleReply, handleReplyAll, handleForward, handleToggleStar, archiveActionEnabled, handleArchive, handleDelete, handleMarkAsRead]);
// A message window has no mail page behind it to answer ?.
onShowHelp: inOwnWindow ? () => setShowShortcuts(true) : undefined,
}), [email, handleReply, handleReplyAll, handleForward, handleToggleStar, archiveActionEnabled, handleArchive, handleDelete, handleMarkAsRead, inOwnWindow]);
// A Pro tab answers while it is the one in front; a message window is
// always in front of itself (it has no tabs).
useKeyboardShortcuts({
enabled: isFocusedTab && !!email,
enabled: (isFocusedTab || inOwnWindow) && !!email && !showShortcuts,
singleKeyShortcuts,
emails: shortcutEmails,
selectedEmailId: email?.id,
@@ -633,6 +663,24 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
<EmailViewer
noAutomaticActions={noAutomaticActions}
email={email}
mvConversation={conversationView ? viewConversation.emails : undefined}
conversationPane={conversationView}
mvConversationPending={conversationView && viewConversation.pending}
onLoadConversationEmail={conversationView ? (e) => void viewConversation.loadBody(e) : undefined}
onMarkConversationEmailRead={conversationView ? (e) => void viewConversation.markRead(e) : undefined}
onReplyConversationEmail={conversationView ? async (e) => {
// The reply quotes the message, so it needs the body.
const full = e.bodyValues ? e : await viewConversation.loadBody(e);
replyToMessage(full ?? e);
} : undefined}
onReplyAllConversationEmail={conversationView ? async (e) => {
const full = e.bodyValues ? e : await viewConversation.loadBody(e);
replyAllToMessage(full ?? e);
} : undefined}
onForwardConversationEmail={conversationView ? async (e) => {
const full = e.bodyValues ? e : await viewConversation.loadBody(e);
forwardMessage(full ?? e);
} : undefined}
blobSource={blobSource}
isLoading={isLoading}
onReply={handleReply}
@@ -659,6 +707,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
/>
</ErrorBoundary>
<ConfirmDialog {...confirmDialogProps} />
{inOwnWindow && <KeyboardShortcutsModal isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />}
{inOwnWindow && (
<MoveToDialog
emails={moveDialogOpen && email ? [email] : []}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Email } from "@/lib/jmap/types";
import type { EmailBlobContext } from "@/stores/email-store";
import { emailHooks } from "@/lib/plugin-hooks";
export interface ViewConversation {
/** Every message of the open message's conversation, oldest first; undefined while there is none. */
emails: Email[] | undefined;
/** The conversation is on its way. */
pending: boolean;
/** Fetches a folded message's body when it unfolds. */
loadBody: (target: Email) => Promise<Email | null>;
/** An unfolded message was read. */
markRead: (target: Email) => Promise<void>;
}
/**
* The conversation around a message read outside the mail page (a message
* window), fetched the way the mail page's reading pane fetches it: the
* thread's ids, then their list properties, drafts left out except the open
* one, oldest first. A conversation of one message is none.
*/
export function useViewConversation({ enabled, email, source, onChanged }: {
enabled: boolean;
email: Email | null;
source: EmailBlobContext;
onChanged?: () => void;
}): ViewConversation {
const threadId = enabled ? email?.threadId ?? null : null;
const openId = email?.id ?? null;
const [result, setResult] = useState<{ threadId: string; emails: Email[] } | null>(null);
const [pending, setPending] = useState(false);
const seqRef = useRef(0);
const onChangedRef = useRef(onChanged);
onChangedRef.current = onChanged;
useEffect(() => {
const seq = ++seqRef.current;
const client = source.client;
if (!threadId || !client) {
setResult(null);
setPending(false);
return;
}
setPending(true);
void (async () => {
const thread = await client.getThread(threadId, source.accountId);
const ids = thread?.emailIds ?? [];
if (ids.length < 2) return null;
const emails = await client.getSomeEmails(ids, source.accountId);
return emails
.filter((e) => e.id === openId || !e.keywords?.$draft)
.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
})().catch(() => null).then((emails) => {
if (seq !== seqRef.current) return;
setResult(emails && emails.length > 1 ? { threadId, emails } : null);
setPending(false);
});
}, [threadId, openId, source.client, source.accountId]);
const patch = useCallback((id: string, change: (e: Email) => Email) => {
setResult((prev) => (prev ? { ...prev, emails: prev.emails.map((e) => (e.id === id ? change(e) : e)) } : prev));
}, []);
const loadBody = useCallback(async (target: Email) => {
const client = source.client;
if (!client) return null;
const seq = seqRef.current;
const full = await client.getEmail(target.id, source.accountId).catch(() => null);
if (!full || seq !== seqRef.current) return null;
patch(target.id, (e) => ({ ...full, keywords: e.keywords }));
return full;
}, [source.client, source.accountId, patch]);
const markRead = useCallback(async (target: Email) => {
const client = source.client;
if (!client || target.keywords?.$seen) return;
try {
if (emailHooks.onBeforeEmailReadStateChange.size > 0
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [target.id], read: true }))) {
return;
}
await client.markAsRead(target.id, true, source.accountId);
void emailHooks.onEmailReadStateChange.emit({ emailIds: [target.id], read: true });
patch(target.id, (e) => ({ ...e, keywords: { ...e.keywords, $seen: true } }));
onChangedRef.current?.();
} catch (error) {
console.error("Failed to mark conversation message read:", error);
}
}, [source.client, source.accountId, patch]);
return {
emails: result && result.threadId === threadId ? result.emails : undefined,
pending: pending && !!threadId,
loadBody,
markRead,
};
}
@@ -74,3 +74,35 @@ describe('first-start notices', () => {
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
});
});
// pass-2 mail-p2-19: offline, the desktop app showed the offline bar and, under
// it, this notice four lines tall saying much the same.
describe('first-start notices and the offline bar', () => {
afterEach(() => {
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => true });
});
it('wait while offline and come back online', () => {
render(<FirstStartNoticeBar />);
act(() => showFirstStartNotice({ id: 'kept', text: 'Mail is kept on this device.' }));
expect(screen.getByText('Mail is kept on this device.')).toBeTruthy();
act(() => {
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false });
window.dispatchEvent(new Event('offline'));
});
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
act(() => {
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => true });
window.dispatchEvent(new Event('online'));
});
expect(screen.getByText('Mail is kept on this device.')).toBeTruthy();
});
it('keep to one line, the whole text on hover', () => {
render(<FirstStartNoticeBar />);
act(() => showFirstStartNotice({ id: 'long', text: 'A long text that would wrap to four lines in a narrow list.' }));
const text = screen.getByText('A long text that would wrap to four lines in a narrow list.');
expect(text.className).toContain('truncate');
expect(text.getAttribute('title')).toBe('A long text that would wrap to four lines in a narrow list.');
});
});
@@ -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();
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, renderHook, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
@@ -12,7 +12,7 @@ vi.mock('@/hooks/use-media-query', () => ({
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }));
const { WelcomeBanner } = await import('../welcome-banner');
const { WelcomeBanner, useWelcomeBannerDue } = await import('../welcome-banner');
const { useSettingsStore } = await import('@/stores/settings-store');
describe('WelcomeBanner tips', () => {
@@ -63,3 +63,38 @@ describe('WelcomeBanner and Escape', () => {
expect(useSettingsStore.getState().onboardingCompleted).toBe(false);
});
});
// pass2 mail-p2-17: settings are per account, so a second account started
// with onboardingCompleted false and showed the tips again on the same device.
describe('WelcomeBanner once per device', () => {
beforeEach(() => {
localStorage.clear();
device.isMobile = false;
});
it('stays away for an account added on a device that has seen the tips', () => {
localStorage.setItem('onboarding_completed', 'true');
useSettingsStore.setState({ onboardingCompleted: false, showOnboardingOnNewDevices: false });
const { container } = render(<WelcomeBanner />);
expect(container.querySelector('[data-welcome]')).toBeNull();
});
it('is not due for that account either, so first-start notices are not held back', () => {
localStorage.setItem('onboarding_completed', 'true');
useSettingsStore.setState({ onboardingCompleted: false, showOnboardingOnNewDevices: false });
const { result } = renderHook(() => useWelcomeBannerDue());
expect(result.current).toBe(false);
});
it('shows on a device that has not seen them', () => {
useSettingsStore.setState({ onboardingCompleted: false, showOnboardingOnNewDevices: false });
render(<WelcomeBanner />);
expect(screen.getByRole('complementary')).toBeInTheDocument();
});
it('points at the bar on the left, not a sidebar menu', () => {
useSettingsStore.setState({ onboardingCompleted: false });
render(<WelcomeBanner />);
expect(screen.getByText('tip_sidebar')).toBeInTheDocument();
});
});
+10 -5
View File
@@ -5,6 +5,7 @@ import { Info, X } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { closeFirstStartNotice, useCurrentFirstStartNotice } from "@/lib/first-start-notices";
import { useConnectionState } from "@/hooks/use-connection-state";
import { useWelcomeBannerDue } from "@/components/ui/welcome-banner";
/**
@@ -12,21 +13,25 @@ import { useWelcomeBannerDue } from "@/components/ui/welcome-banner";
* lib/first-start-notices). In the page's flow, it covers nothing. It waits while the
* welcome tips are due, wherever they show (above the list or in the reading pane),
* so the first start has one greeting at a time. `hidden` while something else greets
* the user there.
* the user there. Offline, the offline bar above says what matters then, so the
* notice waits too.
*
* One line: a longer text is cut, and shows whole on hover.
*/
export function FirstStartNoticeBar({ hidden = false }: { hidden?: boolean }) {
const tCommon = useTranslations("common");
const notice = useCurrentFirstStartNotice();
const { offline } = useConnectionState();
const welcomeDue = useWelcomeBannerDue();
if (!notice || hidden || welcomeDue) return null;
if (!notice || hidden || offline || welcomeDue) return null;
return (
<div
role="status"
data-first-start-notice={notice.id}
className="flex items-start gap-3 border-b border-border bg-accent/30 px-4 py-2.5 text-sm"
className="flex items-center gap-3 border-b border-border bg-accent/30 px-4 py-1.5 text-sm"
>
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden="true" />
<p className="min-w-0 flex-1 text-foreground">{notice.text}</p>
<Info className="h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden="true" />
<p className="min-w-0 flex-1 truncate text-foreground" title={notice.text}>{notice.text}</p>
{notice.action && (
<Button
variant="outline"
+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"
+20 -22
View File
@@ -20,19 +20,29 @@ export function useWelcomeBannerDue(): boolean {
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
const [due, setDue] = useState(false);
useEffect(() => {
if (!onboardingCompleted) {
setDue(true);
return;
}
let seenHere = true;
try {
seenHere = localStorage.getItem(ONBOARDING_KEY) === "true";
} catch { /* localStorage unavailable */ }
setDue(showOnboardingOnNewDevices && !seenHere);
setDue(welcomeTipsDue(onboardingCompleted, showOnboardingOnNewDevices, seenOnThisDevice()));
}, [onboardingCompleted, showOnboardingOnNewDevices]);
return due;
}
function seenOnThisDevice(): boolean {
try {
return localStorage.getItem(ONBOARDING_KEY) === "true";
} catch {
return false;
}
}
/**
* The tips show once per device. Settings are kept per account, so an
* account added later starts with `onboardingCompleted` false; the device's
* own flag keeps the tips from coming back for it.
*/
export function welcomeTipsDue(onboardingCompleted: boolean, showOnNewDevices: boolean, seenHere: boolean): boolean {
if (seenHere) return false;
return !onboardingCompleted || showOnNewDevices;
}
/**
* First-run tips. `list` sits above the message list (phones, and layouts
* without a reading pane beside the list); `pane` is a card in the empty
@@ -64,19 +74,7 @@ export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane"
}, [onboardingCompleted, updateSetting]);
useEffect(() => {
if (!onboardingCompleted) {
setVisible(true);
return;
}
if (showOnboardingOnNewDevices) {
try {
if (localStorage.getItem(ONBOARDING_KEY) !== "true") {
setVisible(true);
return;
}
} catch { /* localStorage unavailable */ }
}
setVisible(false);
setVisible(welcomeTipsDue(onboardingCompleted, showOnboardingOnNewDevices, seenOnThisDevice()));
}, [onboardingCompleted, showOnboardingOnNewDevices]);
const dismiss = useCallback(() => {
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useShallow } from "zustand/react/shallow";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { totalInboxUnread } from "@/lib/account-inbox-unread";
/**
* Unread mail in the inboxes of the signed-in accounts that aren't on
* screen, while "count every account" is on in Notifications (the default).
* The other accounts' folder lists are what the account switcher reads
* (useProMultiAccountMailboxes); one not loaded yet adds nothing.
*/
export function useOtherAccountsUnread(): number {
const enabled = useSettingsStore((s) => s.unreadBadgeAllAccounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const otherIds = useAccountStore(
useShallow((s) => (enabled ? s.accounts.filter((a) => a.isConnected && a.id !== activeAccountId).map((a) => a.id) : [])),
);
const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
return totalInboxUnread(otherIds, accountMailboxes);
}
/**
* The number the app shows for new mail: the open account's own inbox plus,
* by the same setting, every other account's. The favicon, the app badge,
* the rail's Mail entry and the tab title all show this one number
* (usability test 2026-11-04, mail-6).
*/
export function useAllInboxesUnread(): number {
// `role === 'inbox'` alone is not enough: shared and group inboxes ship in
// the same `mailboxes` array, so on a delegated setup the first match can
// be somebody else's inbox.
const own = useEmailStore((s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0);
return own + useOtherAccountsUnread();
}
+16
View File
@@ -36,3 +36,19 @@ export const LIST_COLUMN_CLASS = {
date: 'w-28 shrink-0 text-end',
size: 'w-16 shrink-0 text-end',
} as const;
/**
* Whether a stacked row puts its preview after the subject, on the subject's
* line, instead of on a line of its own (mail-15). The regular density does
* on a computer, which brings a row from about 96 px to about 68 px, so a
* 1366×768 screen lists about ten. Comfortable keeps two preview lines, and
* phones keep the preview under the subject, where the line is too short.
*/
export function showsPreviewOnSubjectLine(options: {
showPreview: boolean;
density: string;
isMobile: boolean;
mountainView: boolean;
}): boolean {
return options.showPreview && options.density === 'regular' && !options.isMobile && !options.mountainView;
}
+1 -1
View File
@@ -13,7 +13,7 @@ const row = (description: string): ShortcutEntry => {
describe('the shortcut help in the desktop app', () => {
it('lists the Outlook keys its menus run beside the single keys', () => {
expect(shortcutKeyFor(row('shortcuts.actions.reply'), 'windows')).toBe('Mod + R / r');
expect(shortcutKeyFor(row('shortcuts.actions.reply_all'), 'linux')).toBe('Mod + Shift + R / R / a');
expect(shortcutKeyFor(row('shortcuts.actions.reply_all'), 'linux')).toBe('Mod + Shift + R / Shift + R');
expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'mac')).toBe('Mod + Shift + F / f');
expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'windows')).toBe('Ctrl + F / Ctrl + Shift + F / f');
expect(shortcutKeyFor(row('shortcuts.global.search'), 'windows')).toBe('Ctrl + E / /');
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { emptyFolderLabelKey, emptyFolderPrompt } from '../empty-folder-text';
// p2-14: the confirm said "All emails in this folder will be permanently
// deleted" with no number; it now says how many, and names Trash or Spam.
describe('emptyFolderPrompt', () => {
it('names Trash and counts its messages', () => {
expect(emptyFolderPrompt({ role: 'trash', totalEmails: 146 }, false)).toEqual({
title: 'mail_p2_folders.empty_trash',
message: 'mail_p2_folders.empty_trash_confirm',
values: { count: 146 },
confirm: 'mail_p2_folders.empty_trash',
});
});
it('names Spam and counts its messages', () => {
const prompt = emptyFolderPrompt({ role: 'junk', totalEmails: 3 }, false);
expect(prompt.title).toBe('mail_p2_folders.empty_spam');
expect(prompt.message).toBe('mail_p2_folders.empty_spam_confirm');
expect(prompt.values).toEqual({ count: 3 });
});
it('keeps the old wording for other folders', () => {
expect(emptyFolderPrompt({ role: null, totalEmails: 3 }, true).message).toBe('email_list.empty_folder.confirm_message_trash');
expect(emptyFolderLabelKey('inbox')).toBeNull();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { formatDate } from '../utils';
import { useSettingsStore } from '@/stores/settings-store';
// mail-p2-10: on Wednesday Nov 4 at 10:40, a mail from Wednesday Oct 28 at
// 17:40 read "Wed 17:40", as if it came later today. The weekday names only
// the last six calendar days; older mail shows its date.
describe('formatDate weekday window', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-11-04T09:40:00Z')); // Wed 10:40 in Berlin
useSettingsStore.setState({ dateFormat: 'smart', timeFormat: '24h', dateLocale: 'auto', timeZone: 'Europe/Berlin' });
});
afterEach(() => {
vi.useRealTimers();
});
it('gives a mail from the same weekday last week its date, not the weekday', () => {
expect(formatDate(new Date('2026-10-28T16:40:00Z'))).toBe('Oct 28');
});
it('keeps the weekday for the last six calendar days', () => {
expect(formatDate(new Date('2026-10-29T16:40:00Z'))).toBe('Thu 17:40');
expect(formatDate(new Date('2026-11-03T22:40:00Z'))).toBe('Tue 23:40');
});
it('counts days in the display zone, not 24-hour periods', () => {
// Late on Oct 28 in Berlin is less than seven 24-hour periods ago but
// still last Wednesday.
expect(formatDate(new Date('2026-10-28T22:30:00Z'))).toBe('Oct 28');
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { addEcho, isUndoWindow, mergeEchoes, sentEcho, settleSent, withoutHeldSend } from '../held-send';
const mb = (role: string, totalEmails: number, extra: Partial<Mailbox> = {}) =>
({ id: role, name: role, role, totalEmails, unreadEmails: 0, ...extra }) as Mailbox;
const now = Date.parse('2026-11-04T10:00:00Z');
const soon = new Date(now + 5000).toISOString();
describe('withoutHeldSend', () => {
it('shows Sent and Scheduled as they were before a send in its Undo window', () => {
const view = withoutHeldSend([mb('inbox', 146), mb('sent', 52)], 1, { sendAt: soon, countsBefore: { sent: 51, scheduled: 0 } }, now);
expect(view.scheduledTotal).toBe(0);
expect(view.mailboxes.find((m) => m.role === 'sent')!.totalEmails).toBe(51);
expect(view.mailboxes.find((m) => m.role === 'inbox')!.totalEmails).toBe(146);
});
it('shows a count that fell meanwhile as it is', () => {
const view = withoutHeldSend([mb('sent', 49)], 0, { sendAt: soon, countsBefore: { sent: 51, scheduled: 1 } }, now);
expect(view.mailboxes[0].totalEmails).toBe(49);
expect(view.scheduledTotal).toBe(0);
});
it('counts a mail scheduled for later at once', () => {
const tomorrow = new Date(now + 86_400_000).toISOString();
const view = withoutHeldSend([mb('sent', 52)], 1, { sendAt: tomorrow, countsBefore: { sent: 51, scheduled: 0 } }, now);
expect(view.scheduledTotal).toBe(1);
expect(view.mailboxes[0].totalEmails).toBe(52);
});
it('leaves a shared Sent folder alone', () => {
const view = withoutHeldSend([mb('sent', 9, { isShared: true })], 0, { sendAt: soon, countsBefore: { sent: 5, scheduled: 0 } }, now);
expect(view.mailboxes[0].totalEmails).toBe(9);
});
});
describe('isUndoWindow', () => {
it('is two minutes at most', () => {
expect(isUndoWindow(soon, now)).toBe(true);
expect(isUndoWindow(new Date(now + 121_000).toISOString(), now)).toBe(false);
expect(isUndoWindow(undefined, now)).toBe(false);
});
});
describe('echoes of sent replies', () => {
const server = [{ id: 'a' }, { id: 'b' }] as Email[];
const echo = sentEcho({ emailId: 'c', threadId: 't', to: ['sam@example.org'], subject: 'Re: x', text: 'Thanks', fromEmail: 'me@example.org' });
it('is read, carries its text and the server id', () => {
expect(echo.id).toBe('c');
expect(echo.keywords.$seen).toBe(true);
expect(Object.values(echo.bodyValues!)[0].value).toBe('Thanks');
expect(echo.sentEcho).toBe(true);
});
it('is added once', () => {
expect(addEcho(addEcho(server, echo), echo).map((e) => e.id)).toEqual(['a', 'b', 'c']);
});
it('gives way to the server copy, and stays while the server lacks it', () => {
expect(mergeEchoes([...server, { id: 'c' } as Email], [...server, echo]).map((e) => (e.sentEcho ? 'echo' : e.id))).toEqual(['a', 'b', 'c']);
expect(mergeEchoes(server, [...server, echo]).map((e) => (e.sentEcho ? 'echo' : e.id))).toEqual(['a', 'b', 'echo']);
});
it('keeps the body of a message already unfolded', () => {
const loaded = { id: 'a', keywords: {}, mailboxIds: { inbox: true }, bodyValues: { 1: { value: 'Hi' } } } as unknown as Email;
const listed = { id: 'a', keywords: { $seen: true }, mailboxIds: { archive: true } } as unknown as Email;
const [merged] = mergeEchoes([listed], [loaded]);
expect(merged.bodyValues).toEqual(loaded.bodyValues);
expect(merged.keywords).toEqual({ $seen: true });
expect(merged.mailboxIds).toEqual({ archive: true });
});
});
describe('settleSent', () => {
it('drops the waiting mark once the send time passed, keeps later ones', () => {
const past = { id: 'a', isScheduled: true, scheduledSendAt: new Date(now - 1000).toISOString() } as Email;
const later = { id: 'b', isScheduled: true, scheduledSendAt: new Date(now + 60_000).toISOString() } as Email;
const [a, b] = settleSent([past, later], now);
expect(a.isScheduled).toBe(false);
expect(b.isScheduled).toBe(true);
});
});
+21 -5
View File
@@ -46,8 +46,24 @@ const ids = (nodes: MoveMenuNode[]): string[] => nodes.flatMap((n) => [n.id, ...
describe('buildMoveMenu', () => {
it('lists system folders first in mail-client order, then the rest by name', () => {
const { nodes } = buildMoveMenu(mailboxes, { currentMailboxId: 'work', ownerAccountId: 'A', locale: 'en' });
// Drafts can't take a moved message; the server's sortOrder doesn't count.
expect(names(nodes)).toEqual(['Inbox', 'Sent Items', 'Archive', 'Junk Mail', 'Trash', 'apple', 'Read only', 'Receipts', 'Work', 'Zettelkasten']);
// Drafts and Sent can't take a moved message (pass 2 p2-18 for Sent);
// the server's sortOrder doesn't put a folder above the Inbox.
expect(names(nodes)).toEqual(['Inbox', 'Archive', 'Junk Mail', 'Trash', 'apple', 'Read only', 'Receipts', 'Work', 'Zettelkasten']);
});
// p2-18: the sidebar showed Receipts > 2026, 2025, the Move list 2025, 2026.
it("orders the user's folders as the sidebar does: set order, then years newest first", () => {
const boxes = [
own('inbox', 'Inbox', { role: 'inbox', sortOrder: 10 }),
own('receipts', 'Receipts'),
own('y2025', '2025', { parentId: 'receipts' }),
own('y2026', '2026', { parentId: 'receipts' }),
own('b', 'Beta', { sortOrder: 2 }),
own('a', 'Alpha', { sortOrder: 3 }),
];
const { nodes } = buildMoveMenu(boxes, { ownerAccountId: 'A', locale: 'en' });
expect(names(nodes)).toEqual(['Inbox', 'Receipts', 'Beta', 'Alpha']);
expect(names(nodes.find((n) => n.id === 'receipts')!.children)).toEqual(['2026', '2025']);
});
it('keeps the nesting, sorts numbers as numbers, and keeps a read-only parent as a step', () => {
@@ -65,7 +81,7 @@ describe('buildMoveMenu', () => {
const inInbox = { mailboxIds: { inbox: true } } as unknown as Email;
const { nodes, targetCount } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox] });
expect(nodes[0]).toMatchObject({ id: 'inbox', enabled: false, current: true });
expect(targetCount).toBe(13);
expect(targetCount).toBe(12);
});
it('lets a search hit from another folder move into the open one (#1452)', () => {
@@ -90,8 +106,8 @@ describe('buildMoveMenu', () => {
const inInbox = { mailboxIds: { inbox: true } } as unknown as Email;
const recentIds = (account: string | undefined) => (account === 'A' ? ['gone', 'inbox', 'acme', 'S:projects', 'sent', 'trash'] : []);
const { recent } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox], recentIds });
// A deleted folder and the open one drop out; three show.
expect(recent.map((n) => n.id)).toEqual(['acme', 'S:projects', 'sent']);
// A deleted folder, the open one and Sent drop out; three show.
expect(recent.map((n) => n.id)).toEqual(['acme', 'S:projects', 'trash']);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from 'vitest';
import { guardOfflineMailChanges } from '../offline-key-guard';
describe('keys that change mail, offline', () => {
it('answer with the refusal instead of running, and run again once they can', () => {
let can = false;
const archive = vi.fn();
const next = vi.fn();
const refused = vi.fn();
const handlers = guardOfflineMailChanges({ onArchive: archive, onNextEmail: next }, () => can, refused);
handlers.onArchive();
expect(archive).not.toHaveBeenCalled();
expect(refused).toHaveBeenCalledTimes(1);
handlers.onNextEmail();
expect(next).toHaveBeenCalledTimes(1);
can = true;
handlers.onArchive();
expect(archive).toHaveBeenCalledTimes(1);
expect(refused).toHaveBeenCalledTimes(1);
});
it('leaves a key with no handler without one', () => {
const handlers = guardOfflineMailChanges({ onArchive: undefined as (() => void) | undefined }, () => false, vi.fn());
expect(handlers.onArchive).toBeUndefined();
});
});
+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);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import type { Email } from '@/lib/jmap/types';
import { batchToastTitle, selectionRowCount } from '../selection-toast';
// mail-18: a batch toast said "5 emails archived" under a bar that said
// "5 conversations". The toasts now count the rows the bar counts.
const t = (key: string, values?: Record<string, unknown>) => `${key} ${JSON.stringify(values ?? {})}`;
const mail = (id: string, threadId: string) => ({ id, threadId }) as Email;
describe('selection toasts', () => {
it('counts conversations, not the messages a conversation row ticks', () => {
const selected = [mail('a', 't1'), mail('b', 't1'), mail('c', 't2')];
expect(selectionRowCount(selected, 3, false)).toBe(2);
expect(selectionRowCount(selected, 3, true)).toBe(3);
expect(selectionRowCount([], 4, false)).toBe(4);
});
it('names conversations while the list groups them', () => {
expect(batchToastTitle(t, 'archive', 5, false)).toBe('mail_p2_list.conversations_archived {"count":5}');
expect(batchToastTitle(t, 'trash', 2, false)).toBe('mail_p2_list.conversations_to_trash {"count":2}');
expect(batchToastTitle(t, 'delete', 2, false)).toBe('mail_p2_list.conversations_deleted {"count":2}');
expect(batchToastTitle(t, 'move', 3, false, 'Family')).toBe('mail_p2_list.conversations_moved_to {"count":3,"mailbox":"Family"}');
});
it('keeps the email wording with conversations off', () => {
expect(batchToastTitle(t, 'archive', 5, true)).toBe('notifications.emails_archived {"count":5}');
expect(batchToastTitle(t, 'move', 3, true, 'Family')).toBe('mail_list_extra.moved_count_to {"count":3,"mailbox":"Family"}');
});
});
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { spamApplicableIn } from '../spam-applicable';
describe('spamApplicableIn', () => {
it('offers Report spam on received mail', () => {
for (const role of ['inbox', 'archive', 'junk', null, undefined]) expect(spamApplicableIn(role)).toBe(true);
});
it('not on your own outgoing mail, nor in Trash', () => {
for (const role of ['sent', 'drafts', 'scheduled', 'trash']) expect(spamApplicableIn(role)).toBe(false);
});
});
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatTabTitle, mailTitleContext } from '../tab-title';
import { formatTabTitle, mailTitleContext, mailboxTitleLine } from '../tab-title';
describe('formatTabTitle', () => {
it('puts the context first, the account next and the app name last', () => {
@@ -50,3 +50,25 @@ describe('mailTitleContext', () => {
expect(mailTitleContext(view, { showSubject: false })).toBe('Reply');
});
});
describe('mailboxTitleLine', () => {
const t = (key: string, values?: Record<string, string | number>) => `${key} ${JSON.stringify(values)}`;
it('names the folder with its unread count', () => {
expect(mailboxTitleLine({ name: 'Family', unread: 2 }, t)).toBe('Family (2)');
expect(mailboxTitleLine({ name: 'Family', unread: 0 }, t)).toBe('Family');
});
// mail-6: the title said 10 while 30 were unread in all accounts.
it('adds the other accounts on the Inbox', () => {
expect(mailboxTitleLine({ name: 'Inbox', unread: 10, otherUnread: 20, ownInbox: true }, t))
.toBe('mail_p2_folders.title_all_accounts {"name":"Inbox","count":10,"total":30}');
expect(mailboxTitleLine({ name: 'Inbox', unread: 0, otherUnread: 20, ownInbox: true }, t))
.toBe('mail_p2_folders.title_all_accounts_only {"name":"Inbox","total":20}');
});
it('leaves other folders and an Inbox with no other unread mail alone', () => {
expect(mailboxTitleLine({ name: 'Family', unread: 1, otherUnread: 20 }, t)).toBe('Family (1)');
expect(mailboxTitleLine({ name: 'Inbox', unread: 10, otherUnread: 0, ownInbox: true }, t)).toBe('Inbox (10)');
});
});
@@ -341,6 +341,28 @@ describe('the window controls over the app', () => {
expect(root.hasAttribute(CONTROLS_LEFT_ATTRIBUTE)).toBe(true);
});
// mail-p2-16: the desktop app sends geometrychange while the page still has
// its old width; the controls' share came out of that and stayed wrong.
it('measures again with the new width once the window has resized', () => {
const before = window.innerWidth;
const fake = overlayAt(0, before - 137);
render(<WindowControlsHost />);
expect(rightInset()).toBe('137px');
act(() => {
(fake as unknown as { getTitlebarAreaRect: () => object }).getTitlebarAreaRect = () => ({ x: 0, y: 0, width: 800 - 137, height: 56 });
fake.dispatchEvent(new Event('geometrychange'));
});
act(() => {
Object.defineProperty(window, 'innerWidth', { value: 800, configurable: true, writable: true });
window.dispatchEvent(new Event('resize'));
});
try {
expect(rightInset()).toBe('137px');
} finally {
Object.defineProperty(window, 'innerWidth', { value: before, configurable: true, writable: true });
}
});
it('follows geometrychange, and takes everything back when the overlay goes', () => {
const fake = overlayAt(0, window.innerWidth - 138);
render(<WindowControlsHost />);
@@ -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);
+7 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge, DesktopOsCommand } from '../types';
import type { NotificationActionRequest, NotificationActionStatus } from '@/lib/pwa-notify/page-actions';
@@ -44,6 +44,12 @@ async function run(command: DesktopOsCommand) {
const text = (value: string) => new TextEncoder().encode(value);
// The handler pulls in much of the app the first time; load it once here, with room to spare,
// so the first test isn't the one that times out on a busy machine.
beforeAll(async () => {
await import('../os-commands');
}, 60_000);
beforeEach(async () => {
vi.clearAllMocks();
authenticated = true;

Some files were not shown because too many files have changed in this diff Show More