From 0aa590bbfce16b61eb15bea3b11617649f6397a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 17:24:20 +0000 Subject: [PATCH] refactor: extract group metadata resolution into shared helper Refactored profile fallback logic into a reusable helper that both NIP-29 adapter and GroupListViewer can use. This fixes the issue where GroupListViewer wasn't benefiting from the profile metadata fallback. Changes: - Created shared `group-metadata-helpers.ts` with: - `isValidPubkey()` - validates 64-character hex strings - `resolveGroupMetadata()` - unified metadata resolution with fallback - `ResolvedGroupMetadata` type with source tracking - Updated NIP-29 adapter to use shared helper (DRY) - Updated GroupListViewer to resolve metadata with profile fallback - Added resolvedMetadata to GroupInfo interface - Added useEffect to resolve metadata for all groups - Updated GroupListItem to use resolved metadata Priority: 1. NIP-29 metadata (kind 39000) if available 2. Profile metadata (kind 0) if groupId is a valid pubkey 3. Fallback to groupId as name Tests: All 1037 tests passing Build: Successful --- src/components/GroupListViewer.tsx | 71 +++++++++++++----- src/lib/chat/adapters/nip-29-adapter.ts | 69 +++--------------- src/lib/chat/group-metadata-helpers.ts | 97 +++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 74 deletions(-) create mode 100644 src/lib/chat/group-metadata-helpers.ts diff --git a/src/components/GroupListViewer.tsx b/src/components/GroupListViewer.tsx index 249a194..a45ca0b 100644 --- a/src/components/GroupListViewer.tsx +++ b/src/components/GroupListViewer.tsx @@ -6,7 +6,6 @@ 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"; @@ -16,6 +15,10 @@ import { RichText } from "./nostr/RichText"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import { + resolveGroupMetadata, + type ResolvedGroupMetadata, +} from "@/lib/chat/group-metadata-helpers"; const MOBILE_BREAKPOINT = 768; @@ -40,6 +43,7 @@ interface GroupInfo { relayUrl: string; metadata?: NostrEvent; lastMessage?: NostrEvent; + resolvedMetadata?: ResolvedGroupMetadata; } /** @@ -61,16 +65,11 @@ const GroupListItem = memo(function GroupListItem({ isSelected: boolean; onClick: () => void; }) { - // Extract group name from metadata + // Extract group name from resolved metadata (includes profile fallback) 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; - } + const groupName = isUnmanagedGroup + ? formatRelayForDisplay(group.relayUrl) + : group.resolvedMetadata?.name || group.groupId; // Get last message author and content const lastMessageAuthor = group.lastMessage?.pubkey; @@ -342,6 +341,40 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { ); }, [groups]); + // Resolve metadata with profile fallback for groups without NIP-29 metadata + const [resolvedMetadataMap, setResolvedMetadataMap] = useState< + Map + >(new Map()); + + useEffect(() => { + if (groups.length === 0) return; + + const resolveAllMetadata = async () => { + const newResolvedMap = new Map(); + + // Resolve metadata for each group + await Promise.all( + groups.map(async (group) => { + // Skip unmanaged groups + if (group.groupId === "_") return; + + const existingMetadata = groupMetadataMap?.get(group.groupId); + const resolved = await resolveGroupMetadata( + group.groupId, + group.relayUrl, + existingMetadata, + ); + + newResolvedMap.set(`${group.relayUrl}'${group.groupId}`, resolved); + }), + ); + + setResolvedMetadataMap(newResolvedMap); + }; + + resolveAllMetadata(); + }, [groups, groupMetadataMap]); + // 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(() => { @@ -397,12 +430,16 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { } // 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), - })); + const groupsWithInfo: GroupInfo[] = groups.map((g) => { + const groupKey = `${g.relayUrl}'${g.groupId}`; + return { + groupId: g.groupId, + relayUrl: g.relayUrl, + metadata: groupMetadataMap?.get(g.groupId), + lastMessage: messageMap.get(g.groupId), + resolvedMetadata: resolvedMetadataMap.get(groupKey), + }; + }); // Sort by recency (most recent first) groupsWithInfo.sort((a, b) => { @@ -414,7 +451,7 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { return groupsWithInfo; }), ); - }, [groups, groupMetadataMap]); + }, [groups, groupMetadataMap, resolvedMetadataMap]); // Only require sign-in if no identifier is provided (viewing own groups) if (!targetPubkey) { diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index d326586..f7cf3db 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -1,7 +1,7 @@ import { Observable, firstValueFrom } from "rxjs"; import { map, first, toArray } from "rxjs/operators"; import type { Filter } from "nostr-tools"; -import { nip19, kinds } from "nostr-tools"; +import { nip19 } from "nostr-tools"; import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; import type { Conversation, @@ -25,15 +25,7 @@ import { GroupMessageBlueprint, ReactionBlueprint, } from "applesauce-common/blueprints"; -import { profileLoader } from "@/services/loaders"; -import { getProfileContent } from "applesauce-core/helpers"; - -/** - * Check if a string is a valid nostr pubkey (64 character hex string) - */ -function isValidPubkey(str: string): boolean { - return /^[0-9a-f]{64}$/i.test(str); -} +import { resolveGroupMetadata } from "@/lib/chat/group-metadata-helpers"; /** * NIP-29 Adapter - Relay-Based Groups @@ -189,55 +181,18 @@ export class Nip29Adapter extends ChatProtocolAdapter { console.log(`[NIP-29] Metadata event tags:`, metadataEvent.tags); } - // Extract group info from metadata event - let title = metadataEvent - ? getTagValues(metadataEvent, "name")[0] || groupId - : groupId; - let description = metadataEvent - ? getTagValues(metadataEvent, "about")[0] - : undefined; - let icon = metadataEvent - ? getTagValues(metadataEvent, "picture")[0] - : undefined; + // Resolve group metadata with profile fallback + const resolved = await resolveGroupMetadata( + groupId, + relayUrl, + metadataEvent, + ); - // Fallback: If no metadata found and groupId is a valid pubkey, use profile metadata - if (!metadataEvent && isValidPubkey(groupId)) { - console.log( - `[NIP-29] No group metadata found, groupId is valid pubkey. Fetching profile for ${groupId.slice(0, 8)}...`, - ); + const title = resolved.name || groupId; + const description = resolved.description; + const icon = resolved.icon; - try { - // Fetch profile metadata (kind 0) for the pubkey - const profileEvent = await firstValueFrom( - profileLoader({ - kind: kinds.Metadata, - pubkey: groupId, - relays: [relayUrl], // Try the group relay first - }), - { defaultValue: undefined }, - ); - - if (profileEvent) { - const profileContent = getProfileContent(profileEvent); - if (profileContent) { - console.log( - `[NIP-29] Using profile metadata as fallback for group ${groupId.slice(0, 8)}...`, - ); - title = profileContent.display_name || profileContent.name || title; - description = profileContent.about || description; - icon = profileContent.picture || icon; - } - } - } catch (error) { - console.warn( - `[NIP-29] Failed to fetch profile fallback for ${groupId.slice(0, 8)}:`, - error, - ); - // Continue with default title (groupId) if profile fetch fails - } - } - - console.log(`[NIP-29] Group title: ${title}`); + console.log(`[NIP-29] Group title: ${title} (source: ${resolved.source})`); // Fetch admins (kind 39001) and members (kind 39002) in parallel // Both use d tag (addressable events signed by relay) diff --git a/src/lib/chat/group-metadata-helpers.ts b/src/lib/chat/group-metadata-helpers.ts new file mode 100644 index 0000000..734881e --- /dev/null +++ b/src/lib/chat/group-metadata-helpers.ts @@ -0,0 +1,97 @@ +import { firstValueFrom } from "rxjs"; +import { kinds } from "nostr-tools"; +import { profileLoader } from "@/services/loaders"; +import { getProfileContent } from "applesauce-core/helpers"; +import type { NostrEvent } from "@/types/nostr"; + +/** + * Check if a string is a valid nostr pubkey (64 character hex string) + */ +export function isValidPubkey(str: string): boolean { + return /^[0-9a-f]{64}$/i.test(str); +} + +/** + * Resolved group metadata + */ +export interface ResolvedGroupMetadata { + name?: string; + description?: string; + icon?: string; + source: "nip29" | "profile" | "fallback"; +} + +/** + * Resolve group metadata with profile fallback + * + * Priority: + * 1. NIP-29 metadata (kind 39000) if available + * 2. Profile metadata (kind 0) if groupId is a valid pubkey + * 3. Fallback to groupId as name + * + * @param groupId - The group identifier (may be a pubkey) + * @param relayUrl - The relay URL to fetch profile from (if needed) + * @param metadataEvent - Optional NIP-29 metadata event (kind 39000) + * @returns Resolved metadata + */ +export async function resolveGroupMetadata( + groupId: string, + relayUrl: string, + metadataEvent?: NostrEvent, +): Promise { + // If NIP-29 metadata exists, use it (priority 1) + if (metadataEvent && metadataEvent.kind === 39000) { + const name = + metadataEvent.tags.find((t) => t[0] === "name")?.[1] || groupId; + const description = metadataEvent.tags.find((t) => t[0] === "about")?.[1]; + const icon = metadataEvent.tags.find((t) => t[0] === "picture")?.[1]; + + return { + name, + description, + icon, + source: "nip29", + }; + } + + // If no NIP-29 metadata and groupId is a valid pubkey, try profile fallback (priority 2) + if (isValidPubkey(groupId)) { + try { + const profileEvent = await firstValueFrom( + profileLoader({ + kind: kinds.Metadata, + pubkey: groupId, + relays: [relayUrl], + }), + { defaultValue: undefined }, + ); + + if (profileEvent) { + const profileContent = getProfileContent(profileEvent); + if (profileContent) { + return { + name: + profileContent.display_name || + profileContent.name || + groupId.slice(0, 8) + ":" + groupId.slice(-8), + description: profileContent.about, + icon: profileContent.picture, + source: "profile", + }; + } + } + } catch (error) { + console.warn( + `[GroupMetadata] Failed to fetch profile fallback for ${groupId.slice(0, 8)}:`, + error, + ); + // Fall through to fallback + } + } + + // Fallback: use groupId as name (priority 3) + return { + name: groupId, + source: "fallback", + }; +}