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/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/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/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) => ( + + )) + )} +
+
+ +
+
+