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