diff --git a/LOCAL_RELAY.md b/LOCAL_RELAY.md index c415c545..0228161a 100644 --- a/LOCAL_RELAY.md +++ b/LOCAL_RELAY.md @@ -14,10 +14,11 @@ The local relay is built using three main components: ### Automatic Caching -Events are automatically cached to IndexedDB in two ways: +Events are automatically cached to IndexedDB as they stream in from the network: -1. **Query Results** - When you query relays, the local relay is included and returns cached events instantly +1. **Incoming Events** - All events received from remote relays are automatically cached as they arrive 2. **Published Events** - When you publish events, they're automatically stored locally via the `eventRouter` +3. **No Syncing Required** - The cache builds naturally as you use the app, no background syncing needed ### Query Flow @@ -25,38 +26,37 @@ When you query events with `nostr.query()`: 1. The query is routed to the local relay (`local://indexeddb`) AND remote relays 2. Local relay responds immediately with cached events from IndexedDB -3. Remote relays respond with fresh events from the network -4. Results are deduplicated by event ID -5. New events from remote relays are automatically cached locally for future queries +3. Remote relays stream fresh events from the network +4. **Each event from remote relays is automatically cached as it arrives** +5. Results are deduplicated by event ID This means you get: - **Instant results** from the local cache - **Fresh data** from remote relays -- **Offline access** to previously cached events +- **Automatic caching** of all events you encounter +- **Offline access** to previously viewed content -### Event Syncing +### Streaming Cache Updates -You can use the `useEventSync` hook to automatically sync events to the local cache: +The NostrProvider intercepts the event stream from each relay and caches events in real-time: ```typescript -import { useEventSync } from '@/hooks/useEventSync'; - -function MyComponent() { - // Sync posts from users you follow - useEventSync({ - filters: [ - { kinds: [1], authors: followingPubkeys, limit: 100 } - ], - interval: 30000, // Sync every 30 seconds - onNewEvents: (count) => { - console.log(`Synced ${count} new events`); - } - }); - - // ...rest of component -} +// In NostrProvider - automatically happens for every query +relay.req = async function* (filters, opts) { + for await (const event of originalReq(filters, opts)) { + // Cache each event as it streams in (fire and forget) + eventStore.addEvent(event, [url]); + yield event; + } +}; ``` +This means: +- Posts are cached when you view the feed +- Profiles are cached when you view author info +- Replies are cached when you open a thread +- No manual syncing required - the cache builds as you browse + ## Settings UI The local cache is displayed in **Settings > Relays** with: @@ -165,11 +165,16 @@ for await (const event of localRelay.req([{ kinds: [1] }])) { ## Comparison to mi -This implementation is inspired by the local event storage system in the `mi` project, with the following differences: +This implementation is inspired by the local event storage system in the `mi` project, with the following key differences: 1. **Automatic integration** - The local relay is transparently integrated into the NPool, so all queries automatically include it 2. **Relay interface** - Implements a full relay interface rather than just a storage layer -3. **Composite indexes** - Includes a `kind_pubkey` composite index for more efficient queries -4. **UI integration** - Displays cache status and management in the relay settings +3. **Streaming cache** - Events are cached in real-time as they stream in from relays, not via polling/syncing +4. **Composite indexes** - Includes a `kind_pubkey` composite index for more efficient queries +5. **UI integration** - Displays cache status and management in the relay settings -The key advantage is that you don't need to explicitly query the event store - it's automatically queried as part of every `nostr.query()` call, providing instant results from the cache while simultaneously fetching fresh data from remote relays. +The key advantages: +- **Zero configuration** - You don't need to explicitly query the event store or set up syncing +- **Real-time caching** - Events are cached as they arrive, building the cache organically as you use the app +- **Instant loads on refresh** - Profiles and posts load instantly from cache while fresh data streams in from relays +- **No background syncing** - No polling intervals or sync hooks needed, everything happens naturally diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index 16352814..6a606595 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -53,7 +53,22 @@ const NostrProvider: React.FC = (props) => { if (url === 'local://indexeddb') { return localRelay as unknown as NRelay1; } - return new NRelay1(url); + + const relay = new NRelay1(url); + + // Intercept events as they stream in from remote relays and cache them + const originalReq = relay.req.bind(relay); + relay.req = async function* (filters: NostrFilter[], opts?: { signal?: AbortSignal }) { + for await (const event of originalReq(filters, opts)) { + // Cache event from this relay (fire and forget) + eventStore.addEvent(event, [url]).catch(error => { + console.debug('[NostrProvider] Failed to cache event from relay:', error); + }); + yield event; + } + }; + + return relay; }, reqRouter(filters: NostrFilter[]) { const routes = new Map(); diff --git a/src/hooks/useAuthor.ts b/src/hooks/useAuthor.ts index 271d4c2b..9c63cee1 100644 --- a/src/hooks/useAuthor.ts +++ b/src/hooks/useAuthor.ts @@ -18,7 +18,8 @@ export function useAuthor(pubkey: string | undefined) { ); if (!event) { - throw new Error('No event found'); + // Return empty object instead of throwing - profile doesn't exist or isn't cached yet + return {}; } try { @@ -29,6 +30,6 @@ export function useAuthor(pubkey: string | undefined) { } }, staleTime: 5 * 60 * 1000, // Keep cached data fresh for 5 minutes - retry: 3, + retry: false, // Don't retry - if profile isn't found, it just doesn't exist }); } diff --git a/src/hooks/useEventSync.ts b/src/hooks/useEventSync.ts deleted file mode 100644 index 33e669ca..00000000 --- a/src/hooks/useEventSync.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useEffect, useRef, useCallback } from 'react'; -import { useNostr } from '@nostrify/react'; -import { useAppContext } from '@/hooks/useAppContext'; -import { eventStore } from '@/lib/eventStore'; -import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; - -interface UseEventSyncOptions { - /** Filters to sync events for */ - filters?: NostrFilter[]; - /** Polling interval in milliseconds (default: 30 seconds) */ - interval?: number; - /** Whether to enable syncing (default: true) */ - enabled?: boolean; - /** Callback when new events are synced */ - onNewEvents?: (count: number) => void; -} - -/** - * Hook to automatically sync events from relays to local IndexedDB storage. - * Polls relays at regular intervals to fetch new events matching the provided filters. - */ -export function useEventSync(options: UseEventSyncOptions = {}) { - const { - filters, - interval = 30000, // 30 seconds - enabled = true, - onNewEvents, - } = options; - - const { nostr } = useNostr(); - const { config } = useAppContext(); - const lastSyncRef = useRef(Math.floor(Date.now() / 1000)); - const intervalIdRef = useRef(null); - - const syncEvents = useCallback(async (skipCallback = false) => { - if (!filters || filters.length === 0) { - return; - } - - try { - const relayUrls = config.relayMetadata.relays.filter(r => r.read).map(r => r.url); - - if (relayUrls.length === 0) { - console.debug('[EventSync] No read relays configured, skipping sync'); - return; - } - - // Add 'since' to all filters to only fetch new events - const since = lastSyncRef.current; - const sinceFilters = filters.map(filter => ({ - ...filter, - since: Math.floor(since), - limit: filter.limit || 100, - })); - - const signal = AbortSignal.timeout(10000); // 10 second timeout - - const events = await nostr.query(sinceFilters, { signal }); - - if (events.length > 0) { - // Store all events with relay information - await eventStore.addEvents(events, relayUrls); - - // Update last sync timestamp to the most recent event - const newestEvent = events.reduce((newest, current) => - current.created_at > newest.created_at ? current : newest - ); - lastSyncRef.current = newestEvent.created_at + 1; // +1 to avoid duplicates - - console.debug(`[EventSync] Synced ${events.length} new events`); - - // Only trigger callback if not skipped (skip on initial mount) - if (!skipCallback) { - onNewEvents?.(events.length); - } - } - - } catch (error) { - console.debug('[EventSync] Sync error:', error); - } - }, [filters, nostr, config, onNewEvents]); - - useEffect(() => { - if (!enabled || !filters || filters.length === 0) { - return; - } - - // Initialize the event store - eventStore.init().catch(error => { - console.error('[EventSync] Failed to initialize event store:', error); - }); - - // Initial sync - skip callback to prevent UI flicker - syncEvents(true).catch((error) => { - console.debug('[EventSync] Initial sync error:', error); - }); - - // Set up polling interval - intervalIdRef.current = window.setInterval(() => { - syncEvents(false).catch((error) => { - console.debug('[EventSync] Interval sync error:', error); - }); - }, interval); - - return () => { - if (intervalIdRef.current !== null) { - window.clearInterval(intervalIdRef.current); - } - }; - }, [enabled, filters, interval, syncEvents]); - - return { - /** Trigger a manual sync */ - sync: useCallback(async () => { - await syncEvents(false); - }, [syncEvents]), - }; -}