Browse Source

tighten relay selection

imwald
Silberengel 2 weeks ago
parent
commit
4d5bc73cc4
  1. 5
      src/components/MetadataRelaysOnlySetting/index.tsx
  2. 4
      src/components/NormalFeed/index.tsx
  3. 39
      src/components/NoteList/index.tsx
  4. 34
      src/components/Relay/index.tsx
  5. 5
      src/constants.ts
  6. 1
      src/hooks/index.tsx
  7. 19
      src/hooks/useRelayPageFeedPolicy.ts
  8. 4
      src/lib/account-list-relay-urls.ts
  9. 12
      src/lib/favorites-feed-relays.ts
  10. 7
      src/lib/home-feed-relays.ts
  11. 4
      src/lib/live-activities.ts
  12. 14
      src/lib/metadata-policy-curated-relays.test.ts
  13. 34
      src/lib/metadata-policy-curated-relays.ts
  14. 62
      src/lib/read-only-relay-personal.test.ts
  15. 124
      src/lib/read-only-relay-personal.ts
  16. 6
      src/lib/relay-list-builder.ts
  17. 7
      src/lib/relay-strikes.test.ts
  18. 35
      src/lib/relay-strikes.ts
  19. 25
      src/lib/single-relay-browse-kinds.test.ts
  20. 19
      src/lib/single-relay-browse-kinds.ts
  21. 17
      src/providers/FeedProvider.test.ts
  22. 7
      src/providers/FeedProvider.tsx
  23. 16
      src/services/client-query.service.ts
  24. 94
      src/services/client.service.ts
  25. 6
      src/services/local-storage.service.ts

5
src/components/MetadataRelaysOnlySetting/index.tsx

@ -1,8 +1,8 @@ @@ -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() { @@ -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() { @@ -32,7 +33,7 @@ export default function MetadataRelaysOnlySetting() {
</div>
<div className="text-muted-foreground text-xs max-w-xl">
{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.'
)}
</div>
</div>

4
src/components/NormalFeed/index.tsx

@ -100,6 +100,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -100,6 +100,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
hostPrimaryPageName?: TPrimaryPageName
/** Single-relay kindless wave EOSEd with no events: parent re-subscribes with explicit kinds. */
onSingleRelayKindlessEmpty?: () => 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<TNoteListRef, { @@ -151,6 +153,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
showFeedClientFilter: showFeedClientFilterProp,
hostPrimaryPageName,
onSingleRelayKindlessEmpty,
onSingleRelayBrowseEmpty,
feedTopNotice,
oneShotFetch = false,
progressiveWarmupQuery,
@ -412,6 +415,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -412,6 +415,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
hostPrimaryPageName={hostPrimaryPageName}
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}
onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty}
onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty}
feedTopNotice={feedTopNotice}
oneShotFetch={oneShotFetch}
progressiveWarmupQuery={progressiveWarmupQuery}

39
src/components/NoteList/index.tsx

