diff --git a/src/components/GroupListViewer.tsx b/src/components/GroupListViewer.tsx new file mode 100644 index 0000000..6dfd755 --- /dev/null +++ b/src/components/GroupListViewer.tsx @@ -0,0 +1,430 @@ +import { useState, useMemo, memo, useCallback } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { map } from "rxjs/operators"; +import { Loader2 } from "lucide-react"; +import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import accountManager from "@/services/accounts"; +import { ChatViewer } from "./ChatViewer"; +import { getTagValue } from "applesauce-core/helpers"; +import type { NostrEvent } from "@/types/nostr"; +import type { ProtocolIdentifier } from "@/types/chat"; +import { cn } from "@/lib/utils"; +import Timestamp from "./Timestamp"; +import { useEffect } from "react"; +import { UserName } from "./nostr/UserName"; +import { RichText } from "./nostr/RichText"; + +interface GroupInfo { + groupId: string; + relayUrl: string; + metadata?: NostrEvent; + lastMessage?: NostrEvent; +} + +/** + * Format relay URL for display + */ +function formatRelayForDisplay(url: string): string { + return url.replace(/^wss?:\/\//, "").replace(/\/$/, ""); +} + +/** + * GroupListItem - Single group in the list + */ +const GroupListItem = memo(function GroupListItem({ + group, + isSelected, + onClick, +}: { + group: GroupInfo; + isSelected: boolean; + onClick: () => void; +}) { + // Extract group name from metadata + const isUnmanagedGroup = group.groupId === "_"; + let groupName: string; + if (isUnmanagedGroup) { + groupName = formatRelayForDisplay(group.relayUrl); + } else if (group.metadata && group.metadata.kind === 39000) { + groupName = getTagValue(group.metadata, "name") || group.groupId; + } else { + groupName = group.groupId; + } + + // Get last message author and content + const lastMessageAuthor = group.lastMessage?.pubkey; + const lastMessageContent = group.lastMessage?.content; + + return ( +
+
+ {groupName} + {group.lastMessage && ( + + + + )} +
+ {/* Last message preview - hide images and event embeds */} + {lastMessageAuthor && lastMessageContent && ( +
+ + :{" "} + + + +
+ )} +
+ ); +}); + +/** + * MemoizedChatViewer - Memoized chat viewer to prevent unnecessary re-renders + */ +const MemoizedChatViewer = memo( + function MemoizedChatViewer({ + groupId, + relayUrl, + }: { + groupId: string; + relayUrl: string; + }) { + return ( + + ); + }, + // Custom comparison: only re-render if group actually changed + (prev, next) => + prev.groupId === next.groupId && prev.relayUrl === next.relayUrl, +); + +/** + * GroupListViewer - Multi-room chat interface + * + * Left panel: List of groups from user's kind 10009, sorted by recency + * Right panel: Chat view for selected group + */ +export function GroupListViewer() { + const activeAccount = use$(accountManager.active$); + const activePubkey = activeAccount?.pubkey; + + // State for selected group + const [selectedGroup, setSelectedGroup] = useState<{ + groupId: string; + relayUrl: string; + } | null>(null); + + // State for sidebar width + const [sidebarWidth, setSidebarWidth] = useState(280); + const [isResizing, setIsResizing] = useState(false); + + // Handle resize with proper cleanup + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + + const startX = e.clientX; + const startWidth = sidebarWidth; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startX; + const newWidth = startWidth + deltaX; + // Clamp between 200px and 500px + setSidebarWidth(Math.max(200, Math.min(500, newWidth))); + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + // Cleanup listeners on component unmount (stored in ref) + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, + [sidebarWidth], + ); + + // Cleanup resize event listeners on unmount + useEffect(() => { + return () => { + setIsResizing(false); + }; + }, []); + + // Load user's kind 10009 (group list) event + const groupListEvent = use$( + () => + activePubkey ? eventStore.replaceable(10009, activePubkey) : undefined, + [activePubkey], + ); + + // Extract groups from the event with relay URL validation + const groups = useMemo(() => { + if (!groupListEvent) return []; + + const extractedGroups: Array<{ + groupId: string; + relayUrl: string; + }> = []; + + for (const tag of groupListEvent.tags) { + if (tag[0] === "group" && tag[1] && tag[2]) { + // Validate relay URL before adding + const relayUrl = tag[2]; + try { + const url = new URL( + relayUrl.startsWith("ws://") || relayUrl.startsWith("wss://") + ? relayUrl + : `wss://${relayUrl}`, + ); + // Only accept ws:// or wss:// protocols + if (url.protocol === "ws:" || url.protocol === "wss:") { + extractedGroups.push({ + groupId: tag[1], + relayUrl: url.toString(), + }); + } + } catch { + // Invalid URL, skip this group + continue; + } + } + } + + return extractedGroups; + }, [groupListEvent]); + + // Subscribe to group metadata (kind 39000) for all groups + useEffect(() => { + if (groups.length === 0) return; + + const groupIds = groups.map((g) => g.groupId).filter((id) => id !== "_"); + const relayUrls = Array.from(new Set(groups.map((g) => g.relayUrl))); + + if (groupIds.length === 0) return; + + const subscription = pool + .subscription(relayUrls, [{ kinds: [39000], "#d": groupIds }], { + eventStore, + }) + .subscribe(); + + return () => { + subscription.unsubscribe(); + }; + }, [groups]); + + // Load metadata for all groups + const groupMetadataMap = use$(() => { + const groupIds = groups.map((g) => g.groupId).filter((id) => id !== "_"); + if (groupIds.length === 0) return undefined; + + return eventStore.timeline([{ kinds: [39000], "#d": groupIds }]).pipe( + map((events) => { + const metadataMap = new Map(); + for (const evt of events) { + const dTag = evt.tags.find((t) => t[0] === "d"); + if (dTag && dTag[1]) { + metadataMap.set(dTag[1], evt); + } + } + return metadataMap; + }), + ); + }, [groups]); + + // Subscribe to latest messages (kind 9) for all groups to get recency + // NOTE: Separate filters needed to ensure we get 1 message per group (not N total across all groups) + useEffect(() => { + if (groups.length === 0) return; + + const relayUrls = Array.from(new Set(groups.map((g) => g.relayUrl))); + const groupIds = groups.map((g) => g.groupId); + + // One filter per group to ensure limit:1 applies per group, not globally + const subscription = pool + .subscription( + relayUrls, + groupIds.map((groupId) => ({ + kinds: [9], + "#h": [groupId], + limit: 1, + })), + { eventStore }, + ) + .subscribe(); + + return () => { + subscription.unsubscribe(); + }; + }, [groups]); + + // Load latest messages and merge with group data + const groupsWithRecency = use$(() => { + if (groups.length === 0) return undefined; + + const groupIds = groups.map((g) => g.groupId); + + return eventStore + .timeline( + groupIds.map((groupId) => ({ + kinds: [9], + "#h": [groupId], + limit: 1, + })), + ) + .pipe( + map((events) => { + // Create a map of groupId -> latest message + const messageMap = new Map(); + for (const evt of events) { + const hTag = evt.tags.find((t) => t[0] === "h"); + if (hTag && hTag[1]) { + const existing = messageMap.get(hTag[1]); + if (!existing || evt.created_at > existing.created_at) { + messageMap.set(hTag[1], evt); + } + } + } + + // Merge with groups + const groupsWithInfo: GroupInfo[] = groups.map((g) => ({ + groupId: g.groupId, + relayUrl: g.relayUrl, + metadata: groupMetadataMap?.get(g.groupId), + lastMessage: messageMap.get(g.groupId), + })); + + // Sort by recency (most recent first) + groupsWithInfo.sort((a, b) => { + const aTime = a.lastMessage?.created_at || 0; + const bTime = b.lastMessage?.created_at || 0; + return bTime - aTime; + }); + + return groupsWithInfo; + }), + ); + }, [groups, groupMetadataMap]); + + if (!activePubkey) { + return ( +
+ Sign in to view your groups +
+ ); + } + + if (!groupListEvent) { + return ( +
+ + Loading groups... +
+ ); + } + + if (!groups || groups.length === 0) { + return ( +
+ No groups configured. Add groups to your kind 10009 list. +
+ ); + } + + if (!groupsWithRecency) { + return ( +
+ + Loading group details... +
+ ); + } + + return ( +
+ {/* Left sidebar: Group list */} + + + {/* Resize handle */} +
+ + {/* Right panel: Chat view */} +
+ {selectedGroup ? ( + + ) : ( +
+ Select a group to view chat +
+ )} +
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 576d35e..a00275f 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -30,6 +30,9 @@ const ConnViewer = lazy(() => import("./ConnViewer")); const ChatViewer = lazy(() => import("./ChatViewer").then((m) => ({ default: m.ChatViewer })), ); +const GroupListViewer = lazy(() => + import("./GroupListViewer").then((m) => ({ default: m.GroupListViewer })), +); const SpellsViewer = lazy(() => import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })), ); @@ -173,13 +176,18 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { content = ; break; case "chat": - content = ( - - ); + // Check if this is a group list (kind 10009) - render multi-room interface + if (window.props.identifier?.type === "group-list") { + content = ; + } else { + content = ( + + ); + } break; case "spells": content = ; diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 3e91358..8a0778f 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -1,7 +1,8 @@ -import type { ChatCommandResult } from "@/types/chat"; +import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat"; // import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter"; import { Nip29Adapter } from "./chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "./chat/adapters/nip-53-adapter"; +import { nip19 } from "nostr-tools"; // Import other adapters as they're implemented // import { Nip17Adapter } from "./chat/adapters/nip-17-adapter"; // import { Nip28Adapter } from "./chat/adapters/nip-28-adapter"; @@ -34,6 +35,31 @@ export function parseChatCommand(args: string[]): ChatCommandResult { identifier = `${args[0]}'${args[1]}`; } + // Check for kind 10009 (group list) naddr - open multi-room interface + if (identifier.startsWith("naddr1")) { + try { + const decoded = nip19.decode(identifier); + if (decoded.type === "naddr" && decoded.data.kind === 10009) { + const groupListIdentifier: GroupListIdentifier = { + type: "group-list", + value: { + kind: 10009, + pubkey: decoded.data.pubkey, + identifier: decoded.data.identifier, + }, + relays: decoded.data.relays, + }; + return { + protocol: "nip-29", // Use nip-29 as the protocol designation + identifier: groupListIdentifier, + adapter: null, // No adapter needed for group list view + }; + } + } catch (e) { + // Not a valid naddr, continue to adapter parsing + } + } + // Try each adapter in priority order const adapters = [ // new Nip17Adapter(), // Phase 2 @@ -68,6 +94,9 @@ Currently supported formats: - naddr1... (NIP-53 live activity chat, kind 30311) Example: chat naddr1... (live stream address) + - naddr1... (Multi-room group list, kind 10009) + Example: + chat naddr1... (group list address) More formats coming soon: - npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages) diff --git a/src/types/chat.ts b/src/types/chat.ts index 6d77c7f..51b7b45 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -178,6 +178,22 @@ export interface ChannelIdentifier { relays?: string[]; } +/** + * Group list identifier (kind 10009) + * Used to open multi-room chat interface + */ +export interface GroupListIdentifier { + type: "group-list"; + /** Address pointer for the group list (kind 10009) */ + value: { + kind: 10009; + pubkey: string; + identifier: string; + }; + /** Relay hints from naddr encoding */ + relays?: string[]; +} + /** * Protocol-specific identifier - discriminated union * Returned by adapter parseIdentifier() @@ -187,7 +203,8 @@ export type ProtocolIdentifier = | LiveActivityIdentifier | DMIdentifier | NIP05Identifier - | ChannelIdentifier; + | ChannelIdentifier + | GroupListIdentifier; /** * Chat command parsing result diff --git a/src/types/man.ts b/src/types/man.ts index a31f4c8..a97b2bd 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -350,18 +350,19 @@ export const manPages: Record = { section: "1", synopsis: "chat ", description: - "Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups and NIP-53 live activity chat. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event to join its chat.", + "Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event. For multi-room interface, pass the naddr of a kind 10009 group list event.", options: [ { flag: "", description: - "NIP-29 group (relay'group-id) or NIP-53 live activity (naddr1...)", + "NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)", }, ], examples: [ "chat relay.example.com'bitcoin-dev Join NIP-29 relay group", "chat wss://nos.lol'welcome Join NIP-29 group with explicit protocol", - "chat naddr1... Join NIP-53 live activity chat", + "chat naddr1...30311... Join NIP-53 live activity chat", + "chat naddr1...10009... Open multi-room group list interface", ], seeAlso: ["profile", "open", "req", "live"], appId: "chat",