From fc56dc93886eb03e0da9ceef2ccf4de2c888238b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Jan 2026 11:12:29 +0000 Subject: [PATCH] feat: Display blossom servers in user menu with caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements caching and display of user's blossom server lists (kind 10063) in the user menu dropdown. **Key Changes:** 1. **Database Schema (db.ts)**: - Added `CachedBlossomServerList` interface - Added `blossomServers` table to Dexie v15 - Indexed by pubkey and updatedAt for efficient querying 2. **Caching Service (blossom-server-cache.ts)**: - Dual-layer caching: LRU memory cache (100 entries) + Dexie persistent cache - 24-hour TTL to reduce network requests - Auto-caches kind:10063 events from EventStore - Mirrors relay-list-cache pattern for consistency 3. **Sync Hooks**: - `useBlossomServerCacheSync`: Subscribes to EventStore for auto-caching kind:10063 events - `useAccountSync`: Extended to fetch and watch active account's blossom servers 4. **State Management**: - Added `blossomServers` field to `activeAccount` in GrimoireState - Created `setActiveAccountBlossomServers` pure function and callback - Reactive updates when user's server list changes 5. **User Menu UI (user-menu.tsx)**: - Added blossom servers section with HardDrive icon - Shows server count badge (e.g., "3 servers") - Clickable links open servers in new tabs - Displays only when user has configured servers **Architecture:** - Follows existing relay list caching pattern for consistency - Reactive: UI auto-updates when kind:10063 events arrive - Incremental sync: Fetches on login, caches for 24h - Performance: Memory cache for <1ms lookups, Dexie for persistence **Testing:** - Build: ✓ No TypeScript errors - Tests: ✓ All 838 tests passing --- src/components/layouts/AppShell.tsx | 4 + src/components/nostr/user-menu.tsx | 33 ++- src/core/logic.ts | 25 +++ src/core/state.ts | 10 + src/hooks/useAccountSync.ts | 46 +++- src/hooks/useBlossomServerCacheSync.ts | 24 +++ src/services/blossom-server-cache.ts | 285 +++++++++++++++++++++++++ src/services/db.ts | 22 ++ src/types/app.ts | 1 + 9 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useBlossomServerCacheSync.ts create mode 100644 src/services/blossom-server-cache.ts diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx index 946ba81..3f82958 100644 --- a/src/components/layouts/AppShell.tsx +++ b/src/components/layouts/AppShell.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, ReactNode } from "react"; import { Terminal } from "lucide-react"; import { useAccountSync } from "@/hooks/useAccountSync"; import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync"; +import { useBlossomServerCacheSync } from "@/hooks/useBlossomServerCacheSync"; import { useRelayState } from "@/hooks/useRelayState"; import relayStateManager from "@/services/relay-state-manager"; import { TabBar } from "../TabBar"; @@ -25,6 +26,9 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) { // Auto-cache kind:10002 relay lists from EventStore to Dexie useRelayListCacheSync(); + // Auto-cache kind:10063 blossom server lists from EventStore to Dexie + useBlossomServerCacheSync(); + // Initialize global relay state manager useEffect(() => { relayStateManager.initialize().catch((err) => { diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index f6dd4ed..ac3ae8f 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -1,4 +1,4 @@ -import { User } from "lucide-react"; +import { User, HardDrive, ExternalLink } from "lucide-react"; import accounts from "@/services/accounts"; import { useProfile } from "@/hooks/useProfile"; import { use$ } from "applesauce-react/hooks"; @@ -54,6 +54,7 @@ export default function UserMenu() { const account = use$(accounts.active$); const { state, addWindow } = useGrimoire(); const relays = state.activeAccount?.relays; + const blossomServers = state.activeAccount?.blossomServers; const [showSettings, setShowSettings] = useState(false); const [showLogin, setShowLogin] = useState(false); @@ -123,6 +124,36 @@ export default function UserMenu() { )} + {blossomServers && blossomServers.length > 0 && ( + <> + + + + + Blossom Servers + ({blossomServers.length}) + + {blossomServers.map((server) => ( + + + + {server} + + + ))} + + + )} + {/* setShowSettings(true)} diff --git a/src/core/logic.ts b/src/core/logic.ts index 9694947..349a520 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -295,6 +295,31 @@ export const setActiveAccountRelays = ( }; }; +/** + * Updates the blossom server list for the active account. + */ +export const setActiveAccountBlossomServers = ( + state: GrimoireState, + blossomServers: string[], +): GrimoireState => { + if (!state.activeAccount) { + return state; + } + + // If blossom servers reference hasn't changed, return state unchanged + if (state.activeAccount.blossomServers === blossomServers) { + return state; + } + + return { + ...state, + activeAccount: { + ...state.activeAccount, + blossomServers, + }, + }; +}; + /** * Deletes a workspace by ID. * Cannot delete the last remaining workspace. diff --git a/src/core/state.ts b/src/core/state.ts index dd5e37f..a23302b 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -275,6 +275,15 @@ export const useGrimoire = () => { [setState], ); + const setActiveAccountBlossomServers = useCallback( + (blossomServers: string[]) => { + setState((prev) => + Logic.setActiveAccountBlossomServers(prev, blossomServers), + ); + }, + [setState], + ); + const updateLayoutConfig = useCallback( (layoutConfig: Partial) => { setState((prev) => Logic.updateLayoutConfig(prev, layoutConfig)); @@ -351,6 +360,7 @@ export const useGrimoire = () => { setActiveWorkspace, setActiveAccount, setActiveAccountRelays, + setActiveAccountBlossomServers, updateLayoutConfig, applyPresetLayout, updateWorkspaceLabel, diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts index fe4bd60..bc7f96f 100644 --- a/src/hooks/useAccountSync.ts +++ b/src/hooks/useAccountSync.ts @@ -5,12 +5,17 @@ import { useGrimoire } from "@/core/state"; import { addressLoader } from "@/services/loaders"; import type { RelayInfo } from "@/types/app"; import { normalizeRelayURL } from "@/lib/relay-url"; +import { getServersFromEvent } from "@/services/blossom"; /** - * Hook that syncs active account with Grimoire state and fetches relay lists + * Hook that syncs active account with Grimoire state and fetches relay lists and blossom servers */ export function useAccountSync() { - const { setActiveAccount, setActiveAccountRelays } = useGrimoire(); + const { + setActiveAccount, + setActiveAccountRelays, + setActiveAccountBlossomServers, + } = useGrimoire(); const eventStore = useEventStore(); // Watch active account from accounts service @@ -83,4 +88,41 @@ export function useAccountSync() { storeSubscription.unsubscribe(); }; }, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]); + + // Fetch and watch blossom server list (kind 10063) when account changes + useEffect(() => { + if (!activeAccount?.pubkey) { + return; + } + + const pubkey = activeAccount.pubkey; + let lastBlossomEventId: string | undefined; + + // Subscribe to kind 10063 (blossom server list) + const subscription = addressLoader({ + kind: 10063, + pubkey, + identifier: "", + }).subscribe(); + + // Watch for blossom server list event in store + const storeSubscription = eventStore + .replaceable(10063, pubkey, "") + .subscribe((blossomListEvent) => { + if (!blossomListEvent) return; + + // Only process if this is a new event + if (blossomListEvent.id === lastBlossomEventId) return; + lastBlossomEventId = blossomListEvent.id; + + // Parse servers from event + const servers = getServersFromEvent(blossomListEvent); + setActiveAccountBlossomServers(servers); + }); + + return () => { + subscription.unsubscribe(); + storeSubscription.unsubscribe(); + }; + }, [activeAccount?.pubkey, eventStore, setActiveAccountBlossomServers]); } diff --git a/src/hooks/useBlossomServerCacheSync.ts b/src/hooks/useBlossomServerCacheSync.ts new file mode 100644 index 0000000..68aacfe --- /dev/null +++ b/src/hooks/useBlossomServerCacheSync.ts @@ -0,0 +1,24 @@ +/** + * Hook to keep blossom server cache in sync with EventStore + * + * Subscribes to kind:10063 events and automatically caches them in Dexie. + * Should be used once at app root level. + */ + +import { useEffect } from "react"; +import { useEventStore } from "applesauce-react/hooks"; +import blossomServerCache from "@/services/blossom-server-cache"; + +export function useBlossomServerCacheSync() { + const eventStore = useEventStore(); + + useEffect(() => { + // Subscribe to EventStore for auto-caching + blossomServerCache.subscribeToEventStore(eventStore); + + // Cleanup on unmount + return () => { + blossomServerCache.unsubscribe(); + }; + }, [eventStore]); +} diff --git a/src/services/blossom-server-cache.ts b/src/services/blossom-server-cache.ts new file mode 100644 index 0000000..23ddc09 --- /dev/null +++ b/src/services/blossom-server-cache.ts @@ -0,0 +1,285 @@ +/** + * Blossom Server Cache Service + * + * Caches BUD-03 blossom server lists (kind:10063) in Dexie for fast access. + * Reduces network requests and improves cold start performance. + * + * Auto-caches kind:10063 events from EventStore when subscribed. + */ + +import type { NostrEvent } from "@/types/nostr"; +import { getServersFromEvent } from "./blossom"; +import db, { CachedBlossomServerList } from "./db"; +import type { IEventStore } from "applesauce-core/event-store"; +import type { Subscription } from "rxjs"; + +const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours +const MAX_MEMORY_CACHE = 100; // LRU cache size + +class BlossomServerCache { + private eventStoreSubscription: Subscription | null = null; + private memoryCache = new Map(); + private cacheOrder: string[] = []; + + /** + * Subscribe to EventStore to auto-cache kind:10063 events + */ + subscribeToEventStore(eventStore: IEventStore): void { + if (this.eventStoreSubscription) { + console.warn("[BlossomServerCache] Already subscribed to EventStore"); + return; + } + + // Subscribe to kind:10063 events + this.eventStoreSubscription = eventStore + .filters({ kinds: [10063] }) + .subscribe((event: NostrEvent) => { + // Cache each kind:10063 event as it arrives + this.set(event); + }); + + console.log( + "[BlossomServerCache] Subscribed to EventStore for kind:10063 events", + ); + } + + /** + * Unsubscribe from EventStore + */ + unsubscribe(): void { + if (this.eventStoreSubscription) { + this.eventStoreSubscription.unsubscribe(); + this.eventStoreSubscription = null; + console.log("[BlossomServerCache] Unsubscribed from EventStore"); + } + } + + /** + * Get cached blossom server list for a pubkey + * Returns undefined if not cached or stale + */ + async get(pubkey: string): Promise { + try { + const cached = await db.blossomServers.get(pubkey); + + // Check if stale (>24 hours) + if (cached && Date.now() - cached.updatedAt < CACHE_TTL) { + return cached; + } + + // Stale or not found + if (cached) { + console.debug( + `[BlossomServerCache] Cached server list for ${pubkey.slice(0, 8)} is stale (${Math.floor((Date.now() - cached.updatedAt) / 1000 / 60)}min old)`, + ); + } + + return undefined; + } catch (error) { + console.error( + `[BlossomServerCache] Error reading cache for ${pubkey.slice(0, 8)}:`, + error, + ); + return undefined; + } + } + + /** + * Store blossom server list event in cache + */ + async set(event: NostrEvent): Promise { + try { + if (event.kind !== 10063) { + console.warn( + `[BlossomServerCache] Attempted to cache non-10063 event (kind ${event.kind})`, + ); + return; + } + + // Parse servers from event + const servers = getServersFromEvent(event); + + // Store in Dexie and memory cache + const cachedEntry: CachedBlossomServerList = { + pubkey: event.pubkey, + event, + servers, + updatedAt: Date.now(), + }; + + await db.blossomServers.put(cachedEntry); + + // Also populate memory cache + this.memoryCache.set(event.pubkey, cachedEntry); + this.cacheOrder.push(event.pubkey); + this.evictOldest(); + + console.debug( + `[BlossomServerCache] Cached ${servers.length} server(s) for ${event.pubkey.slice(0, 8)}`, + ); + } catch (error) { + console.error( + `[BlossomServerCache] Error caching server list for ${event.pubkey.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Update LRU order for a pubkey + */ + private updateLRU(pubkey: string): void { + const index = this.cacheOrder.indexOf(pubkey); + if (index > -1) { + this.cacheOrder.splice(index, 1); + } + this.cacheOrder.push(pubkey); + } + + /** + * Evict oldest entries from memory cache if over limit + */ + private evictOldest(): void { + while (this.cacheOrder.length > MAX_MEMORY_CACHE) { + const oldest = this.cacheOrder.shift(); + if (oldest) { + this.memoryCache.delete(oldest); + } + } + } + + /** + * Get blossom servers from memory cache only (synchronous, fast) + * Used for real-time operations where async Dexie lookup would be too slow + * Returns null if not in memory cache + */ + getServersSync(pubkey: string): string[] | null { + const memCached = this.memoryCache.get(pubkey); + if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { + this.updateLRU(pubkey); + return memCached.servers; + } + return null; + } + + /** + * Get blossom servers for a pubkey from cache + */ + async getServers(pubkey: string): Promise { + // Check memory cache first (< 1ms) + const memCached = this.memoryCache.get(pubkey); + if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { + this.updateLRU(pubkey); + return memCached.servers; + } + + // Then check Dexie (5-10ms) + const cached = await this.get(pubkey); + if (cached) { + // Populate memory cache for next time + this.memoryCache.set(pubkey, cached); + this.cacheOrder.push(pubkey); + this.evictOldest(); + return cached.servers; + } + + return null; + } + + /** + * Check if we have a valid cache entry for a pubkey + */ + async has(pubkey: string): Promise { + const cached = await this.get(pubkey); + return cached !== undefined; + } + + /** + * Invalidate (delete) cache entry for a pubkey + */ + async invalidate(pubkey: string): Promise { + try { + await db.blossomServers.delete(pubkey); + // Also remove from memory cache + this.memoryCache.delete(pubkey); + const index = this.cacheOrder.indexOf(pubkey); + if (index > -1) { + this.cacheOrder.splice(index, 1); + } + console.debug( + `[BlossomServerCache] Invalidated cache for ${pubkey.slice(0, 8)}`, + ); + } catch (error) { + console.error( + `[BlossomServerCache] Error invalidating cache for ${pubkey.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Clear all cached blossom server lists + */ + async clear(): Promise { + try { + await db.blossomServers.clear(); + // Also clear memory cache + this.memoryCache.clear(); + this.cacheOrder = []; + console.log("[BlossomServerCache] Cleared all cached server lists"); + } catch (error) { + console.error("[BlossomServerCache] Error clearing cache:", error); + } + } + + /** + * Get cache statistics + */ + async getStats(): Promise<{ + count: number; + oldestEntry: number | null; + newestEntry: number | null; + memoryCacheSize: number; + memoryCacheLimit: number; + }> { + try { + const count = await db.blossomServers.count(); + const all = await db.blossomServers.toArray(); + + if (all.length === 0) { + return { + count: 0, + oldestEntry: null, + newestEntry: null, + memoryCacheSize: this.memoryCache.size, + memoryCacheLimit: MAX_MEMORY_CACHE, + }; + } + + const timestamps = all.map((entry) => entry.updatedAt); + const oldest = Math.min(...timestamps); + const newest = Math.max(...timestamps); + + return { + count, + oldestEntry: oldest, + newestEntry: newest, + memoryCacheSize: this.memoryCache.size, + memoryCacheLimit: MAX_MEMORY_CACHE, + }; + } catch (error) { + console.error("[BlossomServerCache] Error getting stats:", error); + return { + count: 0, + oldestEntry: null, + newestEntry: null, + memoryCacheSize: this.memoryCache.size, + memoryCacheLimit: MAX_MEMORY_CACHE, + }; + } + } +} + +// Singleton instance +export const blossomServerCache = new BlossomServerCache(); +export default blossomServerCache; diff --git a/src/services/db.ts b/src/services/db.ts index 0bb2fa9..6e9ee79 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -54,6 +54,13 @@ export interface RelayLivenessEntry { backoffUntil?: number; } +export interface CachedBlossomServerList { + pubkey: string; + event: NostrEvent; + servers: string[]; + updatedAt: number; +} + export interface LocalSpell { id: string; // UUID for local-only spells, or event ID for published spells alias?: string; // Optional local-only quick name (e.g., "btc") @@ -88,6 +95,7 @@ class GrimoireDb extends Dexie { relayAuthPreferences!: Table; relayLists!: Table; relayLiveness!: Table; + blossomServers!: Table; spells!: Table; spellbooks!: Table; @@ -311,6 +319,20 @@ class GrimoireDb extends Dexie { spells: "&id, alias, createdAt, isPublished, deletedAt", spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", }); + + // Version 15: Add blossom server list caching + this.version(15).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", + }); } } diff --git a/src/types/app.ts b/src/types/app.ts index 6c42a94..ac99533 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -87,6 +87,7 @@ export interface GrimoireState { activeAccount?: { pubkey: string; relays?: RelayInfo[]; + blossomServers?: string[]; }; compactModeKinds?: number[]; locale?: {