FeedProvider.tsx raw
1 import { getRelaySetFromEvent } from '@/lib/event-metadata'
2 import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
3 import indexedDb from '@/services/indexed-db.service'
4 import storage from '@/services/local-storage.service'
5 import { TFeedInfo, TFeedType } from '@/types'
6 import { kinds } from 'nostr-tools'
7 import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
8 import { useFavoriteRelays } from './FavoriteRelaysProvider'
9 import { useNostr } from './NostrProvider'
10
11 // Domain imports
12 import {
13 Feed,
14 FeedType,
15 ContentFilter,
16 fromFeed,
17 toRelayUrls,
18 fromRelayUrls,
19 FeedSwitched
20 } from '@/domain/feed'
21 import { Pubkey } from '@/domain/shared/value-objects/Pubkey'
22 import { RelayUrl } from '@/domain/shared/value-objects/RelayUrl'
23 import { eventDispatcher } from '@/domain/shared'
24 import { setSocialHandlerCallbacks } from '@/application/handlers/SocialEventHandlers'
25
26 /**
27 * Feed context type
28 *
29 * Provides both legacy TFeedInfo for backward compatibility
30 * and new domain model access.
31 */
32 type TFeedContext = {
33 // Legacy interface (for backward compatibility)
34 feedInfo: TFeedInfo
35 relayUrls: string[]
36 isReady: boolean
37 switchFeed: (
38 feedType: TFeedType | null,
39 options?: { activeRelaySetId?: string; pubkey?: string; relay?: string | null }
40 ) => Promise<void>
41 markFeedLoaded: () => void
42
43 // Domain model interface
44 feed: Feed | null
45 contentFilter: ContentFilter
46 updateContentFilter: (filter: ContentFilter) => void
47 refresh: () => void
48 }
49
50 const FeedContext = createContext<TFeedContext | undefined>(undefined)
51
52 export const useFeed = () => {
53 const context = useContext(FeedContext)
54 if (!context) {
55 throw new Error('useFeed must be used within a FeedProvider')
56 }
57 return context
58 }
59
60 export function FeedProvider({ children }: { children: React.ReactNode }) {
61 const { pubkey, isInitialized } = useNostr()
62 const { relaySets } = useFavoriteRelays()
63
64 // Domain state
65 const [feed, setFeed] = useState<Feed | null>(null)
66 const [contentFilter, setContentFilter] = useState<ContentFilter>(ContentFilter.default())
67
68 // Legacy state (derived from domain state)
69 const [isReady, setIsReady] = useState(false)
70 const feedRef = useRef<Feed | null>(feed)
71
72 // Derive legacy feedInfo from domain Feed
73 const feedInfo = useMemo<TFeedInfo>(() => {
74 return feed ? fromFeed(feed) : null
75 }, [feed])
76
77 // Derive relayUrls from domain Feed
78 const relayUrls = useMemo<string[]>(() => {
79 return feed ? fromRelayUrls(feed.relayUrls) : []
80 }, [feed])
81
82 // Get owner Pubkey from string
83 const ownerPubkey = useMemo(() => {
84 return pubkey ? Pubkey.tryFromString(pubkey) : null
85 }, [pubkey])
86
87 // Initialize feed on mount
88 useEffect(() => {
89 const init = async () => {
90 if (!isInitialized) {
91 return
92 }
93
94 let storedFeedInfo: TFeedInfo = null
95 if (pubkey) {
96 const retrieved = storage.getFeedInfo(pubkey)
97 storedFeedInfo = retrieved ?? null
98 if (!storedFeedInfo) {
99 storedFeedInfo = { feedType: 'following' }
100 }
101 }
102
103 if (storedFeedInfo?.feedType === 'relays') {
104 return await switchFeed('relays', { activeRelaySetId: storedFeedInfo.id })
105 }
106
107 if (storedFeedInfo?.feedType === 'relay') {
108 return await switchFeed('relay', { relay: storedFeedInfo.id })
109 }
110
111 if (storedFeedInfo?.feedType === 'following' && pubkey) {
112 return await switchFeed('following', { pubkey })
113 }
114
115 if (storedFeedInfo?.feedType === 'pinned' && pubkey) {
116 return await switchFeed('pinned', { pubkey })
117 }
118
119 setIsReady(true)
120 }
121
122 init()
123 }, [pubkey, isInitialized])
124
125 // Retry 'relays' feed when relay sets become available after initial load
126 useEffect(() => {
127 if (!isInitialized || !pubkey || !relaySets.length) return
128 // Only retry if we don't have a feed or have a 'relays' stored but no active feed
129 const storedFeedInfo = storage.getFeedInfo(pubkey)
130 if (storedFeedInfo?.feedType === 'relays' && !feed) {
131 switchFeed('relays', { activeRelaySetId: storedFeedInfo.id })
132 }
133 }, [relaySets, isInitialized, pubkey, feed])
134
135 // Wire up event handler callbacks
136 useEffect(() => {
137 setSocialHandlerCallbacks({
138 onFeedRefreshNeeded: () => {
139 // Trigger feed refresh when follow list changes
140 if (feed) {
141 const event = feed.refresh()
142 eventDispatcher.dispatch(event)
143 }
144 },
145 onRefilterNeeded: () => {
146 // Content filter hasn't changed, but mute list has
147 // The filter will pick up new mutes on next render
148 setContentFilter((prev) => prev)
149 }
150 })
151 }, [feed])
152
153 /**
154 * Switch to a different feed type
155 */
156 const switchFeed = useCallback(async (
157 feedType: TFeedType | null,
158 options: {
159 activeRelaySetId?: string | null
160 pubkey?: string | null
161 relay?: string | null
162 } = {}
163 ) => {
164 const previousFeed = feedRef.current
165
166 if (!feedType) {
167 setFeed(null)
168 feedRef.current = null
169 setIsReady(true)
170 return
171 }
172
173 setIsReady(false)
174
175 let newFeed: Feed | null = null
176 let newFeedType: FeedType | null = null
177
178 if (feedType === 'relay') {
179 const normalizedUrl = normalizeUrl(options.relay ?? '')
180 if (!normalizedUrl || !isWebsocketUrl(normalizedUrl)) {
181 setIsReady(true)
182 return
183 }
184
185 const relayUrl = RelayUrl.tryCreate(normalizedUrl)
186 if (!relayUrl) {
187 setIsReady(true)
188 return
189 }
190
191 newFeed = Feed.singleRelay(relayUrl)
192 newFeedType = FeedType.relay(normalizedUrl)
193 } else if (feedType === 'relays') {
194 const relaySetId = options.activeRelaySetId ?? (relaySets.length > 0 ? relaySets[0].id : null)
195 if (!relaySetId || !pubkey || !ownerPubkey) {
196 setIsReady(true)
197 return
198 }
199
200 let relaySet =
201 relaySets.find((set) => set.id === relaySetId) ??
202 (relaySets.length > 0 ? relaySets[0] : null)
203
204 if (!relaySet) {
205 const storedRelaySetEvent = await indexedDb.getReplaceableEvent(
206 pubkey,
207 kinds.Relaysets,
208 relaySetId
209 )
210 if (storedRelaySetEvent) {
211 relaySet = getRelaySetFromEvent(storedRelaySetEvent)
212 }
213 }
214
215 if (relaySet) {
216 const relayUrlObjects = toRelayUrls(relaySet.relayUrls)
217 newFeed = Feed.relays(ownerPubkey, relaySet.id, relayUrlObjects)
218 newFeedType = FeedType.relays(relaySet.id)
219 }
220 } else if (feedType === 'following') {
221 if (!options.pubkey || !ownerPubkey) {
222 setIsReady(true)
223 return
224 }
225 newFeed = Feed.following(ownerPubkey)
226 newFeedType = FeedType.following()
227 } else if (feedType === 'pinned') {
228 if (!options.pubkey || !ownerPubkey) {
229 setIsReady(true)
230 return
231 }
232 newFeed = Feed.pinned(ownerPubkey)
233 newFeedType = FeedType.pinned()
234 }
235
236 if (newFeed && newFeedType) {
237 // Update state
238 setFeed(newFeed)
239 feedRef.current = newFeed
240
241 // Persist to storage
242 const newFeedInfo = fromFeed(newFeed)
243 storage.setFeedInfo(newFeedInfo, pubkey)
244
245 // Dispatch domain event
246 const event = new FeedSwitched(
247 ownerPubkey,
248 previousFeed?.type ?? null,
249 newFeedType,
250 newFeedType.relaySetId ?? undefined
251 )
252 eventDispatcher.dispatch(event)
253 setIsReady(true)
254 } else {
255 // No feed could be created — mark ready immediately
256 setIsReady(true)
257 }
258 }, [pubkey, ownerPubkey, relaySets])
259
260 /**
261 * Signal that the feed's initial data has loaded (called by NoteList)
262 */
263 const markFeedLoaded = useCallback(() => {
264 setIsReady(true)
265 }, [])
266
267 /**
268 * Update content filter settings
269 */
270 const updateContentFilter = useCallback((newFilter: ContentFilter) => {
271 setContentFilter(newFilter)
272
273 // If we have a feed, emit the domain event
274 if (feed && ownerPubkey) {
275 const event = feed.updateContentFilter(newFilter)
276 eventDispatcher.dispatch(event)
277 }
278 }, [feed, ownerPubkey])
279
280 /**
281 * Refresh the current feed
282 */
283 const refresh = useCallback(() => {
284 if (feed) {
285 const event = feed.refresh()
286 eventDispatcher.dispatch(event)
287 }
288 }, [feed])
289
290 const value = useMemo<TFeedContext>(() => ({
291 // Legacy interface
292 feedInfo,
293 relayUrls,
294 isReady,
295 switchFeed,
296 markFeedLoaded,
297
298 // Domain model interface
299 feed,
300 contentFilter,
301 updateContentFilter,
302 refresh
303 }), [feedInfo, relayUrls, isReady, switchFeed, markFeedLoaded, feed, contentFilter, updateContentFilter, refresh])
304
305 return (
306 <FeedContext.Provider value={value}>
307 {children}
308 </FeedContext.Provider>
309 )
310 }
311