Add multi-room group chat support (#70)

* feat: add multi-room group chat interface (GroupListViewer)

Add a Discord/Slack-style multi-room chat interface for NIP-29 groups:

- New GroupListViewer component with split layout:
  - Left panel: List of groups from kind 10009, sorted by recency
  - Right panel: Full chat view for selected group
- Loads group metadata (kind 39000) for icons and names
- Tracks latest messages (kind 9) for activity-based sorting
This commit is contained in:
Alejandro
2026-01-13 10:30:10 +01:00
committed by GitHub
parent 5de60f17d3
commit c4687da3ef
5 changed files with 497 additions and 12 deletions

View File

@@ -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 (
<div
className={cn(
"flex flex-col gap-0 px-2 py-0.5 cursor-crosshair hover:bg-muted/50 transition-colors border-b",
isSelected && "bg-muted/70",
)}
onClick={onClick}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{groupName}</span>
{group.lastMessage && (
<span className="text-xs text-muted-foreground flex-shrink-0">
<Timestamp timestamp={group.lastMessage.created_at} />
</span>
)}
</div>
{/* Last message preview - hide images and event embeds */}
{lastMessageAuthor && lastMessageContent && (
<div className="text-xs text-muted-foreground truncate line-clamp-1">
<UserName
pubkey={lastMessageAuthor}
className="text-xs font-medium"
/>
:{" "}
<span className="inline truncate">
<RichText
event={group.lastMessage}
className="inline"
options={{
showImages: false,
showEventEmbeds: false,
}}
/>
</span>
</div>
)}
</div>
);
});
/**
* MemoizedChatViewer - Memoized chat viewer to prevent unnecessary re-renders
*/
const MemoizedChatViewer = memo(
function MemoizedChatViewer({
groupId,
relayUrl,
}: {
groupId: string;
relayUrl: string;
}) {
return (
<ChatViewer
protocol="nip-29"
identifier={
{
type: "group",
value: groupId,
relays: [relayUrl],
} as ProtocolIdentifier
}
/>
);
},
// 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<string, NostrEvent>();
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<string, NostrEvent>();
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 (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Sign in to view your groups
</div>
);
}
if (!groupListEvent) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<span>Loading groups...</span>
</div>
);
}
if (!groups || groups.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No groups configured. Add groups to your kind 10009 list.
</div>
);
}
if (!groupsWithRecency) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<span>Loading group details...</span>
</div>
);
}
return (
<div className="flex h-full">
{/* Left sidebar: Group list */}
<aside
className="flex flex-col border-r bg-background"
style={{ width: sidebarWidth }}
>
<div className="flex-1 overflow-y-auto">
{groupsWithRecency.map((group) => (
<GroupListItem
key={`${group.relayUrl}'${group.groupId}`}
group={group}
isSelected={
selectedGroup?.groupId === group.groupId &&
selectedGroup?.relayUrl === group.relayUrl
}
onClick={() =>
setSelectedGroup({
groupId: group.groupId,
relayUrl: group.relayUrl,
})
}
/>
))}
</div>
</aside>
{/* Resize handle */}
<div
className={cn(
"w-1 bg-border hover:bg-primary/50 cursor-col-resize transition-colors",
isResizing && "bg-primary",
)}
onMouseDown={handleMouseDown}
/>
{/* Right panel: Chat view */}
<div className="flex-1 min-w-0">
{selectedGroup ? (
<MemoizedChatViewer
groupId={selectedGroup.groupId}
relayUrl={selectedGroup.relayUrl}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a group to view chat
</div>
)}
</div>
</div>
);
}

View File

@@ -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 = <ConnViewer />;
break;
case "chat":
content = (
<ChatViewer
protocol={window.props.protocol}
identifier={window.props.identifier}
customTitle={window.customTitle}
/>
);
// Check if this is a group list (kind 10009) - render multi-room interface
if (window.props.identifier?.type === "group-list") {
content = <GroupListViewer />;
} else {
content = (
<ChatViewer
protocol={window.props.protocol}
identifier={window.props.identifier}
customTitle={window.customTitle}
/>
);
}
break;
case "spells":
content = <SpellsViewer />;

View File

@@ -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)

View File

@@ -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

View File

@@ -350,18 +350,19 @@ export const manPages: Record<string, ManPageEntry> = {
section: "1",
synopsis: "chat <identifier>",
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: "<identifier>",
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",