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, type Message, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { relativeTime } from "../lib/format"; 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 ?? []; useEffect(() => { if (isSystem || !ready) return; let cancelled = false; (async () => { const out: Record = {}; for (const m of 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; }; }, [items, 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]); useEffect(() => { bottomRef.current?.scrollIntoView({ block: "end" }); }, [items.length, plain]); 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"] }); }, }); 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")}
) : (