diff --git a/package.json b/package.json index 0b664636..bf1988de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.14.0", + "version": "23.15.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx b/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx index cf5684c6..f340a488 100644 --- a/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx +++ b/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx @@ -30,8 +30,7 @@ function rowTitle(url: string, connected: boolean, t: (k: string) => string) { } /** - * Same interaction pattern as {@link SeenOnButton}: Server + counts, menu lists relays with {@link RelayIcon}. - * Shows favorites + default/inbox relays; disconnected relays are muted. + * Server icon + menu listing relays with an open WebSocket in the pool. */ export function ActiveRelaysTitlebarButton() { const { t } = useTranslation() diff --git a/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx index 58112760..0ee5e1e9 100644 --- a/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx +++ b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx @@ -32,8 +32,7 @@ function rowTitle(url: string, connected: boolean, t: (k: string) => string) { } /** - * Desktop sidebar: relay avatars for favorites, inbox, cache, HTTP index, and defaults; - * muted when the WebSocket is down (HTTP index relays count as active when configured). + * Desktop sidebar: relay avatars for relays with an open WebSocket in the pool. */ export function ConnectedRelaysSidebarStrip({ className }: { className?: string }) { const { t } = useTranslation() diff --git a/src/components/MetadataRelaysOnlySetting/index.tsx b/src/components/MetadataRelaysOnlySetting/index.tsx index ddc39027..24d33d05 100644 --- a/src/components/MetadataRelaysOnlySetting/index.tsx +++ b/src/components/MetadataRelaysOnlySetting/index.tsx @@ -31,7 +31,7 @@ export default function MetadataRelaysOnlySetting() {
{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.' + 'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists (plus profile and search index relays). Publishing is unchanged. Relay explore and Search pages are exempt.' )}
diff --git a/src/hooks/useRelayConnectionRows.ts b/src/hooks/useRelayConnectionRows.ts index 1e61482a..82034d5f 100644 --- a/src/hooks/useRelayConnectionRows.ts +++ b/src/hooks/useRelayConnectionRows.ts @@ -1,16 +1,4 @@ -import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' -import { - getHttpRelayListFromEvent, - getRelayListReadFromEventNoFastFallback -} from '@/lib/event-metadata' -import { - canonicalRelaySessionKey, - normalizeAnyRelayUrl, - normalizeHttpRelayUrl, - urlMatchesConfiguredHttpIndexRelay -} from '@/lib/url' -import { useNostr } from '@/providers/NostrProvider' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { canonicalRelaySessionKey, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' import client from '@/services/client.service' import { useEffect, useMemo, useState } from 'react' @@ -26,107 +14,38 @@ function rowCanon(url: string): string { return (canonicalRelaySessionKey(url) || normalizeRelayRowUrl(url)).trim().toLowerCase() } -function mergeUniquePreserveOrder(...lists: (readonly string[] | undefined)[]): string[] { - const seen = new Set() - const out: string[] = [] - for (const list of lists) { - if (!list?.length) continue - for (const raw of list) { - const n = normalizeRelayRowUrl(raw) - const k = rowCanon(n) - if (!k || seen.has(k)) continue - seen.add(k) - out.push(n) - } - } - return out -} - export type TRelayConnectionRow = { url: string - /** WebSocket open in the pool, or HTTP index relay in use for the viewer. */ + /** WebSocket open in the pool. */ connected: boolean } /** - * Relays for “active relays” UI: favorites + NIP-65 read/write + kind 10432 cache + kind 10243 HTTP index - * + defaults + fast-read, then any pool-connected URL not already listed. + * Relays for “active relays” UI: only relays with an open WebSocket in the pool right now. */ export function useRelayConnectionRows(): { rows: TRelayConnectionRow[] - /** Relays counted as active (open WebSocket or configured HTTP index). */ connectedCount: number } { - const { relayList, cacheRelayListEvent, httpRelayListEvent } = useNostr() - const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const [connectedCanon, setConnectedCanon] = useState>(() => - new Set(client.getConnectedRelayUrls().map(rowCanon)) - ) - const [httpIndexBases, setHttpIndexBases] = useState(() => - client.getViewerHttpIndexRelayBases() - ) + const [connectedUrls, setConnectedUrls] = useState(() => client.getConnectedRelayUrls()) useEffect(() => { - const tick = () => { - setConnectedCanon(new Set(client.getConnectedRelayUrls().map(rowCanon))) - setHttpIndexBases(client.getViewerHttpIndexRelayBases()) - } + const tick = () => setConnectedUrls(client.getConnectedRelayUrls()) tick() const id = window.setInterval(tick, POLL_MS) return () => clearInterval(id) }, []) - const cacheRelayUrls = useMemo(() => { - if (!cacheRelayListEvent) return [] - return getRelayListReadFromEventNoFastFallback(cacheRelayListEvent, blockedRelays) - }, [cacheRelayListEvent, blockedRelays]) - - const httpIndexRelayUrls = useMemo(() => { - const out: string[] = [...(relayList?.httpRead ?? []), ...(relayList?.httpWrite ?? [])] - if (httpRelayListEvent) { - const http = getHttpRelayListFromEvent(httpRelayListEvent, blockedRelays) - out.push(...http.httpRead, ...http.httpWrite) - } - return out - }, [relayList?.httpRead, relayList?.httpWrite, httpRelayListEvent, blockedRelays]) - return useMemo(() => { - const inbox = [...(relayList?.read ?? []), ...(relayList?.write ?? [])] - const base = mergeUniquePreserveOrder( - favoriteRelays, - inbox, - cacheRelayUrls, - httpIndexRelayUrls, - DEFAULT_FAVORITE_RELAYS, - FAST_READ_RELAY_URLS - ) - const baseCanon = new Set(base.map(rowCanon)) - - const isConnected = (url: string) => - urlMatchesConfiguredHttpIndexRelay(url, httpIndexBases) || connectedCanon.has(rowCanon(url)) - - const rowFor = (url: string): TRelayConnectionRow => ({ - url, - connected: isConnected(url) - }) - - const rows: TRelayConnectionRow[] = base.map((url) => rowFor(url)) - - for (const url of client.getConnectedRelayUrls()) { + const seen = new Set() + const rows: TRelayConnectionRow[] = [] + for (const raw of connectedUrls) { + const url = normalizeRelayRowUrl(raw) const k = rowCanon(url) - if (baseCanon.has(k)) continue - rows.push(rowFor(url)) + if (!k || seen.has(k)) continue + seen.add(k) + rows.push({ url, connected: true }) } - - const connectedCount = rows.filter((r) => r.connected).length - return { rows, connectedCount } - }, [ - favoriteRelays, - relayList?.read, - relayList?.write, - cacheRelayUrls, - httpIndexRelayUrls, - connectedCanon, - httpIndexBases - ]) + return { rows, connectedCount: rows.length } + }, [connectedUrls]) } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index b85fca25..b30fe973 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -108,8 +108,8 @@ export default { "Relay Settings": "Relay-Einstellungen", "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.", + "When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists (plus profile and search index relays). Publishing is unchanged. Relay explore and Search pages are exempt.": + "Wenn aktiv, werden nur noch Lese-Verbindungen zu Relays auf deinen Listen (plus Profil- und Suchindex-Relays) geöffnet. 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 80ce9d07..3211b33d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -113,8 +113,8 @@ export default { "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.", + "When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists (plus profile and search index relays). Publishing is unchanged. Relay explore and Search pages are exempt.": + "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. 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/lib/read-only-relay-personal.test.ts b/src/lib/read-only-relay-personal.test.ts index 9517dd71..75f5b809 100644 --- a/src/lib/read-only-relay-personal.test.ts +++ b/src/lib/read-only-relay-personal.test.ts @@ -73,7 +73,7 @@ describe('read-only-relay-personal', () => { expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual(urls) }) - it('metadata-only policy blocks ad-hoc and FAST_READ bootstrap relays, keeps profile relays', () => { + it('metadata-only policy blocks ad-hoc reads at network level, not in sanitizeRelayUrlsForFetch', () => { setRestrictConnectionsToMetadataRelaysOnly(true) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://relay.example.com/']), { viewerActive: true }) const urls = [ @@ -82,10 +82,7 @@ describe('read-only-relay-personal', () => { 'wss://theforest.nostr1.com/', 'wss://nostr.wirednet.jp/' ] - expect(sanitizeRelayUrlsForFetch(urls)).toEqual([ - 'wss://relay.example.com/', - 'wss://profiles.nostr1.com/' - ]) + expect(sanitizeRelayUrlsForFetch(urls)).toEqual(urls) expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false) @@ -99,6 +96,7 @@ describe('read-only-relay-personal', () => { expect(sanitizeRelayUrlsForFetch(urls).map((u) => u.replace(/\/$/, ''))).toEqual([ 'wss://nostr.land', 'wss://aggr.nostr.land', + 'wss://nostr.wirednet.jp' ]) expect(isRelayConnectionAllowedForViewer(AGGR_NOSTR_LAND_WSS)).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false) diff --git a/src/lib/read-only-relay-personal.ts b/src/lib/read-only-relay-personal.ts index d9f2d92d..f7bfb517 100644 --- a/src/lib/read-only-relay-personal.ts +++ b/src/lib/read-only-relay-personal.ts @@ -95,17 +95,12 @@ export function isRelayAllowedUnderMetadataOnlyPolicy(url: string): boolean { return false } -/** Block WebSocket (and other) pool connects when metadata-only policy is on. */ +/** Block read-side pool connects / HTTP index fetches 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() } @@ -183,11 +178,9 @@ export function sanitizeRelayUrlsForFetch( const key = relayUrlKey(u) return key.length > 0 && keys.has(key) }) - return filterToViewerMetadataRelaysOnly( - filterViewerBlockedRelaysForFetch( - filterAggrNostrLandUnlessViewerEligible( - filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys) - ) + return filterViewerBlockedRelaysForFetch( + filterAggrNostrLandUnlessViewerEligible( + filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys) ) ) } diff --git a/src/lib/viewer-relay-defaults.ts b/src/lib/viewer-relay-defaults.ts index 940b810b..4f4e0a25 100644 --- a/src/lib/viewer-relay-defaults.ts +++ b/src/lib/viewer-relay-defaults.ts @@ -3,7 +3,6 @@ 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 = { @@ -17,9 +16,9 @@ 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. */ +/** Public read mirrors used when relay lists are empty. */ export function publicReadRelayFallbackUrls(): readonly string[] { - return isMetadataRelaysOnlyPolicyActive() ? [] : FAST_READ_RELAY_URLS + return FAST_READ_RELAY_URLS } export function viewerUsesGlobalRelayDefaults(args: { @@ -27,7 +26,6 @@ export function viewerUsesGlobalRelayDefaults(args: { 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/services/client-query.service.ts b/src/services/client-query.service.ts index 32673cf0..f247a5c6 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -40,7 +40,7 @@ import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch- import type { Filter, Event as NEvent } from 'nostr-tools' import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools' import type { AbstractRelay } from 'nostr-tools/abstract-relay' -import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' +import { sanitizeRelayUrlsForFetch, isRelayConnectionAllowedForViewer } from '@/lib/read-only-relay-personal' import { publicReadRelayFallbackUrls } from '@/lib/viewer-relay-defaults' import nip66Service from './nip66.service' import type { ISigner, TSignerType } from '@/types' @@ -479,9 +479,9 @@ export class QueryService { ? FIRST_RELAY_RESULT_GRACE_MS : null - const httpRelayBases = httpIndexBasesForRelayQuery(urls, options?.httpIndexRelayBases ?? []).filter( - (u) => !relaySessionStrikes.isReadHttpSkipped(u) - ) + const httpRelayBases = httpIndexBasesForRelayQuery(urls, options?.httpIndexRelayBases ?? []) + .filter((u) => !relaySessionStrikes.isReadHttpSkipped(u)) + .filter((u) => isRelayConnectionAllowedForViewer(u)) const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u))) const wsQueryUrls = urls.filter((u) => !httpKeys.has(canonicalRelaySessionKey(u))) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 955cbcaf..10a7160e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -42,7 +42,6 @@ import { isReadOnlyIndexerRelay, isReadOnlyRelayAllowedForViewer, isRelayConnectionAllowedForViewer, - isMetadataRelaysOnlyPolicyActive, setViewerPersonalRelayKeys } from '@/lib/read-only-relay-personal' import { @@ -438,7 +437,7 @@ class ClientService extends EventTarget { const rawEnsureRelay = this.pool.ensureRelay.bind(this.pool) this.pool.ensureRelay = async ( url: string, - params?: { connectionTimeout?: number; abort?: AbortSignal } + params?: { connectionTimeout?: number; abort?: AbortSignal; purpose?: 'read' | 'write' } ) => { // While offline, skip any relay that isn't on the local network. // This prevents a flood of failed WebSocket/HTTP connection attempts across @@ -446,7 +445,7 @@ class ClientService extends EventTarget { if (!navigator.onLine && !isLocalNetworkUrl(url)) { throw new Error(`[offline] skipping non-local relay ${url}`) } - if (!isRelayConnectionAllowedForViewer(url)) { + if (params?.purpose !== 'write' && !isRelayConnectionAllowedForViewer(url)) { throw new Error(`[metadata-relays-only] skipping relay ${url}`) } if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) { @@ -1772,7 +1771,7 @@ class ClientService extends EventTarget { wsAttempt }) - const ensureOpts = { connectionTimeout } + const ensureOpts = { connectionTimeout, purpose: 'write' as const } const connectionPromise = isLocal ? Promise.race([ this.pool.ensureRelay(url, ensureOpts), @@ -4522,7 +4521,7 @@ class ClientService extends EventTarget { stripped.write.length > 0 ? stripped.write : write.filter(urlIsNonLocalForRemoteViewer) if (read.length === 0 && write.length === 0) { read = [...publicReadRelayFallbackUrls()] - write = isMetadataRelaysOnlyPolicyActive() ? [] : [...FAST_WRITE_RELAY_URLS] + write = [...FAST_WRITE_RELAY_URLS] } } return mergeKind10243({ @@ -4961,9 +4960,7 @@ class ClientService extends EventTarget { let urls = [...publicReadRelayFallbackUrls()] if (myPubkey) { const relayList = await this.fetchRelayList(myPubkey) - urls = isMetadataRelaysOnlyPolicyActive() - ? relayList.read.slice(0, 5) - : relayList.read.concat([...publicReadRelayFallbackUrls()]).slice(0, 5) + urls = relayList.read.concat([...publicReadRelayFallbackUrls()]).slice(0, 5) } return [{ urls, filter: { authors: pubkeys } }] }