mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user