Files
grimoire/src/components/GroupListViewer.tsx
2026-01-13 10:50:17 +01:00

465 lines
14 KiB
TypeScript

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, GroupListIdentifier } 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,
);
interface GroupListViewerProps {
identifier?: GroupListIdentifier;
}
/**
* GroupListViewer - Multi-room chat interface
*
* Left panel: List of groups from kind 10009, sorted by recency
* Right panel: Chat view for selected group
*
* @param identifier - Optional group list identifier. If provided, loads that specific
* kind 10009 event. If not provided, loads active user's list.
*/
export function GroupListViewer({ identifier }: GroupListViewerProps) {
const activeAccount = use$(accountManager.active$);
const activePubkey = activeAccount?.pubkey;
// Determine which pubkey/identifier to load:
// - If identifier prop is provided, use that (allows viewing other users' lists)
// - Otherwise, use active user's pubkey (default behavior)
const targetPubkey = identifier?.value.pubkey || activePubkey;
const targetIdentifier = identifier?.value.identifier || ""; // Empty string is default d-tag for kind 10009
const targetRelays = identifier?.relays;
// 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 kind 10009 (group list) event
// If identifier is provided with relays, subscribe to those relays first
useEffect(() => {
if (!targetPubkey || !targetRelays || targetRelays.length === 0) return;
const subscription = pool
.subscription(
targetRelays,
[{ kinds: [10009], authors: [targetPubkey], "#d": [targetIdentifier] }],
{ eventStore },
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [targetPubkey, targetIdentifier, targetRelays]);
const groupListEvent = use$(
() =>
targetPubkey
? eventStore.replaceable(10009, targetPubkey, targetIdentifier)
: undefined,
[targetPubkey, targetIdentifier],
);
// 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]);
// Only require sign-in if no identifier is provided (viewing own groups)
if (!targetPubkey) {
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>
);
}