Files
claudemesh/apps/web/src/modules/mesh/topic-chat-panel.tsx
Alejandro Gutiérrez 7f6af0137d
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
feat(api+web): browser claims + re-seals encryption on v1 topics
Closes the last gap from phase 3.5: web-created topics start as v1
plaintext (mutations.ts ensureGeneralTopic doesn't generate a key,
because the dashboard owner has a throwaway pubkey with no secret).
Once the browser identity is registered via /v1/me/peer-pubkey, the
chat panel can lazily upgrade the topic to v2.

API (POST /v1/topics/:name/claim-key)
- Atomic claim: only succeeds when topic.encrypted_key_pubkey IS
  NULL. Body carries the new senderPubkey + the caller's sealed copy
  of the freshly-generated topic key. Race losers get 409 with the
  winning senderPubkey so they fall through to the regular fetch
  path. Idempotent at topic_member_key level.

Web
- claimTopicKey() in services/crypto/topic-key.ts: generates a fresh
  32-byte symmetric key, seals for self, POSTs the claim. Returns
  the in-memory key so the caller can encrypt immediately without a
  follow-up GET /key round-trip.
- sealTopicKeyFor(): mirrors the CLI helper so a browser holder can
  re-seal for newcomers (CLI peers, other browsers) instead of the
  topic going dark when only a browser has the key.
- TopicChatPanel: when keyState === "topic_unencrypted", composer
  now shows a "🔓 plaintext (v1) — encryption not yet enabled" line
  with an "enable encryption" button. Click → claimTopicKey → state
  flips to "ready" → 🔒 v0.3.0 banner appears. On race-lost, falls
  through to fetch.
- New 30s re-seal loop fires while holding the key: polls
  /pending-seals, seals via sealTopicKeyFor for each pending target,
  POSTs to /seal. Same cadence + soft-fail discipline as the CLI.

Net effect: any dashboard user can convert legacy v1 topics to v2
with a single click, and CLI peers joining later will receive a
sealed copy from the browser's re-seal loop without manual action.
2026-05-02 23:22:26 +01:00

1104 lines
39 KiB
TypeScript

