diff --git a/components/__tests__/keyboard-shortcuts-sections.test.tsx b/components/__tests__/keyboard-shortcuts-sections.test.tsx index 635511557..27f4c9ba4 100644 --- a/components/__tests__/keyboard-shortcuts-sections.test.tsx +++ b/components/__tests__/keyboard-shortcuts-sections.test.tsx @@ -37,7 +37,9 @@ describe('keyboard shortcuts help per app', () => { nav.pathname = '/'; render( {}} />); expect(screen.getAllByText('or').length).toBeGreaterThan(0); - expect(screen.getAllByText('Ctrl + K').length).toBe(1); + // The command palette, and Insert link in the composer. + expect(screen.getAllByText('Ctrl + K').length).toBe(2); + expect(screen.queryByText('⌘ + K')).toBeNull(); }); }); diff --git a/components/contacts/__tests__/directory-picker-dialog.test.tsx b/components/contacts/__tests__/directory-picker-dialog.test.tsx index 192f8dc4b..8f41baf9d 100644 --- a/components/contacts/__tests__/directory-picker-dialog.test.tsx +++ b/components/contacts/__tests__/directory-picker-dialog.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { DirectoryPickerDialog } from '../directory-picker-dialog'; import { useContactStore, type DirectoryEntry, type DirectoryStatus } from '@/stores/contact-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { ContactCard } from '@/lib/jmap/types'; // next-intl is mocked globally in vitest.setup (t returns the key). @@ -25,7 +26,11 @@ function rows() { } describe('DirectoryPickerDialog', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + // No contacts of the user's own: the picker opens on the directory. + useContactStore.setState({ contacts: [] }); + }); it('lists the directory before any search, one page at a time', () => { setDirectory(people(60), 'ready'); @@ -109,3 +114,69 @@ describe('DirectoryPickerDialog', () => { expect(screen.queryByText('refresh_failed')).not.toBeInTheDocument(); }); }); + +// compose-17: the picker listed only the server's directory, not the +// contacts the To field's suggestions offer. +describe('DirectoryPickerDialog with contacts', () => { + function contact(id: string, name: string, ...emails: string[]): ContactCard { + return { + id, addressBookIds: { book: true }, + name: { full: name }, + emails: Object.fromEntries(emails.map((address, i) => [`e${i}`, { address }])), + } as unknown as ContactCard; + } + + beforeEach(() => { + vi.clearAllMocks(); + setDirectory([{ name: 'Server Account', email: 'server@example.org' }], 'ready'); + useContactStore.setState({ + contacts: [ + contact('c1', 'Zoe Fischer', 'zoe@example.com', 'zoe.f@work.example'), + contact('c2', 'Anna Becker', 'anna@example.com'), + { id: 'g1', kind: 'group', name: { full: 'Team' } } as unknown as ContactCard, + ], + }); + }); + + it('opens on the contacts, one row per address, without groups', () => { + render(); + + expect(screen.getByRole('tab', { name: 'tab_contacts' })).toHaveAttribute('aria-selected', 'true'); + expect(rows().map((r) => r.textContent)).toEqual([ + expect.stringContaining('anna@example.com'), + expect.stringContaining('zoe.f@work.example'), + expect.stringContaining('zoe@example.com'), + ]); + expect(screen.queryByText('Server Account')).not.toBeInTheDocument(); + }); + + it('searches the contacts', () => { + render(); + fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'anna' } }); + expect(rows()).toHaveLength(1); + fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'nobody' } }); + expect(screen.getByText('contacts_no_match')).toBeInTheDocument(); + }); + + it('adds what was checked on both tabs', () => { + const onAdd = vi.fn(); + render(); + fireEvent.click(rows()[0]); + fireEvent.click(screen.getByRole('tab', { name: 'tab_directory' })); + fireEvent.click(rows()[0]); + fireEvent.click(screen.getByText('add_to')); + + expect(onAdd).toHaveBeenCalledWith('to', [ + expect.objectContaining({ name: 'Anna Becker', email: 'anna@example.com' }), + expect.objectContaining({ email: 'server@example.org' }), + ]); + }); + + it('shows the contacts alone where the server has no directory', () => { + const noDirectory = { supportsPrincipals: () => false } as unknown as IJMAPClient; + render(); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + expect(rows()).toHaveLength(3); + }); +}); + diff --git a/components/contacts/directory-browser.tsx b/components/contacts/directory-browser.tsx index 5f5f89d8b..1998b702b 100644 --- a/components/contacts/directory-browser.tsx +++ b/components/contacts/directory-browser.tsx @@ -51,6 +51,13 @@ interface DirectoryBrowserProps { /** Set while the list is being printed. */ printing?: boolean; className?: string; + /** + * Browse these instead of the server's directory (the recipient picker's + * Contacts tab): nothing is fetched and there is nothing to refresh. + */ + entries?: DirectoryEntry[]; + /** With `entries`: the list's name and what an empty list or search says. */ + labels?: { list: string; emptyTitle: string; emptyHint?: string; noMatch: string }; } /** @@ -69,13 +76,17 @@ export function DirectoryBrowser({ onPrint, printing = false, className, + entries: ownEntries, + labels, }: DirectoryBrowserProps) { const t = useTranslations("contacts.directory"); const tContacts = useTranslations("contacts"); const tCommon = useTranslations("common"); const locale = useLocale(); - const entries = useContactStore((s) => s.directoryPrincipals); - const status = useContactStore((s) => s.directoryStatus); + const directoryEntries = useContactStore((s) => s.directoryPrincipals); + const directoryStatus = useContactStore((s) => s.directoryStatus); + const entries = ownEntries ?? directoryEntries; + const status = ownEntries ? "ready" : directoryStatus; const fetchDirectory = useContactStore((s) => s.fetchDirectory); const [query, setQuery] = useState(""); const [page, setPage] = useState(0); @@ -83,8 +94,8 @@ export function DirectoryBrowser({ // The directory loads at sign-in; fetch here only if that never ran. useEffect(() => { - if (client && status === "idle") void fetchDirectory(client); - }, [client, status, fetchDirectory]); + if (!ownEntries && client && status === "idle") void fetchDirectory(client); + }, [ownEntries, client, status, fetchDirectory]); useEffect(() => { if (autoFocusSearch) searchRef.current?.focus(); @@ -117,7 +128,9 @@ export function DirectoryBrowser({ }; let placeholder: React.ReactNode = null; - if (entries.length === 0) { + if (entries.length === 0 && ownEntries) { + placeholder = } title={labels?.emptyTitle ?? t("empty_title")} hint={labels?.emptyHint} />; + } else if (entries.length === 0) { if (loading) { placeholder = ( } title={t("loading")} /> @@ -132,7 +145,7 @@ export function DirectoryBrowser({ placeholder = } title={t("empty_title")} hint={t("empty_hint")} />; } } else if (filtered.length === 0) { - placeholder = } title={t("no_match")} />; + placeholder = } title={labels?.noMatch ?? t("no_match")} />; } return ( @@ -163,13 +176,15 @@ export function DirectoryBrowser({ onClear={() => { setQuery(""); setPage(0); }} clearLabel={tContacts("clear_search")} /> - - - + {!ownEntries && ( + + + + )} {onPrint && ( @@ -186,7 +201,7 @@ export function DirectoryBrowser({ )} -
+
{placeholder ?? pageEntries.map((entry) => { const key = directoryKey(entry); const checked = selected.has(key); diff --git a/components/contacts/directory-picker-dialog.tsx b/components/contacts/directory-picker-dialog.tsx index 493a84e3c..ca031d91c 100644 --- a/components/contacts/directory-picker-dialog.tsx +++ b/components/contacts/directory-picker-dialog.tsx @@ -1,36 +1,94 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { X } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { cn } from "@/lib/utils"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; -import { useContactStore, type DirectoryEntry } from "@/stores/contact-store"; +import type { ContactCard } from "@/lib/jmap/types"; +import { useContactStore, getContactDisplayName, type DirectoryEntry } from "@/stores/contact-store"; import { DirectoryBrowser, directoryKey } from "./directory-browser"; export type RecipientField = "to" | "cc" | "bcc"; +type PickerTab = "contacts" | "directory"; + interface DirectoryPickerDialogProps { client: IJMAPClient | null; onClose: () => void; - /** Called with the checked entries in directory order; the dialog closes afterwards. */ + /** Called with the checked entries in list order; the dialog closes afterwards. */ onAdd: (field: RecipientField, entries: DirectoryEntry[]) => void; } -/** The composer's "Select from directory" dialog. */ +/** Every address of the user's own contacts, one row each; groups are left out. */ +export function contactEntries(contacts: ContactCard[]): DirectoryEntry[] { + const seen = new Set(); + const entries: DirectoryEntry[] = []; + for (const contact of contacts) { + if (contact.kind === "group") continue; + const name = getContactDisplayName(contact); + for (const email of Object.values(contact.emails ?? {})) { + const address = email?.address?.trim(); + if (!address) continue; + const entry = { id: `${contact.id}:${address}`, name: name || address, email: address }; + if (seen.has(directoryKey(entry))) continue; + seen.add(directoryKey(entry)); + entries.push(entry); + } + } + return entries; +} + +/** + * The composer's recipient picker: the user's own contacts, and the server's + * directory (other accounts) on a tab of its own where the server has one. + * It used to list only the directory, without the contacts autocomplete offers. + */ export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPickerDialogProps) { const t = useTranslations("contacts.directory"); + const tSend = useTranslations("compose_send"); const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true }); + const contacts = useContactStore((s) => s.contacts); + const ownEntries = useMemo(() => contactEntries(contacts ?? []), [contacts]); + const hasDirectory = !!client?.supportsPrincipals?.(); + // Contacts first; straight to the directory for someone who has none. + const [tab, setTab] = useState(() => (ownEntries.length === 0 && hasDirectory ? "directory" : "contacts")); const [selected, setSelected] = useState>(() => new Set()); const add = (field: RecipientField) => { - const entries = useContactStore.getState().directoryPrincipals.filter((e) => selected.has(directoryKey(e))); + // Checked on either tab, each address once. + const pool = [...ownEntries, ...useContactStore.getState().directoryPrincipals]; + const seen = new Set(); + const entries = pool.filter((e) => { + const key = directoryKey(e); + if (!selected.has(key) || seen.has(key)) return false; + seen.add(key); + return true; + }); if (entries.length === 0) return; onAdd(field, entries); onClose(); }; + const tabButton = (value: PickerTab, label: string) => ( + + ); + return (

- {t("picker_title")} + {tSend("picker_title")}

- + {hasDirectory && ( +
+ {tabButton("contacts", tSend("tab_contacts"))} + {tabButton("directory", tSend("tab_directory"))} +
+ )} + +
+ +
diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx index bdec82551..f71f24a0d 100644 --- a/components/email/__tests__/composer-a11y.test.tsx +++ b/components/email/__tests__/composer-a11y.test.tsx @@ -307,6 +307,46 @@ describe('recipient fields (SR-06, CV-02)', () => { } }); + // compose-6: Backspace never leaves half an address behind. + it('selects the last chip on the first Backspace and removes it whole on the second', () => { + render(, Zoe Fischer ' }} />); + const input = screen.getByRole('combobox', { name: 'to:' }) as HTMLInputElement; + input.focus(); + fireEvent.keyDown(input, { key: 'Backspace' }); + let chips = within(screen.getByRole('list', { name: 'to:' })).getAllByRole('listitem'); + expect(chips).toHaveLength(2); + expect(document.activeElement).toBe(chips[1]); + expect(input.value).toBe(''); + + fireEvent.keyDown(chips[1], { key: 'Backspace' }); + chips = within(screen.getByRole('list', { name: 'to:' })).getAllByRole('listitem'); + expect(chips).toHaveLength(1); + expect(chips[0]).toHaveTextContent('Anna Berg'); + expect(input.value).toBe(''); + expect(document.activeElement).toBe(input); + + // Three presses take out two recipients, never part of one. + fireEvent.keyDown(input, { key: 'Backspace' }); + fireEvent.keyDown(document.activeElement!, { key: 'Backspace' }); + expect(screen.queryAllByRole('listitem')).toHaveLength(0); + expect(input.value).toBe(''); + }); + + it('lets go of a selected chip on Escape and keeps the composer open', () => { + const onClose = vi.fn(); + render(); + const input = screen.getByRole('combobox', { name: 'to:' }) as HTMLInputElement; + input.focus(); + fireEvent.keyDown(input, { key: 'Backspace' }); + const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem'); + expect(document.activeElement).toBe(chip); + fireEvent.keyDown(chip, { key: 'Escape' }); + expect(document.activeElement).toBe(input); + expect(screen.getAllByRole('listitem')).toHaveLength(1); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + it('announces a recipient added by typing', () => { render(); expect(liveRegion()).toHaveTextContent(''); @@ -370,17 +410,17 @@ describe('message body (SR-07)', () => { expect(screen.getByLabelText('body_label').tagName).toBe('TEXTAREA'); }); - it('marks an empty body invalid and links the error text', async () => { - render(); + it('asks before sending an empty body instead of marking it invalid', async () => { + const onSend = vi.fn(); + render(); const editor = screen.getByTestId('rich-text-editor'); - expect(editor).not.toHaveAttribute('data-invalid'); fireEvent.keyDown(screen.getByTestId('composer-subject'), { key: 'Enter', ctrlKey: true }); - await act(async () => {}); - expect(editor).toHaveAttribute('data-invalid', 'true'); - const error = document.getElementById(editor.getAttribute('data-describedby')!); - expect(error).toHaveTextContent('validation.body_required'); + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('empty_text_title'); + expect(editor).not.toHaveAttribute('data-invalid'); + expect(onSend).not.toHaveBeenCalled(); }); }); diff --git a/components/email/__tests__/composer-account-switch.test.tsx b/components/email/__tests__/composer-account-switch.test.tsx index a5dfc270e..4ecb6fb34 100644 --- a/components/email/__tests__/composer-account-switch.test.tsx +++ b/components/email/__tests__/composer-account-switch.test.tsx @@ -233,9 +233,8 @@ describe('composer across an account switch', () => { useAuthStore.setState({ client: clientB as never, activeAccountId: 'acct-B' as never }); view.rerender(); + // Closing saves the draft; no question unless the save fails. act(() => requestCloseRef.current!(vi.fn())); - const dialog = await screen.findByRole('alertdialog'); - fireEvent.click(within(dialog).getByText('save')); await waitFor(() => expect(clientA.createDraft).toHaveBeenCalled()); expect(clientB.createDraft).not.toHaveBeenCalled(); diff --git a/components/email/__tests__/composer-attachment-list-scroll.test.tsx b/components/email/__tests__/composer-attachment-list-scroll.test.tsx index af1d5673f..04bc59023 100644 --- a/components/email/__tests__/composer-attachment-list-scroll.test.tsx +++ b/components/email/__tests__/composer-attachment-list-scroll.test.tsx @@ -218,21 +218,20 @@ const DRAFT = { attachments: ATTACHMENTS, }; -/** The wrapping flex row that holds every chip and the expand button. */ +/** The wrapping flex row that holds every chip. */ function chipRow() { - return screen.getByText('invoice-0.pdf').closest('[class*="flex-wrap"]') as HTMLElement; + return screen.getByTestId('composer-attachments'); } /** * The attachment row is a shrink-0 sibling of the body's scroll container, * so whatever height it takes is taken from the composer and from nothing - * else. Collapsed that is three chips. Expanded it is one row per two - * attachments, and with a few dozen files it grew past the bottom of the - * composer: the send toolbar was pushed off-screen and no ancestor - * scrolled, so neither the rest of the list nor Send could be reached. - * Expanding therefore has to bound the row and scroll it itself. + * else. With a few dozen files it grew past the bottom of the composer: the + * send toolbar was pushed off-screen and no ancestor scrolled. It used to + * show three chips and "+N", which hid the fourth file's upload. Now every + * chip shows, wrapping, and the row is bounded and scrolls itself. */ -describe('expanded attachment list', () => { +describe('attachment list', () => { beforeEach(() => { observers.length = 0; vi.stubGlobal('ResizeObserver', ResizeObserverStub); @@ -242,39 +241,27 @@ describe('expanded attachment list', () => { vi.unstubAllGlobals(); }); - it('does not scroll while collapsed', () => { + it('shows every attachment, with no counter to expand', () => { render(); - expect(chipRow().className).not.toContain('overflow-y-auto'); + expect(screen.getByText('invoice-3.pdf')).toBeInTheDocument(); + expect(screen.getByText('invoice-11.pdf')).toBeInTheDocument(); + expect(screen.queryByText(`+${ATTACHMENTS.length - 3}`)).toBeNull(); }); - it('shows only the first three attachments and a counter for the rest', () => { + it('caps its height and scrolls itself', () => { render(); - expect(screen.getByText('invoice-2.pdf')).toBeInTheDocument(); - expect(screen.queryByText('invoice-3.pdf')).not.toBeInTheDocument(); - expect(screen.getByText(`+${ATTACHMENTS.length - 3}`)).toBeInTheDocument(); - }); - - it('caps its height and scrolls once expanded', () => { - render(); - - fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`)); - const row = chipRow(); - expect(screen.getByText('invoice-11.pdf')).toBeInTheDocument(); expect(row.className).toContain('overflow-y-auto'); // Bounded, or the row grows with the attachment count again and takes // the send toolbar off the bottom of the composer with it. expect(row.className).toContain('max-h-['); }); - it('stops scrolling again when collapsed back', () => { + it('says how many files and how much they weigh', () => { render(); - fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`)); - fireEvent.click(screen.getByText('show_less')); - - expect(chipRow().className).not.toContain('overflow-y-auto'); + expect(screen.getByTestId('composer-attachments-summary')).toHaveTextContent('attachments_summary'); }); }); diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index fda2b7909..d6f877d86 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -135,6 +135,9 @@ vi.mock('@/stores/toast-store', () => ({ toast: { info: () => {}, error: () => {}, success: () => {} }, })); +const draftToast = vi.hoisted(() => ({ showDraftSavedToast: vi.fn() })); +vi.mock('@/lib/compose/draft-close-toast', () => draftToast); + vi.mock('@/lib/plugin-hooks', () => ({ identityHooks: { onSignatureRender: { size: 0, transform: async (value: unknown) => value }, @@ -164,6 +167,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ vi.mock('@/lib/email-sanitization', () => ({ sanitizeSignatureHtml: (v: string) => v, sanitizeEmailHtml: (v: string) => v, + escapeHtml: (v: string) => v.replace(/&/g, '&').replace(//g, '>'), parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), })); @@ -213,8 +217,9 @@ describe('a message row dragged over the composer', () => { /** * A host that replaces the compose session - a mailto: click, say - must not * drop text the user has not sent. It hands a follow-up to requestCloseRef and - * the composer runs it only once it has really closed, so "Save" and "Discard" - * continue into the new session while "Cancel" leaves the draft standing. + * the composer runs it only once it has really closed. Closing saves the draft + * without asking; only a failed save asks, and there "Discard" continues into + * the new session while "Cancel" leaves the draft standing. */ const DIRTY_DRAFT = { @@ -253,13 +258,40 @@ function renderWithCloseRef(props: Record = {}) { beforeEach(resetAnnouncerForTests); describe('composer close guard with a follow-up', () => { - it('asks before replacing an unsaved draft instead of dropping it', async () => { - const { requestCloseRef, afterClose } = renderWithCloseRef(); + const savingClient = (createDraft = vi.fn().mockResolvedValue('draft-9')) => { + useAuthStore.setState({ + client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, + }); + return createDraft; + }; + afterEach(() => { + useAuthStore.setState({ client: null }); + }); + + it('saves the draft and runs the follow-up, without asking', async () => { + const createDraft = savingClient(); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); act(() => requestCloseRef.current!(afterClose)); - expect(await screen.findByText('close_draft_title')).toBeInTheDocument(); + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + expect(createDraft).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('asks only when the save fails, and keeps the text', async () => { + // No connection to save through: the save fails. + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + + act(() => requestCloseRef.current!(afterClose)); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('save_failed_close'); + expect(within(dialog).getByRole('button', { name: /try_again/ })).toBeInTheDocument(); expect(afterClose).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue('Half-written more')).toBeInTheDocument(); }); describe('in the desktop app', () => { @@ -273,12 +305,12 @@ describe('composer close guard with a follow-up', () => { return confirm; }; - it('asks through the OS, and shows no dialog of its own', async () => { + it('asks through the OS when the save fails, and shows no dialog of its own', async () => { const confirm = nativeBridge(2); const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); act(() => requestCloseRef.current!(afterClose)); await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); - expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]); + expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', detail: 'save_failed_close', buttons: ['try_again', 'discard', 'cancel'], defaultId: 0 }]); expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); // The last button is Cancel: nothing happens. await act(async () => {}); @@ -293,9 +325,18 @@ describe('composer close guard with a follow-up', () => { await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); }); + + it('saves without asking when the save works', async () => { + const confirm = nativeBridge(2); + savingClient(); + const { requestCloseRef, afterClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + expect(confirm).not.toHaveBeenCalled(); + }); }); - it('runs the follow-up after discarding', async () => { + it('runs the follow-up after discarding a draft that could not be saved', async () => { const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); act(() => requestCloseRef.current!(afterClose)); @@ -327,44 +368,77 @@ describe('composer close guard with a follow-up', () => { expect(afterClose).not.toHaveBeenCalled(); }); - it('closes without asking once autosave has stored every change', async () => { - const createDraft = vi.fn().mockResolvedValue('draft-9'); - useAuthStore.setState({ - client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, - }); - try { - const { requestCloseRef, afterClose } = renderWithCloseRef(); - await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 }); - await act(async () => {}); + it('closes without saving again once autosave has stored every change', async () => { + const createDraft = savingClient(); + const { requestCloseRef, afterClose } = renderWithCloseRef(); + await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 }); + await act(async () => {}); - act(() => requestCloseRef.current!(afterClose)); + act(() => requestCloseRef.current!(afterClose)); - await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); - expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); - } finally { - useAuthStore.setState({ client: null }); - } + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + expect(createDraft).toHaveBeenCalledTimes(1); + expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); }); - it('still asks when there are edits since the last autosave', async () => { - const createDraft = vi.fn().mockResolvedValue('draft-9'); - useAuthStore.setState({ - client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, - }); - try { - const { requestCloseRef, afterClose } = renderWithCloseRef(); - await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 }); - fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } }); + it('saves the edits made since the last autosave when it closes', async () => { + const createDraft = savingClient(); + const { requestCloseRef, afterClose } = renderWithCloseRef(); + await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 }); + fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } }); - act(() => requestCloseRef.current!(afterClose)); + act(() => requestCloseRef.current!(afterClose)); - expect(await screen.findByText('close_draft_title')).toBeInTheDocument(); - } finally { - useAuthStore.setState({ client: null }); - } + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + expect(createDraft).toHaveBeenCalledTimes(2); + expect(createDraft.mock.calls[1][1]).toBe('Half-written more and more'); + expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); + }); + + it('says the draft was kept, with a Discard that deletes it through the host', async () => { + savingClient(); + draftToast.showDraftSavedToast.mockClear(); + const onDiscardDraft = vi.fn(); + const { requestCloseRef, afterClose } = renderWithCloseRef({ onDiscardDraft }); + + act(() => requestCloseRef.current!(afterClose)); + + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + expect(draftToast.showDraftSavedToast).toHaveBeenCalledTimes(1); + const [discard, labels] = draftToast.showDraftSavedToast.mock.calls[0]; + expect(labels).toMatchObject({ saved: 'draft_saved', discard: 'discard' }); + discard(); + expect(onDiscardDraft).toHaveBeenCalledWith('draft-9', expect.anything()); + }); + + // compose-19: the keys people bring from other mail apps. + it('saves the draft on Ctrl+S, and keeps the browser from saving the page', async () => { + const createDraft = savingClient(); + renderWithCloseRef(); + const subject = screen.getByDisplayValue('Half-written more'); + expect(fireEvent.keyDown(subject, { key: 's', ctrlKey: true })).toBe(false); + await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1)); + + // Nothing new to save: it still says so. + await waitFor(() => expect(screen.getByTestId('composer-save-status')).toHaveAttribute('data-status', 'idle'), { timeout: 4000 }); + fireEvent.keyDown(subject, { key: 's', ctrlKey: true }); + await waitFor(() => expect(screen.getByTestId('composer-save-status')).toHaveAttribute('data-status', 'saved')); + expect(createDraft).toHaveBeenCalledTimes(1); + }); + + it('shows Cc on Ctrl+Shift+C and Bcc on Ctrl+Shift+B, and puts the cursor there', async () => { + renderWithCloseRef(); + // A new message puts the cursor in To shortly after it opens; let that pass. + await act(async () => { await new Promise((r) => setTimeout(r, 250)); }); + const subject = screen.getByDisplayValue('Half-written more'); + expect(fireEvent.keyDown(subject, { key: 'C', ctrlKey: true, shiftKey: true })).toBe(false); + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'cc_label' }))); + expect(fireEvent.keyDown(subject, { key: 'B', ctrlKey: true, shiftKey: true })).toBe(false); + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'bcc_label' }))); }); it('closes straight through when there is nothing to lose', async () => { + draftToast.showDraftSavedToast.mockClear(); const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>; const afterClose = vi.fn(); render(); @@ -373,67 +447,37 @@ describe('composer close guard with a follow-up', () => { await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); + expect(draftToast.showDraftSavedToast).not.toHaveBeenCalled(); }); - it('runs the follow-up after saving the draft', async () => { - const createDraft = vi.fn().mockResolvedValue('draft-9'); - useAuthStore.setState({ - client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, - }); - try { - const { requestCloseRef, afterClose } = renderWithCloseRef(); - act(() => requestCloseRef.current!(afterClose)); - const dialog = await screen.findByRole('alertdialog'); - fireEvent.click(within(dialog).getByText('save')); - - await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); - expect(createDraft).toHaveBeenCalledTimes(1); - } finally { - useAuthStore.setState({ client: null }); - } - }); - - it('tells screen readers the draft was saved when the user saves, not on autosave', async () => { - const createDraft = vi.fn().mockResolvedValue('draft-9'); - useAuthStore.setState({ - client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, - }); + it('tells screen readers the draft was saved when the user closes, not on autosave', async () => { + const createDraft = savingClient(); const polite = () => document.getElementById(LIVE_REGION_IDS.polite); - try { - const { requestCloseRef, afterClose } = renderWithCloseRef(); - await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 }); - await new Promise((r) => setTimeout(r, 200)); - expect(polite()?.textContent ?? '').not.toContain('draft_saved'); + const { requestCloseRef, afterClose } = renderWithCloseRef(); + await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 }); + await new Promise((r) => setTimeout(r, 200)); + expect(polite()?.textContent ?? '').not.toContain('draft_saved'); - fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } }); - act(() => requestCloseRef.current!(afterClose)); - fireEvent.click(within(await screen.findByRole('alertdialog')).getByText('save')); + fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } }); + act(() => requestCloseRef.current!(afterClose)); - await waitFor(() => expect(polite()?.textContent).toContain('draft_saved')); - } finally { - useAuthStore.setState({ client: null }); - } + await waitFor(() => expect(polite()?.textContent).toContain('draft_saved')); }); - it('keeps the composer open and drops the follow-up when the save fails', async () => { + it('keeps the composer open, says why and drops the follow-up when the save fails', async () => { // A refused save keeps the live composer recoverable. Replacing it via a // close follow-up would bury the unsaved text. Navigation can still stash // that text through the existing unmount path. - const createDraft = vi.fn().mockRejectedValue(new Error('server refused')); - useAuthStore.setState({ - client: { createDraft, hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, - }); + const createDraft = savingClient(vi.fn().mockRejectedValue(new Error('server refused'))); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); try { const onSaveState = vi.fn(); const { requestCloseRef, afterClose, onClose, unmount } = renderWithCloseRef({ onSaveState }); act(() => requestCloseRef.current!(afterClose)); - const dialog = await screen.findByRole('alertdialog'); - fireEvent.click(within(dialog).getByText('save')); await waitFor(() => expect(createDraft).toHaveBeenCalled()); - await act(async () => {}); + expect(await screen.findByRole('alertdialog')).toHaveTextContent('save_failed_close'); expect(onClose).not.toHaveBeenCalled(); expect(screen.getByDisplayValue('Half-written more')).toBeInTheDocument(); expect(afterClose).not.toHaveBeenCalled(); @@ -444,7 +488,6 @@ describe('composer close guard with a follow-up', () => { expect.objectContaining({ subject: 'Half-written more' }), ); } finally { - useAuthStore.setState({ client: null }); consoleError.mockRestore(); } }); diff --git a/components/email/__tests__/composer-discard-uploads.test.tsx b/components/email/__tests__/composer-discard-uploads.test.tsx index 2dd6cfb40..36cfc0199 100644 --- a/components/email/__tests__/composer-discard-uploads.test.tsx +++ b/components/email/__tests__/composer-discard-uploads.test.tsx @@ -107,7 +107,7 @@ afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.clearAllTimers(); vi.useRealTimers(); }); -type Control = 'close' | 'toolbar'; +type Control = 'toolbar'; async function openComposer(strict = false) { const closed = vi.fn(); let reopen!: () => void; @@ -135,7 +135,7 @@ async function openComposer(strict = false) { fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files } }); await advance(); }, dialog: (control: Control) => { - fireEvent.click(screen.getByTestId(control === 'close' ? 'composer-close' : 'composer-discard')); + fireEvent.click(screen.getByTestId(control === 'toolbar' ? 'composer-discard' : 'composer-close')); return within(screen.getByRole('alertdialog')); }, reopen: async () => { act(() => reopen()); await advance(); }, @@ -148,7 +148,9 @@ function expectNewComposer() { expect(screen.queryByRole('progressbar')).toBeNull(); expect(screen.queryByText('A.txt')).toBeNull(); expect(screen.queryByText('C.txt')).toBeNull(); } -const modes = (['close', 'toolbar'] as const).flatMap(control => [false, true].map(strict => ({ control, strict }))); +// The X no longer asks Save or Discard: it saves (composer-save-pending-attachments). +// Discard is the toolbar's. +const modes = (['toolbar'] as const).flatMap(control => [false, true].map(strict => ({ control, strict }))); describe('explicit Discard with pending attachment uploads', () => { describe.each(modes)('$control control, Strict Mode=$strict', ({ control, strict }) => { @@ -232,7 +234,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('close').getByRole('button', { name: /^Discard$/ })); await advance(); + fireEvent.click(c.dialog('toolbar').getByRole('button', { name: /^Discard$/ })); 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')); diff --git a/components/email/__tests__/composer-draft-attachments.test.tsx b/components/email/__tests__/composer-draft-attachments.test.tsx index 7fd66bf6e..b3667e961 100644 --- a/components/email/__tests__/composer-draft-attachments.test.tsx +++ b/components/email/__tests__/composer-draft-attachments.test.tsx @@ -376,7 +376,8 @@ describe('a re-opened draft that lives in a group account', () => { render(); fireEvent.change(screen.getByDisplayValue('Quarterly numbers'), { target: { value: 'Quarterly numbers v2' } }); - act(() => requestCloseRef.current!()); + // Closing keeps the draft now; Discard is the way to delete it. + fireEvent.click(screen.getAllByTestId('composer-discard')[0]); fireEvent.click(within(screen.getByRole('alertdialog')).getByText('discard')); expect(onDiscardDraft).toHaveBeenCalledWith('eaaaaab', { accountId: 'c', localAccountId: undefined }); @@ -393,7 +394,8 @@ describe('a re-opened draft that lives in a group account', () => { expect(createDraft.mock.calls[0][13]?.previousDraftAccountId).toBeUndefined(); fireEvent.change(screen.getByDisplayValue('Quarterly numbers v2'), { target: { value: 'Quarterly numbers v3' } }); - act(() => requestCloseRef.current!()); + // Closing keeps the draft now; Discard is the way to delete it. + fireEvent.click(screen.getAllByTestId('composer-discard')[0]); fireEvent.click(within(screen.getByRole('alertdialog')).getByText('discard')); expect(onDiscardDraft).toHaveBeenCalledWith('draft-v2', { accountId: undefined, localAccountId: undefined }); }); diff --git a/components/email/__tests__/composer-encryption-options.test.tsx b/components/email/__tests__/composer-encryption-options.test.tsx new file mode 100644 index 000000000..95f74169d --- /dev/null +++ b/components/email/__tests__/composer-encryption-options.test.tsx @@ -0,0 +1,280 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { emailHooks } from '@/lib/plugin-hooks'; + +// "Require encrypted delivery" read like encryption but only asks the servers +// for TLS on the way. It is now named for what it does and is an advanced +// option, and without a crypto plugin an "Encrypt…" item says where to get one. + +// ─── Heavy component mocks (mirrors composer-close-guard.test.tsx) ────────── + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => 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 }), +})); + +const push = vi.fn(); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push, back: vi.fn(), replace: vi.fn() }), + usePathname: () => '/en', +})); + +// ─── 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) => 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) => 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) => 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) => 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, + showAdvancedSettings: false, + 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) => 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) => 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) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, 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: { size: 0, 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, + sanitizeSignatureHtmlForDisplay: (v: string) => v, + sanitizePluginBodyHtml: (v: string) => v, + escapeHtml: (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 DRAFT = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Plans', + body: '

Hello

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +const openOptions = () => fireEvent.click(screen.getByTestId('composer-options-menu')); +const setComposeSendHandlers = (size: number) => { + (emailHooks.onComposeSend as unknown as { size: number }).size = size; +}; + +describe('composer encryption options', () => { + beforeEach(() => { + useAuthStore.setState({ + client: { supportsSubmissionExtension: (name: string) => name === 'REQUIRETLS', hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, + }); + usePolicyStore.setState({ demoFeatureOverrides: { pluginsEnabled: true } } as never); + setComposeSendHandlers(0); + }); + afterEach(() => { + useAuthStore.setState({ client: null }); + useSettingsStore.setState({ showAdvancedSettings: false } as never); + usePolicyStore.setState({ demoFeatureOverrides: {} } as never); + vi.clearAllMocks(); + }); + + it('names the TLS option for what it does and keeps it to advanced settings', () => { + const { unmount } = render(); + openOptions(); + expect(screen.queryByTestId('composer-require-tls-toggle')).toBeNull(); + unmount(); + + useSettingsStore.setState({ showAdvancedSettings: true } as never); + render(); + openOptions(); + const item = screen.getByTestId('composer-require-tls-toggle'); + expect(item).toHaveTextContent('require_tls'); + expect(item).toHaveTextContent('require_tls_hint'); + expect(item).not.toHaveTextContent('option_require_tls'); + }); + + it('keeps a ticked TLS option visible so it can be unticked', () => { + render(); + openOptions(); + expect(screen.getByTestId('composer-require-tls-toggle')).toHaveAttribute('aria-checked', 'true'); + }); + + it('offers "Encrypt…" without a crypto plugin, opening the add-ons', () => { + render(); + openOptions(); + const item = screen.getByTestId('composer-encrypt-setup'); + expect(item).toHaveTextContent('encrypt'); + expect(item).toHaveTextContent('encrypt_hint'); + fireEvent.click(item); + expect(push).toHaveBeenCalledWith('/settings/addons'); + }); + + it('leaves "Encrypt…" out once a crypto plugin takes over sending', () => { + setComposeSendHandlers(1); + render(); + openOptions(); + expect(screen.queryByTestId('composer-encrypt-setup')).toBeNull(); + }); + + it('leaves "Encrypt…" out where add-ons are switched off', () => { + usePolicyStore.setState({ demoFeatureOverrides: { pluginsEnabled: false } } as never); + render(); + openOptions(); + expect(screen.queryByTestId('composer-encrypt-setup')).toBeNull(); + }); +}); diff --git a/components/email/__tests__/composer-from-override-envelope.test.tsx b/components/email/__tests__/composer-from-override-envelope.test.tsx index c6d39338e..261eff0af 100644 --- a/components/email/__tests__/composer-from-override-envelope.test.tsx +++ b/components/email/__tests__/composer-from-override-envelope.test.tsx @@ -11,7 +11,10 @@ vi.mock('@/components/email/rich-text-editor', () => ({ })); vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); -vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ + SubAddressHelper: ({ open, hideTrigger }: { open?: boolean; hideTrigger?: boolean }) => + open ?
sub-address popover
: hideTrigger ? 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 })); @@ -268,17 +271,46 @@ describe('composer From override envelope (#1009)', () => { expect(sent.envelopeMailFrom).toBeUndefined(); }); - it('offers no Custom button', () => { + it('offers no Edit sender', () => { render(); - expect(screen.queryByText('from_override.toggle_off')).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'from_edit_sender' })).not.toBeInTheDocument(); }); - // Control: the button is there with the gate on, so the test above is not + // Control: the entry is there with the gate on, so the test above is not // passing on a renamed label. - it('offers the Custom button with the gate on', () => { + it('offers Edit sender with the gate on', () => { setCustomFrom(true); render(); - expect(screen.getByText('from_override.toggle_off')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'from_edit_sender' })).toBeInTheDocument(); }); }); }); + +// compose-20: the rare sender options live at the end of the From list, not +// as buttons on every message. +describe('the From list', () => { + const from = () => screen.getByTestId('composer-from') as HTMLSelectElement; + + it('has no sub-address or Override button in the row', () => { + render(); + expect(screen.queryByText('sub-address button')).not.toBeInTheDocument(); + expect(screen.queryByText('from_override.toggle_off')).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'from_sub_address' })).toBeInTheDocument(); + }); + + it('opens the sub-address popover from the list and keeps the identity', () => { + render(); + const before = from().value; + fireEvent.change(from(), { target: { value: '__sub_address' } }); + expect(screen.getByRole('dialog')).toHaveTextContent('sub-address popover'); + expect(from().value).toBe(before); + }); + + it('turns From into fields to edit from the list, with a way back', () => { + render(); + fireEvent.change(from(), { target: { value: '__edit_sender' } }); + expect(screen.getByLabelText('from_override.email_label')).toHaveValue('me@example.com'); + fireEvent.click(screen.getByText('from_override.toggle_on')); + expect(screen.queryByLabelText('from_override.email_label')).not.toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx index d50d46ad5..ef4e15f1b 100644 --- a/components/email/__tests__/composer-mention.test.tsx +++ b/components/email/__tests__/composer-mention.test.tsx @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import React from 'react'; import type { Editor } from '@tiptap/core'; import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; import { EmailComposer } from '../email-composer'; import { useSettingsStore } from '@/stores/settings-store'; @@ -301,7 +302,7 @@ describe('composer with the @ recipient list', () => { } }); - it('lets Escape close the open list, not the composer; the next Escape asks to close', async () => { + it('lets Escape close the open list, not the composer; the next Escape closes that', async () => { const { editor, onClose } = await renderComposer(); await typeInto(editor, ' @'); await screen.findByRole('listbox', { name: 'mention_recipients' }); @@ -311,8 +312,10 @@ describe('composer with the @ recipient list', () => { expect(screen.queryByRole('alertdialog')).toBeNull(); expect(onClose).not.toHaveBeenCalled(); + // The composer's close runs: with no connection here its save fails, + // which is when it asks. press(editor, 'Escape'); - expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title'); + expect(await screen.findByRole('alertdialog')).toHaveTextContent('save_failed_close'); }); it('keeps Ctrl+Enter as the send shortcut while the list is open', async () => { @@ -357,7 +360,7 @@ describe('composer with the @ recipient list', () => { await waitFor(() => expect(mentionList()).toBeNull()); fireEvent.keyDown(subject, { key: 'Escape' }); - expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title'); + expect(await screen.findByRole('alertdialog')).toHaveTextContent('save_failed_close'); }); it('closes the list on a click elsewhere, but not on a click on the list itself', async () => { @@ -538,12 +541,30 @@ describe('composer with the @ recipient list', () => { expect(onClose).not.toHaveBeenCalled(); }); - it('pulls the last recipient back whole on Backspace, without the key deleting its last character', async () => { + // The first Backspace selects the last recipient, it doesn't turn it into text. + it('selects the last recipient on Backspace, without the key deleting anything', async () => { await renderComposer({ to: 'Sarah Kim ' }); const to = within(screen.getByTestId('composer-to')).getByRole('combobox') as HTMLInputElement; // fireEvent returns false when the handler stopped the key's default action. expect(fireEvent.keyDown(to, { key: 'Backspace' })).toBe(false); - expect(to.value).toBe('Sarah Kim '); + expect(to.value).toBe(''); + expect(document.activeElement).toHaveAttribute('data-recipient-chip'); + }); + + // compose-17: the button was there only with a server directory, so + // contacts alone were never offered. + it('offers the recipient picker for the contacts when the server has no directory', async () => { + const contactState = (useContactStore as unknown as { getState: () => { contacts: unknown[] } }).getState(); + contactState.contacts = [{ id: 'c1', name: { full: 'Zoe Fischer' }, emails: { e: { address: 'zoe@example.com' } } }]; + try { + await renderComposer(); + const button = screen.getByTestId('composer-directory-picker'); + expect(button).toHaveAccessibleName('picker_button'); + fireEvent.click(button); + expect(screen.getByRole('dialog', { name: 'directory' })).toBeInTheDocument(); + } finally { + contactState.contacts = []; + } }); // The directory picker is drawn inside the composer itself, not portaled. diff --git a/components/email/__tests__/composer-offline-send.test.tsx b/components/email/__tests__/composer-offline-send.test.tsx new file mode 100644 index 000000000..5c7b5f425 --- /dev/null +++ b/components/email/__tests__/composer-offline-send.test.tsx @@ -0,0 +1,289 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// Writing offline. The footer says "Offline" next to Send. With "Keep mail on +// this device" on, Send puts the message in the outbox as before. Without it +// nothing can keep the message safely for later: Send says so, keeps the +// text, and points at the setting. It used to fail with "Failed to send". + +const offline = vi.hoisted(() => ({ active: false, allowed: true })); +vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({ + ...(await importOriginal()), + offlineActive: () => offline.active, + offlineAllowed: () => offline.allowed, +})); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }), +})); + +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 }), +})); + +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) => 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) => 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) => 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) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +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: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} }, +})); + +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 })); + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +let online = true; +beforeEach(() => { + online = true; + offline.active = false; + offline.allowed = true; + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online }); +}); +afterEach(() => { + online = true; +}); + +function goOffline() { + online = false; + act(() => { window.dispatchEvent(new Event('offline')); }); +} +function goOnline() { + online = true; + act(() => { window.dispatchEvent(new Event('online')); }); +} + +const footer = () => document.querySelector('[data-composer-footer]')!; + +describe('the composer while offline', () => { + it('says "Offline" next to Send, and stops once the network is back', () => { + render(); + expect(screen.queryByTestId('composer-offline')).toBeNull(); + goOffline(); + const label = screen.getByTestId('composer-offline'); + expect(footer()).toContainElement(label); + expect(label.textContent).toContain('offline'); + expect(label.textContent).toContain('offline_hint'); + goOnline(); + expect(screen.queryByTestId('composer-offline')).toBeNull(); + }); + + it('without offline mode, Send keeps the message and says why, with the way to the setting', async () => { + const onSend = vi.fn(); + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + const notice = await screen.findByTestId('composer-offline-notice'); + expect(notice.textContent).toContain('offline_send_unsaved'); + const link = screen.getByRole('link', { name: 'settings.keep_label' }); + expect(link.getAttribute('href')).toMatch(/\/settings\/backup\/offline$/); + expect(link).toHaveAttribute('target', '_blank'); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByTestId('email-composer')).toBeInTheDocument(); + + goOnline(); + expect(screen.queryByTestId('composer-offline-notice')).toBeNull(); + }); + + it('says the message is in Drafts only when the server has it', async () => { + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + expect((await screen.findByTestId('composer-offline-notice')).textContent).toContain('offline_send_saved'); + }); + + it('Ctrl+Enter takes the same way', async () => { + const onSend = vi.fn(); + render(); + goOffline(); + fireEvent.keyDown(screen.getByTestId('composer-send'), { key: 'Enter', ctrlKey: true }); + await screen.findByTestId('composer-offline-notice'); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('leaves out the link where this device may not keep mail', async () => { + offline.allowed = false; + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + await screen.findByTestId('composer-offline-notice'); + expect(screen.queryByRole('link', { name: 'settings.keep_label' })).toBeNull(); + }); + + it('with offline mode on, Send goes ahead: the outbox keeps it until the network is back', async () => { + offline.active = true; + const onSend = vi.fn(); + render(); + goOffline(); + expect(screen.getByTestId('composer-offline').textContent).toContain('offline_hint_outbox'); + fireEvent.click(screen.getByTestId('composer-send')); + await waitFor(() => expect(onSend).toHaveBeenCalled()); + expect(screen.queryByTestId('composer-offline-notice')).toBeNull(); + }); +}); diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx index 199eee835..2731b6a23 100644 --- a/components/email/__tests__/composer-own-window.test.tsx +++ b/components/email/__tests__/composer-own-window.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextIntlClientProvider } from 'next-intl'; import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer'; @@ -325,6 +325,20 @@ describe('a composer that is all its window shows', () => { } }); + // Escape in a pop-out or quick compose window: the same as anywhere, + // saved and kept, and the main window says so (this one is gone). + it('saves on Escape, closes, and hands "Draft saved" to the main window', async () => { + const onDraftKept = vi.fn(); + const onClose = vi.fn(); + mount({ ownWindow: { onDraftKept }, onClose }); + fireEvent.change(subjectField(), { target: { value: 'Kept on Escape' } }); + fireEvent.keyDown(subjectField(), { key: 'Escape' }); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(creations.map((draft) => draft.subject)).toEqual(['Kept on Escape']); + expect(onDraftKept).toHaveBeenCalledWith('saved-1', expect.objectContaining({ localAccountId: expect.anything() })); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + it('lets an untouched composer go without a word', () => { mount({ ownWindow: {} }); expect(unload().defaultPrevented).toBe(false); @@ -364,3 +378,22 @@ describe('a composer that is all its window shows', () => { expect(creations).toHaveLength(saved); }); }); + +describe('back online', () => { + it('saves what could not be saved while offline, so "saved in Drafts once you are back online" holds', async () => { + let onLine = true; + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine); + rejectSave = true; + mount({}); + onLine = false; + act(() => { window.dispatchEvent(new Event('offline')); }); + fireEvent.change(subjectField(), { target: { value: 'Written on a train' } }); + await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 }); + + rejectSave = false; + onLine = true; + act(() => { window.dispatchEvent(new Event('online')); }); + await waitFor(() => expect(creations).toHaveLength(2)); + expect(creations[1].subject).toBe('Written on a train'); + }); +}); diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 082e40541..817a277ad 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -331,6 +331,21 @@ describe('composer-send-menu', () => { expect(schedule).toHaveTextContent('Ctrl + Shift + Enter'); }); + // 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', () => { + useAuthStore.setState({ + client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never, + }); + render(); + + const arrow = screen.getAllByRole('button', { name: 'schedule_send' })[0]; + expect(arrow).toHaveAttribute('aria-haspopup', 'dialog'); + fireEvent.click(arrow); + + expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull(); + expect(screen.getByRole('dialog')).toHaveTextContent('schedule_send_description'); + }); + it('keeps a plain Send button without plugin entries or scheduled send', () => { render(); expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull(); diff --git a/components/email/__tests__/composer-priority.test.tsx b/components/email/__tests__/composer-priority.test.tsx index c0b6c73fe..8fc867d9d 100644 --- a/components/email/__tests__/composer-priority.test.tsx +++ b/components/email/__tests__/composer-priority.test.tsx @@ -232,9 +232,8 @@ function mockClient() { } async function saveViaCloseDialog(requestCloseRef: React.MutableRefObject<((afterClose?: () => void) => void) | null>) { + // Closing saves the draft; no question unless the save fails. act(() => requestCloseRef.current!(vi.fn())); - const dialog = await screen.findByRole('alertdialog'); - fireEvent.click(within(dialog).getByText('save')); } describe('composer message priority (#970)', () => { diff --git a/components/email/__tests__/composer-reply-draft-threading.test.tsx b/components/email/__tests__/composer-reply-draft-threading.test.tsx index 2e34434c0..17289b674 100644 --- a/components/email/__tests__/composer-reply-draft-threading.test.tsx +++ b/components/email/__tests__/composer-reply-draft-threading.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; @@ -226,9 +226,8 @@ describe('re-opened reply draft', () => { render(); fireEvent.change(screen.getByDisplayValue('Re: Plans'), { target: { value: 'Re: Plans, edited' } }); + // Closing saves the draft; no question unless the save fails. act(() => requestCloseRef.current!(vi.fn())); - const dialog = await screen.findByRole('alertdialog'); - fireEvent.click(within(dialog).getByText('save')); await waitFor(() => expect(createDraft).toHaveBeenCalled()); const args = createDraft.mock.calls[0]; diff --git a/components/email/__tests__/composer-reply-signature.test.tsx b/components/email/__tests__/composer-reply-signature.test.tsx index 2198cec74..7cfc8a9d6 100644 --- a/components/email/__tests__/composer-reply-signature.test.tsx +++ b/components/email/__tests__/composer-reply-signature.test.tsx @@ -379,3 +379,39 @@ describe('reply quote attribution markers', () => { expect(html).toContain('

