diff --git a/package-lock.json b/package-lock.json index 9c193b2d..393081a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.10.0", + "version": "23.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.10.0", + "version": "23.9.2", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 4535eb80..1a2fb41f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.10.0", + "version": "23.9.2", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/Explore/ExploreFavoriteRelays.tsx b/src/components/Explore/ExploreFavoriteRelays.tsx index 266ac175..cbac4207 100644 --- a/src/components/Explore/ExploreFavoriteRelays.tsx +++ b/src/components/Explore/ExploreFavoriteRelays.tsx @@ -1,6 +1,7 @@ import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '@/components/RelaySimpleInfo' import { Button } from '@/components/ui/button' import { DEFAULT_FAVORITE_RELAYS } from '@/constants' +import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import { useFetchRelayInfo } from '@/hooks' import { toRelay, toRelaySettings } from '@/lib/link' import { normalizeUrl, simplifyUrl } from '@/lib/url' @@ -61,6 +62,7 @@ export default function ExploreFavoriteRelays() { const { navigate } = usePrimaryPage() const { push } = useSecondaryPage() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const blockedSet = useMemo( () => new Set(blockedRelays.map((b) => normalizeUrl(b) || b)), @@ -75,6 +77,9 @@ export default function ExploreFavoriteRelays() { if (visible.length > 0) { return { urls: visible, usingDefaults: false } } + if (!useGlobalRelayBootstrap) { + return { urls: [], usingDefaults: false } + } const defaultsFiltered = DEFAULT_FAVORITE_RELAYS.filter((r) => { const k = normalizeUrl(r) || r return k && !blockedSet.has(k) @@ -83,7 +88,7 @@ export default function ExploreFavoriteRelays() { urls: defaultsFiltered.length > 0 ? defaultsFiltered : DEFAULT_FAVORITE_RELAYS, usingDefaults: true } - }, [favoriteRelays, blockedSet]) + }, [favoriteRelays, blockedSet, useGlobalRelayBootstrap]) if (urls.length === 0) return null diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index c5779769..7c7b17bd 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -2,6 +2,7 @@ import storage from '@/services/local-storage.service' import NoteList, { TNoteListRef } from '@/components/NoteList' import { RefreshButton } from '@/components/RefreshButton' import Tabs, { TabDefinition } from '@/components/Tabs' +import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useUserTrust } from '@/contexts/user-trust-context' import { PROFILE_MEDIA_TAB_KINDS, FAST_READ_RELAY_URLS } from '@/constants' @@ -27,7 +28,10 @@ import KindFilter from '../KindFilter' * Home Gallery: favorites (or chip relays) first, then {@link FAST_READ_RELAY_URLS} so NIP-71 / picture / voice * events are not starved when the user’s relay set is mostly text timelines. Deduped by normalized URL. */ -function galleryRelayUrlsMergedWithReadLayer(favoriteUrls: readonly string[]): string[] { +function galleryRelayUrlsMergedWithReadLayer( + favoriteUrls: readonly string[], + mergeGlobalFastRead: boolean +): string[] { const seen = new Set() const out: string[] = [] const add = (raw: string) => { @@ -39,7 +43,9 @@ function galleryRelayUrlsMergedWithReadLayer(favoriteUrls: readonly string[]): s out.push(n) } for (const u of favoriteUrls) add(u) - for (const u of FAST_READ_RELAY_URLS) add(u) + if (mergeGlobalFastRead) { + for (const u of FAST_READ_RELAY_URLS) add(u) + } return out } @@ -155,6 +161,7 @@ const NormalFeed = forwardRef(() => { @@ -219,7 +226,7 @@ const NormalFeed = forwardRef 0 ? mainFeedGalleryRelayUrls : isMainFeed && widenMainGalleryRelays - ? galleryRelayUrlsMergedWithReadLayer(req.urls) + ? galleryRelayUrlsMergedWithReadLayer(req.urls, useGlobalRelayBootstrap) : req.urls, filter: { ...req.filter, kinds: MEDIA_KINDS } })) @@ -230,7 +237,8 @@ const NormalFeed = forwardRef { diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 10cd0468..53ad00b4 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -100,6 +100,7 @@ import { stableFeedKindKey } from '@/features/feed/descriptor' import { mapNoteListSubRequestsForTimeline } from '@/features/feed/note-list-requests' +import { stripNostrLandAggrFromTimelineSubRequests } from '@/lib/home-feed-relays' import { createFetchEventsFeedRuntimeLoader } from '@/features/feed/client-loader' import { FeedRuntime } from '@/features/feed/runtime' import { buildFeedDiagnosticsSnapshot, logFeedDiagnostics } from '@/features/feed/diagnostics' @@ -1893,7 +1894,10 @@ const NoteList = forwardRef( let diskPrimeCancelled = false const primeDiskWhileAwaitingRelayProbe = async () => { try { - const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current) + const mapped = stripNostrLandAggrFromTimelineSubRequests( + feedSubscriptionKey, + mapLiveSubRequestsForTimeline(subRequestsRef.current) + ) .map((req) => isOfflineRef.current ? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) } @@ -1975,7 +1979,10 @@ const NoteList = forwardRef( const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current - const mappedSubRequests = mapLiveSubRequestsForTimeline(subRequestsRef.current) + const mappedSubRequests = stripNostrLandAggrFromTimelineSubRequests( + feedSubscriptionKey, + mapLiveSubRequestsForTimeline(subRequestsRef.current) + ) .map((req) => isOfflineRef.current ? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) } @@ -3064,6 +3071,7 @@ const NoteList = forwardRef( } }, [ timelineSubscriptionKey, + feedSubscriptionKey, sessionSnapshotIdentityKey, subRequestsKey, preserveTimelineOnSubRequestsChange, @@ -3104,7 +3112,10 @@ const NoteList = forwardRef( if (!tk) return let deltaActive = true - const mappedDelta = mapLiveSubRequestsForTimeline(deltas) + const mappedDelta = stripNostrLandAggrFromTimelineSubRequests( + feedSubscriptionKey, + mapLiveSubRequestsForTimeline(deltas) + ) const seeAllNoSpellDelta = seeAllFeedEventsRef.current && !useFilterAsIsRef.current const filterMissingKindsDelta = (f: Filter) => !f.kinds || f.kinds.length === 0 const invalidDelta = mappedDelta.filter(({ urls, filter: f }) => { @@ -3335,6 +3346,7 @@ const NoteList = forwardRef( followingFeedDeltaSubRequestsKey, timelineKey, oneShotFetch, + feedSubscriptionKey, mapLiveSubRequestsForTimeline, areAlgoRelays, allowKindlessRelayExplore, @@ -3511,6 +3523,7 @@ const NoteList = forwardRef( useEffect(() => { if (!timelinePublicReadFallback) return + if (feedSubscriptionKey === 'home-all-favorites') return if (oneShotFetch || areAlgoRelays) return if (!navigator.onLine) return if (feedFullSearchEvents !== null) return @@ -3587,6 +3600,7 @@ const NoteList = forwardRef( })() }, [ timelinePublicReadFallback, + feedSubscriptionKey, oneShotFetch, areAlgoRelays, progressiveWarmupQuery, diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 74eee0d1..94c184f3 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -1084,7 +1084,7 @@ function ReplyNoteList({ if (!rootInfo) return // Type guard try { - // READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes + // READ from: thread hints, author/user NIP-65, favorites, cache — then DEFAULT_FAVORITE_RELAYS fallback. const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) diff --git a/src/hooks/use-global-relay-bootstrap-defaults.ts b/src/hooks/use-global-relay-bootstrap-defaults.ts new file mode 100644 index 00000000..91c9fc28 --- /dev/null +++ b/src/hooks/use-global-relay-bootstrap-defaults.ts @@ -0,0 +1,14 @@ +import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' + +/** @returns true when the app may merge FAST_READ / FAST_WRITE / DEFAULT_FAVORITE bootstrap relays. */ +export function useGlobalRelayBootstrapDefaults(): boolean { + const { pubkey, relayList } = useNostr() + const { favoriteRelays } = useFavoriteRelays() + return viewerUsesGlobalRelayDefaults({ + viewerPubkey: pubkey, + favoriteRelayUrls: favoriteRelays, + relayList + }) +} diff --git a/src/hooks/useProfileAuthorFeedSubRequests.ts b/src/hooks/useProfileAuthorFeedSubRequests.ts index fa92bd7f..332db736 100644 --- a/src/hooks/useProfileAuthorFeedSubRequests.ts +++ b/src/hooks/useProfileAuthorFeedSubRequests.ts @@ -1,4 +1,6 @@ import { buildProfileAuthorSubRequestsFromUrlGroups } from '@/lib/profile-author-subrequests' +import { isSocialKindBlockedKind } from '@/constants' +import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { normalizeAnyRelayUrl } from '@/lib/url' @@ -6,7 +8,6 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostrOptional } from '@/providers/nostr-context' import client from '@/services/client.service' import type { TFeedSubRequest } from '@/types' -import { isSocialKindBlockedKind } from '@/constants' import { useCallback, useEffect, useMemo, useState } from 'react' function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { @@ -41,6 +42,7 @@ export function useProfileAuthorFeedSubRequests({ } { const nostr = useNostrOptional() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const includeAuthorLocalRelays = useMemo(() => { const me = nostr?.pubkey?.trim() @@ -80,7 +82,8 @@ export function useProfileAuthorFeedSubRequests({ emptyAuthor, socialKinds, includeAuthorLocalRelays, - kinds + kinds, + useGlobalRelayBootstrap ) if (!cancelled) { setProvisionalUrls(provisional) @@ -98,7 +101,8 @@ export function useProfileAuthorFeedSubRequests({ authorRl, socialKinds, includeAuthorLocalRelays, - kinds + kinds, + useGlobalRelayBootstrap ) setFullUrls(full) }) @@ -109,7 +113,7 @@ export function useProfileAuthorFeedSubRequests({ // `relayListsKey` already fingerprints `favoriteRelays` + `blockedRelays` by sorted URL content. // Do not list those arrays here: the provider often hands new `[]` references each render and would // retrigger this effect forever (setState → re-render → new refs → effect → …). - }, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, includeAuthorLocalRelays]) + }, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, includeAuthorLocalRelays, useGlobalRelayBootstrap]) const activeUrls = fullUrls?.length ? fullUrls : provisionalUrls diff --git a/src/hooks/useProfilePins.tsx b/src/hooks/useProfilePins.tsx index 18ba0b41..3faf9233 100644 --- a/src/hooks/useProfilePins.tsx +++ b/src/hooks/useProfilePins.tsx @@ -1,4 +1,5 @@ import { Event } from 'nostr-tools' +import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import { buildAuthorInboxOutboxRelayUrls, buildProfileAugmentedReadRelayUrls, @@ -79,6 +80,7 @@ function blockedRelaysContentKey(blockedRelays: string[]): string { export function useProfilePins(pubkey: string | undefined) { const nostr = useNostrOptional() const { blockedRelays } = useFavoriteRelays() + const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays]) const includeAuthorLocalRelays = useMemo(() => { const me = nostr?.pubkey?.trim() @@ -179,7 +181,7 @@ export function useProfilePins(pubkey: string | undefined) { client.fetchPinListEvent(pk).catch(() => undefined) ]) const authorRelays = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays) - const pinsResolveRelays = buildProfileAugmentedReadRelayUrls(authorRelays, blockedRelays) + const pinsResolveRelays = buildProfileAugmentedReadRelayUrls(authorRelays, blockedRelays, 16, useGlobalRelayBootstrap) if (!pinsResolveRelays.length) { if (!paintedLocalPins) setPinEvents([]) return diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index e476a053..0c687782 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -3,6 +3,7 @@ import client, { eventService } from '@/services/client.service' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Event, kinds as nostrKinds, type Filter } from 'nostr-tools' import { CALENDAR_EVENT_KINDS, ExtendedKind, isDocumentRelayKind, isSocialKindBlockedKind } from '@/constants' +import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' @@ -130,6 +131,7 @@ export function useProfileTimeline({ }: UseProfileTimelineOptions): UseProfileTimelineResult { const nostr = useNostrOptional() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const includeAuthorLocalRelays = useMemo(() => { const me = nostr?.pubkey?.trim() if (!me) return false @@ -311,7 +313,8 @@ export function useProfileTimeline({ emptyAuthor, socialKinds, includeAuthorLocalRelays, - kinds + kinds, + useGlobalRelayBootstrap ) const startWave = async (subRequests: ReturnType) => { @@ -406,7 +409,8 @@ export function useProfileTimeline({ authorRl, socialKinds, includeAuthorLocalRelays, - kinds + kinds, + useGlobalRelayBootstrap ) const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls) if (cancelled || deltaUrls.length === 0) return @@ -439,7 +443,7 @@ export function useProfileTimeline({ subscriptionRef.current() subscriptionRef.current = () => {} } - }, [pubkey, cacheKey, JSON.stringify(kinds), limit, refreshToken, relayListsKey, includeAuthorLocalRelays]) + }, [pubkey, cacheKey, JSON.stringify(kinds), limit, refreshToken, relayListsKey, includeAuthorLocalRelays, useGlobalRelayBootstrap]) const refresh = useCallback(() => { subscriptionRef.current() diff --git a/src/lib/account-list-relay-urls.ts b/src/lib/account-list-relay-urls.ts index d88c14ab..f3f80a75 100644 --- a/src/lib/account-list-relay-urls.ts +++ b/src/lib/account-list-relay-urls.ts @@ -1,6 +1,7 @@ import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { buildPrioritizedReadRelayUrls, buildPrioritizedWriteRelayUrls } from '@/lib/relay-url-priority' import { normalizeAnyRelayUrl } from '@/lib/url' +import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import client from '@/services/client.service' /** @@ -14,21 +15,28 @@ export async function buildAccountListRelayUrlsForMerge(options: { }): Promise { const { accountPubkey, favoriteRelays, blockedRelays } = options const myRelayList = await client.fetchRelayList(accountPubkey) - const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays) + const useGlobal = viewerUsesGlobalRelayDefaults({ + viewerPubkey: accountPubkey, + favoriteRelayUrls: favoriteRelays ?? [], + relayList: myRelayList + }) + const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays, useGlobal) const read = buildPrioritizedReadRelayUrls({ userReadRelays: myRelayList.read ?? [], userWriteRelays: myRelayList.write ?? [], favoriteRelays: favoritesTier, blockedRelays, maxRelays: 100, - applySocialKindBlockedFilter: false + applySocialKindBlockedFilter: false, + includeGlobalFastRead: useGlobal }) const write = buildPrioritizedWriteRelayUrls({ userWriteRelays: myRelayList.write ?? [], favoriteRelays: favoritesTier, blockedRelays, maxRelays: 100, - applySocialKindBlockedFilter: false + applySocialKindBlockedFilter: false, + includeGlobalFastWriteReadTails: useGlobal }) const merged = [...read, ...write] return [...new Set(merged.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))] diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 868e7592..32cdbf17 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -16,8 +16,30 @@ const emptyHttpRelayListFields = { httpOriginalRelays: [] as TMailboxRelay[] } -export function getRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) { +export type GetRelayListFromEventOptions = { + /** + * When false, never substitute {@link FAST_READ_RELAY_URLS} / {@link FAST_WRITE_RELAY_URLS} for missing or + * oversized lists (use `[]` or the first 8 entries instead). Default true for anonymous / bootstrap callers. + */ + globalReadWriteFallback?: boolean +} + +export function getRelayListFromEvent( + event?: Event | null, + blockedRelays?: string[], + options?: GetRelayListFromEventOptions +) { + const globalFb = options?.globalReadWriteFallback !== false + if (!event) { + if (!globalFb) { + return { + write: [] as string[], + read: [] as string[], + originalRelays: [] as TRelayList['originalRelays'], + ...emptyHttpRelayListFields + } + } return { write: FAST_WRITE_RELAY_URLS, read: FAST_READ_RELAY_URLS, @@ -61,14 +83,62 @@ export function getRelayListFromEvent(event?: Event | null, blockedRelays?: stri // If there are too many relays, use the default inbox/outbox relays. // Because they don't know anything about relays, their settings cannot be trusted + const readOut = + relayList.read.length && relayList.read.length <= 8 + ? relayList.read + : globalFb + ? FAST_READ_RELAY_URLS + : relayList.read.slice(0, 8) + const writeOut = + relayList.write.length && relayList.write.length <= 8 + ? relayList.write + : globalFb + ? FAST_WRITE_RELAY_URLS + : relayList.write.slice(0, 8) return { - write: relayList.write.length && relayList.write.length <= 8 ? relayList.write : FAST_WRITE_RELAY_URLS, - read: relayList.read.length && relayList.read.length <= 8 ? relayList.read : FAST_READ_RELAY_URLS, + write: writeOut, + read: readOut, originalRelays: relayList.originalRelays, ...emptyHttpRelayListFields } } +/** + * Read-side `r` tags from a relay list event (e.g. kind 10012) without {@link FAST_READ_RELAY_URLS} fallback + * when the list is empty or oversized — for strict “viewer-owned” REQ stacks (relay pulse). + */ +export function getRelayListReadFromEventNoFastFallback( + event: Event | null | undefined, + blockedRelays?: string[] +): string[] { + if (!event) return [] + + const torBrowserDetected = isTorBrowser() + const normalizedBlockedRelays = (blockedRelays || []).map((url) => normalizeUrl(url) || url) + const read: string[] = [] + + event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => { + if (!url || typeof url !== 'string' || url.trim() === '' || url === 'ws://' || url === 'wss://') return + if (!isWebsocketUrl(url)) return + + const normalizedUrl = normalizeUrl(url) + if (!normalizedUrl) return + if (normalizedBlockedRelays.includes(normalizedUrl)) return + if (normalizedUrl.endsWith('.onion/') && !torBrowserDetected) return + + if (type === 'write') return + if (type === 'read') { + read.push(normalizedUrl) + } else { + read.push(normalizedUrl) + } + }) + + if (read.length === 0) return [] + if (read.length <= 8) return read + return read.slice(0, 8) +} + /** Kind 10243: `r` tags with http(s) URLs only; same read/write/both semantics as NIP-65. */ export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) { const out = { diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index cb899824..957f3459 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -18,6 +18,7 @@ import { } from '@/lib/relay-url-priority' import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' +import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults' const blockedSet = (blockedRelays: string[]) => new Set(blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b)) @@ -25,8 +26,8 @@ const blockedSet = (blockedRelays: string[]) => /** * 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}. Same list drives the favorites tier in REQ/publish prioritization and the - * all-favorites home feed. + * {@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. @@ -41,14 +42,15 @@ export function userReadRelaysWithHttp( export function getFavoritesFeedRelayUrls( favoriteRelays: string[], - blockedRelays: string[] + blockedRelays: string[], + useGlobalFavoriteDefaults = true ): string[] { const blocked = blockedSet(blockedRelays) const visible = favoriteRelays.filter((r) => { const k = normalizeAnyRelayUrl(r) || r return k && !blocked.has(k) }) - const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS + const base = visible.length > 0 ? visible : useGlobalFavoriteDefaults ? DEFAULT_FAVORITE_RELAYS : [] return feedRelayPolicyUrls( [{ source: 'favorites', urls: base }], { @@ -107,10 +109,14 @@ const PROFILE_AUGMENTED_READ_MAX_RELAYS = 16 export function buildProfileAugmentedReadRelayUrls( authorRelayUrls: string[], blockedRelays: string[], - maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS + maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS, + useGlobalRelayBootstrap = true ): string[] { const readOnlyLayer = READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) - const fastReadLayer = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) + const fastReadLayer = + useGlobalRelayBootstrap + ? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]) + : [] const merged = mergeRelayUrlLayers([authorRelayUrls, readOnlyLayer, fastReadLayer], blockedRelays) return merged.slice(0, maxRelays) } @@ -127,6 +133,12 @@ export type ReadRelayPriorityOptions = { * 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 } /** @@ -138,7 +150,9 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox( userInboxReadRelays: string[], options?: ReadRelayPriorityOptions ): string[] { - const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + const useFavDefaults = options?.useGlobalFavoriteDefaults !== false + const includeFast = options?.includeGlobalFastRead !== false + const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults) return buildPrioritizedReadRelayUrls({ userReadRelays: userInboxReadRelays, userWriteRelays: options?.userWriteRelays ?? [], @@ -146,7 +160,8 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays: favorites, blockedRelays, maxRelays: options?.maxRelays, - applySocialKindBlockedFilter: options?.applySocialKindBlockedFilter + applySocialKindBlockedFilter: options?.applySocialKindBlockedFilter, + includeGlobalFastRead: includeFast }) } @@ -169,8 +184,11 @@ export function buildProfilePageReadRelayUrls( kindsIncludeSocialBlockedKind: boolean, includeAuthorLocalRelays = false, /** When the timeline includes document kinds (30023, 30040, …), add document index relays and raise the cap. */ - profileKindsHint?: readonly number[] + 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 @@ -180,8 +198,10 @@ export function buildProfilePageReadRelayUrls( const authorWrite = [...(list.httpWrite ?? []), ...(list.write ?? [])] const authorHasNoNip65 = authorRead.length === 0 && authorWrite.length === 0 - const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) - const fastReadLayer = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] + 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( @@ -202,7 +222,8 @@ export function buildProfilePageReadRelayUrls( ) /** Authors without kind 10002: widen REQ targets so notes/metadata are still discoverable on index relays. */ if (authorHasNoNip65) { - const profileFetchLayer = PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] + const profileSource = useGlobal ? PROFILE_FETCH_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer() + const profileFetchLayer = profileSource.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] return mergeRelayUrlLayers([urls, profileFetchLayer], blockedRelays).slice(0, maxRelays + 8) } if (wantsDocumentLayer) { @@ -235,7 +256,9 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( ? options.applySocialKindBlockedFilter : relayFilterIncludesSocialKindBlockedKind(r.filter) - const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + const useFavDefaults = options?.useGlobalFavoriteDefaults !== false + const includeFast = options?.includeGlobalFastRead !== false + const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults) const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? []) @@ -243,7 +266,8 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( userReadRelays: userInboxReadRelays, userWriteRelays: options?.userWriteRelays ?? [], authorWriteRelays: authorOnly, - favoriteRelays: favorites + favoriteRelays: favorites, + includeGlobalFastRead: includeFast }) const layers = [relayUrlsLocalsFirst(r.urls), ...coreLayers] diff --git a/src/lib/home-feed-relays.ts b/src/lib/home-feed-relays.ts index ca0cb6d1..d119d54b 100644 --- a/src/lib/home-feed-relays.ts +++ b/src/lib/home-feed-relays.ts @@ -1,12 +1,24 @@ +import { MAX_REQ_RELAY_URLS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' +import { getHttpRelayListFromEvent, getRelayListReadFromEventNoFastFallback } from '@/lib/event-metadata' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { normalizeAnyRelayUrl } from '@/lib/url' +import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' +import type { Event } from 'nostr-tools' function relayUrlIsNostrLandAggr(url: string): boolean { - const normalized = (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() - const aggr = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase() - return normalized === aggr + const raw = url.trim() + if (!raw) return false + const normalized = (normalizeAnyRelayUrl(raw) || raw).toLowerCase() + const aggrCanon = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase() + if (normalized === aggrCanon) return true + try { + const u = new URL(normalized) + return u.hostname.toLowerCase() === 'aggr.nostr.land' + } catch { + return /^wss:\/\/aggr\.nostr\.land\/?$/i.test(normalized) + } } /** Drop nostr.land aggregate from REQ stacks where it must not appear (e.g. home feeds). */ @@ -14,15 +26,36 @@ export function stripNostrLandAggrFromRelayUrls(urls: readonly string[]): string return urls.filter((url) => !relayUrlIsNostrLandAggr(url)) } +/** + * Home “Lieblings-Relays” feed must never open timeline REQs to nostr.land’s aggregate relay (reserved for + * threads / profiles / spells). Strips aggr from every shard after mapping, including trailing-slash variants. + */ +export function stripNostrLandAggrFromTimelineSubRequests( + feedSubscriptionKey: string | undefined, + requests: readonly T[] +): T[] { + if (feedSubscriptionKey !== 'home-all-favorites') { + return requests.slice() as T[] + } + return requests.map((r) => ({ + ...r, + urls: stripNostrLandAggrFromRelayUrls(r.urls) + })) as T[] +} + export function buildAllFavoritesFeedRelayUrls( favoriteRelays: string[], blockedRelays: string[], - extraFeedRelayUrls: string[] + extraFeedRelayUrls: string[], + useGlobalFavoriteDefaults = true ): string[] { return stripNostrLandAggrFromRelayUrls( feedRelayPolicyUrls( [ - { source: 'favorites', urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) }, + { + source: 'favorites', + urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults) + }, { source: 'fallback', urls: extraFeedRelayUrls } ], { @@ -35,3 +68,62 @@ export function buildAllFavoritesFeedRelayUrls( ) ) } + +/** + * Relay pulse (sidebar active authors): only the viewer’s own stack — favorites (+ relay sets), + * NIP-65 read, kind 10012 cache read, and HTTP index reads — never the global fast-read layer. + */ +export function buildRelayPulseQueryRelayUrls(options: { + viewerPubkey: string | null | undefined + favoriteRelayUrls: string[] + blockedRelays: string[] + relayList: { read?: string[]; httpRead?: string[] } | null | undefined + cacheRelayListEvent: Event | null | undefined + httpRelayListEvent: Event | null | undefined +}): string[] { + const { + viewerPubkey, + favoriteRelayUrls, + blockedRelays, + relayList, + cacheRelayListEvent, + httpRelayListEvent + } = options + + const useGlobalFavoriteDefaults = viewerUsesGlobalRelayDefaults({ + viewerPubkey, + favoriteRelayUrls, + relayList + }) + const primaryRelays = getFavoritesFeedRelayUrls(favoriteRelayUrls, blockedRelays, useGlobalFavoriteDefaults) + const inboxRelayUrls = relayList?.read?.length ? relayList.read : [] + + const cacheRelayUrls: string[] = [] + if (cacheRelayListEvent) { + cacheRelayUrls.push(...getRelayListReadFromEventNoFastFallback(cacheRelayListEvent, blockedRelays)) + } + + const httpRelayUrls: string[] = [...(relayList?.httpRead ?? [])] + if (httpRelayListEvent) { + httpRelayUrls.push(...getHttpRelayListFromEvent(httpRelayListEvent, blockedRelays).httpRead) + } + + return stripNostrLandAggrFromRelayUrls( + feedRelayPolicyUrls( + [ + { source: 'favorites', urls: primaryRelays }, + { source: 'viewer-read', urls: inboxRelayUrls }, + { source: 'cache', urls: cacheRelayUrls }, + { source: 'http-index', urls: httpRelayUrls } + ], + { + operation: 'read', + blockedRelays, + nostrLandAggr: 'never', + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true, + maxRelays: MAX_REQ_RELAY_URLS + } + ) + ) +} diff --git a/src/lib/live-activities.ts b/src/lib/live-activities.ts index 31c40ddd..7bdb2e86 100644 --- a/src/lib/live-activities.ts +++ b/src/lib/live-activities.ts @@ -662,21 +662,31 @@ export function buildLiveActivitiesRelayUrls(options: { blockedRelays: string[] relayListRead: string[] relayListWrite: string[] + /** + * When false for a logged-in viewer with their own relay stack, omit {@link FAST_READ_RELAY_URLS} and skip + * {@link DEFAULT_FAVORITE_RELAYS} when favorites are empty. Default true (signed-out / bootstrap). + */ + includeGlobalFastRead?: boolean }): string[] { const { loggedIn, favoriteRelays, blockedRelays, relayListRead, relayListWrite } = options + const includeFast = options.includeGlobalFastRead !== false + const useGlobalFavoriteDefaults = includeFast if (loggedIn) { - const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)) + const fav = relayUrlsLocalsFirst( + getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults) + ) const read = relayUrlsLocalsFirst(relayListRead) const write = relayUrlsLocalsFirst(relayListWrite) const fast = dedupeNormalizeRelayUrlsOrdered( FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) ) - return feedRelayPolicyUrls([ - { source: 'favorites', urls: fav }, - { source: 'viewer-read', urls: read }, - { source: 'viewer-write', urls: write }, - { source: 'fast-read', urls: fast } - ], { + const layers = [ + { source: 'favorites' as const, urls: fav }, + { source: 'viewer-read' as const, urls: read }, + { source: 'viewer-write' as const, urls: write }, + ...(includeFast ? [{ source: 'fast-read' as const, urls: fast }] : []) + ] + return feedRelayPolicyUrls(layers, { operation: 'read', blockedRelays, maxRelays: MAX_REQ_RELAY_URLS, @@ -684,20 +694,23 @@ export function buildLiveActivitiesRelayUrls(options: { allowThirdPartyLocalRelays: true }) } - const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)) + const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, true)) const fast = dedupeNormalizeRelayUrlsOrdered( FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) ) - return feedRelayPolicyUrls([ - { source: 'favorites', urls: fav }, - { source: 'fast-read', urls: fast } - ], { - operation: 'read', - blockedRelays, - maxRelays: MAX_REQ_RELAY_URLS, - applySocialKindBlockedFilter: true, - allowThirdPartyLocalRelays: true - }) + return feedRelayPolicyUrls( + [ + { source: 'favorites', urls: fav }, + { source: 'fast-read', urls: fast } + ], + { + operation: 'read', + blockedRelays, + maxRelays: MAX_REQ_RELAY_URLS, + applySocialKindBlockedFilter: true, + allowThirdPartyLocalRelays: true + } + ) } /** Milliseconds until the next wall-clock quarter hour (:00, :15, :30, :45). */ diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 1dda86d2..20ab72a9 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -11,10 +11,11 @@ import { FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { getCacheRelayUrls } from './private-relays' +import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import client from '@/services/client.service' import logger from '@/lib/logger' import type { Event } from 'nostr-tools' @@ -249,21 +250,38 @@ export async function buildExploreProfileAndUserRelayList( if (!userPubkey) { return boot } + let useGlobal = true + try { + const [fav, peeked] = await Promise.all([ + client.fetchFavoriteRelays(userPubkey).catch(() => [] as string[]), + client.peekRelayListFromStorage(userPubkey).catch(() => null) + ]) + useGlobal = viewerUsesGlobalRelayDefaults({ + viewerPubkey: userPubkey, + favoriteRelayUrls: fav, + relayList: peeked ?? undefined + }) + } catch { + useGlobal = true + } try { const built = await buildComprehensiveRelayList({ userPubkey, includeUserOwnRelays: true, includeProfileFetchRelays: true, - includeFastReadRelays: true, + includeFastReadRelays: useGlobal, includeFavoriteRelays: false, includeLocalRelays: true, includeFastWriteRelays: false, includeSearchableRelays: false }) + if (!useGlobal) { + return built + } if (!built.length) return boot return dedupeNormalizedRelayUrls([...boot, ...built]) } catch { - return boot + return useGlobal ? boot : [] } } @@ -330,6 +348,7 @@ export async function buildPollResultsReadRelayUrls(options: { let authorReadSlice: string[] = [] let viewerReadSlice: string[] = [] + let useGlobalFastRead = true try { const [authorRl, viewerRl] = await Promise.all([ pollEvent.pubkey ? client.peekRelayListFromStorage(pollEvent.pubkey) : Promise.resolve(null), @@ -340,6 +359,11 @@ export async function buildPollResultsReadRelayUrls(options: { } if (viewerRl) { viewerReadSlice = userReadRelaysWithHttp(viewerRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE) + useGlobalFastRead = viewerUsesGlobalRelayDefaults({ + viewerPubkey, + favoriteRelayUrls: viewerFavoriteRelayUrls, + relayList: viewerRl + }) } } catch { /* ignore — poll results still use other layers */ @@ -357,7 +381,9 @@ export async function buildPollResultsReadRelayUrls(options: { } } - pushLayer([...FAST_READ_RELAY_URLS]) + if (useGlobalFastRead) { + pushLayer([...FAST_READ_RELAY_URLS]) + } pushLayer(authorReadSlice) return feedRelayPolicyUrls([{ source: 'fallback', urls: ordered }], { @@ -370,8 +396,8 @@ export async function buildPollResultsReadRelayUrls(options: { } /** - * Build relay list for reading replies/comments - * READ from: FAST_READ_RELAY_URLS + user's inboxes/outboxes + local relays + OP author's outboxes + * Build relay list for reading replies/comments: thread hints, author/user NIP-65, favorites, cache — + * then default favorite relays only when global bootstrap applies (signed-out or no configured stack). */ export async function buildReplyReadRelayList( opAuthorPubkey: string | undefined, @@ -379,18 +405,34 @@ export async function buildReplyReadRelayList( blockedRelays: string[] = [], threadRelayHints: string[] = [] ): Promise { - return buildComprehensiveRelayList({ + let useGlobal = true + if (userPubkey) { + try { + const [fav, rl] = await Promise.all([ + client.fetchFavoriteRelays(userPubkey).catch(() => [] as string[]), + client.peekRelayListFromStorage(userPubkey) + ]) + useGlobal = viewerUsesGlobalRelayDefaults({ + viewerPubkey: userPubkey, + favoriteRelayUrls: fav, + relayList: rl ?? undefined + }) + } catch { + useGlobal = true + } + } + const scoped = await buildComprehensiveRelayList({ authorPubkey: opAuthorPubkey, userPubkey, relayHints: threadRelayHints, includeUserOwnRelays: Boolean(userPubkey), - includeFastReadRelays: true, - includeSearchableRelays: true, + includeFastReadRelays: useGlobal, + includeSearchableRelays: false, includeLocalRelays: true, - /** Same menu list as timelines — threads often opened from favorites. */ includeFavoriteRelays: Boolean(userPubkey), - /** FAST_READ + SEARCHABLE before author/user NIP-65 slices so broken personal relays do not starve thread REQ under the global connection cap. */ - preferPublicReadRelaysEarly: true, + preferPublicReadRelaysEarly: false, + includeProfileFetchRelays: useGlobal, blockedRelays }) + return mergeRelayUrlLayers([scoped, defaultFavoriteRelaysForViewer(useGlobal)], blockedRelays) } diff --git a/src/lib/relay-url-priority.ts b/src/lib/relay-url-priority.ts index aa641964..e0bda12e 100644 --- a/src/lib/relay-url-priority.ts +++ b/src/lib/relay-url-priority.ts @@ -71,6 +71,8 @@ export function buildReadRelayPriorityLayers(opts: { userWriteRelays?: string[] authorWriteRelays?: string[] favoriteRelays: string[] + /** When false, omit the global FAST_READ tier (logged-in users with their own relay stack). Default true. */ + includeGlobalFastRead?: boolean }): string[][] { const userWrite = opts.userWriteRelays ?? [] const writeLocals = userWrite.filter((u) => { @@ -81,7 +83,7 @@ export function buildReadRelayPriorityLayers(opts: { const tier1 = dedupeNormalizeRelayUrlsOrdered([...writeLocals, ...userReadOrdered]) const tier2 = dedupeNormalizeRelayUrlsOrdered(opts.authorWriteRelays ?? []) const tier3 = dedupeNormalizeRelayUrlsOrdered(opts.favoriteRelays ?? []) - const tier4 = normFastRead() + const tier4 = opts.includeGlobalFastRead === false ? [] : normFastRead() return [tier1, tier2, tier3, tier4] } @@ -98,6 +100,8 @@ export function buildPrioritizedReadRelayUrls(opts: { maxRelays?: number /** Default true: strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} for social-kind-heavy timelines. Set false for other queries. */ applySocialKindBlockedFilter?: boolean + /** Default true: append global FAST_READ tier. */ + includeGlobalFastRead?: boolean }): string[] { const max = opts.maxRelays ?? MAX_REQ_RELAY_URLS const applySocial = opts.applySocialKindBlockedFilter !== false @@ -110,7 +114,8 @@ export function buildPrioritizedReadRelayUrls(opts: { userReadRelays: opts.userReadRelays, userWriteRelays: opts.userWriteRelays, authorWriteRelays: opts.authorWriteRelays, - favoriteRelays: opts.favoriteRelays + favoriteRelays: opts.favoriteRelays, + includeGlobalFastRead: opts.includeGlobalFastRead }) const policyLayers: FeedRelayLayer[] = [ { source: 'viewer-read', urls: layers[0] ?? [] }, @@ -136,11 +141,16 @@ function buildWriteRelayPriorityLayers(opts: { authorReadRelays?: string[] favoriteRelays?: string[] extraRelays?: string[] + /** When false, omit global FAST_WRITE and FAST_READ tails. Default true. */ + includeGlobalFastWriteReadTails?: boolean }): string[][] { const tier1 = relayUrlsLocalsFirst(opts.userWriteRelays) const tier2 = filterContextAuthorReadRelaysForPublish(opts.authorReadRelays ?? []) const tier3 = dedupeNormalizeRelayUrlsOrdered(opts.favoriteRelays ?? []) const tier4 = dedupeNormalizeRelayUrlsOrdered(opts.extraRelays ?? []) + if (opts.includeGlobalFastWriteReadTails === false) { + return [tier1, tier2, tier3, tier4, [], []] + } const tier5 = normFastWrite() const tier6 = normFastRead() return [tier1, tier2, tier3, tier4, tier5, tier6] @@ -158,13 +168,16 @@ export function buildPrioritizedWriteRelayUrls(opts: { maxRelays?: number /** When true, strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before capping (social kinds). */ applySocialKindBlockedFilter?: boolean + /** Default true: append FAST_WRITE then FAST_READ tiers. */ + includeGlobalFastWriteReadTails?: boolean }): string[] { const max = opts.maxRelays ?? MAX_PUBLISH_RELAYS const layers = buildWriteRelayPriorityLayers({ userWriteRelays: opts.userWriteRelays, authorReadRelays: opts.authorReadRelays, favoriteRelays: opts.favoriteRelays, - extraRelays: opts.extraRelays + extraRelays: opts.extraRelays, + includeGlobalFastWriteReadTails: opts.includeGlobalFastWriteReadTails }) return feedRelayPolicyUrls([ { source: 'viewer-write', urls: layers[0] ?? [] }, diff --git a/src/lib/viewer-relay-defaults.ts b/src/lib/viewer-relay-defaults.ts new file mode 100644 index 00000000..d3c84786 --- /dev/null +++ b/src/lib/viewer-relay-defaults.ts @@ -0,0 +1,54 @@ +import { + DEFAULT_FAVORITE_RELAYS, + FAST_READ_RELAY_URLS, + PROFILE_FETCH_RELAY_URLS +} from '@/constants' +import { normalizeUrl } from '@/lib/url' + +export type ViewerRelayListLike = { + read?: string[] | null + write?: string[] | null + httpRead?: string[] | null +} | null | undefined + +/** + * Use {@link DEFAULT_FAVORITE_RELAYS}, {@link FAST_READ_RELAY_URLS}, and {@link FAST_WRITE_RELAY_URLS} only when + * the user is not signed in, or when they are signed in but have configured neither favorite relays nor a NIP-65 + * (kind 10002 / HTTP index) relay list. Otherwise REQ/publish stacks should stay on their own relays. + */ +export function viewerUsesGlobalRelayDefaults(args: { + viewerPubkey: string | null | undefined + favoriteRelayUrls: readonly string[] + relayList: ViewerRelayListLike +}): boolean { + if (!args.viewerPubkey?.trim()) return true + const hasFavorites = args.favoriteRelayUrls.some((u) => typeof u === 'string' && u.trim().length > 0) + const rl = args.relayList + const hasNip65 = + (rl?.read?.length ?? 0) > 0 || + (rl?.write?.length ?? 0) > 0 || + (rl?.httpRead?.length ?? 0) > 0 + return !(hasFavorites || hasNip65) +} + +const fastReadKeySet = (): Set => { + const s = new Set() + for (const u of FAST_READ_RELAY_URLS) { + const n = (normalizeUrl(u) || u).toLowerCase() + if (n) s.add(n) + } + return s +} + +/** PROFILE_FETCH stack with {@link FAST_READ_RELAY_URLS} entries removed (order preserved). */ +export function profileFetchRelayUrlsWithoutFastReadLayer(): string[] { + const drop = fastReadKeySet() + return PROFILE_FETCH_RELAY_URLS.filter((u) => { + const n = (normalizeUrl(u) || u).toLowerCase() + return n && !drop.has(n) + }) +} + +export function defaultFavoriteRelaysForViewer(useGlobalDefaults: boolean): string[] { + return useGlobalDefaults ? [...DEFAULT_FAVORITE_RELAYS] : [] +} diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 9ef460f1..f7c684f8 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -1,21 +1,12 @@ import NormalFeed from '@/components/NormalFeed' import type { TNoteListRef } from '@/components/NoteList' -import { isReplyNoteEvent } from '@/lib/event' -import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { checkAlgoRelay } from '@/lib/relay' -import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { normalizeUrl } from '@/lib/url' import { useFeed } from '@/providers/feed-context' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' -import client from '@/services/client.service' import relayInfoService from '@/services/relay-info.service' -import { kinds, type Event } from 'nostr-tools' -import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' - -const AGGR_RELAY_KEY = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase() - -function relaySeenKey(url: string): string { - return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() -} +import { kinds } from 'nostr-tools' +import React, { forwardRef, useEffect, useMemo, useState } from 'react' const RelaysFeed = forwardRef< TNoteListRef, @@ -106,28 +97,6 @@ const RelaysFeed = forwardRef< } ] }, [canRenderFeed, replyRelayUrls, relayUrls, defaultKinds]) - const hideAggrOnlyMainFeedEvent = useCallback( - (event: Event) => { - const seenRelays = client.getSeenEventRelayUrls(event.id).map(relaySeenKey) - if (!seenRelays.includes(AGGR_RELAY_KEY)) return false - const allowedRelays = new Set(relayUrls.map(relaySeenKey)) - return !seenRelays.some((relay) => relay !== AGGR_RELAY_KEY && allowedRelays.has(relay)) - }, - [relayUrls] - ) - const hideAggrOnlyReplyGalleryStackEvent = useCallback( - (event: Event) => { - const seenRelays = client.getSeenEventRelayUrls(event.id).map(relaySeenKey) - if (!seenRelays.includes(AGGR_RELAY_KEY)) return false - const allowedRelays = new Set(replyRelayUrls.map(relaySeenKey)) - return !seenRelays.some((relay) => relay !== AGGR_RELAY_KEY && allowedRelays.has(relay)) - }, - [replyRelayUrls] - ) - const hideAggrOnlyNonReplyEvent = useCallback( - (event: Event) => hideAggrOnlyMainFeedEvent(event) && !isReplyNoteEvent(event), - [hideAggrOnlyMainFeedEvent] - ) if (!canRenderFeed) { return null @@ -150,10 +119,6 @@ const RelaysFeed = forwardRef< feedTimelineScopeKey="all-favorites" showFeedClientFilter hostPrimaryPageName="feed" - extraShouldHideEvent={hideAggrOnlyMainFeedEvent} - extraShouldHideGalleryEvent={hideAggrOnlyReplyGalleryStackEvent} - extraShouldHideRepliesEvent={hideAggrOnlyNonReplyEvent} - timelinePublicReadFallback /> ) }) diff --git a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx index f9bc4693..efebb569 100644 --- a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx +++ b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx @@ -18,6 +18,7 @@ import { dedupeAppendIds, resolveSpellListATags } from '@/lib/spell-list-import' +import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import { useBookmarks } from '@/providers/bookmarks-context' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' @@ -294,6 +295,7 @@ export default function CreateSpellDialog({ const { pubkey, publish, checkLogin, relayList } = useNostr() const { addBookmark, removeBookmark } = useBookmarks() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const [form, setForm] = useState(DEFAULT_PARAMS) const [saving, setSaving] = useState(false) const scrollBodyRef = useRef(null) @@ -325,7 +327,8 @@ export default function CreateSpellDialog({ setForm(draft) setListImportNotices(notices) const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { - userWriteRelays: relayList?.write ?? [] + userWriteRelays: relayList?.write ?? [], + useGlobalRelayBootstrap }) if (pendingATags.length === 0) return void resolveSpellListATags(pendingATags, urls).then(({ ids, notices: extra }) => { @@ -335,7 +338,7 @@ export default function CreateSpellDialog({ if (extra.length) setListImportNotices((n) => [...n, ...extra]) }) }, - [favoriteRelays, blockedRelays, relayList] + [favoriteRelays, blockedRelays, relayList, useGlobalRelayBootstrap] ) const handleLoadManualList = useCallback(async () => { diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index eb792828..2098e4e6 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -17,6 +17,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' +import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import { usePrimaryPage } from '@/contexts/primary-page-context' import logger from '@/lib/logger' @@ -89,6 +90,7 @@ const SpellsPage = forwardRef(function SpellsPage( const { hideUntrustedNotifications } = useUserTrust() const { isSmallScreen } = useScreenSize() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const { showKinds: kindFilterShowKinds, showKind1OPs, @@ -369,7 +371,8 @@ const SpellsPage = forwardRef(function SpellsPage( } const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { - userWriteRelays: relayList?.write ?? [] + userWriteRelays: relayList?.write ?? [], + useGlobalRelayBootstrap }) const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) const authorAllowlist = new Set(catalogAuthors) @@ -484,7 +487,8 @@ const SpellsPage = forwardRef(function SpellsPage( relayMailboxStableKey, loadSpells, contactsSyncKey, - spellCatalogManualRefreshKey + spellCatalogManualRefreshKey, + useGlobalRelayBootstrap ]) useEffect(() => { diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 6b788d33..b4259855 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -14,6 +14,7 @@ import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { toProfileList } from '@/lib/link' import { @@ -47,6 +48,7 @@ const NoteListPage = forwardRef(({ index, hid const { push } = useSecondaryPage() const { relayList, pubkey } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const interestList = useInterestListOptional() const isSubscribed = interestList?.isSubscribed ?? (() => false) const subscribe = interestList?.subscribe ?? (async () => {}) @@ -109,7 +111,9 @@ const NoteListPage = forwardRef(({ index, hid .filter((k) => !isNaN(k)) const readUrlOpts = { userWriteRelays: relayList?.write ?? [], - applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind) + applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind), + useGlobalFavoriteDefaults: useGlobalRelayBootstrap, + includeGlobalFastRead: useGlobalRelayBootstrap } const hashtag = searchParams.get('t') const searchFromUrl = searchParams.get('s') @@ -205,7 +209,7 @@ const NoteListPage = forwardRef(({ index, hid favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [] } + { userWriteRelays: relayList?.write ?? [], useGlobalFavoriteDefaults: useGlobalRelayBootstrap, includeGlobalFastRead: useGlobalRelayBootstrap } ) } ]) @@ -237,7 +241,11 @@ const NoteListPage = forwardRef(({ index, hid favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [] } + { + userWriteRelays: relayList?.write ?? [], + useGlobalFavoriteDefaults: useGlobalRelayBootstrap, + includeGlobalFastRead: useGlobalRelayBootstrap + } ) ) setControls( @@ -294,7 +302,8 @@ const NoteListPage = forwardRef(({ index, hid t, isSubscribed, subscribe, - client + client, + useGlobalRelayBootstrap ]) // Initialize on mount diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx index 21bd5055..18526323 100644 --- a/src/providers/FavoriteRelaysActivityProvider.tsx +++ b/src/providers/FavoriteRelaysActivityProvider.tsx @@ -1,8 +1,7 @@ import storage from '@/services/local-storage.service' import logger from '@/lib/logger' import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants' -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' -import { buildLiveActivitiesRelayUrls } from '@/lib/live-activities' +import { buildRelayPulseQueryRelayUrls } from '@/lib/home-feed-relays' import { readRelayPulseActiveNpubsCache, writeRelayPulseActiveNpubsCache @@ -25,9 +24,14 @@ import { const ACTIVE_WINDOW_SEC = 3600 /** Recent slice (seconds): newest notes dominate global REQ limits; a shorter window improves author diversity. */ const PULSE_RECENT_TAIL_SEC = 1200 -/** Per-REQ event cap; two time slices run in parallel and merge (see {@link fetchRelayPulseNoteEvents}). */ -const PULSE_REQ_LIMIT_RECENT = 900 -const PULSE_REQ_LIMIT_EARLIER = 1400 +/** + * Per-REQ event caps for the sidebar relay pulse. Keep small: each event is Schnorr-verified on the WebSocket + * thread in nostr-tools; limits of 900+1400 caused main-thread timeouts in verifyEvent when relays returned large batches. + */ +const PULSE_REQ_LIMIT_RECENT = 120 +const PULSE_REQ_LIMIT_EARLIER = 160 +/** Hard cap after merging two slices — enough for pubkey diversity without megabytes of verification work. */ +const PULSE_MERGED_EVENT_CAP = 400 const FETCH_RETRY_DELAY_MS = 2500 /** Wall-clock cadence while the tab is visible */ const POLL_INTERVAL_MS = 60 * 60 * 1000 @@ -94,7 +98,9 @@ async function fetchRelayPulseNoteEvents( for (const r of settled) { if (r.status === 'fulfilled') merged.push(...r.value) } - return mergeRelayPulseEventsById(merged) + const deduped = mergeRelayPulseEventsById(merged) + deduped.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id)) + return deduped.slice(0, PULSE_MERGED_EVENT_CAP) } function aggregatePubkeysByRecency(events: { pubkey: string; created_at: number }[]): string[] { @@ -139,8 +145,9 @@ function partitionByFollows(orderedPubkeys: string[], followings: string[]) { } export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) { - const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const { pubkey: viewerPubkey, followListEvent, relayList } = useNostr() + const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays() + const { pubkey: viewerPubkey, followListEvent, relayList, cacheRelayListEvent, httpRelayListEvent } = + useNostr() const followings = useMemo( () => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []), [followListEvent] @@ -160,85 +167,87 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R orderedPubkeysRef.current = orderedPubkeys /** After restoring from disk, ignore the first empty network result (timeouts / slow relays), then behave normally. */ const skipFirstEmptyNetworkOverwriteRef = useRef(false) + const favoriteRelayUrlsForPulse = useMemo( + () => [...favoriteRelays, ...relaySets.flatMap((rs) => rs.relayUrls)], + [favoriteRelays, relaySets] + ) + const pulseQueryUrls = useMemo( () => - buildLiveActivitiesRelayUrls({ - loggedIn: !!viewerPubkey, - favoriteRelays, + buildRelayPulseQueryRelayUrls({ + viewerPubkey, + favoriteRelayUrls: favoriteRelayUrlsForPulse, blockedRelays, - relayListRead: userReadRelaysWithHttp(relayList), - relayListWrite: relayList?.write ?? [] + relayList, + cacheRelayListEvent, + httpRelayListEvent }), - [viewerPubkey, favoriteRelays, blockedRelays, relayList] + [ + viewerPubkey, + favoriteRelayUrlsForPulse, + blockedRelays, + relayList, + cacheRelayListEvent, + httpRelayListEvent + ] ) const relayKey = useMemo(() => pulseQueryUrls.join('\n'), [pulseQueryUrls]) - const fetchActive = useCallback( - async (useDefaultRelays = false) => { - const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null - const urls = useDefaultRelays - ? buildLiveActivitiesRelayUrls({ - loggedIn: false, - favoriteRelays: [], - blockedRelays, - relayListRead: [], - relayListWrite: [] - }) - : pulseQueryUrls - if (urls.length === 0) { - setLoading(false) - setRelayActivityReady(true) - const now = Date.now() - setOrderedPubkeys([]) + const fetchActive = useCallback(async () => { + const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null + const urls = pulseQueryUrls + if (urls.length === 0) { + setLoading(false) + setRelayActivityReady(true) + const now = Date.now() + setOrderedPubkeys([]) + lastCompletedFetchAtRef.current = now + setLastFetchedAtMs(now) + writeRelayPulseActiveNpubsCache({ + relayKey, + viewerPubkey: cacheViewer, + orderedPubkeys: [], + lastFetchedAtMs: now + }) + return + } + setLoading(true) + const anchorSec = Math.floor(Date.now() / 1000) + try { + const events = await fetchRelayPulseNoteEvents(urls, anchorSec) + const now = Date.now() + const nextPubkeys = aggregatePubkeysByRecency(events) + const prev = orderedPubkeysRef.current + if ( + skipFirstEmptyNetworkOverwriteRef.current && + nextPubkeys.length === 0 && + prev.length > 0 + ) { + skipFirstEmptyNetworkOverwriteRef.current = false + logger.debug('[FavoriteRelaysActivity] kept relay pulse from cache; first fetch returned empty') + } else { + skipFirstEmptyNetworkOverwriteRef.current = false + setOrderedPubkeys(nextPubkeys) lastCompletedFetchAtRef.current = now setLastFetchedAtMs(now) writeRelayPulseActiveNpubsCache({ relayKey, viewerPubkey: cacheViewer, - orderedPubkeys: [], + orderedPubkeys: nextPubkeys, lastFetchedAtMs: now }) - return } - setLoading(true) - const anchorSec = Math.floor(Date.now() / 1000) - try { - const events = await fetchRelayPulseNoteEvents(urls, anchorSec) - const now = Date.now() - const nextPubkeys = aggregatePubkeysByRecency(events) - const prev = orderedPubkeysRef.current - if ( - skipFirstEmptyNetworkOverwriteRef.current && - nextPubkeys.length === 0 && - prev.length > 0 - ) { - skipFirstEmptyNetworkOverwriteRef.current = false - logger.debug('[FavoriteRelaysActivity] kept relay pulse from cache; first fetch returned empty') - } else { - skipFirstEmptyNetworkOverwriteRef.current = false - setOrderedPubkeys(nextPubkeys) - lastCompletedFetchAtRef.current = now - setLastFetchedAtMs(now) - writeRelayPulseActiveNpubsCache({ - relayKey, - viewerPubkey: cacheViewer, - orderedPubkeys: nextPubkeys, - lastFetchedAtMs: now - }) - } - } catch (error) { - logger.debug('[FavoriteRelaysActivity] fetch failed', { error, useDefaultRelays }) - if (!useDefaultRelays && favoriteRelays.length > 0) { - setTimeout(() => void fetchRef.current(true), FETCH_RETRY_DELAY_MS) - } - } finally { - setLoading(false) - setRelayActivityReady(true) + } catch (error) { + logger.debug('[FavoriteRelaysActivity] fetch failed', { error }) + if (pulseQueryUrls.length > 0) { + setTimeout(() => void fetchRef.current(), FETCH_RETRY_DELAY_MS) } - }, - [favoriteRelays, blockedRelays, relayKey, viewerPubkey, pulseQueryUrls] - ) + } finally { + setLoading(false) + setRelayActivityReady(true) + } + }, [relayKey, viewerPubkey, pulseQueryUrls]) const fetchRef = useRef(fetchActive) fetchRef.current = fetchActive diff --git a/src/providers/FavoriteRelaysProvider.tsx b/src/providers/FavoriteRelaysProvider.tsx index 36cd6299..77131aa5 100644 --- a/src/providers/FavoriteRelaysProvider.tsx +++ b/src/providers/FavoriteRelaysProvider.tsx @@ -1,4 +1,5 @@ import { FAST_READ_RELAY_URLS, DEFAULT_FAVORITE_RELAYS } from '@/constants' +import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import storage from '@/services/local-storage.service' import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event' import { getReplaceableEventIdentifier } from '@/lib/event' @@ -25,11 +26,9 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode useEffect(() => { if (!favoriteRelaysEvent) { - /** Curated app defaults for the home feed — same for anonymous and logged-in users until kind 10012 loads. */ - const favoriteRelays: string[] = [...DEFAULT_FAVORITE_RELAYS] + let favoriteRelays: string[] = [] if (pubkey) { - // Only add stored relay sets if user is logged in const storedRelaySets = storage.getRelaySets() storedRelaySets.forEach(({ relayUrls }) => { relayUrls.forEach((url) => { @@ -40,6 +39,15 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode }) } + const useGlobal = viewerUsesGlobalRelayDefaults({ + viewerPubkey: pubkey, + favoriteRelayUrls: favoriteRelays, + relayList + }) + if (favoriteRelays.length === 0 && useGlobal) { + favoriteRelays = [...DEFAULT_FAVORITE_RELAYS] + } + setFavoriteRelays(favoriteRelays) setRelaySetEvents([]) return @@ -82,9 +90,16 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode ) setRelaySetEvents(storedRelaySetEvents.filter(Boolean) as Event[]) + const relaySetDiscoverGlobal = viewerUsesGlobalRelayDefaults({ + viewerPubkey: pubkey, + favoriteRelayUrls: relays, + relayList + }) const normalizedRelays = [ - ...(relayList?.write ?? []).map(url => normalizeAnyRelayUrl(url) || url), - ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) + ...(relayList?.write ?? []).map((url) => normalizeAnyRelayUrl(url) || url), + ...(relaySetDiscoverGlobal + ? FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) + : []) ] const newRelaySetEvents = await queryService.fetchEvents( Array.from(new Set(normalizedRelays)).slice(0, 5), @@ -121,7 +136,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode ) } init() - }, [favoriteRelaysEvent, pubkey]) + }, [favoriteRelaysEvent, pubkey, relayList]) useEffect(() => { if (!blockedRelaysEvent) { diff --git a/src/providers/FeedProvider.test.ts b/src/providers/FeedProvider.test.ts index 390cd7e5..167dbd2f 100644 --- a/src/providers/FeedProvider.test.ts +++ b/src/providers/FeedProvider.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' +import { FAST_READ_RELAY_URLS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' +import { buildRelayPulseQueryRelayUrls, buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' -import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' +import type { Event } from 'nostr-tools' describe('home feed relay policy', () => { it('keeps aggr.nostr.land out of the main home feed', () => { @@ -38,4 +40,47 @@ describe('home feed relay policy', () => { expect(merged).toContain('wss://relay.example/') expect(merged).toContain('wss://inbox.example/') }) + + it('stripNostrLandAggrFromRelayUrls removes aggr with trailing slash and hostname variants', () => { + const stripped = stripNostrLandAggrFromRelayUrls([ + 'wss://relay.example/', + 'wss://aggr.nostr.land/', + AGGR_NOSTR_LAND_WSS, + 'wss://AGGR.nostr.land' + ]) + expect(stripped).toEqual(['wss://relay.example/']) + }) + + it('relay pulse stack excludes global fast-read and aggr', () => { + const nineReadTags: string[][] = Array.from({ length: 9 }, (_, i) => [ + 'r', + `wss://many-${i}.example/`, + 'read' + ]) + const oversizedCacheList = { + kind: 10012, + tags: [...nineReadTags], + content: '', + created_at: 0, + pubkey: 'a'.repeat(64), + id: 'b'.repeat(64), + sig: 'c'.repeat(128) + } satisfies Event + + const urls = buildRelayPulseQueryRelayUrls({ + viewerPubkey: 'd'.repeat(64), + favoriteRelayUrls: ['wss://fav.example/'], + blockedRelays: [], + relayList: { read: ['wss://nip65.example/'], httpRead: ['https://http-index.example/'] }, + cacheRelayListEvent: oversizedCacheList, + httpRelayListEvent: null + }) + + for (const u of FAST_READ_RELAY_URLS) { + expect(urls).not.toContain(u) + } + expect(urls).not.toContain(AGGR_NOSTR_LAND_WSS) + expect(urls).not.toContain('wss://aggr.nostr.land/') + expect(urls.filter((u) => u.startsWith('wss://many-')).length).toBe(8) + }) }) diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index b0b1bc91..491c1779 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -1,10 +1,11 @@ -import { FAST_READ_RELAY_URLS } from '@/constants' +import { DEFAULT_FAVORITE_RELAYS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import logger from '@/lib/logger' import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility' import { normalizeAnyRelayUrl } from '@/lib/url' +import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import type { Dispatch, ReactNode, SetStateAction } from 'react' @@ -50,9 +51,19 @@ function buildHomeReplyFeedRelayUrls( } export function FeedProvider({ children }: { children: ReactNode }) { - const { isInitialized, relayList, cacheRelayListEvent, httpRelayListEvent } = useNostr() + const { isInitialized, relayList, cacheRelayListEvent, httpRelayListEvent, pubkey } = useNostr() const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays() + const useGlobalRelayDefaults = useMemo( + () => + viewerUsesGlobalRelayDefaults({ + viewerPubkey: pubkey, + favoriteRelayUrls: [...favoriteRelays, ...relaySets.flatMap((relaySet) => relaySet.relayUrls)], + relayList + }), + [pubkey, favoriteRelays, relaySets, relayList] + ) + const favoriteFeedRelayUrls = useMemo( () => [...favoriteRelays, ...relaySets.flatMap((relaySet) => relaySet.relayUrls)], [favoriteRelays, relaySets] @@ -68,7 +79,9 @@ export function FeedProvider({ children }: { children: ReactNode }) { const replyExtraRelayLayers = useMemo(() => { const cacheRelayUrls: string[] = [] if (cacheRelayListEvent) { - const list = getRelayListFromEvent(cacheRelayListEvent, blockedRelays) + const list = getRelayListFromEvent(cacheRelayListEvent, blockedRelays, { + globalReadWriteFallback: useGlobalRelayDefaults + }) cacheRelayUrls.push(...list.read) } @@ -79,12 +92,20 @@ export function FeedProvider({ children }: { children: ReactNode }) { } return { - inboxRelayUrls: relayList?.read?.length ? relayList.read : FAST_READ_RELAY_URLS, - outboxRelayUrls: relayList?.write?.length ? relayList.write : FAST_READ_RELAY_URLS, + inboxRelayUrls: relayList?.read?.length + ? relayList.read + : useGlobalRelayDefaults + ? DEFAULT_FAVORITE_RELAYS + : [], + outboxRelayUrls: relayList?.write?.length + ? relayList.write + : useGlobalRelayDefaults + ? DEFAULT_FAVORITE_RELAYS + : [], cacheRelayUrls, httpRelayUrls } - }, [relayList, cacheRelayListEvent, httpRelayListEvent, blockedRelays]) + }, [relayList, cacheRelayListEvent, httpRelayListEvent, blockedRelays, useGlobalRelayDefaults]) /** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */ const [relayUrls, setRelayUrls] = useState(() => @@ -124,7 +145,12 @@ export function FeedProvider({ children }: { children: ReactNode }) { const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' }) const updateFeedRelayUrls = useCallback(() => { - const primaryRelays = buildAllFavoritesFeedRelayUrls(favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls) + const primaryRelays = buildAllFavoritesFeedRelayUrls( + favoriteFeedRelayUrls, + blockedRelays, + primaryExtraRelayUrls, + useGlobalRelayDefaults + ) const replyRelays = buildHomeReplyFeedRelayUrls( primaryRelays, replyExtraRelayLayers.inboxRelayUrls, @@ -144,7 +170,7 @@ export function FeedProvider({ children }: { children: ReactNode }) { } setUrlStateIfChanged(setRelayUrls, primaryRelays) setUrlStateIfChanged(setReplyRelayUrls, replyRelays) - }, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged]) + }, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged, useGlobalRelayDefaults]) const favoriteRelaysIdentity = useMemo( () => diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index 583e42f7..76328003 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -10,6 +10,7 @@ import { } from '@/lib/live-activities' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import logger from '@/lib/logger' +import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { registerSessionInteractivePrewarmListener } from '@/services/session-interactive-prewarm-bridge' @@ -29,6 +30,16 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode const showLiveActivitiesBanner = userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner() + const useGlobalBootstrap = useMemo( + () => + viewerUsesGlobalRelayDefaults({ + viewerPubkey: pubkey, + favoriteRelayUrls: favoriteRelays, + relayList + }), + [pubkey, favoriteRelays, relayList] + ) + const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [carouselHiddenAddresses, setCarouselHiddenAddresses] = useState>(() => new Set()) @@ -50,7 +61,8 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode favoriteRelays, blockedRelays, relayListRead: relayRead, - relayListWrite: relayWrite + relayListWrite: relayWrite, + includeGlobalFastRead: useGlobalBootstrap }) if (urls.length === 0) { rawItemsRef.current = [] @@ -91,7 +103,8 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode blockedRelays, relayRead, relayWrite, - followings + followings, + useGlobalBootstrap ]) const toggleLiveActivityCarouselHidden = useCallback(async (address: string) => { diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 5bb5cd1a..26d5d813 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -25,6 +25,7 @@ import { getLatestEvent, minePow } from '@/lib/event' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' +import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { LoginRequiredError } from '@/lib/nostr-errors' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' @@ -66,18 +67,33 @@ export { useNostr } from '@/providers/nostr-context' export type { TNostrContext } from '@/providers/nostr-context' /** Kind 10012 `relay` tags for publish / target-relay prioritization. */ -function favoriteRelayUrlsForPublish(favoriteRelaysEvent: Event | null, pubkey: string | null): string[] { - if (!favoriteRelaysEvent) { - return pubkey ? [...DEFAULT_FAVORITE_RELAYS] : [] +function favoriteRelayUrlsForPublish( + favoriteRelaysEvent: Event | null, + pubkey: string | null, + relayList: TRelayList | null | undefined +): string[] { + const urlsFromEvent = (): string[] => { + const urls: string[] = [] + if (!favoriteRelaysEvent) return urls + favoriteRelaysEvent.tags.forEach(([name, v]) => { + if (name === 'relay' && v) { + const n = normalizeAnyRelayUrl(v) || v + if (n && !urls.includes(n)) urls.push(n) + } + }) + return urls } - const urls: string[] = [] - favoriteRelaysEvent.tags.forEach(([name, v]) => { - if (name === 'relay' && v) { - const n = normalizeAnyRelayUrl(v) || v - if (n && !urls.includes(n)) urls.push(n) - } + const fromEvent = urlsFromEvent() + const useGlobal = viewerUsesGlobalRelayDefaults({ + viewerPubkey: pubkey, + favoriteRelayUrls: fromEvent, + relayList }) - return urls.length > 0 ? urls : pubkey ? [...DEFAULT_FAVORITE_RELAYS] : [] + if (!favoriteRelaysEvent) { + return useGlobal && pubkey ? [...DEFAULT_FAVORITE_RELAYS] : [] + } + if (fromEvent.length > 0) return fromEvent + return useGlobal && pubkey ? [...DEFAULT_FAVORITE_RELAYS] : [] } function blockedRelayUrlsFromEvent(blockedRelaysEvent: Event | null): string[] { @@ -1561,7 +1577,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { noteStatsService.beginPublishPriority() try { logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) }) - const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey) + const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey, relayList) const relays = await client.determineTargetRelays(event, { ...options, favoriteRelayUrls, @@ -1686,7 +1702,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { client.interruptBackgroundQueries() // Privacy: Only use user's own relays, never connect to "seen on" relays - const favUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account?.pubkey ?? null) + const favUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account?.pubkey ?? null, relayList) const relays = await client.determineTargetRelays(targetEvent, { favoriteRelayUrls: favUrls, blockedRelayUrls: blockedRelayUrlsFromEvent(blockedRelaysEvent) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 0a29e616..3f0005c2 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -36,6 +36,8 @@ import { SEARCHABLE_RELAY_URLS } from '@/constants' +import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' + /** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { if (relaySupportsSearch) return f @@ -986,6 +988,14 @@ class ClientService extends EventTarget { blockedRelays: blockedRelayUrls, applySocialKindBlockedFilter: isSocialKindBlockedKind(event.kind) } + const policyRelayList = await this.peekRelayListFromStorage(event.pubkey).catch(() => + this.emptyRelayListForPublish() + ) + const useGlobalRelayDefaults = viewerUsesGlobalRelayDefaults({ + viewerPubkey: event.pubkey, + favoriteRelayUrls: favoriteRelayUrls ?? [], + relayList: policyRelayList + }) if (event.kind === kinds.RelayList) { logger.info('[DetermineTargetRelays] Determining target relays for relay list event', { pubkey: event.pubkey, @@ -1023,11 +1033,24 @@ class ClientService extends EventTarget { } if (userWriteRelays.length === 0 && seenRelays.length === 0) { + if (!useGlobalRelayDefaults) { + return this.filterPublishingRelays( + buildPrioritizedWriteRelayUrls({ + userWriteRelays: [], + favoriteRelays: favoriteRelayUrls ?? [], + maxRelays: MAX_PUBLISH_RELAYS, + includeGlobalFastWriteReadTails: false, + ...writeRelayPubOpts + }), + event + ) + } return this.filterPublishingRelays( buildPrioritizedWriteRelayUrls({ userWriteRelays: [...FAST_WRITE_RELAY_URLS], favoriteRelays: favoriteRelayUrls ?? [], maxRelays: MAX_PUBLISH_RELAYS, + includeGlobalFastWriteReadTails: false, ...writeRelayPubOpts }), event @@ -1040,6 +1063,7 @@ class ClientService extends EventTarget { favoriteRelays: favoriteRelayUrls ?? [], extraRelays: seenRelays, maxRelays: MAX_PUBLISH_RELAYS, + includeGlobalFastWriteReadTails: useGlobalRelayDefaults, ...writeRelayPubOpts }), event @@ -1073,7 +1097,7 @@ class ClientService extends EventTarget { .filter((url): url is string => !!url) let authorWrite = dedupeNormalizeRelayUrlsOrdered([...authorHttpWrites, ...authorWsWrites]) if (authorWrite.length === 0) { - authorWrite = [...FAST_WRITE_RELAY_URLS] + authorWrite = useGlobalRelayDefaults ? [...FAST_WRITE_RELAY_URLS] : [] } let recipientRead: string[] = [] recipientRead = recipientRelayLists.flatMap((rl) => [ @@ -1112,6 +1136,9 @@ class ClientService extends EventTarget { recipientReadCount: recipientRead.length }) if (pubRelays.length > 0) return pubRelays + if (!useGlobalRelayDefaults) { + return this.filterPublishingRelays([], event) + } return this.filterPublishingRelays( feedRelayPolicyUrls([{ source: 'fast-write', urls: relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS]) }], { operation: 'write', @@ -1161,10 +1188,14 @@ class ClientService extends EventTarget { userWriteRelays: spellWriteFiltered.length > 0 ? spellWriteFiltered - : dedupeNormalizeRelayUrlsOrdered(FAST_WRITE_RELAY_URLS), + : useGlobalRelayDefaults + ? dedupeNormalizeRelayUrlsOrdered(FAST_WRITE_RELAY_URLS) + : [], favoriteRelays: favoriteRelayUrls ?? [], extraRelays: [], maxRelays: MAX_PUBLISH_RELAYS, + includeGlobalFastWriteReadTails: + spellWriteFiltered.length > 0 ? useGlobalRelayDefaults : false, ...writeRelayPubOpts }), event @@ -1203,23 +1234,33 @@ class ClientService extends EventTarget { ExtendedKind.RELAY_REVIEW ].includes(event.kind) ) { - bootstrapExtras.push(...PROFILE_FETCH_RELAY_URLS) + bootstrapExtras.push( + ...(useGlobalRelayDefaults ? PROFILE_FETCH_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer()) + ) logger.debug('[DetermineTargetRelays] Relay list event detected, adding PROFILE_FETCH_RELAY_URLS', { kind: event.kind, - profileFetchRelays: PROFILE_FETCH_RELAY_URLS, + profileFetchRelays: useGlobalRelayDefaults + ? PROFILE_FETCH_RELAY_URLS + : profileFetchRelayUrlsWithoutFastReadLayer(), additionalRelayCount: bootstrapExtras.length }) } else if (event.kind === ExtendedKind.FAVORITE_RELAYS || event.kind === kinds.Relaysets) { // Use fast write relays for favorite-relays and kind 30002 relay-set replaceables to avoid // timeouts and auth-only relays dominating the attempt list. - bootstrapExtras.push(...FAST_WRITE_RELAY_URLS) + if (useGlobalRelayDefaults) { + bootstrapExtras.push(...FAST_WRITE_RELAY_URLS) + } logger.debug('[DetermineTargetRelays] Favorite relays or relay set event, adding FAST_WRITE_RELAY_URLS', { kind: event.kind, fastWriteRelays: FAST_WRITE_RELAY_URLS, additionalRelayCount: bootstrapExtras.length }) } else if (event.kind === ExtendedKind.RSS_FEED_LIST) { - bootstrapExtras.push(...FAST_WRITE_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS) + if (useGlobalRelayDefaults) { + bootstrapExtras.push(...FAST_WRITE_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS) + } else { + bootstrapExtras.push(...profileFetchRelayUrlsWithoutFastReadLayer()) + } } if (isDocumentRelayKind(event.kind)) { bootstrapExtras.push(...DOCUMENT_RELAY_URLS) @@ -1253,6 +1294,7 @@ class ClientService extends EventTarget { favoriteRelays: favoriteRelayUrls ?? [], extraRelays: bootstrapExtras, maxRelays: MAX_PUBLISH_RELAYS, + includeGlobalFastWriteReadTails: useGlobalRelayDefaults, ...writeRelayPubOpts }), event @@ -1275,12 +1317,14 @@ class ClientService extends EventTarget { // Fallback for all publishing when no relays (e.g. after cache clear or fetch failure). // Use FAST_WRITE_RELAY_URLS so writes always have known-good write relays. if (!relays.length) { - relays = isDocumentRelayKind(event.kind) - ? dedupeNormalizeRelayUrlsOrdered([...FAST_WRITE_RELAY_URLS, ...DOCUMENT_RELAY_URLS]) - : [...FAST_WRITE_RELAY_URLS] - logger.info('[DetermineTargetRelays] Using default write relays (no user/extra relays)', { - count: relays.length - }) + if (useGlobalRelayDefaults) { + relays = isDocumentRelayKind(event.kind) + ? dedupeNormalizeRelayUrlsOrdered([...FAST_WRITE_RELAY_URLS, ...DOCUMENT_RELAY_URLS]) + : [...FAST_WRITE_RELAY_URLS] + logger.info('[DetermineTargetRelays] Using default write relays (no user/extra relays)', { + count: relays.length + }) + } } relays = this.filterPublishingRelays(relays, event) diff --git a/src/services/spell.service.ts b/src/services/spell.service.ts index dfa75a8a..46f632cf 100644 --- a/src/services/spell.service.ts +++ b/src/services/spell.service.ts @@ -87,11 +87,14 @@ export function getRelaysForSpellCatalogSync( favoriteRelays: string[], blockedRelays: string[], userInboxReadRelays: string[], - options?: { userWriteRelays?: string[] } + options?: { userWriteRelays?: string[]; useGlobalRelayBootstrap?: boolean } ): string[] { + const g = options?.useGlobalRelayBootstrap !== false return getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, userInboxReadRelays, { userWriteRelays: options?.userWriteRelays ?? [], - applySocialKindBlockedFilter: false + applySocialKindBlockedFilter: false, + useGlobalFavoriteDefaults: g, + includeGlobalFastRead: g }) }