src/interaction.ts
export const interactionActions = [
{ id: "next", label: "Next email", key: "j" },
{ id: "previous", label: "Previous email", key: "k" },
{ id: "open", label: "Open email", key: "Enter" },
// Ids stay stable so saved shortcuts keep working after Important/Other became Inbox tabs.
{ id: "important", label: "Inbox", key: "i" },
{ id: "other", label: "Next Inbox category", key: "o" },
{ id: "search", label: "Search", key: "/" },
{ id: "compose", label: "Compose", key: "c" },
{ id: "reply", label: "Reply", key: "r" },
{ id: "done", label: "Archive", key: "e" },
{ id: "snooze", label: "Snooze", key: "s" },
{ id: "teach", label: "Teach sorting", key: "m" },
{ id: "density", label: "Comfortable / Compact", key: "d" },
{ id: "privacy", label: "Hide personal information on / off", key: "p" },
{ id: "settings", label: "Settings", key: "," },
{ id: "help", label: "Keyboard shortcuts", key: "?" },
{ id: "back", label: "Back", key: "ArrowLeft", alt: true },
{ id: "forward", label: "Forward", key: "ArrowRight", alt: true },
] as const;
export type InteractionAction = (typeof interactionActions)[number]["id"];
export type Shortcut = { key: string; ctrl?: boolean; meta?: boolean; alt?: boolean; shift?: boolean };
export type InteractionPreferences = {
opening: "double" | "single";
shortcuts: Record<InteractionAction, Shortcut>;
};
export const defaults: InteractionPreferences = {
opening: "double",
shortcuts: Object.fromEntries(interactionActions.map((action) =>
[action.id, { key: action.key, alt: "alt" in action && action.alt }])) as InteractionPreferences["shortcuts"],
};
const storageKey = "emc2.interaction.v1";
function canonical(shortcut: Shortcut): Shortcut {
return { key: shortcut.key.length === 1 ? shortcut.key.toLowerCase() : shortcut.key,
ctrl: !!shortcut.ctrl, meta: !!shortcut.meta, alt: !!shortcut.alt,
// Printable symbols already encode Shift in KeyboardEvent.key.
shift: !!shortcut.shift && (/^[a-z]$/i.test(shortcut.key) || ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Enter"].includes(shortcut.key)) };
}
function same(a: Shortcut, b: Shortcut) {
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
}
export function shortcutFromEvent(event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey">): Shortcut {
return canonical({ key: event.key, ctrl: event.ctrlKey, meta: event.metaKey, alt: event.altKey, shift: event.shiftKey });
}
export function shortcutLabel(shortcut: Shortcut): string {
const binding = canonical(shortcut);
const key = ({ ArrowLeft: "Left", ArrowRight: "Right", ArrowUp: "Up", ArrowDown: "Down" } as Record<string, string>)[binding.key] ||
(binding.key.length === 1 ? binding.key.toUpperCase() : binding.key);
return [binding.ctrl && "Ctrl", binding.meta && "Cmd", binding.alt && "Alt", binding.shift && "Shift", key].filter(Boolean).join("+");
}
export function validateShortcut(action: InteractionAction, shortcut: Shortcut, value: InteractionPreferences): string | null {
const binding = canonical(shortcut);
const navigation = binding.alt && !binding.ctrl && !binding.meta && !binding.shift &&
["ArrowLeft", "ArrowRight"].includes(binding.key);
if (!(/^[a-z0-9,./;\[\]'\\?]$/.test(binding.key) || ["Enter", "ArrowUp", "ArrowDown"].includes(binding.key) || navigation))
return "Choose a letter, punctuation key, Enter, or an arrow shortcut. Escape and editing keys are reserved.";
if (binding.ctrl || binding.meta || (binding.alt && !navigation && !binding.shift))
return "Command, Control, and Alt shortcuts are reserved for the system. Use a single key, Shift combination, or Alt+Left/Right.";
if (/^[0-9]$/.test(binding.key) && !binding.shift && !binding.alt)
return "Number keys are reserved for account filters.";
if (binding.key === "t" && !binding.shift && !binding.alt && action !== "teach")
return "T is reserved as the Teach sorting alias.";
const conflict = interactionActions.find((other) => other.id !== action && same(binding, value.shortcuts[other.id]));
return conflict ? `Already used for ${conflict.label}.` : null;
}
export function read(): InteractionPreferences {
const fallback = (): InteractionPreferences => structuredClone(defaults);
try {
const stored: unknown = JSON.parse(localStorage.getItem(storageKey) || "null");
if (!stored || typeof stored !== "object") return fallback();
const input = stored as Record<string, unknown>;
const value = fallback();
if (input.opening === "single") value.opening = "single";
if (!input.shortcuts || typeof input.shortcuts !== "object") return value;
const bindings = input.shortcuts as Record<string, unknown>;
for (const { id } of interactionActions) {
const candidate = bindings[id];
if (!candidate || typeof candidate !== "object") { value.shortcuts = fallback().shortcuts; return value; }
const fields = candidate as Record<string, unknown>;
if (typeof fields.key !== "string" || ["ctrl", "meta", "alt", "shift"].some((key) => fields[key] !== undefined && typeof fields[key] !== "boolean")) { value.shortcuts = fallback().shortcuts; return value; }
value.shortcuts[id] = canonical(candidate as Shortcut);
}
if (interactionActions.some(({ id }) => validateShortcut(id, value.shortcuts[id], value)))
value.shortcuts = fallback().shortcuts;
return value;
} catch { return fallback(); }
}
export function save(value: InteractionPreferences): boolean {
if (!["double", "single"].includes(value.opening) || interactionActions.some(({ id }) => validateShortcut(id, value.shortcuts[id], value))) return false;
try {
// Serialize only the preference contract, never unrelated object fields.
localStorage.setItem(storageKey, JSON.stringify({ opening: value.opening,
shortcuts: Object.fromEntries(interactionActions.map(({ id }) => [id, canonical(value.shortcuts[id])])) }));
return true;
} catch { return false; }
}
export function matches(event: KeyboardEvent, action: InteractionAction, value: InteractionPreferences, platform = ""): boolean {
if (event.defaultPrevented || event.isComposing || event.repeat || event.getModifierState?.("AltGraph")) return false;
const target = event.target;
if (target && "closest" in target && typeof target.closest === "function" &&
target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), dialog, [role="dialog"], [data-interaction-settings]')) return false;
const binding = shortcutFromEvent(event);
if (same(binding, value.shortcuts[action])) return true;
if (action === "teach" && same(value.shortcuts.teach, defaults.shortcuts.teach) && same(binding, { key: "t" })) return true;
return platform === "darwin" && (action === "back" || action === "forward") &&
same(binding, { key: action === "back" ? "[" : "]", meta: true });
}