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.test.ts b/src/lib/chat/adapters/nip-29-adapter.test.ts index 335246d..5e2464f 100644 --- a/src/lib/chat/adapters/nip-29-adapter.test.ts +++ b/src/lib/chat/adapters/nip-29-adapter.test.ts @@ -180,4 +180,77 @@ describe("Nip29Adapter", () => { expect(capabilities.requiresRelay).toBe(true); }); }); + + describe("profile fallback for pubkey group IDs", () => { + it("should parse valid pubkey as group ID", () => { + const validPubkey = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const result = adapter.parseIdentifier( + `wss://relay.example.com'${validPubkey}`, + ); + + expect(result).toEqual({ + type: "group", + value: validPubkey, + relays: ["wss://relay.example.com"], + }); + }); + + it("should parse uppercase pubkey as group ID", () => { + const validPubkey = + "3BF0C63FCB93463407AF97A5E5EE64FA883D107EF9E558472C4EB9AAAEFA459D"; + const result = adapter.parseIdentifier( + `wss://relay.example.com'${validPubkey}`, + ); + + expect(result).toEqual({ + type: "group", + value: validPubkey, + relays: ["wss://relay.example.com"], + }); + }); + + it("should parse mixed case pubkey as group ID", () => { + const validPubkey = + "3bF0c63Fcb93463407aF97a5e5Ee64fA883d107eF9e558472c4eB9aaaEfa459D"; + const result = adapter.parseIdentifier( + `wss://relay.example.com'${validPubkey}`, + ); + + expect(result).toEqual({ + type: "group", + value: validPubkey, + relays: ["wss://relay.example.com"], + }); + }); + + it("should not treat short hex strings as valid pubkeys", () => { + // Less than 64 characters should be treated as normal group IDs + const shortHex = "3bf0c63f"; + const result = adapter.parseIdentifier( + `wss://relay.example.com'${shortHex}`, + ); + + expect(result).toEqual({ + type: "group", + value: shortHex, + relays: ["wss://relay.example.com"], + }); + }); + + it("should not treat non-hex strings as valid pubkeys", () => { + // 64 characters but contains non-hex characters + const nonHex = + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; + const result = adapter.parseIdentifier( + `wss://relay.example.com'${nonHex}`, + ); + + expect(result).toEqual({ + type: "group", + value: nonHex, + relays: ["wss://relay.example.com"], + }); + }); + }); }); diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 1e57b3b..4587155 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -28,6 +28,7 @@ import { GroupMessageBlueprint, ReactionBlueprint, } from "applesauce-common/blueprints"; +import { resolveGroupMetadata } from "@/lib/chat/group-metadata-helpers"; /** * NIP-29 Adapter - Relay-Based Groups @@ -183,18 +184,18 @@ export class Nip29Adapter extends ChatProtocolAdapter { console.log(`[NIP-29] Metadata event tags:`, metadataEvent.tags); } - // Extract group info from metadata event - const title = metadataEvent - ? getTagValues(metadataEvent, "name")[0] || groupId - : groupId; - const description = metadataEvent - ? getTagValues(metadataEvent, "about")[0] - : undefined; - const icon = metadataEvent - ? getTagValues(metadataEvent, "picture")[0] - : undefined; + // Resolve group metadata with profile fallback + const resolved = await resolveGroupMetadata( + groupId, + relayUrl, + metadataEvent, + ); - console.log(`[NIP-29] Group title: ${title}`); + const title = resolved.name || groupId; + const description = resolved.description; + const icon = resolved.icon; + + 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", + }; +}