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
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> |
|
) |
|
}
|
|
|