feat: batch-load group metadata and handle unmanaged "_" groups

Improvements to kind 10009 (Public Chats) renderer:

Batch Loading:
- PublicChatsRenderer now batch-loads all group metadata (kind 39000) in a single subscription
- Much more efficient than individual subscriptions per group
- Filters out "_" groups from metadata fetch (they don't have metadata)
- Creates a metadata map to pass to each GroupLink

Special "_" Group Handling:
- "_" represents the unmanaged relay top-level group
- Displays the relay name instead of group ID
- Example: "pyramid.fiatjaf.com" instead of "_"

GroupLink Updates:
- Accepts optional metadata prop (pre-loaded from parent)
- No longer fetches metadata individually (more efficient)
- Extracts group name and icon from provided metadata
- Falls back to group ID if metadata not available

Performance:
- Single subscription for all groups vs N subscriptions
- Reduces relay traffic and improves rendering speed

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2026-01-11 22:10:22 +01:00
parent 6b6605ded0
commit 8f262e1189
3 changed files with 55 additions and 34 deletions

View File

@@ -123,16 +123,9 @@ export function ChatViewer({
return (
<div className="flex h-full flex-col">
{/* Header with conversation info and controls */}
<div className="px-1 border-b w-full">
<div className="px-4 border-b w-full py-0.5">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-1 min-w-0 items-center gap-2">
{conversation.metadata?.icon && (
<img
src={conversation.metadata.icon}
alt={conversation.title}
className="h-4 w-4 object-cover flex-shrink-0"
/>
)}
<div className="flex-1 flex flex-row gap-2 items-baseline min-w-0">
<h2 className="truncate text-base font-semibold">
{customTitle || conversation.title}

View File

@@ -1,22 +1,21 @@
import { MessageSquare } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { use$ } from "applesauce-react/hooks";
import { map } from "rxjs/operators";
import eventStore from "@/services/event-store";
import { getTagValue } from "applesauce-core/helpers";
import type { NostrEvent } from "@/types/nostr";
/**
* Format group identifier for display
* Shows just the group-id part without the relay URL
* Format relay URL for display
* Removes protocol and trailing slash
*/
function formatGroupIdForDisplay(groupId: string): string {
return groupId;
function formatRelayForDisplay(url: string): string {
return url.replace(/^wss?:\/\//, "").replace(/\/$/, "");
}
export interface GroupLinkProps {
groupId: string;
relayUrl: string;
metadata?: NostrEvent; // Optional pre-loaded metadata
className?: string;
iconClassname?: string;
}
@@ -25,35 +24,36 @@ export interface GroupLinkProps {
* GroupLink - Clickable NIP-29 group component
* Displays group name (from kind 39000 metadata) or group ID
* Opens chat window on click
*
* Special case: "_" group ID represents the unmanaged relay top-level group
*/
export function GroupLink({
groupId,
relayUrl,
metadata,
className,
iconClassname,
}: GroupLinkProps) {
const { addWindow } = useGrimoire();
// Try to fetch group metadata (kind 39000) from EventStore
// NIP-29 metadata events use #d tag with group ID
const groupMetadata = use$(
() =>
eventStore
.timeline([{ kinds: [39000], "#d": [groupId], limit: 1 }])
.pipe(map((events) => events[0])),
[groupId],
);
// Handle special case: "_" is the unmanaged relay top-level group
const isUnmanagedGroup = groupId === "_";
// Extract group name from metadata if available
const groupName =
groupMetadata && groupMetadata.kind === 39000
? getTagValue(groupMetadata, "name") || groupId
: groupId;
let groupName: string;
if (isUnmanagedGroup) {
// For "_" groups, show the relay name
groupName = formatRelayForDisplay(relayUrl);
} else if (metadata && metadata.kind === 39000) {
groupName = getTagValue(metadata, "name") || groupId;
} else {
groupName = groupId;
}
// Extract group icon if available
// Extract group icon if available (not applicable for "_" groups)
const groupIcon =
groupMetadata && groupMetadata.kind === 39000
? getTagValue(groupMetadata, "picture")
!isUnmanagedGroup && metadata && metadata.kind === 39000
? getTagValue(metadata, "picture")
: undefined;
const handleClick = () => {
@@ -68,8 +68,6 @@ export function GroupLink({
});
};
const displayName = formatGroupIdForDisplay(groupName);
return (
<div
className={cn(
@@ -93,7 +91,7 @@ export function GroupLink({
)}
/>
)}
<span className="text-xs truncate">{displayName}</span>
<span className="text-xs truncate">{groupName}</span>
</div>
</div>
);

View File

@@ -1,5 +1,9 @@
import { use$ } from "applesauce-react/hooks";
import { map } from "rxjs/operators";
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import { GroupLink } from "../GroupLink";
import eventStore from "@/services/event-store";
import type { NostrEvent } from "@/types/nostr";
/**
* Extract group references from a kind 10009 event
@@ -27,10 +31,35 @@ function extractGroups(event: { tags: string[][] }): Array<{
* Public Chats Renderer (Kind 10009)
* NIP-51 list of NIP-29 groups
* Displays each group as a clickable link with icon and name
* Batch-loads metadata for all groups to show their names
*/
export function PublicChatsRenderer({ event }: BaseEventProps) {
const groups = extractGroups(event);
// Batch-load metadata for all groups at once
// Filter out "_" which is the unmanaged relay group (doesn't have metadata)
const groupIds = groups.map((g) => g.groupId).filter((id) => id !== "_");
const groupMetadataMap = use$(
() =>
groupIds.length > 0
? eventStore.timeline([{ kinds: [39000], "#d": groupIds }]).pipe(
map((events) => {
const metadataMap = new Map<string, NostrEvent>();
for (const evt of events) {
// Extract group ID from #d tag
const dTag = evt.tags.find((t) => t[0] === "d");
if (dTag && dTag[1]) {
metadataMap.set(dTag[1], evt);
}
}
return metadataMap;
}),
)
: undefined,
[groupIds.join(",")],
);
if (groups.length === 0) {
return (
<BaseEventContainer event={event}>
@@ -49,6 +78,7 @@ export function PublicChatsRenderer({ event }: BaseEventProps) {
key={`${group.relayUrl}'${group.groupId}`}
groupId={group.groupId}
relayUrl={group.relayUrl}
metadata={groupMetadataMap?.get(group.groupId)}
/>
))}
</div>