Files
siftlode/frontend/src/components/Messages.tsx
T
peter 76358a0d77 refactor(ui): the popover engine and every render-phase ref write compile under the React Compiler
E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.

- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
  a ref, so every component holding one was skipped (21 call sites, the engine and its surface
  among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
  else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
  useSyncExternalStore, which React asks on every render instead of a cached memo.

Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
  the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
  The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
  draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
  inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
  "one writer" rule; the writer comes first now and the effect goes through it.

The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
2026-10-03 23:18:26 +02:00

339 lines
12 KiB
TypeScript

import { useEffect, useEffectEvent, 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 { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
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 ChatThread from "./ChatThread";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import { RelativeTime } from "./ui/RelativeTime";
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. The TRIGGER is `dataUpdatedAt` and the
// payload is read through an effect event, which is what makes the dep list below complete without
// making the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
// new array every render, and anything handing back a fresh object per render (a stub, a caller
// that rebuilds it) turns "re-run when the data changes" into a render loop through setPreviews.
// Measured the hard way — E27 S2 keyed this on `q.data`, and Messages.keyboard.dom.test hung.
const latestConversations = useEffectEvent(() => q.data);
useEffect(() => {
if (!ready) return;
let cancelled = false;
const conversations = latestConversations();
// Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
void (async () => {
const out: Record<number, string> = {};
for (const c of conversations?.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;
};
}, [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">
<SectionLabel>{t("messages.conversations")}</SectionLabel>
{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 shape="list" />
) : items.length === 0 ? (
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>{t("messages.empty")}</div>
) : (
// 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.
<NavGrid className="flex flex-col gap-1.5">
{items.map((c, i) => {
const isSystem = c.partner.id === SYSTEM_ID;
return (
<div
key={c.partner.id}
{...navItemAttrs(i, c.partner.name)}
className="group flex items-center gap-1 rounded-xl state-layer transition"
>
<Pressable
selectable
data-nav-open=""
onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 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>
<RelativeTime
at={c.last_message.created_at}
className="ml-auto text-2xs text-muted shrink-0"
/>
</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>
</Pressable>
{!isSystem && ready && (
<IconButton
size="md"
onClick={() => openChat(c.partner)}
label={t("messages.popOut")}
className="mr-1.5 reveal"
>
<ExternalLink />
</IconButton>
)}
</div>
);
})}
</NavGrid>
)}
</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">
<IconButton
label={t("channel.back")}
size="sm"
variant="ghost"
// 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).
onClick={onBack}
>
<ArrowLeft className="w-4 h-4" />
</IconButton>
<SectionLabel>{t("messages.newTitle")}</SectionLabel>
</div>
<Input
className="w-full"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("messages.searchPeople")}
/>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState shape="list" />
) : users.length === 0 ? (
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
{t("messages.noPeople")}
</div>
) : (
<div className="flex flex-col gap-1">
{users.map((u) => (
<Pressable
selectable
key={u.id}
layer
onClick={() => onPick(u)}
className="flex items-center gap-3 p-2.5 rounded-xl"
>
<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>
</Pressable>
))}
</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-[65dvh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<IconButton
label={t("channel.back")}
size="sm"
variant="ghost"
// 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).
onClick={onBack}
>
<ArrowLeft className="w-4 h-4" />
</IconButton>
<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 && (
<IconButton
size="md"
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
label={t("messages.popOut")}
>
<ExternalLink />
</IconButton>
)}
</div>
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
</div>
);
}