Files
webmail/hooks/use-keyboard-shortcuts.ts
T

384 lines
12 KiB
TypeScript

"use client";
import { useEffect, useCallback, useRef } from "react";
import { Email } from "@/lib/jmap/types";
import { isEditableEventTarget } from "@/lib/keyboard";
import { keyboardHooks } from "@/lib/plugin-hooks";
export interface KeyboardShortcutHandlers {
// Navigation
onNextEmail?: () => void;
onPreviousEmail?: () => void;
onOpenEmail?: () => void;
onCloseEmail?: () => void;
/** `n`: open the next unread message below the current one (#514). */
onNextUnread?: () => void;
// Email actions
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onToggleStar?: () => void;
onArchive?: () => void;
onDelete?: () => void;
onMarkAsUnread?: () => void;
onMarkAsRead?: () => void;
onToggleSpam?: () => void;
// Global actions
onCompose?: () => void;
onFocusSearch?: () => void;
onShowHelp?: () => void;
onRefresh?: () => void;
// Selection
onSelectAll?: () => void;
onDeselectAll?: () => void;
/** Shift + ↓/j and Shift + ↑/k: grow or shrink the selection by one row. */
onExtendSelection?: (direction: 1 | -1) => void;
// Thread actions
onToggleThreadExpansion?: () => void;
/** Mountain View's `x`: put the focused conversation in or out of the selection. */
onToggleSelection?: () => void;
/** Mountain View's `g` sequences: `g` then i / s / t / d / a. */
onGoToMailbox?: (target: GoToMailboxTarget) => void;
}
export type GoToMailboxTarget = 'inbox' | 'starred' | 'sent' | 'drafts' | 'all';
/** The letter each `g` sequence ends on. */
const GO_TO_KEYS: Record<string, GoToMailboxTarget> = {
i: 'inbox',
s: 'starred',
t: 'sent',
d: 'drafts',
a: 'all',
};
/** How long a pending `g` waits for its second key before lapsing. */
const GO_TO_SEQUENCE_MS = 1500;
export interface UseKeyboardShortcutsOptions {
enabled?: boolean;
/**
* Mountain View assigns `x` to selection rather than to thread expansion,
* and enables the `g` sequences.
*/
mountainViewKeys?: boolean;
emails: Email[];
selectedEmailId?: string;
selectionCount?: number;
handlers: KeyboardShortcutHandlers;
}
// Shortcuts must fire regardless of the active keyboard layout (e.g. Cyrillic,
// Greek). Derive the key from the PHYSICAL key (event.code) instead of the
// layout-dependent event.key: letters from KeyA..KeyZ, and the symbol shortcuts
// (/, ?, #, !) from their US-QWERTY positions so they stay reachable on non-Latin
// layouts. Arrows/Enter/Escape keep event.key, which is already layout-neutral.
function physicalShortcutKey(event: KeyboardEvent): string {
const code = event.code;
if (code && code.length === 4 && code.startsWith("Key")) {
return code.charAt(3).toLowerCase();
}
switch (code) {
case "Slash":
return event.shiftKey ? "?" : "/";
case "Digit1":
if (event.shiftKey) return "!";
break;
case "Digit3":
if (event.shiftKey) return "#";
break;
}
return event.key.toLowerCase();
}
export function useKeyboardShortcuts({
enabled = true,
mountainViewKeys = false,
emails,
selectedEmailId,
selectionCount = 0,
handlers,
}: UseKeyboardShortcutsOptions) {
const handlersRef = useRef(handlers);
// A `g` waiting for the letter that completes it. Held in a ref so the
// listener stays stable; cleared on timeout, on completion, and on any key
// that isn't part of a sequence.
const pendingGoTo = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep handlers ref updated
useEffect(() => {
handlersRef.current = handlers;
}, [handlers]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs. Must be event-based
// (composedPath), not document.activeElement: the QuotedHtml island's
// shadow root retargets activeElement to its plain-div host (#654).
if (isEditableEventTarget(event)) return;
const h = handlersRef.current;
const key = physicalShortcutKey(event);
const keyName = `${event.ctrlKey || event.metaKey ? "mod+" : ""}${event.shiftKey && key.length === 1 && /[a-z]/.test(key) ? "shift+" : ""}${key}`;
// Runs a shortcut's action. Plugins may stop it first (onBeforeShortcut)
// and hear about it after; with no plugin listening it runs at once.
const run = (action: string, fn: (() => void) | undefined) => {
if (!fn) return;
const payload = { key: keyName, action };
const done = () => {
fn();
void keyboardHooks.onAfterShortcut.emit(payload);
};
if (keyboardHooks.onBeforeShortcut.size === 0) {
done();
return;
}
void keyboardHooks.onBeforeShortcut.intercept(payload).then((ok) => { if (ok) done(); });
};
const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
// Shortcuts that work with modifiers
if (event.ctrlKey || event.metaKey) {
switch (key) {
case "a":
// Ctrl/Cmd + A: Select all
event.preventDefault();
run("select-all", h.onSelectAll);
return;
}
}
// Shortcuts that should NOT work with modifiers
if (hasModifier) return;
const hasBatchTarget = !!selectedEmailId || selectionCount > 0;
// Second half of a `g` sequence. Checked before the switch so `s`, `t`,
// `a` and `d` reach the folder jump instead of star / reply-all.
if (mountainViewKeys && pendingGoTo.current !== null) {
clearTimeout(pendingGoTo.current);
pendingGoTo.current = null;
const target = GO_TO_KEYS[key];
if (target && h.onGoToMailbox) {
event.preventDefault();
const goTo = h.onGoToMailbox;
run(`go-to-${target}`, () => goTo(target));
return;
}
}
switch (key) {
// Navigation
case "j":
case "arrowdown":
event.preventDefault();
if (event.shiftKey && h.onExtendSelection) {
const extend = h.onExtendSelection;
run("extend-selection-down", () => extend(1));
} else {
run("next-email", h.onNextEmail);
}
break;
case "k":
case "arrowup":
event.preventDefault();
if (event.shiftKey && h.onExtendSelection) {
const extend = h.onExtendSelection;
run("extend-selection-up", () => extend(-1));
} else {
run("previous-email", h.onPreviousEmail);
}
break;
case "enter":
case "o":
if (selectedEmailId) {
event.preventDefault();
run("open-email", h.onOpenEmail);
}
break;
case "escape":
event.preventDefault();
run("close", h.onCloseEmail || h.onDeselectAll ? () => { h.onCloseEmail?.(); h.onDeselectAll?.(); } : undefined);
break;
// Email actions (only when email is selected)
case "r":
if (selectedEmailId) {
event.preventDefault();
if (event.shiftKey) {
run("reply-all", h.onReplyAll);
} else {
run("reply", h.onReply);
}
}
break;
case "a":
if (selectedEmailId) {
event.preventDefault();
run("reply-all", h.onReplyAll);
}
break;
case "f":
if (selectedEmailId) {
event.preventDefault();
run("forward", h.onForward);
}
break;
case "s":
if (selectedEmailId) {
event.preventDefault();
run("toggle-star", h.onToggleStar);
}
break;
case "e":
if (hasBatchTarget) {
event.preventDefault();
run("archive", h.onArchive);
}
break;
case "#":
case "delete":
case "backspace":
if (hasBatchTarget) {
event.preventDefault();
run("delete", h.onDelete);
}
break;
case "u":
if (hasBatchTarget) {
event.preventDefault();
run("mark-as-unread", h.onMarkAsUnread);
}
break;
case "i":
if (hasBatchTarget && event.shiftKey) {
event.preventDefault();
run("mark-as-read", h.onMarkAsRead);
}
break;
case "!":
if (hasBatchTarget) {
event.preventDefault();
run("toggle-spam", h.onToggleSpam);
}
break;
// Global actions
case "c":
event.preventDefault();
run("compose", h.onCompose);
break;
case "/":
event.preventDefault();
run("focus-search", h.onFocusSearch);
break;
case "?":
event.preventDefault();
run("show-help", h.onShowHelp);
break;
case "g":
if (event.shiftKey) {
event.preventDefault();
run("refresh", h.onRefresh);
} else if (mountainViewKeys && h.onGoToMailbox) {
// Arm the sequence; the next key either completes it or lapses.
event.preventDefault();
pendingGoTo.current = setTimeout(() => {
pendingGoTo.current = null;
}, GO_TO_SEQUENCE_MS);
}
break;
// Thread actions
case "x":
if (mountainViewKeys) {
if (selectedEmailId) {
event.preventDefault();
run("toggle-selection", h.onToggleSelection);
}
} else if (selectedEmailId) {
event.preventDefault();
run("toggle-thread-expansion", h.onToggleThreadExpansion);
}
break;
// Navigation: next unread, as in Thunderbird (#514)
case "n":
if (!event.shiftKey) {
event.preventDefault();
run("next-unread", h.onNextUnread);
}
break;
}
},
[selectedEmailId, selectionCount, mountainViewKeys]
);
// A pending sequence must not outlive the hook.
useEffect(() => () => {
if (pendingGoTo.current !== null) clearTimeout(pendingGoTo.current);
}, []);
useEffect(() => {
if (!mountainViewKeys && pendingGoTo.current !== null) {
clearTimeout(pendingGoTo.current);
pendingGoTo.current = null;
}
}, [mountainViewKeys]);
useEffect(() => {
if (!enabled) return;
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [enabled, handleKeyDown]);
// Helper to get next/previous email
const getAdjacentEmailIndex = useCallback(
(direction: "next" | "previous"): number => {
if (emails.length === 0) return -1;
if (!selectedEmailId) {
// If no email selected, select first (for next) or last (for previous)
return direction === "next" ? 0 : emails.length - 1;
}
const currentIndex = emails.findIndex((e) => e.id === selectedEmailId);
if (currentIndex === -1) return direction === "next" ? 0 : emails.length - 1;
if (direction === "next") {
return currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
} else {
return currentIndex > 0 ? currentIndex - 1 : currentIndex;
}
},
[emails, selectedEmailId]
);
return { getAdjacentEmailIndex };
}
// The help modal's rows live per app in lib/keyboard-shortcuts; Mail's are
// re-exported here under their old names.
export type { ShortcutEntry } from "@/lib/keyboard-shortcuts";
export { MAIL_SHORTCUTS as KEYBOARD_SHORTCUTS } from "@/lib/keyboard-shortcuts";