diff --git a/src/components/MetadataRelaysOnlySetting/index.tsx b/src/components/MetadataRelaysOnlySetting/index.tsx
new file mode 100644
index 00000000..ddc39027
--- /dev/null
+++ b/src/components/MetadataRelaysOnlySetting/index.tsx
@@ -0,0 +1,39 @@
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+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'
+
+export default function MetadataRelaysOnlySetting() {
+ const { t } = useTranslation()
+ const [enabled, setEnabled] = useState(false)
+
+ useEffect(() => {
+ const on = storage.getRestrictRelaysToMetadataLists()
+ setEnabled(on)
+ setRestrictConnectionsToMetadataRelaysOnly(on)
+ }, [])
+
+ const onChange = (checked: boolean) => {
+ setEnabled(checked)
+ storage.setRestrictRelaysToMetadataLists(checked)
+ setRestrictConnectionsToMetadataRelaysOnly(checked)
+ client.interruptBackgroundQueries({ closePooledRelayConnections: true })
+ }
+
+ return (
+
+
+
+
+
+
+ {t(
+ 'When on, the app only connects to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. It will not open background connections to public mirrors, author outboxes, or other suggested relays.'
+ )}
+
+
+ )
+}
diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx
index a1607614..dd853c42 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 { useFetchRelayInfo } from '@/hooks'
+import { useBypassMetadataRelaysOnlyPolicy, useFetchRelayInfo } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager'
import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import { canonicalRelaySessionKey, isLocalNetworkUrl, normalizeRelayUrlForPage } from '@/lib/url'
@@ -31,6 +31,7 @@ const Relay = forwardRef<
ref
) {
const { t } = useTranslation()
+ useBypassMetadataRelaysOnlyPolicy()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const { showKinds } = useKindFilterOrDefaults()
const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url])
diff --git a/src/constants.ts b/src/constants.ts
index 8e485a41..0f9115b8 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -369,6 +369,8 @@ export const StorageKey = {
SHOW_RSS_FEED: 'showRssFeed',
PANE_MODE: 'paneMode',
ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish',
+ /** When `'true'`, only connect to relays on the viewer's NIP-65 / favorites / cache / HTTP lists. */
+ RESTRICT_RELAYS_TO_METADATA_LISTS: 'restrictRelaysToMetadataLists',
/** When not `'false'`, show green Sonner toasts after successful publishes (default on). */
SHOW_PUBLISH_SUCCESS_TOASTS: 'showPublishSuccessToasts',
/** When not `'false'`, show NIP-53 live activity banner (default on). */
diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx
index e02edc8f..24c0b783 100644
--- a/src/hooks/index.tsx
+++ b/src/hooks/index.tsx
@@ -1,3 +1,4 @@
+export * from './useBypassMetadataRelaysOnlyPolicy'
export * from './useNearViewport'
export * from './useFetchCalendarRsvps'
export * from './useFetchEvent'
diff --git a/src/hooks/useBypassMetadataRelaysOnlyPolicy.ts b/src/hooks/useBypassMetadataRelaysOnlyPolicy.ts
new file mode 100644
index 00000000..4595e7b9
--- /dev/null
+++ b/src/hooks/useBypassMetadataRelaysOnlyPolicy.ts
@@ -0,0 +1,13 @@
+import {
+ enterMetadataRelaysOnlyBypass,
+ leaveMetadataRelaysOnlyBypass
+} from '@/lib/read-only-relay-personal'
+import { useEffect } from 'react'
+
+/** Disable “only my relay lists” while mounted (relay explore, search, relay directory). */
+export function useBypassMetadataRelaysOnlyPolicy(): void {
+ useEffect(() => {
+ enterMetadataRelaysOnlyBypass()
+ return () => leaveMetadataRelaysOnlyBypass()
+ }, [])
+}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 5e3d5c50..b85fca25 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -106,7 +106,10 @@ export default {
"Please log in to view notifications.": "Please log in to view notifications.",
"Follows you": "Folgt dir",
"Relay Settings": "Relay-Einstellungen",
- "Relays and Storage Settings": "Relays and Storage Settings",
+ "Relays and Storage Settings": "Relays und Speicher",
+ "Only my relay lists": "Nur meine Relay-Listen",
+ "When on, the app only connects to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. It will not open background connections to public mirrors, author outboxes, or other suggested relays.":
+ "Wenn aktiv, werden Feeds nicht mehr auf generische öffentliche Leserelays (FAST_READ) oder zufällige Autoren-/Hinweis-Relays erweitert. Deine Relay-Listen, Profil- und Suchindex-Relays, Dokument-Relays und aggr.nostr.land (mit Nostr Land) bleiben aktiv. Relay-Entdecken und Suche sind ausgenommen.",
"Relay set name": "Relay-Set Name",
"Add a new relay set": "Neues Relay-Set hinzufügen",
Add: "Hinzufügen",
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index c772984f..80ce9d07 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -112,6 +112,9 @@ export default {
"Follows you": "Follows you",
"Relay Settings": "Relays and Storage Settings",
"Relays and Storage Settings": "Relays and Storage Settings",
+ "Only my relay lists": "Only my relay lists",
+ "When on, the app only connects to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. It will not open background connections to public mirrors, author outboxes, or other suggested relays.":
+ "When on, the app stops widening feeds to generic public read relays (FAST_READ) and random author or hint relays. Your relay lists, profile and search index relays, document relays, and aggr.nostr.land (with Nostr Land) still work. Relay explore and Search pages are exempt.",
"Relay set name": "Relay set name",
"Add a new relay set": "Add a new relay set",
Add: "Add",
diff --git a/src/lib/metadata-policy-curated-relays.test.ts b/src/lib/metadata-policy-curated-relays.test.ts
new file mode 100644
index 00000000..f3b94a09
--- /dev/null
+++ b/src/lib/metadata-policy-curated-relays.test.ts
@@ -0,0 +1,10 @@
+import { PROFILE_RELAY_URLS } from '@/constants'
+import { describe, expect, it } from 'vitest'
+import { isMetadataPolicyCuratedRelay } 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)
+ })
+})
diff --git a/src/lib/metadata-policy-curated-relays.ts b/src/lib/metadata-policy-curated-relays.ts
new file mode 100644
index 00000000..1a7a0469
--- /dev/null
+++ b/src/lib/metadata-policy-curated-relays.ts
@@ -0,0 +1,56 @@
+import {
+ BOOKSTR_RELAY_URLS,
+ DOCUMENT_RELAY_URLS,
+ FOLLOWS_HISTORY_RELAY_URLS,
+ GIF_RELAY_URLS,
+ NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS,
+ NIP66_DISCOVERY_RELAY_URLS,
+ PROFILE_RELAY_URLS,
+ READ_ONLY_RELAY_URLS,
+ SEARCHABLE_RELAY_URLS
+} from '@/constants'
+import { normalizeAnyRelayUrl } from '@/lib/url'
+
+/** App-defined relay stacks (metadata, search, documents, …) — not ad-hoc FAST_READ widening. */
+const METADATA_POLICY_CURATED_RELAY_LISTS: readonly (readonly string[])[] = [
+ PROFILE_RELAY_URLS,
+ READ_ONLY_RELAY_URLS,
+ SEARCHABLE_RELAY_URLS,
+ DOCUMENT_RELAY_URLS,
+ GIF_RELAY_URLS,
+ BOOKSTR_RELAY_URLS,
+ NIP66_DISCOVERY_RELAY_URLS,
+ FOLLOWS_HISTORY_RELAY_URLS,
+ NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS
+]
+
+let curatedRelayKeySet: ReadonlySet | null = null
+
+function relayKeyForCuratedSet(url: string): string {
+ return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
+}
+
+function getCuratedRelayKeySet(): ReadonlySet {
+ if (!curatedRelayKeySet) {
+ const out = new Set()
+ for (const list of METADATA_POLICY_CURATED_RELAY_LISTS) {
+ for (const u of list) {
+ const key = relayKeyForCuratedSet(u)
+ if (key) out.add(key)
+ }
+ }
+ curatedRelayKeySet = out
+ }
+ return curatedRelayKeySet
+}
+
+/** 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)
+}
+
+/** For tests: reset lazy-built key set after constant changes. */
+export function resetMetadataPolicyCuratedRelayKeysForTests(): void {
+ curatedRelayKeySet = null
+}
diff --git a/src/lib/read-only-relay-personal.test.ts b/src/lib/read-only-relay-personal.test.ts
index 1e4ba68d..9517dd71 100644
--- a/src/lib/read-only-relay-personal.test.ts
+++ b/src/lib/read-only-relay-personal.test.ts
@@ -5,19 +5,26 @@ import {
buildPersonalRelayKeySet,
filterReadOnlyRelaysUnlessPersonal,
isPersonalListRequiredReadOnlyRelay,
+ isRelayConnectionAllowedForViewer,
sanitizeRelayUrlsForFetch,
+ enterMetadataRelaysOnlyBypass,
+ leaveMetadataRelaysOnlyBypass,
+ setRestrictConnectionsToMetadataRelaysOnly,
setViewerPersonalRelayKeys
} from './read-only-relay-personal'
import { setViewerBlockedRelayUrls } from './viewer-blocked-relays'
describe('read-only-relay-personal', () => {
beforeEach(() => {
- setViewerPersonalRelayKeys(new Set())
+ setRestrictConnectionsToMetadataRelaysOnly(false)
+ setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
setViewerBlockedRelayUrls([])
syncViewerRelayStackNostrLandAggrEligible([])
})
afterEach(() => {
+ setRestrictConnectionsToMetadataRelaysOnly(false)
+ leaveMetadataRelaysOnlyBypass()
setViewerBlockedRelayUrls([])
syncViewerRelayStackNostrLandAggrEligible([])
})
@@ -61,8 +68,49 @@ describe('read-only-relay-personal', () => {
})
it('keeps filter.nostr.wine when on the viewer personal list', () => {
- setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://filter.nostr.wine/']))
+ setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://filter.nostr.wine/']), { viewerActive: true })
const urls = ['wss://relay.damus.io/', 'wss://filter.nostr.wine/']
expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual(urls)
})
+
+ it('metadata-only policy blocks ad-hoc and FAST_READ bootstrap relays, keeps profile relays', () => {
+ setRestrictConnectionsToMetadataRelaysOnly(true)
+ setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://relay.example.com/']), { viewerActive: true })
+ const urls = [
+ 'wss://relay.example.com/',
+ 'wss://profiles.nostr1.com/',
+ 'wss://theforest.nostr1.com/',
+ 'wss://nostr.wirednet.jp/'
+ ]
+ expect(sanitizeRelayUrlsForFetch(urls)).toEqual([
+ 'wss://relay.example.com/',
+ 'wss://profiles.nostr1.com/'
+ ])
+ expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true)
+ expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false)
+ expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false)
+ })
+
+ it('metadata-only policy still allows aggr when viewer lists wss://nostr.land', () => {
+ setRestrictConnectionsToMetadataRelaysOnly(true)
+ syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
+ setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true })
+ 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',
+ ])
+ expect(isRelayConnectionAllowedForViewer(AGGR_NOSTR_LAND_WSS)).toBe(true)
+ expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false)
+ })
+
+ it('metadata-only bypass allows relays outside personal lists', () => {
+ setRestrictConnectionsToMetadataRelaysOnly(true)
+ setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true })
+ enterMetadataRelaysOnlyBypass()
+ const urls = ['wss://nostr.land/', 'wss://relay.damus.io/']
+ expect(sanitizeRelayUrlsForFetch(urls)).toEqual(urls)
+ expect(isRelayConnectionAllowedForViewer('wss://relay.damus.io/')).toBe(true)
+ leaveMetadataRelaysOnlyBypass()
+ })
})
diff --git a/src/lib/read-only-relay-personal.ts b/src/lib/read-only-relay-personal.ts
index 796b64ec..d9f2d92d 100644
--- a/src/lib/read-only-relay-personal.ts
+++ b/src/lib/read-only-relay-personal.ts
@@ -1,5 +1,15 @@
-import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants'
-import { filterAggrNostrLandUnlessViewerEligible } from '@/lib/nostr-land-relay-eligibility'
+import {
+ DEFAULT_FAVORITE_RELAYS,
+ FAST_READ_RELAY_URLS,
+ FAST_WRITE_RELAY_URLS,
+ READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS
+} from '@/constants'
+import { isMetadataPolicyCuratedRelay } from '@/lib/metadata-policy-curated-relays'
+import {
+ filterAggrNostrLandUnlessViewerEligible,
+ getViewerRelayStackNostrLandAggrEligible,
+ relayUrlIsAggrNostrLand
+} from '@/lib/nostr-land-relay-eligibility'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { filterViewerBlockedRelaysForFetch } from '@/lib/viewer-blocked-relays'
import { normalizeAnyRelayUrl } from '@/lib/url'
@@ -11,6 +21,90 @@ const personalListRequiredKeySet = new Set(
)
let viewerPersonalRelayKeys = new Set()
+/** True after a logged-in viewer's personal relay keys were synced (including empty lists). */
+let viewerMetadataRelaysPolicyActive = false
+let restrictConnectionsToMetadataRelaysOnly = false
+/** Relay explore / search UI: metadata-only policy must not narrow relays on those pages. */
+let metadataRelaysOnlyBypassDepth = 0
+
+export function setRestrictConnectionsToMetadataRelaysOnly(enabled: boolean): void {
+ restrictConnectionsToMetadataRelaysOnly = enabled
+}
+
+export function isRestrictConnectionsToMetadataRelaysOnly(): boolean {
+ return restrictConnectionsToMetadataRelaysOnly
+}
+
+export function enterMetadataRelaysOnlyBypass(): void {
+ metadataRelaysOnlyBypassDepth++
+}
+
+export function leaveMetadataRelaysOnlyBypass(): void {
+ metadataRelaysOnlyBypassDepth = Math.max(0, metadataRelaysOnlyBypassDepth - 1)
+}
+
+export function isMetadataRelaysOnlyBypassActive(): boolean {
+ return metadataRelaysOnlyBypassDepth > 0
+}
+
+let metadataPolicyBootstrapBlockedKeys: ReadonlySet | null = null
+
+function getMetadataPolicyBootstrapBlockedKeys(): ReadonlySet {
+ if (!metadataPolicyBootstrapBlockedKeys) {
+ const out = new Set()
+ for (const list of [FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, DEFAULT_FAVORITE_RELAYS]) {
+ for (const u of list) {
+ const key = relayUrlKey(u)
+ if (key) out.add(key)
+ }
+ }
+ metadataPolicyBootstrapBlockedKeys = out
+ }
+ return metadataPolicyBootstrapBlockedKeys
+}
+
+/** True when URL is only a generic bootstrap mirror (FAST_READ / FAST_WRITE / default favorites). */
+export function isMetadataPolicyBootstrapRelay(url: string): boolean {
+ const key = relayUrlKey(url)
+ return key.length > 0 && getMetadataPolicyBootstrapBlockedKeys().has(key)
+}
+
+/** Logged-in viewer with metadata-only mode: block FAST_READ widening, keep curated stacks. */
+export function isMetadataRelaysOnlyPolicyActive(): boolean {
+ return (
+ restrictConnectionsToMetadataRelaysOnly &&
+ viewerMetadataRelaysPolicyActive &&
+ !isMetadataRelaysOnlyBypassActive()
+ )
+}
+
+export function isRelayUrlInViewerMetadataLists(url: string): boolean {
+ const key = relayUrlKey(url)
+ return key.length > 0 && viewerPersonalRelayKeys.has(key)
+}
+
+/**
+ * Under metadata-only policy: viewer lists, Nostr Land aggr, and {@link isMetadataPolicyCuratedRelay}
+ * (profile / read-only / searchable / document stacks). Blocks ad-hoc relays and FAST_READ bootstrap only.
+ */
+export function isRelayAllowedUnderMetadataOnlyPolicy(url: string): boolean {
+ if (isRelayUrlInViewerMetadataLists(url)) return true
+ if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(url)) return true
+ if (isMetadataPolicyCuratedRelay(url)) return true
+ if (isMetadataPolicyBootstrapRelay(url)) return false
+ return false
+}
+
+/** Block WebSocket (and other) pool connects when metadata-only policy is on. */
+export function isRelayConnectionAllowedForViewer(url: string): boolean {
+ if (!isMetadataRelaysOnlyPolicyActive()) return true
+ return isRelayAllowedUnderMetadataOnlyPolicy(url)
+}
+
+function filterToViewerMetadataRelaysOnly(urls: readonly string[]): string[] {
+ if (!isMetadataRelaysOnlyPolicyActive()) return [...urls]
+ return urls.filter((u) => isRelayAllowedUnderMetadataOnlyPolicy(u))
+}
export function relayUrlKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
@@ -37,8 +131,14 @@ export function buildPersonalRelayKeySet(urls: readonly string[]): Set {
}
/** Updated when the logged-in viewer's NIP-65 / favorites / cache relays hydrate. */
-export function setViewerPersonalRelayKeys(keys: ReadonlySet): void {
+export function setViewerPersonalRelayKeys(
+ keys: ReadonlySet,
+ policy?: { viewerActive?: boolean }
+): void {
viewerPersonalRelayKeys = new Set(keys)
+ if (policy?.viewerActive !== undefined) {
+ viewerMetadataRelaysPolicyActive = policy.viewerActive
+ }
}
export function getViewerPersonalRelayKeys(): ReadonlySet {
@@ -83,9 +183,11 @@ export function sanitizeRelayUrlsForFetch(
const key = relayUrlKey(u)
return key.length > 0 && keys.has(key)
})
- return filterViewerBlockedRelaysForFetch(
- filterAggrNostrLandUnlessViewerEligible(
- filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys)
+ return filterToViewerMetadataRelaysOnly(
+ filterViewerBlockedRelaysForFetch(
+ filterAggrNostrLandUnlessViewerEligible(
+ filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys)
+ )
)
)
}
diff --git a/src/lib/relay-list-sanitize.test.ts b/src/lib/relay-list-sanitize.test.ts
index 27be159f..3413badf 100644
--- a/src/lib/relay-list-sanitize.test.ts
+++ b/src/lib/relay-list-sanitize.test.ts
@@ -23,7 +23,7 @@ describe('stripLocalRelaysFromThirdPartyHints', () => {
describe('sanitizeRelayUrlsForFetch', () => {
it('strips third-party locals and unlisted filter.nostr.wine', () => {
- setViewerPersonalRelayKeys(new Set())
+ setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
const urls = ['wss://relay.example.com/', 'ws://127.0.0.1:7777/', 'wss://filter.nostr.wine/']
expect(sanitizeRelayUrlsForFetch(urls)).toEqual(['wss://relay.example.com/'])
})
diff --git a/src/lib/viewer-relay-defaults.ts b/src/lib/viewer-relay-defaults.ts
index 6e64433d..940b810b 100644
--- a/src/lib/viewer-relay-defaults.ts
+++ b/src/lib/viewer-relay-defaults.ts
@@ -3,6 +3,7 @@ import {
FAST_READ_RELAY_URLS,
PROFILE_RELAY_URLS
} from '@/constants'
+import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal'
import { normalizeUrl } from '@/lib/url'
export type ViewerRelayListLike = {
@@ -16,11 +17,17 @@ export type ViewerRelayListLike = {
* the user is not signed in, or when they are signed in but have configured neither favorite relays nor a NIP-65
* (kind 10002 / HTTP index) relay list. Otherwise REQ/publish stacks should stay on their own relays.
*/
+/** Public read mirrors used when relay lists are empty; empty when metadata-only policy is on. */
+export function publicReadRelayFallbackUrls(): readonly string[] {
+ return isMetadataRelaysOnlyPolicyActive() ? [] : FAST_READ_RELAY_URLS
+}
+
export function viewerUsesGlobalRelayDefaults(args: {
viewerPubkey: string | null | undefined
favoriteRelayUrls: readonly string[]
relayList: ViewerRelayListLike
}): boolean {
+ if (isMetadataRelaysOnlyPolicyActive()) return false
if (!args.viewerPubkey?.trim()) return true
const hasFavorites = args.favoriteRelayUrls.some((u) => typeof u === 'string' && u.trim().length > 0)
const rl = args.relayList
diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx
index 3d193a59..1c6dd2d0 100644
--- a/src/pages/primary/ExplorePage/index.tsx
+++ b/src/pages/primary/ExplorePage/index.tsx
@@ -3,6 +3,7 @@ import { buildExplorePopularRelayUrls } from '@/lib/explore-popular-relays'
import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
+import { useBypassMetadataRelaysOnlyPolicy } from '@/hooks/useBypassMetadataRelaysOnlyPolicy'
import { useSmartRelayNavigation } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
@@ -61,6 +62,7 @@ function filterMonitoringRelaySuggestions(urls: string[], rawQuery: string): str
}
const ExplorePage = forwardRef((_, ref) => {
+ useBypassMetadataRelaysOnlyPolicy()
const { pubkey, relayList } = useNostr()
const layoutRef = useRef(null)
const [contentRefreshKey, setContentRefreshKey] = useState(0)
diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx
index ae2d10a2..1daaf3d3 100644
--- a/src/pages/primary/SearchPage/index.tsx
+++ b/src/pages/primary/SearchPage/index.tsx
@@ -8,10 +8,12 @@ import { useNostr } from '@/providers/NostrProvider'
import { TPageRef, TSearchParams } from '@/types'
import { BookOpen, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
+import { useBypassMetadataRelaysOnlyPolicy } from '@/hooks/useBypassMetadataRelaysOnlyPolicy'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const SearchPage = forwardRef((_props, ref) => {
+ useBypassMetadataRelaysOnlyPolicy()
const { t } = useTranslation()
const { current, display } = usePrimaryPage()
const { pubkey, relayList } = useNostr()
diff --git a/src/pages/secondary/RelayReviewsPage/index.tsx b/src/pages/secondary/RelayReviewsPage/index.tsx
index 064713bf..fc40a164 100644
--- a/src/pages/secondary/RelayReviewsPage/index.tsx
+++ b/src/pages/secondary/RelayReviewsPage/index.tsx
@@ -13,11 +13,13 @@ import { normalizeRelayUrlForPage, simplifyUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import type { TFeedSubRequest } from '@/types'
+import { useBypassMetadataRelaysOnlyPolicy } from '@/hooks/useBypassMetadataRelaysOnlyPolicy'
import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage'
const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => {
+ useBypassMetadataRelaysOnlyPolicy()
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef(null)
diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx
index 5d91c809..94001f0d 100644
--- a/src/pages/secondary/RelaySettingsPage/index.tsx
+++ b/src/pages/secondary/RelaySettingsPage/index.tsx
@@ -1,4 +1,5 @@
import CacheRelaysSetting from '@/components/CacheRelaysSetting'
+import MetadataRelaysOnlySetting from '@/components/MetadataRelaysOnlySetting'
import HttpRelaysSetting from '@/components/HttpRelaysSetting'
import JsonViewDialog from '@/components/JsonViewDialog'
import MailboxSetting from '@/components/MailboxSetting'
@@ -120,6 +121,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
}
>
setJsonOpen(false)} />
+
+
+
{t('Favorite Relays')}
diff --git a/src/pages/secondary/SearchPage/index.tsx b/src/pages/secondary/SearchPage/index.tsx
index 900787d1..ac625ff1 100644
--- a/src/pages/secondary/SearchPage/index.tsx
+++ b/src/pages/secondary/SearchPage/index.tsx
@@ -11,10 +11,12 @@ import { useNostr } from '@/providers/NostrProvider'
import { BookOpen } from 'lucide-react'
import { TSearchParams } from '@/types'
import { Button } from '@/components/ui/button'
+import { useBypassMetadataRelaysOnlyPolicy } from '@/hooks/useBypassMetadataRelaysOnlyPolicy'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
+ useBypassMetadataRelaysOnlyPolicy()
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage()
diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts
index f4f090b5..03e4a3f5 100644
--- a/src/services/client-query.service.ts
+++ b/src/services/client-query.service.ts
@@ -42,6 +42,7 @@ 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 } from '@/lib/read-only-relay-personal'
+import { publicReadRelayFallbackUrls } from '@/lib/viewer-relay-defaults'
import nip66Service from './nip66.service'
import type { ISigner, TSignerType } from '@/types'
@@ -803,7 +804,7 @@ export class QueryService {
if (relayFiltersUseCapitalLetterTagKeys(filters)) {
relays = relayUrlsStripExtendedTagReqBlocked(relays)
if (relays.length === 0) {
- relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS])
+ relays = relayUrlsStripExtendedTagReqBlocked([...publicReadRelayFallbackUrls()])
}
}
// WebSocket REQ only — drop https URLs (index relays use HTTP polling elsewhere).
@@ -1071,7 +1072,7 @@ export class QueryService {
const originalDedupedRelays = Array.from(new Set(urls))
let relays = originalDedupedRelays
if (relays.length === 0) {
- relays = [...FAST_READ_RELAY_URLS]
+ relays = [...publicReadRelayFallbackUrls()]
}
const filters = Array.isArray(filter) ? filter : [filter]
const stripSocialBlockedRelays =
@@ -1082,16 +1083,16 @@ export class QueryService {
const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url))
relays = relaysAfterSocialKindBlockedStrip(originalDedupedRelays, stripped)
if (relays.length === 0) {
- const fallback = [...FAST_READ_RELAY_URLS].filter(
+ const fallback = [...publicReadRelayFallbackUrls()].filter(
(url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)
)
- relays = fallback.length > 0 ? fallback : [...FAST_READ_RELAY_URLS]
+ relays = fallback.length > 0 ? fallback : [...publicReadRelayFallbackUrls()]
}
}
if (relayFiltersUseCapitalLetterTagKeys(filters)) {
relays = relayUrlsStripExtendedTagReqBlocked(relays)
if (relays.length === 0) {
- relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS])
+ relays = relayUrlsStripExtendedTagReqBlocked([...publicReadRelayFallbackUrls()])
}
}
const { onevent, ...queryOpts } = options ?? {}
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index bc25366c..253a16a6 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -41,9 +41,15 @@ import {
sanitizeRelayUrlsForFetch,
isReadOnlyIndexerRelay,
isReadOnlyRelayAllowedForViewer,
+ isRelayConnectionAllowedForViewer,
+ isMetadataRelaysOnlyPolicyActive,
setViewerPersonalRelayKeys
} from '@/lib/read-only-relay-personal'
-import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
+import {
+ profileFetchRelayUrlsWithoutFastReadLayer,
+ publicReadRelayFallbackUrls,
+ viewerUsesGlobalRelayDefaults
+} from '@/lib/viewer-relay-defaults'
import {
parseBlockedRelayUrlsFromEvent,
setViewerBlockedRelayUrls
@@ -440,6 +446,9 @@ class ClientService extends EventTarget {
if (!navigator.onLine && !isLocalNetworkUrl(url)) {
throw new Error(`[offline] skipping non-local relay ${url}`)
}
+ if (!isRelayConnectionAllowedForViewer(url)) {
+ throw new Error(`[metadata-relays-only] skipping relay ${url}`)
+ }
if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) {
throw new Error(`[http-index-relay] ${url} uses the HTTPS index API, not WebSocket`)
}
@@ -618,7 +627,7 @@ class ClientService extends EventTarget {
const pk = pubkey?.trim() || this.pubkey?.trim()
if (!pk) {
this.viewerHttpIndexRelayBases = []
- setViewerPersonalRelayKeys(new Set())
+ setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
syncViewerRelayStackNostrLandAggrEligible([])
setViewerBlockedRelayUrls([])
return
@@ -655,7 +664,7 @@ class ClientService extends EventTarget {
} catch {
// ignore
}
- setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls))
+ setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls), { viewerActive: true })
syncViewerRelayStackNostrLandAggrEligible(urls)
}
@@ -2060,7 +2069,7 @@ class ClientService extends EventTarget {
if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) {
relays = relayUrlsStripExtendedTagReqBlocked(relays)
if (relays.length === 0 && navigator.onLine) {
- relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS])
+ relays = relayUrlsStripExtendedTagReqBlocked([...publicReadRelayFallbackUrls()])
}
}
const key = this.generateTimelineKey(relays, filter as Filter)
@@ -2490,7 +2499,7 @@ class ClientService extends EventTarget {
if (relayFiltersUseCapitalLetterTagKeys(filters)) {
relays = relayUrlsStripExtendedTagReqBlocked(relays)
if (relays.length === 0) {
- relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS])
+ relays = relayUrlsStripExtendedTagReqBlocked([...publicReadRelayFallbackUrls()])
}
}
relays = Array.from(new Set(relays))
@@ -2861,7 +2870,7 @@ class ClientService extends EventTarget {
if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) {
wsRelayUrls = relayUrlsStripExtendedTagReqBlocked(wsRelayUrls)
if (wsRelayUrls.length === 0 && navigator.onLine && !relayAuthoritativeTimeline) {
- wsRelayUrls = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS])
+ wsRelayUrls = relayUrlsStripExtendedTagReqBlocked([...publicReadRelayFallbackUrls()])
}
}
const timelineUrls = originalDedupedRelays
@@ -3376,7 +3385,7 @@ class ClientService extends EventTarget {
)
let relays = [...wsOriginal]
if (relays.length === 0 && httpRelayBases.length === 0) {
- relays = [...FAST_READ_RELAY_URLS]
+ relays = [...publicReadRelayFallbackUrls()]
}
const filters = Array.isArray(filter) ? filter : [filter]
relays = withDocumentRelayUrlsForFilters(relays, filters)
@@ -3392,7 +3401,7 @@ class ClientService extends EventTarget {
let queryRelays = dedupeNormalizeRelayUrlsOrdered([...relays, ...httpRelayBases])
/** If every candidate was filtered away, still hit public read mirrors so REQ does not no-op. */
if (queryRelays.length === 0) {
- queryRelays = dedupeNormalizeRelayUrlsOrdered([...FAST_READ_RELAY_URLS])
+ queryRelays = dedupeNormalizeRelayUrlsOrdered([...publicReadRelayFallbackUrls()])
}
const events = await this.queryService.query(queryRelays, filter, onevent, {
eoseTimeout,
@@ -4507,8 +4516,8 @@ class ClientService extends EventTarget {
write =
stripped.write.length > 0 ? stripped.write : write.filter(urlIsNonLocalForRemoteViewer)
if (read.length === 0 && write.length === 0) {
- read = [...FAST_READ_RELAY_URLS]
- write = [...FAST_WRITE_RELAY_URLS]
+ read = [...publicReadRelayFallbackUrls()]
+ write = isMetadataRelaysOnlyPolicyActive() ? [] : [...FAST_WRITE_RELAY_URLS]
}
}
return mergeKind10243({
@@ -4944,10 +4953,12 @@ class ClientService extends EventTarget {
// If many websocket connections are initiated simultaneously, it will be
// very slow on Safari (for unknown reason)
if (isSafari()) {
- let urls = FAST_READ_RELAY_URLS
+ let urls = [...publicReadRelayFallbackUrls()]
if (myPubkey) {
const relayList = await this.fetchRelayList(myPubkey)
- urls = relayList.read.concat(FAST_READ_RELAY_URLS).slice(0, 5)
+ urls = isMetadataRelaysOnlyPolicyActive()
+ ? relayList.read.slice(0, 5)
+ : relayList.read.concat([...publicReadRelayFallbackUrls()]).slice(0, 5)
}
return [{ urls, filter: { authors: pubkeys } }]
}
diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts
index 72ac42be..3138166d 100644
--- a/src/services/local-storage.service.ts
+++ b/src/services/local-storage.service.ts
@@ -8,6 +8,7 @@ import {
} from '@/constants'
import { kinds } from 'nostr-tools'
import { isSameAccount } from '@/lib/account'
+import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal'
import { randomString } from '@/lib/random'
import {
TAccount,
@@ -82,7 +83,8 @@ const SETTINGS_KEYS = [
StorageKey.RESPECT_QUIET_TAGS,
StorageKey.GLOBAL_QUIET_MODE,
StorageKey.SHOW_RSS_FEED,
- StorageKey.PANE_MODE
+ StorageKey.PANE_MODE,
+ StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS
] as const
class LocalStorageService {
@@ -131,6 +133,7 @@ class LocalStorageService {
private addRandomRelaysToPublish: boolean = false
private showPublishSuccessToasts: boolean = true
private showLiveActivitiesBanner: boolean = true
+ private restrictRelaysToMetadataLists: boolean = false
constructor() {
if (!LocalStorageService.instance) {
@@ -460,6 +463,12 @@ class LocalStorageService {
const showLiveActivitiesStr = window.localStorage.getItem(StorageKey.SHOW_LIVE_ACTIVITIES_BANNER)
this.showLiveActivitiesBanner = showLiveActivitiesStr !== 'false'
+ const restrictMetadataRelaysStr = window.localStorage.getItem(
+ StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS
+ )
+ this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr === 'true'
+ setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists)
+
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@@ -660,6 +669,11 @@ class LocalStorageService {
if (showRssStr != null) this.showRssFeed = showRssStr === 'true'
const paneStr = get(StorageKey.PANE_MODE)
if (paneStr === 'single' || paneStr === 'double') this.panelMode = paneStr
+ const restrictMetadataRelaysStr = get(StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS)
+ if (restrictMetadataRelaysStr != null) {
+ this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr === 'true'
+ setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists)
+ }
}
getRelaySets() {
@@ -1128,6 +1142,16 @@ class LocalStorageService {
this.persistSetting(StorageKey.PANE_MODE, mode)
}
+ getRestrictRelaysToMetadataLists(): boolean {
+ return this.restrictRelaysToMetadataLists
+ }
+
+ setRestrictRelaysToMetadataLists(restrict: boolean) {
+ this.restrictRelaysToMetadataLists = restrict
+ setRestrictConnectionsToMetadataRelaysOnly(restrict)
+ this.persistSetting(StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS, restrict.toString())
+ }
+
getAccountNetworkHydrateAt(pubkey: string): number | undefined {
try {
const raw = window.localStorage.getItem(StorageKey.ACCOUNT_NETWORK_HYDRATE_AT_MAP)