mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
302
src/services/group-metadata-cache.ts
Normal file
302
src/services/group-metadata-cache.ts
Normal 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;
|
||||
Reference in New Issue
Block a user