mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user