From ec4fd9c529af27afa05854c5570e58dc4eec837c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:03:05 +0100 Subject: [PATCH 1/5] fix: keep mail shortcuts working after a click into an HTML message --- .../conversation-frame-keys.test.tsx | 64 +++++++++++ components/email/email-viewer.tsx | 3 + components/email/thread-conversation-view.tsx | 3 + lib/__tests__/email-frame-keys.test.ts | 108 ++++++++++++++++++ lib/email-frame-keys.ts | 90 +++++++++++++++ 5 files changed, 268 insertions(+) create mode 100644 components/email/__tests__/conversation-frame-keys.test.tsx create mode 100644 lib/__tests__/email-frame-keys.test.ts create mode 100644 lib/email-frame-keys.ts diff --git a/components/email/__tests__/conversation-frame-keys.test.tsx b/components/email/__tests__/conversation-frame-keys.test.tsx new file mode 100644 index 000000000..a39dd5012 --- /dev/null +++ b/components/email/__tests__/conversation-frame-keys.test.tsx @@ -0,0 +1,64 @@ +// mail-3: a key pressed after a click into an HTML message of a conversation +// stayed in the message's frame, so the mail shortcuts did nothing. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; + +const t = Object.assign((k: string) => k, { raw: (k: string) => k, rich: (k: string) => k }); +vi.mock('next-intl', () => ({ + useTranslations: () => t, + useLocale: () => 'en', +})); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { ThreadConversationView } = await import('@/components/email/thread-conversation-view'); + +function renderHtmlMessage() { + const email = { + id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true }, + from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }], + subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hi', hasAttachment: false, + htmlBody: [{ partId: '1', type: 'text/html' }], textBody: [], attachments: [], + bodyValues: { '1': { value: '

Newsletter

' } }, + }; + const { container } = render( + {}} + />, + ); + const iframe = container.querySelector('iframe')!; + const srcdoc = iframe.getAttribute('srcdoc') ?? ''; + const doc = iframe.contentDocument!; + doc.body.innerHTML = /]*>([\s\S]*)<\/body>/.exec(srcdoc)![1]; + fireEvent.load(iframe); + return doc; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('keys in a conversation message', () => { + it('reach the page, where the shortcut handlers listen', () => { + const doc = renderHtmlMessage(); + const heard: string[] = []; + const listen = (e: KeyboardEvent) => heard.push(e.key); + window.addEventListener('keydown', listen); + try { + const FrameKeyboardEvent = (doc.defaultView as unknown as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent; + doc.getElementById('p')!.dispatchEvent(new FrameKeyboardEvent('keydown', { key: 'j', code: 'KeyJ', bubbles: true, cancelable: true })); + } finally { + window.removeEventListener('keydown', listen); + } + expect(heard).toEqual(['j']); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0b1b5fac7..dc1ed92b0 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -23,6 +23,7 @@ import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, re import { getAttachmentDisplayName } from "@/lib/attachment-name"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { measureEmailFrameHeight } from "@/lib/email-frame-height"; +import { forwardFrameKeys } from "@/lib/email-frame-keys"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode"; import { fitEmailBodyWidth, type FitOptions } from "@/lib/email-fit-width"; import { hasFullBleedCanvas } from "@/lib/email-full-bleed"; @@ -3733,6 +3734,8 @@ export function EmailViewer({ onSelection: (text) => { if (iframe.contentDocument === doc) onBodySelection(text); }, onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: emailIdRef.current, text }), }); + // j, k, e and the other shortcuts keep working after a click into the body. + forwardFrameKeys(doc, iframe); } } catch { diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 0977c9136..328f64a97 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -20,6 +20,7 @@ import { ReceivedLabelChip } from "./received-label-chip"; import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { measureEmailFrameHeight } from "@/lib/email-frame-height"; +import { forwardFrameKeys } from "@/lib/email-frame-keys"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; import { mailLanguage } from "@/lib/mail-language"; import { htmlToReadableText } from "@/lib/html-to-readable-text"; @@ -926,6 +927,8 @@ export function ConversationEmailBody({ emailId: email.id, onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: email.id, text }), }); + // j, k, e and the other shortcuts keep working after a click into the body. + forwardFrameKeys(doc, iframe); setHtmlBodyEl(doc.body); } catch { // contentDocument may be inaccessible under stricter sandboxes; ignore. diff --git a/lib/__tests__/email-frame-keys.test.ts b/lib/__tests__/email-frame-keys.test.ts new file mode 100644 index 000000000..e8cf4da93 --- /dev/null +++ b/lib/__tests__/email-frame-keys.test.ts @@ -0,0 +1,108 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; +import { forwardFrameKeys } from '../email-frame-keys'; + +// mail-3: after a click into an HTML message, j, k, e, # and the rest did +// nothing, since the key presses went to the body's frame and never reached +// the page's shortcut handlers. + +const email = { id: 'm1' } as never; + +function frameWith(html: string) { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const doc = frame.contentDocument!; + doc.body.innerHTML = html; + forwardFrameKeys(doc, frame); + return { frame, doc }; +} + +/** A key press as the frame's own window makes it. */ +function pressIn(doc: Document, target: Element, key: string, code: string, init: KeyboardEventInit = {}) { + const FrameKeyboardEvent = (doc.defaultView as unknown as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent; + const event = new FrameKeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...init }); + target.dispatchEvent(event); + return event; +} + +function mountShortcuts() { + const handlers = { onNextEmail: vi.fn(), onArchive: vi.fn(), onDelete: vi.fn(), onCloseEmail: vi.fn(), onSelectAll: vi.fn() }; + renderHook(() => useKeyboardShortcuts({ emails: [email], selectedEmailId: 'm1', handlers })); + return handlers; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('keys pressed in a message body', () => { + it('reach the app shortcuts: j, e and #', () => { + const handlers = mountShortcuts(); + const { doc } = frameWith('

Newsletter

