From 4d5bc73cc48a86f77fcde89610c0f7a55e00a8ae Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 31 May 2026 13:44:07 +0200 Subject: [PATCH] tighten relay selection --- .../MetadataRelaysOnlySetting/index.tsx | 5 +- src/components/NormalFeed/index.tsx | 4 + src/components/NoteList/index.tsx | 39 ++++++ src/components/Relay/index.tsx | 34 ++++- src/constants.ts | 5 +- src/hooks/index.tsx | 1 + src/hooks/useRelayPageFeedPolicy.ts | 19 +++ src/lib/account-list-relay-urls.ts | 4 +- src/lib/favorites-feed-relays.ts | 12 +- src/lib/home-feed-relays.ts | 7 +- src/lib/live-activities.ts | 4 +- .../metadata-policy-curated-relays.test.ts | 14 +- src/lib/metadata-policy-curated-relays.ts | 34 +++++ src/lib/read-only-relay-personal.test.ts | 62 ++++++++- src/lib/read-only-relay-personal.ts | 124 +++++++++++++++++- src/lib/relay-list-builder.ts | 6 +- src/lib/relay-strikes.test.ts | 7 + src/lib/relay-strikes.ts | 35 ++++- src/lib/single-relay-browse-kinds.test.ts | 25 ++++ src/lib/single-relay-browse-kinds.ts | 19 +++ src/providers/FeedProvider.test.ts | 17 ++- src/providers/FeedProvider.tsx | 7 + src/services/client-query.service.ts | 16 ++- src/services/client.service.ts | 94 +++++++++---- src/services/local-storage.service.ts | 6 +- 25 files changed, 534 insertions(+), 66 deletions(-) create mode 100644 src/hooks/useRelayPageFeedPolicy.ts create mode 100644 src/lib/single-relay-browse-kinds.test.ts create mode 100644 src/lib/single-relay-browse-kinds.ts diff --git a/src/components/MetadataRelaysOnlySetting/index.tsx b/src/components/MetadataRelaysOnlySetting/index.tsx index ed030f62..1d5c4324 100644 --- a/src/components/MetadataRelaysOnlySetting/index.tsx +++ b/src/components/MetadataRelaysOnlySetting/index.tsx @@ -1,8 +1,8 @@ import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' +import { METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT, setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal' import client from '@/services/client.service' import storage from '@/services/local-storage.service' -import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -22,6 +22,7 @@ export default function MetadataRelaysOnlySetting() { setRestrictConnectionsToMetadataRelaysOnly(checked) client.interruptBackgroundQueries({ closePooledRelayConnections: true }) client.closeMetadataPolicyDisallowedRelayConnections() + window.dispatchEvent(new CustomEvent(METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT)) } return ( @@ -32,7 +33,7 @@ export default function MetadataRelaysOnlySetting() {
{t( - 'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.' + 'When on (default), the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists, plus hard-coded relays only while a query or subscription needs them. Publishing is unchanged. Relay explore and Search pages are exempt.' )}
diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index d5d26552..6825bcc0 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -100,6 +100,8 @@ const NormalFeed = forwardRef void + /** Relay explore: explicit kinds EOSEd empty — parent widens to kindless `{ limit }` once. */ + onSingleRelayBrowseEmpty?: () => void /** Shown above the feed list (e.g. after kindless→kinds fallback on a single-relay chip). */ feedTopNotice?: ReactNode /** Passed through to {@link NoteList} (d-tag browse one-shot). */ @@ -151,6 +153,7 @@ const NormalFeed = forwardRef void + /** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */ + onSingleRelayBrowseEmpty?: () => void /** Optional banner above the feed (e.g. kindless→kinds fallback). */ feedTopNotice?: ReactNode /** When true, render events as an Instagram-style 3-column square media grid. */ @@ -946,8 +950,11 @@ const NoteList = forwardRef( const feedRelayReturnedAnyEventRef = useRef(false) /** One-shot per timeline init: avoid double-calling parent fallback (Strict Mode / duplicate EOSE). */ const singleRelayKindlessFallbackAttemptedRef = useRef(false) + const singleRelayBrowseFallbackAttemptedRef = useRef(false) const onSingleRelayKindlessEmptyRef = useRef(onSingleRelayKindlessEmpty) onSingleRelayKindlessEmptyRef.current = onSingleRelayKindlessEmpty + const onSingleRelayBrowseEmptyRef = useRef(onSingleRelayBrowseEmpty) + onSingleRelayBrowseEmptyRef.current = onSingleRelayBrowseEmpty /** Timeout handle for kindless EOSE fallback; cleared when EOSE arrives or effect tears down. */ const kindlessEoseTimeoutRef = useRef | null>(null) /** Dedupe {@link toast.error} when relays return nothing for a feed load. */ @@ -2250,6 +2257,7 @@ const NoteList = forwardRef( feedPaintLiveRelayDoneRef.current = false feedRelayReturnedAnyEventRef.current = false singleRelayKindlessFallbackAttemptedRef.current = false + singleRelayBrowseFallbackAttemptedRef.current = false } // Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton. @@ -2293,6 +2301,14 @@ const NoteList = forwardRef( if (useFilterAsIs && allowKindlessRelayExplore && urls.length === 1) { return false } + if ( + useFilterAsIs && + urls.length === 1 && + relayAuthoritativeFeedOnlyRef.current && + hostPrimaryPageNameRef.current === 'relay' + ) { + return false + } return true }) if (invalidFilters.length > 0) { @@ -3401,6 +3417,28 @@ const NoteList = forwardRef( } } + // Relay explore: explicit kinds returned nothing — parent retries kindless once. + if ( + eosed && + effectActive && + onSingleRelayBrowseEmptyRef.current && + !singleRelayBrowseFallbackAttemptedRef.current && + !feedRelayReturnedAnyEventRef.current && + relayAuthoritativeFeedOnlyRef.current && + hostPrimaryPageNameRef.current === 'relay' + ) { + const reqs = subRequestsRef.current + const f0 = reqs[0] + if (reqs.length === 1 && f0 && f0.urls.length === 1) { + const f = f0.filter as Filter + const hasKinds = Array.isArray(f.kinds) && f.kinds.length > 0 + if (hasKinds) { + singleRelayBrowseFallbackAttemptedRef.current = true + onSingleRelayBrowseEmptyRef.current() + } + } + } + if ( effectActive && eosed && @@ -3960,6 +3998,7 @@ const NoteList = forwardRef( useEffect(() => { if (relayAuthoritativeFeedOnly) return if (!timelinePublicReadFallback) return + if (isMetadataRelaysOnlyPolicyActive()) return if (feedSubscriptionKey === 'home-all-favorites') return if (oneShotFetch || areAlgoRelays) return if (!navigator.onLine) return diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx index 92aa5509..dfb9fc7a 100644 --- a/src/components/Relay/index.tsx +++ b/src/components/Relay/index.tsx @@ -2,7 +2,7 @@ import NormalFeed from '@/components/NormalFeed' import type { TNoteListRef } from '@/components/NoteList' import RelayInfo from '@/components/RelayInfo' import SearchInput from '@/components/SearchInput' -import { useBypassMetadataRelaysOnlyPolicy, useFetchRelayInfo } from '@/hooks' +import { useFetchRelayInfo, useRelayPageFeedPolicy } from '@/hooks' import type { TPrimaryPageName } from '@/PageManager' import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { canonicalRelaySessionKey, isLocalNetworkUrl, normalizeRelayUrlForPage } from '@/lib/url' @@ -15,6 +15,7 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'r import { useTranslation } from 'react-i18next' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url' +import { kindsForSingleRelayBrowse } from '@/lib/single-relay-browse-kinds' import { stableFeedKindKey } from '@/features/feed/descriptor' import NotFound from '../NotFound' @@ -32,13 +33,15 @@ const Relay = forwardRef< ref ) { const { t } = useTranslation() - useBypassMetadataRelaysOnlyPolicy() + useRelayPageFeedPolicy() const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { showKinds } = useKindFilterOrDefaults() const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url]) const { relayInfo } = useFetchRelayInfo(normalizedUrl) const [searchInput, setSearchInput] = useState('') const [debouncedInput, setDebouncedInput] = useState(searchInput) + /** After explicit-kinds REQ EOSEs empty, retry kindless `{ limit }` once (document/specialty relays). */ + const [kindlessBrowseFallback, setKindlessBrowseFallback] = useState(false) const internalNoteListRef = useRef(null) const noteListRef = ref ?? internalNoteListRef @@ -81,13 +84,21 @@ const Relay = forwardRef< } }, [normalizedUrl, noteListRef]) + useEffect(() => { + setKindlessBrowseFallback(false) + }, [normalizedUrl]) + /** Default browse: explicit kinds (many strfry / small relays never return a useful kindless global REQ). */ const relayBrowseKindsKey = useMemo(() => stableFeedKindKey(showKinds), [showKinds]) const relayBrowseKinds = useMemo( - () => (showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]), - [relayBrowseKindsKey, showKinds] + () => (normalizedUrl ? kindsForSingleRelayBrowse(normalizedUrl, showKinds) : [kinds.ShortTextNote]), + [relayBrowseKindsKey, showKinds, normalizedUrl] ) + const onSingleRelayBrowseEmpty = useCallback(() => { + setKindlessBrowseFallback(true) + }, []) + const relayFeedSubRequests = useMemo(() => { if (!normalizedUrl) return [] const q = debouncedInput.trim() @@ -99,13 +110,21 @@ const Relay = forwardRef< } ] } + if (kindlessBrowseFallback) { + return [ + { + urls: [normalizedUrl], + filter: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } + } + ] + } return [ { urls: [normalizedUrl], filter: { kinds: [...relayBrowseKinds], limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } } ] - }, [normalizedUrl, debouncedInput, relayBrowseKindsKey]) + }, [normalizedUrl, debouncedInput, relayBrowseKindsKey, kindlessBrowseFallback]) const allowKindlessRelayExplore = debouncedInput.trim().length > 0 @@ -116,7 +135,7 @@ const Relay = forwardRef< ) const shouldHideEventNotFromThisRelay = useCallback( (ev: Event) => { - if (hostPrimaryPageName === 'relay' || allowKindlessRelayExplore) { + if (allowKindlessRelayExplore) { return false } if (!relaySeenMatchKey) return false @@ -127,7 +146,7 @@ const Relay = forwardRef< if (seen.length === 0) return false return !seen.some((u) => canonicalRelaySessionKey(u) === relaySeenMatchKey) }, - [relaySeenMatchKey, normalizedUrl, hostPrimaryPageName, allowKindlessRelayExplore] + [relaySeenMatchKey, normalizedUrl, allowKindlessRelayExplore] ) const alexandriaFeedEmptyUrl = useMemo(() => { @@ -168,6 +187,7 @@ const Relay = forwardRef< extraShouldHideEvent={shouldHideEventNotFromThisRelay} extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay} relayAuthoritativeFeedOnly + onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty} alexandriaEmptyUrl={alexandriaFeedEmptyUrl} /> diff --git a/src/constants.ts b/src/constants.ts index 14dc9463..696abde6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -165,7 +165,7 @@ export const RELAY_SLOW_PARK_SIGNALS_THRESHOLD = 2 export const RELAY_SLOW_PARK_COOLDOWN_MS = 5 * 60 * 1000 /** Close pooled WebSocket when no SUBs and no pool activity for this long (see {@link initRelayPoolIdle}). */ -export const RELAY_POOL_SOCKET_IDLE_MS = 90_000 +export const RELAY_POOL_SOCKET_IDLE_MS = 15_000 /** How often to scan for idle relay sockets. */ export const RELAY_POOL_IDLE_SWEEP_INTERVAL_MS = 45_000 @@ -510,7 +510,8 @@ export const FAST_WRITE_RELAY_URLS = [ 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://thecitadel.nostr1.com', - 'wss://nos.lol' + 'wss://nos.lol', + 'wss://relay.layer.systems' ] /** diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 24c0b783..321ba592 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -1,4 +1,5 @@ export * from './useBypassMetadataRelaysOnlyPolicy' +export * from './useRelayPageFeedPolicy' export * from './useNearViewport' export * from './useFetchCalendarRsvps' export * from './useFetchEvent' diff --git a/src/hooks/useRelayPageFeedPolicy.ts b/src/hooks/useRelayPageFeedPolicy.ts new file mode 100644 index 00000000..6b3b850d --- /dev/null +++ b/src/hooks/useRelayPageFeedPolicy.ts @@ -0,0 +1,19 @@ +import { + enterMetadataRelaysOnlyBypass, + enterSingleRelayExplicitBrowse, + leaveMetadataRelaysOnlyBypass, + leaveSingleRelayExplicitBrowse +} from '@/lib/read-only-relay-personal' +import { useEffect } from 'react' + +/** Relay detail feed: bypass metadata-only narrowing, user blocks, and session strikes for the page relay. */ +export function useRelayPageFeedPolicy(): void { + useEffect(() => { + enterMetadataRelaysOnlyBypass() + enterSingleRelayExplicitBrowse() + return () => { + leaveSingleRelayExplicitBrowse() + leaveMetadataRelaysOnlyBypass() + } + }, []) +} diff --git a/src/lib/account-list-relay-urls.ts b/src/lib/account-list-relay-urls.ts index b1e15d36..80e28ae7 100644 --- a/src/lib/account-list-relay-urls.ts +++ b/src/lib/account-list-relay-urls.ts @@ -32,7 +32,7 @@ export async function buildAccountListRelayUrlsForMerge(options: { blockedRelays, maxRelays: 100, applySocialKindBlockedFilter: false, - includeGlobalFastRead: useGlobal + includeGlobalFastRead: useGlobal && viewerIncludeGlobalFastReadRelayLayer() }) const write = buildPrioritizedWriteRelayUrls({ userWriteRelays: writeOutboxes, @@ -40,7 +40,7 @@ export async function buildAccountListRelayUrlsForMerge(options: { blockedRelays, maxRelays: 100, applySocialKindBlockedFilter: false, - includeGlobalFastWriteReadTails: useGlobal + includeGlobalFastWriteReadTails: useGlobal && viewerIncludeGlobalFastWriteRelayLayer() }) const merged = [...read, ...write] return [...new Set(merged.map((u) => normalizeRelayUrlByScheme(u) || u).filter(Boolean))] diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index 9b12774c..a470a91a 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -20,6 +20,7 @@ import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay- import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { relaySessionStrikes } from '@/lib/relay-strikes' import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults' +import { viewerIncludeGlobalFastReadRelayLayer, viewerIncludeGlobalFastWriteRelayLayer } from '@/lib/read-only-relay-personal' import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays' import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes' import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes' @@ -141,12 +142,13 @@ export function buildProfileAugmentedReadRelayUrls( maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS, useGlobalRelayBootstrap = true ): string[] { + const allowFastReadBootstrap = useGlobalRelayBootstrap && viewerIncludeGlobalFastReadRelayLayer() const fastReadLayer = - useGlobalRelayBootstrap + allowFastReadBootstrap ? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]) : [] const merged = mergeRelayUrlLayers( - useGlobalRelayBootstrap ? [fastReadLayer, authorRelayUrls] : [authorRelayUrls, fastReadLayer], + allowFastReadBootstrap ? [fastReadLayer, authorRelayUrls] : [authorRelayUrls, fastReadLayer], blockedRelays ) return merged.slice(0, maxRelays) @@ -196,7 +198,8 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox( options?: ReadRelayPriorityOptions ): string[] { const useFavDefaults = options?.useGlobalFavoriteDefaults !== false - const includeFast = options?.includeGlobalFastRead !== false + const includeFast = + options?.includeGlobalFastRead !== false && viewerIncludeGlobalFastReadRelayLayer() const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults) return buildPrioritizedReadRelayUrls({ userReadRelays: userInboxReadRelays, @@ -315,7 +318,8 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( : relayFilterIncludesSocialKindBlockedKind(r.filter) const useFavDefaults = options?.useGlobalFavoriteDefaults !== false - const includeFast = options?.includeGlobalFastRead !== false + const includeFast = + options?.includeGlobalFastRead !== false && viewerIncludeGlobalFastReadRelayLayer() const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults) const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? []) diff --git a/src/lib/home-feed-relays.ts b/src/lib/home-feed-relays.ts index 7edd8537..8ccad0cc 100644 --- a/src/lib/home-feed-relays.ts +++ b/src/lib/home-feed-relays.ts @@ -1,6 +1,8 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { stripNostrLandAggrFromRelayUrls } from '@/lib/nostr-land-relay-eligibility' +import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal' +import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' export { stripNostrLandAggrFromRelayUrls } @@ -28,6 +30,9 @@ export function buildAllFavoritesFeedRelayUrls( extraFeedRelayUrls: string[], useGlobalFavoriteDefaults = true ): string[] { + const extras = isMetadataRelaysOnlyPolicyActive() + ? extraFeedRelayUrls.filter((u) => !isWispTrendingNotesRelayUrl(u)) + : extraFeedRelayUrls return stripNostrLandAggrFromRelayUrls( feedRelayPolicyUrls( [ @@ -35,7 +40,7 @@ export function buildAllFavoritesFeedRelayUrls( source: 'favorites', urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults) }, - { source: 'fallback', urls: extraFeedRelayUrls } + { source: 'fallback', urls: extras } ], { operation: 'favorites-feed', diff --git a/src/lib/live-activities.ts b/src/lib/live-activities.ts index b326fae9..994f2852 100644 --- a/src/lib/live-activities.ts +++ b/src/lib/live-activities.ts @@ -7,6 +7,7 @@ import { MAX_REQ_RELAY_URLS, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' +import { viewerIncludeGlobalFastReadRelayLayer } from '@/lib/read-only-relay-personal' import { normalizeAnyRelayUrl } from '@/lib/url' import { nip19, type Event, type Filter } from 'nostr-tools' @@ -669,7 +670,8 @@ export function buildLiveActivitiesRelayUrls(options: { includeGlobalFastRead?: boolean }): string[] { const { loggedIn, favoriteRelays, blockedRelays, relayListRead, relayListWrite } = options - const includeFast = options.includeGlobalFastRead !== false + const includeFast = + options.includeGlobalFastRead !== false && viewerIncludeGlobalFastReadRelayLayer() const useGlobalFavoriteDefaults = includeFast if (loggedIn) { const fav = relayUrlsLocalsFirst( diff --git a/src/lib/metadata-policy-curated-relays.test.ts b/src/lib/metadata-policy-curated-relays.test.ts index f3b94a09..8ac46df9 100644 --- a/src/lib/metadata-policy-curated-relays.test.ts +++ b/src/lib/metadata-policy-curated-relays.test.ts @@ -1,10 +1,20 @@ -import { PROFILE_RELAY_URLS } from '@/constants' +import { DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' import { describe, expect, it } from 'vitest' -import { isMetadataPolicyCuratedRelay } from './metadata-policy-curated-relays' +import { + isMetadataPolicyCuratedRelay, + isMetadataPolicyOperationScopedRelay +} from './metadata-policy-curated-relays' describe('metadata-policy-curated-relays', () => { it('recognizes profile relay constants', () => { expect(isMetadataPolicyCuratedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true) expect(isMetadataPolicyCuratedRelay('wss://nostr.wirednet.jp/')).toBe(false) }) + + it('operation scope excludes FAST_READ widening', () => { + expect(isMetadataPolicyOperationScopedRelay(DOCUMENT_RELAY_URLS[0]!)).toBe(true) + expect(isMetadataPolicyOperationScopedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true) + expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false) + expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false) + }) }) diff --git a/src/lib/metadata-policy-curated-relays.ts b/src/lib/metadata-policy-curated-relays.ts index cbff8af1..ed63c07d 100644 --- a/src/lib/metadata-policy-curated-relays.ts +++ b/src/lib/metadata-policy-curated-relays.ts @@ -24,7 +24,20 @@ const METADATA_POLICY_CURATED_RELAY_LISTS: readonly (readonly string[])[] = [ NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS ] +/** + * Curated stacks allowed to connect briefly under metadata-only policy when merged into an active + * query/subscribe (documents, GIFs, profiles, …). Excludes FAST_READ, search indexers, and read-only mirrors. + */ +const METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS: readonly (readonly string[])[] = [ + PROFILE_RELAY_URLS, + DOCUMENT_RELAY_URLS, + GIF_RELAY_URLS, + BOOKSTR_RELAY_URLS, + FOLLOWS_HISTORY_RELAY_URLS +] + let curatedRelayKeySet: ReadonlySet | null = null +let operationScopedRelayKeySet: ReadonlySet | null = null function relayKeyForCuratedSet(url: string): string { return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() @@ -44,12 +57,32 @@ function getCuratedRelayKeySet(): ReadonlySet { return curatedRelayKeySet } +function getOperationScopedRelayKeySet(): ReadonlySet { + if (!operationScopedRelayKeySet) { + const out = new Set() + for (const list of METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS) { + for (const u of list) { + const key = relayKeyForCuratedSet(u) + if (key) out.add(key) + } + } + operationScopedRelayKeySet = out + } + return operationScopedRelayKeySet +} + /** True for relays from specialized constants (profile fetch, read-only indexers, NIP-50, …). */ export function isMetadataPolicyCuratedRelay(url: string): boolean { const key = relayKeyForCuratedSet(url) return key.length > 0 && getCuratedRelayKeySet().has(key) } +/** Purpose-specific constants that may connect during an in-flight read (not general feed widening). */ +export function isMetadataPolicyOperationScopedRelay(url: string): boolean { + const key = relayKeyForCuratedSet(url) + return key.length > 0 && getOperationScopedRelayKeySet().has(key) +} + let profileRelayKeySet: ReadonlySet | null = null function getProfileRelayKeySet(): ReadonlySet { @@ -73,5 +106,6 @@ export function isMetadataPolicyProfileRelay(url: string): boolean { /** For tests: reset lazy-built key set after constant changes. */ export function resetMetadataPolicyCuratedRelayKeysForTests(): void { curatedRelayKeySet = null + operationScopedRelayKeySet = null profileRelayKeySet = null } diff --git a/src/lib/read-only-relay-personal.test.ts b/src/lib/read-only-relay-personal.test.ts index 190310b2..786d0baa 100644 --- a/src/lib/read-only-relay-personal.test.ts +++ b/src/lib/read-only-relay-personal.test.ts @@ -4,11 +4,16 @@ import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-rela import { buildPersonalRelayKeySet, filterReadOnlyRelaysUnlessPersonal, + grantRelayConnectionOperationScope, isPersonalListRequiredReadOnlyRelay, isRelayConnectionAllowedForViewer, + resetRelayConnectionOperationScopeForTests, sanitizeRelayUrlsForFetch, enterMetadataRelaysOnlyBypass, + enterSingleRelayExplicitBrowse, + enterSingleRelayExplicitFetchScope, leaveMetadataRelaysOnlyBypass, + leaveSingleRelayExplicitBrowse, setRestrictConnectionsToMetadataRelaysOnly, setViewerPersonalRelayKeys } from './read-only-relay-personal' @@ -20,13 +25,16 @@ describe('read-only-relay-personal', () => { setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) setViewerBlockedRelayUrls([]) syncViewerRelayStackNostrLandAggrEligible([]) + resetRelayConnectionOperationScopeForTests() }) afterEach(() => { setRestrictConnectionsToMetadataRelaysOnly(false) leaveMetadataRelaysOnlyBypass() + leaveSingleRelayExplicitBrowse() setViewerBlockedRelayUrls([]) syncViewerRelayStackNostrLandAggrEligible([]) + resetRelayConnectionOperationScopeForTests() }) it('requires personal list only for filter.nostr.wine', () => { @@ -73,7 +81,7 @@ describe('read-only-relay-personal', () => { expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual(urls) }) - it('metadata-only policy blocks ad-hoc feed relays but allows profile mirrors at connect time', () => { + it('metadata-only policy blocks ad-hoc feed relays at connect time', () => { setRestrictConnectionsToMetadataRelaysOnly(true) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://relay.example.com/']), { viewerActive: true }) const urls = [ @@ -82,9 +90,9 @@ describe('read-only-relay-personal', () => { 'wss://theforest.nostr1.com/', 'wss://nostr.wirednet.jp/' ] - expect(sanitizeRelayUrlsForFetch(urls)).toEqual(urls) - expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) - expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true) + expect(sanitizeRelayUrlsForFetch(urls)).toEqual(['wss://relay.example.com/']) + expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false) + expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://relay.example.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false) @@ -97,18 +105,36 @@ describe('read-only-relay-personal', () => { const urls = ['wss://nostr.land/', AGGR_NOSTR_LAND_WSS, 'wss://nostr.wirednet.jp/'] expect(sanitizeRelayUrlsForFetch(urls).map((u) => u.replace(/\/$/, ''))).toEqual([ 'wss://nostr.land', - 'wss://aggr.nostr.land', - 'wss://nostr.wirednet.jp' + 'wss://aggr.nostr.land' ]) expect(isRelayConnectionAllowedForViewer(AGGR_NOSTR_LAND_WSS)).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false) }) - it('metadata-only policy allows profile bootstrap relays at connect time', () => { + it('operation scope allows document and gif constant relays during fetch', () => { setRestrictConnectionsToMetadataRelaysOnly(true) setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) + expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(false) + expect(isRelayConnectionAllowedForViewer('wss://nostr.wine/')).toBe(false) + const revoke = grantRelayConnectionOperationScope([ + 'wss://thecitadel.nostr1.com/', + 'wss://nostr.wine/', + 'wss://essayist.decentnewsroom.com/' + ]) expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true) + expect(isRelayConnectionAllowedForViewer('wss://nostr.wine/')).toBe(false) + expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(true) + revoke() + }) + + it('metadata-only policy allows curated relays only during an operation scope', () => { + setRestrictConnectionsToMetadataRelaysOnly(true) + setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) + expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false) + const revoke = grantRelayConnectionOperationScope(['wss://profiles.nostr1.com/']) expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) + revoke() + expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false) }) it('metadata-only policy allows viewer cache and HTTP index relays', () => { @@ -135,4 +161,26 @@ describe('read-only-relay-personal', () => { expect(isRelayConnectionAllowedForViewer('wss://relay.damus.io/')).toBe(true) leaveMetadataRelaysOnlyBypass() }) + + it('explicit single-relay browse keeps user-blocked and non-list relays', () => { + setRestrictConnectionsToMetadataRelaysOnly(true) + setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true }) + setViewerBlockedRelayUrls(['wss://relay.layer.systems/']) + enterSingleRelayExplicitBrowse() + const target = 'wss://relay.layer.systems/' + expect(sanitizeRelayUrlsForFetch([target])).toEqual([target]) + expect(isRelayConnectionAllowedForViewer(target)).toBe(true) + leaveSingleRelayExplicitBrowse() + }) + + it('operation scope grants an explicit single-relay target under metadata-only', () => { + setRestrictConnectionsToMetadataRelaysOnly(true) + setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) + const leaveFetchScope = enterSingleRelayExplicitFetchScope() + const target = 'wss://relay.layer.systems/' + const revokeScope = grantRelayConnectionOperationScope([target]) + expect(isRelayConnectionAllowedForViewer(target)).toBe(true) + revokeScope() + leaveFetchScope() + }) }) diff --git a/src/lib/read-only-relay-personal.ts b/src/lib/read-only-relay-personal.ts index db9b0ba6..d248da57 100644 --- a/src/lib/read-only-relay-personal.ts +++ b/src/lib/read-only-relay-personal.ts @@ -1,7 +1,7 @@ import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants' -import { isMetadataPolicyProfileRelay } from '@/lib/metadata-policy-curated-relays' +import { isMetadataPolicyOperationScopedRelay } from '@/lib/metadata-policy-curated-relays' import { filterAggrNostrLandUnlessViewerEligible, getViewerRelayStackNostrLandAggrEligible, @@ -23,6 +23,15 @@ let viewerMetadataRelaysPolicyActive = false let restrictConnectionsToMetadataRelaysOnly = false /** Relay explore / search UI: metadata-only policy must not narrow relays on those pages. */ let metadataRelaysOnlyBypassDepth = 0 +/** Relay detail page mounted: explicit single-relay browse must not be blocked by strikes / user blocks / list gates. */ +let singleRelayExplicitBrowseDepth = 0 +/** In-flight authoritative single-relay timeline REQ (see {@link enterSingleRelayExplicitFetchScope}). */ +let singleRelayExplicitFetchDepth = 0 +/** In-flight query/subscribe URLs (constants + caller stack) allowed to connect briefly under metadata-only policy. */ +const operationScopedRelayKeys = new Set() + +/** Dispatched when metadata-only relay policy toggles (feeds should rebuild relay URL lists). */ +export const METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT = 'jumble:metadata-relays-only-changed' export function setRestrictConnectionsToMetadataRelaysOnly(enabled: boolean): void { restrictConnectionsToMetadataRelaysOnly = enabled @@ -44,6 +53,44 @@ export function isMetadataRelaysOnlyBypassActive(): boolean { return metadataRelaysOnlyBypassDepth > 0 } +export function enterSingleRelayExplicitBrowse(): void { + singleRelayExplicitBrowseDepth++ +} + +export function leaveSingleRelayExplicitBrowse(): void { + singleRelayExplicitBrowseDepth = Math.max(0, singleRelayExplicitBrowseDepth - 1) +} + +export function isSingleRelayExplicitBrowseActive(): boolean { + return singleRelayExplicitBrowseDepth > 0 +} + +/** While active, {@link sanitizeRelayUrlsForFetch} and pool connects honor the lone target relay. */ +export function enterSingleRelayExplicitFetchScope(): () => void { + singleRelayExplicitFetchDepth++ + return () => { + singleRelayExplicitFetchDepth = Math.max(0, singleRelayExplicitFetchDepth - 1) + } +} + +export function isSingleRelayExplicitFetchScopeActive(): boolean { + return singleRelayExplicitFetchDepth > 0 +} + +/** True while an explicit single-relay browse page or authoritative timeline REQ is active. */ +export function isSingleRelayExplicitPolicyActive(): boolean { + return isSingleRelayExplicitBrowseActive() || isSingleRelayExplicitFetchScopeActive() +} + +function shouldPreserveExplicitSingleRelay( + urls: readonly string[], + preserveExplicitSingleRelay?: boolean +): boolean { + if (urls.length !== 1) return false + if (preserveExplicitSingleRelay === true) return true + return isSingleRelayExplicitPolicyActive() +} + /** Logged-in viewer with metadata-only mode: only connect reads to the viewer's relay lists. */ export function isMetadataRelaysOnlyPolicyActive(): boolean { return ( @@ -60,17 +107,51 @@ export function isRelayUrlInViewerMetadataLists(url: string): boolean { /** * Under metadata-only policy: viewer NIP-65 / favorites / cache / HTTP lists, plus aggr.nostr.land when - * wss://nostr.land is listed, plus {@link PROFILE_RELAY_URLS} for kind-0 / profile hydration. + * wss://nostr.land is listed, plus relays in an active {@link grantRelayConnectionOperationScope}. */ export function isRelayAllowedUnderMetadataOnlyPolicy(url: string): boolean { if (isRelayUrlInViewerMetadataLists(url)) return true if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(url)) return true - if (isMetadataPolicyProfileRelay(url)) return true + const key = relayUrlKey(url) + if (key.length > 0 && operationScopedRelayKeys.has(key)) return true return false } +/** + * Allow read connects to non-personal relays only for the lifetime of an in-flight query/subscribe. + * Under metadata-only policy, only {@link isMetadataPolicyOperationScopedRelay} URLs are granted + * (document / GIF / profile stacks — not FAST_READ or feed widening). + */ +export function grantRelayConnectionOperationScope(urls: readonly string[]): () => void { + if (!isMetadataRelaysOnlyPolicyActive()) return () => {} + const added: string[] = [] + for (const raw of urls) { + if (isRelayUrlInViewerMetadataLists(raw)) continue + if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(raw)) continue + if ( + !isMetadataPolicyOperationScopedRelay(raw) && + !(urls.length === 1 && isSingleRelayExplicitPolicyActive()) + ) { + continue + } + const key = relayUrlKey(raw) + if (!key || operationScopedRelayKeys.has(key)) continue + operationScopedRelayKeys.add(key) + added.push(key) + } + return () => { + for (const key of added) operationScopedRelayKeys.delete(key) + } +} + +/** @internal */ +export function resetRelayConnectionOperationScopeForTests(): void { + operationScopedRelayKeys.clear() +} + /** Block read-side pool connects / HTTP index fetches when metadata-only policy is on. */ export function isRelayConnectionAllowedForViewer(url: string): boolean { + if (isSingleRelayExplicitPolicyActive()) return true if (!isMetadataRelaysOnlyPolicyActive()) return true return isRelayAllowedUnderMetadataOnlyPolicy(url) } @@ -126,6 +207,29 @@ function isAllowedForKeys(url: string, personalKeys: ReadonlySet): boole return key.length > 0 && personalKeys.has(key) } +/** Under metadata-only policy: viewer relay lists + aggr.nostr.land when nostr.land is listed. */ +function filterRelayUrlsToMetadataOnlyPersonalLists( + urls: readonly string[], + personalKeys: ReadonlySet +): string[] { + return urls.filter((u) => { + const key = relayUrlKey(u) + if (key.length > 0 && personalKeys.has(key)) return true + if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(u)) return true + return false + }) +} + +/** When metadata-only is on, omit global FAST_READ / trending widening from REQ stacks. */ +export function viewerIncludeGlobalFastReadRelayLayer(): boolean { + return !isMetadataRelaysOnlyPolicyActive() +} + +/** When metadata-only is on, omit {@link FAST_WRITE_RELAY_URLS} from read-side merge/fetch stacks (publish unchanged). */ +export function viewerIncludeGlobalFastWriteRelayLayer(): boolean { + return !isMetadataRelaysOnlyPolicyActive() +} + /** * Drop {@link READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS} unless the viewer listed them on NIP-65 / favorites / 10432. * Other read-only index relays (aggr.nostr.land, search.nos.today, …) are unchanged. @@ -144,17 +248,27 @@ export function filterReadOnlyRelaysUnlessPersonal( */ export function sanitizeRelayUrlsForFetch( urls: readonly string[], - personalKeys?: ReadonlySet + personalKeys?: ReadonlySet, + opts?: { preserveExplicitSingleRelay?: boolean } ): string[] { + if (shouldPreserveExplicitSingleRelay(urls, opts?.preserveExplicitSingleRelay)) { + const raw = urls[0]!.trim() + if (!raw) return [] + return [normalizeAnyRelayUrl(raw) || raw] + } const keys = personalKeys ?? viewerPersonalRelayKeys const withoutThirdPartyLocals = urls.filter((u) => { if (urlIsNonLocalForRemoteViewer(u)) return true const key = relayUrlKey(u) return key.length > 0 && keys.has(key) }) - return filterViewerBlockedRelaysForFetch( + let out = filterViewerBlockedRelaysForFetch( filterAggrNostrLandUnlessViewerEligible( filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys) ) ) + if (isMetadataRelaysOnlyPolicyActive()) { + out = filterRelayUrlsToMetadataOnlyPersonalLists(out, keys) + } + return out } diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index f4cf8dfc..6ec86cd0 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -23,7 +23,7 @@ import { normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' -import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' +import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch, isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal' import { getCacheRelayUrls } from './private-relays' import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import client from '@/services/client.service' @@ -206,7 +206,9 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio ] let effectiveIncludeFastRead = includeFastReadRelays - if (userPubkey && includeFastReadRelays) { + if (isMetadataRelaysOnlyPolicyActive()) { + effectiveIncludeFastRead = false + } else if (userPubkey && includeFastReadRelays) { if (useGlobalRelayDefaultsOption !== undefined) { effectiveIncludeFastRead = useGlobalRelayDefaultsOption } else { diff --git a/src/lib/relay-strikes.test.ts b/src/lib/relay-strikes.test.ts index ad2894bd..2cc2ca4a 100644 --- a/src/lib/relay-strikes.test.ts +++ b/src/lib/relay-strikes.test.ts @@ -85,6 +85,13 @@ describe('relaySessionStrikes.clearKey', () => { relaySessionStrikes.reset() }) + it('recordConnectionFailure applies rate-limit cooldown on HTTP 429', () => { + const url = 'wss://relay.layer.systems/' + relaySessionStrikes.recordConnectionFailure(url, 'HTTP/1.1 429 Too Many Requests') + expect(relaySessionStrikes.isRateLimited(url)).toBe(true) + expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(true) + }) + it('removes strike state so relay is no longer skipped', () => { const url = 'ws://localhost:4000/' relaySessionStrikes.applyRateLimitCooldownForUrl(url) diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index 8957ffca..2f3027b0 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -21,6 +21,8 @@ const STRIKE_COOLDOWN_MS = 3 * 60 * 1000 /** Rate-limit style NOTICE / overload → cool down without incrementing strike counter. */ const RATE_LIMIT_COOLDOWN_MS = 10 * 60 * 1000 +/** HTTP 429 on WebSocket handshake: shorter backoff so explicit relay browse recovers after accidental hammering. */ +const CONNECTION_RATE_LIMIT_COOLDOWN_MS = 90 * 1000 /** Non–cache-relay failures: at most one strike increment per key per this window. */ const STRIKE_INCREMENT_DEBOUNCE_MS = 30 * 1000 @@ -123,6 +125,15 @@ class RelaySessionStrikes { return e } + /** True when a relay NOTICE or connection error put this URL in rate-limit cooldown. */ + isRateLimited(url: string): boolean { + const key = sessionKey(url) + if (!key) return false + const e = this.byKey.get(key) + if (!e) return false + return Date.now() < e.rateLimitUntil + } + /** True when read / WS / HTTP index fetch should omit this relay (unless single-relay override). */ isReadHttpSkipped(url: string): boolean { const key = sessionKey(url) @@ -132,6 +143,19 @@ class RelaySessionStrikes { return Date.now() < Math.max(e.rateLimitUntil, e.readStrikeSkipUntil, e.slowParkUntil) } + /** WS/HTTP connect failure: rate-limit style errors cool down without accruing read strikes. */ + recordConnectionFailure(url: string, message: string, source: 'connection' | 'http' = 'connection'): void { + if (classifyRelayNotice(message) === 'rate_limit') { + if (source === 'connection') { + this.applyConnectionRateLimitCooldownForUrl(url) + } else { + this.applyRateLimitCooldownForUrl(url) + } + return + } + this.recordReadFailure(url, source) + } + /** True when publish should omit this relay (unless single-target override). */ isPublishSkipped(url: string): boolean { const key = sessionKey(url) @@ -156,12 +180,17 @@ class RelaySessionStrikes { applyRateLimitCooldownForUrl(url: string): void { const key = sessionKey(url) - if (key) this.applyRateLimitCooldownKey(key) + if (key) this.applyRateLimitCooldownKey(key, RATE_LIMIT_COOLDOWN_MS) + } + + applyConnectionRateLimitCooldownForUrl(url: string): void { + const key = sessionKey(url) + if (key) this.applyRateLimitCooldownKey(key, CONNECTION_RATE_LIMIT_COOLDOWN_MS) } - private applyRateLimitCooldownKey(key: string): void { + private applyRateLimitCooldownKey(key: string, cooldownMs = RATE_LIMIT_COOLDOWN_MS): void { const e = this.getEntry(key) - e.rateLimitUntil = Math.max(e.rateLimitUntil, Date.now() + RATE_LIMIT_COOLDOWN_MS) + e.rateLimitUntil = Math.max(e.rateLimitUntil, Date.now() + cooldownMs) } /** WS connect failure, HTTP transport failure, etc. */ diff --git a/src/lib/single-relay-browse-kinds.test.ts b/src/lib/single-relay-browse-kinds.test.ts new file mode 100644 index 00000000..3f8b2cdb --- /dev/null +++ b/src/lib/single-relay-browse-kinds.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { kinds } from 'nostr-tools' +import { NIP_SEARCH_DOCUMENT_KINDS } from '@/constants' +import { kindsForSingleRelayBrowse, relayUrlIsDocumentRelay } from './single-relay-browse-kinds' + +describe('single-relay-browse-kinds', () => { + it('detects document relays', () => { + expect(relayUrlIsDocumentRelay('wss://essayist.decentnewsroom.com/')).toBe(true) + expect(relayUrlIsDocumentRelay('wss://relay.damus.io/')).toBe(false) + }) + + it('merges document kinds for document relays', () => { + const out = kindsForSingleRelayBrowse('wss://essayist.decentnewsroom.com/', [kinds.ShortTextNote]) + expect(out).toContain(kinds.ShortTextNote) + for (const k of NIP_SEARCH_DOCUMENT_KINDS) { + expect(out).toContain(k) + } + }) + + it('keeps picker kinds only for generic relays', () => { + expect(kindsForSingleRelayBrowse('wss://relay.damus.io/', [kinds.ShortTextNote])).toEqual([ + kinds.ShortTextNote + ]) + }) +}) diff --git a/src/lib/single-relay-browse-kinds.ts b/src/lib/single-relay-browse-kinds.ts new file mode 100644 index 00000000..dc27a0a2 --- /dev/null +++ b/src/lib/single-relay-browse-kinds.ts @@ -0,0 +1,19 @@ +import { DOCUMENT_RELAY_URLS, NIP_SEARCH_DOCUMENT_KINDS } from '@/constants' +import { canonicalRelaySessionKey } from '@/lib/url' +import { kinds } from 'nostr-tools' + +const documentRelayKeySet = new Set( + DOCUMENT_RELAY_URLS.map((u) => canonicalRelaySessionKey(u)).filter(Boolean) +) + +export function relayUrlIsDocumentRelay(url: string): boolean { + const key = canonicalRelaySessionKey(url) + return key.length > 0 && documentRelayKeySet.has(key) +} + +/** Kinds for a single-relay browse REQ (picker + document kinds on document relays). */ +export function kindsForSingleRelayBrowse(relayUrl: string, showKinds: readonly number[]): number[] { + const base = showKinds.length > 0 ? [...showKinds] : [kinds.ShortTextNote] + if (!relayUrlIsDocumentRelay(relayUrl)) return base + return [...new Set([...base, ...NIP_SEARCH_DOCUMENT_KINDS])] +} diff --git a/src/providers/FeedProvider.test.ts b/src/providers/FeedProvider.test.ts index 3d9ebef0..830c5021 100644 --- a/src/providers/FeedProvider.test.ts +++ b/src/providers/FeedProvider.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' -import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' +import { buildWispTrendingNotesRelayUrl, isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' +import { + setRestrictConnectionsToMetadataRelaysOnly, + setViewerPersonalRelayKeys +} from '@/lib/read-only-relay-personal' describe('home feed relay policy', () => { it('keeps aggr.nostr.land out of the main home feed', () => { @@ -39,6 +43,17 @@ describe('home feed relay policy', () => { expect(merged).toContain('wss://inbox.example/') }) + it('metadata-only policy omits wisp trending from home feed relay list', () => { + setRestrictConnectionsToMetadataRelaysOnly(true) + setViewerPersonalRelayKeys(new Set(['wss://relay.example.com/']), { viewerActive: true }) + const wisp = buildWispTrendingNotesRelayUrl() + const urls = buildAllFavoritesFeedRelayUrls(['wss://relay.example.com/'], [], [wisp]) + expect(urls).toContain('wss://relay.example.com/') + expect(urls.some((u) => isWispTrendingNotesRelayUrl(u))).toBe(false) + setRestrictConnectionsToMetadataRelaysOnly(false) + setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) + }) + it('stripNostrLandAggrFromRelayUrls removes aggr with trailing slash and hostname variants', () => { const stripped = stripNostrLandAggrFromRelayUrls([ 'wss://relay.example/', diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index ceed3f88..62d7727a 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -6,6 +6,7 @@ import { syncViewerRelayStackNostrLandAggrEligible, urlsForViewerNostrLandAggrEligibilitySync } from '@/lib/nostr-land-relay-eligibility' +import { METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT } from '@/lib/read-only-relay-personal' import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes' import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays' @@ -264,6 +265,12 @@ export function FeedProvider({ children }: { children: ReactNode }) { } }, [isInitialized, favoriteRelaysIdentity, blockedRelaysIdentity, replyExtraRelaysIdentity, updateFeedRelayUrls]) + useEffect(() => { + const onPolicyChange = () => updateFeedRelayUrls() + window.addEventListener(METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT, onPolicyChange) + return () => window.removeEventListener(METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT, onPolicyChange) + }, [updateFeedRelayUrls]) + return ( closeRelayPoolSocketsIfIdle([relayKey])) } } @@ -532,6 +535,7 @@ export class QueryService { } const resultPromise = new Promise((resolve) => { + const revokeOperationScope = grantRelayConnectionOperationScope(urls) const events: NEvent[] = [] const cancelAbortRegistrations: Array<() => void> = [] const abortHttp = new AbortController() @@ -640,6 +644,8 @@ export class QueryService { } cancelAbortRegistrations.length = 0 resolved = true + revokeOperationScope() + closeRelayPoolSocketsIfIdle([...wsQueryUrls, ...httpRelayBases]) if (resolveTimeout) clearTimeout(resolveTimeout) if (firstResultGraceTimeoutId) clearTimeout(firstResultGraceTimeoutId) if (feedFirstResultGraceTimeoutId) clearTimeout(feedFirstResultGraceTimeoutId) @@ -868,6 +874,8 @@ export class QueryService { return { close: () => {} } } + const revokeOperationScope = grantRelayConnectionOperationScope(relays) + const _knownIds = new Set() const grouped = new Map() for (const url of relays) { @@ -1101,7 +1109,11 @@ export class QueryService { // relay is mis-labeled "skipped" in batch_end. void allOpened.then(() => { subs.forEach(({ close: subClose }) => subClose()) - setTimeout(() => opBatch?.finalize('closed', 'subscribe_close'), 0) + setTimeout(() => { + opBatch?.finalize('closed', 'subscribe_close') + revokeOperationScope() + closeRelayPoolSocketsIfIdle(relays) + }, 0) }) } } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 80fed986..e0e9161e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -55,6 +55,11 @@ import { isRelayConnectionAllowedForViewer, isMetadataRelaysOnlyPolicyActive, isRestrictConnectionsToMetadataRelaysOnly, + grantRelayConnectionOperationScope, + enterSingleRelayExplicitFetchScope, + isSingleRelayExplicitBrowseActive, + isSingleRelayExplicitFetchScopeActive, + isSingleRelayExplicitPolicyActive, setViewerPersonalRelayKeys } from '@/lib/read-only-relay-personal' import { @@ -116,7 +121,12 @@ function sanitizeSubscribeFiltersBeforeReq(filter: Filter | Filter[]): Filter[] return asArray.map(sanitizeETagFilterForSubscribe).filter((f): f is Filter => !!f) } -function withDocumentRelayUrlsForFilters(relays: string[], filters: Filter[]): string[] { +function withDocumentRelayUrlsForFilters( + relays: string[], + filters: Filter[], + opts?: { singleRelayFeed?: boolean } +): string[] { + if (opts?.singleRelayFeed) return relays if (!filters.some((f) => relayFilterIncludesDocumentRelayKind(f))) return relays return dedupeNormalizeRelayUrlsOrdered([...relays, ...DOCUMENT_RELAY_URLS]) } @@ -190,7 +200,7 @@ import { urlMatchesConfiguredHttpIndexRelay } from '@/lib/url' import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor' -import { initRelayPoolIdle, touchRelayPoolActivity } from '@/lib/relay-pool-idle' +import { initRelayPoolIdle, touchRelayPoolActivity, closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle' import { relaySessionStrikes } from '@/lib/relay-strikes' import { isSafari } from '@/lib/utils' import { @@ -466,8 +476,13 @@ class ClientService extends EventTarget { if (params?.purpose !== 'write' && !isRelayConnectionAllowedForViewer(url)) { throw new Error(`[metadata-relays-only] skipping relay ${url}`) } - if (params?.purpose !== 'write' && relaySessionStrikes.isReadHttpSkipped(url)) { - throw new Error(`[relay-strike] skipping unresponsive relay ${url}`) + if (params?.purpose !== 'write') { + if (relaySessionStrikes.isRateLimited(url)) { + throw new Error(`[relay-rate-limit] skipping relay ${url}`) + } + if (!isSingleRelayExplicitPolicyActive() && relaySessionStrikes.isReadHttpSkipped(url)) { + throw new Error(`[relay-strike] skipping unresponsive relay ${url}`) + } } if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) { throw new Error(`[http-index-relay] ${url} uses the HTTPS index API, not WebSocket`) @@ -489,10 +504,11 @@ class ClientService extends EventTarget { params?.purpose !== 'write' && !msg.includes('[metadata-relays-only]') && !msg.includes('[relay-strike]') && + !msg.includes('[relay-rate-limit]') && !msg.includes('[offline]') && !msg.includes('[http-index-relay]') ) { - relaySessionStrikes.recordReadFailure(url, 'connection') + relaySessionStrikes.recordConnectionFailure(url, msg, 'connection') } throw err } @@ -1772,6 +1788,7 @@ class ClientService extends EventTarget { publishOpBatch.record(idx, url, rs?.success === true, rs?.error) }) publishOpBatch.logEnd(status) + queueMicrotask(() => closeRelayPoolSocketsIfIdle(publishTargetUrls)) } /** @@ -2598,7 +2615,8 @@ class ClientService extends EventTarget { onclose, startLogin, onAllClose, - connectionSlotPriority + connectionSlotPriority, + singleRelayExplicit }: { onevent?: (evt: NEvent) => void oneose?: (eosed: boolean) => void @@ -2607,17 +2625,25 @@ class ClientService extends EventTarget { onAllClose?: (reasons: string[]) => void /** Jump the global connection queue (single-relay authoritative timelines). */ connectionSlotPriority?: boolean + /** Authoritative single-relay timeline: keep the target relay through sanitizers and strikes. */ + singleRelayExplicit?: boolean }, relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } ) { const originalDedupedRelays = Array.from(new Set(urls)) + const preserveExplicitSingleRelay = + originalDedupedRelays.length === 1 && + (singleRelayExplicit === true || isSingleRelayExplicitBrowseActive()) + const revokeFetchScope = preserveExplicitSingleRelay ? enterSingleRelayExplicitFetchScope() : () => {} const httpKeys = new Set( httpIndexBasesForRelayQuery(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) => canonicalRelaySessionKey(u) ) ) let relays = sanitizeRelayUrlsForFetch( - originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))) + originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))), + undefined, + { preserveExplicitSingleRelay } ) if (navigator.onLine) { relays = stripLocalNetworkRelaysForWssReq(relays) @@ -2640,7 +2666,9 @@ class ClientService extends EventTarget { } } - relays = withDocumentRelayUrlsForFilters(relays, filters) + relays = withDocumentRelayUrlsForFilters(relays, filters, { + singleRelayFeed: originalDedupedRelays.length === 1 + }) const stripSocialBlockedRelays = SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && @@ -2691,8 +2719,9 @@ class ClientService extends EventTarget { /** * Same rule as {@link QueryService.subscribe}: never `pool.close` a lone relay when the REQ carries NIP-50 * `search` — overlapping one-shots (e.g. Strict Mode) otherwise reset the socket before EOSE. + * Also skip for explicit single-relay browse feeds: close+reconnect on every timeline resubscribe triggers 429s. */ - if (groupedRequests.length === 1 && !hasNip50Search) { + if (groupedRequests.length === 1 && !hasNip50Search && !preserveExplicitSingleRelay) { try { this.pool.close([groupedRequests[0]!.url]) } catch { @@ -2716,6 +2745,8 @@ class ClientService extends EventTarget { } } + const revokeOperationScope = grantRelayConnectionOperationScope(relays) + const reqGroupId = relayReqLog?.groupId ?? `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` @@ -2807,7 +2838,11 @@ class ClientService extends EventTarget { relay = await that.pool.ensureRelay(url, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) patchRelayNoticeForFetchFailures(relay, relayKey, (u, m) => that.handleRelayNoticeSession(u, m)) } catch (err) { - relaySessionStrikes.recordReadFailure(url, 'connection') + relaySessionStrikes.recordConnectionFailure( + url, + (err as Error)?.message ?? String(err), + 'connection' + ) that.queryService.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) return @@ -2864,7 +2899,11 @@ class ClientService extends EventTarget { that.handleRelayNoticeSession(u, m) ) } catch (err) { - relaySessionStrikes.recordReadFailure(url, 'connection') + relaySessionStrikes.recordConnectionFailure( + url, + (err as Error)?.message ?? String(err), + 'connection' + ) nip42ResubscribePending.delete(i) that.queryService.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) @@ -2969,7 +3008,14 @@ class ClientService extends EventTarget { this.removeEventListener('newEvent', handleNewEventFromInternal) void allOpened.then(() => { subs.forEach(({ close: subClose }) => subClose()) - setTimeout(() => opBatch.finalize('closed', 'subscription_closed'), 0) + setTimeout(() => { + opBatch.finalize('closed', 'subscription_closed') + revokeOperationScope() + revokeFetchScope() + if (!preserveExplicitSingleRelay) { + closeRelayPoolSocketsIfIdle(relays) + } + }, 0) }) } } @@ -3067,7 +3113,9 @@ class ClientService extends EventTarget { const httpTimelinePollBases = httpIndexBasesForRelayQuery( originalDedupedRelays, this.viewerHttpIndexRelayBases - ).filter((u) => !relaySessionStrikes.isReadHttpSkipped(u)) + ).filter( + (u) => relayAuthoritativeTimeline || !relaySessionStrikes.isReadHttpSkipped(u) + ) let httpPollIntervalId: ReturnType | null = null let httpPollCursorUnix = 0 const clearHttpTimelinePoll = () => { @@ -3325,7 +3373,8 @@ class ClientService extends EventTarget { onclose: onClose, connectionSlotPriority: connectionSlotPriority === true || - (relayAuthoritativeTimeline && wsRelays.length === 1 && navigator.onLine) + (relayAuthoritativeTimeline && wsRelays.length === 1 && navigator.onLine), + singleRelayExplicit: relayAuthoritativeTimeline && originalDedupedRelays.length === 1 }, httpOnlyShard ? undefined : relayReqLog) @@ -3539,15 +3588,22 @@ class ClientService extends EventTarget { this.viewerHttpIndexRelayBases ) const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u))) + const preserveExplicitSingleRelay = + originalDedupedRelays.length === 1 && + (isSingleRelayExplicitBrowseActive() || isSingleRelayExplicitFetchScopeActive()) const wsOriginal = sanitizeRelayUrlsForFetch( - originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))) + originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))), + undefined, + { preserveExplicitSingleRelay } ) let relays = [...wsOriginal] if (relays.length === 0 && httpRelayBases.length === 0) { relays = [...publicReadRelayFallbackUrls()] } const filters = Array.isArray(filter) ? filter : [filter] - relays = withDocumentRelayUrlsForFilters(relays, filters) + relays = withDocumentRelayUrlsForFilters(relays, filters, { + singleRelayFeed: originalDedupedRelays.length === 1 + }) const stripSocialBlockedRelays = SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) @@ -3622,12 +3678,6 @@ class ClientService extends EventTarget { return { events: [], connectionError: e instanceof Error ? e.message : String(e) } } } - try { - await this.pool.ensureRelay(normalized, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - return { events: [], connectionError: msg } - } try { const events = await this.queryService.query([normalized], filter, undefined, queryOpts) return { events, connectionError: undefined } diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 2fdaa479..e3b316ac 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -124,7 +124,7 @@ class LocalStorageService { private showPublishSuccessToasts: boolean = false private showDetailedPublishToasts: boolean = true private showLiveActivitiesBanner: boolean = true - private restrictRelaysToMetadataLists: boolean = false + private restrictRelaysToMetadataLists: boolean = true constructor() { if (!LocalStorageService.instance) { @@ -422,7 +422,7 @@ class LocalStorageService { const restrictMetadataRelaysStr = window.localStorage.getItem( StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS ) - this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr === 'true' + this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr !== 'false' setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists) // Clean up deprecated data @@ -617,7 +617,7 @@ class LocalStorageService { if (paneStr === 'single' || paneStr === 'double') this.panelMode = paneStr const restrictMetadataRelaysStr = get(StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS) if (restrictMetadataRelaysStr != null) { - this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr === 'true' + this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr !== 'false' setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists) } }