From 6327023866d6126637bac14019d3038a2c119236 Mon Sep 17 00:00:00 2001 From: Emin Mahrt Date: Fri, 24 Jul 2026 22:33:26 +0200 Subject: [PATCH 1/3] feat(web): project chat channels and messages --- web/src/features/chat/lib/chat.ts | 111 ++++++++++++++++++++++++++ web/tests/unit/chat.test.ts | 125 ++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 web/src/features/chat/lib/chat.ts create mode 100644 web/tests/unit/chat.test.ts diff --git a/web/src/features/chat/lib/chat.ts b/web/src/features/chat/lib/chat.ts new file mode 100644 index 0000000000..d7798a9a76 --- /dev/null +++ b/web/src/features/chat/lib/chat.ts @@ -0,0 +1,111 @@ +import type { NostrEvent } from "@/shared/lib/nostr-client"; + +export type ChatChannelType = "stream" | "forum" | "dm" | "private" | "unknown"; +export type ChatChannelVisibility = "open" | "private"; + +export type ChatChannel = { + id: string; + name: string; + type: ChatChannelType; + visibility: ChatChannelVisibility; + isMember: boolean; +}; + +const CHAT_MESSAGE_KINDS = new Set([9, 40002]); + +function tagValue(event: NostrEvent, name: string): string | undefined { + return event.tags.find((tag) => tag[0] === name)?.[1]; +} + +function hasTag(event: NostrEvent, name: string): boolean { + return event.tags.some((tag) => tag[0] === name); +} + +function channelType(event: NostrEvent): ChatChannelType { + const declared = tagValue(event, "t"); + if (declared === "forum" || declared === "dm" || declared === "private") { + return declared; + } + if (hasTag(event, "hidden")) return "dm"; + if (hasTag(event, "private")) return "private"; + return declared ? "unknown" : "stream"; +} + +function channelVisibility(event: NostrEvent): ChatChannelVisibility { + return hasTag(event, "private") || channelType(event) === "private" + ? "private" + : "open"; +} + +export function projectChatChannels( + membershipEvents: NostrEvent[], + metadataEvents: NostrEvent[], +): ChatChannel[] { + const memberships = new Set( + membershipEvents + .filter((event) => event.kind === 39002) + .map((event) => tagValue(event, "d")) + .filter((id): id is string => Boolean(id)), + ); + const latestMetadata = new Map(); + + for (const event of metadataEvents) { + if (event.kind !== 39000) continue; + const id = tagValue(event, "d"); + if (!id) continue; + const previous = latestMetadata.get(id); + if (!previous || event.created_at >= previous.created_at) { + latestMetadata.set(id, event); + } + } + + return [...latestMetadata.entries()] + .filter(([, event]) => tagValue(event, "archived") !== "true") + .map(([id, event]): ChatChannel => { + const isMember = memberships.has(id); + return { + id, + name: tagValue(event, "name")?.trim() || "unnamed", + type: channelType(event), + visibility: channelVisibility(event), + isMember, + }; + }) + .filter((channel) => channel.visibility === "open" || channel.isMember) + .sort((left, right) => { + if (left.isMember !== right.isMember) return left.isMember ? -1 : 1; + const leftGeneral = left.name.toLowerCase() === "general"; + const rightGeneral = right.name.toLowerCase() === "general"; + if (leftGeneral !== rightGeneral) return leftGeneral ? -1 : 1; + return left.name.localeCompare(right.name, undefined, { + sensitivity: "base", + }); + }); +} + +export function selectAutoJoinChannel( + channels: ChatChannel[], +): ChatChannel | null { + if (channels.some((channel) => channel.isMember)) return null; + const candidates = channels.filter( + (channel) => + channel.visibility === "open" && + channel.type === "stream" && + channel.name.toLowerCase() === "general", + ); + return candidates.length === 1 ? candidates[0] : null; +} + +export function mergeChatMessages( + current: NostrEvent[], + incoming: NostrEvent[], +): NostrEvent[] { + const byId = new Map(); + for (const event of [...current, ...incoming]) { + if (CHAT_MESSAGE_KINDS.has(event.kind)) byId.set(event.id, event); + } + return [...byId.values()].sort( + (left, right) => + left.created_at - right.created_at || left.id.localeCompare(right.id), + ); +} diff --git a/web/tests/unit/chat.test.ts b/web/tests/unit/chat.test.ts new file mode 100644 index 0000000000..02c1e92af5 --- /dev/null +++ b/web/tests/unit/chat.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + mergeChatMessages, + projectChatChannels, + selectAutoJoinChannel, +} from "../../src/features/chat/lib/chat.ts"; +import type { NostrEvent } from "../../src/shared/lib/nostr-client.ts"; + +function event( + id: string, + kind: number, + tags: string[][], + content = "", + createdAt = 1, +): NostrEvent { + return { + id: id.padEnd(64, "0"), + pubkey: "ab".repeat(32), + sig: "cd".repeat(64), + kind, + tags, + content, + created_at: createdAt, + }; +} + +test("projects channel memberships onto relay metadata", () => { + const generalId = "11111111-1111-4111-8111-111111111111"; + const privateId = "22222222-2222-4222-8222-222222222222"; + const publicId = "33333333-3333-4333-8333-333333333333"; + + const channels = projectChatChannels( + [ + event("member-general", 39002, [["d", generalId]]), + event("member-private", 39002, [["d", privateId]]), + ], + [ + event("meta-public", 39000, [ + ["d", publicId], + ["name", "random"], + ]), + event("meta-private", 39000, [ + ["d", privateId], + ["name", "staff"], + ["private"], + ]), + event("meta-general", 39000, [ + ["d", generalId], + ["name", "general"], + ]), + ], + ); + + assert.deepEqual( + channels.map(({ id, name, visibility, isMember }) => ({ + id, + name, + visibility, + isMember, + })), + [ + { id: generalId, name: "general", visibility: "open", isMember: true }, + { id: privateId, name: "staff", visibility: "private", isMember: true }, + { id: publicId, name: "random", visibility: "open", isMember: false }, + ], + ); +}); + +test("hides archived channels and private channels without membership", () => { + const archivedId = "44444444-4444-4444-8444-444444444444"; + const privateId = "55555555-5555-4555-8555-555555555555"; + + const channels = projectChatChannels( + [], + [ + event("archived", 39000, [ + ["d", archivedId], + ["name", "old"], + ["archived", "true"], + ]), + event("private", 39000, [ + ["d", privateId], + ["name", "secret"], + ["private"], + ]), + ], + ); + + assert.deepEqual(channels, []); +}); + +test("selects the single open general channel only for a new member", () => { + const general = { + id: "11111111-1111-4111-8111-111111111111", + name: "general", + type: "stream" as const, + visibility: "open" as const, + isMember: false, + }; + assert.equal(selectAutoJoinChannel([general]), general); + assert.equal(selectAutoJoinChannel([{ ...general, isMember: true }]), null); + assert.equal( + selectAutoJoinChannel([ + general, + { ...general, id: "22222222-2222-4222-8222-222222222222" }, + ]), + null, + ); +}); + +test("merges supported chat messages by id in chronological order", () => { + const channelId = "11111111-1111-4111-8111-111111111111"; + const first = event("first", 9, [["h", channelId]], "one", 10); + const second = event("second", 40002, [["h", channelId]], "two", 20); + const unsupported = event("reaction", 7, [["h", channelId]], "+", 30); + + assert.deepEqual( + mergeChatMessages([second], [first, second, unsupported]).map( + ({ content }) => content, + ), + ["one", "two"], + ); +}); From 1c3c20e09ca48c0cb622a0a5af80b3bae19544ea Mon Sep 17 00:00:00 2001 From: Emin Mahrt Date: Fri, 24 Jul 2026 22:36:30 +0200 Subject: [PATCH 2/3] feat(web): publish and subscribe to chat events --- web/src/features/chat/lib/chat-client.ts | 105 +++++++++++ web/src/shared/lib/nostr-client.ts | 212 +++++++++++++++++++++++ web/src/shared/lib/nostr-signer.ts | 13 ++ web/tests/unit/nostr-signer.test.ts | 2 + 4 files changed, 332 insertions(+) create mode 100644 web/src/features/chat/lib/chat-client.ts diff --git a/web/src/features/chat/lib/chat-client.ts b/web/src/features/chat/lib/chat-client.ts new file mode 100644 index 0000000000..3b9b057646 --- /dev/null +++ b/web/src/features/chat/lib/chat-client.ts @@ -0,0 +1,105 @@ +import { + type NostrEvent, + publishEvent, + queryEvents, + subscribeEvents, +} from "@/shared/lib/nostr-client"; +import { getBrowserPublicKey, signNostrEvent } from "@/shared/lib/nostr-signer"; +import { relayWsUrl } from "@/shared/lib/relay-url"; +import { + type ChatChannel, + mergeChatMessages, + projectChatChannels, + selectAutoJoinChannel, +} from "./chat"; + +const CHANNEL_QUERY_LIMIT = 500; +const MESSAGE_QUERY_LIMIT = 100; +const MAX_MESSAGE_LENGTH = 60_000; +const CHAT_MESSAGE_KINDS = [9, 40002]; + +async function queryChannelCatalog( + wsUrl: string, + pubkey: string, +): Promise { + const [memberships, metadata] = await Promise.all([ + queryEvents(wsUrl, { + kinds: [39002], + "#p": [pubkey], + limit: CHANNEL_QUERY_LIMIT, + }), + queryEvents(wsUrl, { kinds: [39000], limit: CHANNEL_QUERY_LIMIT }), + ]); + return projectChatChannels(memberships, metadata); +} + +export type ChatCatalog = { + pubkey: string; + channels: ChatChannel[]; +}; + +export async function loadChatCatalog(): Promise { + const wsUrl = relayWsUrl(); + const pubkey = await getBrowserPublicKey(); + let channels = await queryChannelCatalog(wsUrl, pubkey); + const autoJoin = selectAutoJoinChannel(channels); + if (autoJoin) { + await joinChatChannel(autoJoin.id); + channels = await queryChannelCatalog(wsUrl, pubkey); + } + return { pubkey, channels }; +} + +export async function joinChatChannel(channelId: string): Promise { + const signed = await signNostrEvent({ + kind: 9021, + tags: [["h", channelId]], + content: "", + }); + await publishEvent(relayWsUrl(), signed); +} + +export async function loadChatMessages( + channelId: string, +): Promise { + const events = await queryEvents(relayWsUrl(), { + kinds: CHAT_MESSAGE_KINDS, + "#h": [channelId], + limit: MESSAGE_QUERY_LIMIT, + }); + return mergeChatMessages([], events); +} + +export async function sendChatMessage( + channelId: string, + content: string, +): Promise { + const trimmed = content.trim(); + if (!trimmed) throw new Error("Write a message first."); + if (trimmed.length > MAX_MESSAGE_LENGTH) { + throw new Error("Message is too long."); + } + const signed = await signNostrEvent({ + kind: 9, + tags: [["h", channelId]], + content: trimmed, + }); + await publishEvent(relayWsUrl(), signed); + return signed; +} + +export function subscribeToChatChannel( + channelId: string, + onEvent: (event: NostrEvent) => void, + onError: (error: Error) => void, +): () => void { + return subscribeEvents( + relayWsUrl(), + { + kinds: CHAT_MESSAGE_KINDS, + "#h": [channelId], + since: Math.floor(Date.now() / 1000), + }, + { onEvent, onError }, + ); +} diff --git a/web/src/shared/lib/nostr-client.ts b/web/src/shared/lib/nostr-client.ts index d8a986ef27..55c6417fb8 100644 --- a/web/src/shared/lib/nostr-client.ts +++ b/web/src/shared/lib/nostr-client.ts @@ -24,6 +24,7 @@ export interface NostrFilter { export type NostrEvent = SignedNostrEvent; const QUERY_TIMEOUT_MS = 10_000; +const PUBLISH_TIMEOUT_MS = 10_000; /** * Open a WebSocket to `wsUrl`, authenticate via NIP-42 if challenged, @@ -173,3 +174,214 @@ export function queryEvents( }); }); } + +/** Publish a signed event and wait for the relay's matching `OK` response. */ +export function publishEvent(wsUrl: string, event: NostrEvent): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + let settled = false; + let eventSent = false; + let authEventId: string | null = null; + let unauthenticatedPublishTimer: ReturnType | null = + null; + + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (unauthenticatedPublishTimer) { + clearTimeout(unauthenticatedPublishTimer); + } + ws.close(); + if (error) reject(error); + else resolve(); + }; + + const timeout = setTimeout( + () => + finish( + new Error(`Relay publish timed out after ${PUBLISH_TIMEOUT_MS}ms`), + ), + PUBLISH_TIMEOUT_MS, + ); + + const sendEvent = () => { + if (eventSent || settled) return; + eventSent = true; + ws.send(JSON.stringify(["EVENT", event])); + }; + + ws.addEventListener("open", () => { + unauthenticatedPublishTimer = setTimeout(sendEvent, 100); + }); + + ws.addEventListener("message", async (message) => { + let data: unknown; + try { + data = JSON.parse(String(message.data)); + } catch { + return; + } + if (!Array.isArray(data)) return; + + if (data[0] === "AUTH" && typeof data[1] === "string") { + if (unauthenticatedPublishTimer) { + clearTimeout(unauthenticatedPublishTimer); + unauthenticatedPublishTimer = null; + } + try { + const authEvent = await signNostrEvent(makeAuthEvent(wsUrl, data[1])); + if (settled) return; + authEventId = authEvent.id; + ws.send(JSON.stringify(["AUTH", authEvent])); + } catch (error) { + finish( + error instanceof Error + ? error + : new Error("Failed to sign relay authentication."), + ); + } + return; + } + + if (data[0] !== "OK") return; + if (data[1] === authEventId) { + if (data[2] === true) sendEvent(); + else { + finish( + new Error( + typeof data[3] === "string" + ? data[3] + : "Relay authentication failed.", + ), + ); + } + } else if (data[1] === event.id) { + if (data[2] === true) finish(); + else { + finish( + new Error( + typeof data[3] === "string" + ? data[3] + : "Relay rejected the event.", + ), + ); + } + } + }); + + ws.addEventListener("error", () => { + finish(new Error("WebSocket connection failed")); + }); + ws.addEventListener("close", () => { + if (!settled) + finish(new Error("Relay closed before acknowledging event.")); + }); + }); +} + +type SubscriptionCallbacks = { + onEvent(event: NostrEvent): void; + onEose?(): void; + onError?(error: Error): void; +}; + +/** Maintain a live NIP-01 subscription until the returned cleanup is called. */ +export function subscribeEvents( + wsUrl: string, + filter: NostrFilter, + callbacks: SubscriptionCallbacks, +): () => void { + const ws = new WebSocket(wsUrl); + const subId = `live-${Date.now().toString(36)}-${crypto.randomUUID()}`; + let closedByClient = false; + let reqSent = false; + let authEventId: string | null = null; + let unauthenticatedReqTimer: ReturnType | null = null; + + const reportError = (error: Error) => { + if (!closedByClient) callbacks.onError?.(error); + }; + const sendReq = () => { + if (reqSent || closedByClient) return; + reqSent = true; + ws.send(JSON.stringify(["REQ", subId, filter])); + }; + + ws.addEventListener("open", () => { + unauthenticatedReqTimer = setTimeout(sendReq, 100); + }); + ws.addEventListener("message", async (message) => { + let data: unknown; + try { + data = JSON.parse(String(message.data)); + } catch { + return; + } + if (!Array.isArray(data)) return; + + if (data[0] === "AUTH" && typeof data[1] === "string") { + if (unauthenticatedReqTimer) { + clearTimeout(unauthenticatedReqTimer); + unauthenticatedReqTimer = null; + } + try { + const authEvent = await signNostrEvent(makeAuthEvent(wsUrl, data[1])); + if (closedByClient) return; + authEventId = authEvent.id; + ws.send(JSON.stringify(["AUTH", authEvent])); + } catch (error) { + reportError( + error instanceof Error + ? error + : new Error("Failed to sign relay authentication."), + ); + ws.close(); + } + return; + } + + if (data[0] === "OK" && data[1] === authEventId) { + if (data[2] === true) sendReq(); + else { + reportError( + new Error( + typeof data[3] === "string" + ? data[3] + : "Relay authentication failed.", + ), + ); + ws.close(); + } + } else if (data[0] === "EVENT" && data[1] === subId && data[2]) { + callbacks.onEvent(data[2] as NostrEvent); + } else if (data[0] === "EOSE" && data[1] === subId) { + callbacks.onEose?.(); + } else if (data[0] === "CLOSED" && data[1] === subId) { + reportError( + new Error( + typeof data[2] === "string" + ? data[2] + : "Relay closed the subscription.", + ), + ); + } + }); + ws.addEventListener("error", () => { + reportError(new Error("WebSocket connection failed")); + }); + ws.addEventListener("close", () => { + if (!closedByClient && reqSent) { + reportError(new Error("Relay subscription disconnected.")); + } + }); + + return () => { + closedByClient = true; + if (unauthenticatedReqTimer) clearTimeout(unauthenticatedReqTimer); + if (reqSent && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(["CLOSE", subId])); + } + ws.close(); + }; +} diff --git a/web/src/shared/lib/nostr-signer.ts b/web/src/shared/lib/nostr-signer.ts index 06fd9f6c9e..ada503cbae 100644 --- a/web/src/shared/lib/nostr-signer.ts +++ b/web/src/shared/lib/nostr-signer.ts @@ -76,6 +76,19 @@ export function hasBrowserSigner(): boolean { return hasNuriSigner() || hasNip07Provider(); } +export async function getBrowserPublicKey(): Promise { + if (nuriSecretKey) { + return getPublicKey(nuriSecretKey); + } + const provider = typeof window === "undefined" ? undefined : window.nostr; + if (!provider) throw new Nip07UnavailableError(); + const pubkey = await provider.getPublicKey(); + if (!/^[0-9a-f]{64}$/i.test(pubkey)) { + throw new Error("The NIP-07 extension returned an invalid public key."); + } + return pubkey.toLowerCase(); +} + function sameUnsignedEvent( expected: UnsignedNostrEvent, actual: SignedNostrEvent, diff --git a/web/tests/unit/nostr-signer.test.ts b/web/tests/unit/nostr-signer.test.ts index 75eda11ee9..e6a944b35c 100644 --- a/web/tests/unit/nostr-signer.test.ts +++ b/web/tests/unit/nostr-signer.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { getPublicKey } from "nostr-tools/pure"; import { + getBrowserPublicKey, hasNuriSigner, lockNuriSigner, signNostrEvent, @@ -16,6 +17,7 @@ test("Nuri signer signs from a copied session key and locks cleanly", async () = assert.equal(unlockNuriSigner(secretKey), expectedPubkey); secretKey.fill(0); assert.equal(hasNuriSigner(), true); + assert.equal(await getBrowserPublicKey(), expectedPubkey); const signed = await signNostrEvent({ kind: 1, From 2422adf1d54d099f60144e7d169d4b25e5d15e79 Mon Sep 17 00:00:00 2001 From: Emin Mahrt Date: Sat, 25 Jul 2026 06:34:54 +0200 Subject: [PATCH 3/3] feat(web): load chat after Nuri wallet login --- web/src/app/routes/index.tsx | 4 +- web/src/app/routes/repos.tsx | 14 +- web/src/features/chat/ui/ChatPage.tsx | 439 ++++++++++++++++++ .../nuri-wallet/ui/NuriWalletGate.tsx | 10 +- web/tests/e2e/smoke.spec.ts | 2 +- 5 files changed, 462 insertions(+), 7 deletions(-) create mode 100644 web/src/features/chat/ui/ChatPage.tsx diff --git a/web/src/app/routes/index.tsx b/web/src/app/routes/index.tsx index 83c9bc60cf..4c01fed30d 100644 --- a/web/src/app/routes/index.tsx +++ b/web/src/app/routes/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; +import { ChatPage } from "@/features/chat/ui/ChatPage"; import { NuriWalletGate } from "@/features/nuri-wallet/ui/NuriWalletGate"; -import { ReposPage } from "@/features/repos/ui/ReposPage"; export const Route = createFileRoute("/")({ component: IndexPage, @@ -9,7 +9,7 @@ export const Route = createFileRoute("/")({ function IndexPage() { return ( - + ); } diff --git a/web/src/app/routes/repos.tsx b/web/src/app/routes/repos.tsx index b58d39ada2..ad6dfbeaaf 100644 --- a/web/src/app/routes/repos.tsx +++ b/web/src/app/routes/repos.tsx @@ -1,5 +1,15 @@ -import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; +import { NuriWalletGate } from "@/features/nuri-wallet/ui/NuriWalletGate"; +import { ReposPage } from "@/features/repos/ui/ReposPage"; export const Route = createFileRoute("/repos")({ - component: () => , + component: RepositoriesPage, }); + +function RepositoriesPage() { + return ( + + + + ); +} diff --git a/web/src/features/chat/ui/ChatPage.tsx b/web/src/features/chat/ui/ChatPage.tsx new file mode 100644 index 0000000000..9b5e834334 --- /dev/null +++ b/web/src/features/chat/ui/ChatPage.tsx @@ -0,0 +1,439 @@ +import { Link } from "@tanstack/react-router"; +import { + FolderGit2, + Hash, + LoaderCircle, + MessageCircle, + RefreshCw, + Send, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import buzzAppIcon from "@/assets/app-icon@3x.png"; +import { + joinChatChannel, + loadChatCatalog, + loadChatMessages, + sendChatMessage, + subscribeToChatChannel, + type ChatCatalog, +} from "@/features/chat/lib/chat-client"; +import { mergeChatMessages } from "@/features/chat/lib/chat"; +import type { NostrEvent } from "@/shared/lib/nostr-client"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; + +type TimelineState = { + channelId: string | null; + events: NostrEvent[]; + loading: boolean; + error: string; +}; + +const EMPTY_TIMELINE: TimelineState = { + channelId: null, + events: [], + loading: false, + error: "", +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function messageTime(createdAt: number): string { + return new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(createdAt * 1_000)); +} + +function MessageRow({ + event, + ownPubkey, +}: { + event: NostrEvent; + ownPubkey: string; +}) { + const own = event.pubkey === ownPubkey; + const author = own ? "You" : truncatePubkey(event.pubkey); + return ( +
+
+ {author.substring(0, 2).toUpperCase()} +
+
+
+ + {author} + + +
+
+ + {event.content} + +
+
+
+ ); +} + +export function ChatPage() { + const [catalog, setCatalog] = useState(null); + const [catalogError, setCatalogError] = useState(""); + const [catalogLoading, setCatalogLoading] = useState(true); + const [activeChannelId, setActiveChannelId] = useState(null); + const [joiningChannelId, setJoiningChannelId] = useState(null); + const [timeline, setTimeline] = useState(EMPTY_TIMELINE); + const [connectionError, setConnectionError] = useState(""); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const catalogRequest = useRef | null>(null); + const timelineEnd = useRef(null); + + const refreshCatalog = useCallback(async (force = false) => { + setCatalogLoading(true); + setCatalogError(""); + if (force || !catalogRequest.current) { + catalogRequest.current = loadChatCatalog(); + } + try { + const loaded = await catalogRequest.current; + setCatalog(loaded); + setActiveChannelId((current) => { + if (loaded.channels.some((channel) => channel.id === current)) { + return current; + } + return ( + loaded.channels.find((channel) => channel.isMember)?.id ?? + loaded.channels[0]?.id ?? + null + ); + }); + } catch (error) { + setCatalogError(errorMessage(error)); + } finally { + setCatalogLoading(false); + } + }, []); + + useEffect(() => { + void refreshCatalog(); + }, [refreshCatalog]); + + const activeChannel = useMemo( + () => + catalog?.channels.find((channel) => channel.id === activeChannelId) ?? + null, + [activeChannelId, catalog], + ); + + useEffect(() => { + if (!activeChannel?.isMember) { + setTimeline(EMPTY_TIMELINE); + return; + } + let cancelled = false; + setConnectionError(""); + setTimeline({ + channelId: activeChannel.id, + events: [], + loading: true, + error: "", + }); + + const closeSubscription = subscribeToChatChannel( + activeChannel.id, + (event) => { + if (cancelled) return; + setTimeline((current) => + current.channelId === activeChannel.id + ? { + ...current, + events: mergeChatMessages(current.events, [event]), + } + : current, + ); + }, + (error) => { + if (!cancelled) setConnectionError(error.message); + }, + ); + + void loadChatMessages(activeChannel.id) + .then((events) => { + if (cancelled) return; + setTimeline((current) => + current.channelId === activeChannel.id + ? { + ...current, + events: mergeChatMessages(current.events, events), + loading: false, + } + : current, + ); + }) + .catch((error) => { + if (cancelled) return; + setTimeline({ + channelId: activeChannel.id, + events: [], + loading: false, + error: errorMessage(error), + }); + }); + + return () => { + cancelled = true; + closeSubscription(); + }; + }, [activeChannel]); + + const latestMessageId = timeline.events[timeline.events.length - 1]?.id; + useEffect(() => { + if (latestMessageId) { + timelineEnd.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [latestMessageId]); + + const join = async () => { + if (!activeChannel || joiningChannelId) return; + setJoiningChannelId(activeChannel.id); + setCatalogError(""); + try { + await joinChatChannel(activeChannel.id); + catalogRequest.current = null; + await refreshCatalog(true); + setActiveChannelId(activeChannel.id); + } catch (error) { + setCatalogError(errorMessage(error)); + } finally { + setJoiningChannelId(null); + } + }; + + const send = async () => { + if (!activeChannel?.isMember || sending || !draft.trim()) return; + setSending(true); + setConnectionError(""); + try { + const sent = await sendChatMessage(activeChannel.id, draft); + setTimeline((current) => ({ + ...current, + events: mergeChatMessages(current.events, [sent]), + })); + setDraft(""); + } catch (error) { + setConnectionError(errorMessage(error)); + } finally { + setSending(false); + } + }; + + return ( +
+ + +
+
+
+ +
+
+

+ {activeChannel?.name ?? "Chat"} +

+

+ {activeChannel?.isMember + ? "Connected to Buzz relay" + : "Choose or join a channel"} +

+
+
+ + {catalogLoading && !catalog ? ( +
+ Loading channels… +
+ ) : catalogError ? ( +
+ {catalogError} +
+ ) : !activeChannel ? ( +
+ +

No chat channel yet

+

+ An administrator needs to create the first open channel. +

+
+ ) : !activeChannel.isMember ? ( +
+ +

+ Join #{activeChannel.name} +

+

+ Join this open channel to read and send messages. +

+ +
+ ) : ( + <> + {connectionError ? ( +
+ {connectionError} +
+ ) : null} +
+ {timeline.loading ? ( +
+ Loading + messages… +
+ ) : timeline.error ? ( +

+ {timeline.error} +

+ ) : timeline.events.length === 0 ? ( +
+ +

+ Start #{activeChannel.name} +

+

+ Send the first message to this channel. +

+
+ ) : ( + timeline.events.map((event) => ( + + )) + )} +
+
+ +
+
+