feat(web): topic chat UI over /api/v1/* (v0.2.0)
New dashboard route at /dashboard/meshes/[id]/topics/[name] gives signed-in users a thin chat client over the v0.2.0 REST surface. The mesh detail page now lists topics with one-click links into the chat. Backend layout: - packages/api/src/modules/mesh/api-key-auth.ts — exports createDashboardApiKey() that mints a 24h read+send key scoped to a single topic for the caller's member id. The page server component calls this on every render and embeds the secret in the props of the client component; the secret never touches sessionStorage so a tab close = key effectively abandoned (the row remains until expiresAt). - apps/web/.../topics/[name]/page.tsx — server component, NextAuth gate, resolves the user's meshMember.id, mints the key, renders the shell. - apps/web/src/modules/mesh/topic-chat-panel.tsx — client component, polls GET /v1/topics/:name/messages every 5s, sends via POST /v1/messages. Encoding wraps base64(plaintext) into the ciphertext field — matches the current broker contract until per-topic HKDF lands in v0.3.0. The mesh detail page gains a Topics section with empty-state copy that points users at the CLI verb (claudemesh topic create) for now; topic creation from the web UI is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
229
apps/web/src/modules/mesh/topic-chat-panel.tsx
Normal file
229
apps/web/src/modules/mesh/topic-chat-panel.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
interface TopicMessage {
|
||||
id: string;
|
||||
senderPubkey: string;
|
||||
senderName: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
topicName: string;
|
||||
topicId: string;
|
||||
meshSlug: string;
|
||||
apiKeySecret: string;
|
||||
apiKeyExpiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode plaintext into the broker's wire format. v0.2.0 uses base64
|
||||
* plaintext in the `ciphertext` field — real per-topic symmetric keys
|
||||
* land in v0.3.0. Same applies to the random nonce: it satisfies the
|
||||
* schema but isn't cryptographically meaningful yet.
|
||||
*/
|
||||
function encodeOutgoing(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 };
|
||||
}
|
||||
|
||||
function decodeIncoming(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]";
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function TopicChatPanel({
|
||||
topicName,
|
||||
meshSlug,
|
||||
apiKeySecret,
|
||||
apiKeyExpiresAt,
|
||||
}: Props) {
|
||||
const [messages, setMessages] = useState<TopicMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const headers = useMemo(
|
||||
() => ({
|
||||
Authorization: `Bearer ${apiKeySecret}`,
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
[apiKeySecret],
|
||||
);
|
||||
|
||||
const refresh = 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[] };
|
||||
setMessages(json.messages.slice().reverse());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [headers, topicName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const t = setInterval(refresh, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [messages.length]);
|
||||
|
||||
const send = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { ciphertext, nonce } = encodeOutgoing(text);
|
||||
const res = await fetch("/api/v1/messages", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ topic: topicName, ciphertext, nonce }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
setError(`send failed: ${res.status} ${body}`);
|
||||
return;
|
||||
}
|
||||
setDraft("");
|
||||
void refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex h-[70vh] flex-col">
|
||||
<CardHeader className="flex-row items-center justify-between border-b py-3">
|
||||
<CardTitle className="text-base font-medium">
|
||||
<span className="text-muted-foreground">#</span>
|
||||
{topicName}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{meshSlug}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
key expires {fmtTime(apiKeyExpiresAt)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
No messages yet. Be the first.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="flex flex-col gap-3">
|
||||
{messages.map((m) => (
|
||||
<li key={m.id} className="flex flex-col gap-0.5">
|
||||
<div className="text-muted-foreground flex items-baseline gap-2 text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
{m.senderName || m.senderPubkey.slice(0, 8)}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{m.senderPubkey.slice(0, 6)}…
|
||||
</span>
|
||||
<span>{fmtTime(m.createdAt)}</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{decodeIncoming(m.ciphertext)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t p-3">
|
||||
{error ? (
|
||||
<p className="mb-2 text-xs text-destructive">{error}</p>
|
||||
) : null}
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={`Message #${topicName}…`}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" disabled={sending || !draft.trim()}>
|
||||
{sending ? "…" : "Send"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user