@ -27,6 +27,7 @@ import { @@ -27,6 +27,7 @@ import {
isSpellSubRequestsSameFiltersDifferentRelays
} from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger'
import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal'
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist'
import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
@ -781,6 +782,7 @@ const NoteList = forwardRef( @@ -781,6 +782,7 @@ const NoteList = forwardRef(
*/
feedClientFilterTabRowHost,
onSingleRelayKindlessEmpty,
onSingleRelayBrowseEmpty,
feedTopNotice,
gridLayout = false,
/**
@ -855,6 +857,8 @@ const NoteList = forwardRef( @@ -855,6 +857,8 @@ const NoteList = forwardRef(
feedClientFilterTabRowHost?: HTMLElement | null
/** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */
onSingleRelayKindlessEmpty?: () => 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( @@ -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<ReturnType<typeof setTimeout> | null>(null)
/** Dedupe {@link toast.error} when relays return nothing for a feed load. */
@ -2250,6 +2257,7 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -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( @@ -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

34
src/components/Relay/index.tsx

@ -2,7 +2,7 @@ import NormalFeed from '@/components/NormalFeed' @@ -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 @@ -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< @@ -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<TNoteListRef>(null)
const noteListRef = ref ?? internalNoteListRef
@ -81,13 +84,21 @@ const Relay = forwardRef< @@ -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<TFeedSubRequest[]>(() => {
if (!normalizedUrl) return []
const q = debouncedInput.trim()
@ -99,13 +110,21 @@ const Relay = forwardRef< @@ -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< @@ -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< @@ -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< @@ -168,6 +187,7 @@ const Relay = forwardRef<
extraShouldHideEvent={shouldHideEventNotFromThisRelay}
extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay}
relayAuthoritativeFeedOnly
onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty}
alexandriaEmptyUrl={alexandriaFeedEmptyUrl}
/>
</div>

5
src/constants.ts

@ -165,7 +165,7 @@ export const RELAY_SLOW_PARK_SIGNALS_THRESHOLD = 2 @@ -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 = [ @@ -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'
]
/**

1
src/hooks/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
export * from './useBypassMetadataRelaysOnlyPolicy'
export * from './useRelayPageFeedPolicy'
export * from './useNearViewport'
export * from './useFetchCalendarRsvps'
export * from './useFetchEvent'

19
src/hooks/useRelayPageFeedPolicy.ts

@ -0,0 +1,19 @@ @@ -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()
}
}, [])
}

4
src/lib/account-list-relay-urls.ts

@ -32,7 +32,7 @@ export async function buildAccountListRelayUrlsForMerge(options: { @@ -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: { @@ -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))]

12
src/lib/favorites-feed-relays.ts

@ -20,6 +20,7 @@ import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay- @@ -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( @@ -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( @@ -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( @@ -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 ?? [])

7
src/lib/home-feed-relays.ts

@ -1,6 +1,8 @@ @@ -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( @@ -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( @@ -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',

4
src/lib/live-activities.ts

@ -7,6 +7,7 @@ import { @@ -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: { @@ -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(

14
src/lib/metadata-policy-curated-relays.test.ts

@ -1,10 +1,20 @@ @@ -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)
})
})

34
src/lib/metadata-policy-curated-relays.ts

@ -24,7 +24,20 @@ const METADATA_POLICY_CURATED_RELAY_LISTS: readonly (readonly string[])[] = [ @@ -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<string> | null = null
let operationScopedRelayKeySet: ReadonlySet<string> | null = null
function relayKeyForCuratedSet(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
@ -44,12 +57,32 @@ function getCuratedRelayKeySet(): ReadonlySet<string> { @@ -44,12 +57,32 @@ function getCuratedRelayKeySet(): ReadonlySet<string> {
return curatedRelayKeySet
}
function getOperationScopedRelayKeySet(): ReadonlySet<string> {
if (!operationScopedRelayKeySet) {
const out = new Set<string>()
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<string> | null = null
function getProfileRelayKeySet(): ReadonlySet<string> {
@ -73,5 +106,6 @@ export function isMetadataPolicyProfileRelay(url: string): boolean { @@ -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
}

62
src/lib/read-only-relay-personal.test.ts

@ -4,11 +4,16 @@ import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-rela @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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()
})
})

124
src/lib/read-only-relay-personal.ts

@ -1,7 +1,7 @@ @@ -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 @@ -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<string>()
/** 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 { @@ -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 { @@ -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<string>): boole @@ -126,6 +207,29 @@ function isAllowedForKeys(url: string, personalKeys: ReadonlySet<string>): 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>
): 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( @@ -144,17 +248,27 @@ export function filterReadOnlyRelaysUnlessPersonal(
*/
export function sanitizeRelayUrlsForFetch(
urls: readonly string[],
personalKeys?: ReadonlySet<string>
personalKeys?: ReadonlySet<string>,
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
}

6
src/lib/relay-list-builder.ts

@ -23,7 +23,7 @@ import { @@ -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 @@ -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 {

7
src/lib/relay-strikes.test.ts

@ -85,6 +85,13 @@ describe('relaySessionStrikes.clearKey', () => { @@ -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)

35
src/lib/relay-strikes.ts

@ -21,6 +21,8 @@ const STRIKE_COOLDOWN_MS = 3 * 60 * 1000 @@ -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 { @@ -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 { @@ -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 { @@ -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. */

25
src/lib/single-relay-browse-kinds.test.ts

@ -0,0 +1,25 @@ @@ -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
])
})
})

19
src/lib/single-relay-browse-kinds.ts

@ -0,0 +1,19 @@ @@ -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])]
}

17
src/providers/FeedProvider.test.ts

@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' @@ -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', () => { @@ -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/',

7
src/providers/FeedProvider.tsx

@ -6,6 +6,7 @@ import { @@ -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 }) { @@ -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 (
<FeedContext.Provider
value={useMemo(

16
src/services/client-query.service.ts

@ -40,7 +40,8 @@ import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch- @@ -40,7 +40,8 @@ import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-
import type { Filter, Event as NEvent } from 'nostr-tools'
import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools'
import type { AbstractRelay } from 'nostr-tools/abstract-relay'
import { sanitizeRelayUrlsForFetch, isRelayConnectionAllowedForViewer } from '@/lib/read-only-relay-personal'
import { sanitizeRelayUrlsForFetch, isRelayConnectionAllowedForViewer, grantRelayConnectionOperationScope } from '@/lib/read-only-relay-personal'
import { closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle'
import { publicReadRelayFallbackUrls } from '@/lib/viewer-relay-defaults'
import nip66Service from './nip66.service'
import type { ISigner, TSignerType } from '@/types'
@ -399,6 +400,8 @@ export class QueryService { @@ -399,6 +400,8 @@ export class QueryService {
if (queue?.length) {
const next = queue.shift()!
next()
} else if (count === 0) {
queueMicrotask(() => closeRelayPoolSocketsIfIdle([relayKey]))
}
}
@ -532,6 +535,7 @@ export class QueryService { @@ -532,6 +535,7 @@ export class QueryService {
}
const resultPromise = new Promise<NEvent[]>((resolve) => {
const revokeOperationScope = grantRelayConnectionOperationScope(urls)
const events: NEvent[] = []
const cancelAbortRegistrations: Array<() => void> = []
const abortHttp = new AbortController()
@ -640,6 +644,8 @@ export class QueryService { @@ -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 { @@ -868,6 +874,8 @@ export class QueryService {
return { close: () => {} }
}
const revokeOperationScope = grantRelayConnectionOperationScope(relays)
const _knownIds = new Set<string>()
const grouped = new Map<string, Filter[]>()
for (const url of relays) {
@ -1101,7 +1109,11 @@ export class QueryService { @@ -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)
})
}
}

94
src/services/client.service.ts

@ -55,6 +55,11 @@ import { @@ -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[] @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<typeof setInterval> | null = null
let httpPollCursorUnix = 0
const clearHttpTimelinePoll = () => {
@ -3325,7 +3373,8 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 }

6
src/services/local-storage.service.ts

@ -124,7 +124,7 @@ class LocalStorageService { @@ -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 { @@ -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 { @@ -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)
}
}

Loading…
Cancel
Save