Browse Source

fix slow-loading threads

guard against other people's local relays
imwald
Silberengel 4 weeks ago
parent
commit
9ddcecd2ac
  1. 8
      src/components/Embedded/EmbeddedNote.tsx
  2. 19
      src/components/ReplyNoteList/index.tsx
  3. 4
      src/constants.ts
  4. 35
      src/hooks/useFetchThreadContextEvent.tsx
  5. 4
      src/lib/citation-picker-relays.ts
  6. 18
      src/lib/read-only-relay-personal.ts
  7. 45
      src/lib/relay-list-builder.ts
  8. 37
      src/lib/relay-list-sanitize.test.ts
  9. 8
      src/lib/relay-list-sanitize.ts
  10. 5
      src/lib/thread-context-relays.ts
  11. 75
      src/services/client-events.service.ts
  12. 6
      src/services/client-query.service.ts
  13. 22
      src/services/client.service.ts

8
src/components/Embedded/EmbeddedNote.tsx

@ -19,7 +19,7 @@ import nip66Service from '@/services/nip66.service'
import { navigationEventStore } from '@/services/navigation-event-store' import { navigationEventStore } from '@/services/navigation-event-store'
import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls' import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' 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 { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
@ -343,7 +343,7 @@ function EmbeddedNoteFetched({
operation: 'read', operation: 'read',
blockedRelays, blockedRelays,
applySocialKindBlockedFilter: false, applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true allowThirdPartyLocalRelays: false
}) })
) )
if (cancelled || !ev) return if (cancelled || !ev) return
@ -536,7 +536,7 @@ function buildEmbedWideRelayUrlsStatic(
relayHintsFromParent: string[], relayHintsFromParent: string[],
viewerInboxRelayUrls: string[] viewerInboxRelayUrls: string[]
): string[] { ): string[] {
return filterReadOnlyRelaysUnlessPersonal( return sanitizeRelayUrlsForFetch(
feedRelayPolicyUrls( feedRelayPolicyUrls(
[ [
{ {
@ -557,7 +557,7 @@ function buildEmbedWideRelayUrlsStatic(
{ {
operation: 'read', operation: 'read',
applySocialKindBlockedFilter: false, applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true allowThirdPartyLocalRelays: false
} }
) )
) )

19
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 discussionFeedCache from '@/services/discussion-feed-cache.service'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' 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 opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined
const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
const fromBrowsingFeed = browsingRelayUrls.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]) ...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed])
] ])
const replyBlockedRelays = [ const replyBlockedRelays = [
...(blockedRelays || []) ...(blockedRelays || [])
] ]
@ -1104,12 +1105,14 @@ function ReplyNoteList({
limit: LIMIT limit: LIMIT
}) })
const relayUrlsForThreadReq = feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], { const relayUrlsForThreadReq = sanitizeRelayUrlsForFetch(
operation: 'read', feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], {
blockedRelays: replyBlockedRelays, operation: 'read',
applySocialKindBlockedFilter: false, blockedRelays: replyBlockedRelays,
allowThirdPartyLocalRelays: true applySocialKindBlockedFilter: false,
}) allowThirdPartyLocalRelays: false
})
)
// For URL threads: stream events as they arrive from each relay so replies appear // 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. // immediately, rather than waiting up to 10 s for all relays to EOSE.

