diff --git a/nip66-cron/index.mjs b/nip66-cron/index.mjs index dcd75a25..e8cadf4e 100644 --- a/nip66-cron/index.mjs +++ b/nip66-cron/index.mjs @@ -328,8 +328,9 @@ async function publishEvent (relayUrls, event) { let ok = 0 const conns = [] for (const url of relayUrls) { + let ws try { - const ws = new WebSocket(url, { handshakeTimeout: 8000 }) + ws = new WebSocket(url, { handshakeTimeout: 8000 }) await new Promise((resolve, reject) => { let timeoutId let resolved = false @@ -374,6 +375,9 @@ async function publishEvent (relayUrls, event) { }) } catch (err) { log('Publish relay error', { url, err: err.message }) + if (ws) { + try { ws.close() } catch (_) {} + } } } for (const ws of conns) { diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 5c5cc034..2bcd7c0f 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -23,8 +23,6 @@ import { urlsForViewerNostrLandAggrEligibilitySync } from '@/lib/nostr-land-relay-eligibility' import { - enterMetadataRelaysOnlyBypass, - leaveMetadataRelaysOnlyBypass, sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { useFavoriteRelays } from '@/providers/favorite-relays-context' @@ -305,7 +303,6 @@ function EmbeddedNoteFetched({ containingEventRef.current = containingEvent useEffect(() => { - enterMetadataRelaysOnlyBypass() let cancelled = false const noteKey = noteId.trim() embedNoteKeyRef.current = noteKey @@ -386,7 +383,6 @@ function EmbeddedNoteFetched({ if (eventRef.current) { return () => { cancelled = true - leaveMetadataRelaysOnlyBypass() if (retryIntervalRef.current) { clearInterval(retryIntervalRef.current) retryIntervalRef.current = null @@ -406,7 +402,6 @@ function EmbeddedNoteFetched({ return () => { cancelled = true - leaveMetadataRelaysOnlyBypass() if (retryIntervalRef.current) { clearInterval(retryIntervalRef.current) retryIntervalRef.current = null diff --git a/src/components/MetadataRelaysOnlySetting/index.tsx b/src/components/MetadataRelaysOnlySetting/index.tsx deleted file mode 100644 index 1d5c4324..00000000 --- a/src/components/MetadataRelaysOnlySetting/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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 { 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 }) - client.closeMetadataPolicyDisallowedRelayConnections() - window.dispatchEvent(new CustomEvent(METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT)) - } - - return ( -
-
- - -
-
- {t( - 'When on (default), the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists, plus hard-coded relays only while a query or subscription needs them. Publishing is unchanged. Relay explore and Search pages are exempt.' - )} -
-
- ) -} diff --git a/src/constants.ts b/src/constants.ts index 696abde6..35e9caf1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -388,7 +388,7 @@ 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. */ + /** @deprecated Removed — personal-relay read policy is always on when logged in. */ RESTRICT_RELAYS_TO_METADATA_LISTS: 'restrictRelaysToMetadataLists', /** When `'true'`, show Sonner toasts after successful publishes (default off). */ SHOW_PUBLISH_SUCCESS_TOASTS: 'showPublishSuccessToasts', @@ -529,11 +529,8 @@ export const MONERO_NOSTR_RELAY_URLS = [ /** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish. * Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */ export const GIF_RELAY_URLS = [ - 'wss://relay.damus.io', - 'wss://relay.primal.net', 'wss://thecitadel.nostr1.com', - 'wss://nos.lol', - 'wss://nostr.mom' + 'wss://gifbuddy.lol' ] export const SEARCHABLE_RELAY_URLS = [ @@ -552,8 +549,8 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550 export const PROFILE_RELAY_URLS = [ 'wss://profiles.nostr1.com', 'wss://profiles.nostrver.se/', - 'wss://indexer.coracle.social/', - 'wss://thecitadel.nostr1.com' + 'wss://thecitadel.nostr1.com', + 'wss://indexer.coracle.social/' ] export const FOLLOWS_HISTORY_RELAY_URLS = [ diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 321ba592..c3745355 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -1,4 +1,3 @@ -export * from './useBypassMetadataRelaysOnlyPolicy' export * from './useRelayPageFeedPolicy' export * from './useNearViewport' export * from './useFetchCalendarRsvps' diff --git a/src/hooks/useBypassMetadataRelaysOnlyPolicy.ts b/src/hooks/useBypassMetadataRelaysOnlyPolicy.ts deleted file mode 100644 index 4595e7b9..00000000 --- a/src/hooks/useBypassMetadataRelaysOnlyPolicy.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/hooks/useRelayPageFeedPolicy.ts b/src/hooks/useRelayPageFeedPolicy.ts index 6b3b850d..42f13c29 100644 --- a/src/hooks/useRelayPageFeedPolicy.ts +++ b/src/hooks/useRelayPageFeedPolicy.ts @@ -1,19 +1,10 @@ -import { - enterMetadataRelaysOnlyBypass, - enterSingleRelayExplicitBrowse, - leaveMetadataRelaysOnlyBypass, - leaveSingleRelayExplicitBrowse -} from '@/lib/read-only-relay-personal' +import { enterSingleRelayExplicitBrowse, 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. */ +/** Relay detail feed: connect to the page relay even if it is not on the viewer's personal lists. */ export function useRelayPageFeedPolicy(): void { useEffect(() => { - enterMetadataRelaysOnlyBypass() enterSingleRelayExplicitBrowse() - return () => { - leaveSingleRelayExplicitBrowse() - leaveMetadataRelaysOnlyBypass() - } + return () => leaveSingleRelayExplicitBrowse() }, []) } diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index 1ba66982..a7e56b7f 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -115,9 +115,6 @@ 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 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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. 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/i18n/locales/de.ts b/src/i18n/locales/de.ts index 345b4b60..b8570c32 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -116,9 +116,6 @@ export default { 'Follows you': 'Folgt dir', 'Relay Settings': 'Relay-Einstellungen', 'Relays and Storage Settings': 'Relays und Speicher', - 'Only my relay lists': 'Nur meine Relay-Listen', - '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.': - 'Wenn aktiv, bleiben Lese-Verbindungen auf deinen Listen plus den eingebauten Profilindex-Relays (profiles.nostr1.com, relay.damus.io, …). Andere Relays für Feeds, Threads oder Suche werden nur bei Listeneintrag genutzt. Veröffentlichen bleibt unverändert. 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 07476c36..4f019a8d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -113,9 +113,6 @@ 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 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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. 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/i18n/locales/es.ts b/src/i18n/locales/es.ts index 216fef19..d373372a 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -115,9 +115,6 @@ export default { 'Follows you': 'Te sigue', 'Relay Settings': 'Configuración de relés', 'Relays and Storage Settings': 'Relays and Storage Settings', - 'Only my relay lists': 'Only my relay lists', - '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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.', 'Relay set name': 'Nombre del conjunto de relés', 'Add a new relay set': 'Agregar un nuevo conjunto de relés', Add: 'Agregar', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 6f6d3860..ff4ed7a9 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -115,9 +115,6 @@ export default { 'Follows you': 'Vous suit', 'Relay Settings': 'Paramètres des relais', 'Relays and Storage Settings': 'Relays and Storage Settings', - 'Only my relay lists': 'Only my relay lists', - '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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.', 'Relay set name': 'Nom du groupe de relais', 'Add a new relay set': 'Ajouter un nouveau groupe de relais', Add: 'Ajouter', diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 32535ac1..6d58d736 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -115,9 +115,6 @@ 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 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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. 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/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 5792ac70..4a6a937b 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -115,9 +115,6 @@ export default { 'Follows you': 'Obserwujący', 'Relay Settings': 'Ustawienia transmiterów', 'Relays and Storage Settings': 'Relays and Storage Settings', - 'Only my relay lists': 'Only my relay lists', - '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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.', 'Relay set name': 'Wpisz nazwę grupy', 'Add a new relay set': 'Utwórz grupę transmiterów', Add: 'Dodaj', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index b46dd49c..17dc98be 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -115,9 +115,6 @@ export default { 'Follows you': 'Подписан на вас', 'Relay Settings': 'Настройки ретрансляторов', 'Relays and Storage Settings': 'Relays and Storage Settings', - 'Only my relay lists': 'Only my relay lists', - '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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.', 'Relay set name': 'Имя набора ретрансляторов', 'Add a new relay set': 'Добавить новый набор ретрансляторов', Add: 'Добавить', diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index c0c49c39..26451859 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -115,9 +115,6 @@ 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 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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. 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/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 3e1adab7..1f1ae6dc 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -115,9 +115,6 @@ export default { 'Follows you': '关注了你', 'Relay Settings': '服务器设置', 'Relays and Storage Settings': 'Relays and Storage Settings', - 'Only my relay lists': 'Only my relay lists', - '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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.', 'Relay set name': '服务器组名', 'Add a new relay set': '添加新的服务器组', Add: '添加', diff --git a/src/lib/account-list-relay-urls.ts b/src/lib/account-list-relay-urls.ts index 80e28ae7..c14fb523 100644 --- a/src/lib/account-list-relay-urls.ts +++ b/src/lib/account-list-relay-urls.ts @@ -4,6 +4,10 @@ import { normalizeRelayUrlByScheme } from '@/lib/url' import { collectViewerReadInboxUrls } from '@/lib/viewer-read-inboxes' import { collectViewerWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' +import { + viewerIncludeGlobalFastReadRelayLayer, + viewerIncludeGlobalFastWriteRelayLayer +} from '@/lib/read-only-relay-personal' import client from '@/services/client.service' /** diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index a470a91a..3ab5e90d 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -20,7 +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 { viewerIncludeGlobalFastReadRelayLayer } 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' diff --git a/src/lib/metadata-policy-curated-relays.test.ts b/src/lib/metadata-policy-curated-relays.test.ts index 8ac46df9..e5022f65 100644 --- a/src/lib/metadata-policy-curated-relays.test.ts +++ b/src/lib/metadata-policy-curated-relays.test.ts @@ -1,6 +1,7 @@ import { DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' import { describe, expect, it } from 'vitest' import { + isMetadataPolicyActiveReadGrantRelay, isMetadataPolicyCuratedRelay, isMetadataPolicyOperationScopedRelay } from './metadata-policy-curated-relays' @@ -17,4 +18,10 @@ describe('metadata-policy-curated-relays', () => { expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false) expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false) }) + + it('active read grant includes search and discovery stacks', () => { + expect(isMetadataPolicyActiveReadGrantRelay('wss://search.nos.today/')).toBe(true) + expect(isMetadataPolicyActiveReadGrantRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false) + expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr.wirednet.jp/')).toBe(false) + }) }) diff --git a/src/lib/metadata-policy-curated-relays.ts b/src/lib/metadata-policy-curated-relays.ts index ed63c07d..384fb6d5 100644 --- a/src/lib/metadata-policy-curated-relays.ts +++ b/src/lib/metadata-policy-curated-relays.ts @@ -43,6 +43,30 @@ function relayKeyForCuratedSet(url: string): string { return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() } +/** Relays grantable for the duration of an active read query/subscribe (not general feed widening). */ +const METADATA_POLICY_ACTIVE_READ_GRANT_RELAY_LISTS: readonly (readonly string[])[] = [ + ...METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS, + SEARCHABLE_RELAY_URLS, + READ_ONLY_RELAY_URLS, + NIP66_DISCOVERY_RELAY_URLS +] + +let activeReadGrantRelayKeySet: ReadonlySet | null = null + +function getActiveReadGrantRelayKeySet(): ReadonlySet { + if (!activeReadGrantRelayKeySet) { + const out = new Set() + for (const list of METADATA_POLICY_ACTIVE_READ_GRANT_RELAY_LISTS) { + for (const u of list) { + const key = relayKeyForCuratedSet(u) + if (key) out.add(key) + } + } + activeReadGrantRelayKeySet = out + } + return activeReadGrantRelayKeySet +} + function getCuratedRelayKeySet(): ReadonlySet { if (!curatedRelayKeySet) { const out = new Set() @@ -83,6 +107,12 @@ export function isMetadataPolicyOperationScopedRelay(url: string): boolean { return key.length > 0 && getOperationScopedRelayKeySet().has(key) } +/** Search / index / discovery stacks allowed only while an active read operation lists them. */ +export function isMetadataPolicyActiveReadGrantRelay(url: string): boolean { + const key = relayKeyForCuratedSet(url) + return key.length > 0 && getActiveReadGrantRelayKeySet().has(key) +} + let profileRelayKeySet: ReadonlySet | null = null function getProfileRelayKeySet(): ReadonlySet { @@ -107,5 +137,6 @@ export function isMetadataPolicyProfileRelay(url: string): boolean { export function resetMetadataPolicyCuratedRelayKeysForTests(): void { curatedRelayKeySet = null operationScopedRelayKeySet = null + activeReadGrantRelayKeySet = null profileRelayKeySet = null } diff --git a/src/lib/read-only-relay-personal.test.ts b/src/lib/read-only-relay-personal.test.ts index 786d0baa..2b11ed2f 100644 --- a/src/lib/read-only-relay-personal.test.ts +++ b/src/lib/read-only-relay-personal.test.ts @@ -9,19 +9,15 @@ import { isRelayConnectionAllowedForViewer, resetRelayConnectionOperationScopeForTests, sanitizeRelayUrlsForFetch, - enterMetadataRelaysOnlyBypass, enterSingleRelayExplicitBrowse, enterSingleRelayExplicitFetchScope, - leaveMetadataRelaysOnlyBypass, leaveSingleRelayExplicitBrowse, - setRestrictConnectionsToMetadataRelaysOnly, setViewerPersonalRelayKeys } from './read-only-relay-personal' import { setViewerBlockedRelayUrls } from './viewer-blocked-relays' describe('read-only-relay-personal', () => { beforeEach(() => { - setRestrictConnectionsToMetadataRelaysOnly(false) setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) setViewerBlockedRelayUrls([]) syncViewerRelayStackNostrLandAggrEligible([]) @@ -29,9 +25,8 @@ describe('read-only-relay-personal', () => { }) afterEach(() => { - setRestrictConnectionsToMetadataRelaysOnly(false) - leaveMetadataRelaysOnlyBypass() leaveSingleRelayExplicitBrowse() + setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) setViewerBlockedRelayUrls([]) syncViewerRelayStackNostrLandAggrEligible([]) resetRelayConnectionOperationScopeForTests() @@ -81,8 +76,7 @@ describe('read-only-relay-personal', () => { expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual(urls) }) - it('metadata-only policy blocks ad-hoc feed relays at connect time', () => { - setRestrictConnectionsToMetadataRelaysOnly(true) + it('personal-relay policy blocks ad-hoc feed relays at connect time', () => { setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://relay.example.com/']), { viewerActive: true }) const urls = [ 'wss://relay.example.com/', @@ -90,16 +84,18 @@ describe('read-only-relay-personal', () => { 'wss://theforest.nostr1.com/', 'wss://nostr.wirednet.jp/' ] - 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(sanitizeRelayUrlsForFetch(urls)).toEqual([ + 'wss://relay.example.com/', + 'wss://profiles.nostr1.com/' + ]) + expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) + expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://relay.example.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) + it('personal-relay policy still allows aggr when viewer lists wss://nostr.land', () => { syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/']) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true }) const urls = ['wss://nostr.land/', AGGR_NOSTR_LAND_WSS, 'wss://nostr.wirednet.jp/'] @@ -112,33 +108,30 @@ describe('read-only-relay-personal', () => { }) 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) + expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false) + expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false) const revoke = grantRelayConnectionOperationScope([ 'wss://thecitadel.nostr1.com/', - 'wss://nostr.wine/', + 'wss://nostr.wirednet.jp/', 'wss://essayist.decentnewsroom.com/' ]) expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true) - expect(isRelayConnectionAllowedForViewer('wss://nostr.wine/')).toBe(false) + expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).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) + it('personal-relay policy allows document relays only during an operation scope', () => { 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) + expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(false) + const revoke = grantRelayConnectionOperationScope(['wss://essayist.decentnewsroom.com/']) + expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(true) revoke() - expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false) + expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(false) }) - it('metadata-only policy allows viewer cache and HTTP index relays', () => { - setRestrictConnectionsToMetadataRelaysOnly(true) + it('personal-relay policy allows viewer cache and HTTP index relays', () => { setViewerPersonalRelayKeys( buildPersonalRelayKeySet([ 'ws://localhost:4869/', @@ -152,18 +145,15 @@ describe('read-only-relay-personal', () => { expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).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() + it('personal-relay policy allows profile index relays without operation scope', () => { + setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) + expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) + expect(sanitizeRelayUrlsForFetch(['wss://profiles.nostr1.com/', 'wss://nostr.wirednet.jp/'])).toEqual([ + 'wss://profiles.nostr1.com/' + ]) }) 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() @@ -173,8 +163,7 @@ describe('read-only-relay-personal', () => { leaveSingleRelayExplicitBrowse() }) - it('operation scope grants an explicit single-relay target under metadata-only', () => { - setRestrictConnectionsToMetadataRelaysOnly(true) + it('operation scope grants an explicit single-relay target under personal-relay policy', () => { setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) const leaveFetchScope = enterSingleRelayExplicitFetchScope() const target = 'wss://relay.layer.systems/' diff --git a/src/lib/read-only-relay-personal.ts b/src/lib/read-only-relay-personal.ts index d248da57..6666cb5e 100644 --- a/src/lib/read-only-relay-personal.ts +++ b/src/lib/read-only-relay-personal.ts @@ -1,7 +1,7 @@ import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants' -import { isMetadataPolicyOperationScopedRelay } from '@/lib/metadata-policy-curated-relays' +import { isMetadataPolicyActiveReadGrantRelay, isMetadataPolicyProfileRelay } from '@/lib/metadata-policy-curated-relays' import { filterAggrNostrLandUnlessViewerEligible, getViewerRelayStackNostrLandAggrEligible, @@ -20,39 +20,13 @@ 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 /** 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. */ +/** In-flight query/subscribe URLs allowed to connect briefly under the personal-relay read policy. */ const operationScopedRelayKeys = new Set() -/** Dispatched when metadata-only relay policy toggles (feeds should rebuild relay URL lists). */ -export const METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT = 'jumble:metadata-relays-only-changed' - -export function setRestrictConnectionsToMetadataRelaysOnly(enabled: boolean): void { - restrictConnectionsToMetadataRelaysOnly = enabled -} - -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 -} - export function enterSingleRelayExplicitBrowse(): void { singleRelayExplicitBrowseDepth++ } @@ -91,13 +65,12 @@ function shouldPreserveExplicitSingleRelay( return isSingleRelayExplicitPolicyActive() } -/** Logged-in viewer with metadata-only mode: only connect reads to the viewer's relay lists. */ +/** + * Logged-in viewer: only connect reads to personal relay lists, aggr when eligible, + * operation-scoped URLs, and explicit single-relay browse. + */ export function isMetadataRelaysOnlyPolicyActive(): boolean { - return ( - restrictConnectionsToMetadataRelaysOnly && - viewerMetadataRelaysPolicyActive && - !isMetadataRelaysOnlyBypassActive() - ) + return viewerMetadataRelaysPolicyActive } export function isRelayUrlInViewerMetadataLists(url: string): boolean { @@ -106,12 +79,13 @@ 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 relays in an active {@link grantRelayConnectionOperationScope}. + * Under personal-relay read policy: viewer NIP-65 / favorites / cache / HTTP lists, aggr.nostr.land when + * wss://nostr.land is listed, {@link PROFILE_RELAY_URLS}, plus relays in an active operation scope. */ 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 @@ -119,21 +93,17 @@ export function isRelayAllowedUnderMetadataOnlyPolicy(url: string): boolean { /** * 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). + * Call with the caller's intended URL list **before** {@link sanitizeRelayUrlsForFetch}. */ export function grantRelayConnectionOperationScope(urls: readonly string[]): () => void { if (!isMetadataRelaysOnlyPolicyActive()) return () => {} + const singleExplicit = urls.length === 1 && isSingleRelayExplicitPolicyActive() 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 - } + if (isMetadataPolicyProfileRelay(raw)) continue + if (!isMetadataPolicyActiveReadGrantRelay(raw) && !singleExplicit) continue const key = relayUrlKey(raw) if (!key || operationScopedRelayKeys.has(key)) continue operationScopedRelayKeys.add(key) @@ -149,7 +119,7 @@ export function resetRelayConnectionOperationScopeForTests(): void { operationScopedRelayKeys.clear() } -/** Block read-side pool connects / HTTP index fetches when metadata-only policy is on. */ +/** Block read-side pool connects / HTTP index fetches when personal-relay policy is on. */ export function isRelayConnectionAllowedForViewer(url: string): boolean { if (isSingleRelayExplicitPolicyActive()) return true if (!isMetadataRelaysOnlyPolicyActive()) return true @@ -207,7 +177,7 @@ function isAllowedForKeys(url: string, personalKeys: ReadonlySet): boole return key.length > 0 && personalKeys.has(key) } -/** Under metadata-only policy: viewer relay lists + aggr.nostr.land when nostr.land is listed. */ +/** Under personal-relay policy: viewer lists + aggr + profile index + in-flight operation scope. */ function filterRelayUrlsToMetadataOnlyPersonalLists( urls: readonly string[], personalKeys: ReadonlySet @@ -216,16 +186,18 @@ function filterRelayUrlsToMetadataOnlyPersonalLists( const key = relayUrlKey(u) if (key.length > 0 && personalKeys.has(key)) return true if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(u)) return true + if (isMetadataPolicyProfileRelay(u)) return true + if (key.length > 0 && operationScopedRelayKeys.has(key)) return true return false }) } -/** When metadata-only is on, omit global FAST_READ / trending widening from REQ stacks. */ +/** Omit global FAST_READ / trending widening from REQ stacks under personal-relay policy. */ 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). */ +/** Omit {@link FAST_WRITE_RELAY_URLS} from read-side merge/fetch stacks (publish unchanged). */ export function viewerIncludeGlobalFastWriteRelayLayer(): boolean { return !isMetadataRelaysOnlyPolicyActive() } diff --git a/src/lib/relay-pool-idle.test.ts b/src/lib/relay-pool-idle.test.ts new file mode 100644 index 00000000..41de8f3d --- /dev/null +++ b/src/lib/relay-pool-idle.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { PROFILE_RELAY_URLS } from '@/constants' +import { setViewerPersonalRelayKeys } from '@/lib/read-only-relay-personal' +import { + closePublishTransientRelaySockets, + initRelayPoolIdle, + resetRelayPoolIdleForTests +} from './relay-pool-idle' + +describe('relay-pool-idle publish cleanup', () => { + const closed: string[] = [] + const pool = { + listConnectionStatus: () => + new Map([ + ['wss://author-outbox.example/', true], + ['wss://relay.example.com/', true], + [PROFILE_RELAY_URLS[0]!, true] + ]), + close: (urls: string[]) => { + closed.push(...urls) + } + } + + beforeEach(() => { + closed.length = 0 + setViewerPersonalRelayKeys(new Set(['wss://relay.example.com/']), { viewerActive: true }) + initRelayPoolIdle(pool as never, () => false) + }) + + afterEach(() => { + resetRelayPoolIdleForTests() + setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) + }) + + it('closes author/random publish relays but keeps personal and profile index', () => { + closePublishTransientRelaySockets([ + 'wss://author-outbox.example/', + 'wss://relay.example.com/', + PROFILE_RELAY_URLS[0]! + ]) + expect(closed).toEqual(['wss://author-outbox.example/']) + }) + + it('skips relays that still have active subscriptions', () => { + resetRelayPoolIdleForTests() + initRelayPoolIdle(pool as never, (key) => key.includes('author-outbox')) + closePublishTransientRelaySockets(['wss://author-outbox.example/']) + expect(closed).toEqual([]) + }) +}) diff --git a/src/lib/relay-pool-idle.ts b/src/lib/relay-pool-idle.ts index 62a9889c..2d1f5055 100644 --- a/src/lib/relay-pool-idle.ts +++ b/src/lib/relay-pool-idle.ts @@ -1,4 +1,6 @@ import { RELAY_POOL_IDLE_SWEEP_INTERVAL_MS, RELAY_POOL_SOCKET_IDLE_MS } from '@/constants' +import { isMetadataPolicyProfileRelay } from '@/lib/metadata-policy-curated-relays' +import { isRelayUrlInViewerMetadataLists } from '@/lib/read-only-relay-personal' import logger from '@/lib/logger' import { canonicalRelaySessionKey, normalizeAnyRelayUrl } from '@/lib/url' import type { SimplePool } from 'nostr-tools' @@ -14,6 +16,10 @@ function canon(url: string): string { return canonicalRelaySessionKey(normalizeAnyRelayUrl(url) || url.trim()) } +function shouldKeepProfileRelaySocketOpen(url: string): boolean { + return isMetadataPolicyProfileRelay(url) +} + /** Mark relay URL as recently used (connect, REQ, publish). */ export function touchRelayPoolActivity(url: string): void { const key = canon(url) @@ -49,6 +55,7 @@ export function sweepIdleRelayPoolSockets(): void { if (!connected) continue const key = canon(url) if (!key) continue + if (shouldKeepProfileRelaySocketOpen(url)) continue if (hasActiveSubs(key)) continue const last = lastActivityMs.get(key) ?? 0 if (now - last < RELAY_POOL_SOCKET_IDLE_MS) continue @@ -81,6 +88,7 @@ export function closeRelayPoolSocketsIfIdle(urls: readonly string[]): void { for (const raw of urls) { const key = canon(raw) if (!key || hasActiveSubs(key)) continue + if (shouldKeepProfileRelaySocketOpen(raw)) continue const normalized = normalizeAnyRelayUrl(raw) || raw const connected = [...status.entries()].some( ([u, ok]) => ok && canon(u) === key @@ -96,6 +104,43 @@ export function closeRelayPoolSocketsIfIdle(urls: readonly string[]): void { } } +/** + * After publish: drop sockets opened for author outboxes, random NIP-66 picks, and other non-personal + * targets. Keeps profile index relays and the viewer's own list relays connected. + */ +export function closePublishTransientRelaySockets(urls: readonly string[]): void { + if (!pool || !hasActiveSubs || urls.length === 0) return + let status: Map + try { + status = pool.listConnectionStatus() + } catch { + return + } + + const toClose: string[] = [] + for (const raw of urls) { + if (isRelayUrlInViewerMetadataLists(raw)) continue + if (isMetadataPolicyProfileRelay(raw)) continue + const key = canon(raw) + if (!key || hasActiveSubs(key)) continue + const normalized = normalizeAnyRelayUrl(raw) || raw + const connected = [...status.entries()].some( + ([u, ok]) => ok && canon(u) === key + ) + if (connected) toClose.push(normalized) + } + if (toClose.length === 0) return + try { + pool.close(toClose) + logger.debug('[RelayPoolIdle] closed publish transient sockets', { relays: toClose }) + } catch { + /* ignore */ + } + for (const url of toClose) { + lastActivityMs.delete(canon(url)) + } +} + export function resetRelayPoolIdleForTests(): void { if (sweepTimer != null) { clearInterval(sweepTimer) diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx index 1c6dd2d0..3d193a59 100644 --- a/src/pages/primary/ExplorePage/index.tsx +++ b/src/pages/primary/ExplorePage/index.tsx @@ -3,7 +3,6 @@ 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' @@ -62,7 +61,6 @@ 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 1daaf3d3..ae2d10a2 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -8,12 +8,10 @@ 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 0d6bb4d4..b458fd58 100644 --- a/src/pages/secondary/RelayReviewsPage/index.tsx +++ b/src/pages/secondary/RelayReviewsPage/index.tsx @@ -10,13 +10,11 @@ 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 94001f0d..5d91c809 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -1,5 +1,4 @@ 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' @@ -121,9 +120,6 @@ 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 ac625ff1..900787d1 100644 --- a/src/pages/secondary/SearchPage/index.tsx +++ b/src/pages/secondary/SearchPage/index.tsx @@ -11,12 +11,10 @@ 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/providers/FeedProvider.test.ts b/src/providers/FeedProvider.test.ts index 830c5021..d22d6b47 100644 --- a/src/providers/FeedProvider.test.ts +++ b/src/providers/FeedProvider.test.ts @@ -4,7 +4,6 @@ import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import { buildWispTrendingNotesRelayUrl, isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { - setRestrictConnectionsToMetadataRelaysOnly, setViewerPersonalRelayKeys } from '@/lib/read-only-relay-personal' @@ -43,14 +42,12 @@ 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) + it('personal-relay policy omits wisp trending from home feed relay list', () => { 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 }) }) diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 62d7727a..26dc41f2 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -6,12 +6,11 @@ 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' import { normalizeAnyRelayUrl } from '@/lib/url' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' +import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes' +import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import type { Dispatch, ReactNode, SetStateAction } from 'react' @@ -265,12 +264,6 @@ 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 ( void, options?: QueryOptions ): Promise { - urls = sanitizeRelayUrlsForFetch(urls) + const originalUrls = [...urls] + const revokeOperationScope = grantRelayConnectionOperationScope(originalUrls) + urls = sanitizeRelayUrlsForFetch(originalUrls) const sanitizedFilters = sanitizeFiltersBeforeReq(filter) - if (sanitizedFilters.length === 0) return [] - if (options?.signal?.aborted) return [] + if (sanitizedFilters.length === 0) { + revokeOperationScope() + return [] + } + if (options?.signal?.aborted) { + revokeOperationScope() + return [] + } const maxFilters = RELAY_REQ_MAX_FILTERS_PER_MESSAGE if (sanitizedFilters.length > maxFilters) { @@ -535,7 +543,6 @@ export class QueryService { } const resultPromise = new Promise((resolve) => { - const revokeOperationScope = grantRelayConnectionOperationScope(urls) const events: NEvent[] = [] const cancelAbortRegistrations: Array<() => void> = [] const abortHttp = new AbortController() @@ -845,6 +852,7 @@ export class QueryService { return { close: () => {} } } const originalDedupedRelays = Array.from(new Set(urls)) + const revokeOperationScope = grantRelayConnectionOperationScope(originalDedupedRelays) let relays = sanitizeRelayUrlsForFetch(originalDedupedRelays) const stripSocialBlockedRelays = @@ -870,12 +878,11 @@ export class QueryService { } if (relays.length === 0) { + revokeOperationScope() queueMicrotask(() => callbacks.oneose?.(true)) return { close: () => {} } } - const revokeOperationScope = grantRelayConnectionOperationScope(relays) - const _knownIds = new Set() const grouped = new Map() for (const url of relays) { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index e0e9161e..21649b4e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -47,6 +47,7 @@ import { collectViewerWriteOutboxUrls, collectWriteOutboxUrlsFromRelayList } from '@/lib/viewer-write-outboxes' +import { isMetadataPolicyProfileRelay } from '@/lib/metadata-policy-curated-relays' import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch, @@ -54,7 +55,7 @@ import { isReadOnlyRelayAllowedForViewer, isRelayConnectionAllowedForViewer, isMetadataRelaysOnlyPolicyActive, - isRestrictConnectionsToMetadataRelaysOnly, + isRelayUrlInViewerMetadataLists, grantRelayConnectionOperationScope, enterSingleRelayExplicitFetchScope, isSingleRelayExplicitBrowseActive, @@ -200,7 +201,7 @@ import { urlMatchesConfiguredHttpIndexRelay } from '@/lib/url' import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor' -import { initRelayPoolIdle, touchRelayPoolActivity, closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle' +import { initRelayPoolIdle, touchRelayPoolActivity, closePublishTransientRelaySockets, closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle' import { relaySessionStrikes } from '@/lib/relay-strikes' import { isSafari } from '@/lib/utils' import { @@ -433,6 +434,8 @@ class ClientService extends EventTarget { /** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */ private sessionRelayPublishStats = new Map() + /** Author outbox / random publish targets to close after publish (not personal or profile index). */ + private publishTransientRelayUrls = new Set() /** * IndexedDB profile index + NIP-66 relay discovery run once per page session. When logged in, @@ -716,9 +719,7 @@ class ClientService extends EventTarget { /** IndexedDB-first: personal lists (incl. cache + HTTP) before policy or network so locals stay allowed. */ const storageUrls = await this.collectViewerPersonalRelayUrlsFromStorage(pk) this.viewerHttpIndexRelayBases = storageUrls.httpIndexBases - setViewerPersonalRelayKeys(buildPersonalRelayKeySet(storageUrls.all), { - viewerActive: isRestrictConnectionsToMetadataRelaysOnly() - }) + setViewerPersonalRelayKeys(buildPersonalRelayKeySet(storageUrls.all), { viewerActive: true }) syncViewerRelayStackNostrLandAggrEligible(storageUrls.all) relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(storageUrls.cacheRelayEvent) this.closeMetadataPolicyDisallowedRelayConnections() @@ -1201,6 +1202,11 @@ class ClientService extends EventTarget { event: NEvent, { specifiedRelayUrls, additionalRelayUrls, favoriteRelayUrls, blockedRelayUrls }: TPublishOptions = {} ) { + this.publishTransientRelayUrls.clear() + const finish = (relays: string[]): string[] => { + this.stagePublishTransientRelays(relays) + return relays + } const writeRelayPubOpts = { blockedRelays: blockedRelayUrls, applySocialKindBlockedFilter: isSocialKindBlockedKind(event.kind) @@ -1244,9 +1250,23 @@ class ClientService extends EventTarget { if (userWriteRelays.length === 0 && seenRelays.length === 0) { if (!useGlobalRelayDefaults) { - return this.filterPublishingRelays( + return finish( + this.filterPublishingRelays( + buildPrioritizedWriteRelayUrls({ + userWriteRelays: [], + favoriteRelays: favoriteRelayUrls ?? [], + maxRelays: MAX_PUBLISH_RELAYS, + includeGlobalFastWriteReadTails: false, + ...writeRelayPubOpts + }), + event + ) + ) + } + return finish( + this.filterPublishingRelays( buildPrioritizedWriteRelayUrls({ - userWriteRelays: [], + userWriteRelays: [...FAST_WRITE_RELAY_URLS], favoriteRelays: favoriteRelayUrls ?? [], maxRelays: MAX_PUBLISH_RELAYS, includeGlobalFastWriteReadTails: false, @@ -1254,29 +1274,21 @@ class ClientService extends EventTarget { }), event ) - } - return this.filterPublishingRelays( + ) + } + return finish( + this.filterPublishingRelays( buildPrioritizedWriteRelayUrls({ - userWriteRelays: [...FAST_WRITE_RELAY_URLS], + userWriteRelays: userWriteRelays, + authorReadRelays: [], favoriteRelays: favoriteRelayUrls ?? [], + extraRelays: seenRelays, maxRelays: MAX_PUBLISH_RELAYS, - includeGlobalFastWriteReadTails: false, + includeGlobalFastWriteReadTails: useGlobalRelayDefaults, ...writeRelayPubOpts }), event ) - } - return this.filterPublishingRelays( - buildPrioritizedWriteRelayUrls({ - userWriteRelays: userWriteRelays, - authorReadRelays: [], - favoriteRelays: favoriteRelayUrls ?? [], - extraRelays: seenRelays, - maxRelays: MAX_PUBLISH_RELAYS, - includeGlobalFastWriteReadTails: useGlobalRelayDefaults, - ...writeRelayPubOpts - }), - event ) } @@ -1313,7 +1325,7 @@ class ClientService extends EventTarget { authorWriteCount: authorWrite.length, recipientReadCount: recipientRead.length }) - return pubRelays + return finish(pubRelays) } // Payment attestations (9741): attester outbox + attester read inboxes (profile wall REQ) + @@ -1355,7 +1367,7 @@ class ClientService extends EventTarget { senderInboxCount: senderInboxes.length, seenRelayCount: seenRelays.length }) - return attestationRelays + return finish(attestationRelays) } let relays: string[] @@ -1383,22 +1395,24 @@ class ClientService extends EventTarget { const n = normalizeRelayUrlByScheme(url) || url return !readOnlySet.has(n) }) - return this.filterPublishingRelays( - buildPrioritizedWriteRelayUrls({ - userWriteRelays: - spellWriteFiltered.length > 0 - ? spellWriteFiltered - : useGlobalRelayDefaults - ? dedupeNormalizeRelayUrlsOrdered(FAST_WRITE_RELAY_URLS) - : [], - favoriteRelays: favoriteRelayUrls ?? [], - extraRelays: [], - maxRelays: MAX_PUBLISH_RELAYS, - includeGlobalFastWriteReadTails: - spellWriteFiltered.length > 0 ? useGlobalRelayDefaults : false, - ...writeRelayPubOpts - }), - event + return finish( + this.filterPublishingRelays( + buildPrioritizedWriteRelayUrls({ + userWriteRelays: + spellWriteFiltered.length > 0 + ? spellWriteFiltered + : useGlobalRelayDefaults + ? dedupeNormalizeRelayUrlsOrdered(FAST_WRITE_RELAY_URLS) + : [], + favoriteRelays: favoriteRelayUrls ?? [], + extraRelays: [], + maxRelays: MAX_PUBLISH_RELAYS, + includeGlobalFastWriteReadTails: + spellWriteFiltered.length > 0 ? useGlobalRelayDefaults : false, + ...writeRelayPubOpts + }), + event + ) ) } @@ -1539,7 +1553,7 @@ class ClientService extends EventTarget { } else { relays = dedupeNormalizeRelayUrlsOrdered(relays).slice(0, MAX_PUBLISH_RELAYS) } - return relays + return finish(relays) } /** NOTICE handler: session strikes + rate-limit cooldown + debug log for fetch failures. */ @@ -1621,6 +1635,34 @@ class ClientService extends EventTarget { relaySessionStrikes.clearKey(urlOrSessionKey) } + /** Stage non-personal publish targets (e.g. from {@link determineTargetRelays}) for post-publish socket cleanup. */ + stagePublishTransientRelays(urls: readonly string[]): void { + for (const raw of urls) { + if (!raw?.trim()) continue + if (isRelayUrlInViewerMetadataLists(raw)) continue + if (isMetadataPolicyProfileRelay(raw)) continue + this.publishTransientRelayUrls.add(normalizeUrl(raw) || raw.trim()) + } + } + + /** Close author outbox / random publish sockets; keeps profile index and viewer list relays up. */ + closePublishTransientRelays(extraUrls?: readonly string[]): void { + const seen = new Set() + const urls: string[] = [] + const add = (list: readonly string[]) => { + for (const raw of list) { + const n = normalizeUrl(raw) || raw.trim() + if (!n || seen.has(n)) continue + seen.add(n) + urls.push(n) + } + } + add([...this.publishTransientRelayUrls]) + if (extraUrls?.length) add(extraUrls) + this.publishTransientRelayUrls.clear() + closePublishTransientRelaySockets(urls) + } + /** * From a list of candidate relay URLs (e.g. public lively), return up to `count` relays, * preferring those that have succeeded and been fast this session. Excludes read-only relays. @@ -1788,7 +1830,10 @@ class ClientService extends EventTarget { publishOpBatch.record(idx, url, rs?.success === true, rs?.error) }) publishOpBatch.logEnd(status) - queueMicrotask(() => closeRelayPoolSocketsIfIdle(publishTargetUrls)) + queueMicrotask(() => { + closePublishTransientRelaySockets(publishTargetUrls) + client.closePublishTransientRelays() + }) } /** @@ -2635,6 +2680,7 @@ class ClientService extends EventTarget { originalDedupedRelays.length === 1 && (singleRelayExplicit === true || isSingleRelayExplicitBrowseActive()) const revokeFetchScope = preserveExplicitSingleRelay ? enterSingleRelayExplicitFetchScope() : () => {} + const revokeOperationScope = grantRelayConnectionOperationScope(originalDedupedRelays) const httpKeys = new Set( httpIndexBasesForRelayQuery(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) => canonicalRelaySessionKey(u) @@ -2661,6 +2707,8 @@ class ClientService extends EventTarget { oneose?.(true) relayReqLog?.onBatchEnd?.([]) }) + revokeOperationScope() + revokeFetchScope() return { close: () => {} } @@ -2740,13 +2788,13 @@ class ClientService extends EventTarget { oneose?.(true) relayReqLog?.onBatchEnd?.([]) }) + revokeOperationScope() + revokeFetchScope() return { close: () => {} } } - const revokeOperationScope = grantRelayConnectionOperationScope(relays) - const reqGroupId = relayReqLog?.groupId ?? `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index e3b316ac..bfd2b29c 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -11,7 +11,6 @@ import { isSameAccount } from '@/lib/account' import { DEFAULT_ZAP_SATS } from '@/lib/lightning' import { isPaytoCategory } from '@/lib/payto-category-display' import type { PaytoCategory } from '@/lib/payto-registry' -import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal' import { randomString } from '@/lib/random' import { TAccount, @@ -80,8 +79,7 @@ const SETTINGS_KEYS = [ StorageKey.DEFAULT_EXPIRATION_ENABLED, StorageKey.DEFAULT_EXPIRATION_MONTHS, StorageKey.SHOW_RSS_FEED, - StorageKey.PANE_MODE, - StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS + StorageKey.PANE_MODE ] as const class LocalStorageService { @@ -124,7 +122,6 @@ class LocalStorageService { private showPublishSuccessToasts: boolean = false private showDetailedPublishToasts: boolean = true private showLiveActivitiesBanner: boolean = true - private restrictRelaysToMetadataLists: boolean = true constructor() { if (!LocalStorageService.instance) { @@ -419,12 +416,6 @@ 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 !== 'false' - setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists) - // Clean up deprecated data window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) @@ -433,6 +424,7 @@ class LocalStorageService { window.localStorage.removeItem(StorageKey.ACCOUNT_MUTE_DECRYPTED_TAGS_MAP) window.localStorage.removeItem(StorageKey.ACTIVE_RELAY_SET_ID) window.localStorage.removeItem(StorageKey.FEED_TYPE) + window.localStorage.removeItem(StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS) } /** Persist a setting. Keys in SETTINGS_KEYS go only to IndexedDB; others use localStorage. */ @@ -615,11 +607,6 @@ 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 !== 'false' - setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists) - } } getRelaySets() { @@ -1031,16 +1018,6 @@ 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)