From 9739b0f2c9ae52d29ad10a65207aff17518069a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 09:14:35 +0000 Subject: [PATCH] fix: prevent group name flicker in GroupListViewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group name flickered because metadata was resolved asynchronously on every mount with no caching. This caused names to change from groupId → actual name after the async resolution completed. Fix implements a multi-tier approach: 1. Load cached metadata from IndexedDB immediately on mount (fast path for returning users - prevents flicker on subsequent visits) 2. Extract names synchronously from kind 39000 events when available (no async needed for groups with NIP-29 metadata in eventStore) 3. Only run async profile resolution for groups that truly need it (groupId is a pubkey but no NIP-29 metadata available) 4. Cache all resolved metadata to IndexedDB for future visits Changes: - Add groupMetadata table to Dexie DB (version 18) - Add sync extraction helper (extractMetadataFromEvent) - Add cache load/save helpers (loadCachedGroupMetadata, cacheGroupMetadataBatch) - Refactor GroupListViewer to use tiered resolution with caching https://claude.ai/code/session_01CCxAcUsRBkWSL6as1wtFoA --- src/components/GroupListViewer.tsx | 157 +++++++++++++++++++++---- src/lib/chat/group-metadata-helpers.ts | 100 ++++++++++++++++ src/services/db.ts | 30 +++++ 3 files changed, 266 insertions(+), 21 deletions(-) diff --git a/src/components/GroupListViewer.tsx b/src/components/GroupListViewer.tsx index a45ca0b..88eedd4 100644 --- a/src/components/GroupListViewer.tsx +++ b/src/components/GroupListViewer.tsx @@ -17,6 +17,11 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; import { resolveGroupMetadata, + extractMetadataFromEvent, + loadCachedGroupMetadata, + cacheGroupMetadataBatch, + getGroupCacheKey, + isValidPubkey, type ResolvedGroupMetadata, } from "@/lib/chat/group-metadata-helpers"; @@ -341,39 +346,149 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { ); }, [groups]); - // Resolve metadata with profile fallback for groups without NIP-29 metadata + // Resolve metadata with a multi-tier approach to prevent flicker: + // 1. Load from IndexedDB cache immediately (fastest, prevents flicker on subsequent visits) + // 2. Use sync extraction from kind 39000 events (fast, no async) + // 3. Only async-resolve for groups needing profile fallback (slow, only when needed) const [resolvedMetadataMap, setResolvedMetadataMap] = useState< Map >(new Map()); + // Track if cache has been loaded to avoid flashing groupId before cache loads + const [cacheLoaded, setCacheLoaded] = useState(false); + + // Step 1: Load from IndexedDB cache immediately on mount (fast path for returning users) useEffect(() => { - if (groups.length === 0) return; + if (groups.length === 0) { + setCacheLoaded(true); + return; + } - const resolveAllMetadata = async () => { - const newResolvedMap = new Map(); + loadCachedGroupMetadata(groups).then((cached) => { + if (cached.size > 0) { + setResolvedMetadataMap((prev) => { + // Merge cached with existing (cached is baseline, existing may have fresher data) + const merged = new Map(cached); + for (const [key, value] of prev) { + merged.set(key, value); + } + return merged; + }); + } + setCacheLoaded(true); + }); + }, [groups]); - // Resolve metadata for each group - await Promise.all( - groups.map(async (group) => { - // Skip unmanaged groups - if (group.groupId === "_") return; + // Step 2 & 3: Sync extraction from kind 39000 + async profile fallback + useEffect(() => { + if (groups.length === 0 || !cacheLoaded) return; - const existingMetadata = groupMetadataMap?.get(group.groupId); - const resolved = await resolveGroupMetadata( - group.groupId, - group.relayUrl, - existingMetadata, + // Determine which groups need async resolution (no NIP-29 metadata, groupId is pubkey) + const needsAsyncResolution: Array<{ groupId: string; relayUrl: string }> = + []; + + for (const group of groups) { + if (group.groupId === "_") continue; + + const metadataEvent = groupMetadataMap?.get(group.groupId); + if (!metadataEvent && isValidPubkey(group.groupId)) { + needsAsyncResolution.push(group); + } + } + + // 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); + + 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), ); + } 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 cached value (don't overwrite) + } - newResolvedMap.set(`${group.relayUrl}'${group.groupId}`, resolved); - }), - ); + return updated; + }); - setResolvedMetadataMap(newResolvedMap); - }; + // 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; + }> = []; - resolveAllMetadata(); - }, [groups, groupMetadataMap]); + 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; + }); + + // Cache the resolved metadata for future visits + cacheGroupMetadataBatch(resolved); + } + }; + + resolveProfileMetadata(); + } + + // Cache sync-resolved NIP-29 metadata + const toCache: Array<{ + relayUrl: string; + groupId: string; + metadata: ResolvedGroupMetadata; + }> = []; + + for (const group of groups) { + if (group.groupId === "_") continue; + + const metadataEvent = groupMetadataMap?.get(group.groupId); + if (metadataEvent && metadataEvent.kind === 39000) { + toCache.push({ + relayUrl: group.relayUrl, + groupId: group.groupId, + metadata: extractMetadataFromEvent(group.groupId, metadataEvent), + }); + } + } + + if (toCache.length > 0) { + cacheGroupMetadataBatch(toCache); + } + }, [groups, groupMetadataMap, cacheLoaded]); // 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/group-metadata-helpers.ts b/src/lib/chat/group-metadata-helpers.ts index 734881e..f33a72e 100644 --- a/src/lib/chat/group-metadata-helpers.ts +++ b/src/lib/chat/group-metadata-helpers.ts @@ -2,6 +2,7 @@ import { firstValueFrom } from "rxjs"; import { kinds } from "nostr-tools"; import { profileLoader } from "@/services/loaders"; import { getProfileContent } from "applesauce-core/helpers"; +import db, { type CachedGroupMetadata } from "@/services/db"; import type { NostrEvent } from "@/types/nostr"; /** @@ -21,6 +22,105 @@ export interface ResolvedGroupMetadata { 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 + * This is the fast path - no async needed when we have the metadata event + */ +export function extractMetadataFromEvent( + groupId: string, + metadataEvent: NostrEvent, +): ResolvedGroupMetadata { + 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", + }; +} + +/** + * Load cached group metadata from IndexedDB + * Returns a Map of cache key -> metadata for fast lookups + */ +export async function loadCachedGroupMetadata( + groups: Array<{ groupId: string; relayUrl: string }>, +): Promise> { + const keys = groups.map((g) => getGroupCacheKey(g.relayUrl, g.groupId)); + const cached = await db.groupMetadata.bulkGet(keys); + + const map = new Map(); + for (const entry of cached) { + if (entry) { + map.set(entry.key, { + name: entry.name, + description: entry.description, + icon: entry.icon, + source: entry.source, + }); + } + } + return map; +} + +/** + * Save resolved group metadata to IndexedDB cache + */ +export async function cacheGroupMetadata( + relayUrl: string, + groupId: string, + metadata: ResolvedGroupMetadata, +): Promise { + const key = getGroupCacheKey(relayUrl, groupId); + const entry: CachedGroupMetadata = { + key, + groupId, + relayUrl, + name: metadata.name, + description: metadata.description, + icon: metadata.icon, + source: metadata.source, + updatedAt: Date.now(), + }; + await db.groupMetadata.put(entry); +} + +/** + * Batch save multiple group metadata entries + */ +export async function cacheGroupMetadataBatch( + entries: Array<{ + relayUrl: string; + groupId: string; + metadata: ResolvedGroupMetadata; + }>, +): Promise { + const now = Date.now(); + const records: CachedGroupMetadata[] = entries.map( + ({ relayUrl, groupId, metadata }) => ({ + key: getGroupCacheKey(relayUrl, groupId), + groupId, + relayUrl, + name: metadata.name, + description: metadata.description, + icon: metadata.icon, + source: metadata.source, + updatedAt: now, + }), + ); + await db.groupMetadata.bulkPut(records); +} + /** * Resolve group metadata with profile fallback * diff --git a/src/services/db.ts b/src/services/db.ts index 4dd8206..c2f1942 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -108,6 +108,17 @@ export interface GrimoireZap { comment?: string; // Optional zap comment/message } +export interface CachedGroupMetadata { + key: string; // Primary key: "relayUrl'groupId" + groupId: string; + relayUrl: string; + name?: string; + description?: string; + icon?: string; + source: "nip29" | "profile" | "fallback"; + updatedAt: number; +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -121,6 +132,7 @@ class GrimoireDb extends Dexie { spellbooks!: Table; lnurlCache!: Table; grimoireZaps!: Table; + groupMetadata!: Table; constructor(name: string) { super(name); @@ -388,6 +400,24 @@ class GrimoireDb extends Dexie { grimoireZaps: "&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]", }); + + // Version 18: Add group metadata caching to prevent name flicker + this.version(18).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + lnurlCache: "&address, fetchedAt", + grimoireZaps: + "&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]", + groupMetadata: "&key, groupId, relayUrl, updatedAt", + }); } }