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