Files
siftlode/frontend/src/components/ChatThread.tsx
T
peter bd6aaa1e43 feat(design): the Copy rule — names, verbs, dates and units (E28 S3g)
The owner's picks (all the recommended options) become DESIGN.md's Copy
chapter and a sweep of both languages (742 values, no key drift):

- One name per feature: the rail's label, which may shorten it (Alerts
  -> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads,
  Channel manager -> Channels); one "sync from YouTube" verb.
- Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and
  their Hungarian pairs), checked against what each action does.
- Sentence case, the app's places capitalised; "…" on a command that
  asks first (a popover or form title got its own key without it);
  one-sentence messages without a stop or a tick, "Couldn't …"; curly
  quotes.
- Dates only from lib/format.ts, in the UI language: formatDateTime
  names the month and stops at the minute (it printed "9/27/2026,
  8:30:14 AM"), formatDateTimeSec for the log, the audit and the
  recorder; ui/RelativeTime pairs "3d ago" with the exact minute on
  hover. local/date-format refuses toLocale*String and Intl date
  formatters elsewhere.
- Units from Intl, short (formatUnit): runtimes, totals, the Scheduler
  countdown, the stats range, the player's tap skip. "2h 13m" was
  English in both languages.
- src/i18n/copyRule.test.ts holds the machine-readable part (quotes,
  "...", ticks, bare unit letters).
2026-09-27 05:46:17 +02:00

242 lines
9.8 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { api, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { notifyActionError } from "../lib/actionErrors";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
import { Textarea } from "./ui/Input";
import { RelativeTime } from "./ui/RelativeTime";
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
// chrome (back button / minimize / close) is the caller's. Self-contained key gating: prompts
// setup/unlock (server-truth) for a user conversation until messaging is ready.
export default function ChatThread({
meId,
partnerId,
isSystem,
}: {
meId: number;
partnerId: number;
isSystem: boolean;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [draft, setDraft] = useState("");
const [plain, setPlain] = useState<Record<number, string>>({});
const bottomRef = useRef<HTMLDivElement>(null);
const fade = useScrollFade();
const ready = useKeyState() === "ready";
const needsKey = !isSystem && !ready;
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
qk.thread(partnerId),
() => api.thread(partnerId),
{ intervalMs: POLL_MS, enabled: !needsKey },
);
const items = q.data?.items ?? [];
// The two effects below take the FETCH as their trigger and read the payload through this ref, so
// their dep lists name everything they touch without making the identity of `q.data` a trigger.
const threadRef = useRef(q.data);
threadRef.current = q.data;
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
// effect (and setPlain a new object) on every render while data is loading — a render storm.
useEffect(() => {
if (isSystem || !ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and
// once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
let pub: string | null = null;
let refreshed = false;
for (const m of threadRef.current?.items ?? []) {
if (!m.ciphertext || !m.iv) continue;
try {
if (!pub) pub = await partnerPub(partnerId);
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
} catch {
// A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so
// this device's cached public key is stale. Refresh it ONCE per pass and retry — that
// also re-derives future sends under the current key. A genuinely bad message still ends
// up "can't decrypt".
if (!refreshed) {
refreshed = true;
try {
pub = await refreshPartnerPub(partnerId);
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
continue;
} catch {
/* fall through to the error label below */
}
}
out[m.id] = "⚠ " + t("messages.cantDecrypt");
}
}
if (!cancelled) setPlain(out);
})();
return () => {
cancelled = true;
};
// `dataUpdatedAt` is the trigger and the payload is read through the ref above — see Messages,
// where keying this family on `q.data` itself produced a render loop.
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
// refresh the badges — but ONLY when the incoming-message count actually changed, not on every
// 20s poll (which would needlessly refetch /conversations + /unread_count each tick).
const lastIncoming = useRef(-1);
const lastPartner = useRef(partnerId);
useEffect(() => {
if (!q.dataUpdatedAt) return;
// The Messages page reuses one ChatThread instance across conversation switches (not keyed by
// partnerId), so reset the counter when the partner changes — otherwise switching to a thread
// with the same incoming-count would skip the open-marks-read refresh.
if (lastPartner.current !== partnerId) {
lastPartner.current = partnerId;
lastIncoming.current = -1;
}
const incoming = (threadRef.current?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: qk.conversations() });
qc.invalidateQueries({ queryKey: qk.messageUnread() });
}
}, [q.dataUpdatedAt, partnerId, meId, qc]);
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
// which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader
// back to the bottom even when nothing new arrived.
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [items.length]);
const send = useMutation({
mutationFn: async (text: string) => {
const pub = await partnerPub(partnerId);
const { ciphertext, iv } = await e2ee.encryptFor(partnerId, pub, text);
return api.sendMessage(partnerId, ciphertext, iv);
},
onSuccess: () => {
setDraft("");
qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
qc.invalidateQueries({ queryKey: qk.conversations() });
},
onError: (e) => {
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already
// raised the global error dialog; 404 (recipient gone) and 429 (rate-limited) are "caller-
// handled" in api.req (no modal), so surface those here or the send fails silently.
//
// Through the shared helper, which knows both of those rules: it no-ops on anything already
// reported, and it prefers the server's reason only when that reason is a STRUCTURED detail.
// The hand-written copy that used to live here did `e.detail || …`, so a Hungarian reader got
// the route's English sentence — "That member hasn't set up messaging yet." — which is
// exactly what round 5 changed `notifyActionError` to stop doing. A second copy of a contract
// is how the fix misses a caller (round 6).
notifyActionError(e, t("messages.sendFailed"));
},
});
const submit = () => {
const text = draft.trim();
if (text && !send.isPending) send.mutate(text);
};
if (needsKey) {
return (
<div className="flex-1 grid place-items-center p-3 overflow-y-auto">
<div className="w-full max-w-sm">
<KeyGate meId={meId} compact />
</div>
</div>
);
}
return (
<>
<div
ref={fade.ref}
style={fade.style}
className="flex-1 overflow-y-auto no-scrollbar p-3 flex flex-col gap-2"
>
{q.isError && !q.data ? (
<div className="m-auto">
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
</div>
) : q.isPending ? (
<div className="m-auto">
<LoadingState />
</div>
) : items.length === 0 ? (
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
) : (
items.map((m) => {
const mine = m.sender_id === meId;
const text = m.kind === "system" ? (m.body ?? "") : (plain[m.id] ?? "…");
return (
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap wrap-break-word ${
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
}`}
>
{text}
<RelativeTime
at={m.created_at}
className={`block text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}
/>
</div>
</div>
);
})
)}
<div ref={bottomRef} />
</div>
{isSystem ? (
<div className="p-2 border-t border-border text-center text-xs text-muted">
{t("messages.systemReadOnly")}
</div>
) : (
<div className="flex items-end gap-2 p-2 border-t border-border">
<Textarea
className="flex-1 resize-none max-h-28"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder={t("messages.writePlaceholder")}
/>
<IconButton
label={t("messages.send")}
size="md"
variant="primary"
onClick={submit}
disabled={!draft.trim() || send.isPending}
>
<Send className="w-4 h-4" />
</IconButton>
</div>
)}
</>
);
}