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:
2026-10-18 20:00:31 +02:00
co-authored by Claude Opus 5.5
parent 463f96ad57
commit 8a1b9979e9
83 changed files with 8677 additions and 293 deletions
+55 -37
View File
@@ -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);