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()