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>({}); const bottomRef = useRef(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 = {}; // 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 (
); } return ( <>
{q.isError && !q.data ? (
q.refetch()}> {t("common.loadError")}
) : q.isPending ? (
) : items.length === 0 ? (
{t("messages.threadEmpty")}
) : ( items.map((m) => { const mine = m.sender_id === meId; const text = m.kind === "system" ? (m.body ?? "") : (plain[m.id] ?? "…"); return (
{text}
); }) )}
{isSystem ? (
{t("messages.systemReadOnly")}
) : (