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