'); + const p = doc.querySelector('p')!; + const j = pressIn(doc, p, 'j', 'KeyJ'); + pressIn(doc, p, 'e', 'KeyE'); + pressIn(doc, p, '#', 'Digit3', { shiftKey: true }); + expect(handlers.onNextEmail).toHaveBeenCalledTimes(1); + expect(handlers.onArchive).toHaveBeenCalledTimes(1); + expect(handlers.onDelete).toHaveBeenCalledTimes(1); + // The frame doesn't act on a key a shortcut took. + expect(j.defaultPrevented).toBe(true); + }); + + it('stay in a form inside the mail', () => { + const handlers = mountShortcuts(); + const { doc } = frameWith('
x
'); + pressIn(doc, doc.querySelector('input')!, 'j', 'KeyJ'); + pressIn(doc, doc.querySelector('textarea')!, 'e', 'KeyE'); + pressIn(doc, doc.querySelector('b')!, 'Backspace', 'Backspace'); + expect(handlers.onNextEmail).not.toHaveBeenCalled(); + expect(handlers.onArchive).not.toHaveBeenCalled(); + expect(handlers.onDelete).not.toHaveBeenCalled(); + }); + + it('leave Ctrl+A, Ctrl+C and the scrolling keys to the body', () => { + const handlers = mountShortcuts(); + const { doc } = frameWith('

Text

'); + const p = doc.querySelector('p')!; + const selectAll = pressIn(doc, p, 'a', 'KeyA', { ctrlKey: true }); + const copy = pressIn(doc, p, 'c', 'KeyC', { ctrlKey: true }); + const space = pressIn(doc, p, ' ', 'Space'); + const down = pressIn(doc, p, 'ArrowDown', 'ArrowDown'); + // Ctrl+A in the list selects every message; in the body it selects the text. + expect(handlers.onSelectAll).not.toHaveBeenCalled(); + expect(handlers.onNextEmail).not.toHaveBeenCalled(); + for (const event of [selectAll, copy, space, down]) expect(event.defaultPrevented).toBe(false); + }); + + it('Escape puts the focus back on the open message in the list', () => { + const handlers = mountShortcuts(); + const list = document.createElement('div'); + list.id = 'message-list'; + list.innerHTML = '
A
B
'; + document.body.appendChild(list); + const { doc } = frameWith('

Text

