Browse Source

fix relay connections

imwald
Silberengel 3 weeks ago
parent
commit
cb275a28d9
  1. 2
      package.json
  2. 3
      src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx
  3. 3
      src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx
  4. 2
      src/components/MetadataRelaysOnlySetting/index.tsx
  5. 109
      src/hooks/useRelayConnectionRows.ts
  6. 4
      src/i18n/locales/de.ts
  7. 4
      src/i18n/locales/en.ts
  8. 8
      src/lib/read-only-relay-personal.test.ts
  9. 11
      src/lib/read-only-relay-personal.ts
  10. 6
      src/lib/viewer-relay-defaults.ts
  11. 8
      src/services/client-query.service.ts
  12. 13
      src/services/client.service.ts

2
package.json

@ -1,6 +1,6 @@ @@ -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",

3
src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx

@ -30,8 +30,7 @@ function rowTitle(url: string, connected: boolean, t: (k: string) => string) { @@ -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()

3
src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx

@ -32,8 +32,7 @@ function rowTitle(url: string, connected: boolean, t: (k: string) => string) { @@ -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()

2
src/components/MetadataRelaysOnlySetting/index.tsx

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

109
src/hooks/useRelayConnectionRows.ts

@ -1,16 +1,4 @@ @@ -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 { @@ -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<string>()
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<Set<string>>(() =>
new Set(client.getConnectedRelayUrls().map(rowCanon))
)
const [httpIndexBases, setHttpIndexBases] = useState<readonly string[]>(() =>
client.getViewerHttpIndexRelayBases()
)
const [connectedUrls, setConnectedUrls] = useState<string[]>(() => 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<string>()
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])
}

4
src/i18n/locales/de.ts

@ -108,8 +108,8 @@ export default { @@ -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",

4
src/i18n/locales/en.ts

@ -113,8 +113,8 @@ export default { @@ -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",

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

@ -73,7 +73,7 @@ describe('read-only-relay-personal', () => { @@ -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', () => { @@ -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', () => { @@ -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)

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

@ -95,17 +95,12 @@ export function isRelayAllowedUnderMetadataOnlyPolicy(url: string): boolean { @@ -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( @@ -183,11 +178,9 @@ export function sanitizeRelayUrlsForFetch(
const key = relayUrlKey(u)
return key.length > 0 && keys.has(key)
})
return filterToViewerMetadataRelaysOnly(
filterViewerBlockedRelaysForFetch(
return filterViewerBlockedRelaysForFetch(
filterAggrNostrLandUnlessViewerEligible(
filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys)
)
)
)
}

6
src/lib/viewer-relay-defaults.ts

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

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

@ -40,7 +40,7 @@ import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch- @@ -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 { @@ -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)))

13
src/services/client.service.ts

@ -42,7 +42,6 @@ import { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 } }]
}

Loading…
Cancel
Save