Files
siftlode/frontend/src/components/Messages.tsx
T
peter 20e17c0ccb fix(e10): review round 1 — the boundary that did not bind, and 14 more
The epic-end review found 32 verified issues. This is the first fix round:
the three highs, the user-facing mediums, and the tests that were passing
for the wrong reason. Docs and the remaining low-severity tail are next.

The boundary rule was the worst of them. Both `no-restricted-imports`
regexes were written in plain "..." literals, where `\.` is an unknown
escape that collapses to the any-char `.` — so the rule read like a
boundary and matched far less than one. `../../lib/api` (the barrel every
feature component imports) and `../../components/Feed` (the same file
`../Feed` names) both sailed through a green gate. String.raw, a third
pattern for the second spelling, and a vitest over ESLint.lintText so a
rule that silently matches nothing can never look like a clean codebase
again.

closeThenNavigate waited for exactly one reclaim popstate. With a second
Back-closable layer still mounted — a palette opened over the player —
the navigation unmounted that layer too, and its reconcile backed over
the entry the navigation had just pushed: "go to Channels" closed the
player and landed on the feed. It now waits for the LAST programmatic
back, then adopts the entries the navigation buried, and falls back after
250ms rather than hanging if no popstate is ever coming.

The rail's inline rename died on arrival. With no domReference, floating-
ui's returnFocus falls through to a module-global stack and restores some
unrelated element from an earlier popover — blurring the field into its
own commit-and-close, from the second popover of a session onward. The
context menu now owns the restore: back to the row, but only if the row
is still there and nothing else has claimed focus.

Also:
- Backspace under the cheat sheet stopped Plex playback and dropped to
  the grid; `back` now defers to any layer above, like `escape` beside it.
- A move invalidated only the source list, so the target kept serving
  pre-move contents for 30s while the rail already counted N+1.
- Every move error a user can reach is a 404, which `req` leaves to the
  caller — so the row snapped back in silence and the shipped moveErrors
  strings were unreachable.
- swallowClick armed on long-press but was disarmed only by the click it
  was armed for; any press that produced no click ate the next tap on the
  whole list.
- The context menu stayed parked over whatever row scrolled under it.
- overflow-clip on the chip lanes ate the focus ring's top and bottom.
- "Why is this off" rode on disabled rows, which show no tooltip and take
  no focus: a new `inert` item keeps the reason readable.
- Shift+F10 was advertised app-wide but wired only on Playlists; it has
  its own scope now, and useContextMenu matches through the shared parser
  instead of its own literals.
- Mod+K could not close the palette, `h` machine-gunned on auto-repeat,
  and both were bound above the branch that mounts their overlays.
- Palette options were in the tab order under aria-activedescendant, and
  a scroll-synthesised mousemove stole the keyboard selection.
- The OSD live region was created together with its own text.
- Ending a rename stranded focus on <body>.
- The Button variants' hover halves are `enabled:` — the one migrated
  site that had guarded its own lost the guard on the way in.
- HU builtinLocked named the list "Watch later" in a Hungarian sentence.

Tests: the chip-lane contract counted per FILE, so a second hand-rolled
lane in an already-compliant file was invisible; the closeThenNavigate
ordering test only passed on a popstate left behind by its neighbours and
failed alone, so it moved to its own file.
2026-09-05 23:52:29 +02:00

321 lines
11 KiB
TypeScript

