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