From: Bob

'); }); }); + +// The forwarded header had From, Date and Subject only; Gmail and Outlook +// also say who the mail went to. +describe('forwarded header recipients', () => { + afterEach(() => { + useSettingsStore.setState({ plainTextMode: false, signaturePosition: 'below_quote' }); + setIdentities([ME]); + }); + const FORWARDED = { + ...REPLY_TO, + to: [{ name: 'Anna Becker', email: 'anna@example.com' }, { email: 'team@example.com' }], + cc: [{ name: 'Carol', email: 'carol@example.com' }], + }; + + it('lists To and Cc in a plain text forward', async () => { + useSettingsStore.setState({ plainTextMode: true }); + const onSend = vi.fn(); + render(); + const sent = await send(onSend); + expect(sent.body).toContain('subject_label: Question\nforward_to_label: Anna Becker , team@example.com\nforward_cc_label: Carol \n\nOriginal text'); + }); + + // escapeHtml is mocked to pass values through in this file. + it('lists To and Cc in an HTML forward', () => { + render(); + const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!; + expect(html).toContain('subject_label: Question
forward_to_label: Anna Becker , team@example.com
forward_cc_label: Carol

'); + }); + + it('leaves out a Cc line when nobody was copied', () => { + render(); + const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!; + expect(html).toContain('forward_to_label: '); + expect(html).not.toContain('forward_cc_label'); + }); +}); diff --git a/components/email/__tests__/composer-save-pending-attachments.test.tsx b/components/email/__tests__/composer-save-pending-attachments.test.tsx index 270839b63..4bb4a0d5b 100644 --- a/components/email/__tests__/composer-save-pending-attachments.test.tsx +++ b/components/email/__tests__/composer-save-pending-attachments.test.tsx @@ -164,9 +164,9 @@ async function openComposer() { add: async (...files: File[]) => { fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files } }); await flush(); }, + // Closing saves the draft without asking (it asks only when that fails). chooseSave: async () => { fireEvent.click(screen.getByTestId('composer-close')); - fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: /^Save$/ })); await flush(); }, reopen: async () => { act(() => reopen()); await flush(); }, @@ -229,7 +229,8 @@ describe('Save and close with attachment uploads', () => { await c.chooseSave(); await expectSaved(['A.txt']); await waitFor(() => expect(c.closed).toHaveBeenCalledOnce()); }); it('can discard during the wait without a later save or close follow-up', async () => { - const c = await openComposer(); await c.add(file()); await c.chooseSave(); c.closeAgain(); + const c = await openComposer(); await c.add(file()); await c.chooseSave(); + fireEvent.click(screen.getAllByTestId('composer-discard')[0]); fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: /^Discard$/ })); await complete(0); await new Promise(resolve => setTimeout(resolve, 200)); expect(creations).toEqual([]); expect(c.closed).toHaveBeenCalledOnce(); diff --git a/components/email/__tests__/composer-send-failed.test.tsx b/components/email/__tests__/composer-send-failed.test.tsx new file mode 100644 index 000000000..aec0f1b79 --- /dev/null +++ b/components/email/__tests__/composer-send-failed.test.tsx @@ -0,0 +1,238 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ──────────────── + +const editorKeyDown = vi.hoisted(() => vi.fn()); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { + 'data-testid': 'rich-text-editor', + onKeyDown: (event: React.KeyboardEvent) => { + if (!event.defaultPrevented) editorKeyDown(); + }, + }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => 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) => 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) => 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) => 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) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +const updateSetting = vi.fn(); + +// compose-24: the host closes the composer as soon as the send starts. A send +// that fails must still hand the message back, or it would vanish. + + +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) => 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) => 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) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, 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 READY = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Quarterly numbers', + body: '