import { useEffect, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./ui/Avatar";
import KeyGate from "./KeyGate";
import { inputCls } from "./ui/form";
import ChatThread from "./ChatThread";
type View =
| "list"
| "new"
| { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
export default function Messages({ meId }: { meId: number }) {
// Sub-views live in browser history, so Back returns to the conversation list before leaving
// the module (open() pushes an entry; back() = history.back()).
const { view, open, back } = useHistorySubview<View>("list");
// Guard against a stale sub-view whose partnerId is the current user's own id (you can't
// message yourself). This can be inherited across an account switch — fall back to the list.
if (typeof view === "object" && view.partnerId !== meId) {
return (
<PageThread
meId={meId}
partnerId={view.partnerId}
isSystem={!!view.system}
seedName={view.name}
seedAvatar={view.avatar}
onBack={back}
/>
);
}
if (view === "new") {
return (
<Directory
onPick={(u) => open({ partnerId: u.id, name: u.name, avatar: u.avatar_url })}
onBack={back}
/>
);
}
return (
<ConversationList
meId={meId}
onOpen={(c) =>
open({
partnerId: c.partner.id,
name: c.partner.name,
avatar: c.partner.avatar_url,
system: c.partner.id === SYSTEM_ID,
})
}
onNew={() => open("new")}
/>
);
}
function ConversationList({
meId,
onOpen,
onNew,
}: {
meId: number;
onOpen: (c: Conversation) => void;
onNew: () => void;
}) {
const { t } = useTranslation();
const keyState = useKeyState();
const ready = keyState === "ready";
const q = useLiveQuery<{ items: Conversation[] }>(qk.conversations(), api.conversations, {
intervalMs: POLL_MS,
});
const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({});
// Decrypt conversation previews when fresh data arrives. Keyed on dataUpdatedAt (not `items`):
// the `?? []` fallback is a new array each render, which would otherwise spin this effect
// (and setPreviews) every render while the query is loading.
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
for (const c of q.data?.items ?? []) {
const m = c.last_message;
if (m.kind === "user" && m.ciphertext && m.iv) {
try {
const pub = await partnerPub(c.partner.id);
out[c.partner.id] = await e2ee.decryptFrom(c.partner.id, pub, m.ciphertext, m.iv);
} catch {
out[c.partner.id] = "⚠";
}
}
}
if (!cancelled) setPreviews(out);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready]);
function previewText(c: Conversation): string {
const m = c.last_message;
if (m.kind === "system") return m.body ?? "";
if (!ready) return "🔒 " + t("messages.encrypted");
return previews[c.partner.id] ?? "…";
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs uppercase tracking-wide text-muted">
{t("messages.conversations")}
</div>
{ready && (
<Button variant="primary" size="xs" onClick={onNew}>
<SquarePen className="w-4 h-4" />
{t("messages.new")}
</Button>
)}
</div>
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<LoadingState />
) : items.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
) : (
<div className="flex flex-col gap-1.5">
{items.map((c) => {
const isSystem = c.partner.id === SYSTEM_ID;
return (
<div
key={c.partner.id}
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
>
<button
onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
>
<Avatar
src={c.partner.avatar_url}
fallback={c.partner.name}
className="w-10 h-10 rounded-full shrink-0 text-sm"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}
>
{c.partner.name}
</span>
<span className="ml-auto text-2xs text-muted shrink-0">
{relativeTime(c.last_message.created_at)}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}
>
{previewText(c)}
</span>
{c.unread > 0 && (
<span className="ml-auto shrink-0 min-w-5 h-5 px-1.5 grid place-items-center rounded-full bg-accent text-accent-fg text-2xs font-semibold">
{c.unread}
</span>
)}
</div>
</div>
</button>
{!isSystem && ready && (
<button
onClick={() => openChat(c.partner)}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
const { t } = useTranslation();
const [filter, setFilter] = useState("");
const q = useQuery({ queryKey: qk.messageUsers(), queryFn: api.messageUsers });
const users = (q.data ?? []).filter((u) =>
u.name.toLowerCase().includes(filter.trim().toLowerCase()),
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
icon={<ArrowLeft className="w-4 h-4" />}
/>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
</div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("messages.searchPeople")}
className={inputCls}
/>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState />
) : users.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">
{t("messages.noPeople")}
</div>
) : (
<div className="flex flex-col gap-1">
{users.map((u) => (
<button
key={u.id}
onClick={() => onPick(u)}
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
>
<Avatar
src={u.avatar_url}
fallback={u.name}
className="w-9 h-9 rounded-full shrink-0 text-sm"
/>
<span className="truncate font-medium">{u.name}</span>
</button>
))}
</div>
)}
</div>
);
}
function PageThread({
meId,
partnerId,
isSystem,
seedName,
seedAvatar,
onBack,
}: {
meId: number;
partnerId: number;
isSystem: boolean;
seedName?: string;
seedAvatar?: string | null;
onBack: () => void;
}) {
const { t } = useTranslation();
const ready = useKeyState() === "ready";
const name = seedName ?? "";
return (
<div className="flex flex-col h-[65vh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<Button
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
icon={<ArrowLeft className="w-4 h-4" />}
/>
<Avatar
src={seedAvatar}
fallback={name}
className="w-9 h-9 rounded-full shrink-0 text-sm"
/>
<span className="font-semibold truncate">{name}</span>
{!isSystem && (
<ShieldCheck
className="w-4 h-4 text-muted shrink-0"
aria-label={t("messages.encryptedHint")}
/>
)}
<div className="flex-1" />
{!isSystem && ready && (
<button
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
</div>
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
</div>
);
}