From 8731508645146b0db5379e7f11804bc954bc8cfc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 13 Sep 2026 07:00:51 +0200 Subject: [PATCH] feat(keys): the conversation list walks with j/k; the cheat sheet names keys in the reader's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E11 S5 — the HU/EN sweep and the keyboard-only pass over every module. - The keyboard cheat sheet printed "Space" and "Menu" on a Hungarian sheet: a keycap that carries a WORD (not a legend every layout prints alike, like Esc, Ctrl or Enter) is now named by the caller in the reader's language (`formatChord(chord, words)`, `keys.cap.*`: Szóköz / Menü). - The Plex player's ⌫/Esc line said "Stop & back to feed", but the player goes back to wherever it was opened from (Home, a Plex page): "Stop & go back" / "Leállítás és vissza". - Messages: the conversation list is a keyboard list (NavGrid + navItemAttrs, the name button is the open element; the pop-out button keeps its own Enter). D1 covers every list whose rows open one thing; this one was missed in S4. Notification cards have no single open action and stay out, like the download queue. Sweep, measured: every static `t("…")` key used in the source exists in both languages (the only misses are template prefixes); the HU values equal to EN are proper nouns and loanwords (Admin, Plex, Trakt, Shorts). Palette in Hungarian: "téma" makes "Téma: Midnight" the active row ahead of the channel "Topik - mindig témában", live region "7 találat". Keyboard-only pass in Chrome, Hungarian UI — 1024×768: every `g` chord lands on its module, `j` rings the first item (solid 2px accent) on Home, Feed, Channels, Playlists, Plex and Downloads, `s` focuses the search on Feed, Channels, Playlists and Plex, no page scrolls sideways. 3440×1440: `j` → → ↓ walks Home, Feed (8 columns: 0 → 1 → 9), Channels, Playlists, Plex and Downloads, no sideways scroll. The help sheet lists every line translated; inside the Plex player "Szóköz / K" and "⌫ / Esc Leállítás és vissza". Messages: a real `j` rings the row, Enter opens the thread. --- .../components/Messages.keyboard.dom.test.tsx | 67 +++++++++++++++++++ frontend/src/components/Messages.tsx | 12 +++- frontend/src/components/PlexPlayer.tsx | 3 +- frontend/src/components/ui/KeyboardHelp.tsx | 3 +- frontend/src/i18n/locales/en/keys.json | 8 ++- frontend/src/i18n/locales/hu/keys.json | 8 ++- frontend/src/lib/keymap.sequence.test.ts | 5 +- frontend/src/lib/keymap.test.ts | 18 +++-- frontend/src/lib/keymap.ts | 33 +++++---- 9 files changed, 127 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/Messages.keyboard.dom.test.tsx diff --git a/frontend/src/components/Messages.keyboard.dom.test.tsx b/frontend/src/components/Messages.keyboard.dom.test.tsx new file mode 100644 index 00000000..19dc6792 --- /dev/null +++ b/frontend/src/components/Messages.keyboard.dom.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +// +// The conversation list as a keyboard list (E11 S5): `j`/`k` walk the conversations and Enter opens +// the one the row's name button opens — not the pop-out button beside it. The live query, the key +// state and the thread are stubbed: the list itself is what is under test. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +const conversation = (id: number, name: string) => ({ + partner: { id, name, avatar_url: null }, + last_message: { kind: "system", body: `hello from ${name}`, created_at: "2026-09-13T00:00:00Z" }, + unread: 0, +}); +const items = [conversation(0, "Siftlode"), conversation(7, "Anna"), conversation(9, "Bence")]; + +vi.mock("../lib/useLiveQuery", () => ({ + useLiveQuery: () => ({ data: { items }, isError: false, isLoading: false, refetch: () => {} }), +})); +vi.mock("../lib/messaging", () => ({ + SYSTEM_ID: 0, + POLL_MS: 20000, + partnerPub: async () => "", + useKeyState: () => "ready", +})); +const openChat = vi.fn(); +vi.mock("../lib/chatDock", () => ({ openChat: (...a: unknown[]) => openChat(...a) })); +vi.mock("./ChatThread", () => ({ + default: ({ partnerId }: { partnerId: number }) =>
{partnerId}
, +})); + +import Messages from "./Messages"; + +afterEach(() => { + cleanup(); + history.replaceState(null, ""); +}); + +function press(key: string) { + act(() => { + (document.activeElement ?? document.body).dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ); + }); +} + +describe("Messages · the conversation list is a keyboard list", () => { + it("walks the conversations with j/k and opens the focused one on Enter", () => { + render(); + const focusedRow = () => document.activeElement?.getAttribute("data-nav-index"); + press("j"); + press("j"); + press("j"); + expect(focusedRow()).toBe("2"); + expect(document.activeElement?.textContent).toContain("Bence"); + press("k"); + expect(focusedRow()).toBe("1"); + expect(document.activeElement?.textContent).toContain("Anna"); + press("Enter"); + expect(screen.getByTestId("thread").textContent).toBe("7"); + expect(openChat).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 687b7661..18782c6e 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -16,6 +16,8 @@ import Avatar from "./ui/Avatar"; import KeyGate from "./KeyGate"; import { inputCls } from "./ui/form"; import ChatThread from "./ChatThread"; +import NavGrid from "./ui/NavGrid"; +import { navItemAttrs } from "../lib/listNav"; type View = | "list" @@ -140,15 +142,19 @@ function ConversationList({ ) : items.length === 0 ? (
{t("messages.empty")}
) : ( -
- {items.map((c) => { + // A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button + // opens; the pop-out button beside it keeps its own Enter. + + {items.map((c, i) => { const isSystem = c.partner.id === SYSTEM_ID; return (
); })} -
+ )} ); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 0b3ed07a..bb532f92 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -915,7 +915,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { info: () => setInfoOpen((v) => !v), stats: () => setStatsOpen((v) => !v), back: (ev) => { - // HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade). + // HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse + // Back button (shared cascade). // Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY // and its focused close button is not a typing target, so the key reaches us regardless. // Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet diff --git a/frontend/src/components/ui/KeyboardHelp.tsx b/frontend/src/components/ui/KeyboardHelp.tsx index e6a8d7e0..d7386951 100644 --- a/frontend/src/components/ui/KeyboardHelp.tsx +++ b/frontend/src/components/ui/KeyboardHelp.tsx @@ -47,6 +47,7 @@ export default function KeyboardHelp() { ? scopes.filter((s) => !isPageLevelScope(s) && s !== "contextMenu") : scopes; const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]); + const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") }; return (
@@ -58,7 +59,7 @@ export default function KeyboardHelp() { key={chord} className="rounded-sm border border-border bg-surface px-1.5 py-0.5 text-xs font-mono whitespace-nowrap" > - {formatChord(chord)} + {formatChord(chord, words)} ))} diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json index b7e3decd..508fb699 100644 --- a/frontend/src/i18n/locales/en/keys.json +++ b/frontend/src/i18n/locales/en/keys.json @@ -18,7 +18,7 @@ "home": "Go to Home (what should I watch?)", "info": "Media info", "stats": "Stats for nerds", - "back": "Stop & back to feed", + "back": "Stop & go back", "browseBack": "Back one level", "closePlayer": "Close the player", "palette": "Open the jump-to palette", @@ -34,5 +34,9 @@ "search": "Jump to the search box", "listStep": "Next / previous item", "gridMove": "Move around the list", - "open": "Open the selected item" + "open": "Open the selected item", + "cap": { + "space": "Space", + "menu": "Menu" + } } diff --git a/frontend/src/i18n/locales/hu/keys.json b/frontend/src/i18n/locales/hu/keys.json index d50a001b..31f017c9 100644 --- a/frontend/src/i18n/locales/hu/keys.json +++ b/frontend/src/i18n/locales/hu/keys.json @@ -18,7 +18,7 @@ "home": "Ugrás a kezdőlapra (mit nézzek?)", "info": "Média infó", "stats": "Statisztika (nerdöknek)", - "back": "Leállítás és vissza a feedre", + "back": "Leállítás és vissza", "browseBack": "Vissza egy szintet", "closePlayer": "Lejátszó bezárása", "palette": "Ugrás-paletta megnyitása", @@ -34,5 +34,9 @@ "search": "Ugrás a keresőbe", "listStep": "Következő / előző elem", "gridMove": "Mozgás a listában", - "open": "A kijelölt elem megnyitása" + "open": "A kijelölt elem megnyitása", + "cap": { + "space": "Szóköz", + "menu": "Menü" + } } diff --git a/frontend/src/lib/keymap.sequence.test.ts b/frontend/src/lib/keymap.sequence.test.ts index 2e8d6c3b..4d31b5aa 100644 --- a/frontend/src/lib/keymap.sequence.test.ts +++ b/frontend/src/lib/keymap.sequence.test.ts @@ -149,8 +149,9 @@ describe("the page-level rows", () => { }); it("render as the keys to press, step by step", () => { - expect(formatChord("g h")).toBe("G H"); - expect(formatChord("g ,")).toBe("G ,"); + const words = { Space: "Space", ContextMenu: "Menu" }; + expect(formatChord("g h", words)).toBe("G H"); + expect(formatChord("g ,", words)).toBe("G ,"); }); // `g h` shares Home's line with Shift+W; leaving out an unreachable chord must not take that with it. diff --git a/frontend/src/lib/keymap.test.ts b/frontend/src/lib/keymap.test.ts index 681a3c25..7377bf68 100644 --- a/frontend/src/lib/keymap.test.ts +++ b/frontend/src/lib/keymap.test.ts @@ -213,11 +213,19 @@ describe("helpLines", () => { }); describe("formatChord", () => { + const words = { Space: "Szóköz", ContextMenu: "Menü" }; + it("renders glyphs and upper-cases the letter", () => { - expect(formatChord("ArrowLeft")).toBe("←"); - expect(formatChord("Backspace")).toBe("⌫"); - expect(formatChord("Shift+ArrowRight")).toBe("⇧ →"); - expect(formatChord("k")).toBe("K"); - expect(formatChord("Space")).toBe("Space"); + expect(formatChord("ArrowLeft", words)).toBe("←"); + expect(formatChord("Backspace", words)).toBe("⌫"); + expect(formatChord("Shift+ArrowRight", words)).toBe("⇧ →"); + expect(formatChord("k", words)).toBe("K"); + }); + + // A key whose cap carries a word is named in the reader's language: the Hungarian sheet said "Space". + it("names a word key in the caller's language", () => { + expect(formatChord("Space", words)).toBe("Szóköz"); + expect(formatChord("ContextMenu", words)).toBe("Menü"); + expect(formatChord("Shift+F10", words)).toBe("⇧ F10"); }); }); diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index 68dedce6..413d1e47 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -195,7 +195,7 @@ export const SHORTCUTS = [ { id: "stats", scope: "plexPlayer", keys: ["d"], labelKey: "keys.stats" }, // Two rows, one help line: ⌫ is the plain "stop & out", Escape is the layer-aware cascade (defer to // anything above → cancel auto-skip → close the topmost player overlay → close). Sharing a labelKey - // keeps them a single "⌫ Esc Stop & back to feed" row in the sheet. + // keeps them a single "⌫ Esc Stop & go back" row in the sheet. { id: "back", scope: "plexPlayer", keys: ["Backspace"], labelKey: "keys.back" }, { id: "escape", @@ -558,28 +558,33 @@ const GLYPHS: Record = { ArrowUp: "↑", ArrowDown: "↓", Backspace: "⌫", - ContextMenu: "Menu", // the dedicated menu key; "ContextMenu" is a DOM name, not a keycap Escape: "Esc", Shift: "⇧", }; +/** The keys a keycap names with a WORD rather than a legend every layout prints alike (Esc, Ctrl, + * Enter): the caller supplies them in the reader's language — a Hungarian sheet said "Space". */ +export type KeycapWords = Readonly>; + const isMac = (): boolean => typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent); /** A chord as the help sheet shows it: `Mod+Shift+z` → `Ctrl ⇧ Z` (`⌘ ⇧ Z` on a Mac), and a * sequence step by step: `g h` → `G H`. */ -export function formatChord(chord: string): string { - return stepsOf(chord).map(formatStep).join(" "); -} - -function formatStep(chord: string): string { - return chord - .split("+") - .map((part, i, all) => { - if (part.toLowerCase() === "mod") return isMac() ? "⌘" : "Ctrl"; - if (i < all.length - 1) return GLYPHS[part] ?? part; - return GLYPHS[part] ?? (part.length === 1 ? part.toUpperCase() : part); - }) +export function formatChord(chord: string, words: KeycapWords): string { + const name = (part: string): string | undefined => + GLYPHS[part] ?? words[part as keyof KeycapWords]; + return stepsOf(chord) + .map((step) => + step + .split("+") + .map((part, i, all) => { + if (part.toLowerCase() === "mod") return isMac() ? "⌘" : "Ctrl"; + if (i < all.length - 1) return name(part) ?? part; + return name(part) ?? (part.length === 1 ? part.toUpperCase() : part); + }) + .join(" "), + ) .join(" "); }