Files
webmail-client/frontend/src/lib/useKeyboardShortcuts.ts
T
lukasandClaude Opus 5.5 8a1b9979e9 Productivity features, UI translations, local email translation
- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings
  section, mail templates (Compose > More > Templates), a Subscriptions
  folder with bulk unsubscribe/delete, and tracker blocking with a
  Privacy toggle (on by default)
- Own dialogs instead of browser popups; emoji picker in compose; compose
  windows survive a visit to Settings
- Sentence completion learned from the user's sent mail (no AI)
- Interface in English and Polish (one file per language, English as the
  fallback), picked from the browser on first run, then kept
- Email translation on the server via LibreTranslate: Translate button
  and "Translate to...", messages in several languages translated part
  by part, names/addresses/emoji/file names/phone numbers kept, an
  animated line while translating
- Antispam "Mark as spam" loads with the message, not when the menu opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:00:31 +02:00

102 lines
2.8 KiB
TypeScript

import { useEffect, useRef } from "react";
// Keyboard shortcuts (SPEC §9.4; the list users see is lib/shortcuts.ts).
// Ignores keystrokes while typing in inputs, except Escape. "g" starts a
// two-key sequence (g i = Inbox). With `enabled` false only Escape works.
export interface Shortcuts {
enabled?: boolean;
onSearch?: () => void;
onCompose?: () => void;
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onArchive?: () => void;
onTrash?: () => void;
onTogglePin?: () => void;
onSnooze?: () => void;
onMarkRead?: () => void;
onMarkUnread?: () => void;
onBack?: () => void;
onGoInbox?: () => void;
onGoSnoozed?: () => void;
onGoDrafts?: () => void;
onGoSent?: () => void;
onGoSubscriptions?: () => void;
onHelp?: () => void;
onEscape?: () => void;
onNext?: () => void;
onPrev?: () => void;
}
function isTyping(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
}
export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
const ref = useRef(shortcuts);
ref.current = shortcuts;
const pendingG = useRef(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const s = ref.current;
if (e.key === "Escape") {
s.onEscape?.();
return;
}
if (s.enabled === false || isTyping(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const run = (fn: (() => void) | undefined) => {
if (!fn) return;
e.preventDefault();
fn();
};
if (pendingG.current) {
pendingG.current = false;
const go: Record<string, (() => void) | undefined> = {
i: s.onGoInbox,
z: s.onGoSnoozed,
d: s.onGoDrafts,
t: s.onGoSent,
b: s.onGoSubscriptions,
};
if (e.key in go) {
run(go[e.key]);
return;
}
}
// Shift+letter arrives as the capital letter.
const actions: Record<string, (() => void) | undefined> = {
"/": s.onSearch,
"?": s.onHelp,
c: s.onCompose,
r: s.onReply,
a: s.onReplyAll,
f: s.onForward,
e: s.onArchive,
"#": s.onTrash,
s: s.onTogglePin,
b: s.onSnooze,
I: s.onMarkRead,
U: s.onMarkUnread,
u: s.onBack,
j: s.onNext,
k: s.onPrev,
};
if (e.key === "g") {
pendingG.current = true;
window.setTimeout(() => (pendingG.current = false), 1000);
return;
}
if (e.key in actions) run(actions[e.key]);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
}