4
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_EOSE_TIMEOUT_MS = 2_000
export const HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 5_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}). */ /** 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_EOSE_TIMEOUT_MS = 14_000
export const EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 40_000 export const EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 40_000

35
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 { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -105,10 +106,23 @@ export function useFetchThreadContextEvent(
viewerPubkey ?? undefined, viewerPubkey ?? undefined,
blockedRelays blockedRelays
) )
const opts = relayUrls.length ? { relayHints: relayUrls } : undefined const threadOpts = relayUrls.length
let fetchedEvent = skipShortcuts ? { relayHints: relayUrls, threadContext: true as const }
? await eventService.fetchEventForceRetry(eventId, opts) : { threadContext: true as const }
: await eventService.fetchEvent(eventId, opts)
const fetchParentOrRoot = async () => {
if (skipShortcuts) {
return eventService.fetchEventForceRetry(eventId, threadOpts)
}
return eventService.fetchEvent(eventId, threadOpts)
}
let fetchedEvent = await Promise.race([
fetchParentOrRoot(),
new Promise<undefined>((resolve) => {
window.setTimeout(() => resolve(undefined), THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS)
})
])
if ( if (
!fetchedEvent && !fetchedEvent &&
@ -116,10 +130,13 @@ export function useFetchThreadContextEvent(
SEARCHABLE_RELAY_URLS.length > 0 SEARCHABLE_RELAY_URLS.length > 0
) { ) {
searchableAttemptedRef.current = true searchableAttemptedRef.current = true
fetchedEvent = await client.fetchEventWithExternalRelays( const searchable = sanitizeRelayUrlsForFetch([...SEARCHABLE_RELAY_URLS])
eventId, fetchedEvent = await Promise.race([
SEARCHABLE_RELAY_URLS client.fetchEventWithExternalRelays(eventId, searchable),
) new Promise<undefined>((resolve) => {
window.setTimeout(() => resolve(undefined), THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS)
})
])
if (fetchedEvent) { if (fetchedEvent) {
client.addEventToCache(fetchedEvent) client.addEventToCache(fetchedEvent)
} }

4
src/lib/citation-picker-relays.ts

@ -6,7 +6,7 @@ import {
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' 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 { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -64,5 +64,5 @@ export async function buildCitationPickerSearchRelayUrls(): Promise<string[]> {
[] []
) )
return filterReadOnlyRelaysUnlessPersonal(merged).slice(0, CITATION_SEARCH_MAX_RELAYS) return sanitizeRelayUrlsForFetch(merged).slice(0, CITATION_SEARCH_MAX_RELAYS)
} }

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

@ -1,4 +1,5 @@
import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants' import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
const personalListRequiredKeySet = new Set( const personalListRequiredKeySet = new Set(
@ -65,3 +66,20 @@ export function filterReadOnlyRelaysUnlessPersonal(
const keys = personalKeys ?? viewerPersonalRelayKeys const keys = personalKeys ?? viewerPersonalRelayKeys
return urls.filter((u) => isAllowedForKeys(u, keys)) 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>
): 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)
}

45
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 { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' 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 { getCacheRelayUrls } from './private-relays'
import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -176,6 +176,12 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
relayUrls.add(normalized) 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) => { const addHttpRelay = (url: string | undefined) => {
if (!url || !isHttpRelayUrl(url)) return if (!url || !isHttpRelayUrl(url)) return
const normalized = normalizeAnyRelayUrl(url) || url.trim() const normalized = normalizeAnyRelayUrl(url) || url.trim()
@ -220,13 +226,13 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
} }
// 1. Relay hints (highest priority - explicit hints) // 1. Relay hints (highest priority - explicit hints)
relayHints.forEach(addRelay) relayHints.filter(urlIsNonLocalForRemoteViewer).forEach(addRelayFromHints)
// 2. Relays where event was seen // 2. Relays where event was seen
seenRelays.forEach(addRelay) seenRelays.filter(urlIsNonLocalForRemoteViewer).forEach(addRelayFromHints)
// 3. Relays where containing event was found (for embedded events) // 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 // 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). // connection slots on broken personal relays before PROFILE_FETCH + FAST_READ answer).
@ -247,15 +253,15 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
try { try {
const authorRelayList = await client.peekRelayListFromStorage(authorPubkey) const authorRelayList = await client.peekRelayListFromStorage(authorPubkey)
pickAuthorNip65RelaysPreferringViewerOverlap( pickAuthorNip65RelaysPreferringViewerOverlap(
authorRelayList.write ?? [], (authorRelayList.write ?? []).filter(urlIsNonLocalForRemoteViewer),
viewerWsForAuthorOverlap, viewerWsForAuthorOverlap,
AUTHOR_NIP65_RELAY_CAP AUTHOR_NIP65_RELAY_CAP
).forEach(addRelay) ).forEach(addRelayFromHints)
pickAuthorNip65RelaysPreferringViewerOverlap( pickAuthorNip65RelaysPreferringViewerOverlap(
authorRelayList.read ?? [], (authorRelayList.read ?? []).filter(urlIsNonLocalForRemoteViewer),
viewerWsForAuthorOverlap, viewerWsForAuthorOverlap,
AUTHOR_NIP65_RELAY_CAP AUTHOR_NIP65_RELAY_CAP
).forEach(addRelay) ).forEach(addRelayFromHints)
} catch (error) { } catch (error) {
logger.warn('[RelayListBuilder] Failed to read author relay list from storage', { 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 userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey))
const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10) const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10)
const userWrite = [...(userRelayList.write || []).slice(0, 10)] const userWrite = [...(userRelayList.write || []).slice(0, 10)]
userRead.forEach((u) => { userRead.filter(urlIsNonLocalForRemoteViewer).forEach((u) => {
trackPersonal(u) trackPersonal(u)
addRelay(u) addRelayFromHints(u)
}) })
userWrite.forEach((u) => { userWrite.filter(urlIsNonLocalForRemoteViewer).forEach((u) => {
trackPersonal(u) trackPersonal(u)
addRelay(u) addRelayFromHints(u)
}) })
// Include local relays from kind 10432 // 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 // Even if not including user's own relays, still include user's inboxes for reading
try { try {
const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey))
;(userRelayList.read ?? []).slice(0, 10).forEach((u) => { ;(userRelayList.read ?? [])
trackPersonal(u) .slice(0, 10)
addRelay(u) .filter(urlIsNonLocalForRemoteViewer)
}) .forEach((u) => {
trackPersonal(u)
addRelayFromHints(u)
})
// Include local relays from kind 10432 if enabled // Include local relays from kind 10432 if enabled
if (includeLocalRelays) { if (includeLocalRelays) {
@ -367,12 +376,12 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
const merged = Array.from(relayUrls) const merged = Array.from(relayUrls)
const personalKeys = userPubkey ? buildPersonalRelayKeySet(personalRelayUrls) : undefined const personalKeys = userPubkey ? buildPersonalRelayKeySet(personalRelayUrls) : undefined
const ws = filterReadOnlyRelaysUnlessPersonal( const ws = sanitizeRelayUrlsForFetch(
feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], { feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], {
operation: 'read', operation: 'read',
blockedRelays, blockedRelays,
applySocialKindBlockedFilter: false, applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true allowThirdPartyLocalRelays: false
}), }),
personalKeys personalKeys
) )

37
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])
})
})

8
src/lib/relay-list-sanitize.ts

@ -11,6 +11,14 @@ export function urlIsNonLocalForRemoteViewer(url: string): boolean {
return true 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: * 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. * the viewer cannot reach the author's `localhost` / `192.168.*` / etc., but we used to rank them first.

5
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 { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
@ -36,7 +37,9 @@ export async function buildThreadContextFetchRelayUrls(
blockedRelays: string[] = [] blockedRelays: string[] = []
): Promise<string[]> { ): Promise<string[]> {
const tagHints = relayHintsFromThreadETag(targetTag) const tagHints = relayHintsFromThreadETag(targetTag)
const threadRelayHints = [...new Set([...tagHints, ...relayHintsFromEventTags(contextEvent)])] const threadRelayHints = sanitizeRelayUrlsForFetch([
...new Set([...tagHints, ...relayHintsFromEventTags(contextEvent)])
])
const opAuthorPubkey = pubkeyFromThreadETag(targetTag) const opAuthorPubkey = pubkeyFromThreadETag(targetTag)
return buildReplyReadRelayList(opAuthorPubkey, viewerPubkey, blockedRelays, threadRelayHints) return buildReplyReadRelayList(opAuthorPubkey, viewerPubkey, blockedRelays, threadRelayHints)
} }

75
src/services/client-events.service.ts

@ -5,11 +5,14 @@ import {
EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS, EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS,
HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS, HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS,
HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS, HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS,
THREAD_CONTEXT_TRY_HARDER_GLOBAL_TIMEOUT_MS,
isDocumentRelayKind, isDocumentRelayKind,
NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT,
SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS, SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS,
SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS
} from '@/constants' } from '@/constants'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
collectEmbeddedEventPrefetchTargets, collectEmbeddedEventPrefetchTargets,
@ -351,7 +354,10 @@ export class EventService {
* Fetch single event by ID (hex, note1, nevent1, naddr1). * Fetch single event by ID (hex, note1, nevent1, naddr1).
* Optional `relayHints` (e.g. from the parent articles tags) are merged first so REQ targets the same relays that likely hold the embed. * Optional `relayHints` (e.g. from the parent articles tags) are merged first so REQ targets the same relays that likely hold the embed.
*/ */
async fetchEvent(id: string, opts?: { relayHints?: string[] }): Promise<NEvent | undefined> { async fetchEvent(
id: string,
opts?: { relayHints?: string[]; /** Shorter timeouts; do not block note UI on parent/root. */ threadContext?: boolean }
): Promise<NEvent | undefined> {
const trimmed = id.trim() const trimmed = id.trim()
let hexId: string | undefined let hexId: string | undefined
let pointerHasFetchHints = false let pointerHasFetchHints = false
@ -402,7 +408,7 @@ export class EventService {
} }
} }
if (opts?.relayHints?.length || pointerHasFetchHints) { if (opts?.relayHints?.length || pointerHasFetchHints) {
const hinted = await this._fetchEvent(trimmed, opts?.relayHints) const hinted = await this._fetchEvent(trimmed, opts?.relayHints, opts?.threadContext)
if ( if (
hinted && hinted &&
!shouldDropEventOnIngest(hinted, hexId ? { explicitNoteLookupHexId: hexId } : undefined) !shouldDropEventOnIngest(hinted, hexId ? { explicitNoteLookupHexId: hexId } : undefined)
@ -447,7 +453,10 @@ export class EventService {
/** /**
* Force retry fetch event * Force retry fetch event
*/ */
async fetchEventForceRetry(eventId: string, opts?: { relayHints?: string[] }): Promise<NEvent | undefined> { async fetchEventForceRetry(
eventId: string,
opts?: { relayHints?: string[]; threadContext?: boolean }
): Promise<NEvent | undefined> {
this.clearDataloaderCacheForFetchId(eventId) this.clearDataloaderCacheForFetchId(eventId)
return this.fetchEvent(eventId, opts) return this.fetchEvent(eventId, opts)
} }
@ -1128,17 +1137,24 @@ export class EventService {
for (const tag of event.tags) { for (const tag of event.tags) {
if (tagTypesWithRelayHints.includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') { if (tagTypesWithRelayHints.includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') {
const hint = tag[2] const hint = tag[2]
if (hint.startsWith('wss://') || hint.startsWith('ws://')) { if (
(hint.startsWith('wss://') || hint.startsWith('ws://')) &&
urlIsNonLocalForRemoteViewer(hint)
) {
hints.add(hint) hints.add(hint)
} }
} }
} }
// Also check for dedicated "relays" tag // 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) { if (relaysTag && relaysTag.length > 1) {
relaysTag.slice(1).forEach(url => { relaysTag.slice(1).forEach((url) => {
if (typeof url === 'string' && (url.startsWith('wss://') || url.startsWith('ws://'))) { if (
typeof url === 'string' &&
(url.startsWith('wss://') || url.startsWith('ws://')) &&
urlIsNonLocalForRemoteViewer(url)
) {
hints.add(url) hints.add(url)
} }
}) })
@ -1165,18 +1181,20 @@ export class EventService {
/** /**
* Private: Fetch event by ID (internal implementation) * Private: Fetch event by ID (internal implementation)
*/ */
private async _fetchEvent(id: string, extraRelayHints?: string[]): Promise<NEvent | undefined> { private async _fetchEvent(
id: string,
extraRelayHints?: string[],
threadContext = false
): Promise<NEvent | undefined> {
let filter: Filter | undefined let filter: Filter | undefined
let relays: string[] = [] let relays: string[] = []
let authorHintPubkey: string | undefined 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) { if (extraRelayHints?.length) {
relays = [ relays = normalizeRelayList(extraRelayHints)
...new Set(
extraRelayHints
.map((u) => normalizeUrl(u))
.filter((u): u is string => Boolean(u))
)
]
} }
if (/^[0-9a-f]{64}$/i.test(id)) { if (/^[0-9a-f]{64}$/i.test(id)) {
@ -1189,7 +1207,7 @@ export class EventService {
break break
case 'nevent': case 'nevent':
filter = { ids: [data.id], limit: 1 } 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)) { if (data.author && /^[0-9a-f]{64}$/i.test(data.author)) {
authorHintPubkey = data.author.toLowerCase() authorHintPubkey = data.author.toLowerCase()
} }
@ -1203,7 +1221,7 @@ export class EventService {
'#d': [ident], '#d': [ident],
limit: 1 limit: 1
} }
if (data.relays) relays = [...new Set([...relays, ...data.relays])] if (data.relays) relays = normalizeRelayList([...relays, ...data.relays])
break break
} }
} }
@ -1240,7 +1258,7 @@ export class EventService {
// Extract relay hints from cached event's tags (e, a, q tags) // Extract relay hints from cached event's tags (e, a, q tags)
const eventRelayHints = this.extractRelayHintsFromEvent(cached) const eventRelayHints = this.extractRelayHintsFromEvent(cached)
if (eventRelayHints.length > 0) { if (eventRelayHints.length > 0) {
relays = [...new Set([...relays, ...eventRelayHints])] relays = normalizeRelayList([...relays, ...eventRelayHints])
} }
return cached return cached
} }
@ -1269,14 +1287,21 @@ export class EventService {
// Extract relay hints from found event's tags (e, a, q tags) // Extract relay hints from found event's tags (e, a, q tags)
const eventRelayHints = this.extractRelayHintsFromEvent(event) const eventRelayHints = this.extractRelayHintsFromEvent(event)
if (eventRelayHints.length > 0) { if (eventRelayHints.length > 0) {
relays = [...new Set([...relays, ...eventRelayHints])] relays = normalizeRelayList([...relays, ...eventRelayHints])
} }
return event return event
} }
} }
// Always try comprehensive relay list (author's outboxes + user's inboxes + hints + seen + defaults) // 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)) { if (event && !shouldDropEventOnIngest(event, ingestOpts)) {
this.addEventToCache(event, ingestOpts) this.addEventToCache(event, ingestOpts)
return event return event
@ -1324,7 +1349,8 @@ export class EventService {
filter: Filter, filter: Filter,
alreadyFetchedFromBigRelays = false, alreadyFetchedFromBigRelays = false,
authorHintPubkey?: string, authorHintPubkey?: string,
ingestOpts?: ShouldDropEventOnIngestOptions ingestOpts?: ShouldDropEventOnIngestOptions,
threadContext = false
): Promise<NEvent | undefined> { ): Promise<NEvent | undefined> {
// Get seen relays if we have an event ID // Get seen relays if we have an event ID
const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : [] 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)) (filter.authors?.length === 1 && Array.isArray(filter['#d']) && filter['#d'].length >= 1))
const useFastSingleHitQuery = isSingleEventById || isReplaceableCoordinateFetch 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, { const events = await this.queryService.query(relayUrls, filter, undefined, {
immediateReturn: useFastSingleHitQuery, immediateReturn: useFastSingleHitQuery,
eoseTimeout: useFastSingleHitQuery ? SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS : 500, 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 const event = events

6
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 type { Filter, Event as NEvent } from 'nostr-tools'
import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools' import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools'
import type { AbstractRelay } from 'nostr-tools/abstract-relay' 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 nip66Service from './nip66.service'
import type { ISigner, TSignerType } from '@/types' import type { ISigner, TSignerType } from '@/types'
@ -406,7 +406,7 @@ export class QueryService {
onevent?: (evt: NEvent) => void, onevent?: (evt: NEvent) => void,
options?: QueryOptions options?: QueryOptions
): Promise<NEvent[]> { ): Promise<NEvent[]> {
urls = filterReadOnlyRelaysUnlessPersonal(urls) urls = sanitizeRelayUrlsForFetch(urls)
const sanitizedFilters = sanitizeFiltersBeforeReq(filter) const sanitizedFilters = sanitizeFiltersBeforeReq(filter)
if (sanitizedFilters.length === 0) return [] if (sanitizedFilters.length === 0) return []
if (options?.signal?.aborted) return [] if (options?.signal?.aborted) return []
@ -783,7 +783,7 @@ export class QueryService {
return { close: () => {} } return { close: () => {} }
} }
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
let relays = filterReadOnlyRelaysUnlessPersonal(originalDedupedRelays) let relays = sanitizeRelayUrlsForFetch(originalDedupedRelays)
const stripSocialBlockedRelays = const stripSocialBlockedRelays =
SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&

22
src/services/client.service.ts

@ -32,6 +32,7 @@ import {
NIP66_DISCOVERY_RELAY_URLS, NIP66_DISCOVERY_RELAY_URLS,
PROFILE_RELAY_URLS, PROFILE_RELAY_URLS,
READ_ONLY_RELAY_URLS, READ_ONLY_RELAY_URLS,
READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS,
NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS, NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS,
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
@ -39,7 +40,7 @@ import {
import { getCacheRelayUrls } from '@/lib/private-relays' import { getCacheRelayUrls } from '@/lib/private-relays'
import { import {
buildPersonalRelayKeySet, buildPersonalRelayKeySet,
filterReadOnlyRelaysUnlessPersonal, sanitizeRelayUrlsForFetch,
isReadOnlyIndexerRelay, isReadOnlyIndexerRelay,
isReadOnlyRelayAllowedForViewer, isReadOnlyRelayAllowedForViewer,
setViewerPersonalRelayKeys setViewerPersonalRelayKeys
@ -296,10 +297,17 @@ function summarizeFiltersForRelayLog(filters: Filter[]): Record<string, unknown>
return out 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( 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. */ /** 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 } relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
let relays = filterReadOnlyRelaysUnlessPersonal( let relays = sanitizeRelayUrlsForFetch(
originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
) )
if (navigator.onLine) { if (navigator.onLine) {
@ -3395,7 +3403,7 @@ class ClientService extends EventTarget {
firstRelayResultGraceMs?: number | false 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 // Legacy query implementation removed - now delegated to QueryService
@ -3430,7 +3438,7 @@ class ClientService extends EventTarget {
.filter(Boolean) .filter(Boolean)
) )
) )
const wsOriginal = filterReadOnlyRelaysUnlessPersonal( const wsOriginal = sanitizeRelayUrlsForFetch(
originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
) )
let relays = [...wsOriginal] let relays = [...wsOriginal]

Loading…
Cancel
Save