refactor: use observable-based cache API for group metadata

- Add metadata$() method to GroupMetadataCache that returns BehaviorSubject
- Subscribers get immediate cached value + automatic updates when resolved
- Use combineLatest in GroupListViewer to reactively merge metadata
- Remove cacheVersion hack - observable pattern handles re-renders naturally
- Simplify fetchFromRelay with RxJS operators (filter, take, timeout, catchError)
- Remove unused getSync() method and console.log statements
- Add in-flight request deduplication to prevent duplicate fetches

https://claude.ai/code/session_01CCxAcUsRBkWSL6as1wtFoA
This commit is contained in:
Claude
2026-01-30 16:37:59 +00:00
parent 49f52ed864
commit b5d044cae8
2 changed files with 152 additions and 144 deletions

View File

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

View File

@@ -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<string, GroupMetadata>();
// Observable subjects for reactive updates
private subjects = new Map<
string,
BehaviorSubject<GroupMetadata | undefined>
>();
// Track in-flight resolves to avoid duplicate fetches
private resolving = new Map<string, Promise<GroupMetadata>>();
/**
* 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<GroupMetadata | undefined> {
const key = this.getKey(relayUrl, groupId);
if (!this.subjects.has(key)) {
// Create subject with current cached value (may be undefined)
const subject = new BehaviorSubject<GroupMetadata | undefined>(
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<NostrEvent | undefined> {
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<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];
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<GroupMetadata | undefined> {
@@ -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<GroupMetadata> {
// 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();
}
}