From 3c1f03aae0fb0dd53a2e2fcbfa0bd0a996ffcae8 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 22 May 2026 14:21:43 +0200 Subject: [PATCH] blocked relays more universally --- src/components/Favicon/index.tsx | 25 +++++++++++--- src/hooks/useFetchNip05.tsx | 6 ++++ src/lib/nip05.ts | 43 ++++++++++++++++++++++-- src/lib/read-only-relay-personal.test.ts | 10 ++++++ src/lib/read-only-relay-personal.ts | 7 ++-- src/lib/relay-list-builder.ts | 12 ++----- src/lib/viewer-blocked-relays.test.ts | 36 ++++++++++++++++++++ src/lib/viewer-blocked-relays.ts | 32 ++++++++++++++++++ src/providers/FavoriteRelaysProvider.tsx | 5 +++ src/providers/NostrProvider/index.tsx | 33 ++++++------------ src/services/client.service.ts | 11 ++++++ src/services/note-stats.service.ts | 9 +++-- 12 files changed, 186 insertions(+), 43 deletions(-) create mode 100644 src/lib/viewer-blocked-relays.test.ts create mode 100644 src/lib/viewer-blocked-relays.ts diff --git a/src/components/Favicon/index.tsx b/src/components/Favicon/index.tsx index fc003506..d13d670f 100644 --- a/src/components/Favicon/index.tsx +++ b/src/components/Favicon/index.tsx @@ -1,6 +1,6 @@ import { isFaviconLoadFailed, markFaviconLoadFailed, normalizeFaviconDomain } from '@/lib/favicon-fail-cache' import { cn } from '@/lib/utils' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' export function Favicon({ domain, @@ -12,9 +12,21 @@ export function Favicon({ fallback?: React.ReactNode }) { const host = normalizeFaviconDomain(domain) - const knownFailed = host ? isFaviconLoadFailed(host) : true - const [loading, setLoading] = useState(!knownFailed) - const [error, setError] = useState(knownFailed) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + const loadingRef = useRef(loading) + loadingRef.current = loading + + useEffect(() => { + const knownFailed = !host || isFaviconLoadFailed(host) + setError(knownFailed) + setLoading(!knownFailed) + if (!host || knownFailed) return + return () => { + if (loadingRef.current) markFaviconLoadFailed(host) + } + }, [host]) + if (error || !host) return fallback return ( @@ -28,7 +40,10 @@ export function Favicon({ markFaviconLoadFailed(host) setError(true) }} - onLoad={() => setLoading(false)} + onLoad={() => { + loadingRef.current = false + setLoading(false) + }} /> ) diff --git a/src/hooks/useFetchNip05.tsx b/src/hooks/useFetchNip05.tsx index 87dbe7e4..c34fb25d 100644 --- a/src/hooks/useFetchNip05.tsx +++ b/src/hooks/useFetchNip05.tsx @@ -12,12 +12,18 @@ export function useFetchNip05(nip05?: string, pubkey?: string) { setIsFetching(false) return } + let cancelled = false + setIsFetching(true) verifyNip05(nip05, pubkey).then(({ isVerified, nip05Name, nip05Domain }) => { + if (cancelled) return setNip05IsVerified(isVerified) setNip05Name(nip05Name) setNip05Domain(nip05Domain) setIsFetching(false) }) + return () => { + cancelled = true + } }, [nip05, pubkey]) return { nip05IsVerified, nip05Name, nip05Domain, isFetching } diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts index 3c5bfb74..e6677dd6 100644 --- a/src/lib/nip05.ts +++ b/src/lib/nip05.ts @@ -20,6 +20,14 @@ type TVerifyNip05Result = { /** Bumps when verification rules change so LRU does not serve stale false negatives. */ const VERIFY_CACHE_SCHEMA = 4 +/** Per-domain `nostr.json` (or negative `null`) so feeds do not re-fetch every NIP-05 on the same host. */ +const wellKnownJsonByDomain = new LRUCache | null>({ max: 512 }) +const wellKnownDomainInFlight = new Map | null>>() + +function normalizeNip05Domain(domain: string): string { + return domain.trim().toLowerCase().replace(/\.$/, '') +} + function asNip05LookupString(value: unknown): string { if (typeof value === 'string') return value if (value == null) return '' @@ -215,8 +223,11 @@ async function fetchWellKnownNostrJsonOnce( } } -/** Fetch `/.well-known/nostr.json` (with optional `?name=`). Retries without `name` if the entry is missing. */ -async function fetchWellKnownNostrJson(domain: string, name?: string): Promise | null> { +/** Uncached network: optional `?name=` then full document. */ +async function fetchWellKnownNostrJsonNetwork( + domain: string, + name?: string +): Promise | null> { const trimmedName = typeof name === 'string' ? name.trim() : '' const withQuery = trimmedName.length > 0 ? await fetchWellKnownNostrJsonOnce(domain, trimmedName) : null @@ -234,6 +245,34 @@ async function fetchWellKnownNostrJson(domain: string, name?: string): Promise | null> { + const key = normalizeNip05Domain(domain) + if (!key) return null + if (wellKnownJsonByDomain.has(key)) { + return wellKnownJsonByDomain.get(key) ?? null + } + let inflight = wellKnownDomainInFlight.get(key) + if (!inflight) { + inflight = fetchWellKnownNostrJsonNetwork(key, nameHint).then((json) => { + wellKnownJsonByDomain.set(key, json) + wellKnownDomainInFlight.delete(key) + return json + }) + wellKnownDomainInFlight.set(key, inflight) + } + return inflight +} + +/** Fetch `/.well-known/nostr.json` (with optional `?name=`). Retries without `name` if the entry is missing. */ +async function fetchWellKnownNostrJson(domain: string, name?: string): Promise | null> { + const trimmedName = typeof name === 'string' ? name.trim() : '' + const hint = trimmedName.length > 0 ? trimmedName : undefined + return getOrFetchWellKnownJsonForDomain(domain, hint) +} + export async function fetchPubkeysFromDomain(domain: string): Promise { try { const json = await fetchWellKnownNostrJson(domain) diff --git a/src/lib/read-only-relay-personal.test.ts b/src/lib/read-only-relay-personal.test.ts index 780c4173..1e4ba68d 100644 --- a/src/lib/read-only-relay-personal.test.ts +++ b/src/lib/read-only-relay-personal.test.ts @@ -8,14 +8,17 @@ import { sanitizeRelayUrlsForFetch, setViewerPersonalRelayKeys } from './read-only-relay-personal' +import { setViewerBlockedRelayUrls } from './viewer-blocked-relays' describe('read-only-relay-personal', () => { beforeEach(() => { setViewerPersonalRelayKeys(new Set()) + setViewerBlockedRelayUrls([]) syncViewerRelayStackNostrLandAggrEligible([]) }) afterEach(() => { + setViewerBlockedRelayUrls([]) syncViewerRelayStackNostrLandAggrEligible([]) }) @@ -50,6 +53,13 @@ describe('read-only-relay-personal', () => { ]) }) + it('sanitizeRelayUrlsForFetch drops user-blocked relays', () => { + setViewerBlockedRelayUrls(['wss://freelay.sovbit.host/']) + expect( + sanitizeRelayUrlsForFetch(['wss://relay.damus.io/', 'wss://freelay.sovbit.host/']) + ).toEqual(['wss://relay.damus.io/']) + }) + it('keeps filter.nostr.wine when on the viewer personal list', () => { setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://filter.nostr.wine/'])) const urls = ['wss://relay.damus.io/', 'wss://filter.nostr.wine/'] diff --git a/src/lib/read-only-relay-personal.ts b/src/lib/read-only-relay-personal.ts index 015f9259..796b64ec 100644 --- a/src/lib/read-only-relay-personal.ts +++ b/src/lib/read-only-relay-personal.ts @@ -1,6 +1,7 @@ import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants' import { filterAggrNostrLandUnlessViewerEligible } from '@/lib/nostr-land-relay-eligibility' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' +import { filterViewerBlockedRelaysForFetch } from '@/lib/viewer-blocked-relays' import { normalizeAnyRelayUrl } from '@/lib/url' const personalListRequiredKeySet = new Set( @@ -82,7 +83,9 @@ export function sanitizeRelayUrlsForFetch( const key = relayUrlKey(u) return key.length > 0 && keys.has(key) }) - return filterAggrNostrLandUnlessViewerEligible( - filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys) + return filterViewerBlockedRelaysForFetch( + filterAggrNostrLandUnlessViewerEligible( + filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys) + ) ) } diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 9afbaf2c..c942ad75 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -12,6 +12,7 @@ import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { isRelayBlockedByUser } from '@/lib/relay-blocked' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { canonicalRelaySessionKey, @@ -165,20 +166,13 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio const trackPersonal = (url: string) => { personalRelayUrls.push(url) } - const normalizedBlocked = new Set( - (blockedRelays || []) - .map((url) => (normalizeAnyRelayUrl(url) || url).toLowerCase()) - .filter(Boolean) - ) - const addRelay = (url: string | undefined) => { if (!url) return // This builder feeds WebSocket REQ/publish lists; kind 10243 HTTP index relays use addHttpRelay. if (isKind10243HttpRelayTagUrl(url)) return const normalized = normalizeAnyRelayUrl(url) if (!normalized) return - // Filter blocked (case-insensitive comparison) - if (normalizedBlocked.has(normalized.toLowerCase())) return + if (isRelayBlockedByUser(normalized, blockedRelays)) return relayUrls.add(normalized) } @@ -191,7 +185,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio const addHttpRelay = (url: string | undefined) => { if (!url) return const normalized = normalizeHttpRelayUrl(url) - if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) return + if (!normalized || isRelayBlockedByUser(normalized, blockedRelays)) return if (httpRelayUrls.some((u) => relayKey(u) === relayKey(normalized))) return httpRelayUrls.push(normalized) } diff --git a/src/lib/viewer-blocked-relays.test.ts b/src/lib/viewer-blocked-relays.test.ts new file mode 100644 index 00000000..fbb2993f --- /dev/null +++ b/src/lib/viewer-blocked-relays.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + filterViewerBlockedRelaysForFetch, + parseBlockedRelayUrlsFromEvent, + setViewerBlockedRelayUrls +} from './viewer-blocked-relays' + +describe('viewer-blocked-relays', () => { + it('parseBlockedRelayUrlsFromEvent dedupes relay tags', () => { + setViewerBlockedRelayUrls([]) + const urls = parseBlockedRelayUrlsFromEvent({ + kind: 10006, + tags: [ + ['relay', 'wss://freelay.sovbit.host/'], + ['relay', 'wss://freelay.sovbit.host'] + ], + content: '', + created_at: 1, + id: 'x', + pubkey: 'p', + sig: 's' + }) + expect(urls).toEqual(['wss://freelay.sovbit.host/']) + }) + + it('filterViewerBlockedRelaysForFetch matches hostname across schemes', () => { + setViewerBlockedRelayUrls(['wss://freelay.sovbit.host/']) + expect( + filterViewerBlockedRelaysForFetch([ + 'wss://freelay.sovbit.host/', + 'wss://relay.example.com/', + 'https://freelay.sovbit.host/' + ]) + ).toEqual(['wss://relay.example.com/']) + }) +}) diff --git a/src/lib/viewer-blocked-relays.ts b/src/lib/viewer-blocked-relays.ts new file mode 100644 index 00000000..ca0215f1 --- /dev/null +++ b/src/lib/viewer-blocked-relays.ts @@ -0,0 +1,32 @@ +import type { Event } from 'nostr-tools' +import { isRelayBlockedByUser } from '@/lib/relay-blocked' +import { normalizeAnyRelayUrl } from '@/lib/url' + +let viewerBlockedRelayUrls: readonly string[] = [] + +/** Kind 10006 `relay` tags → normalized URLs (deduped). */ +export function parseBlockedRelayUrlsFromEvent(event: Event | null | undefined): string[] { + const out: string[] = [] + if (!event) return out + event.tags.forEach(([tagName, tagValue]) => { + if (tagName !== 'relay' || !tagValue) return + const n = normalizeAnyRelayUrl(tagValue) + if (n && !out.includes(n)) out.push(n) + }) + return out +} + +/** Updated from IDB hydration and {@link FavoriteRelaysProvider} when the block list changes. */ +export function setViewerBlockedRelayUrls(urls: readonly string[]): void { + viewerBlockedRelayUrls = urls.length ? [...urls] : [] +} + +export function getViewerBlockedRelayUrls(): readonly string[] { + return viewerBlockedRelayUrls +} + +/** Drop user-blocked relays (hostname-aware) before any REQ / query / WebSocket connect. */ +export function filterViewerBlockedRelaysForFetch(urls: readonly string[]): string[] { + if (!viewerBlockedRelayUrls.length) return [...urls] + return urls.filter((u) => !isRelayBlockedByUser(u, viewerBlockedRelayUrls)) +} diff --git a/src/providers/FavoriteRelaysProvider.tsx b/src/providers/FavoriteRelaysProvider.tsx index 99fbf1a2..b8e8e56e 100644 --- a/src/providers/FavoriteRelaysProvider.tsx +++ b/src/providers/FavoriteRelaysProvider.tsx @@ -6,6 +6,7 @@ import { getReplaceableEventIdentifier } from '@/lib/event' import { getRelaySetFromEvent } from '@/lib/event-metadata' import { randomString } from '@/lib/random' import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { setViewerBlockedRelayUrls } from '@/lib/viewer-blocked-relays' import { queryService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { TRelaySet } from '@/types' @@ -156,6 +157,10 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode setBlockedRelays(relays) }, [blockedRelaysEvent]) + useEffect(() => { + setViewerBlockedRelayUrls(blockedRelays) + }, [blockedRelays]) + useEffect(() => { setRelaySets( relaySetEvents.map((evt) => getRelaySetFromEvent(evt, blockedRelays)).filter(Boolean) as TRelaySet[] diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 1dd0af50..3ff2697b 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -31,6 +31,10 @@ import { } from '@/lib/event-metadata' import logger from '@/lib/logger' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' +import { + parseBlockedRelayUrlsFromEvent, + setViewerBlockedRelayUrls +} from '@/lib/viewer-blocked-relays' import { LoginRequiredError } from '@/lib/nostr-errors' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' @@ -106,15 +110,7 @@ function favoriteRelayUrlsForPublish( } function blockedRelayUrlsFromEvent(blockedRelaysEvent: Event | null): string[] { - const out: string[] = [] - if (!blockedRelaysEvent) return out - blockedRelaysEvent.tags.forEach(([tagName, tagValue]) => { - if (tagName === 'relay' && tagValue) { - const n = normalizeAnyRelayUrl(tagValue) - if (n && !out.includes(n)) out.push(n) - } - }) - return out + return parseBlockedRelayUrlsFromEvent(blockedRelaysEvent) } const NIP07_SIGNER_PUBKEY_MISMATCH_MSG = 'Signer pubkey does not match current account' @@ -325,20 +321,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.HTTP_RELAY_LIST) ]) - // Extract blocked relays from event - const blockedRelays: string[] = [] - if (storedBlockedRelaysEvent) { - storedBlockedRelaysEvent.tags.forEach(([tagName, tagValue]) => { - if (tagName === 'relay' && tagValue) { - const normalizedUrl = normalizeUrl(tagValue) - if (normalizedUrl && !blockedRelays.includes(normalizedUrl)) { - blockedRelays.push(normalizedUrl) - } - } - }) - if (!userForcedAccountNetworkHydrate) { - setBlockedRelaysEvent(storedBlockedRelaysEvent) - } + // Extract blocked relays from event (sync to fetch layer before feed REQs) + const blockedRelays = parseBlockedRelayUrlsFromEvent(storedBlockedRelaysEvent ?? null) + setViewerBlockedRelayUrls(blockedRelays) + if (storedBlockedRelaysEvent && !userForcedAccountNetworkHydrate) { + setBlockedRelaysEvent(storedBlockedRelaysEvent) } // Set initial relay list from stored events (will be updated with merged list later) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 48369b80..5079a16a 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -44,6 +44,10 @@ import { setViewerPersonalRelayKeys } from '@/lib/read-only-relay-personal' import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' +import { + parseBlockedRelayUrlsFromEvent, + setViewerBlockedRelayUrls +} from '@/lib/viewer-blocked-relays' /** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { @@ -616,8 +620,15 @@ class ClientService extends EventTarget { this.viewerHttpIndexRelayBases = [] setViewerPersonalRelayKeys(new Set()) syncViewerRelayStackNostrLandAggrEligible([]) + setViewerBlockedRelayUrls([]) return } + try { + const blockedEvt = await indexedDb.getReplaceableEvent(pk, ExtendedKind.BLOCKED_RELAYS) + setViewerBlockedRelayUrls(parseBlockedRelayUrlsFromEvent(blockedEvt ?? null)) + } catch { + setViewerBlockedRelayUrls([]) + } const urls: string[] = [] try { const rl = await this.peekRelayListFromStorage(pk) diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 8cf04d9b..cef2bf1f 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -32,6 +32,8 @@ import { getNip25ReactionTargetHexFromTags, tagNameEquals } from '@/lib/tag' +import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' +import { getViewerBlockedRelayUrls } from '@/lib/viewer-blocked-relays' import client, { eventService } from '@/services/client.service' import { TEmoji } from '@/types' import dayjs from 'dayjs' @@ -607,9 +609,11 @@ class NoteStatsService { } } - /** Stats REQs: dedupe, then prepend {@link AGGR_NOSTR_LAND_WSS} when the viewer lists `wss://nostr.land`. */ + /** Stats REQs: dedupe, user-blocked strip, then prepend {@link AGGR_NOSTR_LAND_WSS} when eligible. */ private finalizeNoteStatsRelayUrls(urls: readonly string[]): string[] { - return prependAggrNostrLandIfViewerEligible(dedupeNormalizeRelayUrlsOrdered(urls)) + return prependAggrNostrLandIfViewerEligible( + sanitizeRelayUrlsForFetch(dedupeNormalizeRelayUrlsOrdered(urls)) + ) } /** {@link buildComprehensiveRelayList} for reactions/reposts/zaps on a note (thread hints, capped author NIP-65). */ @@ -657,6 +661,7 @@ class NoteStatsService { authorPubkey: event.pubkey, userPubkey: me, relayHints, + blockedRelays: [...getViewerBlockedRelayUrls()], includeUserOwnRelays: Boolean(me), includeFavoriteRelays: Boolean(me), includeFastReadRelays: useGlobal,