import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Send } from "lucide-react"; import { api, HttpError, type Message, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import { partnerPub, useKeyState } from "../lib/messaging"; import * as e2ee from "../lib/e2ee"; import KeyGate from "./KeyGate"; const POLL_MS = 20000; // safety net; live updates arrive over the WebSocket // 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 ready = useKeyState() === "ready"; const needsKey = !isSystem && !ready; const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>( ["thread", partnerId], () => api.thread(partnerId), { intervalMs: POLL_MS, enabled: !needsKey } ); const items = q.data?.items ?? []; // 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 = {}; for (const m of q.data?.items ?? []) { if (m.ciphertext && m.iv) { try { const pub = await partnerPub(partnerId); out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv); } catch { out[m.id] = "⚠ " + t("messages.cantDecrypt"); } } } if (!cancelled) setPlain(out); })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [q.dataUpdatedAt, ready, isSystem, partnerId, t]); // Opening / refreshing the thread marks incoming messages read, so clear the badges. useEffect(() => { if (q.dataUpdatedAt) { qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: ["message-unread"] }); } }, [q.dataUpdatedAt, 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: ["thread", partnerId] }); qc.invalidateQueries({ queryKey: ["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. if (e instanceof HttpError && (e.status === 404 || e.status === 429)) { notify({ level: "error", message: e.detail || t("messages.sendFailed") }); } }, }); const submit = () => { const text = draft.trim(); if (text && !send.isPending) send.mutate(text); }; if (needsKey) { return (
); } return ( <>
{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}
{relativeTime(m.created_at)}
); }) )}
{isSystem ? (
{t("messages.systemReadOnly")}
) : (