diff --git a/src/hooks/useEventLoader.ts b/src/hooks/useEventLoader.ts index 575da1db..54a6354e 100644 --- a/src/hooks/useEventLoader.ts +++ b/src/hooks/useEventLoader.ts @@ -1,9 +1,9 @@ import { useEffect, useCallback } from 'react' import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' -import { createEventLoader } from 'applesauce-loaders/loaders' import { NostrEvent } from 'nostr-tools' import { ReadableContent } from '../services/readerService' +import { eventManager } from '../services/eventManager' interface UseEventLoaderProps { eventId?: string @@ -47,44 +47,26 @@ export function useEventLoader({ setReaderContent(content) }, [setReaderContent]) + // Initialize event manager with services + useEffect(() => { + eventManager.setServices(eventStore || null, relayPool || null) + }, [eventStore, relayPool]) + useEffect(() => { if (!eventId) return - // Try to get from event store first - do this synchronously before setting loading state - if (eventStore) { - const cachedEvent = eventStore.getEvent(eventId) - if (cachedEvent) { - displayEvent(cachedEvent) - setReaderLoading(false) - setIsCollapsed(false) - setSelectedUrl('') // Don't set nostr: URL to avoid showing highlights - return - } - } - - // Event not in cache, now set loading state and fetch from relays setReaderLoading(true) setReaderContent(undefined) setSelectedUrl('') // Don't set nostr: URL to avoid showing highlights setIsCollapsed(false) - // If no relay pool yet, wait for it or show a placeholder - if (!relayPool) { - // Show loading state until relayPool becomes available - // The effect will re-run once relayPool is set - return - } - - const eventLoader = createEventLoader(relayPool, { - eventStore: eventStore ?? undefined - }) - - const subscription = eventLoader({ id: eventId }).subscribe({ - next: (event) => { + // Fetch event using the event manager + eventManager.fetchEvent(eventId).then( + (event) => { displayEvent(event) setReaderLoading(false) }, - error: (err) => { + (err) => { const errorContent: ReadableContent = { url: '', html: `
Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}
`, @@ -93,8 +75,6 @@ export function useEventLoader({ setReaderContent(errorContent) setReaderLoading(false) } - }) - - return () => subscription.unsubscribe() - }, [eventId, relayPool, eventStore, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent]) + ) + }, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent]) } diff --git a/src/services/eventManager.ts b/src/services/eventManager.ts new file mode 100644 index 00000000..e798ea4a --- /dev/null +++ b/src/services/eventManager.ts @@ -0,0 +1,136 @@ +import { RelayPool } from 'applesauce-relay' +import { IEventStore } from 'applesauce-core' +import { createEventLoader } from 'applesauce-loaders/loaders' +import { NostrEvent } from 'nostr-tools' +import { BehaviorSubject, Observable } from 'rxjs' + +type EventCallback = (event: NostrEvent) => void +type ErrorCallback = (error: Error) => void + +/** + * Centralized event manager for fetching and caching events + * Handles deduplication of requests and provides a single source of truth + */ +class EventManager { + private eventStore: IEventStore | null = null + private relayPool: RelayPool | null = null + private eventLoader: ReturnType | null = null + + // Track pending requests to avoid duplicates + private pendingRequests = new Map>() + + // Event stream for real-time updates + private eventSubject = new BehaviorSubject(null) + + /** + * Initialize the event manager with event store and relay pool + */ + setServices(eventStore: IEventStore | null, relayPool: RelayPool | null): void { + this.eventStore = eventStore + this.relayPool = relayPool + + if (relayPool && this.eventLoader === null) { + this.eventLoader = createEventLoader(relayPool, { + eventStore: eventStore || undefined + }) + } + } + + /** + * Fetch an event by ID, with automatic deduplication and caching + */ + async fetchEvent(eventId: string): Promise { + // Check cache first + if (this.eventStore) { + const cached = this.eventStore.getEvent(eventId) + if (cached) { + return cached + } + } + + // Return a promise that will be resolved when the event is fetched + return new Promise((resolve, reject) => { + this.fetchEventAsync(eventId, resolve, reject) + }) + } + + /** + * Subscribe to event fetching with callbacks + */ + private fetchEventAsync( + eventId: string, + onSuccess: EventCallback, + onError: ErrorCallback + ): void { + // Check if we're already fetching this event + if (this.pendingRequests.has(eventId)) { + // Add to existing request queue + this.pendingRequests.get(eventId)!.push({ onSuccess, onError }) + return + } + + // Start a new fetch request + this.pendingRequests.set(eventId, [{ onSuccess, onError }]) + + // If no relay pool yet, wait for it + if (!this.relayPool || !this.eventLoader) { + // Will retry when services are set + setTimeout(() => { + // Retry if still no pool + if (!this.relayPool) { + this.retryPendingRequest(eventId) + } + }, 1000) + return + } + + const subscription = this.eventLoader({ id: eventId }).subscribe({ + next: (event: NostrEvent) => { + // Call all pending callbacks + const callbacks = this.pendingRequests.get(eventId) || [] + this.pendingRequests.delete(eventId) + + callbacks.forEach(cb => cb.onSuccess(event)) + + // Emit to stream + this.eventSubject.next(event) + + subscription.unsubscribe() + }, + error: (err: unknown) => { + // Call all pending callbacks with error + const callbacks = this.pendingRequests.get(eventId) || [] + this.pendingRequests.delete(eventId) + + const error = err instanceof Error ? err : new Error(String(err)) + callbacks.forEach(cb => cb.onError(error)) + + subscription.unsubscribe() + } + }) + } + + /** + * Retry pending requests after delay (useful when relay pool becomes available) + */ + private retryPendingRequest(eventId: string): void { + const callbacks = this.pendingRequests.get(eventId) + if (!callbacks) return + + // Re-trigger the fetch + this.pendingRequests.delete(eventId) + if (callbacks.length > 0) { + this.fetchEventAsync(eventId, callbacks[0].onSuccess, callbacks[0].onError) + } + } + + /** + * Get the event stream for reactive updates + */ + getEventStream(): Observable { + return this.eventSubject.asObservable() + } +} + +// Singleton instance +export const eventManager = new EventManager()