Files
siftlode/frontend/src/components/Messages.tsx
T
peter dac9d89f91 feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)
- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
  focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
  filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
  Enter/Escape (the playlist and Plex title renames, the priority number);
  the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
2026-09-24 23:48:22 +02:00

335 lines
12 KiB
TypeScript

import { useEffect, useRef, 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 { 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 ChatThread from "./ChatThread";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
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 a ref, 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 conversationsRef = useRef(q.data);
conversationsRef.current = q.data;
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
for (const c of conversationsRef.current?.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">
<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>
) : (
// 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 hover:bg-card transition"
>
<Pressable
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>
<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>
</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>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
</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 />
) : 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) => (
<Pressable
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-[65vh] 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>
);
}