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.
 
 
 
 

324 lines
13 KiB

import {
DEFAULT_FAVORITE_RELAYS,
DOCUMENT_RELAY_URLS,
FAST_READ_RELAY_URLS,
PROFILE_RELAY_URLS,
isDocumentRelayKind,
relayFilterIncludesSocialKindBlockedKind
} from '@/constants'
import type { TFeedSubRequest } from '@/types'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import {
buildPrioritizedReadRelayUrls,
buildReadRelayPriorityLayers,
dedupeNormalizeRelayUrlsOrdered,
MAX_REQ_RELAY_URLS,
relayUrlsLocalsFirst
} from '@/lib/relay-url-priority'
import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy'
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { relaySessionStrikes } from '@/lib/relay-strikes'
import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility'
import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults'
function isBlockedRelay(url: string, blockedRelays: string[]): boolean {
return isRelayBlockedByUser(url, blockedRelays)
}
/**
* Logged-in user’s favorite relays (kind 10012 `relay` tags via {@link useFavoriteRelays}, plus bootstrap defaults
* when the event is missing): drop blocked, dedupe, normalize. If no non-blocked entries remain, use
* {@link DEFAULT_FAVORITE_RELAYS} only when `useGlobalFavoriteDefaults` is true (signed-out or no NIP-65 and no favorites).
* Same list drives the favorites tier in REQ/publish prioritization and the all-favorites home feed.
*/
/**
* NIP-65 `read` plus HTTP index inboxes (kind 10243) for feed REQ / query URL lists.
*/
export function userReadRelaysWithHttp(
relayList: { read?: string[]; httpRead?: string[] } | undefined | null
): string[] {
const http = relayList?.httpRead ?? []
const read = relayList?.read ?? []
return prependAggrNostrLandIfViewerEligible(dedupeNormalizeRelayUrlsOrdered([...http, ...read]))
}
export function getFavoritesFeedRelayUrls(
favoriteRelays: string[],
blockedRelays: string[],
useGlobalFavoriteDefaults = true
): string[] {
const visible = favoriteRelays.filter((r) => {
const k = normalizeAnyRelayUrl(r) || r
return k && !isBlockedRelay(r, blockedRelays)
})
const base = visible.length > 0 ? visible : useGlobalFavoriteDefaults ? DEFAULT_FAVORITE_RELAYS : []
return feedRelayPolicyUrls(
[{ source: 'favorites', urls: base }],
{
operation: 'favorites-feed',
blockedRelays,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}
)
}
/**
* Merge relay URL lists in order; first occurrence wins; drops blocked.
*/
export function mergeRelayUrlLayers(
layers: readonly (readonly string[])[],
blockedRelays: string[]
): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const layer of layers) {
for (const u of layer) {
const k = normalizeAnyRelayUrl(u) || u
if (!k || isBlockedRelay(u, blockedRelays) || seen.has(k)) continue
seen.add(k)
out.push(k)
}
}
return out
}
/**
* Viewed author’s NIP-65 read list (inboxes), then write list (outboxes), each with LAN/local URLs first; blocked
* stripped. Used for profile pins + Medien before {@link buildProfileAugmentedReadRelayUrls}.
*
* @param includeAuthorLocalRelays When true (viewing your own profile), keep LAN hints so local cache/outbox works.
*/
export function buildAuthorInboxOutboxRelayUrls(
authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
blockedRelays: string[],
includeAuthorLocalRelays = false
): string[] {
const list = includeAuthorLocalRelays
? authorRelayList
: stripMailboxLocalUrlsForRemoteViewers(authorRelayList)
const inboxLayer = relayUrlsLocalsFirst([...(list.httpRead ?? []), ...(list.read ?? [])])
const outboxLayer = relayUrlsLocalsFirst([...(list.httpWrite ?? []), ...(list.write ?? [])])
return mergeRelayUrlLayers([inboxLayer, outboxLayer], blockedRelays)
}
/**
* Profile pins + Medien: author NIP-65 tier (pass from {@link buildAuthorInboxOutboxRelayUrls}), then
* {@link FAST_READ_RELAY_URLS}; dedupe, blocked-stripped, capped.
*/
const PROFILE_AUGMENTED_READ_MAX_RELAYS = 16
export function buildProfileAugmentedReadRelayUrls(
authorRelayUrls: string[],
blockedRelays: string[],
maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS,
useGlobalRelayBootstrap = true
): string[] {
const fastReadLayer =
useGlobalRelayBootstrap
? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
: []
const merged = mergeRelayUrlLayers(
useGlobalRelayBootstrap ? [fastReadLayer, authorRelayUrls] : [authorRelayUrls, fastReadLayer],
blockedRelays
)
return merged.slice(0, maxRelays)
}
/**
* Another user's NIP-65 read/write lists can fill {@link PROFILE_PAGE_FEED_MAX_RELAYS} before the fast-read
* tier is reached in {@link feedRelayPolicyUrls}, so kind 1 / 1111 REQs never hit relays that carry them.
*/
function pinFastReadForRemoteProfileFeed(
urls: string[],
fastReadLayer: readonly string[],
blockedRelays: string[],
maxRelays: number
): string[] {
if (!fastReadLayer.length) return urls.slice(0, maxRelays)
return mergeRelayUrlLayers([fastReadLayer, urls], blockedRelays).slice(0, maxRelays)
}
export type ReadRelayPriorityOptions = {
/** User NIP-65 write list — local URLs are promoted with inboxes for REQ. */
userWriteRelays?: string[]
/** Profile/timeline author outboxes (write relays) when known. */
authorWriteRelays?: string[]
maxRelays?: number
/**
* When set, applies to all subrequests. When unset, each subrequest uses
* {@link relayFilterIncludesSocialKindBlockedKind} on its filter to decide whether to strip
* relays in `SOCIAL_KIND_BLOCKED_RELAY_URLS` before capping.
*/
applySocialKindBlockedFilter?: boolean
/**
* When false, empty favorites do not fall back to {@link DEFAULT_FAVORITE_RELAYS}. Default true.
*/
useGlobalFavoriteDefaults?: boolean
/** When false, omit the global FAST_READ tier. Default true. */
includeGlobalFastRead?: boolean
}
/**
* REQ order: user inboxes + locals → author outboxes → favorites → {@link FAST_READ_RELAY_URLS}.
*/
export function getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays: string[],
blockedRelays: string[],
userInboxReadRelays: string[],
options?: ReadRelayPriorityOptions
): string[] {
const useFavDefaults = options?.useGlobalFavoriteDefaults !== false
const includeFast = options?.includeGlobalFastRead !== false
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults)
return buildPrioritizedReadRelayUrls({
userReadRelays: userInboxReadRelays,
userWriteRelays: options?.userWriteRelays ?? [],
authorWriteRelays: options?.authorWriteRelays ?? [],
favoriteRelays: favorites,
blockedRelays,
maxRelays: options?.maxRelays,
applySocialKindBlockedFilter: options?.applySocialKindBlockedFilter,
includeGlobalFastRead: includeFast
})
}
/**
* Profile page pins + feed: viewed author's NIP-65 read + write (REQ tier 1), then logged-in user's favorites,
* then fast-read defaults from constants, deduped and blocked-stripped, capped at this count.
*/
/** Profile REQ cap: too small waits on a few bad relays; larger spreads load across fast-read / favorites. */
const PROFILE_PAGE_FEED_MAX_RELAYS = 14
/** Long-form / publication profile tab: slightly larger cap + {@link DOCUMENT_RELAY_URLS} merge. */
const PROFILE_PAGE_DOCUMENT_FEED_MAX_RELAYS = 24
export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10
export function buildProfilePageReadRelayUrls(
favoriteRelays: string[],
blockedRelays: string[],
authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
kindsIncludeSocialBlockedKind: boolean,
includeAuthorLocalRelays = false,
/** When the timeline includes document kinds (30023, 30040, …), add document index relays and raise the cap. */
profileKindsHint?: readonly number[],
/** When false, omit global FAST_READ / profile-fetch widening for logged-in users with their own relay stack. */
useGlobalRelayBootstrap?: boolean
): string[] {
const useGlobal = useGlobalRelayBootstrap !== false
const wantsDocumentLayer = profileKindsHint?.some((k) => isDocumentRelayKind(k)) ?? false
const maxRelays = wantsDocumentLayer ? PROFILE_PAGE_DOCUMENT_FEED_MAX_RELAYS : PROFILE_PAGE_FEED_MAX_RELAYS
const list = includeAuthorLocalRelays
? authorRelayList
: stripMailboxLocalUrlsForRemoteViewers(authorRelayList)
const authorRead = [...(list.httpRead ?? []), ...(list.read ?? [])]
const authorWrite = [...(list.httpWrite ?? []), ...(list.write ?? [])]
const authorHasNoNip65 = authorRead.length === 0 && authorWrite.length === 0
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobal)
const fastReadLayer = useGlobal
? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
: []
const authorWriteLayer = relayUrlsLocalsFirst(authorWrite)
const authorReadLayer = relayUrlsLocalsFirst(authorRead)
const urls = feedRelayPolicyUrls(
[
{ source: 'author-write', urls: authorWriteLayer },
{ source: 'author-read', urls: authorReadLayer },
{ source: 'favorites', urls: favorites },
{ source: 'fast-read', urls: fastReadLayer }
],
{
operation: 'read',
blockedRelays,
maxRelays,
applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind,
socialKindBlockedExemptRelays: [...authorWriteLayer, ...authorReadLayer],
allowThirdPartyLocalRelays: true
}
)
const pinFastReadForRemote = useGlobal && !includeAuthorLocalRelays
/** Authors without kind 10002: widen REQ targets so notes/metadata are still discoverable on index relays. */
if (authorHasNoNip65) {
const profileSource = useGlobal ? PROFILE_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer()
const profileFetchLayer = profileSource.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
const cap = maxRelays + 8
const merged = mergeRelayUrlLayers([urls, profileFetchLayer], blockedRelays).slice(0, cap)
return pinFastReadForRemote
? pinFastReadForRemoteProfileFeed(merged, fastReadLayer, blockedRelays, cap)
: merged
}
if (wantsDocumentLayer) {
const docLayer = DOCUMENT_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
const cap = maxRelays + 6
const merged = mergeRelayUrlLayers([urls, docLayer], blockedRelays).slice(0, cap)
return pinFastReadForRemote
? pinFastReadForRemoteProfileFeed(merged, fastReadLayer, blockedRelays, cap)
: merged
}
const merged = pinFastReadForRemote
? pinFastReadForRemoteProfileFeed(urls, fastReadLayer, blockedRelays, maxRelays)
: urls
return relaySessionStrikes.filterReadHttpUrls(merged)
}
/**
* Per subrequest: shared inbox → author/favorites → fast read stack, normalized, user-blocked and (when applicable)
* social-kind-blocked stripped, deduped, capped. Subrequest `urls` are prepended first so explicit shard hints win.
*/
export function augmentSubRequestsWithFavoritesFastReadAndInbox(
requests: TFeedSubRequest[],
favoriteRelays: string[],
blockedRelays: string[],
userInboxReadRelays: string[],
options?: ReadRelayPriorityOptions
): TFeedSubRequest[] {
const max = options?.maxRelays ?? MAX_REQ_RELAY_URLS
const userReadSocialExempt = new Set<string>()
for (const u of userInboxReadRelays) {
const n = normalizeAnyRelayUrl(u) || u.trim()
if (n) userReadSocialExempt.add(n)
}
return requests.map((r) => {
const applySocial =
options?.applySocialKindBlockedFilter !== undefined
? options.applySocialKindBlockedFilter
: relayFilterIncludesSocialKindBlockedKind(r.filter)
const useFavDefaults = options?.useGlobalFavoriteDefaults !== false
const includeFast = options?.includeGlobalFastRead !== false
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults)
const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? [])
const coreLayers = buildReadRelayPriorityLayers({
userReadRelays: userInboxReadRelays,
userWriteRelays: options?.userWriteRelays ?? [],
authorWriteRelays: authorOnly,
favoriteRelays: favorites,
includeGlobalFastRead: includeFast
})
const layers = [relayUrlsLocalsFirst(r.urls), ...coreLayers]
const policyLayers: FeedRelayLayer[] = layers.map((urls, index) => ({
source: index === 0 ? 'explicit' : index === 1 ? 'viewer-read' : 'fallback',
urls
}))
return {
...r,
urls: feedRelayPolicyUrls(policyLayers, {
operation: 'read',
blockedRelays,
maxRelays: max,
applySocialKindBlockedFilter: applySocial,
socialKindBlockedExemptRelays: [...userReadSocialExempt],
allowThirdPartyLocalRelays: true
})
}
})
}