From 343058efbdc975f55899ef8bd1ee1593576e72f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 13:46:15 +0000 Subject: [PATCH] refactor: extract shared group metadata cache service Create a singleton GroupMetadataCache service that both GroupListViewer and NIP-29 adapter share. This ensures: - Cache is shared between components (opening chat populates list, etc) - Single source of truth for group metadata - Consistent caching with applesauce getOrComputeCachedValue Changes: - Add src/services/group-metadata-cache.ts singleton service - Simplify NIP-29 adapter to use cache.resolve() - Simplify GroupListViewer to use shared cache - Remove src/lib/chat/group-metadata-helpers.ts (moved to service) The service provides: - In-memory cache keyed by "relayUrl'groupId" - EventStore integration for kind 39000 events - Relay fetching with timeout - Profile fallback for pubkey-based group IDs - updateFromEvent() for reactive cache updates https://claude.ai/code/session_01CCxAcUsRBkWSL6as1wtFoA --- src/components/GroupListViewer.tsx | 151 ++++++------ src/lib/chat/adapters/nip-29-adapter.ts | 91 +------ src/lib/chat/group-metadata-helpers.ts | 123 ---------- src/services/group-metadata-cache.ts | 302 ++++++++++++++++++++++++ 4 files changed, 372 insertions(+), 295 deletions(-) delete mode 100644 src/lib/chat/group-metadata-helpers.ts create mode 100644 src/services/group-metadata-cache.ts diff --git a/src/components/GroupListViewer.tsx b/src/components/GroupListViewer.tsx index bde249d..07ee43d 100644 --- a/src/components/GroupListViewer.tsx +++ b/src/components/GroupListViewer.tsx @@ -5,6 +5,9 @@ import { Loader2, PanelLeft } from "lucide-react"; import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; import accountManager from "@/services/accounts"; +import groupMetadataCache, { + type GroupMetadata, +} from "@/services/group-metadata-cache"; import { ChatViewer } from "./ChatViewer"; import type { NostrEvent } from "@/types/nostr"; import type { ProtocolIdentifier, GroupListIdentifier } from "@/types/chat"; @@ -15,13 +18,6 @@ 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, - extractMetadataFromEvent, - getGroupCacheKey, - isValidPubkey, - type ResolvedGroupMetadata, -} from "@/lib/chat/group-metadata-helpers"; const MOBILE_BREAKPOINT = 768; @@ -46,7 +42,7 @@ interface GroupInfo { relayUrl: string; metadata?: NostrEvent; lastMessage?: NostrEvent; - resolvedMetadata?: ResolvedGroupMetadata; + resolvedMetadata?: GroupMetadata; } /** @@ -344,96 +340,81 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { ); }, [groups]); - // Resolve metadata with sync extraction + async-only-when-needed: - // 1. Sync extraction from kind 39000 events (fast, no async) - // 2. Only async-resolve for groups needing profile fallback (groupId is pubkey) + // Track resolved metadata for UI updates const [resolvedMetadataMap, setResolvedMetadataMap] = useState< - Map + Map >(new Map()); - // Sync extraction from kind 39000 + async profile fallback only when needed + // Update shared cache when kind 39000 events arrive, then sync to local state + useEffect(() => { + if (!groupMetadataMap || groupMetadataMap.size === 0) return; + + // Update cache from received events + for (const [groupId, event] of groupMetadataMap) { + const group = groups.find((g) => g.groupId === groupId); + if (group) { + groupMetadataCache.updateFromEvent(group.relayUrl, event); + } + } + + // Sync cache to local state for rendering + setResolvedMetadataMap((prev) => { + const updated = new Map(prev); + for (const group of groups) { + if (group.groupId === "_") continue; + const cached = groupMetadataCache.get(group.relayUrl, group.groupId); + if (cached) { + updated.set( + groupMetadataCache.getKey(group.relayUrl, group.groupId), + cached, + ); + } + } + return updated; + }); + }, [groups, groupMetadataMap]); + + // Resolve metadata for groups not yet in cache (async, triggers on mount) useEffect(() => { if (groups.length === 0) return; - // Determine which groups need async resolution (no NIP-29 metadata, groupId is pubkey) - const needsAsyncResolution: Array<{ groupId: string; relayUrl: string }> = - []; + const resolveUncached = async () => { + const resolved: Array<{ + key: string; + metadata: GroupMetadata; + }> = []; - for (const group of groups) { - if (group.groupId === "_") continue; + await Promise.all( + groups.map(async (group) => { + if (group.groupId === "_") return; - const metadataEvent = groupMetadataMap?.get(group.groupId); - if (!metadataEvent && isValidPubkey(group.groupId)) { - needsAsyncResolution.push(group); - } - } + const key = groupMetadataCache.getKey(group.relayUrl, group.groupId); - // Update state using functional update to avoid stale reads - // This extracts sync metadata and preserves existing values for async groups - setResolvedMetadataMap((prev) => { - const updated = new Map(prev); + // Skip if already in shared cache (avoids re-fetching) + if (groupMetadataCache.get(group.relayUrl, group.groupId)) return; - for (const group of groups) { - if (group.groupId === "_") continue; - - const key = getGroupCacheKey(group.relayUrl, group.groupId); - const metadataEvent = groupMetadataMap?.get(group.groupId); - - if (metadataEvent && metadataEvent.kind === 39000) { - // Fast path: we have the NIP-29 metadata event, extract synchronously - updated.set( - key, - extractMetadataFromEvent(group.groupId, metadataEvent), + // Resolve using shared cache (will fetch if needed) + const metadata = await groupMetadataCache.resolve( + group.relayUrl, + group.groupId, ); - } else if (!isValidPubkey(group.groupId)) { - // No NIP-29 metadata, not a pubkey - use groupId as name - updated.set(key, { name: group.groupId, source: "fallback" }); - } - // For pubkey groups without metadata, keep existing value (don't overwrite) + resolved.push({ key, metadata }); + }), + ); + + if (resolved.length > 0) { + setResolvedMetadataMap((prev) => { + const updated = new Map(prev); + for (const { key, metadata } of resolved) { + updated.set(key, metadata); + } + return updated; + }); } + }; - return updated; - }); - - // Async pass: resolve profile metadata for groups that need it - if (needsAsyncResolution.length > 0) { - const resolveProfileMetadata = async () => { - const resolved: Array<{ - relayUrl: string; - groupId: string; - metadata: ResolvedGroupMetadata; - }> = []; - - await Promise.all( - needsAsyncResolution.map(async (group) => { - const metadata = await resolveGroupMetadata( - group.groupId, - group.relayUrl, - undefined, // No NIP-29 metadata, that's why we're here - ); - resolved.push({ - relayUrl: group.relayUrl, - groupId: group.groupId, - metadata, - }); - }), - ); - - // Update state with async-resolved metadata - if (resolved.length > 0) { - setResolvedMetadataMap((prev) => { - const updated = new Map(prev); - for (const { relayUrl, groupId, metadata } of resolved) { - updated.set(getGroupCacheKey(relayUrl, groupId), metadata); - } - return updated; - }); - } - }; - - resolveProfileMetadata(); - } - }, [groups, groupMetadataMap]); + resolveUncached(); + }, [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) diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index a6c04d4..f9e914a 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -17,6 +17,7 @@ import type { NostrEvent } from "@/types/nostr"; import type { ChatAction, GetActionsOptions } from "@/types/chat-actions"; import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; +import groupMetadataCache from "@/services/group-metadata-cache"; import { publishEventToRelays, publishEvent } from "@/services/hub"; import accountManager from "@/services/accounts"; import { getQuotePointer } from "@/lib/nostr-utils"; @@ -28,10 +29,6 @@ import { GroupMessageBlueprint, ReactionBlueprint, } from "applesauce-common/blueprints"; -import { - resolveGroupMetadata, - extractMetadataFromEvent, -} from "@/lib/chat/group-metadata-helpers"; /** * NIP-29 Adapter - Relay-Based Groups @@ -132,89 +129,9 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("No active account"); } - console.log( - `[NIP-29] Resolving group metadata for ${groupId} from ${relayUrl}`, - ); - - // Check eventStore first for cached kind 39000 event - // Use timeline query since we don't know the relay's pubkey - const cachedMetadata = await firstValueFrom( - eventStore - .timeline([{ kinds: [39000], "#d": [groupId], limit: 1 }]) - .pipe(first()), - { defaultValue: [] }, - ); - let metadataEvent: NostrEvent | undefined = cachedMetadata[0]; - - // If not in store, fetch from relay - if (!metadataEvent) { - console.log(`[NIP-29] No cached metadata, fetching from relay`); - - const metadataFilter: Filter = { - kinds: [39000], - "#d": [groupId], - limit: 1, - }; - - const metadataEvents: NostrEvent[] = []; - const metadataObs = pool.subscription([relayUrl], [metadataFilter], { - eventStore, - }); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - console.log("[NIP-29] Metadata fetch timeout"); - resolve(); - }, 5000); - - const sub = metadataObs.subscribe({ - next: (response) => { - if (typeof response === "string") { - clearTimeout(timeout); - console.log( - `[NIP-29] Got ${metadataEvents.length} metadata events`, - ); - sub.unsubscribe(); - resolve(); - } else { - metadataEvents.push(response); - } - }, - error: (err) => { - clearTimeout(timeout); - console.error("[NIP-29] Metadata fetch error:", err); - sub.unsubscribe(); - reject(err); - }, - }); - }); - - metadataEvent = metadataEvents[0]; - } else { - console.log(`[NIP-29] Using cached metadata event`); - } - - // Extract metadata - use sync extraction if we have the event - let title: string; - let description: string | undefined; - let icon: string | undefined; - let source: string; - - if (metadataEvent && metadataEvent.kind === 39000) { - // Fast path: sync extraction from kind 39000 event - const resolved = extractMetadataFromEvent(groupId, metadataEvent); - title = resolved.name || groupId; - description = resolved.description; - icon = resolved.icon; - source = resolved.source; - } else { - // Slow path: async resolution with profile fallback (for pubkey-based groupIds) - const resolved = await resolveGroupMetadata(groupId, relayUrl, undefined); - title = resolved.name || groupId; - description = resolved.description; - icon = resolved.icon; - source = resolved.source; - } + // Resolve group metadata using shared cache + const metadata = await groupMetadataCache.resolve(relayUrl, groupId); + const { name: title, description, icon, source } = metadata; console.log(`[NIP-29] Group title: ${title} (source: ${source})`); diff --git a/src/lib/chat/group-metadata-helpers.ts b/src/lib/chat/group-metadata-helpers.ts deleted file mode 100644 index c5fe8b2..0000000 --- a/src/lib/chat/group-metadata-helpers.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { firstValueFrom } from "rxjs"; -import { kinds } from "nostr-tools"; -import { profileLoader } from "@/services/loaders"; -import { - getProfileContent, - getOrComputeCachedValue, -} from "applesauce-core/helpers"; -import type { NostrEvent } from "@/types/nostr"; - -// Symbol for caching extracted group metadata on event objects -const GroupMetadataSymbol = Symbol("groupMetadata"); - -/** - * 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"; -} - -/** - * Create a cache key for a group - */ -export function getGroupCacheKey(relayUrl: string, groupId: string): string { - return `${relayUrl}'${groupId}`; -} - -/** - * Extract metadata synchronously from a kind 39000 event - * Uses applesauce caching to avoid recomputing on every access - */ -export function extractMetadataFromEvent( - groupId: string, - metadataEvent: NostrEvent, -): ResolvedGroupMetadata { - return getOrComputeCachedValue(metadataEvent, GroupMetadataSymbol, () => { - 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" as const, - }; - }); -} - -/** - * 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 cached extraction (priority 1) - if (metadataEvent && metadataEvent.kind === 39000) { - return extractMetadataFromEvent(groupId, metadataEvent); - } - - // 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", - }; -} diff --git a/src/services/group-metadata-cache.ts b/src/services/group-metadata-cache.ts new file mode 100644 index 0000000..5168dfb --- /dev/null +++ b/src/services/group-metadata-cache.ts @@ -0,0 +1,302 @@ +import { firstValueFrom } from "rxjs"; +import { first } from "rxjs/operators"; +import { kinds, type Filter } from "nostr-tools"; +import { + getProfileContent, + getOrComputeCachedValue, +} from "applesauce-core/helpers"; +import { profileLoader } from "@/services/loaders"; +import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import type { NostrEvent } from "@/types/nostr"; + +// Symbol for caching extracted metadata on event objects +const GroupMetadataSymbol = Symbol("groupMetadata"); + +/** + * Resolved group metadata + */ +export interface GroupMetadata { + name: string; + description?: string; + icon?: string; + source: "nip29" | "profile" | "fallback"; +} + +/** + * 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); +} + +/** + * Extract metadata from a kind 39000 event with caching + */ +function extractFromEvent(groupId: string, event: NostrEvent): GroupMetadata { + return getOrComputeCachedValue(event, GroupMetadataSymbol, () => { + const name = event.tags.find((t) => t[0] === "name")?.[1] || groupId; + const description = event.tags.find((t) => t[0] === "about")?.[1]; + const icon = event.tags.find((t) => t[0] === "picture")?.[1]; + + return { + name, + description, + icon, + source: "nip29" as const, + }; + }); +} + +/** + * Singleton cache for NIP-29 group metadata + * + * Provides a shared cache between GroupListViewer and NIP-29 adapter. + * Checks eventStore first, then fetches from relay if needed. + */ +class GroupMetadataCache { + // In-memory cache: "relayUrl'groupId" -> metadata + private cache = new Map(); + + /** + * Get cache key for a group + */ + getKey(relayUrl: string, groupId: string): string { + return `${relayUrl}'${groupId}`; + } + + /** + * Get metadata from cache (sync, returns undefined if not cached) + */ + get(relayUrl: string, groupId: string): GroupMetadata | undefined { + return this.cache.get(this.getKey(relayUrl, groupId)); + } + + /** + * Set metadata in cache + */ + set(relayUrl: string, groupId: string, metadata: GroupMetadata): void { + this.cache.set(this.getKey(relayUrl, groupId), metadata); + } + + /** + * Check eventStore for cached kind 39000 event and extract metadata + * Returns undefined if not in store + */ + async getFromEventStore( + groupId: string, + ): Promise<{ event: NostrEvent; metadata: GroupMetadata } | undefined> { + const events = await firstValueFrom( + eventStore + .timeline([{ kinds: [39000], "#d": [groupId], limit: 1 }]) + .pipe(first()), + { defaultValue: [] }, + ); + + const event = events[0]; + if (event && event.kind === 39000) { + const metadata = extractFromEvent(groupId, event); + return { event, metadata }; + } + + return undefined; + } + + /** + * Fetch metadata from relay (adds to eventStore automatically) + */ + async fetchFromRelay( + relayUrl: string, + groupId: string, + timeoutMs = 5000, + ): Promise { + const filter: Filter = { + kinds: [39000], + "#d": [groupId], + limit: 1, + }; + + const events: NostrEvent[] = []; + const subscription = pool.subscription([relayUrl], [filter], { + eventStore, + }); + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + console.log(`[GroupMetadataCache] Fetch timeout for ${groupId}`); + resolve(); + }, timeoutMs); + + const sub = subscription.subscribe({ + next: (response) => { + if (typeof response === "string") { + // EOSE + clearTimeout(timeout); + sub.unsubscribe(); + resolve(); + } else { + events.push(response); + } + }, + error: (err) => { + clearTimeout(timeout); + console.error(`[GroupMetadataCache] Fetch error:`, err); + sub.unsubscribe(); + resolve(); + }, + }); + }); + + return events[0]; + } + + /** + * Resolve profile metadata for pubkey-based group IDs + */ + async resolveProfileFallback( + groupId: string, + relayUrl: string, + ): Promise { + if (!isValidPubkey(groupId)) { + return undefined; + } + + try { + const profileEvent = await firstValueFrom( + profileLoader({ + kind: kinds.Metadata, + pubkey: groupId, + relays: [relayUrl], + }), + { defaultValue: undefined }, + ); + + if (profileEvent) { + const content = getProfileContent(profileEvent); + if (content) { + return { + name: + content.display_name || + content.name || + `${groupId.slice(0, 8)}:${groupId.slice(-8)}`, + description: content.about, + icon: content.picture, + source: "profile", + }; + } + } + } catch (error) { + console.warn( + `[GroupMetadataCache] Profile fallback failed for ${groupId.slice(0, 8)}:`, + error, + ); + } + + return undefined; + } + + /** + * Get or fetch metadata for a group + * + * Priority: + * 1. In-memory cache + * 2. EventStore (kind 39000) + * 3. Fetch from relay + * 4. Profile fallback (if groupId is a pubkey) + * 5. Fallback to groupId as name + */ + async resolve( + relayUrl: string, + groupId: string, + options?: { skipFetch?: boolean }, + ): Promise { + const key = this.getKey(relayUrl, groupId); + + // 1. Check in-memory cache + const cached = this.cache.get(key); + if (cached) { + return cached; + } + + // 2. Check eventStore + const fromStore = await this.getFromEventStore(groupId); + if (fromStore) { + this.cache.set(key, fromStore.metadata); + return fromStore.metadata; + } + + // 3. Fetch from relay (unless skipped) + if (!options?.skipFetch) { + console.log(`[GroupMetadataCache] Fetching ${groupId} from ${relayUrl}`); + const event = await this.fetchFromRelay(relayUrl, groupId); + if (event) { + const metadata = extractFromEvent(groupId, event); + this.cache.set(key, metadata); + return metadata; + } + } + + // 4. Try profile fallback for pubkey-based groups + const profileMetadata = await this.resolveProfileFallback( + groupId, + relayUrl, + ); + if (profileMetadata) { + this.cache.set(key, profileMetadata); + return profileMetadata; + } + + // 5. Fallback + const fallback: GroupMetadata = { + name: groupId, + source: "fallback", + }; + this.cache.set(key, fallback); + return fallback; + } + + /** + * Sync resolve from cache or eventStore (no network) + * Returns undefined if not available + */ + getSync(relayUrl: string, groupId: string): GroupMetadata | undefined { + // Check in-memory cache first + const cached = this.get(relayUrl, groupId); + if (cached) { + return cached; + } + + // Can't do sync eventStore query, return undefined + return undefined; + } + + /** + * Update cache from a kind 39000 event + * Called when events are received via subscription + */ + updateFromEvent( + relayUrl: string, + event: NostrEvent, + ): GroupMetadata | undefined { + if (event.kind !== 39000) return undefined; + + const groupId = event.tags.find((t) => t[0] === "d")?.[1]; + if (!groupId) return undefined; + + const metadata = extractFromEvent(groupId, event); + this.set(relayUrl, groupId, metadata); + return metadata; + } + + /** + * Clear cache (useful for testing) + */ + clear(): void { + this.cache.clear(); + } +} + +// Singleton instance +const groupMetadataCache = new GroupMetadataCache(); + +export default groupMetadataCache;