'); + pressIn(doc, doc.querySelector('p')!, 'Escape', 'Escape'); + expect(document.activeElement?.textContent).toBe('B'); + // Only the focus moves: the message stays open. + expect(handlers.onCloseEmail).not.toHaveBeenCalled(); + }); + + it('Escape without a list (a message window) is the app\'s Escape', () => { + const handlers = mountShortcuts(); + const { doc } = frameWith('

Text

'); + pressIn(doc, doc.querySelector('p')!, 'Escape', 'Escape'); + expect(handlers.onCloseEmail).toHaveBeenCalledTimes(1); + }); + + it('stop while a menu is open on the page', () => { + const handlers = mountShortcuts(); + const menu = document.createElement('div'); + menu.setAttribute('role', 'menu'); + document.body.appendChild(menu); + const { doc } = frameWith('

Text

'); + pressIn(doc, doc.querySelector('p')!, 'e', 'KeyE'); + expect(handlers.onArchive).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/email-frame-keys.ts b/lib/email-frame-keys.ts new file mode 100644 index 000000000..7ae575e3a --- /dev/null +++ b/lib/email-frame-keys.ts @@ -0,0 +1,90 @@ +/** + * An HTML message renders in a frame, and a key pressed after a click into it + * goes to the frame's own window: j, k, e, # and the rest stopped working + * until the user clicked back into the list. This hands those keys on to the + * page as a copy of the key press, sent from the frame element, so the app's + * shortcut handlers (hooks/use-keyboard-shortcuts, hooks/use-undo-shortcut) + * take it with their usual checks: an open menu or dialog, single-key + * shortcuts turned off. Escape puts the focus back on the message's row in + * the list, as Escape in a dialog gives the focus back. + * + * Left to the frame: anything typed into a form in the mail, keys with Ctrl, + * Alt or ⌘ (copy, select all), and the keys that scroll the body or move + * through its links (arrows, Space, Tab, Enter, Page Up and Down). + */ + +import { focusTemporarily } from "@/lib/keyboard"; + +/** Keys that aren't one character but are mail shortcuts. */ +const NAMED_KEYS = new Set(["Delete", "Backspace", "Escape"]); + +const EDITABLE = + 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]'; + +/** + * Whether a key press in the frame came from a place that takes text. By tag + * and attribute: the frame's elements belong to another window, so + * `instanceof HTMLElement` (lib/keyboard) is false for all of them. + */ +function typedInFrame(event: KeyboardEvent, doc: Document): boolean { + if (doc.designMode === "on") return true; + const target = event.target as Node | null; + const el = target && target.nodeType === 1 ? (target as Element) : target?.parentElement ?? null; + if (!el) return false; + if (el.closest(EDITABLE)) return true; + return (el as HTMLElement).isContentEditable === true; +} + +/** Whether this key press in the frame belongs to the app. */ +export function isFrameKeyForApp(event: KeyboardEvent, doc: Document): boolean { + if (event.defaultPrevented || event.isComposing) return false; + if (event.ctrlKey || event.metaKey || event.altKey) return false; + if (event.key === " " || (event.key.length !== 1 && !NAMED_KEYS.has(event.key))) return false; + return !typedInFrame(event, doc); +} + +/** + * The open message's row in the message list, or the list itself when no row + * is open. Null where there is no list (a message window). + */ +function listFocusTarget(doc: Document): HTMLElement | null { + const list = doc.getElementById("message-list"); + if (!list) return null; + return ( + list.querySelector('[role="row"][aria-current="true"]') ?? + list.querySelector('[role="row"][tabindex="0"]') ?? + list + ); +} + +/** + * Hands the app's keys from a message frame's document to the page. Returns a + * function that stops it; a frame whose document is replaced stops by itself. + */ +export function forwardFrameKeys(doc: Document, frame: HTMLIFrameElement): () => void { + const onKeyDown = (event: KeyboardEvent) => { + if (!isFrameKeyForApp(event, doc)) return; + if (event.key === "Escape") { + const target = listFocusTarget(frame.ownerDocument); + if (target) { + event.preventDefault(); + focusTemporarily(target, { preventScroll: true }); + return; + } + } + const copy = new KeyboardEvent("keydown", { + key: event.key, + code: event.code, + shiftKey: event.shiftKey, + repeat: event.repeat, + bubbles: true, + cancelable: true, + composed: true, + }); + frame.dispatchEvent(copy); + // A shortcut ran: the frame doesn't act on the key as well. + if (copy.defaultPrevented) event.preventDefault(); + }; + doc.addEventListener("keydown", onKeyDown); + return () => doc.removeEventListener("keydown", onKeyDown); +} From cd3005e03665c5805efde18f274446672c58c62e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:03:07 +0100 Subject: [PATCH 2/5] fix: offer undo in the main window after archiving or deleting in a message window --- .../popout/__tests__/popout-host.test.tsx | 59 +++++++- components/popout/popout-host.tsx | 9 ++ .../__tests__/pro-email-move-undo.test.tsx | 142 ++++++++++++++++++ components/pro/pro-email-tab-body.tsx | 24 ++- lib/popout/__tests__/moved-undo.test.ts | 43 ++++++ lib/popout/channel.ts | 28 +++- lib/popout/moved-undo.ts | 60 ++++++++ 7 files changed, 355 insertions(+), 10 deletions(-) create mode 100644 components/pro/__tests__/pro-email-move-undo.test.tsx create mode 100644 lib/popout/__tests__/moved-undo.test.ts create mode 100644 lib/popout/moved-undo.ts diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index e8a0dd03e..eaf970c12 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -2,7 +2,8 @@ import { act, cleanup, render } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; import { setLockedHere } from '@/lib/app-lock/state'; -import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; +import { announceMovedToMainWindow, openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type PopoutMovedNotice, type RescuedDraft } from '@/lib/popout/channel'; +import { clearUndoHistory, undoHistory, undoLatest } from '@/lib/undo-history'; import { takeRescuedDraft } from '@/lib/popout/rescued-drafts'; import { takeReturnedComposer } from '@/lib/popout/return-to-main'; import { createSessionOpener, credentialFingerprint } from '@/lib/popout/sealed'; @@ -458,3 +459,59 @@ describe('a composer coming back', () => { expect(takeReturnedComposer()).toBeNull(); }); }); + +// mail-4: Archive or Delete in a message window closed the window and its +// toast with it, and Ctrl+Z in the main window didn't reach the move. +describe('a move a message window made', () => { + const NOTICE: PopoutMovedNotice = { + kind: 'archive', + batches: [{ login: 'alice', points: [{ id: 'm1', mailboxIds: { inbox: true }, seen: true }] }], + }; + + it('gets its toast with Undo here, and Ctrl+Z puts the message back through this window\'s login', async () => { + const restoreEmails = vi.fn(async () => {}); + Object.assign(client, { restoreEmails }); + clearUndoHistory(); + render(); + await settle(); + let taken = false; + await act(async () => { + taken = await announceMovedToMainWindow(NOTICE, 2000); + }); + expect(taken).toBe(true); + const toasts = useToastStore.getState().toasts; + expect(toasts.map((toast) => toast.title)).toEqual(['notifications.email_archived']); + expect(toasts[0].action?.label).toBe('notifications.undo'); + expect(undoHistory()).toHaveLength(1); + await act(async () => { + undoLatest(); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + expect(restoreEmails).toHaveBeenCalledWith([{ id: 'm1', mailboxIds: { inbox: true }, seen: true }], undefined); + }); + + it('is taken by one main window of two', async () => { + clearUndoHistory(); + render(<>); + await settle(); + await act(async () => { + await announceMovedToMainWindow(NOTICE, 2000); + }); + await settle(); + expect(useToastStore.getState().toasts).toHaveLength(1); + expect(undoHistory()).toHaveLength(1); + }); + + it('a delete for good still says so, without Undo', async () => { + clearUndoHistory(); + render(); + await settle(); + await act(async () => { + await announceMovedToMainWindow({ kind: 'delete', batches: [] }, 2000); + }); + const toasts = useToastStore.getState().toasts; + expect(toasts.map((toast) => toast.title)).toEqual(['notifications.email_deleted']); + expect(toasts[0].action).toBeUndefined(); + expect(undoHistory()).toHaveLength(0); + }); +}); diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 4239c73b6..b2b154772 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; 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"; import { commitReturnedComposer, dropReturnedComposer, offerReturnedComposer } from "@/lib/popout/return-to-main"; import { credentialFingerprint, sealFor } from "@/lib/popout/sealed"; @@ -164,6 +165,14 @@ export function PopoutHost() { takeOverSent(message.notice); }); break; + case "moved": + // Undo for what a message window archived, deleted or moved: it has closed. + claimOnce(`bulwark-popout-moved:${message.requestId}`, () => { + channel?.post({ type: "moved-ack", requestId: message.requestId }); + takeOverMoved(message.notice, (key, values) => tRef.current(`notifications.${key}`, values)); + refreshList(); + }); + break; case "draft-rescue": startRescue(message.draft); break; diff --git a/components/pro/__tests__/pro-email-move-undo.test.tsx b/components/pro/__tests__/pro-email-move-undo.test.tsx new file mode 100644 index 000000000..5fe5f2499 --- /dev/null +++ b/components/pro/__tests__/pro-email-move-undo.test.tsx @@ -0,0 +1,142 @@ +import { render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; + +/** + * mail-4: Archive or Delete in a message window closed the window, its toast + * went with it, and nothing could undo the move. The view now hands the + * restore points to offerMoveUndo (lib/popout/moved-undo), which gives them + * to the main window, before the window closes. + */ + +const viewerProps = vi.hoisted(() => ({ current: null as Record | null })); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: (props: Record) => { + viewerProps.current = props; + return null; + }, +})); +vi.mock('@/components/error', () => ({ + ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + EmailViewerErrorFallback: () => null, +})); +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } })); +vi.mock('@/lib/mail-removal-confirm', () => ({ confirmMailRemoval: async () => true })); + +const calls = vi.hoisted(() => ({ order: [] as string[], offer: [] as unknown[][] })); +vi.mock('@/lib/popout/moved-undo', () => ({ + offerMoveUndo: async (...args: unknown[]) => { + calls.order.push(`offer:${args[0]}`); + calls.offer.push(args); + }, +})); +const role = vi.hoisted(() => ({ popout: true })); +vi.mock('@/lib/popout/window-role', async (importOriginal) => ({ + ...await importOriginal(), + isPopoutWindow: () => role.popout, +})); + +function selectorStore(state: T) { + const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + return hook; +} + +const auth = vi.hoisted(() => ({ state: { client: null as unknown, activeAccountId: 'alice', getClientForAccount: () => undefined } })); +vi.mock('@/stores/auth-store', () => ({ useAuthStore: selectorStore(auth.state) })); + +const MESSAGE = vi.hoisted(() => ({ + id: 'm1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + from: [{ email: 'sam@example.org', name: 'Sam' }], + to: [{ email: 'me@example.org' }], + subject: 'Launch day!', + bodyValues: { '1': { value: 'Hi' } }, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/plain' }], + attachments: [], +})); +const permanent = vi.hoisted(() => ({ value: false })); +vi.mock('@/stores/email-store', () => ({ + resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client }), + restoreBatchesFor: (emails: Array<{ id: string; mailboxIds: Record }>, client: unknown) => + [{ client, points: emails.map((e) => ({ id: e.id, mailboxIds: e.mailboxIds, seen: true })) }], + bulkDeleteIsPermanent: () => permanent.value, + useEmailStore: selectorStore({ + fetchEmailContent: async () => MESSAGE, + deleteEmail: async () => { calls.order.push('deleted'); return true; }, + moveToMailbox: async () => { calls.order.push('moved'); return true; }, + markAsRead: async () => true, + toggleStar: async () => true, + setEmailKeywordsLocal: () => {}, + mailboxes: [{ id: 'inbox', role: 'inbox', name: 'Inbox' }, { id: 'archive', role: 'archive', name: 'Archive' }], + emails: [], + selectedEmail: null, + selectedMailbox: 'inbox', + }), +})); +vi.mock('@/stores/identity-store', () => ({ useIdentityStore: selectorStore({ identities: [] }) })); +vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore({}) })); +vi.mock('@/stores/pro-tab-store', () => ({ useProTabStore: selectorStore({ openComposeTab: () => {}, closeTab: () => {}, updateTabTitle: () => {} }) })); + +import { ProEmailView } from '../pro-email-tab-body'; + +async function open() { + const client = { getEmail: vi.fn().mockResolvedValue(MESSAGE), getUsername: () => 'me@example.org', getAccountId: () => 'a' }; + const onClose = vi.fn(() => calls.order.push('closed')); + render(); + await waitFor(() => expect(viewerProps.current?.email).toBeTruthy()); + return { client, onClose }; +} + +beforeEach(() => { + viewerProps.current = null; + calls.order = []; + calls.offer = []; + role.popout = true; + permanent.value = false; +}); + +describe('a move out of a message window', () => { + it('Archive hands its Undo on before the window closes', async () => { + const { client } = await open(); + await (viewerProps.current!.onArchive as () => Promise)(); + await waitFor(() => expect(calls.order).toEqual(['moved', 'offer:archive', 'closed'])); + const [, offeredClient, restore, , inOwnWindow] = calls.offer[0]; + expect(offeredClient).toBe(client); + expect(restore).toEqual([{ client, points: [{ id: 'm1', mailboxIds: { inbox: true }, seen: true }] }]); + expect(inOwnWindow).toBe(true); + }); + + it('Delete does too', async () => { + await open(); + await (viewerProps.current!.onDelete as () => Promise)(); + await waitFor(() => expect(calls.order).toEqual(['deleted', 'offer:delete', 'closed'])); + expect((calls.offer[0][2] as unknown[]).length).toBe(1); + }); + + it('a delete for good has nothing to undo', async () => { + permanent.value = true; + await open(); + await (viewerProps.current!.onDelete as () => Promise)(); + await waitFor(() => expect(calls.order).toEqual(['deleted', 'offer:delete', 'closed'])); + expect(calls.offer[0][2]).toBeNull(); + }); + + it('Move to a folder does too', async () => { + await open(); + await (viewerProps.current!.onMoveToMailbox as (id: string) => Promise)('archive'); + await waitFor(() => expect(calls.order).toEqual(['moved', 'offer:move', 'closed'])); + }); + + it('in a tab of the main window the Undo stays in this window', async () => { + role.popout = false; + await open(); + await (viewerProps.current!.onArchive as () => Promise)(); + await waitFor(() => expect(calls.offer).toHaveLength(1)); + expect(calls.offer[0][4]).toBe(false); + }); +}); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 569b13b0a..8f04a3cbf 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { EmailViewer } from "@/components/email/email-viewer"; import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; -import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store"; +import { useEmailStore, resolveEmailBlobContext, bulkDeleteIsPermanent, restoreBatchesFor, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; @@ -39,6 +39,7 @@ import { useOpenEmailInWindow } from '@/hooks/use-popouts-available'; import { isFilePreviewable } from '@/lib/file-preview'; import { useDesktopCommands } from '@/lib/desktop/commands'; import { isPopoutWindow } from '@/lib/popout/window-role'; +import { offerMoveUndo } from '@/lib/popout/moved-undo'; import dynamic from 'next/dynamic'; // Only needed when the device policy turns downloads off. @@ -384,23 +385,30 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, const handleDelete = useCallback(async () => { if (!client || !email) return; if (!(await confirmMailRemoval('delete', 1, confirmDialog, tRemove))) return; + // Where it was, for Undo; a delete for good has none. + const { selectedMailbox } = useEmailStore.getState(); + const restore = bulkDeleteIsPermanent(mailboxes.find((m) => m.id === selectedMailbox)) ? null : restoreBatchesFor([email], client); try { // false: not deleted (a plugin stopped it), so the tab stays open. - if (await deleteEmail(client, email.id)) onClose(); + if (await deleteEmail(client, email.id)) { + await offerMoveUndo('delete', client, restore, tNotifications, isPopoutWindow()); + onClose(); + } } catch (err) { console.error('Delete failed:', err); toast.error(tNotifications('error_deleting')); } - }, [client, email, deleteEmail, onClose, tNotifications, confirmDialog, tRemove]); + }, [client, email, mailboxes, deleteEmail, onClose, tNotifications, confirmDialog, tRemove]); const handleArchive = useCallback(async () => { if (!client || !email) return; const archiveMb = mailboxes.find((m) => m.role === 'archive'); if (!archiveMb) return; if (!(await confirmMailRemoval('archive', 1, confirmDialog, tRemove))) return; + const restore = restoreBatchesFor([email], client); try { if (!(await moveToMailbox(client, email.id, archiveMb.id, emailKeyFor(email)))) return; - toast.success(tNotifications('email_archived')); + await offerMoveUndo('archive', client, restore, tNotifications, isPopoutWindow()); onClose(); } catch (err) { console.error('Archive failed:', err); @@ -494,12 +502,16 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, const handleMoveToMailbox = useCallback(async (mailboxId: string) => { if (!client || !email) return; + const restore = restoreBatchesFor([email], client); try { - if (await moveToMailbox(client, email.id, mailboxId, emailKeyFor(email))) onClose(); + if (await moveToMailbox(client, email.id, mailboxId, emailKeyFor(email))) { + await offerMoveUndo('move', client, restore, tNotifications, isPopoutWindow()); + onClose(); + } } catch (err) { console.error('Move failed:', err); } - }, [client, email, moveToMailbox, onClose]); + }, [client, email, moveToMailbox, onClose, tNotifications]); // This view's message answers the same keys as a message in the mail tab, // while its tab is the one in front. c comes from the mail page, as on any tab. diff --git a/lib/popout/__tests__/moved-undo.test.ts b/lib/popout/__tests__/moved-undo.test.ts new file mode 100644 index 000000000..67549099e --- /dev/null +++ b/lib/popout/__tests__/moved-undo.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; +import { clearUndoHistory, undoHistory } from '@/lib/undo-history'; +import { useAuthStore } from '@/stores/auth-store'; +import { useToastStore } from '@/stores/toast-store'; +import { movedNotice, offerMoveUndo, restoreBatchesFromNotice } from '../moved-undo'; + +const original = useAuthStore.getState(); +const alice = { name: 'alice' }; +const bob = { name: 'bob' }; +const t = (key: string) => key; +const POINTS = [{ id: 'm1', mailboxIds: { inbox: true }, seen: false }]; + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + useAuthStore.setState({ + client: alice as never, + getAllConnectedClients: () => new Map([['alice', alice], ['bob', bob]]) as never, + getClientForAccount: (id: string) => ({ alice, bob } as Record)[id] as never, + }); + useToastStore.getState().clearToasts(); + clearUndoHistory(); +}); + +afterEach(() => { + useAuthStore.setState(original); + vi.unstubAllGlobals(); +}); + +describe('moved-undo', () => { + it('names each batch by its login, and another window finds its own connection for it', () => { + const notice = movedNotice('move', [{ client: bob as never, accountId: 'shared', points: POINTS }]); + expect(notice).toEqual({ kind: 'move', batches: [{ login: 'bob', accountId: 'shared', points: POINTS }] }); + expect(restoreBatchesFromNotice(notice)).toEqual([{ client: bob, accountId: 'shared', points: POINTS }]); + }); + + it('with no main window to take it, the message window keeps the Undo itself', async () => { + await offerMoveUndo('archive', alice as never, [{ client: alice as never, points: POINTS }], t, true); + expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['email_archived']); + expect(undoHistory()).toHaveLength(1); + }); +}); diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts index f2d64a312..d65144638 100644 --- a/lib/popout/channel.ts +++ b/lib/popout/channel.ts @@ -14,6 +14,7 @@ import type { ComposerDraftData } from '@/components/email/email-composer'; import { isAppLockedNow } from '@/lib/app-lock/state'; import type { MessagePriority } from '@/lib/message-priority'; +import type { EmailRestorePoint } from '@/stores/email-store'; import type { ComposePopoutPayload } from './compose-payload'; import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed'; @@ -54,6 +55,16 @@ export interface PopoutSentNotice { } | null; } +/** + * A message a message window archived, deleted or moved, for the main window + * to offer Undo for (a toast and Ctrl+Z). The message window has closed by then. + */ +export interface PopoutMovedNotice { + kind: 'archive' | 'delete' | 'move'; + /** Where the messages were (stores/email-store.ts EmailRestoreBatch), by login instead of client. */ + batches: Array<{ login: string | null; accountId?: string; points: EmailRestorePoint[] }>; +} + /** * A draft whose window is closing, ready to save. A request started while a * window closes doesn't survive, and saving takes several, so another window @@ -91,6 +102,8 @@ export type PopoutMessage = | { type: 'handoff-rendered'; handoffId: string } | { type: 'sent'; requestId: string; notice: PopoutSentNotice } | { type: 'sent-ack'; requestId: string } + | { type: 'moved'; requestId: string; notice: PopoutMovedNotice } + | { type: 'moved-ack'; requestId: string } | { type: 'mail-changed'; accountId: string | null } | { type: 'state-change' } | { type: 'draft-rescue'; draft: RescuedDraft } @@ -197,11 +210,20 @@ export async function requestWindowSession( /** Tells a main window what was sent. True when one took it over. */ export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 500): Promise { + return announceToMainWindow((requestId) => ({ type: 'sent', requestId, notice }), 'sent-ack', timeoutMs); +} + +/** Hands a move's Undo to a main window. True when one took it over. */ +export function announceMovedToMainWindow(notice: PopoutMovedNotice, timeoutMs = 500): Promise { + return announceToMainWindow((requestId) => ({ type: 'moved', requestId, notice }), 'moved-ack', timeoutMs); +} + +function announceToMainWindow(message: (requestId: string) => PopoutMessage, ack: 'sent-ack' | 'moved-ack', timeoutMs: number): Promise { return new Promise((resolve) => { const requestId = newId(); let timer: ReturnType | null = null; - const channel = openPopoutChannel((message) => { - if (message.type === 'sent-ack' && message.requestId === requestId) finish(true); + const channel = openPopoutChannel((answer) => { + if (answer.type === ack && answer.requestId === requestId) finish(true); }); const finish = (taken: boolean) => { if (timer) clearTimeout(timer); @@ -213,7 +235,7 @@ export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 5 return; } timer = setTimeout(() => finish(false), timeoutMs); - channel.post({ type: 'sent', requestId, notice }); + channel.post(message(requestId)); }); } diff --git a/lib/popout/moved-undo.ts b/lib/popout/moved-undo.ts new file mode 100644 index 000000000..334a2de12 --- /dev/null +++ b/lib/popout/moved-undo.ts @@ -0,0 +1,60 @@ +import { toastWithUndo, undoLabels, undoMoveAction } from '@/lib/email-action-toast'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useAuthStore } from '@/stores/auth-store'; +import type { EmailRestoreBatch } from '@/stores/email-store'; +import { announceMovedToMainWindow, type PopoutMovedNotice } from './channel'; + +type MoveKind = PopoutMovedNotice['kind']; +type Translate = (key: string, values?: Record) => string; + +/** The toast's title for each kind, in the `notifications` namespace. */ +const TITLE_KEY: Record = { + archive: 'email_archived', + delete: 'email_deleted', + move: 'email_moved', +}; + +/** A move this window made, with logins in place of clients, so another window can undo it. */ +export function movedNotice(kind: MoveKind, batches: EmailRestoreBatch[] | null): PopoutMovedNotice { + const clients = [...useAuthStore.getState().getAllConnectedClients()]; + return { + kind, + batches: (batches ?? []).map((batch) => ({ + login: clients.find(([, client]) => client === batch.client)?.[0] ?? null, + accountId: batch.accountId, + points: batch.points, + })), + }; +} + +/** A notice's restore batches, through this window's own connections. */ +export function restoreBatchesFromNotice(notice: PopoutMovedNotice): EmailRestoreBatch[] { + const auth = useAuthStore.getState(); + return notice.batches.flatMap((batch) => { + const client = (batch.login ? auth.getClientForAccount(batch.login) : undefined) ?? auth.client; + return client && batch.points.length > 0 ? [{ client, accountId: batch.accountId, points: batch.points }] : []; + }); +} + +/** + * The toast with Undo after a message view archived, deleted or moved its + * message. A message window closes right after, and its toast would go with + * it: there the main window shows the toast and keeps the Undo, so Ctrl+Z in + * the main window reaches it. With no main window to take it, this one does. + */ +export async function offerMoveUndo( + kind: MoveKind, + client: IJMAPClient | null | undefined, + restore: EmailRestoreBatch[] | null, + t: Translate, + inOwnWindow: boolean, +): Promise { + if (inOwnWindow && (await announceMovedToMainWindow(movedNotice(kind, restore)))) return; + toastWithUndo(t(TITLE_KEY[kind]), undoMoveAction(client, restore, undoLabels(t))); +} + +/** The main window's side of offerMoveUndo. `t` is of the `notifications` namespace. */ +export function takeOverMoved(notice: PopoutMovedNotice, t: Translate): void { + const batches = restoreBatchesFromNotice(notice); + toastWithUndo(t(TITLE_KEY[notice.kind]), undoMoveAction(useAuthStore.getState().client, batches, undoLabels(t))); +} From 5be6154065bdd520feb8d2db53adb2daadec67b9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:04:44 +0100 Subject: [PATCH 3/5] fix: let the desktop notice about keeping mail on this device close by itself --- .../__tests__/offline-default-notice.test.tsx | 38 +++++++++++++++++++ components/offline/offline-host.tsx | 6 ++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 components/offline/__tests__/offline-default-notice.test.tsx diff --git a/components/offline/__tests__/offline-default-notice.test.tsx b/components/offline/__tests__/offline-default-notice.test.tsx new file mode 100644 index 000000000..9d634869d --- /dev/null +++ b/components/offline/__tests__/offline-default-notice.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { OfflineNotice } from '@/lib/offline/offline-mode'; +import { useToastStore } from '@/stores/toast-store'; + +// mail-26: the desktop app's "Bulwark keeps your mail on this device" notice +// stayed over the quick reply box until someone closed it. + +const notifier = vi.hoisted(() => ({ show: null as ((notice: OfflineNotice) => void) | null })); +vi.mock('@/lib/offline/offline-mode', () => ({ + setOfflineNotifier: (fn: ((notice: OfflineNotice) => void) | null) => { + notifier.show = fn; + }, + startOfflineMode: () => () => {}, + confirmLoss: () => {}, + unsentCount: async () => ({ messages: 0, changes: 0 }), +})); + +const { OfflineHost } = await import('../offline-host'); + +beforeEach(() => { + useToastStore.getState().clearToasts(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('the notice that the desktop app keeps mail on this device', () => { + it('goes away by itself, and still offers Settings', () => { + render(); + notifier.show!({ kind: 'default-on' }); + const [notice] = useToastStore.getState().toasts; + expect(notice.title).toBe('default_on'); + expect(notice.duration).toBeGreaterThan(0); + expect(notice.action?.label).toBe('open_settings'); + }); +}); diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index f86d066a3..643fc02ce 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -11,6 +11,9 @@ import { buildSettingsPath } from '@/lib/deep-links'; import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode'; import { toast } from '@/stores/toast-store'; +/** How long the notice that the desktop app keeps mail stays: a long sentence, so twice a plain toast. */ +const DEFAULT_ON_NOTICE_MS = 10_000; + /** * Runs offline mode (#523) in this tab: the JMAP client's offline hooks, * the outbox, the wipes. Its notices become toasts, and a sign-out the @@ -74,7 +77,8 @@ export function OfflineHost() { toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage }); break; case 'default-on': - toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage }); + // Said once; it goes like other notices, and Settings has the switch. + toast.info(tStorageRef.current('default_on'), { duration: DEFAULT_ON_NOTICE_MS, action: openStorage }); break; case 'legacy-stranded': toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 }); From acb827bf18338ae6ff096d7be07c30244144c68d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:21:03 +0100 Subject: [PATCH 4/5] feat: add copy, write, add to contacts and find mail to the right-click on an address in a message --- .../__tests__/address-context-menu.test.tsx | 105 ++++++++++++++ components/email/address-context-menu.tsx | 132 ++++++++++++++++++ components/email/email-viewer.tsx | 25 +--- components/email/recipient-popover.tsx | 7 + components/mail/mail-app.tsx | 7 + lib/contacts/add-from-address.ts | 27 ++++ lib/mail-search-request.ts | 58 ++++++++ 7 files changed, 341 insertions(+), 20 deletions(-) create mode 100644 components/email/__tests__/address-context-menu.test.tsx create mode 100644 components/email/address-context-menu.tsx create mode 100644 lib/contacts/add-from-address.ts create mode 100644 lib/mail-search-request.ts diff --git a/components/email/__tests__/address-context-menu.test.tsx b/components/email/__tests__/address-context-menu.test.tsx new file mode 100644 index 000000000..4801007c4 --- /dev/null +++ b/components/email/__tests__/address-context-menu.test.tsx @@ -0,0 +1,105 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useContactStore } from '@/stores/contact-store'; +import { useToastStore } from '@/stores/toast-store'; +import { useMailSearchRequests, type MailSearchRequest } from '@/lib/mail-search-request'; + +// mail-25: a right-click on the sender in a message header did nothing (the +// desktop app showed no menu at all). + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key} ${JSON.stringify(values)}` : key), +})); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => (email: string) => email })); +const requestInternalMailto = vi.hoisted(() => vi.fn((_href: string) => true)); +vi.mock('@/lib/protocol-handlers/mailto', () => ({ requestInternalMailto })); +const added = vi.hoisted(() => vi.fn(async (_email: string, _name?: string) => true)); +vi.mock('@/lib/contacts/add-from-address', () => ({ addAddressToContacts: added })); + +const { RecipientPopover } = await import('../recipient-popover'); + +const original = useContactStore.getState(); +let writeText: ReturnType; + +function MailPage({ onSearch }: { onSearch: (request: MailSearchRequest) => void }) { + useMailSearchRequests(onSearch, true); + return null; +} + +function rightClickSender() { + fireEvent.contextMenu(screen.getByRole('button', { name: 'Sam Lee' }), { clientX: 40, clientY: 30 }); +} + +beforeEach(() => { + writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + useContactStore.setState({ contacts: [] }); + useToastStore.getState().clearToasts(); + requestInternalMailto.mockClear(); + added.mockClear(); +}); + +afterEach(() => { + cleanup(); + useContactStore.setState(original); +}); + +describe('right-click on an address in a message header', () => { + it('offers Copy address, New message, Add to contacts and Find emails from', () => { + render(<> {}} />); + rightClickSender(); + const items = screen.getAllByRole('menuitem').map((item) => item.textContent); + expect(items).toEqual([ + 'desktopUi.context.copy_address', + 'email_composer.new_message', + 'email_viewer.contact_sidebar.add_to_contacts', + 'context_menu.find_emails_from {"name":"Sam Lee"}', + ]); + }); + + it('copies the address', async () => { + render(); + rightClickSender(); + fireEvent.click(screen.getByTestId('address-menu-copy')); + await waitFor(() => expect(writeText).toHaveBeenCalledWith('sam@example.org')); + }); + + it('writes to it in the app\'s composer', () => { + render(); + rightClickSender(); + fireEvent.click(screen.getByTestId('address-menu-write')); + expect(requestInternalMailto).toHaveBeenCalledWith('mailto:sam@example.org'); + }); + + it('adds it to the contacts, with the name from the header', async () => { + render(); + rightClickSender(); + fireEvent.click(screen.getByTestId('address-menu-contact')); + await waitFor(() => expect(added).toHaveBeenCalledWith('sam@example.org', 'Sam Lee')); + }); + + it('a saved contact opens instead of being added again', () => { + useContactStore.setState({ contacts: [{ id: 'c1', emails: { e: { address: 'SAM@example.org' } } }] as never }); + const onViewContact = vi.fn(); + render(); + rightClickSender(); + expect(screen.getByTestId('address-menu-contact').textContent).toBe('email_viewer.contact_sidebar.view_contact'); + fireEvent.click(screen.getByTestId('address-menu-contact')); + expect(onViewContact).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1' }), 'sam@example.org'); + expect(added).not.toHaveBeenCalled(); + }); + + it('finds the mail from this sender on the mail page', () => { + const onSearch = vi.fn(); + render(<>); + rightClickSender(); + act(() => fireEvent.click(screen.getByTestId('address-menu-search'))); + expect(onSearch).toHaveBeenCalledWith({ from: 'sam@example.org' }); + }); + + it('leaves out the search where there is no mail page (a message window)', () => { + render(); + rightClickSender(); + expect(screen.queryByTestId('address-menu-search')).toBeNull(); + }); +}); diff --git a/components/email/address-context-menu.tsx b/components/email/address-context-menu.tsx new file mode 100644 index 000000000..fe4ee8fbb --- /dev/null +++ b/components/email/address-context-menu.tsx @@ -0,0 +1,132 @@ +"use client"; + +// The right-click menu on an address in a message header: copy it, write to +// it, keep it as a contact, find the mail it sent. In the desktop app the +// menu shows natively (components/ui/context-menu). + +import type { MouseEvent, ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator, type ContextMenuPosition } from "@/components/ui/context-menu"; +import { Copy, ExternalLink, Search, Send, UserPlus } from "@/components/icons"; +import { useContextMenu } from "@/hooks/use-context-menu"; +import { addAddressToContacts } from "@/lib/contacts/add-from-address"; +import { mailSearchAvailable, requestMailSearch } from "@/lib/mail-search-request"; +import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; +import type { ContactCard } from "@/lib/jmap/types"; +import { useContactStore } from "@/stores/contact-store"; +import { toast } from "@/stores/toast-store"; + +export interface MenuAddress { + name?: string; + email: string; +} + +interface AddressContextMenuProps { + address: MenuAddress | null; + isOpen: boolean; + position: ContextMenuPosition; + onClose: () => void; + /** Shows a saved contact; without it a saved address offers nothing more. */ + onViewContact?: (contact: ContactCard | null, email: string) => void; +} + +function contactFor(contacts: ContactCard[], email: string): ContactCard | undefined { + const wanted = email.toLowerCase(); + return contacts.find((c) => c.emails && Object.values(c.emails).some((e) => e.address.toLowerCase() === wanted)); +} + +export function AddressContextMenu({ address, isOpen, position, onClose, onViewContact }: AddressContextMenuProps) { + const t = useTranslations(); + const contacts = useContactStore((s) => s.contacts); + if (!address) return null; + const { email, name } = address; + const contact = contactFor(contacts, email); + const run = (action: () => void) => () => { + onClose(); + action(); + }; + + const copy = async () => { + try { + await navigator.clipboard.writeText(email); + toast.success(t("email_viewer.contact_sidebar.copied")); + } catch { + toast.error(t("email_viewer.contact_sidebar.copy_failed")); + } + }; + const write = () => { + const href = `mailto:${email}`; + // No mail page here to take it (a message window): the system's handler. + if (!requestInternalMailto(href)) window.location.href = href; + }; + const save = () => { + void addAddressToContacts(email, name).then((created) => { + if (created) toast.success(t("email_viewer.contact_added")); + }); + }; + + return ( + + void copy())} testId="address-menu-copy" /> + + {contact ? ( + onViewContact && ( + onViewContact(contact, email))} + testId="address-menu-contact" + /> + ) + ) : ( + + )} + {mailSearchAvailable() && ( + <> + + requestMailSearch({ from: email }))} + testId="address-menu-search" + /> + + )} + + ); +} + +/** A right-click menu for one address, for the element that shows it. */ +export function useAddressContextMenu(onViewContact?: (contact: ContactCard | null, email: string) => void): { + onContextMenu: (e: MouseEvent, address: MenuAddress) => void; + menu: ReactNode; +} { + const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu(); + return { + onContextMenu: (e, address) => { + e.preventDefault(); + e.stopPropagation(); + openContextMenu(e, address); + }, + menu: ( + + ), + }; +} + +/** An address as text, with the address menu on a right-click. */ +export function AddressText({ name, email, onViewContact }: MenuAddress & { onViewContact?: (contact: ContactCard | null, email: string) => void }) { + const { onContextMenu, menu } = useAddressContextMenu(onViewContact); + return ( + <> + onContextMenu(e, { name, email })}>{email} + {menu} + + ); +} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index dc1ed92b0..fa7938b8e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -24,6 +24,7 @@ import { getAttachmentDisplayName } from "@/lib/attachment-name"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { measureEmailFrameHeight } from "@/lib/email-frame-height"; import { forwardFrameKeys } from "@/lib/email-frame-keys"; +import { addAddressToContacts } from "@/lib/contacts/add-from-address"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode"; import { fitEmailBodyWidth, type FitOptions } from "@/lib/email-fit-width"; import { hasFullBleedCanvas } from "@/lib/email-full-bleed"; @@ -48,7 +49,7 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p 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 { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, cleanPreview, type MailboxNode } from "@/lib/utils"; import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; import { itemsToHide } from "@/lib/toolbar-overflow"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; @@ -156,6 +157,7 @@ import { useTour } from "@/components/tour/tour-provider"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover"; +import { AddressText } from "./address-context-menu"; import { mentionFromLink } from "@/lib/recipient-mentions"; import { MailtoLink } from "@/components/ui/mailto-link"; import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; @@ -5143,7 +5145,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Email address under name (on the name's line under Mountain View) */} {!mvViewer && sender?.email && sender?.name && ( -
{sender.email}
+
)} @@ -6305,24 +6307,7 @@ ${printWatermark ? printWatermark.html : ''} setContactSidebarEmail(null); } : undefined} onAddToContacts={(addr, name) => { - const { createContact, addLocalContact, supportsSync } = useContactStore.getState(); - const client = useAuthStore.getState().client; - const contactData: Partial = { - emails: { email: { address: addr } }, - // `full` feeds the mandatory vCard FN — strict CardDAV clients - // (Apple Contacts) drop cards without it (#430). - ...(name ? { name: { components: name.includes(' ') - ? [{ kind: 'given' as const, value: name.split(' ')[0] }, { kind: 'surname' as const, value: name.split(' ').slice(1).join(' ') }] - : [{ kind: 'given' as const, value: name }], - isOrdered: true, full: name, - }} : {}), - }; - if (client && supportsSync) { - createContact(client, contactData).then((created) => { if (created) toast.success(t('contact_added')); }); - } else { - void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard) - .then((created) => { if (created) toast.success(t('contact_added')); }); - } + void addAddressToContacts(addr, name).then((created) => { if (created) toast.success(t('contact_added')); }); }} /> )} diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx index caaf8bc78..dad7efedb 100644 --- a/components/email/recipient-popover.tsx +++ b/components/email/recipient-popover.tsx @@ -11,6 +11,7 @@ import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { toast } from "@/stores/toast-store"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; import type { ContactCard } from "@/lib/jmap/types"; +import { useAddressContextMenu } from "./address-context-menu"; const CARD_WIDTH = 300; @@ -260,6 +261,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla const [anchor, setAnchor] = useState(null); const triggerRef = useRef(null); const shownEmail = useOwnDomainAddress()(email); + const addressMenu = useAddressContextMenu(onViewContact); const handleToggle = () => { if (anchor) { @@ -275,6 +277,10 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla