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.
1104 lines
39 KiB
TypeScript
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 “{searchTerm}”
|
|
</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>
|
|
);
|
|
}
|