"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(
<span key={key++}>{text.slice(lastIndex, matchStart)}</span>,
);
}
parts.push(
<span key={key++} className="text-[var(--cm-clay)] font-medium">
{mention}
</span>,
);
lastIndex = matchStart + (mention?.length ?? 0);
}
if (lastIndex < text.length) {
parts.push(<span key={key++}>{text.slice(lastIndex)}</span>);
}
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<Uint8Array>,
): AsyncGenerator<SseEvent> {
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<TopicMessage[]>([]);
const [members, setMembers] = useState<MeshMember[]>([]);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [streamState, setStreamState] = useState<
"connecting" | "live" | "reconnecting" | "stopped"
>("connecting");
const [lastEventAt, setLastEventAt] = useState<number | null>(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<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const seenIdsRef = useRef<Set<string>>(new Set());
const lastMarkReadAtRef = useRef<number>(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<Uint8Array | null>(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<Map<string, string>>(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<typeof setTimeout> | 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<string, string>();
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 `@<word>` 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<string>();
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 (
<div className="flex h-[70vh] flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
{/* Header — mono strip, clay-pulse dot, metadata right */}
<div
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
style={monoStyle}
>
<div className="flex items-center gap-3">
<span className={"inline-block h-2 w-2 rounded-full " + dotClass} />
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
#{topicName}
</span>
</div>
<div className="flex items-center gap-3">
{searchOpen ? (
<input
autoFocus
type="search"
value={searchQuery}
onChange={(e) => 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}
<button
type="button"
onClick={() => {
setSearchOpen((o) => {
const next = !o;
if (!next) setSearchQuery("");
return next;
});
}}
className="text-[10px] uppercase tracking-[0.14em] text-[var(--cm-fg-tertiary)] transition-colors hover:text-[var(--cm-fg-secondary)]"
title="Toggle search (Esc to close)"
>
{searchOpen ? "close" : "search"}
</button>
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
{searchTerm
? `${filteredMessages.length}/${messages.length}`
: `${messages.length} msg`}
{" · "}
{stateLabel}
</span>
</div>
</div>
{/* Body — message stream + member sidebar */}
<div className="flex flex-1 overflow-hidden">
{/* Message stream */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
{messages.length === 0 ? (
<p
className="py-12 text-center text-[11px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
no envelopes on this topic yet
</p>
) : filteredMessages.length === 0 ? (
<p
className="py-12 text-center text-[11px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
no matches for &ldquo;{searchTerm}&rdquo;
</p>
) : (
<ol className="flex flex-col gap-4">
{filteredMessages.map((m) => (
<li key={m.id} className="flex flex-col gap-1">
<div
className="flex items-baseline gap-2 text-[10px]"
style={monoStyle}
>
<span className="text-[var(--cm-fg)] font-medium">
{m.senderName || m.senderPubkey.slice(0, 8)}
</span>
<span className="text-[var(--cm-fg-tertiary)]">
{m.senderPubkey.slice(0, 8)}
</span>
<span className="text-[var(--cm-fg-tertiary)]">
{fmtTime(m.createdAt)}
</span>
</div>
<p className="text-[var(--cm-fg)] text-sm leading-relaxed whitespace-pre-wrap break-words">
{(m.bodyVersion ?? 1) === 2 ? (
<span
className="mr-1 text-[var(--cm-fg-tertiary)]"
title="end-to-end encrypted (v0.3.0 per-topic)"
>
🔒
</span>
) : null}
{renderWithMentions(resolveText(m))}
</p>
</li>
))}
</ol>
)}
</div>
{/* Member sidebar — roster with online dot */}
<aside className="hidden w-[180px] shrink-0 flex-col border-l border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 lg:flex">
<div
className="border-b border-[var(--cm-border)] px-3 py-2 text-[10px] uppercase tracking-[0.14em] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
{onlineCount}/{members.length} online
</div>
<ol className="flex-1 overflow-y-auto py-2">
{members.length === 0 ? (
<li
className="px-3 py-4 text-center text-[10px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
loading
</li>
) : (
<>
{members.filter((m) => m.online).map((m) => (
<li
key={m.memberId}
className="group flex items-center gap-2 px-3 py-1.5"
title={m.summary ?? `${m.role} · ${m.pubkey.slice(0, 12)}`}
>
<span
className={
"inline-block h-1.5 w-1.5 shrink-0 rounded-full " +
(m.status === "dnd"
? "bg-[#c46686]"
: m.status === "working"
? "bg-[var(--cm-clay)]"
: "bg-emerald-500")
}
/>
<span
className="truncate text-[11px] text-[var(--cm-fg)]"
style={monoStyle}
>
{m.displayName}
</span>
{!m.isHuman ? (
<span
className="text-[8px] uppercase tracking-[0.1em] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
bot
</span>
) : null}
</li>
))}
{onlineCount > 0 && onlineCount < members.length ? (
<li
className="mt-3 border-t border-[var(--cm-border)] px-3 pb-1 pt-3 text-[9px] uppercase tracking-[0.14em] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
offline · {members.length - onlineCount}
</li>
) : null}
{members.filter((m) => !m.online).map((m) => (
<li
key={m.memberId}
className="flex items-center gap-2 px-3 py-1.5 opacity-50"
title={`${m.role} · ${m.pubkey.slice(0, 12)}`}
>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-fg-tertiary)]" />
<span
className="truncate text-[11px] text-[var(--cm-fg-secondary)]"
style={monoStyle}
>
{m.displayName}
</span>
{!m.isHuman ? (
<span
className="text-[8px] uppercase tracking-[0.1em] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
bot
</span>
) : null}
</li>
))}
</>
)}
</ol>
</aside>
</div>
{/* Compose */}
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 p-3">
{error ? (
<p
className="mb-2 text-[10px] text-[#c46686]"
style={monoStyle}
>
error · {error}
</p>
) : null}
{keyState === "not_sealed" ? (
<p
className="mb-2 text-[10px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
title="The CLI's 30s re-seal loop will share the topic key with this browser shortly. Messages you send now go as v1 plaintext."
>
🔒 waiting for a CLI peer to share the topic key sending v1 plaintext until then
</p>
) : keyState === "ready" ? (
<p
className="mb-2 text-[10px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
title="Messages you send are encrypted with the topic's symmetric key (crypto_secretbox)."
>
🔒 end-to-end encrypted (v0.3.0)
</p>
) : keyState === "topic_unencrypted" ? (
<div
className="mb-2 flex items-center justify-between gap-3 text-[10px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
<span title="This topic was created before per-topic encryption shipped. Click to generate a key and seal it for everyone going forward.">
🔓 plaintext (v1) encryption not yet enabled
</span>
<button
type="button"
className="rounded border border-[var(--cm-border)] px-2 py-0.5 text-[10px] text-[var(--cm-fg-secondary)] hover:bg-[var(--cm-bg-hover)] disabled:cursor-not-allowed disabled:opacity-60"
disabled={sending}
onClick={async () => {
setError(null);
const result = await claimTopicKey({ apiKeySecret, topicName });
if (result.ok) {
setTopicKey(result.topicKey);
setKeyState("ready");
return;
}
if (result.error.includes("already_encrypted")) {
// Race lost — refetch via the regular path.
const refetch = await getTopicKey({ apiKeySecret, topicName, fresh: true });
if (refetch.ok && refetch.topicKey) {
setTopicKey(refetch.topicKey);
setKeyState("ready");
} else {
setKeyState(refetch.error === "not_sealed" ? "not_sealed" : "error");
}
} else {
setError(`claim failed: ${result.error}`);
}
}}
>
enable encryption
</button>
</div>
) : null}
<form
className="relative flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void send();
}}
>
{/* @-mention dropdown anchored above the textarea */}
{mentionState && mentionMatches.length > 0 ? (
<ul
className="absolute bottom-full left-0 right-0 z-10 mb-2 max-h-56 overflow-y-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] shadow-lg"
style={monoStyle}
>
{mentionMatches.map((m, i) => {
const selected = i === mentionState.selected;
return (
<li key={m.memberId}>
<button
type="button"
onMouseDown={(e) => {
// mouseDown (not click) prevents the textarea
// from losing focus before the insert runs.
e.preventDefault();
insertMention(m.displayName);
}}
onMouseEnter={() =>
setMentionState((prev) =>
prev ? { ...prev, selected: i } : prev,
)
}
className={
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] " +
(selected
? "bg-[var(--cm-bg-hover)]"
: "hover:bg-[var(--cm-bg-hover)]")
}
>
<span
className={
"inline-block h-1.5 w-1.5 shrink-0 rounded-full " +
(m.online
? m.status === "dnd"
? "bg-[#c46686]"
: m.status === "working"
? "bg-[var(--cm-clay)]"
: "bg-emerald-500"
: "bg-[var(--cm-fg-tertiary)]")
}
/>
<span className="text-[var(--cm-fg)]">
{m.displayName}
</span>
{!m.isHuman ? (
<span className="text-[8px] uppercase tracking-[0.1em] text-[var(--cm-fg-tertiary)]">
bot
</span>
) : null}
<span className="ml-auto text-[10px] text-[var(--cm-fg-tertiary)]">
{m.online ? "online" : "offline"}
</span>
</button>
</li>
);
})}
</ul>
) : null}
<textarea
ref={textareaRef}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
updateMentionFromCursor(
e.target.value,
e.target.selectionStart ?? e.target.value.length,
);
}}
onKeyUp={(e) => {
const t = e.currentTarget;
updateMentionFromCursor(t.value, t.selectionStart ?? t.value.length);
}}
onClick={(e) => {
const t = e.currentTarget;
updateMentionFromCursor(t.value, t.selectionStart ?? t.value.length);
}}
onBlur={() => {
// Defer so onMouseDown on the dropdown can resolve first.
setTimeout(() => setMentionState(null), 100);
}}
placeholder={`message #${topicName}`}
rows={1}
className="flex-1 resize-none rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)] focus:border-[var(--cm-border-hover)] focus:outline-none"
onKeyDown={(e) => {
// Mention navigation takes priority when the dropdown is up.
if (mentionState && mentionMatches.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionState((prev) =>
prev
? {
...prev,
selected: (prev.selected + 1) % mentionMatches.length,
}
: prev,
);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionState((prev) =>
prev
? {
...prev,
selected:
(prev.selected - 1 + mentionMatches.length) %
mentionMatches.length,
}
: prev,
);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const target = mentionMatches[mentionState.selected];
if (target) insertMention(target.displayName);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMentionState(null);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
/>
<Button type="submit" disabled={sending || !draft.trim()}>
{sending ? "…" : "send"}
</Button>
</form>
</div>
{/* Status footer — 9px mono, matches peer-graph + state-timeline footers */}
<div
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-clay)]" />
mesh · {meshSlug}
</span>
<span>SSE · 2s push</span>
<span>key valid until {fmtTime(apiKeyExpiresAt)}</span>
<span className="ml-auto">
v0.2.0 · plaintext base64 · per-topic crypto in v0.3.0
</span>
</div>
</div>
);
}