diff --git a/src/components/GroupListViewer.tsx b/src/components/GroupListViewer.tsx index 053cc59..90c0bb1 100644 --- a/src/components/GroupListViewer.tsx +++ b/src/components/GroupListViewer.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, memo, useCallback, useEffect } from "react"; import { use$ } from "applesauce-react/hooks"; +import { combineLatest, of } from "rxjs"; import { map } from "rxjs/operators"; import { Loader2, PanelLeft } from "lucide-react"; import eventStore from "@/services/event-store"; @@ -340,31 +341,6 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { ); }, [groups]); - // Track when cache has been populated (triggers re-render after async resolves) - const [cacheVersion, setCacheVersion] = useState(0); - - // Background resolve for all groups - fire-and-forget, triggers re-render when complete - useEffect(() => { - if (groups.length === 0) return; - - let mounted = true; - - Promise.all( - groups.map(async (g) => { - if (g.groupId === "_") return; - await groupMetadataCache.resolve(g.relayUrl, g.groupId); - }), - ).then(() => { - if (mounted) { - setCacheVersion((v) => v + 1); - } - }); - - return () => { - mounted = false; - }; - }, [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(() => { @@ -391,69 +367,68 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { }; }, [groups]); - // Load latest messages and merge with group data + // Load latest messages and merge with group data using reactive metadata observables const groupsWithRecency = use$(() => { - if (groups.length === 0) return undefined; + if (groups.length === 0) return of(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(); - 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); - } + // Timeline of latest messages + const messages$ = eventStore.timeline( + groupIds.map((groupId) => ({ + kinds: [9], + "#h": [groupId], + limit: 1, + })), + ); + + // Metadata observables for each group (reactive, auto-updates when resolved) + const metadata$ = + groups.length > 0 + ? combineLatest( + groups.map((g) => + g.groupId === "_" + ? of(undefined) + : groupMetadataCache.metadata$(g.relayUrl, g.groupId), + ), + ) + : of([] as (GroupMetadata | undefined)[]); + + // Combine messages and metadata + return combineLatest([messages$, metadata$]).pipe( + map(([events, metadatas]) => { + // Create a map of groupId -> latest message + const messageMap = new Map(); + 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 - get metadata inline from cache - const groupsWithInfo: GroupInfo[] = groups.map((g) => { - const metadataEvent = groupMetadataMap?.get(g.groupId); - // Update cache from event if available, otherwise get from cache - const resolvedMetadata = - g.groupId === "_" - ? undefined - : metadataEvent - ? groupMetadataCache.updateFromEvent( - g.relayUrl, - metadataEvent, - ) - : groupMetadataCache.get(g.relayUrl, g.groupId); + // Merge with groups + const groupsWithInfo: GroupInfo[] = groups.map((g, i) => ({ + groupId: g.groupId, + relayUrl: g.relayUrl, + metadata: groupMetadataMap?.get(g.groupId), + lastMessage: messageMap.get(g.groupId), + resolvedMetadata: metadatas[i], + })); - return { - groupId: g.groupId, - relayUrl: g.relayUrl, - metadata: metadataEvent, - lastMessage: messageMap.get(g.groupId), - resolvedMetadata, - }; - }); + // 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; + }); - // 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; - }), - ); - // cacheVersion triggers re-render when async resolves complete - }, [groups, groupMetadataMap, cacheVersion]); + return groupsWithInfo; + }), + ); + }, [groups, groupMetadataMap]); // Only require sign-in if no identifier is provided (viewing own groups) if (!targetPubkey) { diff --git a/src/services/group-metadata-cache.ts b/src/services/group-metadata-cache.ts index 5168dfb..6790e5e 100644 --- a/src/services/group-metadata-cache.ts +++ b/src/services/group-metadata-cache.ts @@ -1,5 +1,6 @@ -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { BehaviorSubject, firstValueFrom, type Observable } from "rxjs"; +import { filter, first, take, timeout, catchError } from "rxjs/operators"; +import { of } from "rxjs"; import { kinds, type Filter } from "nostr-tools"; import { getProfileContent, @@ -53,11 +54,22 @@ function extractFromEvent(groupId: string, event: NostrEvent): GroupMetadata { * * Provides a shared cache between GroupListViewer and NIP-29 adapter. * Checks eventStore first, then fetches from relay if needed. + * + * Exposes observables via metadata$() for reactive UI updates. */ class GroupMetadataCache { // In-memory cache: "relayUrl'groupId" -> metadata private cache = new Map(); + // Observable subjects for reactive updates + private subjects = new Map< + string, + BehaviorSubject + >(); + + // Track in-flight resolves to avoid duplicate fetches + private resolving = new Map>(); + /** * Get cache key for a group */ @@ -73,17 +85,51 @@ class GroupMetadataCache { } /** - * Set metadata in cache + * Set metadata in cache and notify subscribers */ set(relayUrl: string, groupId: string, metadata: GroupMetadata): void { - this.cache.set(this.getKey(relayUrl, groupId), metadata); + const key = this.getKey(relayUrl, groupId); + this.cache.set(key, metadata); + // Notify any subscribers + this.subjects.get(key)?.next(metadata); + } + + /** + * Get reactive observable for a group's metadata + * + * Returns a BehaviorSubject that: + * - Emits cached value immediately (or undefined if not cached) + * - Emits resolved value when async resolution completes + * + * Use with use$() in React components for automatic re-renders. + */ + metadata$( + relayUrl: string, + groupId: string, + ): Observable { + const key = this.getKey(relayUrl, groupId); + + if (!this.subjects.has(key)) { + // Create subject with current cached value (may be undefined) + const subject = new BehaviorSubject( + this.cache.get(key), + ); + this.subjects.set(key, subject); + + // Trigger background resolution if not already cached + if (!this.cache.has(key)) { + this.resolve(relayUrl, groupId); + } + } + + return this.subjects.get(key)!.asObservable(); } /** * Check eventStore for cached kind 39000 event and extract metadata * Returns undefined if not in store */ - async getFromEventStore( + private async getFromEventStore( groupId: string, ): Promise<{ event: NostrEvent; metadata: GroupMetadata } | undefined> { const events = await firstValueFrom( @@ -105,55 +151,37 @@ class GroupMetadataCache { /** * Fetch metadata from relay (adds to eventStore automatically) */ - async fetchFromRelay( + private async fetchFromRelay( relayUrl: string, groupId: string, timeoutMs = 5000, ): Promise { - const filter: Filter = { + const filterDef: 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]; + try { + const event = await firstValueFrom( + pool.subscription([relayUrl], [filterDef], { eventStore }).pipe( + filter((r): r is NostrEvent => typeof r !== "string"), + take(1), + timeout(timeoutMs), + catchError(() => of(undefined as NostrEvent | undefined)), + ), + { defaultValue: undefined }, + ); + return event; + } catch { + return undefined; + } } /** * Resolve profile metadata for pubkey-based group IDs */ - async resolveProfileFallback( + private async resolveProfileFallback( groupId: string, relayUrl: string, ): Promise { @@ -185,11 +213,8 @@ class GroupMetadataCache { }; } } - } catch (error) { - console.warn( - `[GroupMetadataCache] Profile fallback failed for ${groupId.slice(0, 8)}:`, - error, - ); + } catch { + // Profile fallback failed, continue to next fallback } return undefined; @@ -218,20 +243,40 @@ class GroupMetadataCache { return cached; } + // Deduplicate in-flight resolves + const existing = this.resolving.get(key); + if (existing) { + return existing; + } + + const resolvePromise = this.doResolve(relayUrl, groupId, options); + this.resolving.set(key, resolvePromise); + + try { + return await resolvePromise; + } finally { + this.resolving.delete(key); + } + } + + private async doResolve( + relayUrl: string, + groupId: string, + options?: { skipFetch?: boolean }, + ): Promise { // 2. Check eventStore const fromStore = await this.getFromEventStore(groupId); if (fromStore) { - this.cache.set(key, fromStore.metadata); + this.set(relayUrl, groupId, 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); + this.set(relayUrl, groupId, metadata); return metadata; } } @@ -242,7 +287,7 @@ class GroupMetadataCache { relayUrl, ); if (profileMetadata) { - this.cache.set(key, profileMetadata); + this.set(relayUrl, groupId, profileMetadata); return profileMetadata; } @@ -251,25 +296,10 @@ class GroupMetadataCache { name: groupId, source: "fallback", }; - this.cache.set(key, fallback); + this.set(relayUrl, groupId, 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 @@ -293,6 +323,9 @@ class GroupMetadataCache { */ clear(): void { this.cache.clear(); + this.subjects.forEach((s) => s.complete()); + this.subjects.clear(); + this.resolving.clear(); } }