import { useEffect, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react"; import { api, type Conversation, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { relativeTime } from "../lib/format"; import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging"; import { useHistorySubview } from "../lib/history"; import { openChat } from "../lib/chatDock"; import * as e2ee from "../lib/e2ee"; import Avatar from "./ui/Avatar"; import KeyGate from "./KeyGate"; import ChatThread from "./ChatThread"; import NavGrid from "./ui/NavGrid"; import { navItemAttrs } from "../lib/listNav"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; type View = | "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean }; export default function Messages({ meId }: { meId: number }) { // Sub-views live in browser history, so Back returns to the conversation list before leaving // the module (open() pushes an entry; back() = history.back()). const { view, open, back } = useHistorySubview("list"); // Guard against a stale sub-view whose partnerId is the current user's own id (you can't // message yourself). This can be inherited across an account switch — fall back to the list. if (typeof view === "object" && view.partnerId !== meId) { return ( ); } if (view === "new") { return ( open({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={back} /> ); } return ( open({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID, }) } onNew={() => open("new")} /> ); } function ConversationList({ meId, onOpen, onNew, }: { meId: number; onOpen: (c: Conversation) => void; onNew: () => void; }) { const { t } = useTranslation(); const keyState = useKeyState(); const ready = keyState === "ready"; const q = useLiveQuery<{ items: Conversation[] }>(qk.conversations(), api.conversations, { intervalMs: POLL_MS, }); const items = q.data?.items ?? []; const [previews, setPreviews] = useState>({}); // Decrypt conversation previews when fresh data arrives. The TRIGGER is `dataUpdatedAt` and the // payload is read through a ref, which is what makes the dep list below complete without making // the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a // new array every render, and anything handing back a fresh object per render (a stub, a caller // that rebuilds it) turns "re-run when the data changes" into a render loop through setPreviews. // Measured the hard way — E27 S2 keyed this on `q.data`, and Messages.keyboard.dom.test hung. const conversationsRef = useRef(q.data); conversationsRef.current = q.data; useEffect(() => { if (!ready) return; let cancelled = false; (async () => { const out: Record = {}; for (const c of conversationsRef.current?.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; }; }, [q.dataUpdatedAt, ready]); function previewText(c: Conversation): string { const m = c.last_message; if (m.kind === "system") return m.body ?? ""; if (!ready) return "🔒 " + t("messages.encrypted"); return previews[c.partner.id] ?? "…"; } return (
{t("messages.conversations")}
{ready && ( )}
{(keyState === "setup" || keyState === "unlock") && } {q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading && !q.data ? ( ) : items.length === 0 ? (
{t("messages.empty")}
) : ( // A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button // opens; the pop-out button beside it keeps its own Enter. {items.map((c, i) => { const isSystem = c.partner.id === SYSTEM_ID; return (
onOpen(c)} className="flex items-center gap-3 p-2.5 min-w-0 flex-1" >
0 ? "font-semibold" : "font-medium"}`} > {c.partner.name} {relativeTime(c.last_message.created_at)}
0 ? "text-fg" : "text-muted"}`} > {previewText(c)} {c.unread > 0 && ( {c.unread} )}
{!isSystem && ready && ( openChat(c.partner)} label={t("messages.popOut")} className="mr-1.5 reveal" > )}
); })}
)}
); } function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) { const { t } = useTranslation(); const [filter, setFilter] = useState(""); const q = useQuery({ queryKey: qk.messageUsers(), 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")} /> {q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading ? ( ) : users.length === 0 ? (
{t("messages.noPeople")}
) : (
{users.map((u) => ( onPick(u)} className="flex items-center gap-3 p-2.5 rounded-xl" > {u.name} ))}
)}
); } function PageThread({ meId, partnerId, isSystem, seedName, seedAvatar, onBack, }: { meId: number; partnerId: number; isSystem: boolean; seedName?: string; seedAvatar?: string | null; onBack: () => void; }) { const { t } = useTranslation(); const ready = useKeyState() === "ready"; const name = seedName ?? ""; return (
{name} {!isSystem && ( )}
{!isSystem && ready && ( openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })} label={t("messages.popOut")} > )}
); }