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",