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
This commit is contained in:
Claude
2026-01-30 13:46:15 +00:00
parent 824ba552de
commit 343058efbd
4 changed files with 372 additions and 295 deletions

View File

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

View File

@@ -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<void>((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})`);

View File

@@ -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<ResolvedGroupMetadata> {
// 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",
};
}

View File

@@ -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<string, GroupMetadata>();
/**
* 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<NostrEvent | undefined> {
const filter: Filter = {
kinds: [39000],
"#d": [groupId],
limit: 1,
};
const events: NostrEvent[] = [];
const subscription = pool.subscription([relayUrl], [filter], {
eventStore,
});
await new Promise<void>((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<GroupMetadata | undefined> {
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<GroupMetadata> {
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;