mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 23:16:50 +02:00
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:
430
src/components/GroupListViewer.tsx
Normal file
430
src/components/GroupListViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user