You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

255 lines
9.1 KiB

import { FAST_READ_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { buildAllFavoritesFeedRelayUrls } from '@/lib/home-feed-relays'
import logger from '@/lib/logger'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import type { Dispatch, ReactNode, SetStateAction } from 'react'
import { FeedContext } from './feed-context'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
import { useNostr } from './NostrProvider'
export type { TFeedContext } from './feed-context'
function relayUrlListIdentity(urls: string[]): string {
return urls
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('\n')
}
function relayListMentionsNostrLand(urls: readonly string[]): boolean {
return urls.some((url) => {
const normalized = normalizeAnyRelayUrl(url) || url.trim()
if (!normalized) return false
try {
const parsed = new URL(normalized.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://'))
return parsed.hostname.toLowerCase() === 'nostr.land'
} catch {
return false
}
})
}
function buildHomeReplyFeedRelayUrls(
primaryRelayUrls: string[],
inboxRelayUrls: string[],
cacheRelayUrls: string[],
httpRelayUrls: string[],
includeNostrLandAggr: boolean,
blockedRelays: string[]
): string[] {
return feedRelayPolicyUrls([
{ source: 'favorites', urls: primaryRelayUrls },
{ source: 'viewer-read', urls: inboxRelayUrls },
{ source: 'cache', urls: cacheRelayUrls },
{ source: 'http-index', urls: httpRelayUrls },
...(includeNostrLandAggr ? [{ source: 'read-only', urls: [AGGR_NOSTR_LAND_WSS] }] : [])
], {
operation: 'read',
blockedRelays,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
})
}
export function FeedProvider({ children }: { children: ReactNode }) {
const { isInitialized, relayList, cacheRelayListEvent, httpRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays()
const favoriteFeedRelayUrls = useMemo(
() => [...favoriteRelays, ...relaySets.flatMap((relaySet) => relaySet.relayUrls)],
[favoriteRelays, relaySets]
)
/**
* Mixed trending slice (nostrarchives / Wisp-style feed) so the home timeline isn’t only the user’s
* graph — keeps a finger on what the wider network is surfacing, alongside favorites / NIP-65.
*/
const primaryExtraRelayUrls = useMemo(() => [buildWispTrendingNotesRelayUrl()], [])
/** Home Replies widen to relays that can surface inbox/reply context. */
const replyExtraRelayLayers = useMemo(() => {
const cacheRelayUrls: string[] = []
if (cacheRelayListEvent) {
const list = getRelayListFromEvent(cacheRelayListEvent, blockedRelays)
cacheRelayUrls.push(...list.read, ...list.write)
}
const httpRelayUrls: string[] = [...(relayList?.httpRead ?? []), ...(relayList?.httpWrite ?? [])]
if (httpRelayListEvent) {
const list = getHttpRelayListFromEvent(httpRelayListEvent, blockedRelays)
httpRelayUrls.push(...list.httpRead, ...list.httpWrite)
}
return {
inboxRelayUrls: relayList?.read?.length ? relayList.read : FAST_READ_RELAY_URLS,
outboxRelayUrls: relayList?.write?.length ? relayList.write : FAST_READ_RELAY_URLS,
cacheRelayUrls,
httpRelayUrls
}
}, [relayList, cacheRelayListEvent, httpRelayListEvent, blockedRelays])
/** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */
const [relayUrls, setRelayUrls] = useState<string[]>(() =>
buildAllFavoritesFeedRelayUrls([], [], [buildWispTrendingNotesRelayUrl()])
)
const [replyRelayUrls, setReplyRelayUrls] = useState<string[]>(() =>
buildHomeReplyFeedRelayUrls(
buildAllFavoritesFeedRelayUrls([], [], [buildWispTrendingNotesRelayUrl()]),
[],
[],
[],
false,
[]
)
)
/** Same logical relay policy result — reuse array ref so NoteList does not re-subscribe. */
const setUrlStateIfChanged = useCallback(
(setter: Dispatch<SetStateAction<string[]>>, next: string[]) => {
setter((prev) => {
if (relayUrlListIdentity(prev) === relayUrlListIdentity(next)) return prev
return next
})
},
[]
)
const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' })
const updateFeedRelayUrls = useCallback(() => {
const primaryRelays = buildAllFavoritesFeedRelayUrls(favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls)
const aggrEligibleRelayUrls = [
...favoriteFeedRelayUrls,
...replyExtraRelayLayers.inboxRelayUrls,
...replyExtraRelayLayers.outboxRelayUrls,
...replyExtraRelayLayers.cacheRelayUrls
]
const replyRelays = buildHomeReplyFeedRelayUrls(
primaryRelays,
replyExtraRelayLayers.inboxRelayUrls,
replyExtraRelayLayers.cacheRelayUrls,
replyExtraRelayLayers.httpRelayUrls,
relayListMentionsNostrLand(aggrEligibleRelayUrls),
blockedRelays
)
const primaryId = relayUrlListIdentity(primaryRelays)
const replyId = relayUrlListIdentity(replyRelays)
const prevUrls = lastHomeFeedUrlLogRef.current
if (prevUrls.primary !== primaryId || prevUrls.reply !== replyId) {
lastHomeFeedUrlLogRef.current = { primary: primaryId, reply: replyId }
logger.debug('Updating home feed relay URLs:', {
primaryRelays,
replyRelays
})
}
setUrlStateIfChanged(setRelayUrls, primaryRelays)
setUrlStateIfChanged(setReplyRelayUrls, replyRelays)
}, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged])
const favoriteRelaysIdentity = useMemo(
() =>
[...favoriteFeedRelayUrls]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|'),
[favoriteFeedRelayUrls]
)
const blockedRelaysIdentity = useMemo(
() =>
[...blockedRelays]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|'),
[blockedRelays]
)
const replyExtraRelaysIdentity = useMemo(
() =>
[
...replyExtraRelayLayers.inboxRelayUrls,
...replyExtraRelayLayers.outboxRelayUrls,
...replyExtraRelayLayers.cacheRelayUrls,
...replyExtraRelayLayers.httpRelayUrls
]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|'),
[replyExtraRelayLayers]
)
const lastRelayInitDebugKey = useRef('')
const lastHadFavoriteRelaysRef = useRef<boolean | null>(null)
const relayUrlDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const initKey = [
isInitialized ? '1' : '0',
favoriteRelays.length,
relaySets.length,
favoriteFeedRelayUrls.length - favoriteRelays.length,
replyExtraRelayLayers.inboxRelayUrls.length,
replyExtraRelayLayers.outboxRelayUrls.length,
replyExtraRelayLayers.cacheRelayUrls.length,
replyExtraRelayLayers.httpRelayUrls.length,
blockedRelays.length
].join('\x1e')
const flush = () => {
if (initKey !== lastRelayInitDebugKey.current) {
lastRelayInitDebugKey.current = initKey
logger.debug('FeedProvider relay init:', {
isInitialized,
favoriteRelays: favoriteRelays.length,
relaySets: relaySets.length,
relaySetRelays: favoriteFeedRelayUrls.length - favoriteRelays.length,
inboxRelays: replyExtraRelayLayers.inboxRelayUrls.length,
outboxRelays: replyExtraRelayLayers.outboxRelayUrls.length,
cacheRelays: replyExtraRelayLayers.cacheRelayUrls.length,
httpRelays: replyExtraRelayLayers.httpRelayUrls.length,
blockedRelays: blockedRelays.length
})
}
const hasFavoriteRelays = favoriteFeedRelayUrls.length > 0
const prevHad = lastHadFavoriteRelaysRef.current
lastHadFavoriteRelaysRef.current = hasFavoriteRelays
if (!hasFavoriteRelays && prevHad !== false) {
logger.debug('FeedProvider: no favorite or relay-set relays, using defaults')
}
updateFeedRelayUrls()
}
if (relayUrlDebounceTimerRef.current) {
clearTimeout(relayUrlDebounceTimerRef.current)
}
relayUrlDebounceTimerRef.current = setTimeout(() => {
relayUrlDebounceTimerRef.current = null
flush()
}, 80)
return () => {
if (relayUrlDebounceTimerRef.current) {
clearTimeout(relayUrlDebounceTimerRef.current)
relayUrlDebounceTimerRef.current = null
}
}
}, [isInitialized, favoriteRelaysIdentity, blockedRelaysIdentity, replyExtraRelaysIdentity, updateFeedRelayUrls])
return (
<FeedContext.Provider
value={{
relayUrls,
replyRelayUrls
}}
>
{children}
</FeedContext.Provider>
)
}