"use client"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@turbostarter/ui-web/button"; import { claimTopicKey, decryptMessage, encryptMessage, getTopicKey, registerBrowserPeerPubkey, sealTopicKeyFor, } from "~/services/crypto/topic-key"; interface TopicMessage { id: string; senderPubkey: string; senderName: string; nonce: string; ciphertext: string; /** 1 = legacy plaintext-base64. 2 = crypto_secretbox under topic key. */ bodyVersion?: number; replyToId?: string | null; createdAt: string; } interface MeshMember { memberId: string; pubkey: string; displayName: string; role: string; isHuman: boolean; joinedAt: string; online: boolean; status: string; summary: string | null; } interface Props { topicName: string; topicId: string; meshSlug: string; apiKeySecret: string; apiKeyExpiresAt: string; } /** * v1 (legacy plaintext-base64) decode path. v0.2.0 messages used this * fake-encryption stub; real v0.3.0 ciphertext is decrypted via the * topic key — see `decryptForRender` below. */ function decodeV1(ciphertext: string): string { try { const decoded = typeof window === "undefined" ? Buffer.from(ciphertext, "base64").toString("utf-8") : new TextDecoder().decode( Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)), ); return decoded; } catch { return "[decode failed]"; } } /** Encode v1 plaintext for the rare fallback path when a topic has no * encryption key (legacy v0.2.0 topics). v0.3.0+ topics encrypt via * `encryptMessage` from the topic-key service. */ function encodeV1Outgoing(plaintext: string): { ciphertext: string; nonce: string } { const bytes = new TextEncoder().encode(plaintext); const ciphertext = typeof window === "undefined" ? Buffer.from(bytes).toString("base64") : btoa(String.fromCharCode(...bytes)); const nonceBytes = new Uint8Array(24); crypto.getRandomValues(nonceBytes); const nonce = typeof window === "undefined" ? Buffer.from(nonceBytes).toString("base64") : btoa(String.fromCharCode(...nonceBytes)); return { ciphertext, nonce }; } /** * Render plaintext with @mentions highlighted in clay. We split on the * mention regex and rebuild as alternating spans so React can reconcile * keys cleanly. URL/markdown parsing is out of scope for v0.2.0. */ function renderWithMentions(text: string): React.ReactNode[] { const parts: React.ReactNode[] = []; const re = /(^|\s)(@[A-Za-z0-9_-]+)/g; let lastIndex = 0; let match: RegExpExecArray | null; let key = 0; while ((match = re.exec(text)) !== null) { const [, lead, mention] = match; const matchStart = match.index + (lead?.length ?? 0); if (matchStart > lastIndex) { parts.push( {text.slice(lastIndex, matchStart)}, ); } parts.push( {mention} , ); lastIndex = matchStart + (mention?.length ?? 0); } if (lastIndex < text.length) { parts.push({text.slice(lastIndex)}); } return parts; } function fmtTime(iso: string): string { try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); } catch { return iso; } } const monoStyle = { fontFamily: "var(--cm-font-mono)" } as const; type SseEvent = { event: string; id?: string; data: string; }; /** * Minimal text/event-stream parser. Reads from a `fetch` body so we can * keep the bearer token in the Authorization header — the native * EventSource API doesn't allow custom headers, which would force us to * pass the secret via query string and leak it into proxy/referer logs. * * Yields each `event:`/`id:`/`data:` block. Anything that doesn't fit * the format (comments, blank lines, unknown fields) is skipped. */ async function* readSseStream( reader: ReadableStreamDefaultReader, ): AsyncGenerator { const decoder = new TextDecoder(); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let idx: number; while ((idx = buffer.indexOf("\n\n")) !== -1) { const block = buffer.slice(0, idx); buffer = buffer.slice(idx + 2); const ev: SseEvent = { event: "message", data: "" }; const dataLines: string[] = []; for (const line of block.split("\n")) { if (!line || line.startsWith(":")) continue; const colon = line.indexOf(":"); if (colon < 0) continue; const field = line.slice(0, colon); const val = line.slice(colon + 1).replace(/^ /, ""); if (field === "event") ev.event = val; else if (field === "id") ev.id = val; else if (field === "data") dataLines.push(val); } ev.data = dataLines.join("\n"); yield ev; } } } export function TopicChatPanel({ topicName, meshSlug, apiKeySecret, apiKeyExpiresAt, }: Props) { const [messages, setMessages] = useState([]); const [members, setMembers] = useState([]); const [draft, setDraft] = useState(""); const [error, setError] = useState(null); const [sending, setSending] = useState(false); const [streamState, setStreamState] = useState< "connecting" | "live" | "reconnecting" | "stopped" >("connecting"); const [lastEventAt, setLastEventAt] = useState(null); const [mentionState, setMentionState] = useState<{ query: string; start: number; selected: number; } | null>(null); const [searchQuery, setSearchQuery] = useState(""); const [searchOpen, setSearchOpen] = useState(false); const scrollRef = useRef(null); const textareaRef = useRef(null); const seenIdsRef = useRef>(new Set()); const lastMarkReadAtRef = useRef(0); // v0.3.0 per-topic encryption state. // `topicKey` is the 32-byte symmetric key for the active topic (null = // unencrypted / not yet sealed for this browser). `keyState` distinguishes // the three reasons we might not have a key yet, so the UI can show the // right message ("waiting for a CLI peer to share the key" vs "topic is // legacy plaintext" vs "decrypt failed"). const [topicKey, setTopicKey] = useState(null); const [keyState, setKeyState] = useState< "loading" | "ready" | "not_sealed" | "topic_unencrypted" | "error" >("loading"); // Decrypted plaintext per message id, computed lazily on render. const [decrypted, setDecrypted] = useState>(new Map()); const headers = useMemo( () => ({ Authorization: `Bearer ${apiKeySecret}`, "Content-Type": "application/json", }), [apiKeySecret], ); // Mark the topic read up to now, but at most once per 5 seconds — // we'd otherwise hit /read on every inbound SSE message which is // wasteful (the wall-clock watermark advances either way). const markRead = useCallback(async () => { if (Date.now() - lastMarkReadAtRef.current < 5000) return; lastMarkReadAtRef.current = Date.now(); try { await fetch(`/api/v1/topics/${encodeURIComponent(topicName)}/read`, { method: "PATCH", headers, }); } catch { // Soft-fail — unread counts are advisory. } }, [headers, topicName]); // One-shot history backfill on mount; the SSE stream is forward-only, // so any messages older than connect-time come from this fetch. const loadHistory = useCallback(async () => { try { const res = await fetch( `/api/v1/topics/${encodeURIComponent(topicName)}/messages?limit=100`, { headers, cache: "no-store" }, ); if (!res.ok) { setError(`history fetch failed: ${res.status}`); return; } const json = (await res.json()) as { messages: TopicMessage[] }; const ordered = json.messages.slice().reverse(); for (const m of ordered) seenIdsRef.current.add(m.id); setMessages(ordered); setError(null); } catch (e) { setError((e as Error).message); } }, [headers, topicName]); useEffect(() => { void loadHistory(); void markRead(); }, [loadHistory, markRead]); // Per-topic encryption bootstrap. // // On mount: register the browser's IndexedDB-persisted pubkey against // mesh.member.peer_pubkey (idempotent), then ask /v1/topics/:name/key // for our sealed copy. If no peer has sealed for us yet (404), poll // every 5s — the CLI's 30s re-seal loop will eventually catch up. // If the topic is unencrypted (legacy v0.2.0), fall through to v1. useEffect(() => { let cancelled = false; let pollTimer: ReturnType | null = null; const tryFetchKey = async (firstAttempt: boolean) => { try { if (firstAttempt) { // Idempotent — only writes on first run / after rotation. await registerBrowserPeerPubkey(apiKeySecret); } const res = await getTopicKey({ apiKeySecret, topicName }); if (cancelled) return; if (res.ok && res.topicKey) { setTopicKey(res.topicKey); setKeyState("ready"); return; } if (res.error === "topic_unencrypted") { setTopicKey(null); setKeyState("topic_unencrypted"); return; } if (res.error === "not_sealed") { setTopicKey(null); setKeyState("not_sealed"); // Re-poll: a CLI peer's re-seal loop runs every 30s, so 5s // here gives a quick reaction without hammering the server. pollTimer = setTimeout(() => void tryFetchKey(false), 5000); return; } setKeyState("error"); } catch { if (!cancelled) setKeyState("error"); } }; void tryFetchKey(true); return () => { cancelled = true; if (pollTimer) clearTimeout(pollTimer); }; }, [apiKeySecret, topicName]); // Browser-side re-seal loop. While we hold the topic key, every 30s // we look for newly-joined topic members who don't yet have a sealed // copy and seal it for them. Mirrors the CLI re-seal path so a topic // claimed-by-browser doesn't go dark for CLI joiners. useEffect(() => { if (!topicKey || keyState !== "ready") return; let cancelled = false; const reseal = async () => { try { const res = await fetch( `/api/v1/topics/${encodeURIComponent(topicName)}/pending-seals`, { headers, cache: "no-store" }, ); if (!res.ok) return; const json = (await res.json()) as { pending: Array<{ memberId: string; pubkey: string; displayName: string }>; }; for (const target of json.pending) { if (cancelled) return; const sealed = await sealTopicKeyFor(topicKey, target.pubkey); if (!sealed) continue; try { await fetch( `/api/v1/topics/${encodeURIComponent(topicName)}/seal`, { method: "POST", headers, body: JSON.stringify({ memberId: target.memberId, encryptedKey: sealed.encryptedKey, nonce: sealed.nonce, }), }, ); } catch { // Another holder likely sealed first — fine to swallow. } } } catch { // Soft-fail; next tick retries. } }; void reseal(); const t = setInterval(reseal, 30_000); return () => { cancelled = true; clearInterval(t); }; }, [topicKey, keyState, headers, topicName]); // Decrypt any v2 messages that we haven't decrypted yet. Runs after // `messages` updates (history backfill, SSE delivery) and after // `topicKey` lands. useEffect(() => { if (!topicKey) return; let cancelled = false; (async () => { const additions = new Map(); for (const m of messages) { if ((m.bodyVersion ?? 1) !== 2) continue; if (decrypted.has(m.id)) continue; const plain = await decryptMessage(topicKey, m.ciphertext, m.nonce); additions.set(m.id, plain ?? "[decrypt failed]"); } if (cancelled || additions.size === 0) return; setDecrypted((prev) => { const next = new Map(prev); for (const [k, v] of additions) next.set(k, v); return next; }); })(); return () => { cancelled = true; }; }, [messages, topicKey, decrypted]); // Render-time text resolution: v2 -> decrypted cache; v1 -> legacy decode. // Falls back to a placeholder if v2 hasn't been decrypted yet (the // useEffect above will fill it in). const resolveText = useCallback( (m: TopicMessage): string => { if ((m.bodyVersion ?? 1) === 2) { return decrypted.get(m.id) ?? "🔒 decrypting…"; } return decodeV1(m.ciphertext); }, [decrypted], ); // Roster — refresh every 20s so online state stays roughly current. // Tighter cadence isn't worth a dedicated SSE channel for v1.6.x. useEffect(() => { let cancelled = false; const load = async () => { try { const res = await fetch("/api/v1/members", { headers, cache: "no-store", }); if (!res.ok) return; const json = (await res.json()) as { members: MeshMember[] }; if (!cancelled) setMembers(json.members); } catch { // Soft-fail — sidebar will just show whatever we last had. } }; void load(); const t = setInterval(load, 20_000); return () => { cancelled = true; clearInterval(t); }; }, [headers]); // SSE subscription with auto-reconnect. AbortController unwinds the // stream when the component unmounts or the topic/key changes. useEffect(() => { const ctl = new AbortController(); let cancelled = false; let backoffMs = 1000; const run = async () => { while (!cancelled) { try { setStreamState((prev) => prev === "live" ? "reconnecting" : "connecting", ); const res = await fetch( `/api/v1/topics/${encodeURIComponent(topicName)}/stream`, { headers: { Authorization: `Bearer ${apiKeySecret}` }, signal: ctl.signal, cache: "no-store", }, ); // 4xx is terminal — auth invalid, key revoked, topic gone. // Reconnecting won't fix any of those, so surface the error // and stop. 5xx and network errors fall through to backoff. if (res.status >= 400 && res.status < 500) { const body = await res.text().catch(() => ""); setError(`stream halted: ${res.status} ${body.slice(0, 200)}`); setStreamState("stopped"); return; } if (!res.ok || !res.body) { throw new Error(`stream open failed: ${res.status}`); } backoffMs = 1000; setStreamState("live"); const reader = res.body.getReader(); for await (const ev of readSseStream(reader)) { setLastEventAt(Date.now()); if (ev.event === "ready") continue; if (ev.event === "heartbeat") continue; if (ev.event === "error") { try { const parsed = JSON.parse(ev.data) as { error?: string }; setError(parsed.error ?? "stream error"); } catch { setError("stream error"); } continue; } if (ev.event === "message") { try { const m = JSON.parse(ev.data) as TopicMessage; if (seenIdsRef.current.has(m.id)) continue; seenIdsRef.current.add(m.id); setMessages((cur) => [...cur, m]); void markRead(); } catch { // Drop malformed events silently — heartbeat-as-message // happens once per misconfigured proxy. } } } // Reader exhausted (server closed) — loop will reconnect. } catch (e) { if (cancelled || ctl.signal.aborted) return; setError(`stream: ${(e as Error).message}`); } if (cancelled) return; setStreamState("reconnecting"); await new Promise((r) => setTimeout(r, backoffMs)); backoffMs = Math.min(backoffMs * 2, 15_000); } }; void run(); return () => { cancelled = true; setStreamState("stopped"); ctl.abort(); }; }, [apiKeySecret, topicName, markRead]); useEffect(() => { // Don't yank scroll while the user is searching — they're reading // matches, not the live tail. if (searchQuery.trim()) return; scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth", }); }, [messages.length, searchQuery]); // Member name lookup for autocomplete. Filtered by case-insensitive // prefix match on displayName; shorter names rank higher so e.g. "@al" // surfaces "Alice" above "Alejandro" if both exist. Capped at 8. const mentionMatches = useMemo(() => { if (!mentionState) return []; const q = mentionState.query.toLowerCase(); return members .filter((m) => m.displayName.toLowerCase().startsWith(q)) .sort((a, b) => { if (a.online !== b.online) return a.online ? -1 : 1; return a.displayName.length - b.displayName.length; }) .slice(0, 8); }, [members, mentionState]); // Re-evaluate the @-mention context whenever the textarea changes — // we look at the substring before the cursor and check whether it // ends in `@` with no whitespace between the @ and the cursor. const updateMentionFromCursor = useCallback( (value: string, cursor: number) => { const before = value.slice(0, cursor); const m = before.match(/(^|\s)@([A-Za-z0-9_-]*)$/); if (!m) { setMentionState(null); return; } const query = m[2] ?? ""; const start = before.length - query.length - 1; // index of '@' setMentionState((prev) => prev && prev.start === start && prev.query === query ? prev : { query, start, selected: 0 }, ); }, [], ); const insertMention = useCallback( (memberName: string) => { if (!mentionState) return; const ta = textareaRef.current; if (!ta) return; const before = draft.slice(0, mentionState.start); const after = draft.slice(ta.selectionStart); const replacement = `@${memberName} `; const next = before + replacement + after; const nextCursor = before.length + replacement.length; setDraft(next); setMentionState(null); // Restore cursor + focus on the next tick — React schedules the // value update, so we can't mutate selection in the same frame. requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(nextCursor, nextCursor); }); }, [draft, mentionState], ); // Extract @-mention tokens from the draft body so the server can // populate mesh.notification rows without having to read the // ciphertext (forward-compat with v0.3.0 per-topic encryption). // Capped at 16 to bound notification fan-out. const extractMentions = (text: string): string[] => { const found = new Set(); const re = /(^|[^A-Za-z0-9_-])@([A-Za-z0-9_-]{1,64})(?=$|[^A-Za-z0-9_-])/g; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { found.add(m[2]!.toLowerCase()); if (found.size >= 16) break; } return [...found]; }; const send = async () => { const text = draft.trim(); if (!text) return; setSending(true); setError(null); try { let ciphertext: string; let nonce: string; let bodyVersion: 1 | 2; if (topicKey && keyState === "ready") { const enc = await encryptMessage(topicKey, text); ciphertext = enc.ciphertext; nonce = enc.nonce; bodyVersion = 2; } else { // Legacy unencrypted topic, or sealed-key not yet available. // Sending v1 plaintext keeps the chat working in either case; // CLI peers on encrypted topics will read it as v1 (alongside // their v2 traffic) without the round-trip breaking. const enc = encodeV1Outgoing(text); ciphertext = enc.ciphertext; nonce = enc.nonce; bodyVersion = 1; } const mentions = extractMentions(text); const res = await fetch("/api/v1/messages", { method: "POST", headers, body: JSON.stringify({ topic: topicName, ciphertext, nonce, bodyVersion, ...(mentions.length > 0 ? { mentions } : {}), }), }); if (!res.ok) { const body = await res.text().catch(() => ""); setError(`send failed: ${res.status} ${body}`); return; } setDraft(""); // SSE stream will deliver the message back; no manual refresh. } catch (e) { setError((e as Error).message); } finally { setSending(false); } }; const secondsSinceEvent = lastEventAt ? Math.max(0, Math.floor((Date.now() - lastEventAt) / 1000)) : null; const dotClass = streamState === "live" ? "bg-emerald-500" : streamState === "stopped" ? "bg-[var(--cm-fg-tertiary)]" : "bg-[var(--cm-clay)] animate-pulse"; const stateLabel = streamState === "live" ? `live · ${secondsSinceEvent ?? 0}s` : streamState === "connecting" ? "connecting…" : streamState === "reconnecting" ? "reconnecting…" : "stopped"; const onlineCount = members.filter((m) => m.online).length; // Client-side search over loaded messages. Decodes once per query so // we can filter on plaintext, then highlights matches in render. // Server-side fulltext lands when we move ciphertext to per-topic // keys (v0.3.0) — until then there's no server index to query. const searchTerm = searchQuery.trim().toLowerCase(); const filteredMessages = useMemo(() => { if (!searchTerm) return messages; return messages.filter((m) => resolveText(m).toLowerCase().includes(searchTerm) || (m.senderName ?? "").toLowerCase().includes(searchTerm), ); }, [messages, searchTerm, resolveText]); return (
{/* Header — mono strip, clay-pulse dot, metadata right */}
#{topicName}
{searchOpen ? ( setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setSearchQuery(""); setSearchOpen(false); } }} placeholder="search…" className="w-44 rounded-[var(--cm-radius-sm)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-2 py-1 text-[11px] text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)] focus:border-[var(--cm-border-hover)] focus:outline-none" /> ) : null} {searchTerm ? `${filteredMessages.length}/${messages.length}` : `${messages.length} msg`} {" · "} {stateLabel}
{/* Body — message stream + member sidebar */}
{/* Message stream */}
{messages.length === 0 ? (

no envelopes on this topic yet

) : filteredMessages.length === 0 ? (

no matches for “{searchTerm}”

) : (
    {filteredMessages.map((m) => (
  1. {m.senderName || m.senderPubkey.slice(0, 8)} {m.senderPubkey.slice(0, 8)} {fmtTime(m.createdAt)}

    {(m.bodyVersion ?? 1) === 2 ? ( 🔒 ) : null} {renderWithMentions(resolveText(m))}

  2. ))}
)}
{/* Member sidebar — roster with online dot */}
{/* Compose */}
{error ? (

error · {error}

) : null} {keyState === "not_sealed" ? (

🔒 waiting for a CLI peer to share the topic key — sending v1 plaintext until then

) : keyState === "ready" ? (

🔒 end-to-end encrypted (v0.3.0)

) : keyState === "topic_unencrypted" ? (
🔓 plaintext (v1) — encryption not yet enabled
) : null}
{ e.preventDefault(); void send(); }} > {/* @-mention dropdown anchored above the textarea */} {mentionState && mentionMatches.length > 0 ? (
    {mentionMatches.map((m, i) => { const selected = i === mentionState.selected; return (
  • ); })}
) : null}