Here they are.

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: 'draft-3', +}; + +const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement; + +describe('a send that fails after the host closed the composer', () => { + it('hands the message back to the host', async () => { + const onSend = vi.fn().mockRejectedValue(new Error('refused')); + const onSendFailed = vi.fn(); + render(); + + fireEvent.click(sendButton()); + + await waitFor(() => expect(onSendFailed).toHaveBeenCalledTimes(1)); + expect(onSendFailed.mock.calls[0][0]).toMatchObject({ + to: 'bob@example.com', + subject: 'Quarterly numbers', + body: '

Here they are.

', + draftId: 'draft-3', + }); + }); + + it('says nothing to the host when the message went out', async () => { + const onSend = vi.fn().mockResolvedValue(undefined); + const onSendFailed = vi.fn(); + render(); + + fireEvent.click(sendButton()); + + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSendFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/__tests__/composer-send-failure.test.tsx b/components/email/__tests__/composer-send-failure.test.tsx index 3fc292ae3..bb4c6111a 100644 --- a/components/email/__tests__/composer-send-failure.test.tsx +++ b/components/email/__tests__/composer-send-failure.test.tsx @@ -252,7 +252,7 @@ describe('composer send failures', () => { // window.confirm, before the message goes out at once. it('asks in a dialog before sending without the delay the account lacks', async () => { const nativeConfirm = vi.spyOn(window, 'confirm'); - useSettingsStore.setState({ sendDelaySeconds: 10 } as never); + useSettingsStore.setState({ sendDelaySeconds: 10, explicitSettings: ['sendDelaySeconds'] } as never); try { const onSend = vi.fn().mockResolvedValue(undefined); render(); @@ -262,6 +262,21 @@ describe('composer send failures', () => { expect(onSend).not.toHaveBeenCalled(); fireEvent.click(within(dialog).getByRole('button', { name: 'send' })); await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + } finally { + useSettingsStore.setState({ sendDelaySeconds: 0, explicitSettings: [] } as never); + } + }); + + // Undo send is on by default: an account that can't delay just sends. + it('sends at once, without asking, when the delay is only the default', async () => { + useSettingsStore.setState({ sendDelaySeconds: 5, explicitSettings: [] } as never); + try { + const onSend = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSend.mock.calls[0][0].delayedUntil).toBeUndefined(); + expect(screen.queryByRole('alertdialog', { name: 'send_delay_unsupported_title' })).toBeNull(); } finally { useSettingsStore.setState({ sendDelaySeconds: 0 } as never); } diff --git a/components/email/__tests__/composer-sign-out-flush.test.tsx b/components/email/__tests__/composer-sign-out-flush.test.tsx index 8fac873cc..37fa64755 100644 --- a/components/email/__tests__/composer-sign-out-flush.test.tsx +++ b/components/email/__tests__/composer-sign-out-flush.test.tsx @@ -246,15 +246,18 @@ describe('composer flush before an automatic sign-out', () => { expect(createDraft.mock.calls[0][1]).toBe('Half-written'); }); - it('asks before closing a composer opened with stashed edits', async () => { + // Closing keeps the draft without asking: the stashed edits are saved first. + it('saves a composer opened with stashed edits before it closes', async () => { const onClose = vi.fn(); const requestCloseRef: { current: ((afterClose?: () => void) => void) | null } = { current: null }; render(); act(() => requestCloseRef.current?.()); - expect(await screen.findByRole('alertdialog')).toBeTruthy(); - expect(onClose).not.toHaveBeenCalled(); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(createDraft).toHaveBeenCalledTimes(1); + expect(createDraft.mock.calls[0][1]).toBe('Half-written'); + expect(screen.queryByRole('alertdialog')).toBeNull(); }); it('closes a composer opened with text the server already has without asking', () => { diff --git a/components/email/__tests__/composer-template-reply.test.tsx b/components/email/__tests__/composer-template-reply.test.tsx index db74b7ee8..d2d5abc3e 100644 --- a/components/email/__tests__/composer-template-reply.test.tsx +++ b/components/email/__tests__/composer-template-reply.test.tsx @@ -1,9 +1,10 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import React from 'react'; import type { Editor } from '@tiptap/core'; import { EmailComposer } from '../email-composer'; import { useSettingsStore } from '@/stores/settings-store'; +import { useTemplateStore } from '@/stores/template-store'; // The real composer around the real editor: applying a template to a reply // the user has already started (#911, follow-up to #540). @@ -30,9 +31,9 @@ vi.mock('@/components/templates/template-picker', () => ({ React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'), })); // Save as template: what the composer hands the form. -const savedForm = vi.hoisted(() => ({ initialData: null as null | { body?: string; isHTML?: boolean } })); +const savedForm = vi.hoisted(() => ({ initialData: null as null | { name?: string; body?: string; isHTML?: boolean } })); vi.mock('@/components/templates/template-form', () => ({ - TemplateForm: (props: { initialData?: { body?: string; isHTML?: boolean } }) => { + TemplateForm: (props: { initialData?: { name?: string; body?: string; isHTML?: boolean } }) => { savedForm.initialData = props.initialData ?? null; return null; }, @@ -158,6 +159,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ templateHooks: { onTemplateRender: { size: 0, transform: async (value: unknown) => value }, onTemplateApply: { size: 0, emit: async () => {} }, + onTemplateCreate: { size: 0, emit: async () => {} }, }, emailHooks: { onComposerOpen: { call: async () => [] }, @@ -308,13 +310,46 @@ describe('applying a template to a reply (#911)', () => { savedForm.initialData = null; }); - async function openSaveAsTemplate() { + async function openNameDialog() { fireEvent.click(await screen.findByTestId('composer-templates-menu')); fireEvent.click(await screen.findByRole('menuitem', { name: 'save_as_template' })); + return screen.findByTestId('quick-save-template'); + } + + // The full editor is behind "Edit details…" (compose-18). + async function openSaveAsTemplate() { + fireEvent.click(within(await openNameDialog()).getByText('template_edit_details')); await waitFor(() => expect(savedForm.initialData).not.toBeNull()); return savedForm.initialData!; } + // compose-18: Save as template opened an editor with nine fields. + it('asks for a name only, starting from the subject, and saves the message', async () => { + useTemplateStore.setState({ templates: [] }); + await openReply(); + const subject = (screen.getByTestId('composer-subject') as HTMLInputElement).value; + const dialog = await openNameDialog(); + const name = within(dialog).getByLabelText('template_name') as HTMLInputElement; + expect(name.value).toBe(subject); + expect(savedForm.initialData).toBeNull(); + + fireEvent.change(name, { target: { value: 'Quick answer' } }); + fireEvent.click(within(dialog).getByText('save')); + + await waitFor(() => expect(useTemplateStore.getState().templates).toHaveLength(1)); + expect(useTemplateStore.getState().templates[0]).toMatchObject({ name: 'Quick answer', subject, isHTML: true }); + expect(screen.queryByTestId('quick-save-template')).not.toBeInTheDocument(); + }); + + it('carries the name over into the full editor', async () => { + await openReply(); + const dialog = await openNameDialog(); + fireEvent.change(within(dialog).getByLabelText('template_name'), { target: { value: 'Quick answer' } }); + fireEvent.click(within(dialog).getByText('template_edit_details')); + + await waitFor(() => expect(savedForm.initialData).toMatchObject({ name: 'Quick answer' })); + }); + it('marks a rich text draft as HTML', async () => { await openReply(); const data = await openSaveAsTemplate(); diff --git a/components/email/__tests__/composer-untouched-reply.test.tsx b/components/email/__tests__/composer-untouched-reply.test.tsx new file mode 100644 index 000000000..4deb80492 --- /dev/null +++ b/components/email/__tests__/composer-untouched-reply.test.tsx @@ -0,0 +1,314 @@ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import type { Identity } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; + +// ─── Heavy component mocks (as composer-reply-signature.test.tsx) ─ + +// A stand-in for the TipTap editor: it hands the composer an editor object +// whose content is whatever the composer last rendered into it, the way the +// real editor reports itself ready from an effect after mounting. +const editorState = vi.hoisted(() => ({ html: '' })); +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ content, onChange, onEditorReady }: { + content: string; + onChange: (html: string) => void; + onEditorReady?: (editor: unknown) => void; + }) => { + editorState.html = content; + const onChangeRef = React.useRef(onChange); + onChangeRef.current = onChange; + React.useEffect(() => { + onEditorReady?.({ + isDestroyed: false, + commands: { + focus: () => {}, + setContent: (html: string) => { + editorState.html = html; + onChangeRef.current(html); + }, + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-html': content }); + }, +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => 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) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [ + { id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: 'Alice', textSignature: 'Alice' }, + { id: 'id-team', email: 'team@example.com', name: 'Team', htmlSignature: '', textSignature: '' }, + ] as Array>, + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook, notifyIdentitySelected: () => {} }; +}); + +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) => 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) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + // The default position: replies/forwards get the signature appended at + // send time rather than embedded above the quote. + signaturePosition: 'below_quote', + signatureNewMailEnabled: true, + signatureReplyForwardEnabled: true, + signatureSeparatorEnabled: true, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + externalContentPolicy: 'block', + isSenderTrusted: () => false, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, 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, + sanitizeSignatureHtmlForDisplay: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + sanitizePluginBodyHtml: (v: string) => v, + escapeHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: (html: string) => `
${html}
`, + serializeEditorContent: () => editorState.html, +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +/** + * A Reply or Forward opened and closed again, with nothing typed or changed, + * leaves no draft: the quote and the signature it starts with are no edit, + * nor is a signature that follows the identity the reply resolves to later. + */ + +const ME = { id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: 'Alice', textSignature: 'Alice' }; +const SALES = { id: 'id-sales', email: 'sales@example.com', name: 'Sales', htmlSignature: 'Sales team', textSignature: 'Sales team' }; +const REPLY_TO = { + from: [{ name: 'Bob', email: 'bob@example.com' }], + to: [{ email: 'sales@example.com' }], + subject: 'Question', + body: 'Original text', + htmlBody: '

Original text

', + receivedAt: '2026-08-01T10:00:00Z', +}; + +function savingClient() { + const createDraft = vi.fn().mockResolvedValue('draft-9'); + useAuthStore.setState({ + client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, + }); + return createDraft; +} + +function renderReply(mode: 'reply' | 'replyAll' | 'forward') { + const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>; + const onClose = vi.fn(); + render(); + return { requestCloseRef, onClose }; +} + +const pastAutosave = () => act(async () => { await new Promise((r) => setTimeout(r, 2600)); }); + +describe('an untouched reply or forward', () => { + afterEach(() => { + useAuthStore.setState({ client: null }); + useSettingsStore.setState({ signaturePosition: 'below_quote' }); + useIdentityStore.setState({ identities: [ME] as Identity[] }); + }); + + for (const mode of ['reply', 'replyAll', 'forward'] as const) { + it(`saves nothing, also not by autosave, and closes without asking (${mode})`, async () => { + const createDraft = savingClient(); + const { requestCloseRef, onClose } = renderReply(mode); + + await pastAutosave(); + expect(createDraft).not.toHaveBeenCalled(); + + act(() => requestCloseRef.current!()); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(createDraft).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + } + + // The host builds the quote header after the composer opened (a plugin + // may rewrite it), so the body changes once by itself. + it('stays untouched when the quote header arrives after it opened', async () => { + const createDraft = savingClient(); + const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>; + const onClose = vi.fn(); + const props = { mode: 'reply' as const, requestCloseRef, onClose }; + const view = render(); + view.rerender(Bob wrote:

', quoteHeaderText: 'Bob wrote:' }} />); + + await pastAutosave(); + expect(createDraft).not.toHaveBeenCalled(); + act(() => requestCloseRef.current!()); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(createDraft).not.toHaveBeenCalled(); + }); + + it('stays untouched when its signature follows the identity it resolves to', async () => { + useSettingsStore.setState({ signaturePosition: 'above_quote' }); + useIdentityStore.setState({ identities: [ME, SALES] as Identity[] }); + const createDraft = savingClient(); + const { requestCloseRef, onClose } = renderReply('reply'); + + await pastAutosave(); + act(() => requestCloseRef.current!()); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(createDraft).not.toHaveBeenCalled(); + }); + + it('saves once the user changes something', async () => { + const createDraft = savingClient(); + const { requestCloseRef, onClose } = renderReply('reply'); + fireEvent.change(screen.getByDisplayValue(/Question/), { target: { value: 'Question, answered' } }); + + act(() => requestCloseRef.current!()); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(createDraft).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/email/__tests__/composer-windows-layout.test.tsx b/components/email/__tests__/composer-windows-layout.test.tsx new file mode 100644 index 000000000..a5a5dd5a4 --- /dev/null +++ b/components/email/__tests__/composer-windows-layout.test.tsx @@ -0,0 +1,275 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// One layout in every composer: Send at the bottom end, Discard as a bin left +// of it, the formatting toolbar showing. A pop-out or quick compose window is +// narrower than a tablet, and used to get the phone's layout: Send in the +// header, Discard where Send was, the toolbar behind a button. + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }), +})); + +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 }), +})); + +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) => 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) => 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) => 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) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +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: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} }, +})); + +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 })); + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +const footer = () => document.querySelector('[data-composer-footer]')!; +const header = () => document.querySelector('[data-composer-header]')!; + +describe('a composer in a window of its own', () => { + it('has one Send, at the bottom end, with Discard as a bin right before it', () => { + render(); + const sends = screen.getAllByTestId('composer-send'); + expect(sends).toHaveLength(1); + expect(footer()).toContainElement(sends[0]); + expect(sends[0].className).not.toMatch(/(^|\s)hidden(\s|$)/); + expect(within(header()).queryByTestId('composer-send')).toBeNull(); + + const discard = screen.getByTestId('composer-discard'); + expect(discard.querySelector('svg')).not.toBeNull(); + // Right before Send, in the same group. + expect(discard.nextElementSibling).toBe(sends[0]); + }); + + it('shows the formatting toolbar, with no button to fold it away', () => { + render(); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'false'); + expect(screen.queryByRole('button', { name: 'formatting' })).toBeNull(); + }); + + it('keeps the close button where the main window has it', () => { + render(); + expect(screen.getByTestId('composer-close').className).not.toContain('order-first'); + }); +}); + +describe('full screen', () => { + it('puts the composer over the whole window, and the same button puts it back', () => { + render(); + const root = screen.getByTestId('email-composer'); + expect(root).not.toHaveAttribute('data-maximized'); + + fireEvent.click(screen.getByRole('button', { name: 'maximize' })); + expect(root).toHaveAttribute('data-maximized'); + expect(root.className).toMatch(/(^|\s)fixed(\s|$)/); + expect(root.className).toContain('inset-0'); + + fireEvent.click(screen.getByRole('button', { name: 'restore' })); + expect(root).not.toHaveAttribute('data-maximized'); + expect(root.className).not.toMatch(/(^|\s)fixed(\s|$)/); + }); + + it('goes back with Escape, without closing the composer', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'maximize' })); + const root = screen.getByTestId('email-composer'); + + fireEvent.keyDown(screen.getByRole('button', { name: 'restore' }), { key: 'Escape' }); + + expect(root).not.toHaveAttribute('data-maximized'); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('is not offered in a window of its own, which is all composer already', () => { + render(); + expect(screen.queryByRole('button', { name: 'maximize' })).toBeNull(); + }); +}); + +describe('a composer in the main window', () => { + it('keeps the phone layout for a phone: Send in the header there, toolbar folded', () => { + render(); + expect(within(header()).getByTestId('composer-send')).toBeInTheDocument(); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'true'); + expect(screen.getByRole('button', { name: 'formatting' })).toBeInTheDocument(); + }); + + it('shows Discard as a bin too, with its name for screen readers', () => { + render(); + const discard = screen.getByTestId('composer-discard'); + expect(discard).toHaveAccessibleName('discard'); + expect(discard.querySelector('svg')).not.toBeNull(); + expect(within(discard).getByText('discard').className).toContain('sr-only'); + }); +}); diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx index 4908bc2b9..bba41c478 100644 --- a/components/email/__tests__/empty-subject-warning.test.tsx +++ b/components/email/__tests__/empty-subject-warning.test.tsx @@ -263,27 +263,76 @@ describe('composer empty subject warning', () => { ); }); - it('does not ask again after Send anyway and a trip back to add an attachment', async () => { + // compose-27: the subject and the attachment used to be two dialogs in a row. + it('asks about a missing subject and a missing attachment in one dialog', async () => { useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never); try { const onSend = vi.fn(); render(); fireEvent.click(sendButton()); - fireEvent.click(await screen.findByText('empty_subject.send_anyway')); - // The body says "Hello", so the attachment reminder stops the send. - fireEvent.click(await screen.findByText('forgot_attachment.back')); - await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument()); - fireEvent.click(sendButton()); + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('warnings_title'); + expect(dialog).toHaveTextContent('warning_subject'); + expect(dialog).toHaveTextContent('warning_attachment'); + // Several warnings: no "don't ask again", which would be unclear. + expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument(); - expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument(); - expect(screen.queryByText('empty_subject.title')).not.toBeInTheDocument(); + fireEvent.click(within(dialog).getByText('send_anyway')); + + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument(); } finally { useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never); } }); + it('goes back to editing from the combined dialog without sending', async () => { + useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never); + try { + const onSend = vi.fn(); + render(); + + fireEvent.click(sendButton()); + fireEvent.click(await screen.findByText('back')); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onSend).not.toHaveBeenCalled(); + } finally { + useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never); + } + }); + + // compose-28: Send works with an empty body and asks first. + it('keeps Send enabled with an empty body and asks "Send without text?"', async () => { + const onSend = vi.fn(); + render(); + expect(sendButton()).not.toBeDisabled(); + + fireEvent.click(sendButton()); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('empty_text_title'); + expect(onSend).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByText('send_anyway')); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSend.mock.calls[0][0]).toMatchObject({ subject: 'Lunch?' }); + }); + + it('lists no subject and no text together', async () => { + const onSend = vi.fn(); + render(); + + fireEvent.click(sendButton()); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('warning_subject'); + expect(dialog).toHaveTextContent('warning_text'); + expect(onSend).not.toHaveBeenCalled(); + }); + it('goes back to editing without sending', async () => { const onSend = vi.fn(); render(); @@ -329,9 +378,8 @@ describe('composer empty subject warning', () => { ); fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: '' } }); + // Closing saves the draft; no question unless the save fails. act(() => requestCloseRef.current!(vi.fn())); - const dialog = await screen.findByRole('alertdialog'); - fireEvent.click(within(dialog).getByText('save')); await waitFor(() => expect(createDraft).toHaveBeenCalled()); expect(createDraft.mock.calls[0][1]).toBe(''); diff --git a/components/email/__tests__/placeholder-warning.test.tsx b/components/email/__tests__/placeholder-warning.test.tsx index d73fc79c9..2baf32c91 100644 --- a/components/email/__tests__/placeholder-warning.test.tsx +++ b/components/email/__tests__/placeholder-warning.test.tsx @@ -235,6 +235,7 @@ describe('composer placeholder warning', () => { placeholderWarningEnabled: true, attachmentReminderEnabled: false, attachmentReminderKeywords: [], + sendConfirmation: false, } as never); }); @@ -293,33 +294,36 @@ describe('composer placeholder warning', () => { expect(onSend).not.toHaveBeenCalled(); }); - it('does not ask again after Send anyway and a trip back to add an attachment', async () => { - useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never); - const onSend = vi.fn(); - render(); - + // Send anyway, then "Send this message?" answered with Cancel: the + // placeholders were confirmed, the message is still here. + const confirmThenCancel = async () => { fireEvent.click(sendButton()); fireEvent.click(await screen.findByText('placeholder_warning.send_anyway')); - // The body says "See you", so the attachment reminder stops the send. - fireEvent.click(await screen.findByText('forgot_attachment.back')); - await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument()); + const confirm = await screen.findByRole('alertdialog'); + expect(confirm).toHaveTextContent('send_confirm.title'); + fireEvent.click(within(confirm).getByRole('button', { name: 'cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }; + + it('does not ask again after Send anyway and a cancelled send', async () => { + useSettingsStore.setState({ sendConfirmation: true } as never); + const onSend = vi.fn(); + render(); + await confirmThenCancel(); fireEvent.click(sendButton()); - expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument(); + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('send_confirm.title'); expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument(); expect(onSend).not.toHaveBeenCalled(); }); it('asks again when a new placeholder shows up', async () => { - useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never); + useSettingsStore.setState({ sendConfirmation: true } as never); const onSend = vi.fn(); render(); - - fireEvent.click(sendButton()); - fireEvent.click(await screen.findByText('placeholder_warning.send_anyway')); - fireEvent.click(await screen.findByText('forgot_attachment.back')); - await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument()); + await confirmThenCancel(); fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: 'Lunch with {{name}}' } }); fireEvent.click(sendButton()); @@ -330,6 +334,26 @@ describe('composer placeholder warning', () => { expect(onSend).not.toHaveBeenCalled(); }); + // compose-27: one question for everything the checks found. + it('lists the placeholders and the missing attachment in one dialog', async () => { + useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never); + const onSend = vi.fn(); + render(); + + fireEvent.click(sendButton()); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('warnings_title'); + expect(dialog).toHaveTextContent('warning_placeholders'); + expect(dialog).toHaveTextContent('warning_attachment'); + expect(within(dialog).getByText('[TIME]')).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByText('send_anyway')); + + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + it('ignores placeholders in quoted text and in the signature', async () => { const onSend = vi.fn(); const body = '

Thanks!

' diff --git a/components/email/__tests__/recipient-autocomplete.test.tsx b/components/email/__tests__/recipient-autocomplete.test.tsx index af1feb66d..be289b5a7 100644 --- a/components/email/__tests__/recipient-autocomplete.test.tsx +++ b/components/email/__tests__/recipient-autocomplete.test.tsx @@ -243,7 +243,7 @@ describe('recipient autocomplete', () => { fireEvent.keyDown(input, { key: 'Enter' }); - expect(screen.getByText('User B (userb@example.org)')).toBeInTheDocument(); + expect(screen.getByTitle('User B ')).toBeInTheDocument(); expect(screen.queryByText('user')).not.toBeInTheDocument(); }); @@ -255,7 +255,7 @@ describe('recipient autocomplete', () => { fireEvent.keyDown(input, { key: 'Tab' }); - expect(screen.getByText('User C (userc@example.org)')).toBeInTheDocument(); + expect(screen.getByTitle('User C ')).toBeInTheDocument(); }); it('keeps a complete typed address that no suggestion matches exactly', async () => { @@ -268,7 +268,7 @@ describe('recipient autocomplete', () => { fireEvent.keyDown(input, { key: 'Enter' }); expect(screen.getByText('userb@example.or')).toBeInTheDocument(); - expect(screen.queryByText('User B (userb@example.org)')).not.toBeInTheDocument(); + expect(screen.queryByTitle('User B ')).not.toBeInTheDocument(); }); it('starts the arrow keys from the highlighted suggestion', async () => { @@ -280,6 +280,6 @@ describe('recipient autocomplete', () => { fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' }); - expect(screen.getByText('User C (userc@example.org)')).toBeInTheDocument(); + expect(screen.getByTitle('User C ')).toBeInTheDocument(); }); }); diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx index 058916079..c9971480e 100644 --- a/components/email/__tests__/recipient-chip-drag.test.tsx +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -249,8 +249,8 @@ describe('RecipientChipInput drag and drop', () => { it('keeps a display name with a comma in a single chip (array model)', async () => { render(, ' }} />); - // One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma. - const chip = await screen.findByText('Doo, John (john@doo.org)'); + // One chip, "Doo, John" with the address in its tooltip — not split on the comma. + const chip = await screen.findByTitle('Doo, John '); const chipSpan = chip.closest('[draggable]') as HTMLElement; expect(chipSpan).not.toBeNull(); diff --git a/components/email/__tests__/recipient-paste.test.tsx b/components/email/__tests__/recipient-paste.test.tsx index 3d8a80ca9..911ce6768 100644 --- a/components/email/__tests__/recipient-paste.test.tsx +++ b/components/email/__tests__/recipient-paste.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { fireEvent } from '@testing-library/dom'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; @@ -239,18 +239,31 @@ describe('RecipientChipInput paste', () => { const input = toInput(); paste(input, '"Alice Smith ", "Alex Smith "'); - // Chips render as "Name (email)" when a display name is present. - expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument(); - expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument(); + // A named chip has its address in the tooltip. + expect(screen.getByTitle('Alice Smith ')).toBeInTheDocument(); + expect(screen.getByTitle('Alex Smith ')).toBeInTheDocument(); expect(input.value).toBe(''); }); + // "Maximilian Alexander von Hoh…": the chip showed "Name (address)" and + // cut both. It shows the name; the address is in the tooltip, and screen + // readers still hear it. + it('shows the name only on a named chip', () => { + render(); + paste(toInput(), 'Maximilian Alexander von Hohenberg '); + + const label = screen.getByTitle('Maximilian Alexander von Hohenberg '); + expect(within(label).getByText('Maximilian Alexander von Hohenberg')).toHaveClass('truncate'); + expect(within(label).queryByText(/max\.hohenberg@example\.org/, { ignore: '.sr-only' })).toBeNull(); + expect(label.closest('[draggable]')?.textContent).toBe('Maximilian Alexander von Hohenberg (max.hohenberg@example.org)'); + }); + it('keeps a `Name ` pair as a single named chip', () => { render(); const input = toInput(); paste(input, 'John Doe , jane@y.com'); - expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument(); + expect(screen.getByTitle('John Doe ')).toBeInTheDocument(); expect(screen.getByText('jane@y.com')).toBeInTheDocument(); expect(input.value).toBe(''); }); @@ -260,7 +273,7 @@ describe('RecipientChipInput paste', () => { const input = toInput(); paste(input, '"Doe, John" ; bob@z.com'); - expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument(); + expect(screen.getByTitle('Doe, John ')).toBeInTheDocument(); expect(screen.getByText('bob@z.com')).toBeInTheDocument(); expect(input.value).toBe(''); }); @@ -295,7 +308,7 @@ describe('RecipientChipInput paste', () => { const input = toInput(); paste(input, '"VIP" , not-an-email, b@y.com'); - expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument(); + expect(screen.getByTitle('VIP ')).toBeInTheDocument(); expect(screen.getByText('b@y.com')).toBeInTheDocument(); expect(input.value).toBe('not-an-email'); }); diff --git a/components/email/__tests__/rich-text-editor-a11y.test.tsx b/components/email/__tests__/rich-text-editor-a11y.test.tsx index 12931f3a3..af348308e 100644 --- a/components/email/__tests__/rich-text-editor-a11y.test.tsx +++ b/components/email/__tests__/rich-text-editor-a11y.test.tsx @@ -164,8 +164,11 @@ describe('RichTextEditor image descriptions (MA-02)', () => { const altDialog = () => screen.getByRole('dialog', { name: 'title' }); const html = (editor: Editor) => editor.getHTML(); - it('asks for a description when an image is dropped in, and never uses the file name', async () => { + it('offers a description for a dropped image, and never uses the file name', async () => { const { editor } = await dropImage(); + // Nothing asks on the drop; the chip on the image does, when clicked. + expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull(); + fireEvent.click(await screen.findByTestId('image-alt-add')); const dialog = await waitFor(() => altDialog()); expect(html(editor)).not.toContain('IMG_2041'); expect(html(editor)).not.toMatch(/alt=/); @@ -178,6 +181,9 @@ describe('RichTextEditor image descriptions (MA-02)', () => { it('marks an image decorative with an empty alt', async () => { const { editor } = await dropImage(); + // Nothing asks on the drop; the chip on the image does, when clicked. + expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull(); + fireEvent.click(await screen.findByTestId('image-alt-add')); const dialog = await waitFor(() => altDialog()); fireEvent.click(within(dialog).getByLabelText('decorative')); expect(within(dialog).getByLabelText('label')).toBeDisabled(); diff --git a/components/email/__tests__/rich-text-editor-drop.test.tsx b/components/email/__tests__/rich-text-editor-drop.test.tsx index 235cffce7..215b153b2 100644 --- a/components/email/__tests__/rich-text-editor-drop.test.tsx +++ b/components/email/__tests__/rich-text-editor-drop.test.tsx @@ -1,4 +1,4 @@ -import { act, render, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import React from 'react'; import type { Editor } from '@tiptap/core'; @@ -72,4 +72,33 @@ describe('RichTextEditor file drop', () => { expect(editor.getText()).toBe('Hello'); expect(onDrop).not.toHaveBeenCalled(); }); + + // A pasted or dropped image asks nothing: an optional chip on it offers a description. + it('opens no dialog for a dropped image, and offers "Add description" on it', async () => { + const { editor } = await setup(async () => ({ src: 'data:image/png;base64,AAAA' })); + const file = new File(['png'], 'photo.png', { type: 'image/png' }); + + act(() => { editor.view.dom.dispatchEvent(fileDrop(file)); }); + + const chip = await screen.findByTestId('image-alt-add'); + expect(chip).toHaveTextContent('add_description'); + expect(screen.queryByRole('dialog')).toBeNull(); + + fireEvent.click(chip); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('shows no chip on an image marked decorative', async () => { + let editor: Editor | null = null; + render( + Hi

'} + onChange={() => {}} + onImageUpload={async () => null} + onEditorReady={(ed) => { editor = ed as unknown as Editor; }} + /> + ); + await waitFor(() => expect(editor).not.toBeNull()); + expect(screen.queryByTestId('image-alt-add')).toBeNull(); + }); }); diff --git a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx index e815efa53..c15a51ce4 100644 --- a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx +++ b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx @@ -36,6 +36,17 @@ describe('Insert link', () => { expect(editor.getHTML()).toContain('href="https://example.com/docs"'); }); + it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => { + const editor = await setup('

docs here

'); + act(() => { editor.commands.setTextSelection({ from: 1, to: 5 }); }); + + const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true }); + act(() => { editor.view.dom.dispatchEvent(event); }); + + expect(event.defaultPrevented).toBe(true); + expect(screen.getByRole('dialog', { name: 'insert_link' })).toBeInTheDocument(); + }); + it('removes a link from the dialog', async () => { const editor = await setup('

docs here

'); act(() => { editor.commands.setTextSelection(2); }); diff --git a/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx b/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx new file mode 100644 index 000000000..c45bc217c --- /dev/null +++ b/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx @@ -0,0 +1,114 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; + +import { RichTextEditor, formattingFoldCount } from '../rich-text-editor'; + +// The formatting toolbar keeps one row: in a narrow composer (585 px at +// 1280x720) it wrapped to two and took the body's room. What doesn't fit goes +// behind "More formatting", the least used first. + +let toolbarWidth = 1200; +const observers = new Set(); +class ReportingResizeObserver { + constructor(private cb: ResizeObserverCallback) {} + observe() { observers.add(this.cb); } + unobserve() {} + disconnect() { observers.delete(this.cb); } +} +vi.stubGlobal('ResizeObserver', ReportingResizeObserver); + +const realClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')!; +Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get(this: HTMLElement) { + return this.getAttribute('role') === 'toolbar' ? toolbarWidth : realClientWidth.get!.call(this); + }, +}); + +afterEach(() => { + toolbarWidth = 1200; +}); + +async function setup() { + let ready = false; + render( {}} onEditorReady={() => { ready = true; }} />); + await waitFor(() => expect(ready).toBe(true)); +} + +async function resize(width: number) { + toolbarWidth = width; + await act(async () => { + for (const cb of observers) cb([], {} as ResizeObserver); + }); +} + +const toolbar = () => screen.getByRole('toolbar', { name: 'formatting' }); +const rowItem = (name: string) => toolbar().querySelector(`[data-toolbar-item="${name}"]`); + +describe('formatting toolbar in a narrow composer', () => { + it('shows every button in one row when there is room, without a More button', async () => { + await setup(); + for (const name of ['heading-1', 'quote', 'align-left', 'table', 'undo']) expect(rowItem(name)).not.toBeNull(); + expect(rowItem('more-formatting')).toBeNull(); + }); + + it('moves what does not fit behind "More formatting", and shows it there', async () => { + await setup(); + await resize(561); + + // The least used go first: undo, redo, clear formatting, then alignment. + for (const name of ['undo', 'redo', 'clear-formatting', 'align-left', 'align-right']) expect(rowItem(name)).toBeNull(); + for (const name of ['bold', 'bullet-list', 'link', 'heading-1', 'table']) expect(rowItem(name)).not.toBeNull(); + + const more = rowItem('more-formatting') as HTMLButtonElement; + expect(more).toHaveAccessibleName('more_formatting'); + expect(more).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(more); + const panel = screen.getByRole('dialog', { name: 'more_formatting' }); + expect(more).toHaveAttribute('aria-expanded', 'true'); + for (const name of ['align-left', 'align-center', 'align-right', 'clear-formatting', 'undo', 'redo']) { + expect(within(panel).getByRole('button', { name: new RegExp(`^${name.replace('-', '_')}`) })).toBeInTheDocument(); + } + }); + + it('keeps the arrows in the row: the panel buttons are no Tab stops of the toolbar', async () => { + await setup(); + await resize(561); + fireEvent.click(rowItem('more-formatting')!); + const panel = screen.getByRole('dialog', { name: 'more_formatting' }); + const rowStops = Array.from(toolbar().querySelectorAll('[data-toolbar-item]')) + .filter((el) => !panel.contains(el) && el.tabIndex === 0); + expect(rowStops).toHaveLength(1); + }); + + it('brings everything back into the row when the composer gets wider', async () => { + await setup(); + await resize(400); + expect(rowItem('heading-1')).toBeNull(); + await resize(1000); + expect(rowItem('heading-1')).not.toBeNull(); + expect(rowItem('undo')).not.toBeNull(); + expect(rowItem('more-formatting')).toBeNull(); + }); +}); + +describe('formattingFoldCount', () => { + const sizes = { button: 28, gap: 2, separator: 5 }; + + it('folds nothing for a toolbar that is not laid out', () => { + expect(formattingFoldCount(0, sizes)).toBe(0); + }); + + it('folds more groups the narrower the row', () => { + const counts = [900, 700, 561, 480, 400, 300].map((width) => formattingFoldCount(width, sizes)); + expect(counts[0]).toBe(0); + for (let i = 1; i < counts.length; i++) expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]); + expect(counts[counts.length - 1]).toBe(5); + }); + + it('makes room for the text direction button', () => { + const width = 700; + expect(formattingFoldCount(width, { ...sizes, directionButton: true })).toBeGreaterThanOrEqual(formattingFoldCount(width, sizes)); + }); +}); diff --git a/components/email/__tests__/viewer-quick-reply-back.test.tsx b/components/email/__tests__/viewer-quick-reply-back.test.tsx index 39fae9a39..b4717d125 100644 --- a/components/email/__tests__/viewer-quick-reply-back.test.tsx +++ b/components/email/__tests__/viewer-quick-reply-back.test.tsx @@ -42,7 +42,7 @@ beforeEach(() => clearAllHooks()); async function typeAndSend(onQuickReply: (body: string) => Promise) { render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); - const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement; + const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement; fireEvent.focus(box); fireEvent.change(box, { target: { value: 'My answer' } }); fireEvent.click(screen.getByRole('button', { name: 'send' })); @@ -77,7 +77,7 @@ describe('automatic sign-out while the external recipient question is open', () render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); - const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement; + const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement; fireEvent.focus(box); fireEvent.change(box, { target: { value: 'My answer' } }); fireEvent.click(screen.getByRole('button', { name: 'send' })); diff --git a/components/email/__tests__/viewer-quick-reply-words.test.tsx b/components/email/__tests__/viewer-quick-reply-words.test.tsx new file mode 100644 index 000000000..f980a7c93 --- /dev/null +++ b/components/email/__tests__/viewer-quick-reply-words.test.tsx @@ -0,0 +1,87 @@ +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'; + +// The quick reply box counted characters, hid the full editor behind "More +// options" and didn't say who the reply goes to. It now names the +// recipients in its placeholder and offers "Open full editor". + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + 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: () => [] })); + +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 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(email: Email, onReply = vi.fn()) { + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); +} + +describe('quick reply wording', () => { + it('says who the reply goes to', async () => { + await renderViewer(message); + expect(screen.getByPlaceholderText('quick_reply_to:{"names":"Ben Carter"}')).toBeTruthy(); + }); + + it('names the Reply-To address over the sender, by address when it has no name', async () => { + await renderViewer({ ...message, replyTo: [{ email: 'team@example.com' }] } as unknown as Email); + expect(screen.getByPlaceholderText('quick_reply_to:{"names":"team@example.com"}')).toBeTruthy(); + }); + + it('drops the character count and opens the full editor with the typed text', async () => { + const onReply = vi.fn(); + await renderViewer(message, onReply); + const box = screen.getByPlaceholderText(/^quick_reply_to/); + fireEvent.focus(box); + fireEvent.change(box, { target: { value: 'Thanks, will do' } }); + + expect(screen.queryByText(/characters_count/)).toBeNull(); + expect(screen.queryByText('more_options')).toBeNull(); + const full = screen.getByTestId('quick-reply-full-editor'); + expect(full).toHaveTextContent('open_full_editor'); + fireEvent.click(full); + expect(onReply).toHaveBeenCalledWith('Thanks, will do'); + }); +}); diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index fd95d5e73..b733976e1 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -92,9 +92,9 @@ describe('viewer send failures', () => { useSettingsStore.setState({ quickReplyEnabled: false }); render(); - expect(screen.queryByPlaceholderText('quick_reply_placeholder')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('quick_reply_to')).not.toBeInTheDocument(); act(() => useSettingsStore.getState().updateSetting('quickReplyEnabled', true)); - expect(screen.getByPlaceholderText('quick_reply_placeholder')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('quick_reply_to')).toBeInTheDocument(); }); it('reports a failed quick reply and keeps the text on Stalwart 1.0', async () => { @@ -103,7 +103,7 @@ describe('viewer send failures', () => { , ); - const box = screen.getByPlaceholderText('quick_reply_placeholder'); + const box = screen.getByPlaceholderText('quick_reply_to'); fireEvent.change(box, { target: { value: 'Thanks!' } }); fireEvent.keyDown(box, { key: 'Enter', ctrlKey: true }); @@ -122,7 +122,7 @@ describe('viewer send failures', () => { await act(async () => { await flushComposers(1000); }); expect(onSaveQuickReplyDraft).not.toHaveBeenCalled(); - fireEvent.change(screen.getByPlaceholderText('quick_reply_placeholder'), { target: { value: 'On it.' } }); + fireEvent.change(screen.getByPlaceholderText('quick_reply_to'), { target: { value: 'On it.' } }); await act(async () => { await flushComposers(1000); }); expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('On it.'); }); @@ -133,7 +133,7 @@ describe('viewer send failures', () => { render(); expect(useUIStore.getState().composingCount).toBe(0); - fireEvent.focus(screen.getByPlaceholderText('quick_reply_placeholder')); + fireEvent.focus(screen.getByPlaceholderText('quick_reply_to')); expect(useUIStore.getState().composingCount).toBe(1); fireEvent.click(screen.getByText('cancel')); diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx index 639d758cb..07abf0e97 100644 --- a/components/email/composer-toolbar-menu.tsx +++ b/components/email/composer-toolbar-menu.tsx @@ -67,21 +67,28 @@ export function ComposerToolbarMenu({ icon: Icon, label, activeCount = 0, testId interface ItemProps { icon?: AppIcon; onSelect: () => void; + /** A line under the label, as on the check items. */ + description?: string; + /** Greyed out: the item can't do its job here yet, and says where to set it up. */ + muted?: boolean; testId?: string; children: React.ReactNode; } -export function ComposerToolbarMenuItem({ icon: Icon, onSelect, testId, children }: ItemProps) { +export function ComposerToolbarMenuItem({ icon: Icon, onSelect, description, muted, testId, children }: ItemProps) { return ( ); } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c73c3b691..fc292094a 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -42,7 +42,10 @@ import { Reply, Forward, BookUser, + Lock, + CloudOff, } from "@/components/icons"; +import { useRouter } from "@/i18n/navigation"; import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu"; import { ComposerToolbarMenuRadioItem } from "@/components/email/composer-toolbar-menu"; import type { MessagePriority } from "@/lib/message-priority"; @@ -52,7 +55,10 @@ import { plainOwnRange, readOwnContent, readSelection as readEditorSelection, re import { actionsFor, runAction } from "@/lib/plugin-sandbox/actions"; import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; +import { forwardRecipientLines } from "@/lib/quote-header"; import { ScheduleSendDialog } from "./schedule-send-dialog"; +import { SendWarningsDialog, type SendWarning } from "./send-warnings-dialog"; +import { QuickSaveTemplateDialog } from "@/components/templates/quick-save-template-dialog"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu"; @@ -64,6 +70,9 @@ 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 { attachmentIcon } from "@/components/email/attachment-chips"; +import { attachmentsForDisplay } from "@/lib/compose/attachment-order"; +import { showDraftSavedToast } from "@/lib/compose/draft-close-toast"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; import { containsEmbeddedSignature } from "@/components/email/signature-block"; import { @@ -97,6 +106,9 @@ import type { DirectoryEntry } from "@/stores/contact-store"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { isStandInId } from "@/lib/jmap/stand-in-ids"; import { noteDraftReplacedElsewhere } from "@/lib/offline/queue"; +import { offlineActive, offlineAllowed } from "@/lib/offline/offline-mode"; +import { useBrowserOnline } from "@/hooks/use-browser-online"; +import { appPath, buildSettingsPath } from "@/lib/deep-links"; import type { FileNode } from "@/lib/jmap/types"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store"; @@ -238,6 +250,10 @@ function createChipDragPreview(label: string): HTMLElement { // inserts as a single chip and expands into its members on send. type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } }; +/** The From list's entries that aren't identities. */ +const FROM_SUB_ADDRESS = '__sub_address'; +const FROM_EDIT_SENDER = '__edit_sender'; + // Which `initialFiles` arrays are attached: a remount gets the same array. const attachedInitialFiles = new WeakSet(); @@ -370,6 +386,11 @@ interface EmailComposerProps { /** The id plugins know this composer by, for onAfterEmailSend. */ composerId?: string; }) => void | false | Promise; + /** + * 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. + */ + onSendFailed?: (state: ComposerDraftData) => void; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; /** @@ -418,6 +439,11 @@ interface EmailComposerProps { * started now wouldn't survive. False when that can't be done safely. */ rescueDraft?: (draft: RescuedDraft) => boolean; + /** + * The window closes with its draft kept: another window says so, as + * this one is gone before a toast here could be read. + */ + onDraftKept?: (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; @@ -574,6 +600,7 @@ const OFFLINE_SEND_ERRORS: Record = { export function EmailComposer({ onSend, + onSendFailed, onScheduledSendCreated, onClose, requestCloseRef, @@ -602,11 +629,14 @@ export function EmailComposer({ const tViewer = useTranslations('email_viewer'); const tClassification = useTranslations('classification'); const tEdit = useTranslations('desktopEdit'); - const tProofreading = useTranslations('proofreading'); const tOfflineDrafts = useTranslations('offlineDrafts'); const tOffline = useTranslations('offline'); const tOfflineNotes = useTranslations('offlineConflicts'); const tSendGuards = useTranslations('send_guards'); + const tWords = useTranslations('compose_words'); + const tUx = useTranslations('compose_ux'); + const router = useRouter(); + const showAdvancedSettings = useSettingsStore((state) => state.showAdvancedSettings); const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode); // Per-message format (#1022). Seeded from the "plain text only" setting - @@ -838,6 +868,7 @@ export function EmailComposer({ ? `${from.name} <${from.email}>` : (from.email || from.name || tCommon('unknown'))) : tCommon('unknown'); + const forwardLines = forwardRecipientLines(replyTo, { toLabel: tWords('forward_to_label'), ccLabel: tWords('forward_cc_label') }); const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : ''); const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n'); @@ -858,7 +889,7 @@ export function EmailComposer({ } if (mode === 'forward') { - return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`; + return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}${forwardLines.map((line) => `\n${line.label}: ${line.value}`).join('')}\n\n${originalText}`; } else if (mode === 'reply' || mode === 'replyAll') { return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStrFull })}\n${quotedText}`; } @@ -888,6 +919,8 @@ export function EmailComposer({ ? `${from.name} <${from.email}>` : (from.email || from.name || tCommon('unknown'))) : tCommon('unknown'); + const forwardLinesHtml = forwardRecipientLines(replyTo, { toLabel: tWords('forward_to_label'), ccLabel: tWords('forward_cc_label') }) + .map((line) => `${line.label}: ${escapeHtml(line.value)}
`).join(''); // Like a new mail, an empty line above the signature gives the cursor a // place for the reply. Without it the reply started on the "-- " line (#1503). @@ -936,7 +969,7 @@ export function EmailComposer({ // HTML-escape user-controlled values: an unescaped sender "Name " // has its "" eaten as a bogus HTML tag by the rich-text editor (#482). const quoteHeader = mode === 'forward' - ? `${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${escapeHtml(fromStrFull)}
${tQuote('date_label')}: ${escapeHtml(date)}
${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}

` + ? `${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${escapeHtml(fromStrFull)}
${tQuote('date_label')}: ${escapeHtml(date)}
${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}
${forwardLinesHtml}
` : `${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}
`; // Embed the original as a QuotedHtml island (verbatim, schema-free) so // its layout survives the editor round-trip. Sanitize first to strip @@ -952,7 +985,7 @@ export function EmailComposer({ if (replyTo.body) { const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); if (mode === 'forward') { - return `${head}

${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${escapeHtml(fromStrFull)}
${tQuote('date_label')}: ${escapeHtml(date)}
${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}

${escapedOriginal}`; + return `${head}

${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${escapeHtml(fromStrFull)}
${tQuote('date_label')}: ${escapeHtml(date)}
${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}
${forwardLinesHtml}
${escapedOriginal}`; } else if (mode === 'reply' || mode === 'replyAll') { return `${head}

${replyHeader(`

${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}

`)}
${escapedOriginal}
`; } @@ -1060,6 +1093,12 @@ export function EmailComposer({ const nextCc = getInitialCc(); if (same(nextTo, auto.to) && same(nextCc, auto.cc)) return; autoRecipientsRef.current = { to: nextTo, cc: nextCc }; + // Not the user's change: an untouched reply stays untouched. + initialValuesRef.current = { + ...initialValuesRef.current, + to: formatRecipientList(withInput(nextTo, toInput)), + cc: formatRecipientList(withInput(nextCc, ccInput)), + }; setTo(nextTo); setCc(nextCc); if (nextCc.length > 0) setShowCc(true); @@ -1192,6 +1231,9 @@ export function EmailComposer({ const [filesPicker, setFilesPicker] = useState<{ client: IJMAPClient; accountId?: string } | null>(null); const [showDirectoryPicker, setShowDirectoryPicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); + // Save as template asks for a name only; "Edit details…" opens the full + // editor with the name typed so far (null: the name dialog). + const [saveTemplateDetails, setSaveTemplateDetails] = useState(null); const [showCloseDialog, setShowCloseDialog] = useState(false); // The formatting toolbar is folded away on a phone until asked for. const [showFormattingOnMobile, setShowFormattingOnMobile] = useState(false); @@ -1200,24 +1242,26 @@ export function EmailComposer({ // bar folded away behind "Aa" until asked for. const mvWindow = useMvComposeWindow(); const [mvShowFormatting, setMvShowFormatting] = useState(false); + // A pop-out or quick compose window is narrower than a tablet, but it is a + // window on a computer: it keeps the main window's layout, not the phone's. + const phoneLayout = !ownWindow; + // Full screen: the composer over the whole window, as Gmail has it. The same + // 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; // "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'); - const [showAllAttachments, setShowAllAttachments] = useState(false); + // The sub-address popover, opened from the From list. + const [subAddressOpen, setSubAddressOpen] = useState(false); + // Closing saves without asking; the question comes only when that save failed. + const [closeSaveFailed, setCloseSaveFailed] = useState(false); const [previewAttachment, setPreviewAttachment] = useState(null); - const [showAttachmentWarning, setShowAttachmentWarning] = useState(false); - const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); - const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState(); - const [showEmptySubjectWarning, setShowEmptySubjectWarning] = useState(false); - const [emptySubjectDelayedUntil, setEmptySubjectDelayedUntil] = useState(); - const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false); - const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false); - // A proofreading checker asked to warn before sending with open issues. - const [proofreadingWarning, setProofreadingWarning] = useState<{ count: number; delayedUntil?: string } | 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); + // Open proofreading issues, once a checker asked to warn about them. const proofreadingConfirmedRef = useRef(false); - const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState([]); - const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState(); - const [placeholderDontAskAgain, setPlaceholderDontAskAgain] = useState(false); const [showScheduleDialog, setShowScheduleDialog] = useState(false); // Asks before sending at once when the account can't hold a message back. const { dialogProps: noDelayDialogProps, confirm: confirmNoDelay } = useConfirmDialog(); @@ -1238,6 +1282,7 @@ export function EmailComposer({ if (!(await confirmDiscardTemplate(saveTemplateDirty))) return; setSaveTemplateDirty(false); setShowSaveAsTemplate(false); + setSaveTemplateDetails(null); }; const { client: activeClient } = useAuthStore(); @@ -1410,6 +1455,8 @@ 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(null); + // 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 // does not move the caret back to the start. const replyFocusedRef = useRef(false); @@ -1482,7 +1529,14 @@ export function EmailComposer({ separator: signatureSeparatorEnabled, }), { quoteHeader: getQuoteHeaderLine() }); if (nextHtml !== currentHtml) { - editor.commands.setContent(nextHtml, { emitUpdate: true }); + // The signature following From is no edit of the user's: an untouched + // reply whose identity resolves late must stay untouched. + signatureSwapRef.current = true; + try { + editor.commands.setContent(nextHtml, { emitUpdate: true }); + } finally { + signatureSwapRef.current = false; + } } // Intentionally keyed to the signature-relevant fields plus the internal // prev*Ref guards above; depending on the whole `signatureIdentity` object @@ -1781,6 +1835,9 @@ export function EmailComposer({ // Whether a Sent mailbox is known so the on-demand server search is worth // offering (falls back to hiding the "search the server" row otherwise). const canSearchServer = useContactStore((s) => s.sentMailboxId != null); + // The recipient picker: the user's contacts, and the server's directory. + const hasContacts = useContactStore((s) => (s.contacts?.length ?? 0) > 0); + const tSend = useTranslations('compose_send'); const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); @@ -1867,7 +1924,12 @@ export function EmailComposer({ // Handed-in text and files aren't the user's until they touch them. Nothing // counts as dirty until then, so autosave, the page-close save and the flush // before a sign-out all leave them alone. - const uneditedRef = useRef(initialData?.unedited === true); + // So does a fresh composer, a reply or forward above all: the quote and the + // signature it starts with are no reason to keep a draft. Closed before the + // user changes anything, it leaves nothing behind. + const uneditedRef = useRef(initialData?.unedited === true || ( + !initialDataProp && !prefill && !initialDraftTextProp && !(initialFiles && initialFiles.length > 0) + )); const [firstEditSeen, setFirstEditSeen] = useState(false); const markEdited = useCallback(() => { if (!uneditedRef.current) return; @@ -2822,6 +2884,13 @@ export function EmailComposer({ // Plugin entries make the Send and Attach buttons menus even where the app // itself has nothing else to put there. const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0; + // With nothing but "Schedule send" in Send's menu, the arrow opens the + // schedule dialog itself: a menu with one item was one click too many. + const sendArrowSchedules = !hasSendMenuActions && !!composerClient?.hasDelayedSend(); + const handleSendArrow = (e: React.MouseEvent) => { + if (sendArrowSchedules) openScheduleDialog(); + else toggleSendMenu(e); + }; const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0; // Ticked plugin options count in the Options button's badge like the app's own. const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length; @@ -3616,14 +3685,14 @@ export function EmailComposer({ const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim(); const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading); // A missing subject no longer blocks Send (#684) - users hit the disabled - // button without understanding why. It is confirmed in a dialog instead. - const canSend = toAddresses.length > 0 && hasContent; + // button without understanding why. It is confirmed in a dialog instead, + // and so is a message with no text and no file. + const canSend = toAddresses.length > 0; const getSendTooltip = (): string | undefined => { if (isWaitingForUploads) return t('validation.attachments_uploading'); if (canSend) return undefined; if (toAddresses.length === 0) return t('validation.recipient_required'); - if (!hasContent) return t('validation.body_required'); return undefined; }; @@ -3633,6 +3702,9 @@ export function EmailComposer({ if (composerClient?.hasDelayedSend()) { return new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); } + // Undo send is on by default; a server without delayed sending just + // sends. Only a delay the user picked is worth a question. + if (!useSettingsStore.getState().explicitSettings.includes('sendDelaySeconds')) return undefined; const confirmed = await confirmNoDelay({ title: t('send_delay_unsupported_title'), message: t('send_delay_unsupported_confirm'), @@ -3718,6 +3790,8 @@ export function EmailComposer({ const confirmingSendRef = useRef(false); // The user confirmed sending without a subject for this message. const emptySubjectConfirmedRef = useRef(false); + // ... and without text. + const emptyTextConfirmedRef = useRef(false); // Placeholders the user chose to send as they are. The warning comes back // only for one that is not in here. const confirmedPlaceholdersRef = useRef>(new Set()); @@ -3762,9 +3836,9 @@ export function EmailComposer({ else editor.commands.focus(); }; - const handleSend = async ({ skipAttachmentCheck = false, skipSubjectCheck = false, delayedUntil }: { - skipAttachmentCheck?: boolean; - skipSubjectCheck?: boolean; + const handleSend = async ({ warningsConfirmed = false, delayedUntil }: { + /** "Send anyway" in the warnings dialog: don't check those again. */ + warningsConfirmed?: boolean; delayedUntil?: string; } = {}) => { if (isSendingRef.current) return; @@ -3812,7 +3886,6 @@ export function EmailComposer({ if (!canSend) { const errors: { to?: boolean; body?: boolean } = {}; if (toAddresses.length === 0) errors.to = true; - if (!hasContent) errors.body = true; setValidationErrors(errors); if (errors.to) { @@ -3851,69 +3924,69 @@ export function EmailComposer({ return; } - // Empty subject confirmation (#684). Asked once per message: after "Send - // anyway" and a trip back to add an attachment, it asked again. - if (!skipSubjectCheck && !emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) { - setEmptySubjectDontAskAgain(false); - setEmptySubjectDelayedUntil(delayedUntil); - setShowEmptySubjectWarning(true); - return; - } - - // Unfilled placeholders: template fields left as {{name}}, fill-ins like - // [TIME] from AI drafts, and markers like TODO. The signature is left - // out, or a placeholder in it would stop every message. - if (placeholderWarningEnabled) { - const authored = plainTextMode - ? plainTextBodyWithoutSignature(body, signatureIdentity) - : stripEmbeddedSignatureHtml(body); - const bodyText = extractUserAuthoredText(authored, { - plainTextMode, - forwardedSeparator: tQuote('forwarded_separator'), - }); - // The fill-in words of the UI language, and of English: AI drafts and - // templates are often English whatever the UI language is. - const found = findUnfilledPlaceholders(subject, bodyText, [ - enMessages.email_composer.placeholder_words, - t.raw('placeholder_words'), - ]); - if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) { - setPlaceholderWarningMatches(found); - setPlaceholderDontAskAgain(false); - setPlaceholderWarningDelayedUntil(delayedUntil); - setShowPlaceholderWarning(true); - return; + // What the user may have missed, asked about in one dialog (two in a + // row used to ask about each in turn). Answered with "Send anyway", the + // subject, the text, the placeholders listed and the proofreading issues + // aren't asked about again for this message. + if (!warningsConfirmed) { + const warnings: SendWarning[] = []; + // Empty subject confirmation (#684). + if (!emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) { + warnings.push({ kind: 'subject' }); + } + // No text and no file: Send stays enabled and asks instead. + if (!emptyTextConfirmedRef.current && !hasContent) { + warnings.push({ kind: 'text' }); } - } - // Attachment reminder check - if (!skipAttachmentCheck && attachmentReminderEnabled) { - const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error); - if (!hasAttachments) { - // Scan only the user-authored text: the quoted original of a - // reply/forward often mentions an attachment itself, which used to fire - // the reminder even when the user typed no keyword and added nothing (#570). - const bodyText = extractUserAuthoredText(body, { + // Unfilled placeholders: template fields left as {{name}}, fill-ins like + // [TIME] from AI drafts, and markers like TODO. The signature is left + // out, or a placeholder in it would stop every message. + if (placeholderWarningEnabled) { + const authored = plainTextMode + ? plainTextBodyWithoutSignature(body, signatureIdentity) + : stripEmbeddedSignatureHtml(body); + const bodyText = extractUserAuthoredText(authored, { plainTextMode, forwardedSeparator: tQuote('forwarded_separator'), }); - const searchText = `${subject} ${bodyText}`.toLowerCase(); - const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase())); - if (matched) { - setAttachmentWarningKeyword(matched); - setAttachmentWarningDelayedUntil(delayedUntil); - setShowAttachmentWarning(true); - return; + // The fill-in words of the UI language, and of English: AI drafts and + // templates are often English whatever the UI language is. + const found = findUnfilledPlaceholders(subject, bodyText, [ + enMessages.email_composer.placeholder_words, + t.raw('placeholder_words'), + ]); + if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) { + warnings.push({ kind: 'placeholders', matches: found }); } } - } - // Open proofreading issues, when a checker asked for the warning (the - // LanguageTool plugin's "warn before sending" option). Once per message. - if (!proofreadingConfirmedRef.current) { - const open = proofreading.sendWarningIssues().length; - if (open > 0) { - setProofreadingWarning({ count: open, delayedUntil }); + // Attachment reminder check + if (attachmentReminderEnabled) { + const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error); + if (!hasAttachments) { + // Scan only the user-authored text: the quoted original of a + // reply/forward often mentions an attachment itself, which used to fire + // the reminder even when the user typed no keyword and added nothing (#570). + const bodyText = extractUserAuthoredText(body, { + plainTextMode, + forwardedSeparator: tQuote('forwarded_separator'), + }); + const searchText = `${subject} ${bodyText}`.toLowerCase(); + const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase())); + if (matched) warnings.push({ kind: 'attachment', keyword: matched }); + } + } + + // Open proofreading issues, when a checker asked for the warning (the + // LanguageTool plugin's "warn before sending" option). + if (!proofreadingConfirmedRef.current) { + const open = proofreading.sendWarningIssues().length; + if (open > 0) warnings.push({ kind: 'proofreading', count: open }); + } + + if (warnings.length > 0) { + setSendWarnings({ items: warnings, delayedUntil }); return; } } @@ -4073,6 +4146,9 @@ export function EmailComposer({ const lastStage = (html: string | undefined, text: string) => finalizeOutgoing({ html: html || undefined, text }, outgoingContext, 'last'); const inlineAttachments = rewritten?.attachments ?? []; + // The message as it is now, once onSend has it: the host may close the + // composer before the send is through. + let handedToHost: ComposerDraftData | null = null; try { const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil); @@ -4272,6 +4348,13 @@ export function EmailComposer({ // Read when the close is reported: the host tears the composer down // while onSend is still running. Reset below if the send fails. sentRef.current = true; + handedToHost = { + ...snapshotStateRef.current(), + // The save before the send may have moved the draft to a new id. + draftId: draftIdRef.current, + draftAccountId: draftAccountIdRef.current, + draftLocalAccountId: draftLocalAccountIdRef.current, + }; const outcome = await onSend?.({ to: outgoing.to, cc: outgoing.cc, @@ -4347,6 +4430,8 @@ export function EmailComposer({ explicitCloseRef.current = false; sentRef.current = false; debug.error('Failed to send email:', err); + // A host that closed the composer at Send opens it again with the text. + if (handedToHost) onSendFailed?.(handedToHost); if (isLabelRequiredError(err)) { setLabelInvalid(true); toast.error(tClassification('required_toast')); @@ -4433,7 +4518,28 @@ export function EmailComposer({ setIsSavingAndClosing(false); }; - const handleSaveDraftAndClose = async () => { + // "Draft saved" with Discard, once the composer closed and kept its draft. + // Read before the close resets the refs; the Discard runs after unmount, + // through the host's own discard, as the composer's Discard does. + const announceKeptDraft = () => { + const draftId = draftIdRef.current; + if (!draftId) return; + const where = { accountId: draftAccountIdRef.current, localAccountId: draftLocalAccountIdRef.current }; + const ownWindowKept = ownWindowRef.current?.onDraftKept; + if (ownWindowKept) { + ownWindowKept(draftId, where); + return; + } + const discard = onDiscardDraft; + showDraftSavedToast(() => discard?.(draftId, where), { + saved: t('draft_saved'), + discard: t('discard'), + discarded: tUx('draft_discarded'), + undo: tUx('undo'), + }); + }; + + const handleSaveDraftAndClose = async (opts: { fromClose?: boolean } = {}) => { if (saveCloseAttemptRef.current) return; const attempt = { cancelled: false, saving: false }; saveCloseAttemptRef.current = attempt; @@ -4457,7 +4563,12 @@ export function EmailComposer({ setIsWaitingToSave(false); if (result === 'failed') { toast.error(t('save_failed')); - afterCloseRef.current = null; + if (opts.fromClose) { + setCloseSaveFailed(true); + openCloseDialog('close'); + } else { + afterCloseRef.current = null; + } return; } @@ -4467,10 +4578,21 @@ export function EmailComposer({ const savedId = await saveDraftRef.current({ announce: true }); if (attempt.cancelled) return; if (saveFailedRef.current || !savedId) { + // Nothing to save (no recipient, subject, text or file) isn't a failure. + if (!saveFailedRef.current && !draftIdRef.current && !draftHasContent(draftRecipients().toAddresses)) { + cleanClose(); + return; + } toast.error(t('save_failed')); - afterCloseRef.current = null; + if (opts.fromClose) { + setCloseSaveFailed(true); + openCloseDialog('close'); + } else { + afterCloseRef.current = null; + } return; } + announceKeptDraft(); explicitCloseRef.current = true; stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, draftAccountId: undefined, draftLocalAccountId: undefined, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode }; emitClose(); @@ -4541,7 +4663,7 @@ export function EmailComposer({ const composerCloseButton = ( handleClose()} - className={cn(closeBesideControls ? "-ms-2" : "-me-2", "max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11")} + className={cn(closeBesideControls ? "-ms-2" : "-me-2", phoneLayout && "max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11")} label={tCommon('close')} data-testid="composer-close" data-own-window-close={ownWindow ? "" : undefined} @@ -4601,16 +4723,16 @@ export function EmailComposer({ return; } const discarding = kind === 'discard'; - const buttons = discarding ? [t('discard'), t('cancel')] : [tCommon('save'), t('discard'), t('cancel')]; + const buttons = discarding ? [t('discard'), t('cancel')] : [tUx('try_again'), t('discard'), t('cancel')]; void dialogs.confirm({ type: 'warning', message: discarding ? t('discard_draft_title') : t('close_draft_title'), - detail: discarding ? t('discard_draft_message') : t('close_draft_message'), + detail: discarding ? t('discard_draft_message') : tUx('save_failed_close'), buttons, defaultId: discarding ? 1 : 0, }).then((choice) => { if (buttons[choice] === t('discard')) handleDiscardAndClose(); - else if (!discarding && choice === 0) void handleSaveDraftAndClose(); + else if (!discarding && choice === 0) void handleSaveDraftAndClose({ fromClose: true }); else afterCloseRef.current = null; }); }; @@ -4629,19 +4751,29 @@ export function EmailComposer({ openCloseDialog('discard'); }; + // X, Escape, another message, a host's tab: one rule, as drafts save + // themselves. Closing keeps the draft and says so, with Discard on the + // toast; it asks only when the save fails. A message the user hasn't + // touched (a reply opened and closed again) leaves nothing behind. const handleClose = (afterClose?: () => void) => { if (saveCloseAttemptRef.current?.saving) return; cancelSaveAndClose(); afterCloseRef.current = afterClose ?? null; + setCloseSaveFailed(false); + if (uneditedRef.current || (!isDirtyRef.current && !draftIdRef.current)) { + cleanClose(); + return; + } const savedAlready = draftIdRef.current !== null && changeVersionRef.current === savedVersionRef.current && !saveFailedRef.current && !attachmentsRef.current.some(att => att.uploading || att.error); - if (isDirtyRef.current && !savedAlready) { - openCloseDialog('close'); - } else { + if (!isDirtyRef.current || savedAlready) { + announceKeptDraft(); cleanClose(); + return; } + void handleSaveDraftAndClose({ fromClose: true }); }; // Expose the dirty-aware close handler so external hosts (e.g. the Pro tab @@ -4655,6 +4787,43 @@ export function EmailComposer({ }; }); + // Offline. With "Keep mail on this device" on, a send waits in the outbox + // and goes out once the server answers again. Without it there is nowhere + // safe to keep a message for later: Send says so and the text stays here. + const tComposeWindows = useTranslations('compose_windows'); + const browserOnline = useBrowserOnline(); + const [offlineSendNotice, setOfflineSendNotice] = useState<'saved' | 'unsaved' | null>(null); + const sendOrSayOffline = () => { + if (typeof navigator !== 'undefined' && navigator.onLine === false && !offlineActive(composingAccountId ?? null)) { + const saved = !!draftIdRef.current && changeVersionRef.current === savedVersionRef.current && !saveFailedRef.current; + setOfflineSendNotice(saved ? 'saved' : 'unsaved'); + announceStatus(tComposeWindows(saved ? 'offline_send_saved' : 'offline_send_unsaved')); + return; + } + handleSend(); + }; + // Back online: the notice goes, and what couldn't be saved meanwhile is saved now. + const wasOnlineRef = useRef(browserOnline); + useEffect(() => { + const cameBack = browserOnline && !wasOnlineRef.current; + wasOnlineRef.current = browserOnline; + if (!cameBack) return; + setOfflineSendNotice(null); + if (saveCloseAttemptRef.current || sentRef.current || isSendingRef.current || !isDirtyRef.current) return; + if (changeVersionRef.current === savedVersionRef.current && !saveFailedRef.current) return; + void saveDraftRef.current({ auto: true }); + }, [browserOnline]); + // Only asked while offline: it reads the organisation's policy and the store's support. + const offlineHint = browserOnline ? '' : tComposeWindows(offlineActive(composingAccountId ?? null) ? 'offline_hint_outbox' : 'offline_hint'); + + // "Edit sender…": From becomes a name and an address to type, starting + // from the identity's. + const startFromOverride = () => { + setFromOverrideEnabled(true); + if (!fromOverrideEmail && currentIdentity?.email) setFromOverrideEmail(currentIdentity.email); + if (!fromOverrideName && currentIdentity?.name) setFromOverrideName(currentIdentity.name); + }; + const handleComposerKeyDown = (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; // Keys from a dialog the composer opened (Insert link, ...) reach here @@ -4672,6 +4841,32 @@ export function EmailComposer({ const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey; const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey; if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) { + // Ctrl+S saves the draft (Outlook, Thunderbird, Apple Mail), not the + // page; Ctrl+Shift+C and Ctrl+Shift+B show Cc and Bcc (Gmail). + const key = e.key.toLowerCase(); + if (hasPrimaryModifier && !e.altKey && !e.shiftKey && key === 's') { + e.preventDefault(); + // Says "Draft saved" also when autosave already had everything. + if (!isSavingAndClosing) { + void saveDraft({ announce: true }).then((id) => { + if (!id || saveFailedRef.current) return; + setSaveStatus('saved'); + setTimeout(() => setSaveStatus((status) => (status === 'saved' ? 'idle' : status)), 2000); + }); + } + return; + } + if (hasPrimaryModifier && !e.altKey && e.shiftKey && (key === 'c' || key === 'b')) { + e.preventDefault(); + if (key === 'c') { + setShowCc(true); + setTimeout(() => ccInputRef.current?.focus(), 0); + } else { + setShowBcc(true); + setTimeout(() => bccInputRef.current?.focus(), 0); + } + return; + } // A plugin's composer button with a shortcut (composer-toolbar-actions) // works while the composer has focus, also while typing. if (!e.altKey && !hasPrimaryModifier) return; @@ -4687,20 +4882,25 @@ export function EmailComposer({ filesPicker || showSaveAsTemplate || showScheduleDialog || - showAttachmentWarning || - showEmptySubjectWarning || - showPlaceholderWarning || + sendWarnings || showCloseDialog ) return; if (isPlainEscape) { if (activeAutoField) return; + // A selected recipient: Escape only lets go of it (its own handler). + if (e.target instanceof Element && e.target.closest('[data-recipient-chip]')) return; // Same for the "@" recipient list in the body: this handler runs in the // capture phase, before the editor could close the list itself. if (isRecipientMentionActive(editorRef.current)) return; // And for proofreading's popup and panel: Escape closes only them. if (proofreading.popup || proofreading.panel) return; e.preventDefault(); + // Full screen first goes back to where the composer was. + if (maximized) { + setMaximized(false); + return; + } handleClose(); return; } @@ -4718,7 +4918,7 @@ export function EmailComposer({ } e.preventDefault(); - handleSend(); + sendOrSayOffline(); return; } @@ -4746,7 +4946,13 @@ export function EmailComposer({ data-testid="email-composer" // Edits made while the draft moves wouldn't move with it. inert={isPoppingOut || undefined} - className={cn("flex h-full bg-background relative", className)} + className={cn( + "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)]", + )} + data-maximized={maximized || undefined} onKeyDownCapture={handleComposerKeyDown} // Bubble phase, in the same batch as the field's own onChange. A state // change in the capture phase renders first and puts the old value back. @@ -4874,28 +5080,28 @@ export function EmailComposer({ {saveStatus === 'saving' && (
- {t('saving')} + {t('saving')}
)} {saveStatus === 'saved' && (
- {savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')} + {savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')}
)} {saveStatus === 'error' && (
- {t('save_failed')} + {t('save_failed')}
)} {/* Mobile: keep scheduled send reachable without consuming footer space. */} - {composerClient?.hasDelayedSend() || hasSendMenuActions ? ( + {phoneLayout && (composerClient?.hasDelayedSend() || hasSendMenuActions ? (
) : ( - )} + ))} {rightPanelToggle} - {popOutButton("hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", "w-4 h-4")} + {canMaximize && ( + + )} + {popOutButton(cn(phoneLayout ? "hidden md:grid" : "grid", "h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"), "w-4 h-4")} {!closeBesideControls && composerCloseButton} )} @@ -4973,11 +5191,20 @@ export function EmailComposer({ aria-label={t('from_override.email_label')} /> - ) : identities.length > 1 ? ( + ) : identities.length > 0 ? ( ) : ( @@ -5027,6 +5256,9 @@ export function EmailComposer({ )} {!fromOverrideEnabled && ( 0} + open={identities.length > 0 ? subAddressOpen : undefined} + onOpenChange={setSubAddressOpen} baseEmail={ (selectedIdentityId ? identities.find(id => id.id === selectedIdentityId)?.email @@ -5048,7 +5280,7 @@ export function EmailComposer({ )} - {customFromEnabled && ( + {customFromEnabled && (fromOverrideEnabled || identities.length === 0) && ( - )} )} @@ -5550,6 +5776,23 @@ export function EmailComposer({ )} + {offlineSendNotice && !browserOnline && ( +
+
+ )} + {/* Bottom toolbar. In Mountain View's window both halves fold into one row (see [data-mv-compose-footer] in globals.css): Send first, the tools after it, and Discard as a bin at the far end. */} @@ -5604,7 +5847,7 @@ export function EmailComposer({ {t('attach')} )} - {!plainTextMode && ( + {!plainTextMode && (mvWindow || phoneLayout) && ( )} {composerClient?.hasDelayedSend() || hasSendMenuActions ? ( -
+
) : ( - - - } - /> - - setShowEmptySubjectWarning(false)} - role="alertdialog" - showClose={false} - title={t('empty_subject.title')} - description={t('empty_subject.message')} - footer={ - <> - - - - } - > - - - - setShowPlaceholderWarning(false)} - role="alertdialog" - showClose={false} - title={t('placeholder_warning.title')} - description={t('placeholder_warning.message')} - footer={ - <> - - - - } - > -
    - {placeholderWarningMatches.slice(0, 5).map((p) => ( -
  • {p}
  • - ))} - {placeholderWarningMatches.length > 5 && ( -
  • - {t('placeholder_warning.more', { count: placeholderWarningMatches.length - 5 })} -
  • - )} -
- -
- - setProofreadingWarning(null)} - role="alertdialog" - showClose={false} - title={tProofreading('send_warning.title')} - description={tProofreading('send_warning.body', { count: proofreadingWarning?.count ?? 0 })} - footer={ - <> - - - - } + { + const first = sendWarnings?.items[0]; + setSendWarnings(null); + // The focus trap hands focus back to the Send button as it tears + // down: go to what the first warning is about on the next tick. + setTimeout(() => { + if (first?.kind === 'subject') subjectInputRef.current?.focus(); + else if (first?.kind === 'placeholders') selectPlaceholder(first.matches[0]); + else if (first?.kind === 'proofreading') proofreading.navigate(1); + else if (first?.kind === 'text') { + if (plainTextModeRef.current) bodyRef.current?.focus(); + else editorRef.current?.commands.focus(); + } + }, 0); + }} + onSendAnyway={(dontAskAgain) => { + if (!sendWarnings) return; + const { items, delayedUntil } = sendWarnings; + for (const warning of items) { + if (warning.kind === 'subject') { + emptySubjectConfirmedRef.current = true; + if (dontAskAgain) updateSetting('emptySubjectWarningEnabled', false); + } else if (warning.kind === 'text') { + emptyTextConfirmedRef.current = true; + } else if (warning.kind === 'placeholders') { + warning.matches.forEach((p) => confirmedPlaceholdersRef.current.add(p)); + if (dontAskAgain) updateSetting('placeholderWarningEnabled', false); + } else if (warning.kind === 'proofreading') { + proofreadingConfirmedRef.current = true; + } + } + setSendWarnings(null); + handleSend({ warningsConfirmed: true, delayedUntil }); + }} /> {closeDialogKind === 'close' && ( - )} @@ -6210,6 +6370,9 @@ export function RecipientChipInput({ // one by default: Shift+Tab from the input lands next to it. const [rovingChip, setRovingChip] = useState(null); const chipRefs = useRef>([]); + // The chip a Backspace in the empty input selected: a second one removes it + // and goes back to typing. + const backspaceChipRef = useRef(null); const errorId = `${labelId}-error`; const chipHintId = `${labelId}-chip-hint`; const fieldNames: Record<'to' | 'cc' | 'bcc', string> = { to: t('to'), cc: t('cc'), bcc: t('bcc') }; @@ -6243,6 +6406,25 @@ export function RecipientChipInput({ const handleChipKeyDown = (e: React.KeyboardEvent, index: number) => { if (e.target !== e.currentTarget) return; + const fromInput = backspaceChipRef.current === index; + backspaceChipRef.current = null; + // Escape lets go of the chip (the composer stays open); typing goes on in the input. + if (e.key === 'Escape') { + e.preventDefault(); + inputRef.current?.focus(); + return; + } + if (fromInput && (e.key === 'Backspace' || e.key === 'Delete')) { + e.preventDefault(); + onChipsChange(chips.filter((_, i) => i !== index)); + setRovingChip(null); + inputRef.current?.focus(); + return; + } + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && e.key !== ' ') { + inputRef.current?.focus(); + return; + } const rtl = typeof window !== 'undefined' && getComputedStyle(e.currentTarget).direction === 'rtl'; const back = rtl ? 'ArrowRight' : 'ArrowLeft'; const forward = rtl ? 'ArrowLeft' : 'ArrowRight'; @@ -6287,11 +6469,16 @@ export function RecipientChipInput({ } }, [editingChip]); - // Format a recipient for display in a chip / context menu + // A chip shows the name only (the address when there is none); the full + // "Name
" is in its tooltip and at the top of its menu. const formatChipDisplay = (r: Recipient): string => r.group ? `${r.name || 'Group'} (${r.group.members.length})` - : r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email; + : r.name && r.name !== r.email ? r.name : r.email; + const formatChipFull = (r: Recipient): string => + r.group + ? formatChipDisplay(r) + : r.name && r.name !== r.email ? `${r.name} <${r.email}>` : r.email; // Handle saving an edited chip const handleSaveEdit = (newValue: string) => { @@ -6422,14 +6609,13 @@ export function RecipientChipInput({ return; } - // Backspace on an empty input pulls the last chip back into the input for - // quick editing. + // Backspace on an empty input selects the last chip; the next one removes + // it whole. It used to put the address back as text, so a few presses + // left half an address behind. Editing is a double-click, Enter or F2. if (e.key === 'Backspace' && !inputText && chips.length > 0) { - // Or the key goes on to delete the closing ">" of what was just put back. e.preventDefault(); - const lastChip = chips[chips.length - 1]; - onChipsChange(chips.slice(0, -1)); - onInputChange(formatRecipient(lastChip.name, lastChip.email)); + backspaceChipRef.current = chips.length - 1; + focusChip(chips.length - 1); return; } }; @@ -6624,12 +6810,15 @@ export function RecipientChipInput({ : ( customColor && colorStyles[customColor] ? `${colorStyles[customColor]} cursor-grab active:cursor-grabbing` : "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing"), - !isEditing && draggingIndex === i && "opacity-50" + !isEditing && draggingIndex === i && "opacity-50", + // Selected: by Backspace in the field, arrows or Tab. + !isEditing && "focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary" )} onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)} data-invalid={isInvalid || undefined} title={isInvalid ? t('invalid_recipient') : undefined} role="listitem" + data-recipient-chip="" ref={(el) => { chipRefs.current[i] = el; }} tabIndex={isEditing ? undefined : i === tabChip ? 0 : -1} aria-describedby={isEditing ? undefined : chipHintId} @@ -6684,10 +6873,12 @@ export function RecipientChipInput({ ) : ( m.email).join(', ') : undefined} + title={chip.group ? chip.group.members.map(m => m.email).join(', ') : formatChipFull(chip)} > {chip.group && } {chipDisplay} + {/* Screen readers still hear the address the chip no longer shows. */} + {!chip.group && chipDisplay !== chip.email && {` (${chip.email})`}} {isInvalid && {`, ${t('invalid_recipient')}`}} )} @@ -6784,8 +6975,8 @@ export function RecipientChipInput({ > {contextMenu.data && ( <> -
- {formatChipDisplay(contextMenu.data.recipient)} +
+ {formatChipFull(contextMenu.data.recipient)}
{!contextMenu.data.recipient.group && ( diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 978e77a8a..80ffc1c4c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -49,8 +49,8 @@ import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests"; import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/plugins/app-side-panel"; import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry"; import { QuickReplyPluginActions } from "./quick-reply-plugin-actions"; -import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope"; -import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils"; +import { quickReplyEnvelope, quickReplyRecipients } from "@/lib/outgoing/quick-reply-envelope"; +import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, cleanPreview } from "@/lib/utils"; import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; import { itemsToHide, toolbarHideOrder } from "@/lib/toolbar-overflow"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; @@ -302,6 +302,8 @@ interface EmailViewerProps { onReplyAllConversationEmail?: (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; onEditDraft?: () => void; onCancelScheduledForEdit?: () => void; /** Stop a scheduled message from going out; it goes back to Drafts. */ @@ -1373,6 +1375,7 @@ export function EmailViewer({ onReplyConversationEmail, onReplyAllConversationEmail, mvInlineComposer, + replyComposerOpen, onEditDraft, onCancelScheduledForEdit, onCancelScheduledSend, @@ -1396,6 +1399,7 @@ export function EmailViewer({ const archiveActionEnabled = useArchiveActionEnabled(); const tWindows = useTranslations('pwaWindows'); const tComposer = useTranslations('email_composer'); + const tWords = useTranslations('compose_words'); const tNotifications = useTranslations('notifications'); const tCommon = useTranslations('common'); const tFiles = useTranslations('files'); @@ -1689,6 +1693,13 @@ export function EmailViewer({ if (isQuickReplyFocused) quickReplyBoxRef.current?.scrollIntoView?.({ block: 'nearest' }); }, [isQuickReplyFocused]); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); + // "Reply to Ben Carter…": who the quick reply goes to, by name. + const quickReplyNames = email + ? quickReplyRecipients(email, identities).map((r) => r.name || r.email).join(', ') + : ''; + const quickReplyPlaceholder = quickReplyNames + ? tWords('quick_reply_to', { names: quickReplyNames }) + : t('quick_reply_placeholder'); /** The recipients and identity the quick reply would be sent with. */ const quickMetadata = (): ComposerMetadata => { if (!email) return { subject: '', to: [], attachments: [] }; @@ -3503,7 +3514,7 @@ export function EmailViewer({ const latestSrcDocRef = useRef(null); const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc; // Not on spam: a reply confirms the address to the sender. - const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder + const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder && (!effectiveEmailContent.isHtml || iframeReady); // The plain-text body, where reader-above-quote mounts between text and quote. const [plainBodyEl, setPlainBodyEl] = useState(null); @@ -6204,7 +6215,7 @@ ${printWatermark ? printWatermark.html : ''} void handleSendQuickReply(); } }} - placeholder={t('quick_reply_placeholder')} + placeholder={quickReplyPlaceholder} className={cn( "w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg", "hover:border-accent focus:border-primary focus:outline-hidden transition-all", @@ -6216,10 +6227,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Action buttons - show when focused or has text */} {(isQuickReplyFocused || quickReplyText) && ( -
-
- {t('characters_count', { count: quickReplyText.length })} -
+
void; /** The name of the button that does it ("Image description"). */ editAltLabel?: string; + /** The chip on an image that has no description yet ("Add description"). */ + addAltLabel?: string; } function ResizableImageView({ node, updateAttributes, selected, extension, getPos }: NodeViewProps) { - const { onEditAlt, editAltLabel } = extension.options as ResizableImageOptions; + const { onEditAlt, editAltLabel, addAltLabel } = extension.options as ResizableImageOptions; + // No description and not marked decorative (""): an optional chip asks for one. + const undescribed = node.attrs.alt === null || node.attrs.alt === undefined; const imgRef = useRef(null); const [resizing, setResizing] = useState(false); // A remote image (a draft from another client, pasted web content) stays @@ -93,6 +97,23 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo style={{ width: "100%", height: "auto", display: "block" }} draggable={false} /> + {undescribed && onEditAlt && addAltLabel && !selected && ( + // Optional, never in the way: pasting or dropping an image asks nothing. + + )} {selected && onEditAlt && ( // Edit what the image says to screen readers. Reachable with Tab // while the image is selected, and from the toolbar. @@ -140,7 +161,7 @@ export const ResizableImage = Node.create({ name: "image", addOptions() { - return { onEditAlt: undefined, editAltLabel: undefined }; + return { onEditAlt: undefined, editAltLabel: undefined, addAltLabel: undefined }; }, group: "inline", inline: true, diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 13068e590..952a9da30 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -70,8 +70,56 @@ import { Rows3, Columns3, ImageIcon, + Ellipsis, } from "@/components/icons"; +/** + * Groups of the formatting toolbar that move behind "More formatting" when one + * row is too narrow for all of them, the least used first. Each with its + * buttons and the separators that go with it. + */ +const FOLDABLE_GROUPS = [ + { id: "history", buttons: 3, separators: 2 }, + { id: "align", buttons: 3, separators: 1 }, + { id: "headings", buttons: 2, separators: 1 }, + { id: "table", buttons: 1, separators: 0 }, + { id: "blocks", buttons: 2, separators: 0 }, +] as const; +type FoldableGroup = (typeof FOLDABLE_GROUPS)[number]["id"]; +/** + * Always in the row: font size, bold, italic, underline, strikethrough, the + * two colours, the lists, link, an image's description and More (12), with + * their 3 separators. + */ +const FIXED_TOOLBAR = { buttons: 12, separators: 3 }; + +/** + * How many of FOLDABLE_GROUPS go behind "More formatting" for one row of + * `width` px. 0 for a toolbar that isn't laid out (hidden, or not measured). + */ +export function formattingFoldCount(width: number, sizes: { button: number; gap: number; separator: number; directionButton?: boolean }): number { + if (!(width > 0)) return 0; + const span = (buttons: number, separators: number) => buttons * (sizes.button + sizes.gap) + separators * (sizes.separator + sizes.gap); + const groups = FOLDABLE_GROUPS.map((group) => span(group.buttons + (group.id === "align" && sizes.directionButton ? 1 : 0), group.separators)); + let needed = span(FIXED_TOOLBAR.buttons, FIXED_TOOLBAR.separators) + groups.reduce((sum, group) => sum + group, 0); + // All of it fits without the More button. + if (needed - (sizes.button + sizes.gap) <= width + 1) return 0; + for (let count = 1; count <= groups.length; count++) { + needed -= groups[count - 1]; + if (needed <= width + 1) return count; + } + return groups.length; +} + +/** The toolbar's inner width and the sizes of what is in it, in px. */ +function measureFormattingToolbar(toolbar: HTMLElement, directionButton: boolean): number { + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + const style = getComputedStyle(toolbar); + const width = toolbar.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0); + const button = toolbar.querySelector(":scope > [data-toolbar-item], :scope > div > [data-toolbar-item]")?.offsetWidth || 1.75 * rem; + return formattingFoldCount(width, { button, gap: 0.125 * rem, separator: 1 + 0.25 * rem, directionButton }); +} + export interface InlineImageUpload { src: string; cid?: string; @@ -440,6 +488,7 @@ export function RichTextEditor({ }: RichTextEditorProps) { const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport); const tComposer = useTranslations("email_composer"); + const tUx = useTranslations("compose_ux"); const tQuote = useTranslations("email_viewer"); const onImageUploadRef = React.useRef(onImageUpload); onImageUploadRef.current = onImageUpload; @@ -464,6 +513,8 @@ export function RichTextEditor({ // Images waiting for a description, one dialog at a time: a new image is // found by its src (it has no alt yet), an existing one by its position. const [altQueue, setAltQueue] = useState([]); + // Insert link, for the key handler set up with the editor. + const addLinkRef = useRef<(() => void) | null>(null); const editor = useEditor({ extensions: [ @@ -490,6 +541,7 @@ export function RichTextEditor({ ResizableImage.configure({ onEditAlt: (pos: number) => setAltQueue((queue) => [...queue, { pos }]), editAltLabel: tComposer("image_alt.edit"), + addAltLabel: tUx("add_description"), }), Placeholder.configure({ placeholder, @@ -565,6 +617,12 @@ export function RichTextEditor({ event.preventDefault(); return true; } + // Ctrl+K inserts a link, as in Gmail, Outlook and Word. + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "k" && addLinkRef.current) { + event.preventDefault(); + addLinkRef.current(); + return true; + } return false; }, handleDrop: (view, event) => { @@ -584,11 +642,11 @@ export function RichTextEditor({ if (result) { const { state } = view; const pos = view.posAtCoords({ left: event.clientX, top: event.clientY }); - // No alt yet (never the file name): the user is asked next. + // No alt yet (never the file name). const node = state.schema.nodes.image.create({ src: result.src, alt: null, cid: result.cid }); const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node); view.dispatch(tr); - setAltQueue((queue) => [...queue, { src: result.src }]); + // No dialog: the image offers "Add description" itself. } }); } @@ -607,7 +665,7 @@ export function RichTextEditor({ const node = state.schema.nodes.image.create({ src: result.src, alt: null, cid: result.cid }); const tr = state.tr.replaceSelectionWith(node); view.dispatch(tr); - setAltQueue((queue) => [...queue, { src: result.src }]); + // No dialog: the image offers "Add description" itself. } }); } @@ -694,6 +752,7 @@ export function RichTextEditor({ const { from, to } = editor.state.selection; setLinkDialog({ href: previousUrl ?? "", from, to }); }, [editor]); + addLinkRef.current = addLink; const applyLink = useCallback((url: string) => { if (!editor || !linkDialog) return; editor @@ -716,6 +775,7 @@ export function RichTextEditor({ }, [editor, linkDialog]); const tToolbar = useTranslations("email_composer.toolbar"); + const tWindows = useTranslations("compose_windows"); const tCommon = useTranslations("common"); const [tableMenuOpen, setTableMenuOpen] = useState(false); const tableWrapperRef = useRef(null); @@ -734,11 +794,13 @@ export function RichTextEditor({ // focus last keeps it, the arrows, Home and End move inside. const toolbarRef = useRef(null); const rovingItemRef = useRef(null); + // Buttons in the "More formatting" panel are the panel's, with its own keys. + const inRow = (el: HTMLElement) => !el.closest("[data-toolbar-more-panel]"); const toolbarItems = () => Array.from(toolbarRef.current?.querySelectorAll("[data-toolbar-item]") ?? []) - .filter((el) => !el.disabled); + .filter((el) => !el.disabled && inRow(el)); const syncToolbarTabStops = (focus?: HTMLButtonElement) => { - const all = Array.from(toolbarRef.current?.querySelectorAll("[data-toolbar-item]") ?? []); + const all = Array.from(toolbarRef.current?.querySelectorAll("[data-toolbar-item]") ?? []).filter(inRow); const enabled = all.filter((el) => !el.disabled); const current = focus ?? enabled.find((el) => el.dataset.toolbarItem === rovingItemRef.current) ?? enabled[0]; if (current) rovingItemRef.current = current.dataset.toolbarItem ?? null; @@ -757,6 +819,36 @@ export function RichTextEditor({ return !!stop && document.activeElement === stop; }; + // One row: what doesn't fit goes behind "More formatting" (a wrapped + // second row took the body's room in a narrow composer). + const [foldCount, setFoldCount] = useState(0); + const [moreOpen, setMoreOpen] = useState(false); + const moreButtonRef = useRef(null); + const moreWrapperRef = useRef(null); + const hasEditor = !!editor; + useLayoutEffect(() => { + const toolbar = toolbarRef.current; + if (!toolbar) return; + const measure = () => setFoldCount(measureFormattingToolbar(toolbar, !!rtlEditingSupport)); + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + measure(); + setMoreOpen(false); + }); + observer.observe(toolbar); + return () => observer.disconnect(); + }, [hasEditor, rtlEditingSupport]); + + useEffect(() => { + if (!moreOpen) return; + const handler = (e: MouseEvent) => { + if (moreWrapperRef.current && !moreWrapperRef.current.contains(e.target as Node)) setMoreOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [moreOpen]); + useEffect(() => { if (!fontSizeMenuOpen) return; const handler = (e: MouseEvent) => { @@ -816,6 +908,15 @@ export function RichTextEditor({ const altTarget = findAltTarget(editor, altRequest); const imageSelected = editor.isActive("image"); + // A group that doesn't fit renders in the "More formatting" panel instead, in row order. + const folded = new Set(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id)); + const overflow: React.ReactNode[] = []; + const place = (group: FoldableGroup, node: React.ReactNode) => { + if (!folded.has(group)) return node; + overflow.push({node}); + return null; + }; + const onToolbarKeyDown = (e: React.KeyboardEvent) => { const target = e.target as HTMLElement; // Keys inside a popover are the popover's. @@ -836,7 +937,7 @@ export function RichTextEditor({ }; const onToolbarFocus = (e: React.FocusEvent) => { const target = e.target as HTMLElement; - if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item")) syncToolbarTabStops(target); + if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item") && inRow(target)) syncToolbarTabStops(target); }; const renderPalette = (current: string | null, apply: (color: string) => void, removeLabel: string, remove: () => void) => ( @@ -1101,6 +1202,7 @@ export function RichTextEditor({ + {place("headings", <> + )} + {place("blocks", <> + )} + {place("align", <> + )} )} -
+ {place("table",
) )} -
+
)} + {place("history", <> + )} + + {overflow.length > 0 && ( +
+ setMoreOpen((v) => !v)} + title={tWindows("more_formatting")} + > + + + {moreOpen && ( + setMoreOpen(false)} + className="p-1 flex w-max max-w-[16rem] flex-wrap items-center gap-0.5" + > +
+ {overflow} +
+
+ )} +
+ )}
{/* Editor */} diff --git a/components/email/send-warnings-dialog.tsx b/components/email/send-warnings-dialog.tsx new file mode 100644 index 000000000..a29709903 --- /dev/null +++ b/components/email/send-warnings-dialog.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; + +/** Something the composer asks about before a message goes out. */ +export type SendWarning = + | { kind: "subject" } + | { kind: "text" } + | { kind: "placeholders"; matches: string[] } + | { kind: "attachment"; keyword: string } + | { kind: "proofreading"; count: number }; + +interface SendWarningsDialogProps { + /** What to ask about, in the order the composer checks it. Closed when null. */ + warnings: SendWarning[] | null; + /** "Back to editing", Escape or a click outside. */ + onBack: () => void; + /** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */ + onSendAnyway: (dontAskAgain: boolean) => void; +} + +/** The warnings that can be switched off in Settings (subject and placeholders). */ +const HAS_SETTING = new Set(["subject", "placeholders"]); + +/** + * One question before sending, whatever it found: a single warning keeps its + * 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) { + const t = useTranslations("email_composer"); + const tSend = useTranslations("compose_send"); + const tProofreading = useTranslations("proofreading"); + const [dontAskAgain, setDontAskAgain] = useState(false); + const [shownFor, setShownFor] = useState(null); + // A new question starts with the box unticked. + if (warnings !== shownFor) { + setShownFor(warnings); + setDontAskAgain(false); + } + + const items = warnings ?? []; + const single = items.length === 1 ? items[0] : null; + + const placeholderChips = (matches: string[]) => ( +
    + {matches.slice(0, 5).map((p) => ( +
  • {p}
  • + ))} + {matches.length > 5 && ( +
  • + {t("placeholder_warning.more", { count: matches.length - 5 })} +
  • + )} +
+ ); + + let title: string; + let description: string | undefined; + let backLabel = tSend("back"); + let sendLabel = tSend("send_anyway"); + let body: React.ReactNode = null; + + if (single?.kind === "subject") { + title = t("empty_subject.title"); + description = t("empty_subject.message"); + backLabel = t("empty_subject.back"); + sendLabel = t("empty_subject.send_anyway"); + } else if (single?.kind === "text") { + title = tSend("empty_text_title"); + description = tSend("empty_text_message"); + } else if (single?.kind === "placeholders") { + title = t("placeholder_warning.title"); + description = t("placeholder_warning.message"); + backLabel = t("placeholder_warning.back"); + sendLabel = t("placeholder_warning.send_anyway"); + body = placeholderChips(single.matches); + } else if (single?.kind === "attachment") { + title = t("forgot_attachment.title"); + description = t("forgot_attachment.message", { keyword: single.keyword }); + backLabel = t("forgot_attachment.back"); + sendLabel = t("forgot_attachment.send_anyway"); + } else if (single?.kind === "proofreading") { + title = tProofreading("send_warning.title"); + description = tProofreading("send_warning.body", { count: single.count }); + backLabel = tProofreading("send_warning.review"); + sendLabel = tProofreading("send_warning.send"); + } else { + title = tSend("warnings_title"); + body = ( +
    + {items.map((w) => ( +
  • + +
    + {w.kind === "subject" && tSend("warning_subject")} + {w.kind === "text" && tSend("warning_text")} + {w.kind === "attachment" && tSend("warning_attachment", { keyword: w.keyword })} + {w.kind === "proofreading" && tSend("warning_proofreading", { count: w.count })} + {w.kind === "placeholders" && ( + <> + {tSend("warning_placeholders")} + {placeholderChips(w.matches)} + + )} +
    +
  • + ))} +
+ ); + } + + return ( + 0} + onClose={onBack} + role="alertdialog" + showClose={false} + title={title} + description={description} + testId="send-warnings" + footer={ + <> + + + + } + > + {body} + {single && HAS_SETTING.has(single.kind) && ( + + )} + + ); +} diff --git a/components/files/__tests__/file-picker-dialog.test.tsx b/components/files/__tests__/file-picker-dialog.test.tsx index 75933339e..8991dca32 100644 --- a/components/files/__tests__/file-picker-dialog.test.tsx +++ b/components/files/__tests__/file-picker-dialog.test.tsx @@ -44,6 +44,16 @@ function renderPicker(list: () => Promise = async () => TREE) { } describe('FilePickerDialog', () => { + // The X in the header was labelled "Cancel" like the footer button. + it('names the header X "Close" and keeps one Cancel button, both closing', async () => { + const { onClose } = renderPicker(); + await screen.findByText('invoice.pdf'); + expect(screen.getAllByRole('button', { name: 'cancel' })).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'close' })); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + expect(onClose).toHaveBeenCalledTimes(2); + }); + it('lists the root without hidden or shared nodes, and offers Shared with me', async () => { renderPicker(); expect(await screen.findByText('invoice.pdf')).toBeInTheDocument(); diff --git a/components/files/file-picker-dialog.tsx b/components/files/file-picker-dialog.tsx index 92d4168cb..a6dbf1fbb 100644 --- a/components/files/file-picker-dialog.tsx +++ b/components/files/file-picker-dialog.tsx @@ -45,6 +45,7 @@ function byFolderThenName(a: FileNode, b: FileNode): number { export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogProps) { const t = useTranslations("files"); const tComposer = useTranslations("email_composer"); + const tCommon = useTranslations("common"); const [nodes, setNodes] = useState(null); const [failed, setFailed] = useState(false); const [attempt, setAttempt] = useState(0); @@ -245,7 +246,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr + {!hideTrigger && ( + + )} {/* Popover */} {isOpen && ( diff --git a/components/mail/__tests__/mail-app-continue-draft.test.tsx b/components/mail/__tests__/mail-app-continue-draft.test.tsx new file mode 100644 index 000000000..0c4aaf306 --- /dev/null +++ b/components/mail/__tests__/mail-app-continue-draft.test.tsx @@ -0,0 +1,178 @@ +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'; + +// The "Continue draft" bar and clicking another message while writing: the +// bar's X is its own button and deletes the saved draft, with Undo; another +// message closes the composer the way its X does (it keeps the draft). + +const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } })); +const stub = vi.hoisted(() => ({ requestClose: null as null | ((afterClose?: () => void) => void) })); + +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ onCompose }: { onCompose: () => void }) => , +})); +// The composer hands its draft to the host as it unmounts, and offers the +// host its close when the test says so. +vi.mock('@/components/email/email-composer', async () => { + const { useEffect } = await import('react'); + return { + EmailComposer: ({ onSaveState, requestCloseRef }: { + onSaveState: (data: Record) => void; + requestCloseRef?: { current: unknown }; + }) => { + useEffect(() => { + if (requestCloseRef && stub.requestClose) requestCloseRef.current = stub.requestClose; + return () => { + if (requestCloseRef) requestCloseRef.current = null; + onSaveState({ subject: 'Parked', draftId: 'draft-7', draftAccountId: 'acc', mode: 'compose' }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return
stub composer
; + }, + }; +}); +vi.mock('@/components/search/mv-search-form', () => ({ + MvSearchForm: ({ onSearch }: { onSearch: () => void }) => , +})); +vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () =>
message viewer
})); +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: { id: string }) => void }) => ( + + ), +})); + + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox; +let deleteEmail: ReturnType; + +beforeEach(() => { + device.current = { isMobile: false, isTablet: false, isDesktop: true }; + stub.requestClose = 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) }); + deleteEmail = vi.fn().mockResolvedValue(undefined); + const client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + deleteEmail, + 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: [], selectedEmail: null, 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.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + useToastStore.getState().clearToasts(); +}); + +async function parkDraft() { + render(); + fireEvent.click(screen.getByText('stub compose')); + await screen.findByText('stub composer'); + // No close offered: the composer unmounts and parks its draft. + fireEvent.click(screen.getByText('stub other mail')); + await screen.findByText('email_composer.continue_draft'); +} + +const discardButton = () => screen.getByRole('button', { name: 'discard_draft' }); + +describe('the Continue draft bar', () => { + it('has a Discard button of its own, outside the bar button', async () => { + await parkDraft(); + expect(discardButton().parentElement?.closest('button')).toBeNull(); + }); + + it('deletes the saved draft once the Undo window is over', async () => { + await parkDraft(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + fireEvent.click(discardButton()); + expect(screen.queryByText('email_composer.continue_draft')).toBeNull(); + const undoToast = useToastStore.getState().toasts.find((t) => t.title === 'draft_discarded'); + expect(undoToast?.action?.label).toBe('undo'); + expect(deleteEmail).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(undoToast!.duration! + 10); }); + expect(deleteEmail).toHaveBeenCalledWith('draft-7', 'acc'); + }); + + it('brings the bar back on Undo and keeps the draft', async () => { + await parkDraft(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + fireEvent.click(discardButton()); + const undoToast = useToastStore.getState().toasts.find((t) => t.title === 'draft_discarded')!; + act(() => undoToast.action!.onClick()); + expect(await screen.findByText('email_composer.continue_draft')).toBeInTheDocument(); + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + expect(deleteEmail).not.toHaveBeenCalled(); + }); +}); + +describe('another message clicked while writing', () => { + it('closes the composer through its own close, which keeps the draft', async () => { + const requestClose = vi.fn(); + stub.requestClose = requestClose; + render(); + fireEvent.click(screen.getByText('stub compose')); + await screen.findByText('stub composer'); + + fireEvent.click(screen.getByText('stub other mail')); + + await waitFor(() => expect(requestClose).toHaveBeenCalledTimes(1)); + // Still there until it has saved and closes itself; no bar. + expect(screen.getByText('stub composer')).toBeInTheDocument(); + expect(screen.queryByText('email_composer.continue_draft')).toBeNull(); + }); +}); diff --git a/components/mail/__tests__/mail-app-docked-reply.test.tsx b/components/mail/__tests__/mail-app-docked-reply.test.tsx new file mode 100644 index 000000000..4c5630b23 --- /dev/null +++ b/components/mail/__tests__/mail-app-docked-reply.test.tsx @@ -0,0 +1,159 @@ +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'; + +// Reply in the standard layout on a computer: the composer docks under the +// message it answers, which stays on screen above it, as in Gmail and Outlook. + +const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } })); +const stub = vi.hoisted(() => ({ requestClose: null as null | ((afterClose?: () => void) => void) })); + +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ onCompose }: { onCompose: () => void }) => , +})); +// The composer hands its draft to the host as it unmounts, and offers the +// host its close when the test says so. +vi.mock('@/components/email/email-composer', async () => { + const { useEffect } = await import('react'); + return { + EmailComposer: ({ onSaveState, requestCloseRef, replyTo }: { + onSaveState: (data: Record) => void; + requestCloseRef?: { current: unknown }; + replyTo?: { quoteHeaderHtml?: string }; + }) => { + useEffect(() => { + if (requestCloseRef && stub.requestClose) requestCloseRef.current = stub.requestClose; + return () => { + if (requestCloseRef) requestCloseRef.current = null; + onSaveState({ subject: 'Parked', draftId: 'draft-7', draftAccountId: 'acc', mode: 'compose' }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return
stub composer
; + }, + }; +}); +vi.mock('@/components/search/mv-search-form', () => ({ + MvSearchForm: ({ onSearch }: { onSearch: () => void }) => , +})); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ onReply, onForward, replyComposerOpen }: { onReply: () => void; onForward: () => void; replyComposerOpen?: boolean }) => ( +
+ message viewer + + +
+ ), +})); +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: { id: string }) => void }) => ( + + ), +})); + + +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.requestClose = 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.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + useToastStore.getState().clearToasts(); +}); + +describe('a reply in the standard layout', () => { + for (const action of ['stub reply', 'stub forward']) { + it(`docks under the message, which stays on screen (${action})`, async () => { + render(); + fireEvent.click(await screen.findByText(action)); + const composer = await screen.findByText('stub composer'); + expect(screen.getByTestId('viewer')).toBeInTheDocument(); + expect(composer.closest('[data-reply-dock]')).not.toBeNull(); + // No quick reply box under the message as well. + expect(screen.getByTestId('viewer')).toHaveAttribute('data-reply-open', 'yes'); + }); + } + + // compose-30: a forward says who the message went to. + it('gives a forward a header with To', async () => { + render(); + fireEvent.click(await screen.findByText('stub forward')); + await waitFor(() => expect(screen.getByTestId('stub-composer').getAttribute('data-quote')).toContain('forward_to_label')); + }); + + it('takes the whole pane for a new message', async () => { + render(); + fireEvent.click(screen.getByText('stub compose')); + await screen.findByText('stub composer'); + expect(screen.queryByTestId('viewer')).toBeNull(); + }); +}); diff --git a/components/mail/__tests__/send-closes-composer.test.tsx b/components/mail/__tests__/send-closes-composer.test.tsx new file mode 100644 index 000000000..aa257cc5a --- /dev/null +++ b/components/mail/__tests__/send-closes-composer.test.tsx @@ -0,0 +1,156 @@ +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 { 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'; + +// compose-24: after Send the composer stayed on screen, greyed and without a +// word, until the server answered. It closes at once now, "Sending…" and then +// "Sent · View" say what happens, and a send that fails brings the text back. +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: ({ onSend, onSendFailed, initialData }: { + onSend?: (data: unknown) => Promise; + onSendFailed?: (state: unknown) => void; + initialData?: { subject?: string } | null; + }) => ( +
+ stub subject: {initialData?.subject ?? ''} + +
+ ), +})); +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: ({ onOpenSearch }: { onOpenSearch?: () => void }) => , +})); +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: true, isTablet: false, isDesktop: false }), + useMediaQuery: () => false, + useIsDesktop: () => false, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; +const sendEmail = vi.fn(); + +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) }); + const client = { + getAccountId: () => 'acc', getUsername: () => 'me@example.com', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never); + sendEmail.mockReset().mockResolvedValue({}); + useToastStore.setState({ toasts: [] }); + useEmailStore.setState({ + emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null, + 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), + sendEmail, + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const toastTitled = (title: string) => useToastStore.getState().toasts.find((t) => t.title === title); + +async function openComposer() { + render(); + fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' })); + await screen.findByText('stub send', {}, { timeout: 5000 }); +} + +describe('sending', () => { + it('closes the composer at once and says "Sending…", then "Sent" with View', async () => { + let finish: (result: unknown) => void = () => {}; + sendEmail.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + await openComposer(); + + fireEvent.click(screen.getByText('stub send')); + + // The server hasn't answered yet. + await waitFor(() => expect(screen.queryByText('stub send')).not.toBeInTheDocument()); + expect(sendEmail).toHaveBeenCalled(); + expect(toastTitled('compose_send.sending')).toBeDefined(); + + await act(async () => { finish({ scheduled: false, emailId: 'sent-1' }); }); + + await waitFor(() => expect(toastTitled('compose_send.sent')).toBeDefined()); + expect(toastTitled('compose_send.sent')!.action?.label).toBe('compose_send.view'); + expect(toastTitled('compose_send.sending')).toBeUndefined(); + }); + + it('opens the composer again with the text when the send fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + sendEmail.mockRejectedValue(new Error('refused')); + await openComposer(); + + fireEvent.click(screen.getByText('stub send')); + + expect(await screen.findByText('stub subject: Hi', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(toastTitled('compose_send.sending')).toBeUndefined(); + expect(toastTitled('compose_send.sent')).toBeUndefined(); + }); + + it('leaves a delayed send to the undo toast', async () => { + sendEmail.mockResolvedValue({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: new Date(Date.now() + 5000).toISOString() }); + await openComposer(); + + fireEvent.click(screen.getByText('stub send')); + + await waitFor(() => expect(sendEmail).toHaveBeenCalled()); + await waitFor(() => expect(toastTitled('compose_send.sending')).toBeUndefined()); + expect(toastTitled('compose_send.sent')).toBeUndefined(); + expect(screen.queryByText('stub send')).not.toBeInTheDocument(); + }); +}); diff --git a/components/mail/__tests__/undo-send-toast.test.tsx b/components/mail/__tests__/undo-send-toast.test.tsx index 17f92fa53..92d7e83e3 100644 --- a/components/mail/__tests__/undo-send-toast.test.tsx +++ b/components/mail/__tests__/undo-send-toast.test.tsx @@ -119,6 +119,18 @@ describe('undo send toast', () => { expect(toast.pauseOnHover).toBe(false); }); + // 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 () => { + render(); + sendDelayedBy(3 * 86_400_000); + + await waitFor(() => expect(useToastStore.getState().toasts.some((t) => t.title === 'compose_send.scheduled_for')).toBe(true)); + const toast = useToastStore.getState().toasts.find((t) => t.title === 'compose_send.scheduled_for')!; + expect(toast.action?.label).toBe('cancel'); + expect(toast.secondaryAction?.label).toBe('email_viewer.send_now'); + }); + it('says so when the undo comes too late', async () => { cancelUndoSend.mockRejectedValue(new Error('The requested message is no longer in the queue.')); vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index b4daa4332..bdc888cfc 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -42,8 +42,10 @@ import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type Dr 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 { 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 { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client"; import { subscribePushStateChanges } from "@/lib/push-events"; import { createMailPushListener } from "@/lib/mail-push-listener"; @@ -269,6 +271,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // "Confirm before deleting / archiving" (lib/mail-removal-confirm). const tRemove = useTranslations('email_list.confirm_remove'); const tCommon = useTranslations('common'); + const tComposeUx = useTranslations('compose_ux'); + const tComposeWords = useTranslations('compose_words'); const appName = productName(useConfig().appName); const tQuote = useTranslations('quote_header'); const tComposer = useTranslations('email_composer'); @@ -299,6 +303,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const composerSessionRef = useRef(composerSessionId); composerSessionRef.current = composerSessionId; const composerHostRef = useRef('pane'); + // The compose session docked under its message (dockedReply). + const dockedSessionRef = useRef(null); const showComposerRef = useRef(showComposer); showComposerRef.current = showComposer; // Fields a plugin filled in through onBeforeCompose for the composer about @@ -2605,6 +2611,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // the user's message without so much as a toast (#702). let submitted = false; + // The composer closes at once and "Sending…" shows meanwhile. A send that + // fails comes back through onSendFailed, which opens the composer again. + setShowComposer(false); + setPendingDraft(null); + if (isMobile && !useEmailStore.getState().selectedEmail) setActiveView('list'); + const progress = startSendProgress({ + sending: t('compose_send.sending'), + sent: t('compose_send.sent'), + view: t('compose_send.view'), + }); + try { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = answeredEmailId; @@ -2612,7 +2629,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data)); submitted = true; announceSent(result, data); - setShowComposer(false); + // "Sent" with View. A delayed or scheduled send gets the undo toast, and + // a filing error, a refused recipient or the offline outbox say so themselves. + if (result.scheduled || result.filingError || result.rejectedRecipients?.length || result.queued) { + progress.dismiss(); + } else { + const sentId = result.emailId; + const ownerAccountId = result.emailAccountId && result.emailAccountId !== sendClient.getAccountId() ? result.emailAccountId : undefined; + progress.sent(sentId && sendClient === client + ? () => void applyMailDeepLinkRef.current({ kind: 'message', id: sentId, ownerAccountId }) + : undefined); + } // Sending an edited draft destroys it server-side - and every autosave // before that already replaced it under a fresh id (createDraft is a // destroy+create). If the email on display is the draft this session @@ -2697,11 +2724,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return; } - // Delayed sends get the undo toast instead; a filing error or a refused - // recipient already warned. - // A message kept in the offline outbox said so itself (#523). - if (!result.filingError && !result.rejectedRecipients?.length && !result.queued) toast.success(t('notifications.email_sent')); - // Refresh the current mailbox to update the UI if (!isScheduledView) { await refreshCurrentMailbox(client); @@ -2709,6 +2731,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } } catch (error) { console.error("Failed to send email:", error); + if (submitted) progress.sent(); + else progress.dismiss(); // Post-send bookkeeping - refreshing Sent, flagging the original as // answered, re-reading the thread - is cosmetic once the mail is out. if (!submitted) throw error; @@ -2779,6 +2803,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { fromLabel: tQuote('from_label'), dateLabel: tQuote('date_label'), subjectLabel: tQuote('subject_label'), + // A forward says who the message went to, as Gmail and Outlook do. + toLabel: tComposeWords('forward_to_label'), + ccLabel: tComposeWords('forward_cc_label'), }, }); setComposerQuoteHeader(header); @@ -2786,7 +2813,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { console.warn('[quote-header] plugin transform failed; using default', err); setComposerQuoteHeader(null); } - }, [tCommon, tQuote]); + }, [tCommon, tQuote, tComposeWords]); // Force a clean composer remount on every fresh entry point so prior // compose state can't bleed into the new session (#329 C). The composer is @@ -2999,9 +3026,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { ? Math.max(remainingMs - 1000, 1000) : Math.max(sendDelaySeconds, 8) * 1000; + // A message scheduled for later says when it goes, and offers Cancel + // where a send-delay window offers Undo send. toast.success(isUndoWindow ? t('email_viewer.sending_in', { seconds: Math.round(remainingMs / 1000) }) - : t('email_viewer.scheduled_send_created'), { + : t('compose_send.scheduled_for', { when: formatScheduledSendAt(pending.sendAt, useSettingsStore.getState().timeFormat) }), { duration: undoDurationMs, pauseOnHover: false, secondaryAction: (pending.emailId && pending.identityId) @@ -3043,7 +3072,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } : undefined, action: { - label: t('email_viewer.undo_send'), + label: isUndoWindow ? t('email_viewer.undo_send') : tCommon('cancel'), onClick: () => { if (!undoClient) return refuseSignedOut(); void (async () => { @@ -4535,7 +4564,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { let delayedUntil: string | undefined; if (sendDelaySeconds > 0) { if (!client.hasDelayedSend()) { - const confirmed = await confirmDialog({ + // As in the composer: only a delay the user picked asks. + const confirmed = !useSettingsStore.getState().explicitSettings.includes('sendDelaySeconds') || await confirmDialog({ title: t('email_composer.send_delay_unsupported_title'), message: t('email_composer.send_delay_unsupported_confirm'), confirmText: t('email_composer.send'), @@ -4886,7 +4916,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const mvInlineReply = mvShell && showComposer && !isEmbedded && !mvReplyPoppedOut && (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail); const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply; - const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : 'pane'; + // Elsewhere on a computer a reply or forward docks under the message it + // answers, which stays readable above it, as in Gmail and Outlook. Not + // for a draft being edited: that message is the draft itself. + // 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; + const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane'; composerHostRef.current = composerHost; const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread) || Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null; @@ -4915,11 +4954,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (!client || !email) return new Error('the mail page is not ready'); setCursorOnly(false); - // If composing, suspend the composer (unmount will trigger onSaveState). - // Mountain View's compose window stays open while other mail is read, - // as Gmail's does. (#1229) + // 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 + // other mail is read, as Gmail's does. (#1229) if (showComposer && !floatingComposer) { - setShowComposer(false); + const requestClose = composerRequestCloseRef.current; + if (requestClose) requestClose(); + else setShowComposer(false); } // Find the list-level email for metadata (accountId, scheduled flags, etc.). @@ -5267,6 +5308,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setComposerPrefill(null); return outcome; }} + onSendFailed={(state) => { + setPendingDraft(state); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }} onScheduledSendCreated={async () => { if (client) { await refreshScheduledMetadata(client); @@ -5959,40 +6005,45 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { {/* Inline Composer - shown in viewer pane. In Pro/embedded mode the composer is hoisted into its own Pro tab (see the effect below), so we never render it inline. */} - {(showComposer && !isEmbedded && !floatingComposer && !mvInlineReply) ? composerNode : ( + {(showComposer && !isEmbedded && !floatingComposer && !mvInlineReply && !dockedReply) ? composerNode : ( <> {/* Pending draft banner */} - {pendingDraft && ( - + className="flex flex-1 min-w-0 items-center gap-3 ps-4 pe-2 py-2.5 hover:bg-primary/15 transition-colors cursor-pointer text-start" + > + +
+ {t('email_composer.continue_draft')} + {pendingDraft.subject && ( + {pendingDraft.subject} + )} +
+ + {/* Its own button, so the keyboard reaches it. Deletes the saved + draft too, with Undo instead of a question. */} + +
)} {/* Mobile Conversation View - shown when thread is selected on mobile */} {isMobile && !conversationThread && deepLinkPane === 'thread' ? ( @@ -6132,6 +6183,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { await handleConversationReplyAll(full ?? e); } : undefined} mvInlineComposer={mvInlineReply ? {composerNode} : undefined} + replyComposerOpen={dockedReply} onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined} onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined} isFullscreen={fullscreenReading} @@ -6185,6 +6237,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { )} + {dockedReply && ( +
+ {composerNode} +
+ )} )} diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 6ceedf1fa..d92e90439 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -4,6 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { createComposeHandoff, rememberComposePayload, rememberedComposePayload } from '@/lib/popout/compose-handoff'; 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 { emailHooks } from '@/lib/plugin-hooks'; @@ -547,3 +548,90 @@ describe('after this page crashed', () => { expect(rememberedComposePayload('h2')).toBeNull(); }); }); + +describe('a send held for undo', () => { + const closeSelf = vi.fn(); + const MAILBOXES = [{ id: 'mDrafts', role: 'drafts' }, { id: 'mSent', role: 'sent' }]; + const startDraft = { + id: 'd0', subject: 'Before sending', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + const restoredDraft = { + id: 'e1', subject: 'Back again', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + let originalEmail: ReturnType; + let sendAt: string; + let cancelUndoSend: ReturnType; + + beforeEach(() => { + closeSelf.mockClear(); + originalEmail = useEmailStore.getState(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore: vi.fn() } } as never; + useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never); + identityMocks.multi = { enabled: false, allIdentities: [], groups: [] }; + const client = { getEmail: vi.fn(async (id: string) => (id === 'e1' ? restoredDraft : startDraft)), getMailboxes: vi.fn(async () => MAILBOXES) }; + useAuthStore.setState({ client: client as never, getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) } as never); + cancelUndoSend = vi.fn(async () => restoredDraft); + useEmailStore.setState({ + sendEmail: vi.fn(async () => { + useEmailStore.setState({ pendingUndoSend: { submissionId: 'sub1', emailId: 'e1', identityId: 'id-1', sendAt, isSmime: false } }); + return { scheduled: true, emailSubmissionId: 'sub1', emailId: 'e1', sendAt }; + }), + cancelUndoSend, + } as never); + }); + + afterEach(() => { + useEmailStore.setState(originalEmail); + window.bulwarkDesktop = undefined; + }); + + type Sending = { onSend: (data: object) => Promise; onClose: () => void }; + const DATA = { to: ['a@example.org'], cc: [], bcc: [], subject: 'Hi', body: 'Text', localAccountId: 'alice' }; + + /** Opens the window, sends from it and lets the composer close, as it does after a send. */ + async function sendFromWindow(seconds: number) { + sendAt = new Date(Date.now() + seconds * 1000).toISOString(); + window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice'); + render(); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending')); + const props = composer as unknown as Sending; + await act(async () => { await props.onSend(DATA); }); + act(() => props.onClose()); + } + + it('stays open with "Sending in" and Undo, and does not hand the undo to the main window', async () => { + await sendFromWindow(5); + expect(screen.getByTestId('popout-sending').textContent).toContain('sending_in'); + expect(screen.getByRole('button', { name: 'undo_send' })).toBeInTheDocument(); + expect(closeSelf).not.toHaveBeenCalled(); + const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; + expect(sent?.notice.pendingUndoSend ?? null).toBeNull(); + }); + + it('closes once the undo time is over', async () => { + await sendFromWindow(1.2); + expect(closeSelf).not.toHaveBeenCalled(); + await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 }); + }); + + it('Undo takes the send back and opens the message again in this window', async () => { + await sendFromWindow(30); + fireEvent.click(screen.getByRole('button', { name: 'undo_send' })); + await waitFor(() => expect(cancelUndoSend).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ submissionId: 'sub1', localAccountId: 'alice' }))); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Back again')); + expect(closeSelf).not.toHaveBeenCalled(); + expect(new URLSearchParams(window.location.search).get('draft')).toBe('e1'); + }); + + 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(); + expect(closeSelf).toHaveBeenCalled(); + const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; + expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1'); + }); +}); diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index eaf970c12..afcae4fcf 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -93,9 +93,20 @@ afterEach(() => { const answers = () => heard.filter((message) => message.type === 'session-answer'); -describe('in an ordinary browser tab', () => { +/** A browser tab: not installed, with or without a mouse. */ +function stubTab(fine: boolean) { + delete window.bulwarkDesktop; + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('pointer: fine') ? fine : false, + addEventListener() {}, + removeEventListener() {}, + })); + window.matchMedia = globalThis.matchMedia; +} + +describe('in a browser tab on a phone or tablet', () => { it('runs nothing: no channel and no lock', async () => { - delete window.bulwarkDesktop; + stubTab(false); const opened = FakeBroadcastChannel.open.size; render(); await settle(); @@ -107,6 +118,18 @@ describe('in an ordinary browser tab', () => { }); }); +describe('in a browser tab on a computer', () => { + it('lends its session to a composer popped out of it', async () => { + stubTab(true); + render(); + await settle(); + await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({ + activeAccountId: 'alice', + sessions: [{ accountId: 'alice', authMode: 'oauth' }], + }); + }); +}); + describe('PopoutHost', () => { it('holds no lock while idle, which would keep the page out of the back/forward cache', async () => { render(); @@ -286,6 +309,17 @@ describe('PopoutHost', () => { expect(useToastStore.getState().toasts).toHaveLength(1); }); + it('says once that a closed pop-out kept its draft, with Discard', async () => { + render(<>); + await settle(); + popout.post({ type: 'draft-kept', requestId: 'k1', notice: { draftId: 'd1', localAccountId: 'alice' } }); + await settle(); + const toasts = useToastStore.getState().toasts; + expect(toasts).toHaveLength(1); + expect(toasts[0].title).toBe('email_composer.draft_saved'); + expect(toasts[0].action?.label).toBe('email_composer.discard'); + }); + it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => { render(); await settle(); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 4b9f058a8..5f8f5433b 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; @@ -13,8 +13,9 @@ import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients } from "@/lib/jmap/client"; import { debug } from "@/lib/debug"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; -import { announceSent } from "@/lib/plugin-after-send"; -import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel"; +import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend } from "@/lib/plugin-after-send"; +import { cancelFailureToast } from "@/lib/send-failure-message"; +import { announceDraftKept, announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type PopoutSentNotice, type RescuedDraft } from "@/lib/popout/channel"; import { acknowledgeComposeHandoff, forgetComposePayload, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { returnComposerToMain, type ReturnResult } from "@/lib/popout/return-to-main"; @@ -30,6 +31,7 @@ import { emailHooks } from "@/lib/plugin-hooks"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useIdentityStore } from "@/stores/identity-store"; import type { Email } from "@/lib/jmap/types"; +import { Send } from "@/components/icons"; import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ @@ -49,6 +51,91 @@ const MAIN_CHECK_MS = 1000; /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; +/** A send held this long or less is an undo window ("Sending in 5 s"), not a message scheduled for later. */ +const UNDO_WINDOW_MS = 120_000; +/** Undo goes this long before the server lets the message go, as in the main window. */ +const UNDO_MARGIN_MS = 1000; +/** What the plugins are told when the hold runs out gets this long before the window goes. */ +const RELEASE_WAIT_MS = 1000; + +type HeldSend = NonNullable; + +/** A draft to open again in this window, after Undo brought the message back to Drafts. */ +interface ReopenTarget { + draftId: string; + accountId: string; + ownerAccountId?: string; +} + +/** + * 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. + * The window stays until the hold runs out, then goes. + */ +function SendingWithUndo({ held, onUndone, onGone }: { + held: HeldSend; + /** The send was taken back; the message, back in Drafts, when there is one to open. */ + onUndone: (restored: Email | null) => void; + onGone: () => void; +}) { + const tViewer = useTranslations("email_viewer"); + const [now, setNow] = useState(() => Date.now()); + const [busy, setBusy] = useState(false); + const sendAt = Date.parse(held.sendAt); + const over = !Number.isFinite(sendAt) || now >= sendAt - UNDO_MARGIN_MS; + + useEffect(() => { + if (over) return; + const timer = setInterval(() => setNow(Date.now()), 250); + return () => clearInterval(timer); + }, [over]); + + // Too late to undo: the plugins hear of the send now, and the window goes. + useEffect(() => { + if (!over || busy) return; + releaseAnnouncedSend(held.submissionId); + const timer = setTimeout(onGone, RELEASE_WAIT_MS); + return () => clearTimeout(timer); + }, [over, busy, held.submissionId, onGone]); + + const undo = async () => { + const auth = useAuthStore.getState(); + // Never another login: the submission id names another submission there. + const client = held.localAccountId ? auth.getClientForAccount(held.localAccountId) : auth.client; + if (!client) { + toast.error(tViewer("undo_send_signed_out")); + return; + } + setBusy(true); + let restored: Email | null; + try { + restored = await useEmailStore.getState().cancelUndoSend(client, held); + } catch (error) { + debug.error("Failed to undo the send:", error); + const { title, message } = cancelFailureToast(error, tViewer, tViewer("undo_send_failed")); + toast.error(title, message ? { message } : undefined); + setBusy(false); + return; + } + // Never left the server: plugins don't hear about it. + cancelAnnouncedSend(held.submissionId); + onUndone(held.isSmime ? null : restored); + }; + + const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000)); + return ( +
+
+ ); +} + function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } { if (typeof window === "undefined") return { handoffId: null }; const search = new URLSearchParams(window.location.search); @@ -214,7 +301,12 @@ function NewMessageGate({ onReady }: { onReady: (payload: ComposePopoutPayload) } /** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ -function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) { +function PopoutComposer({ payload, handoffId, onReopen }: { + payload: ComposePopoutPayload; + handoffId: string | null; + /** Undo brought the message back to Drafts: open it again here. */ + onReopen: (target: ReopenTarget) => void; +}) { const t = useTranslations(); const tQuick = useTranslations("desktopQuickCompose"); const client = useAuthStore((s) => s.client); @@ -267,6 +359,16 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId; const pending = useEmailStore.getState().pendingUndoSend; + const withLogin = pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null; + // An undo window stays here, where Send was pressed: the window shows it + // and closes when it runs out. A message scheduled for later goes to the + // main window as before. + const remaining = withLogin ? Date.parse(withLogin.sendAt) - Date.now() : NaN; + const keepHere = result.scheduled && withLogin && Number.isFinite(remaining) && remaining <= UNDO_WINDOW_MS ? withLogin : null; + if (keepHere) { + heldRef.current = keepHere; + useEmailStore.setState({ pendingUndoSend: null }); + } const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined; const taken = await announceSentToMainWindow({ accountId, @@ -274,7 +376,7 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; queued: !!result.queued, filingError: !!result.filingError, rejectedRecipients, - pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null, + pendingUndoSend: keepHere ? null : withLogin, }); // No main window to say so, and this is the only notice these get (#1123). if (!taken && (result.filingError || rejectedRecipients)) { @@ -301,11 +403,36 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; } }, [client, t]); + // A send held for undo, kept in this window until it runs out. + const heldRef = useRef(null); + const [held, setHeld] = useState(null); + const handleClose = useCallback(() => { + announceMailChanged(useAuthStore.getState().activeAccountId); + if (heldRef.current) { + setHeld(heldRef.current); + return; + } + closePopoutWindow(); + }, []); + + const heldGone = useCallback(() => { announceMailChanged(useAuthStore.getState().activeAccountId); closePopoutWindow(); }, []); + const heldUndone = useCallback((restored: Email | null) => { + const sent = heldRef.current; + heldRef.current = null; + const login = sent?.localAccountId ?? useAuthStore.getState().activeAccountId; + announceMailChanged(login); + if (!restored || !login) { + closePopoutWindow(); + return; + } + onReopen({ draftId: restored.id, accountId: login, ownerAccountId: sent?.submissionAccountId }); + }, [onReopen]); + const [mainWindow] = useState(watchMainWindow); useEffect(() => () => mainWindow.stop(), [mainWindow]); @@ -357,6 +484,9 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; onUnloadState: (state: ComposerDraftData) => { if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null }); }, + onDraftKept: (draftId: string, where: { accountId?: string; localAccountId?: string }) => { + announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined }); + }, onDraftSaved: (draftId: string, where: { accountId?: string; localAccountId?: string } = {}) => { const active = useAuthStore.getState().activeAccountId; announceMailChanged(active); @@ -373,6 +503,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; }, }), [handoffId, mainWindow, payload]); + if (held) return ; + return ( (named && draftId && !rememberedComposePayload(named) ? null : named)); const [payload, setPayload] = useState(null); + // After Undo: the message back in Drafts, opened again in a new composer here. + const [reopen, setReopen] = useState(null); + const [generation, setGeneration] = useState(0); + const handleReopen = useCallback((target: ReopenTarget) => { + // A reload starts from the draft, as after a save. + const url = new URL(window.location.href); + url.searchParams.delete("handoff"); + url.searchParams.set("draft", target.draftId); + url.searchParams.set("account", target.accountId); + if (target.ownerAccountId) url.searchParams.set("owner", target.ownerAccountId); + else url.searchParams.delete("owner"); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + setPayload(null); + setReopen(target); + }, []); + const reopened = useCallback((next: ComposePopoutPayload) => { + setReopen(null); + setGeneration((n) => n + 1); + setPayload(next); + }, []); useEffect(() => { if (!handoffId) return; @@ -438,7 +590,8 @@ export function PopoutComposeApp() { return ( - {payload ? + {payload ? + : reopen ? : handoffId ? : draftId ? : } diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index b2b154772..1308ca0bf 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -8,6 +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 { 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"; @@ -61,8 +62,9 @@ function claimOnce(name: string, work: () => void | Promise): void { /** * The main window's side of pop-outs: lends them its session and renews it * for them, passes on that mail changed, and takes over what a pop-out can't - * say any more once it has closed. Only where pop-outs are on offer: an - * ordinary browser tab runs none of this. + * say any more once it has closed. Only where pop-outs are on offer: a + * browser tab on a computer runs it for composers popped out of it, one on a + * phone or tablet runs none of this. */ export function PopoutHost() { const available = usePopoutsAvailable(); @@ -176,6 +178,28 @@ export function PopoutHost() { case "draft-rescue": startRescue(message.draft); break; + case "draft-kept": { + // Said here, as the pop-out closes with its Escape or X. + const { draftId, accountId, localAccountId } = message.notice; + claimOnce(`bulwark-popout-kept:${message.requestId}`, () => { + const tr = tRef.current; + showDraftSavedToast(() => { + const auth = useAuthStore.getState(); + const login = localAccountId ? auth.getClientForAccount(localAccountId) : auth.client; + if (!login) { + toast.error(tr("email_composer.discard_signed_out")); + return; + } + void login.deleteEmail(draftId, accountId).catch((error: unknown) => console.error("Failed to discard draft:", error)); + }, { + saved: tr("email_composer.draft_saved"), + discard: tr("email_composer.discard"), + discarded: tr("compose_ux.draft_discarded"), + undo: tr("compose_ux.undo"), + }); + }); + break; + } case "rescue-query": { const rescue = rescues.get(message.rescueKey); if (rescue) void rescue.then((draftId) => channel?.post({ type: "rescue-answer", rescueKey: message.rescueKey, draftId })); diff --git a/components/settings/__tests__/composing-settings-locks.test.tsx b/components/settings/__tests__/composing-settings-locks.test.tsx index 7dfd0d3dc..a2eb5281c 100644 --- a/components/settings/__tests__/composing-settings-locks.test.tsx +++ b/components/settings/__tests__/composing-settings-locks.test.tsx @@ -21,7 +21,7 @@ vi.mock('../settings-section', async (importOriginal) => ({ const ROWS = { plainTextMode: 'plain_text_mode.label', - autoSelectReplyIdentity: 'auto_select_reply_identity.label', + autoSelectReplyIdentity: 'reply_from_received.label', replyIdentityMatch: 'reply_identity_match.label', } as const; diff --git a/components/settings/__tests__/composing-settings-words.test.tsx b/components/settings/__tests__/composing-settings-words.test.tsx new file mode 100644 index 000000000..1b17010f7 --- /dev/null +++ b/components/settings/__tests__/composing-settings-words.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComposingSettings } from '../composing-settings'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import en from '@/locales/en/common.json'; + +// "Reply from the address it was sent to" explained catch-all matching, +// which belongs to the row below it. The row now says what it does and +// when it helps. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +describe('ComposingSettings wording', () => { + beforeEach(() => { + useSettingsStore.getState().resetToDefaults(); + useAuthStore.setState({ client: null }); + }); + + it('describes replying from the received address without the catch-all note', () => { + render(); + expect(screen.getByText('reply_from_received.description')).toBeTruthy(); + expect(screen.queryByText('auto_select_reply_identity.description')).toBeNull(); + expect(en.compose_words.reply_from_received.description).not.toMatch(/catch-all|Received Address Matching/i); + }); + + it('calls the send delay "Undo send"', () => { + render(); + expect(screen.getByText('undo_send.label')).toBeTruthy(); + expect(screen.queryByText('send_delay.label')).toBeNull(); + expect(en.compose_words.undo_send.label).toBe('Undo send'); + }); +}); diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index e279266c7..5f0955815 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; -import { useSettingsStore } from '@/stores/settings-store'; +import { useSettingsStore, undoSendOptions } from '@/stores/settings-store'; import type { MentionAddsRecipientTo, ReplyIdentityMatch, SendDelaySeconds } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; import { usePolicyStore } from '@/stores/policy-store'; @@ -21,6 +21,7 @@ import { X, ChevronRight } from '@/components/icons'; export function ComposingSettings() { const t = useTranslations('settings.email_behavior'); const tPage = useTranslations('settings.composing_page'); + const tWords = useTranslations('compose_words'); const templatesEnabled = usePolicyStore((s) => s.isFeatureEnabled('templatesEnabled')); // The admin panel can lock or hide these three (restriction-keys.ts). const { isSettingLocked, isSettingHidden } = usePolicyStore(); @@ -102,7 +103,7 @@ export function ComposingSettings() { {!isSettingHidden('autoSelectReplyIdentity') && ( - + updateSetting('autoSelectReplyIdentity', checked)} @@ -157,17 +158,15 @@ export function ComposingSettings() { - +
{ + setName(e.target.value); + if (error) setError(null); + }} + className={cn('mt-1', error && 'border-destructive')} + aria-invalid={error ? true : undefined} + aria-describedby={error ? `${fieldId}-error` : undefined} + autoFocus + /> + {error && ( +

+ {tSettings(`validation.${error}`)} +

+ )} +
+
+ ); +} diff --git a/components/templates/template-form.tsx b/components/templates/template-form.tsx index 5109d4a2e..7f6b78fbc 100644 --- a/components/templates/template-form.tsx +++ b/components/templates/template-form.tsx @@ -22,6 +22,8 @@ import type { Editor } from '@tiptap/react'; interface TemplateFormProps { template?: EmailTemplate; initialData?: { + /** A name typed before the full editor opened (Save as template). */ + name?: string; subject?: string; body?: string; /** The body is the composer's HTML, not plain text (#1506). */ @@ -57,7 +59,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared = const browserList = useTemplateStore((s) => s.templates); const templates = categorySource ?? browserList; - const [name, setName] = useState(template?.name || ''); + const [name, setName] = useState(template?.name || initialData?.name || ''); const [category, setCategory] = useState(template?.category || ''); const [subject, setSubject] = useState(template?.subject || initialData?.subject || ''); const [body, setBody] = useState(template?.body || initialData?.body || ''); diff --git a/components/templates/template-picker.tsx b/components/templates/template-picker.tsx index a8cbd72b3..f84990342 100644 --- a/components/templates/template-picker.tsx +++ b/components/templates/template-picker.tsx @@ -114,7 +114,8 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat )}
- {template.subject && ( + {/* Not the name a second time ("Meeting follow-up / Meeting follow-up"). */} + {template.subject && template.subject.trim() !== template.name.trim() && (

{template.subject}

@@ -131,13 +132,15 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat ); - const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => { + const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource, withHeading = true) => { if (items.length === 0) return null; return (
-

- {title} -

+ {withHeading && ( +

+ {title} +

+ )}
{items.map((item) => renderTemplateItem(item, source))}
); @@ -171,7 +174,9 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat {Object.entries(byCategory) .filter(([cat]) => cat !== '') .map(([cat, items]) => renderSection(cat, items, source))} - {renderSection(t('section_uncategorized'), byCategory[''] ?? [], source)} + {/* "Other" only sets them apart from favourites, recent or categories. */} + {renderSection(t('section_uncategorized'), byCategory[''] ?? [], source, + favorites.length > 0 || recent.length > 0 || Object.keys(byCategory).some((cat) => cat !== ''))} ); if (!withHeading) return
{body}
; diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx index a421af676..335d03634 100644 --- a/components/ui/__tests__/toast-layout.test.tsx +++ b/components/ui/__tests__/toast-layout.test.tsx @@ -208,6 +208,25 @@ describe('toasts and the focused element', () => { expect(toastContainer()).not.toHaveAttribute('data-dodging'); }); + // With the focus in the composer's text or the quick reply box, "Email + // sent" and "Sending in 5 seconds" moved to the top and covered the + // composer's close button and the reader's toolbar. A large field the + // stack only overlaps at a corner keeps the toasts at the bottom. + it('stays at the bottom over a corner of a large focused field', () => { + const h = window.innerHeight; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.hasAttribute('data-toast-container')) return rect(h - 100, 600, 360, 80); + if (this.id === 'body') return rect(h - 400, 300, 700, 360); + return rect(10, 10, 80, 36); + }); + render(<>