diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 25c0331f..558bc161 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -19,7 +19,7 @@ import nip66Service from '@/services/nip66.service' import { navigationEventStore } from '@/services/navigation-event-store' import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' -import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal' +import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useReply } from '@/providers/ReplyProvider' @@ -343,7 +343,7 @@ function EmbeddedNoteFetched({ operation: 'read', blockedRelays, applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true + allowThirdPartyLocalRelays: false }) ) if (cancelled || !ev) return @@ -536,7 +536,7 @@ function buildEmbedWideRelayUrlsStatic( relayHintsFromParent: string[], viewerInboxRelayUrls: string[] ): string[] { - return filterReadOnlyRelaysUnlessPersonal( + return sanitizeRelayUrlsForFetch( feedRelayPolicyUrls( [ { @@ -557,7 +557,7 @@ function buildEmbedWideRelayUrlsStatic( { operation: 'read', applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true + allowThirdPartyLocalRelays: false } ) ) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index bb405fed..5f9b3c1b 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -43,6 +43,7 @@ import noteStatsService from '@/services/note-stats.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' +import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' @@ -1059,9 +1060,9 @@ function ReplyNoteList({ const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) - const threadRelayHints = [ + const threadRelayHints = sanitizeRelayUrlsForFetch([ ...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed]) - ] + ]) const replyBlockedRelays = [ ...(blockedRelays || []) ] @@ -1104,12 +1105,14 @@ function ReplyNoteList({ limit: LIMIT }) - const relayUrlsForThreadReq = feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], { - operation: 'read', - blockedRelays: replyBlockedRelays, - applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true - }) + const relayUrlsForThreadReq = sanitizeRelayUrlsForFetch( + feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], { + operation: 'read', + blockedRelays: replyBlockedRelays, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: false + }) + ) // For URL threads: stream events as they arrive from each relay so replies appear // immediately, rather than waiting up to 10 s for all relays to EOSE. diff --git a/src/constants.ts b/src/constants.ts index 62c84d2e..384d8c4b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -279,6 +279,10 @@ export const SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS = 28_000 export const HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS = 2_000 export const HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 5_000 +/** Parent/root strip on note pages — fail open so the main note is not blocked behind tryHarder. */ +export const THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 10_000 +export const THREAD_CONTEXT_TRY_HARDER_GLOBAL_TIMEOUT_MS = 8_000 + /** Wide REQ for embeds / explicit external lists ({@link EventService.fetchEventWithExternalRelays}). */ export const EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS = 14_000 export const EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 40_000 diff --git a/src/hooks/useFetchThreadContextEvent.tsx b/src/hooks/useFetchThreadContextEvent.tsx index 1df3aed7..3b636e26 100644 --- a/src/hooks/useFetchThreadContextEvent.tsx +++ b/src/hooks/useFetchThreadContextEvent.tsx @@ -1,4 +1,5 @@ -import { SEARCHABLE_RELAY_URLS } from '@/constants' +import { SEARCHABLE_RELAY_URLS, THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants' +import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useNostr } from '@/providers/NostrProvider' @@ -105,10 +106,23 @@ export function useFetchThreadContextEvent( viewerPubkey ?? undefined, blockedRelays ) - const opts = relayUrls.length ? { relayHints: relayUrls } : undefined - let fetchedEvent = skipShortcuts - ? await eventService.fetchEventForceRetry(eventId, opts) - : await eventService.fetchEvent(eventId, opts) + const threadOpts = relayUrls.length + ? { relayHints: relayUrls, threadContext: true as const } + : { threadContext: true as const } + + const fetchParentOrRoot = async () => { + if (skipShortcuts) { + return eventService.fetchEventForceRetry(eventId, threadOpts) + } + return eventService.fetchEvent(eventId, threadOpts) + } + + let fetchedEvent = await Promise.race([ + fetchParentOrRoot(), + new Promise((resolve) => { + window.setTimeout(() => resolve(undefined), THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS) + }) + ]) if ( !fetchedEvent && @@ -116,10 +130,13 @@ export function useFetchThreadContextEvent( SEARCHABLE_RELAY_URLS.length > 0 ) { searchableAttemptedRef.current = true - fetchedEvent = await client.fetchEventWithExternalRelays( - eventId, - SEARCHABLE_RELAY_URLS - ) + const searchable = sanitizeRelayUrlsForFetch([...SEARCHABLE_RELAY_URLS]) + fetchedEvent = await Promise.race([ + client.fetchEventWithExternalRelays(eventId, searchable), + new Promise((resolve) => { + window.setTimeout(() => resolve(undefined), THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS) + }) + ]) if (fetchedEvent) { client.addEventToCache(fetchedEvent) } diff --git a/src/lib/citation-picker-relays.ts b/src/lib/citation-picker-relays.ts index ceac04be..cf6764b6 100644 --- a/src/lib/citation-picker-relays.ts +++ b/src/lib/citation-picker-relays.ts @@ -6,7 +6,7 @@ import { SEARCHABLE_RELAY_URLS } from '@/constants' import { mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' -import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal' +import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' import client from '@/services/client.service' @@ -64,5 +64,5 @@ export async function buildCitationPickerSearchRelayUrls(): Promise { [] ) - return filterReadOnlyRelaysUnlessPersonal(merged).slice(0, CITATION_SEARCH_MAX_RELAYS) + return sanitizeRelayUrlsForFetch(merged).slice(0, CITATION_SEARCH_MAX_RELAYS) } diff --git a/src/lib/read-only-relay-personal.ts b/src/lib/read-only-relay-personal.ts index 3ef14d3c..c90b52c7 100644 --- a/src/lib/read-only-relay-personal.ts +++ b/src/lib/read-only-relay-personal.ts @@ -1,4 +1,5 @@ import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants' +import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { normalizeAnyRelayUrl } from '@/lib/url' const personalListRequiredKeySet = new Set( @@ -65,3 +66,20 @@ export function filterReadOnlyRelaysUnlessPersonal( const keys = personalKeys ?? viewerPersonalRelayKeys return urls.filter((u) => isAllowedForKeys(u, keys)) } + +/** + * Sanitize relay URLs assembled for REQ/fetch: drop other people's LAN/localhost hints (keep viewer's + * own locals from NIP-65 / favorites / 10432), then gated read-only indexers. + */ +export function sanitizeRelayUrlsForFetch( + urls: readonly string[], + personalKeys?: ReadonlySet +): string[] { + const keys = personalKeys ?? viewerPersonalRelayKeys + const withoutThirdPartyLocals = urls.filter((u) => { + if (urlIsNonLocalForRemoteViewer(u)) return true + const key = relayUrlKey(u) + return key.length > 0 && keys.has(key) + }) + return filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys) +} diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 467efeb9..44bff01d 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -14,7 +14,7 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' -import { buildPersonalRelayKeySet, filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal' +import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { getCacheRelayUrls } from './private-relays' import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import client from '@/services/client.service' @@ -176,6 +176,12 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio relayUrls.add(normalized) } + /** Hints / NIP-65 lists — no loopback/LAN (viewer cache relays come from kind 10432 only). */ + const addRelayFromHints = (url: string | undefined) => { + if (!url || !urlIsNonLocalForRemoteViewer(url)) return + addRelay(url) + } + const addHttpRelay = (url: string | undefined) => { if (!url || !isHttpRelayUrl(url)) return const normalized = normalizeAnyRelayUrl(url) || url.trim() @@ -220,13 +226,13 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } // 1. Relay hints (highest priority - explicit hints) - relayHints.forEach(addRelay) + relayHints.filter(urlIsNonLocalForRemoteViewer).forEach(addRelayFromHints) // 2. Relays where event was seen - seenRelays.forEach(addRelay) + seenRelays.filter(urlIsNonLocalForRemoteViewer).forEach(addRelayFromHints) // 3. Relays where containing event was found (for embedded events) - containingEventRelays.forEach(addRelay) + containingEventRelays.filter(urlIsNonLocalForRemoteViewer).forEach(addRelayFromHints) // 3b. Public profile / read relays before user favorites & NIP-65 (batched kind-0 — avoids burning // connection slots on broken personal relays before PROFILE_FETCH + FAST_READ answer). @@ -247,15 +253,15 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio try { const authorRelayList = await client.peekRelayListFromStorage(authorPubkey) pickAuthorNip65RelaysPreferringViewerOverlap( - authorRelayList.write ?? [], + (authorRelayList.write ?? []).filter(urlIsNonLocalForRemoteViewer), viewerWsForAuthorOverlap, AUTHOR_NIP65_RELAY_CAP - ).forEach(addRelay) + ).forEach(addRelayFromHints) pickAuthorNip65RelaysPreferringViewerOverlap( - authorRelayList.read ?? [], + (authorRelayList.read ?? []).filter(urlIsNonLocalForRemoteViewer), viewerWsForAuthorOverlap, AUTHOR_NIP65_RELAY_CAP - ).forEach(addRelay) + ).forEach(addRelayFromHints) } catch (error) { logger.warn('[RelayListBuilder] Failed to read author relay list from storage', { error }) } @@ -267,13 +273,13 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10) const userWrite = [...(userRelayList.write || []).slice(0, 10)] - userRead.forEach((u) => { + userRead.filter(urlIsNonLocalForRemoteViewer).forEach((u) => { trackPersonal(u) - addRelay(u) + addRelayFromHints(u) }) - userWrite.forEach((u) => { + userWrite.filter(urlIsNonLocalForRemoteViewer).forEach((u) => { trackPersonal(u) - addRelay(u) + addRelayFromHints(u) }) // Include local relays from kind 10432 @@ -304,10 +310,13 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio // Even if not including user's own relays, still include user's inboxes for reading try { const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) - ;(userRelayList.read ?? []).slice(0, 10).forEach((u) => { - trackPersonal(u) - addRelay(u) - }) + ;(userRelayList.read ?? []) + .slice(0, 10) + .filter(urlIsNonLocalForRemoteViewer) + .forEach((u) => { + trackPersonal(u) + addRelayFromHints(u) + }) // Include local relays from kind 10432 if enabled if (includeLocalRelays) { @@ -367,12 +376,12 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio const merged = Array.from(relayUrls) const personalKeys = userPubkey ? buildPersonalRelayKeySet(personalRelayUrls) : undefined - const ws = filterReadOnlyRelaysUnlessPersonal( + const ws = sanitizeRelayUrlsForFetch( feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], { operation: 'read', blockedRelays, applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true + allowThirdPartyLocalRelays: false }), personalKeys ) diff --git a/src/lib/relay-list-sanitize.test.ts b/src/lib/relay-list-sanitize.test.ts new file mode 100644 index 00000000..27be159f --- /dev/null +++ b/src/lib/relay-list-sanitize.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + buildPersonalRelayKeySet, + sanitizeRelayUrlsForFetch, + setViewerPersonalRelayKeys +} from './read-only-relay-personal' +import { stripLocalRelaysFromThirdPartyHints } from './relay-list-sanitize' + +describe('stripLocalRelaysFromThirdPartyHints', () => { + it('removes loopback and LAN from hint lists', () => { + const urls = [ + 'wss://relay.example.com/', + 'ws://localhost:4869/', + 'wss://192.168.1.50:7777/', + 'wss://filter.nostr.wine/' + ] + expect(stripLocalRelaysFromThirdPartyHints(urls)).toEqual([ + 'wss://relay.example.com/', + 'wss://filter.nostr.wine/' + ]) + }) +}) + +describe('sanitizeRelayUrlsForFetch', () => { + it('strips third-party locals and unlisted filter.nostr.wine', () => { + setViewerPersonalRelayKeys(new Set()) + const urls = ['wss://relay.example.com/', 'ws://127.0.0.1:7777/', 'wss://filter.nostr.wine/'] + expect(sanitizeRelayUrlsForFetch(urls)).toEqual(['wss://relay.example.com/']) + }) + + it('keeps viewer cache localhost when on personal keys', () => { + const local = 'ws://127.0.0.1:4869/' + setViewerPersonalRelayKeys(buildPersonalRelayKeySet([local])) + const urls = ['wss://relay.example.com/', local, 'ws://192.168.0.2:7777/'] + expect(sanitizeRelayUrlsForFetch(urls)).toEqual(['wss://relay.example.com/', local]) + }) +}) diff --git a/src/lib/relay-list-sanitize.ts b/src/lib/relay-list-sanitize.ts index 13cb04f3..3dd62add 100644 --- a/src/lib/relay-list-sanitize.ts +++ b/src/lib/relay-list-sanitize.ts @@ -11,6 +11,14 @@ export function urlIsNonLocalForRemoteViewer(url: string): boolean { return true } +/** + * Remove loopback/LAN URLs from tag, nevent, or seen-on hints. The viewer's own cache relays (kind 10432) + * are added separately via {@link getCacheRelayUrls} — never from another user's `e` tag hint. + */ +export function stripLocalRelaysFromThirdPartyHints(urls: readonly string[]): string[] { + return urls.filter(urlIsNonLocalForRemoteViewer) +} + /** * Drop LAN/loopback from NIP-65 + HTTP mailbox fields when resolving **another** author's data: * the viewer cannot reach the author's `localhost` / `192.168.*` / etc., but we used to rank them first. diff --git a/src/lib/thread-context-relays.ts b/src/lib/thread-context-relays.ts index 62404ff8..083b2d72 100644 --- a/src/lib/thread-context-relays.ts +++ b/src/lib/thread-context-relays.ts @@ -1,3 +1,4 @@ +import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' @@ -36,7 +37,9 @@ export async function buildThreadContextFetchRelayUrls( blockedRelays: string[] = [] ): Promise { const tagHints = relayHintsFromThreadETag(targetTag) - const threadRelayHints = [...new Set([...tagHints, ...relayHintsFromEventTags(contextEvent)])] + const threadRelayHints = sanitizeRelayUrlsForFetch([ + ...new Set([...tagHints, ...relayHintsFromEventTags(contextEvent)]) + ]) const opAuthorPubkey = pubkeyFromThreadETag(targetTag) return buildReplyReadRelayList(opAuthorPubkey, viewerPubkey, blockedRelays, threadRelayHints) } diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index e10bf36c..46425538 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -5,11 +5,14 @@ import { EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS, HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS, HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS, + THREAD_CONTEXT_TRY_HARDER_GLOBAL_TIMEOUT_MS, isDocumentRelayKind, NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS, SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants' +import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' +import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import logger from '@/lib/logger' import { collectEmbeddedEventPrefetchTargets, @@ -351,7 +354,10 @@ export class EventService { * Fetch single event by ID (hex, note1, nevent1, naddr1). * Optional `relayHints` (e.g. from the parent article’s tags) are merged first so REQ targets the same relays that likely hold the embed. */ - async fetchEvent(id: string, opts?: { relayHints?: string[] }): Promise { + async fetchEvent( + id: string, + opts?: { relayHints?: string[]; /** Shorter timeouts; do not block note UI on parent/root. */ threadContext?: boolean } + ): Promise { const trimmed = id.trim() let hexId: string | undefined let pointerHasFetchHints = false @@ -402,7 +408,7 @@ export class EventService { } } if (opts?.relayHints?.length || pointerHasFetchHints) { - const hinted = await this._fetchEvent(trimmed, opts?.relayHints) + const hinted = await this._fetchEvent(trimmed, opts?.relayHints, opts?.threadContext) if ( hinted && !shouldDropEventOnIngest(hinted, hexId ? { explicitNoteLookupHexId: hexId } : undefined) @@ -447,7 +453,10 @@ export class EventService { /** * Force retry fetch event */ - async fetchEventForceRetry(eventId: string, opts?: { relayHints?: string[] }): Promise { + async fetchEventForceRetry( + eventId: string, + opts?: { relayHints?: string[]; threadContext?: boolean } + ): Promise { this.clearDataloaderCacheForFetchId(eventId) return this.fetchEvent(eventId, opts) } @@ -1128,22 +1137,29 @@ export class EventService { for (const tag of event.tags) { if (tagTypesWithRelayHints.includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') { const hint = tag[2] - if (hint.startsWith('wss://') || hint.startsWith('ws://')) { + if ( + (hint.startsWith('wss://') || hint.startsWith('ws://')) && + urlIsNonLocalForRemoteViewer(hint) + ) { hints.add(hint) } } } - + // Also check for dedicated "relays" tag - const relaysTag = event.tags.find(tag => tag[0] === 'relays') + const relaysTag = event.tags.find((tag) => tag[0] === 'relays') if (relaysTag && relaysTag.length > 1) { - relaysTag.slice(1).forEach(url => { - if (typeof url === 'string' && (url.startsWith('wss://') || url.startsWith('ws://'))) { + relaysTag.slice(1).forEach((url) => { + if ( + typeof url === 'string' && + (url.startsWith('wss://') || url.startsWith('ws://')) && + urlIsNonLocalForRemoteViewer(url) + ) { hints.add(url) } }) } - + return Array.from(hints) } @@ -1165,18 +1181,20 @@ export class EventService { /** * Private: Fetch event by ID (internal implementation) */ - private async _fetchEvent(id: string, extraRelayHints?: string[]): Promise { + private async _fetchEvent( + id: string, + extraRelayHints?: string[], + threadContext = false + ): Promise { let filter: Filter | undefined let relays: string[] = [] let authorHintPubkey: string | undefined + const normalizeRelayList = (urls: string[]) => + sanitizeRelayUrlsForFetch( + [...new Set(urls.map((u) => normalizeUrl(u)).filter((u): u is string => Boolean(u)))] + ) if (extraRelayHints?.length) { - relays = [ - ...new Set( - extraRelayHints - .map((u) => normalizeUrl(u)) - .filter((u): u is string => Boolean(u)) - ) - ] + relays = normalizeRelayList(extraRelayHints) } if (/^[0-9a-f]{64}$/i.test(id)) { @@ -1189,7 +1207,7 @@ export class EventService { break case 'nevent': filter = { ids: [data.id], limit: 1 } - if (data.relays) relays = [...new Set([...relays, ...data.relays])] + if (data.relays) relays = normalizeRelayList([...relays, ...data.relays]) if (data.author && /^[0-9a-f]{64}$/i.test(data.author)) { authorHintPubkey = data.author.toLowerCase() } @@ -1203,7 +1221,7 @@ export class EventService { '#d': [ident], limit: 1 } - if (data.relays) relays = [...new Set([...relays, ...data.relays])] + if (data.relays) relays = normalizeRelayList([...relays, ...data.relays]) break } } @@ -1240,7 +1258,7 @@ export class EventService { // Extract relay hints from cached event's tags (e, a, q tags) const eventRelayHints = this.extractRelayHintsFromEvent(cached) if (eventRelayHints.length > 0) { - relays = [...new Set([...relays, ...eventRelayHints])] + relays = normalizeRelayList([...relays, ...eventRelayHints]) } return cached } @@ -1269,14 +1287,21 @@ export class EventService { // Extract relay hints from found event's tags (e, a, q tags) const eventRelayHints = this.extractRelayHintsFromEvent(event) if (eventRelayHints.length > 0) { - relays = [...new Set([...relays, ...eventRelayHints])] + relays = normalizeRelayList([...relays, ...eventRelayHints]) } return event } } // Always try comprehensive relay list (author's outboxes + user's inboxes + hints + seen + defaults) - const event = await this.tryHarderToFetchEvent(relays, filter, true, authorHintPubkey, ingestOpts) + const event = await this.tryHarderToFetchEvent( + relays, + filter, + true, + authorHintPubkey, + ingestOpts, + threadContext + ) if (event && !shouldDropEventOnIngest(event, ingestOpts)) { this.addEventToCache(event, ingestOpts) return event @@ -1324,7 +1349,8 @@ export class EventService { filter: Filter, alreadyFetchedFromBigRelays = false, authorHintPubkey?: string, - ingestOpts?: ShouldDropEventOnIngestOptions + ingestOpts?: ShouldDropEventOnIngestOptions, + threadContext = false ): Promise { // Get seen relays if we have an event ID const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : [] @@ -1356,10 +1382,15 @@ export class EventService { (filter.authors?.length === 1 && Array.isArray(filter['#d']) && filter['#d'].length >= 1)) const useFastSingleHitQuery = isSingleEventById || isReplaceableCoordinateFetch + const tryHarderGlobalTimeout = threadContext + ? THREAD_CONTEXT_TRY_HARDER_GLOBAL_TIMEOUT_MS + : useFastSingleHitQuery + ? SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS + : 10000 const events = await this.queryService.query(relayUrls, filter, undefined, { immediateReturn: useFastSingleHitQuery, eoseTimeout: useFastSingleHitQuery ? SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS : 500, - globalTimeout: useFastSingleHitQuery ? SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS : 10000 + globalTimeout: tryHarderGlobalTimeout }) const event = events diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index ff5c50cd..184421e3 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -34,7 +34,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 { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal' +import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import nip66Service from './nip66.service' import type { ISigner, TSignerType } from '@/types' @@ -406,7 +406,7 @@ export class QueryService { onevent?: (evt: NEvent) => void, options?: QueryOptions ): Promise { - urls = filterReadOnlyRelaysUnlessPersonal(urls) + urls = sanitizeRelayUrlsForFetch(urls) const sanitizedFilters = sanitizeFiltersBeforeReq(filter) if (sanitizedFilters.length === 0) return [] if (options?.signal?.aborted) return [] @@ -783,7 +783,7 @@ export class QueryService { return { close: () => {} } } const originalDedupedRelays = Array.from(new Set(urls)) - let relays = filterReadOnlyRelaysUnlessPersonal(originalDedupedRelays) + let relays = sanitizeRelayUrlsForFetch(originalDedupedRelays) const stripSocialBlockedRelays = SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && diff --git a/src/services/client.service.ts b/src/services/client.service.ts index cf4c6650..d2a639bd 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -32,6 +32,7 @@ import { NIP66_DISCOVERY_RELAY_URLS, PROFILE_RELAY_URLS, READ_ONLY_RELAY_URLS, + READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS, NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' @@ -39,7 +40,7 @@ import { import { getCacheRelayUrls } from '@/lib/private-relays' import { buildPersonalRelayKeySet, - filterReadOnlyRelaysUnlessPersonal, + sanitizeRelayUrlsForFetch, isReadOnlyIndexerRelay, isReadOnlyRelayAllowedForViewer, setViewerPersonalRelayKeys @@ -296,10 +297,17 @@ function summarizeFiltersForRelayLog(filters: Filter[]): Record return out } +/** Long connect + NIP-42 boost for global index relays — not {@link READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS}. */ const READ_ONLY_RELAY_CONNECT_BOOST_URLS = new Set( - [...READ_ONLY_RELAY_URLS, ...NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS].map( - (u) => normalizeUrl(u) || u - ) + [ + ...READ_ONLY_RELAY_URLS.filter( + (u) => + !READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS.some( + (r) => (normalizeUrl(r) || r).toLowerCase() === (normalizeUrl(u) || u).toLowerCase() + ) + ), + ...NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS + ].map((u) => normalizeUrl(u) || u) ) /** Hostname (+ path when not "/") for readable publish / retry console lines. */ @@ -2520,7 +2528,7 @@ class ClientService extends EventTarget { relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } ) { const originalDedupedRelays = Array.from(new Set(urls)) - let relays = filterReadOnlyRelaysUnlessPersonal( + let relays = sanitizeRelayUrlsForFetch( originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) ) if (navigator.onLine) { @@ -3395,7 +3403,7 @@ class ClientService extends EventTarget { firstRelayResultGraceMs?: number | false } ) { - return this.queryService.query(filterReadOnlyRelaysUnlessPersonal(urls), filter, onevent, options) + return this.queryService.query(sanitizeRelayUrlsForFetch(urls), filter, onevent, options) } // Legacy query implementation removed - now delegated to QueryService @@ -3430,7 +3438,7 @@ class ClientService extends EventTarget { .filter(Boolean) ) ) - const wsOriginal = filterReadOnlyRelaysUnlessPersonal( + const wsOriginal = sanitizeRelayUrlsForFetch( originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) ) let relays = [...wsOriginal]