fix: keep mail shortcuts working after a click into an HTML message

This commit is contained in:
Linus Rath
2026-11-04 06:03:05 +01:00
parent 0e400dbcf4
commit ec4fd9c529
5 changed files with 268 additions and 0 deletions
@@ -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: '<p id="p">Newsletter</p>' } },
};
const { container } = render(
<ThreadConversationView
thread={{ threadId: 't1', emails: [email], latestEmail: email } as never}
emails={[email] as never}
onBack={() => {}}
/>,
);
const iframe = container.querySelector('iframe')!;
const srcdoc = iframe.getAttribute('srcdoc') ?? '';
const doc = iframe.contentDocument!;
doc.body.innerHTML = /<body[^>]*>([\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']);
});
});
+3
View File
@@ -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 {
@@ -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.
+108
View File
@@ -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('<p>Newsletter</p>');
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('<form><input name="q"><textarea></textarea><div contenteditable="true"><b>x</b></div></form>');
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('<p>Text</p>');
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 = '<div role="row" tabindex="-1">A</div><div role="row" tabindex="-1" aria-current="true">B</div>';
document.body.appendChild(list);
const { doc } = frameWith('<p>Text</p>');
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('<p>Text</p>');
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('<p>Text</p>');
pressIn(doc, doc.querySelector('p')!, 'e', 'KeyE');
expect(handlers.onArchive).not.toHaveBeenCalled();
});
});
+90
View File
@@ -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<HTMLElement>('[role="row"][aria-current="true"]') ??
list.querySelector<HTMLElement>('[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);
}