diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx index 5449aa3..11d7b88 100644 --- a/src/components/layouts/AppShell.tsx +++ b/src/components/layouts/AppShell.tsx @@ -1,11 +1,11 @@ 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 { useReplaceableEventCacheSync } from "@/hooks/useReplaceableEventCacheSync"; import { useRelayState } from "@/hooks/useRelayState"; +import { useEventStore } from "applesauce-react/hooks"; import relayStateManager from "@/services/relay-state-manager"; +import replaceableEventCache from "@/services/replaceable-event-cache"; import { TabBar } from "../TabBar"; import CommandLauncher from "../CommandLauncher"; import { GlobalAuthPrompt } from "../GlobalAuthPrompt"; @@ -20,19 +20,21 @@ interface AppShellProps { export function AppShell({ children, hideBottomBar = false }: AppShellProps) { const [commandLauncherOpen, setCommandLauncherOpen] = useState(false); + const eventStore = useEventStore(); - // Sync active account and fetch relay lists - useAccountSync(); - - // Auto-cache kind:10002 relay lists from EventStore to Dexie - useRelayListCacheSync(); - - // Auto-cache kind:10063 blossom server lists from EventStore to Dexie - useBlossomServerCacheSync(); + // Hydrate EventStore from Dexie cache on startup (solves orphaned cache problem) + useEffect(() => { + replaceableEventCache.hydrateEventStore(eventStore).catch((err) => { + console.error("Failed to hydrate EventStore from cache:", err); + }); + }, [eventStore]); // Auto-cache generic replaceable events (contacts, relay lists, blossom servers, emoji lists, etc.) useReplaceableEventCacheSync(); + // Sync active account and fetch configured kinds + useAccountSync(); + // Initialize global relay state manager useEffect(() => { relayStateManager.initialize().catch((err) => { diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts index bc7f96f..8858628 100644 --- a/src/hooks/useAccountSync.ts +++ b/src/hooks/useAccountSync.ts @@ -1,14 +1,18 @@ import { useEffect } from "react"; import { useEventStore, use$ } from "applesauce-react/hooks"; +import type { Subscription } from "rxjs"; import accounts from "@/services/accounts"; import { useGrimoire } from "@/core/state"; import { addressLoader } from "@/services/loaders"; +import { ACTIVE_USER_KINDS } from "@/services/replaceable-event-cache"; import type { RelayInfo } from "@/types/app"; import { normalizeRelayURL } from "@/lib/relay-url"; import { getServersFromEvent } from "@/services/blossom"; +import type { NostrEvent } from "@/types/nostr"; /** - * Hook that syncs active account with Grimoire state and fetches relay lists and blossom servers + * Hook that syncs active account with Grimoire state and fetches configured replaceable events + * Automatically fetches and watches all kinds in ACTIVE_USER_KINDS */ export function useAccountSync() { const { @@ -26,103 +30,93 @@ export function useAccountSync() { setActiveAccount(activeAccount?.pubkey); }, [activeAccount?.pubkey, setActiveAccount]); - // Fetch and watch relay list (kind 10002) when account changes + // Fetch and watch all configured kinds for active user useEffect(() => { if (!activeAccount?.pubkey) { return; } const pubkey = activeAccount.pubkey; - let lastRelayEventId: string | undefined; + const subscriptions: Subscription[] = []; + const lastEventIds = new Map(); - // Subscribe to kind 10002 (relay list) - const subscription = addressLoader({ - kind: 10002, - pubkey, - identifier: "", - }).subscribe(); + // Subscribe to all configured kinds + for (const kind of ACTIVE_USER_KINDS) { + // Fetch from relays + const fetchSub = addressLoader({ + kind, + pubkey, + identifier: "", + }).subscribe(); - // Watch for relay list event in store - const storeSubscription = eventStore - .replaceable(10002, pubkey, "") - .subscribe((relayListEvent) => { - if (!relayListEvent) return; + // Watch for updates in EventStore + const storeSub = eventStore + .replaceable(kind, pubkey, "") + .subscribe((event: NostrEvent | undefined) => { + if (!event) return; - // Only process if this is a new event - if (relayListEvent.id === lastRelayEventId) return; - lastRelayEventId = relayListEvent.id; + // Only process if this is a new event + if (event.id === lastEventIds.get(kind)) return; + lastEventIds.set(kind, event.id); - // Parse relays from tags (NIP-65 format) - // Tag format: ["r", "relay-url", "read|write"] - // If no marker, relay is used for both read and write - const relays: RelayInfo[] = []; - const seenUrls = new Set(); - - for (const tag of relayListEvent.tags) { - if (tag[0] === "r" && tag[1]) { - try { - const url = normalizeRelayURL(tag[1]); - if (seenUrls.has(url)) continue; - seenUrls.add(url); - - const marker = tag[2]; - relays.push({ - url, - read: !marker || marker === "read", - write: !marker || marker === "write", - }); - } catch (error) { - console.warn( - `Skipping invalid relay URL in Kind 10002 event: ${tag[1]}`, - error, - ); - } + // Handle specific kinds + if (kind === 10002) { + // Parse relay list (NIP-65) + const relays = parseRelayList(event); + setActiveAccountRelays(relays); + } else if (kind === 10063) { + // Parse blossom server list (BUD-03) + const servers = getServersFromEvent(event); + setActiveAccountBlossomServers(servers); } - } + // Kind 3 (contacts) is auto-cached but doesn't need UI state updates + // Kind 10030 (emoji list) is auto-cached but doesn't need UI state updates + }); - setActiveAccountRelays(relays); - }); - - return () => { - subscription.unsubscribe(); - storeSubscription.unsubscribe(); - }; - }, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]); - - // Fetch and watch blossom server list (kind 10063) when account changes - useEffect(() => { - if (!activeAccount?.pubkey) { - return; + subscriptions.push(fetchSub, storeSub); } - 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(); + subscriptions.forEach((sub) => sub.unsubscribe()); }; - }, [activeAccount?.pubkey, eventStore, setActiveAccountBlossomServers]); + }, [ + activeAccount?.pubkey, + eventStore, + setActiveAccountRelays, + setActiveAccountBlossomServers, + ]); +} + +/** + * Parse relay list event (NIP-65 format) + * Tag format: ["r", "relay-url", "read|write"] + * If no marker, relay is used for both read and write + */ +function parseRelayList(event: NostrEvent): RelayInfo[] { + const relays: RelayInfo[] = []; + const seenUrls = new Set(); + + for (const tag of event.tags) { + if (tag[0] === "r" && tag[1]) { + try { + const url = normalizeRelayURL(tag[1]); + if (seenUrls.has(url)) continue; + seenUrls.add(url); + + const marker = tag[2]; + relays.push({ + url, + read: !marker || marker === "read", + write: !marker || marker === "write", + }); + } catch (error) { + console.warn( + `Skipping invalid relay URL in kind:10002 event: ${tag[1]}`, + error, + ); + } + } + } + + return relays; } diff --git a/src/services/replaceable-event-cache.ts b/src/services/replaceable-event-cache.ts index 283df82..24fe97a 100644 --- a/src/services/replaceable-event-cache.ts +++ b/src/services/replaceable-event-cache.ts @@ -30,6 +30,20 @@ const MAX_MEMORY_CACHE = 200; // LRU cache size */ export const CACHED_KINDS = [3, 10002, 10063, 10030]; +/** + * Kinds to always fetch and keep synced for active user + * These will be: + * - Hydrated from cache on startup + * - Auto-fetched from relays when user logs in + * - Kept up-to-date via addressLoader subscriptions + */ +export const ACTIVE_USER_KINDS = [ + 3, // Contacts - for $contacts alias resolution + 10002, // Relay list - for outbox relay selection + 10063, // Blossom servers - for media uploads + // 10030, // Emoji list - optional, uncomment to enable +]; + /** * Check if a kind is parameterized replaceable (30000-39999) */ @@ -83,6 +97,48 @@ class ReplaceableEventCache { } } + /** + * Hydrate EventStore with fresh cached events on startup + * Only loads events newer than TTL to avoid stale data + * This solves the "orphaned cache" problem where Dexie has data but EventStore doesn't + */ + async hydrateEventStore(eventStore: IEventStore): Promise { + try { + const cutoff = Date.now() - this.ttl; + + const fresh = await db.replaceableEvents + .where("updatedAt") + .above(cutoff) + .toArray(); + + console.log( + `[ReplaceableEventCache] Hydrating EventStore with ${fresh.length} cached events`, + ); + + // Add all fresh events to EventStore + for (const entry of fresh) { + await eventStore.add(entry.event); + + // Also populate memory cache for fast access + const cacheKey = buildCacheKey(entry.pubkey, entry.kind, entry.d); + this.memoryCache.set(cacheKey, entry); + this.cacheOrder.push(cacheKey); + } + + // Clean up excess memory cache entries + this.evictOldest(); + + console.log( + `[ReplaceableEventCache] Hydration complete. Memory cache: ${this.memoryCache.size} entries`, + ); + } catch (error) { + console.error( + "[ReplaceableEventCache] Error hydrating EventStore:", + error, + ); + } + } + /** * Get cached event for a pubkey+kind (and optional d-tag) * Returns undefined if not cached or stale