fix: prevent group name flicker in GroupListViewer

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
This commit is contained in:
Claude
2026-01-30 09:14:35 +00:00
parent 121fbb7654
commit 9739b0f2c9
3 changed files with 266 additions and 21 deletions

View File

@@ -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<string, ResolvedGroupMetadata>
>(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<string, ResolvedGroupMetadata>();
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)

View File

@@ -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<Map<string, ResolvedGroupMetadata>> {
const keys = groups.map((g) => getGroupCacheKey(g.relayUrl, g.groupId));
const cached = await db.groupMetadata.bulkGet(keys);
const map = new Map<string, ResolvedGroupMetadata>();
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<void> {
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<void> {
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
*

View File

@@ -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<Profile>;
nip05!: Table<Nip05>;
@@ -121,6 +132,7 @@ class GrimoireDb extends Dexie {
spellbooks!: Table<LocalSpellbook>;
lnurlCache!: Table<LnurlCache>;
grimoireZaps!: Table<GrimoireZap>;
groupMetadata!: Table<CachedGroupMetadata>;
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",
});
}
}