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( +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(''); + 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 = '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