diff --git a/package.json b/package.json index 8531250..3e07770 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "rsbuild dev --open", "build": "rsbuild build", "preview": "rsbuild preview", + "test": "RSTEST=1 rstest", "typecheck": "tsc -p tsconfig.json --noEmit", "prettier:check": "prettier --list-different .", "prettier:fix": "prettier --write ." @@ -29,6 +30,7 @@ "devDependencies": { "@rsbuild/core": "^1.6.6", "@rsbuild/plugin-react": "^1.4.2", + "@rstest/core": "^0.7.9", "@tailwindcss/postcss": "^4.1.17", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 85939d8..cd3b4a7 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -1,9 +1,12 @@ import { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; +const isTest = process.env.RSTEST === "1"; + export default defineConfig({ plugins: [pluginReact()], server: { + ...(isTest ? { host: "127.0.0.1", port: 0 } : {}), proxy: { "/api": { target: process.env.API_PROXY_TARGET ?? "http://127.0.0.1:8788", diff --git a/rstest.config.ts b/rstest.config.ts new file mode 100644 index 0000000..b4d8848 --- /dev/null +++ b/rstest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "@rstest/core"; +import type { RsbuildPlugin } from "@rsbuild/core"; + +const rstestServerPlugin = (): RsbuildPlugin => ({ + name: "rstest:server-host", + setup(api) { + api.modifyRsbuildConfig((config) => { + config.server = { + ...config.server, + host: "127.0.0.1", + port: 0, + strictPort: false, + middlewareMode: true, + }; + return config; + }); + }, +}); + +export default defineConfig({ + testMatch: ["tests/**/*.test.js"], + environment: "node", + browser: { enabled: false }, + plugins: [rstestServerPlugin()], +}); diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 17d1d7d..8f86ce6 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -1,5 +1,11 @@ import type { ChamberProposalPageDto, + ChamberChatPeerDto, + ChamberChatSignalDto, + ChamberThreadDetailDto, + ChamberThreadDto, + ChamberThreadMessageDto, + ChamberChatMessageDto, CourtCaseDetailDto, FactionDto, FormationProposalPageDto, @@ -114,6 +120,60 @@ export async function apiChamber(id: string): Promise { return await apiGet(`/api/chambers/${id}`); } +export async function apiChamberThreads( + id: string, +): Promise<{ items: ChamberThreadDto[] }> { + return await apiGet<{ items: ChamberThreadDto[] }>( + `/api/chambers/${id}/threads`, + ); +} + +export async function apiChamberThreadDetail( + chamberId: string, + threadId: string, +): Promise { + return await apiGet( + `/api/chambers/${chamberId}/threads/${threadId}`, + ); +} + +export async function apiChamberChatSignalPost( + chamberId: string, + input: { + peerId: string; + kind: "offer" | "answer" | "candidate"; + targetPeerId?: string; + payload: Record; + }, +): Promise<{ ok: true }> { + return await apiPost<{ ok: true }>(`/api/chambers/${chamberId}/chat/signal`, { + peerId: input.peerId, + kind: input.kind, + targetPeerId: input.targetPeerId, + payload: input.payload, + }); +} + +export async function apiChamberChatSignalPoll( + chamberId: string, + peerId: string, +): Promise<{ messages: ChamberChatSignalDto[] }> { + const qs = new URLSearchParams({ peerId }); + return await apiGet<{ messages: ChamberChatSignalDto[] }>( + `/api/chambers/${chamberId}/chat/signal?${qs.toString()}`, + ); +} + +export async function apiChamberChatPresence( + chamberId: string, + peerId: string, +): Promise<{ peers: ChamberChatPeerDto[] }> { + const qs = new URLSearchParams({ peerId }); + return await apiGet<{ peers: ChamberChatPeerDto[] }>( + `/api/chambers/${chamberId}/chat/presence?${qs.toString()}`, + ); +} + export async function apiProposals(input?: { stage?: string; }): Promise { @@ -189,6 +249,87 @@ export async function apiChamberVote(input: { ); } +export async function apiChamberThreadCreate(input: { + chamberId: string; + title: string; + body: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "chamber.thread.create"; + thread: ChamberThreadDto; +}> { + return await apiPost( + "/api/command", + { + type: "chamber.thread.create", + payload: { + chamberId: input.chamberId, + title: input.title, + body: input.body, + }, + idempotencyKey: input.idempotencyKey, + }, + input.idempotencyKey + ? { headers: { "idempotency-key": input.idempotencyKey } } + : undefined, + ); +} + +export async function apiChamberThreadReply(input: { + chamberId: string; + threadId: string; + body: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "chamber.thread.reply"; + threadId: string; + message: ChamberThreadMessageDto; + replies: number; +}> { + return await apiPost( + "/api/command", + { + type: "chamber.thread.reply", + payload: { + chamberId: input.chamberId, + threadId: input.threadId, + body: input.body, + }, + idempotencyKey: input.idempotencyKey, + }, + input.idempotencyKey + ? { headers: { "idempotency-key": input.idempotencyKey } } + : undefined, + ); +} + +export async function apiChamberChatPost(input: { + chamberId: string; + message: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "chamber.chat.post"; + message: ChamberChatMessageDto; +}> { + return await apiPost( + "/api/command", + { + type: "chamber.chat.post", + payload: { + chamberId: input.chamberId, + message: input.message, + }, + idempotencyKey: input.idempotencyKey, + }, + input.idempotencyKey + ? { headers: { "idempotency-key": input.idempotencyKey } } + : undefined, + ); +} + export async function apiFormationJoin(input: { proposalId: string; role?: string; diff --git a/src/pages/chambers/Chamber.tsx b/src/pages/chambers/Chamber.tsx index abeb916..11f9b60 100644 --- a/src/pages/chambers/Chamber.tsx +++ b/src/pages/chambers/Chamber.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams, Link } from "react-router"; import { Button } from "@/components/primitives/button"; @@ -15,39 +15,72 @@ import { Surface } from "@/components/Surface"; import { PageHint } from "@/components/PageHint"; import { PageHeader } from "@/components/PageHeader"; import { TierLabel } from "@/components/TierLabel"; -import type { ChamberProposalStageDto } from "@/types/api"; -import { apiChamber, apiChambers } from "@/lib/apiClient"; +import { PipelineList } from "@/components/PipelineList"; +import { StatGrid, makeChamberStats } from "@/components/StatGrid"; +import type { + ChamberChatPeerDto, + ChamberChatSignalDto, + ChamberChatMessageDto, + ChamberProposalStageDto, + ChamberThreadDetailDto, + ChamberThreadDto, + ChamberThreadMessageDto, + GetChamberResponse, +} from "@/types/api"; +import { + apiChamber, + apiChamberChatPresence, + apiChamberChatSignalPoll, + apiChamberChatSignalPost, + apiChambers, + apiChamberChatPost, + apiChamberThreadCreate, + apiChamberThreadDetail, + apiChamberThreadReply, + apiMyGovernance, +} from "@/lib/apiClient"; import { NoDataYetBar } from "@/components/NoDataYetBar"; +import { useAuth } from "@/app/auth/AuthContext"; const Chamber: React.FC = () => { const { id } = useParams(); + const { address } = useAuth(); const [chamberTitle, setChamberTitle] = useState(() => id ? id.replace(/-/g, " ") : "Chamber", ); - const [data, setData] = useState<{ - proposals: { - id: string; - title: string; - meta: string; - summary: string; - lead: string; - nextStep: string; - timing: string; - stage: ChamberProposalStageDto; - }[]; - governors: { id: string; name: string; tier: string; focus: string }[]; - threads: { - id: string; - title: string; - author: string; - replies: number; - updated: string; - }[]; - chatLog: { id: string; author: string; message: string }[]; - stageOptions: { value: ChamberProposalStageDto; label: string }[]; - } | null>(null); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); + const [threads, setThreads] = useState([]); + const [chatLog, setChatLog] = useState([]); + const [threadTitle, setThreadTitle] = useState(""); + const [threadBody, setThreadBody] = useState(""); + const [threadError, setThreadError] = useState(null); + const [threadBusy, setThreadBusy] = useState(false); + const [activeThread, setActiveThread] = + useState(null); + const [threadMessages, setThreadMessages] = useState< + ChamberThreadMessageDto[] + >([]); + const [threadDetailError, setThreadDetailError] = useState( + null, + ); + const [threadReplyBody, setThreadReplyBody] = useState(""); + const [threadReplyBusy, setThreadReplyBusy] = useState(false); + const [threadReplyError, setThreadReplyError] = useState(null); + const [chatMessage, setChatMessage] = useState(""); + const [chatError, setChatError] = useState(null); + const [chatBusy, setChatBusy] = useState(false); + const [myChamberIds, setMyChamberIds] = useState([]); + const [chatPeers, setChatPeers] = useState([]); + const [chatSignalError, setChatSignalError] = useState(null); + + const [peerId] = useState( + () => `peer_${Math.random().toString(16).slice(2, 10)}`, + ); + const peerConnectionsRef = useRef>(new Map()); + const dataChannelsRef = useRef>(new Map()); + const chatMessageIdsRef = useRef>(new Set()); const [stageFilter, setStageFilter] = useState("upcoming"); @@ -64,8 +97,16 @@ const Chamber: React.FC = () => { ]); if (!active) return; setData(chamberRes); + const nextThreads = chamberRes.threads ?? []; + const nextChat = chamberRes.chatLog ?? []; + setThreads(nextThreads); + setChatLog(nextChat); + chatMessageIdsRef.current = new Set(nextChat.map((entry) => entry.id)); + setActiveThread(null); + setThreadMessages([]); const found = listRes.items.find((c) => c.id === id); - setChamberTitle(found?.name ?? id.replace(/-/g, " ")); + const fallbackTitle = chamberRes.chamber?.title; + setChamberTitle(found?.name ?? fallbackTitle ?? id.replace(/-/g, " ")); setLoadError(null); } catch (error) { if (!active) return; @@ -78,6 +119,27 @@ const Chamber: React.FC = () => { }; }, [id]); + useEffect(() => { + if (!address) { + setMyChamberIds([]); + return; + } + let active = true; + (async () => { + try { + const res = await apiMyGovernance(); + if (!active) return; + setMyChamberIds(res.myChamberIds ?? []); + } catch { + if (!active) return; + setMyChamberIds([]); + } + })(); + return () => { + active = false; + }; + }, [address]); + const filteredProposals = useMemo( () => (data?.proposals ?? []).filter( @@ -92,10 +154,412 @@ const Chamber: React.FC = () => { (gov) => gov.name.toLowerCase().includes(term) || gov.tier.toLowerCase().includes(term) || - gov.focus.toLowerCase().includes(term), + gov.focus.toLowerCase().includes(term) || + String(gov.acm).includes(term) || + String(gov.lcm).includes(term) || + String(gov.mcm).includes(term) || + String(gov.delegatedWeight).includes(term), ); }, [data, governorSearch]); + const chamberStats = useMemo(() => { + const governors = data?.governors ?? []; + const totals = governors.reduce( + (acc, gov) => ({ + acm: acc.acm + (Number.isFinite(gov.acm) ? gov.acm : 0), + lcm: acc.lcm + (Number.isFinite(gov.lcm) ? gov.lcm : 0), + mcm: acc.mcm + (Number.isFinite(gov.mcm) ? gov.mcm : 0), + }), + { acm: 0, lcm: 0, mcm: 0 }, + ); + return { + governors: String(governors.length), + acm: totals.acm.toLocaleString(), + lcm: totals.lcm.toLocaleString(), + mcm: totals.mcm.toLocaleString(), + }; + }, [data]); + + const chamberMetaItems = useMemo(() => { + const chamber = data?.chamber; + if (!chamber) return []; + const items = [ + { label: "Status", value: chamber.status }, + { label: "Multiplier", value: chamber.multiplier.toFixed(1) }, + { label: "Created", value: chamber.createdAt.slice(0, 10) }, + { label: "Origin", value: chamber.createdByProposalId ?? "Genesis" }, + ]; + if (chamber.dissolvedAt) { + items.push({ + label: "Dissolved", + value: chamber.dissolvedAt.slice(0, 10), + }); + } + if (chamber.dissolvedByProposalId) { + items.push({ + label: "Dissolved by", + value: chamber.dissolvedByProposalId, + }); + } + return items; + }, [data]); + + const isMember = useMemo(() => { + if (!address || !data) return false; + return data.governors.some((gov) => gov.id === address); + }, [address, data]); + + const canWrite = useMemo(() => { + if (!address || !id) return false; + if (id === "general") { + return isMember || myChamberIds.length > 0; + } + return isMember; + }, [address, id, isMember, myChamberIds.length]); + + const appendChatMessage = useCallback((message: ChamberChatMessageDto) => { + if (chatMessageIdsRef.current.has(message.id)) return; + chatMessageIdsRef.current.add(message.id); + setChatLog((prev) => [...prev, message]); + }, []); + + const registerDataChannel = useCallback( + (remotePeerId: string, channel: RTCDataChannel) => { + dataChannelsRef.current.set(remotePeerId, channel); + channel.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data as string) as { + type: string; + payload: ChamberChatMessageDto; + }; + if (parsed?.type === "chat" && parsed.payload) { + appendChatMessage(parsed.payload); + } + } catch { + // ignore malformed messages + } + }; + channel.onclose = () => { + dataChannelsRef.current.delete(remotePeerId); + }; + }, + [appendChatMessage], + ); + + const sendSignal = useCallback( + async (input: { + kind: "offer" | "answer" | "candidate"; + targetPeerId: string; + payload: Record; + }) => { + if (!id) return; + await apiChamberChatSignalPost(id, { + peerId, + kind: input.kind, + targetPeerId: input.targetPeerId, + payload: input.payload, + }); + }, + [id, peerId], + ); + + const ensurePeerConnection = useCallback( + (remotePeerId: string, isCaller: boolean): RTCPeerConnection => { + const existing = peerConnectionsRef.current.get(remotePeerId); + if (existing) return existing; + + const pc = new RTCPeerConnection({ iceServers: [] }); + pc.onicecandidate = (event) => { + if (!event.candidate) return; + void sendSignal({ + kind: "candidate", + targetPeerId: remotePeerId, + payload: event.candidate.toJSON() as Record, + }); + }; + pc.onconnectionstatechange = () => { + if ( + pc.connectionState === "failed" || + pc.connectionState === "closed" || + pc.connectionState === "disconnected" + ) { + pc.close(); + peerConnectionsRef.current.delete(remotePeerId); + dataChannelsRef.current.delete(remotePeerId); + } + }; + + if (isCaller) { + const channel = pc.createDataChannel("chat"); + registerDataChannel(remotePeerId, channel); + } else { + pc.ondatachannel = (event) => { + registerDataChannel(remotePeerId, event.channel); + }; + } + + peerConnectionsRef.current.set(remotePeerId, pc); + return pc; + }, + [registerDataChannel, sendSignal], + ); + + const handleSignal = useCallback( + async (signal: ChamberChatSignalDto) => { + if (!signal?.fromPeerId) return; + const remotePeerId = signal.fromPeerId; + const kind = signal.kind; + const payload = signal.payload as + | RTCSessionDescriptionInit + | RTCIceCandidateInit; + + if (kind === "offer") { + const pc = ensurePeerConnection(remotePeerId, false); + await pc.setRemoteDescription(payload as RTCSessionDescriptionInit); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + if (pc.localDescription) { + await sendSignal({ + kind: "answer", + targetPeerId: remotePeerId, + payload: pc.localDescription.toJSON() as unknown as Record< + string, + unknown + >, + }); + } + return; + } + + if (kind === "answer") { + const pc = ensurePeerConnection(remotePeerId, true); + await pc.setRemoteDescription(payload as RTCSessionDescriptionInit); + return; + } + + if (kind === "candidate") { + const pc = ensurePeerConnection(remotePeerId, false); + try { + await pc.addIceCandidate(payload as RTCIceCandidateInit); + } catch { + // ignore invalid candidates + } + } + }, + [ensurePeerConnection, sendSignal], + ); + + const broadcastChatMessage = useCallback((message: ChamberChatMessageDto) => { + const payload = JSON.stringify({ type: "chat", payload: message }); + for (const channel of dataChannelsRef.current.values()) { + if (channel.readyState === "open") { + channel.send(payload); + } + } + }, []); + + useEffect(() => { + if (!id || !address || !canWrite) return undefined; + let active = true; + const poll = async () => { + try { + const res = await apiChamberChatSignalPoll(id, peerId); + if (!active) return; + for (const signal of res.messages ?? []) { + await handleSignal(signal); + } + setChatSignalError(null); + } catch (error) { + if (!active) return; + setChatSignalError((error as Error).message); + } + if (active) { + window.setTimeout(poll, 2000); + } + }; + poll(); + return () => { + active = false; + }; + }, [address, canWrite, handleSignal, id, peerId]); + + useEffect(() => { + if (!id || !address || !canWrite) return undefined; + let active = true; + const tick = async () => { + try { + const res = await apiChamberChatPresence(id, peerId); + if (!active) return; + const peers = (res.peers ?? []).filter( + (peer) => peer.peerId !== peerId, + ); + setChatPeers(peers); + + for (const peer of peers) { + if (peerConnectionsRef.current.has(peer.peerId)) continue; + if (peerId.localeCompare(peer.peerId) < 0) { + const pc = ensurePeerConnection(peer.peerId, true); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + if (pc.localDescription) { + await sendSignal({ + kind: "offer", + targetPeerId: peer.peerId, + payload: pc.localDescription.toJSON() as unknown as Record< + string, + unknown + >, + }); + } + } + } + setChatSignalError(null); + } catch (error) { + if (!active) return; + setChatSignalError((error as Error).message); + } + if (active) { + window.setTimeout(tick, 6000); + } + }; + tick(); + return () => { + active = false; + }; + }, [address, canWrite, ensurePeerConnection, id, peerId, sendSignal]); + + useEffect(() => { + return () => { + for (const pc of peerConnectionsRef.current.values()) { + pc.close(); + } + peerConnectionsRef.current.clear(); + dataChannelsRef.current.clear(); + }; + }, []); + + const handleThreadCreate = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (!id) return; + if (!canWrite) { + setThreadError( + "You are not eligible to start a thread in this chamber.", + ); + return; + } + if (!threadTitle.trim() || !threadBody.trim()) { + setThreadError("Thread title and body are required."); + return; + } + setThreadBusy(true); + setThreadError(null); + try { + const res = await apiChamberThreadCreate({ + chamberId: id, + title: threadTitle.trim(), + body: threadBody.trim(), + }); + setThreads((prev) => [res.thread, ...prev]); + setThreadTitle(""); + setThreadBody(""); + } catch (error) { + setThreadError((error as Error).message); + } finally { + setThreadBusy(false); + } + }, + [canWrite, id, threadBody, threadTitle], + ); + + const handleThreadSelect = useCallback( + async (threadId: string) => { + if (!id) return; + setThreadDetailError(null); + try { + const detail = await apiChamberThreadDetail(id, threadId); + setActiveThread(detail); + setThreadMessages(detail.messages ?? []); + } catch (error) { + setThreadDetailError((error as Error).message); + } + }, + [id], + ); + + const handleThreadReply = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (!id || !activeThread) return; + if (!canWrite) { + setThreadReplyError("You are not eligible to reply in this chamber."); + return; + } + if (!threadReplyBody.trim()) { + setThreadReplyError("Reply body is required."); + return; + } + setThreadReplyBusy(true); + setThreadReplyError(null); + try { + const res = await apiChamberThreadReply({ + chamberId: id, + threadId: activeThread.thread.id, + body: threadReplyBody.trim(), + }); + setThreadMessages((prev) => [...prev, res.message]); + setThreads((prev) => + prev.map((thread) => + thread.id === activeThread.thread.id + ? { + ...thread, + replies: res.replies, + updated: new Date().toISOString().slice(0, 10), + } + : thread, + ), + ); + setThreadReplyBody(""); + } catch (error) { + setThreadReplyError((error as Error).message); + } finally { + setThreadReplyBusy(false); + } + }, + [activeThread, canWrite, id, threadReplyBody], + ); + + const handleChatSend = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (!id) return; + if (!canWrite) { + setChatError("You are not eligible to chat in this chamber."); + return; + } + if (!chatMessage.trim()) { + setChatError("Chat message is required."); + return; + } + setChatBusy(true); + setChatError(null); + try { + const res = await apiChamberChatPost({ + chamberId: id, + message: chatMessage.trim(), + }); + appendChatMessage(res.message); + broadcastChatMessage(res.message); + setChatMessage(""); + } catch (error) { + setChatError((error as Error).message); + } finally { + setChatBusy(false); + } + }, + [appendChatMessage, broadcastChatMessage, canWrite, chatMessage, id], + ); + return (
@@ -116,6 +580,64 @@ const Chamber: React.FC = () => { ) : null} + {data ? ( +
+ + + Chamber profile + Overview + + + {chamberMetaItems.map((item) => ( + + {item.label} +

+ {item.value} +

+
+ ))} +
+
+ + + + Chamber stats + Governance metrics + + + + + + + + + Pipeline + Proposal flow + + + {data.pipeline ? ( + + ) : ( + + No pipeline data yet. + + )} + + +
+ ) : null} +
@@ -179,30 +701,42 @@ const Chamber: React.FC = () => {

{proposal.summary}

-
- - Next step -

- {proposal.nextStep} -

-
- - Timing -

- {proposal.timing} -

-
-
+ {(() => { + const metaTiles = [ + { label: "Next step", value: proposal.nextStep }, + { label: "Timing", value: proposal.timing }, + ]; + if (typeof proposal.activeGovernors === "number") { + metaTiles.push({ + label: "Active governors", + value: proposal.activeGovernors.toLocaleString(), + }); + } + const columns = + metaTiles.length === 3 + ? "sm:grid-cols-3" + : "sm:grid-cols-2"; + return ( +
+ {metaTiles.map((tile) => ( + + {tile.label} +

+ {tile.value} +

+
+ ))} +
+ ); + })()} )) )} @@ -240,6 +774,14 @@ const Chamber: React.FC = () => {

· {gov.focus}

+

+ ACM {gov.acm.toLocaleString()} · LCM{" "} + {gov.lcm.toLocaleString()} · MCM{" "} + {gov.mcm.toLocaleString()} + {gov.delegatedWeight > 0 + ? ` · Delegated +${gov.delegatedWeight}` + : ""} +