import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Lock, Send, ShieldCheck, SquarePen } from "lucide-react"; import { api, type Conversation, type Message, type MessageUser, type MyKeyBundle } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { relativeTime } from "../lib/format"; import { onMessage } from "../lib/messagesSocket"; import * as e2ee from "../lib/e2ee"; import Avatar from "./Avatar"; const POLL_MS = 20000; // safety-net poll; live updates come over the WebSocket const SYSTEM_ID = 0; // Set-once cache of partner public keys (the server is the directory). const pubCache = new Map(); async function partnerPub(partnerId: number): Promise { const cached = pubCache.get(partnerId); if (cached) return cached; const r = await api.messagePublicKey(partnerId); pubCache.set(partnerId, r.public_key); return r.public_key; } type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean }; export default function Messages({ meId }: { meId: number }) { const qc = useQueryClient(); const [view, setView] = useState("list"); const [unlocked, setUnlocked] = useState(e2ee.isUnlocked()); const keyQ = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 }); const configured = keyQ.data?.configured ?? false; // Restore this device's unlocked key (set once per device, then survives reloads). useEffect(() => { e2ee.loadFromDevice(meId).then((ok) => ok && setUnlocked(true)); }, [meId]); // Live delivery: a pushed message refreshes the conversation list, unread badge, and the // affected thread (which re-decrypts). Near-instant; the poll is only a fallback. useEffect( () => onMessage((m) => { qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: ["message-unread"] }); const partner = m.kind === "system" ? SYSTEM_ID : m.sender_id === meId ? m.recipient_id : m.sender_id; qc.invalidateQueries({ queryKey: ["thread", partner] }); }), [qc, meId] ); const gate = !unlocked ? ( { setUnlocked(true); qc.invalidateQueries({ queryKey: ["message-key"] }); }} /> ) : null; if (typeof view === "object") { return ( setView("list")} /> ); } if (view === "new") { return setView({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={() => setView("list")} />; } return ( setView({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID }) } onNew={() => setView("new")} /> ); } function KeyGate({ meId, configured, bundle, onUnlocked, }: { meId: number; configured: boolean; bundle: MyKeyBundle | undefined; onUnlocked: () => void; }) { const { t } = useTranslation(); const [pass, setPass] = useState(""); const [confirm, setConfirm] = useState(""); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); async function submit() { setErr(null); if (pass.length < 6) return setErr(t("messages.passTooShort")); if (!configured && pass !== confirm) return setErr(t("messages.passMismatch")); setBusy(true); try { if (configured) { await e2ee.unlock(meId, pass, bundle!); } else { const payload = await e2ee.setup(meId, pass); await api.setupMessageKey(payload); } onUnlocked(); } catch { setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed")); } finally { setBusy(false); } } return (
{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}

{configured ? t("messages.unlockBody") : t("messages.setupBody")}

setPass(e.target.value)} onKeyDown={(e) => e.key === "Enter" && configured && submit()} placeholder={t("messages.passphrase")} className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" /> {!configured && ( setConfirm(e.target.value)} placeholder={t("messages.passphraseConfirm")} className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" /> )} {err &&
{err}
} {!configured &&

{t("messages.setupWarn")}

}
); } function ConversationList({ unlocked, gate, onOpen, onNew, }: { unlocked: boolean; gate: React.ReactNode; onOpen: (c: Conversation) => void; onNew: () => void; }) { const { t } = useTranslation(); const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS }); const items = q.data?.items ?? []; const [previews, setPreviews] = useState>({}); // Decrypt the latest message of each user conversation for the list preview (system previews // are already plaintext). Runs whenever the conversations or unlock state change. useEffect(() => { if (!unlocked) return; let cancelled = false; (async () => { const out: Record = {}; for (const c of 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; }; }, [items, unlocked]); function previewText(c: Conversation): string { const m = c.last_message; if (m.kind === "system") return m.body ?? ""; if (!unlocked) return "🔒 " + t("messages.encrypted"); return previews[c.partner.id] ?? "…"; } return (
{t("messages.conversations")}
{unlocked && ( )}
{gate} {q.isLoading && !q.data ? (
{t("common.loading")}
) : items.length === 0 ? (
{t("messages.empty")}
) : (
{items.map((c) => ( ))}
)}
); } function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) { const { t } = useTranslation(); const [filter, setFilter] = useState(""); const q = useQuery({ queryKey: ["message-users"], queryFn: api.messageUsers }); const users = (q.data ?? []).filter((u) => u.name.toLowerCase().includes(filter.trim().toLowerCase())); return (
{t("messages.newTitle")}
setFilter(e.target.value)} placeholder={t("messages.searchPeople")} className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" /> {q.isLoading ? (
{t("common.loading")}
) : users.length === 0 ? (
{t("messages.noPeople")}
) : (
{users.map((u) => ( ))}
)}
); } function Thread({ meId, partnerId, isSystem, seedName, seedAvatar, unlocked, gate, onBack, }: { meId: number; partnerId: number; isSystem: boolean; seedName?: string; seedAvatar?: string | null; unlocked: boolean; gate: React.ReactNode; onBack: () => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const [draft, setDraft] = useState(""); const [plain, setPlain] = useState>({}); const bottomRef = useRef(null); const needsKey = !isSystem && !unlocked; const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>( ["thread", partnerId], () => api.thread(partnerId), { intervalMs: POLL_MS, enabled: !needsKey } ); const partner = q.data?.partner; const items = q.data?.items ?? []; // Decrypt user-message bodies for display (system messages are already plaintext). useEffect(() => { if (isSystem || !unlocked) 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, unlocked, isSystem, partnerId, t]); 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); }; const name = partner?.name ?? seedName ?? ""; return (
{name} {!isSystem && }
{needsKey ? (
{gate}
) : ( <>
{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")}
) : (