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>
This commit is contained in:
@@ -1,15 +1,28 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Keyboard shortcuts (SPEC §9.4). Ignores keystrokes while typing in inputs,
|
||||
// except Escape. Supports the `g i` chord (go to inbox).
|
||||
// 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;
|
||||
@@ -18,7 +31,7 @@ export interface Shortcuts {
|
||||
function isTyping(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
|
||||
@@ -33,49 +46,54 @@ export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
|
||||
s.onEscape?.();
|
||||
return;
|
||||
}
|
||||
if (isTyping(e.target)) 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;
|
||||
if (e.key === "i") {
|
||||
e.preventDefault();
|
||||
s.onGoInbox?.();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "/":
|
||||
e.preventDefault();
|
||||
s.onSearch?.();
|
||||
break;
|
||||
case "c":
|
||||
s.onCompose?.();
|
||||
break;
|
||||
case "r":
|
||||
s.onReply?.();
|
||||
break;
|
||||
case "a":
|
||||
s.onReplyAll?.();
|
||||
break;
|
||||
case "f":
|
||||
s.onForward?.();
|
||||
break;
|
||||
case "e":
|
||||
s.onArchive?.();
|
||||
break;
|
||||
case "j":
|
||||
s.onNext?.();
|
||||
break;
|
||||
case "k":
|
||||
s.onPrev?.();
|
||||
break;
|
||||
case "g":
|
||||
pendingG.current = true;
|
||||
window.setTimeout(() => (pendingG.current = false), 800);
|
||||
break;
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user