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