mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/desktop-offline' into feat/do-ux-p2-pim-fix
This commit is contained in:
+4
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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')}
|
||||
|
||||
@@ -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"><{sender.email}></span>
|
||||
)}
|
||||
{inConversation && sender?.email && sender?.name && (
|
||||
<span className="text-xs text-muted-foreground truncate min-w-0"><<AddressText name={sender.name} email={sender.email} onViewContact={handleViewContactSidebar} />></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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(); }}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })}</>;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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] : []}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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(() => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 / /');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"}');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user