From 18f307c1a9080d41340f9678d15403892a877417 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 16 May 2026 20:39:46 +0200 Subject: [PATCH] more efficiency gains --- src/components/NoteList/index.tsx | 14 +- src/components/ReplyNoteList/index.tsx | 20 +- src/constants.ts | 1 - src/lib/relay-list-builder.test.ts | 17 ++ src/lib/relay-list-builder.ts | 202 ++++++++++++++++-- .../client-replaceable-events.service.ts | 117 +++------- src/services/client.service.ts | 7 +- src/services/note-stats.service.ts | 123 ++++------- 8 files changed, 280 insertions(+), 221 deletions(-) create mode 100644 src/lib/relay-list-builder.test.ts diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index bdae7f43..832cb2e1 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -23,7 +23,7 @@ import { isSpellSubRequestsSameFiltersDifferentRelays } from '@/lib/spell-feed-request-identity' import logger from '@/lib/logger' -import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' @@ -1520,22 +1520,12 @@ const NoteList = forwardRef( void (async () => { if (gen !== feedProfileBatchGenRef.current) return - const contextualReadRelays = Array.from( - new Set( - subRequestsRef.current - .flatMap((r) => r.urls) - .map((u) => normalizeAnyRelayUrl(u) || normalizeUrl(u) || u.trim()) - .filter(Boolean) - ) - ) const chunks: string[][] = [] for (let i = 0; i < need.length; i += FEED_PROFILE_CHUNK) { chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK)) } const settled = await Promise.allSettled( - chunks.map((chunk) => - client.fetchProfilesForPubkeys(chunk, { contextualReadRelays }) - ) + chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk)) ) if (gen !== feedProfileBatchGenRef.current) return diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 3b33d89f..5f99a4d7 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -658,20 +658,6 @@ function ReplyNoteList({ return zapsThenTimeSorted(merged, 'desc') }, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo, event.kind]) - /** Relays that actually delivered thread rows — used to resolve kind-0 when profile mirrors do not replicate them. */ - const threadProfileContextRelays = useMemo(() => { - const s = new Set() - const addEv = (e: NEvent) => { - for (const u of client.getSeenEventRelayUrls(e.id)) { - const n = normalizeAnyRelayUrl(u) || u - if (n) s.add(n) - } - } - addEv(event) - for (const e of mergedFeed) addEv(e) - return [...s] - }, [event, mergedFeed]) - useEffect(() => { if (!rootInfo) return const toAdd = filteredQuoteEvents.filter((evt) => @@ -756,13 +742,12 @@ function ReplyNoteList({ }) void (async () => { - const contextualReadRelays = threadProfileContextRelays const chunks: string[][] = [] for (let i = 0; i < need.length; i += THREAD_PROFILE_CHUNK) { chunks.push(need.slice(i, i + THREAD_PROFILE_CHUNK)) } const settled = await Promise.allSettled( - chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk, { contextualReadRelays })) + chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk)) ) if (gen !== threadProfileBatchGenRef.current) return @@ -804,8 +789,7 @@ function ReplyNoteList({ event, mergedFeed, parentNoteFeed?.profiles, - parentNoteFeed?.pendingPubkeys, - threadProfileContextRelays + parentNoteFeed?.pendingPubkeys ]) const [timelineKey] = useState(undefined) diff --git a/src/constants.ts b/src/constants.ts index 2d2f00a0..8ec22d4c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -411,7 +411,6 @@ export const READ_ONLY_RELAY_URLS = [ 'wss://relaypag.es', 'wss://relay.noswhere.com', 'wss://search.nos.today', - 'wss://trending.nostr.wine', 'wss://relay.nip46.com', 'wss://filter.nostr.wine', 'wss://primus.nostr1.com' diff --git a/src/lib/relay-list-builder.test.ts b/src/lib/relay-list-builder.test.ts new file mode 100644 index 00000000..59204e43 --- /dev/null +++ b/src/lib/relay-list-builder.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { pickAuthorNip65RelaysPreferringViewerOverlap } from './relay-list-builder' + +describe('pickAuthorNip65RelaysPreferringViewerOverlap', () => { + it('prefers relays shared with the viewer, capped at max', () => { + const author = [ + 'wss://author-only.example/', + 'wss://shared.example/', + 'wss://author-two.example/' + ] + const viewer = ['wss://shared.example/', 'wss://viewer-only.example/'] + expect(pickAuthorNip65RelaysPreferringViewerOverlap(author, viewer, 2)).toEqual([ + 'wss://shared.example/', + 'wss://author-only.example/' + ]) + }) +}) diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 734f6126..9832cfde 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -20,6 +20,46 @@ import client from '@/services/client.service' import logger from '@/lib/logger' import type { Event } from 'nostr-tools' +/** Max author NIP-65 read / write URLs merged into comprehensive read lists (shared with viewer first). */ +export const AUTHOR_NIP65_RELAY_CAP = 2 + +function relayKey(url: string): string { + return (normalizeUrl(url) || normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() +} + +/** + * Up to `max` author relays, preferring URLs that also appear on the viewer's NIP-65 read/write lists. + */ +export function pickAuthorNip65RelaysPreferringViewerOverlap( + authorUrls: readonly string[], + viewerUrls: readonly string[], + max: number +): string[] { + if (max <= 0) return [] + const viewerKeys = new Set(viewerUrls.map(relayKey).filter(Boolean)) + const shared: string[] = [] + const authorOnly: string[] = [] + const seen = new Set() + for (const raw of authorUrls) { + const k = relayKey(raw) + if (!k || seen.has(k)) continue + seen.add(k) + if (viewerKeys.has(k)) shared.push(normalizeAnyRelayUrl(raw) || raw.trim()) + else authorOnly.push(normalizeAnyRelayUrl(raw) || raw.trim()) + } + const out: string[] = [] + const push = (u: string) => { + const k = relayKey(u) + if (!k || out.some((x) => relayKey(x) === k)) return + out.push(u) + } + for (const u of [...shared, ...authorOnly]) { + if (out.length >= max) break + push(u) + } + return out +} + function dedupeNormalizedRelayUrls(urls: string[]): string[] { const seen = new Set() const out: string[] = [] @@ -78,6 +118,13 @@ export interface RelayListBuilderOptions { * behind broken personal relays under the global connection cap. */ preferPublicReadRelaysEarly?: boolean + /** + * When set, skips {@link viewerUsesGlobalRelayDefaults} and forces fast-read bootstrap on/off. + * Otherwise fast-read is omitted for logged-in users who have favorites or NIP-65 configured. + */ + useGlobalRelayDefaults?: boolean + /** Append the viewer's kind 10243 HTTP index relays when present (not subject to WS-only `addRelay`). */ + includeViewerHttpIndexRelays?: boolean } /** @@ -98,10 +145,13 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio blockedRelays = [], includeLocalRelays = true, includeFavoriteRelays = false, - preferPublicReadRelaysEarly = false + preferPublicReadRelaysEarly = false, + useGlobalRelayDefaults: useGlobalRelayDefaultsOption, + includeViewerHttpIndexRelays = true } = options const relayUrls = new Set() + const httpRelayUrls: string[] = [] const normalizedBlocked = new Set( (blockedRelays || []).map(url => { const normalized = normalizeUrl(url) || url @@ -120,6 +170,49 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio relayUrls.add(normalized) } + const addHttpRelay = (url: string | undefined) => { + if (!url || !isHttpRelayUrl(url)) return + const normalized = normalizeAnyRelayUrl(url) || url.trim() + if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) return + if (httpRelayUrls.some((u) => relayKey(u) === relayKey(normalized))) return + httpRelayUrls.push(normalized) + } + + let viewerRelayListForShare: { read?: string[]; write?: string[]; httpRead?: string[]; httpWrite?: string[] } | null = + null + if (userPubkey) { + try { + viewerRelayListForShare = await client.peekRelayListFromStorage(userPubkey) + } catch { + viewerRelayListForShare = null + } + } + const viewerWsForAuthorOverlap = [ + ...(viewerRelayListForShare?.read ?? []), + ...(viewerRelayListForShare?.write ?? []) + ] + + let effectiveIncludeFastRead = includeFastReadRelays + if (userPubkey && includeFastReadRelays) { + if (useGlobalRelayDefaultsOption !== undefined) { + effectiveIncludeFastRead = useGlobalRelayDefaultsOption + } else { + try { + const fav = + includeFavoriteRelays && userPubkey + ? await client.fetchFavoriteRelays(userPubkey).catch(() => [] as string[]) + : [] + effectiveIncludeFastRead = viewerUsesGlobalRelayDefaults({ + viewerPubkey: userPubkey, + favoriteRelayUrls: fav, + relayList: viewerRelayListForShare ?? undefined + }) + } catch { + effectiveIncludeFastRead = true + } + } + } + // 1. Relay hints (highest priority - explicit hints) relayHints.forEach(addRelay) @@ -135,7 +228,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio if (includeProfileFetchRelays) { PROFILE_RELAY_URLS.forEach(addRelay) } - if (includeFastReadRelays) { + if (effectiveIncludeFastRead) { FAST_READ_RELAY_URLS.forEach(addRelay) } if (includeSearchableRelays) { @@ -147,10 +240,16 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio if (authorPubkey) { try { const authorRelayList = await client.peekRelayListFromStorage(authorPubkey) - const authorOutboxes = [...(authorRelayList.write || []).slice(0, 10)] - authorOutboxes.forEach(addRelay) - const authorInboxes = userReadRelaysWithHttp(authorRelayList).slice(0, 10) - authorInboxes.forEach(addRelay) + pickAuthorNip65RelaysPreferringViewerOverlap( + authorRelayList.write ?? [], + viewerWsForAuthorOverlap, + AUTHOR_NIP65_RELAY_CAP + ).forEach(addRelay) + pickAuthorNip65RelaysPreferringViewerOverlap( + authorRelayList.read ?? [], + viewerWsForAuthorOverlap, + AUTHOR_NIP65_RELAY_CAP + ).forEach(addRelay) } catch (error) { logger.warn('[RelayListBuilder] Failed to read author relay list from storage', { error }) } @@ -159,7 +258,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio // 5. User's own relays (for profiles/metadata) if (includeUserOwnRelays && userPubkey) { try { - const userRelayList = await client.peekRelayListFromStorage(userPubkey) + const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10) const userWrite = [...(userRelayList.write || []).slice(0, 10)] userRead.forEach(addRelay) @@ -186,10 +285,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } else if (userPubkey) { // Even if not including user's own relays, still include user's inboxes for reading try { - const userRelayList = await client.peekRelayListFromStorage(userPubkey) - userReadRelaysWithHttp(userRelayList) - .slice(0, 10) - .forEach(addRelay) + const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) + ;(userRelayList.read ?? []).slice(0, 10).forEach(addRelay) // Include local relays from kind 10432 if enabled if (includeLocalRelays) { @@ -216,12 +313,12 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } // 7. Fast read relays (fallback) - if (includeFastReadRelays && !preferPublicReadRelaysEarly) { + if (effectiveIncludeFastRead && !preferPublicReadRelaysEarly) { FAST_READ_RELAY_URLS.forEach(addRelay) } // 8. Extra fast-read bootstrap mirrors (call sites use legacy `includeFastWriteRelays`) - if (includeFastWriteRelays) { + if (includeFastWriteRelays && effectiveIncludeFastRead) { FAST_READ_RELAY_URLS.forEach(addRelay) } @@ -230,13 +327,89 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio SEARCHABLE_RELAY_URLS.forEach(addRelay) } + if (includeViewerHttpIndexRelays && userPubkey && viewerRelayListForShare) { + const hasHttp = + (viewerRelayListForShare.httpRead?.length ?? 0) > 0 || + (viewerRelayListForShare.httpWrite?.length ?? 0) > 0 + if (hasHttp) { + ;[...(viewerRelayListForShare.httpRead ?? []), ...(viewerRelayListForShare.httpWrite ?? [])].forEach( + addHttpRelay + ) + } + } + const merged = Array.from(relayUrls) - return feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], { + const ws = feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], { + operation: 'read', + blockedRelays, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + }) + if (httpRelayUrls.length === 0) return ws + const seen = new Set(ws.map(relayKey)) + const out = [...ws] + for (const u of httpRelayUrls) { + const k = relayKey(u) + if (!k || seen.has(k)) continue + seen.add(k) + out.push(u) + } + return out +} + +/** + * Batched kind-0 / profile hydration: {@link PROFILE_RELAY_URLS} plus the logged-in viewer's own relays only. + */ +export async function buildProfileAndUserRelayList( + userPubkey: string | null | undefined, + blockedRelays: string[] = [] +): Promise { + const profileWs = dedupeNormalizedRelayUrls([...PROFILE_RELAY_URLS]) + if (!userPubkey?.trim()) { + return feedRelayPolicyUrls([{ source: 'profile-fetch', urls: profileWs }], { + operation: 'read', + blockedRelays, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + }) + } + const userStack = await buildComprehensiveRelayList({ + userPubkey, + includeUserOwnRelays: true, + includeProfileFetchRelays: false, + includeFastReadRelays: false, + includeFastWriteRelays: false, + includeSearchableRelays: false, + includeFavoriteRelays: true, + includeLocalRelays: true, + includeViewerHttpIndexRelays: true, + blockedRelays + }) + const httpPart = userStack.filter((u) => isHttpRelayUrl(u)) + const wsPart = userStack.filter((u) => !isHttpRelayUrl(u)) + const seen = new Set() + const mergedWs: string[] = [] + for (const u of [...profileWs, ...wsPart]) { + const k = relayKey(u) + if (!k || seen.has(k)) continue + seen.add(k) + mergedWs.push(u) + } + const policyWs = feedRelayPolicyUrls([{ source: 'profile-fetch', urls: mergedWs }], { operation: 'read', blockedRelays, applySocialKindBlockedFilter: false, allowThirdPartyLocalRelays: true }) + const outSeen = new Set(policyWs.map(relayKey)) + const out = [...policyWs] + for (const u of httpPart) { + const k = relayKey(u) + if (!k || outSeen.has(k)) continue + outSeen.add(k) + out.push(u) + } + return out } /** @@ -427,6 +600,7 @@ export async function buildReplyReadRelayList( relayHints: threadRelayHints, includeUserOwnRelays: Boolean(userPubkey), includeFastReadRelays: useGlobal, + useGlobalRelayDefaults: useGlobal, includeSearchableRelays: false, includeLocalRelays: true, includeFavoriteRelays: Boolean(userPubkey), diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 124784f0..337efa79 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -23,7 +23,11 @@ import indexedDb from './indexed-db.service' import type { QueryService } from './client-query.service' import logger from '@/lib/logger' import client from './client.service' -import { buildComprehensiveRelayList, buildExploreProfileAndUserRelayList } from '@/lib/relay-list-builder' +import { + buildComprehensiveRelayList, + buildExploreProfileAndUserRelayList, + buildProfileAndUserRelayList +} from '@/lib/relay-list-builder' import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility' import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' @@ -128,16 +132,28 @@ export class ReplaceableEventService { containingEventRelays: string[] = [] ): Promise { const userPubkey = client.pubkey - const isProfileOrMetadata = kind === kinds.Metadata || kind === kinds.RelayList - - // Use the comprehensive relay list builder + if (kind === kinds.Metadata) { + const profileStack = await buildProfileAndUserRelayList(userPubkey) + const hintLayer = [...relayHints, ...containingEventRelays] + if (hintLayer.length === 0) return profileStack + return Array.from( + new Set([ + ...profileStack, + ...hintLayer + .map((u) => normalizeAnyRelayUrl(u) || normalizeUrl(u) || u.trim()) + .filter((u): u is string => !!u && !isHttpRelayUrl(u)) + ]) + ) + } + + const isProfileOrMetadata = kind === kinds.RelayList return buildComprehensiveRelayList({ authorPubkey, userPubkey, relayHints, containingEventRelays, - includeUserOwnRelays: isProfileOrMetadata, // For profiles/metadata, include user's own relays - includeProfileFetchRelays: isProfileOrMetadata, // For profiles/metadata, include PROFILE_RELAY_URLS + includeUserOwnRelays: isProfileOrMetadata, + includeProfileFetchRelays: isProfileOrMetadata, includeFastReadRelays: true, includeLocalRelays: true }) @@ -529,26 +545,10 @@ export class ReplaceableEventService { // (profile + FAST_READ + viewer read/write/local when logged in). let relayUrls: string[] if (kind === kinds.Metadata) { - const userPk = client.pubkey - if (userPk) { - try { - relayUrls = await buildComprehensiveRelayList({ - userPubkey: userPk, - includeUserOwnRelays: false, - includeProfileFetchRelays: true, - includeFastReadRelays: true, - includeFavoriteRelays: true, - includeLocalRelays: true, - /** Many users publish kind 0 to NIP-65 write relays; batch path includes public read mirrors via {@link buildComprehensiveRelayList}. */ - includeFastWriteRelays: false, - includeSearchableRelays: false, - preferPublicReadRelaysEarly: true - }) - } catch { - relayUrls = Array.from(new Set([...PROFILE_RELAY_URLS, ...FAST_READ_RELAY_URLS])) - } - } else { - relayUrls = Array.from(new Set([...PROFILE_RELAY_URLS, ...FAST_READ_RELAY_URLS])) + try { + relayUrls = await buildProfileAndUserRelayList(client.pubkey) + } catch { + relayUrls = [...PROFILE_RELAY_URLS] } } else if (kind === ExtendedKind.FAVORITE_RELAYS) { relayUrls = await buildExploreProfileAndUserRelayList(client.pubkey) @@ -1047,20 +1047,13 @@ export class ReplaceableEventService { return getProfileFromEvent(event) } - /** - * Fetch profiles for multiple pubkeys - * @param contextualReadRelays Optional relays used for the surrounding feed/thread REQ — queried for kind-0 - * when default profile mirrors miss (e.g. metadata only on a community relay). - */ - async fetchProfilesForPubkeys( - pubkeys: string[], - options?: { contextualReadRelays?: string[] } - ): Promise { + /** Fetch profiles for multiple pubkeys (profile mirrors + viewer's own relays only). */ + async fetchProfilesForPubkeys(pubkeys: string[]): Promise { const deduped = Array.from(new Set(pubkeys.filter((p) => p && p.length === 64))) if (deduped.length === 0) return [] try { return await racePromiseWithTimeout( - this.fetchProfilesForPubkeysBody(deduped, options), + this.fetchProfilesForPubkeysBody(deduped), FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS, 'fetchProfilesForPubkeys' ) @@ -1121,10 +1114,7 @@ export class ReplaceableEventService { return profiles } - private async fetchProfilesForPubkeysBody( - deduped: string[], - options?: { contextualReadRelays?: string[] } - ): Promise { + private async fetchProfilesForPubkeysBody(deduped: string[]): Promise { let events = await this.fetchReplaceableEventsFromProfileFetchRelays(deduped, kinds.Metadata) const gapIdx: number[] = [] for (let i = 0; i < deduped.length; i++) { @@ -1177,53 +1167,6 @@ export class ReplaceableEventService { ) } - const stillMissingIdx: number[] = [] - for (let i = 0; i < deduped.length; i++) { - if (!events[i]) stillMissingIdx.push(i) - } - if (stillMissingIdx.length > 0 && options?.contextualReadRelays?.length) { - const urls = Array.from( - new Set( - options.contextualReadRelays - .map((u) => normalizeAnyRelayUrl(u) || normalizeUrl(u) || u.trim()) - .filter((u): u is string => !!u && !isHttpRelayUrl(u)) - ) - ) - if (urls.length > 0) { - const authors = stillMissingIdx.map((i) => deduped[i]!) - try { - const sanitizedUrls = stripLocalNetworkRelaysForWssReq(urls) - const withAggr = prependAggrNostrLandIfViewerEligible(sanitizedUrls) - if (withAggr.length > 0) { - const evs = await this.queryService.query( - withAggr, - { - kinds: [kinds.Metadata], - authors, - limit: Math.min(Math.max(authors.length * 2, authors.length), 500) - } as Filter, - undefined, - { - firstRelayResultGraceMs: false, - globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, - eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, - replaceableRace: false - } - ) - for (const ev of evs) { - if (ev.kind !== kinds.Metadata || shouldDropEventOnIngest(ev)) continue - const ix = deduped.findIndex((p) => p.toLowerCase() === ev.pubkey.toLowerCase()) - if (ix >= 0 && !events[ix]) { - events[ix] = ev - } - } - } - } catch { - /* best-effort */ - } - } - } - return this.profilesFromMetadataEvents(deduped, events) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 7f862de6..fcaa7537 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -4094,11 +4094,8 @@ class ClientService extends EventTarget { return this.replaceableEventService.fetchProfile(id, skipCache) } - async fetchProfilesForPubkeys( - pubkeys: string[], - options?: { contextualReadRelays?: string[] } - ): Promise { - return this.replaceableEventService.fetchProfilesForPubkeys(pubkeys, options) + async fetchProfilesForPubkeys(pubkeys: string[]): Promise { + return this.replaceableEventService.fetchProfilesForPubkeys(pubkeys) } async getProfileFromIndexedDB(id: string): Promise { diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index b3868d12..e8afc0e5 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -1,9 +1,4 @@ -import { - ExtendedKind, - FAST_READ_RELAY_URLS, - NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, - SEARCHABLE_RELAY_URLS -} from '@/constants' +import { ExtendedKind, NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT } from '@/constants' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { getNip18RepostTargetId, @@ -27,16 +22,15 @@ import { } from '@/lib/rss-article' import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags' import type { TThreadRootRef } from '@/lib/thread-reply-root-match' -import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { buildComprehensiveRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' +import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { getEmojiInfosFromEmojiTags, getNip25ReactionTargetHexFromTags, tagNameEquals } from '@/lib/tag' -import { normalizeAnyRelayUrl } from '@/lib/url' import client, { eventService } from '@/services/client.service' -import { TEmoji, type TRelayList } from '@/types' +import { TEmoji } from '@/types' import dayjs from 'dayjs' import { Event, Filter, kinds } from 'nostr-tools' @@ -585,77 +579,45 @@ class NoteStatsService { } } - /** - * Build relay list for note stats: SEARCHABLE + FAST_READ + optional user favorites + seen relays + - * `e`-tag hints on the note + hints from session-cached referrers + author NIP-65 read (slice 10). - */ + /** {@link buildComprehensiveRelayList} for reactions/reposts/zaps on a note (thread hints, capped author NIP-65). */ private async buildNoteStatsRelayList(event: Event, favoriteRelays?: string[] | null): Promise { - const seen = new Set() - - const add = (url: string | undefined) => { - if (!url) return - // Must use normalizeAnyRelayUrl, not normalizeUrl: the latter converts http(s):// - // index relay URLs into ws(s):// which then hit the WebSocket pool. - const n = normalizeAnyRelayUrl(url) - if (n) seen.add(n) - } - - // 1. Search / discovery relay set (includes read-only index mirrors; see READ_ONLY_RELAY_URLS in constants) - SEARCHABLE_RELAY_URLS.forEach(add) - - // 2. Default fast read set (includes e.g. theforest — not in SEARCHABLE) - FAST_READ_RELAY_URLS.forEach(add) - - // 3. User's favorite relays (spell feed / sidebar) — was previously ignored - favoriteRelays?.forEach(add) - - // 4. Relay(s) where the event was seen - client.getSeenEventRelayUrls(event.id).forEach(add) + const me = client.pubkey?.trim() + const relayHints = [ + ...relayHintsFromEventTags(event), + ...client.getSeenEventRelayUrls(event.id), + ...client.eventService.getSessionRelayHintsForHexTarget(event.id), + ...(favoriteRelays ?? []) + ] - // 5. NIP-10 `e`-tag relay hints on the note itself (often where replies/reactions to it were published) - for (const t of event.tags) { - if ((t[0] === 'e' || t[0] === 'E') && t[2]?.trim()) { - add(t[2]) + let useGlobal = true + if (me) { + try { + const [fav, rl] = await Promise.all([ + client.fetchFavoriteRelays(me).catch(() => [] as string[]), + client.peekRelayListFromStorage(me) + ]) + useGlobal = viewerUsesGlobalRelayDefaults({ + viewerPubkey: me, + favoriteRelayUrls: fav, + relayList: rl ?? undefined + }) + } catch { + useGlobal = true } } - // 6. Session cache (e.g. notifications): events that reference this id with a relay hint - client.eventService.getSessionRelayHintsForHexTarget(event.id).forEach(add) - - const emptyViewerRl: TRelayList = { - write: [], - read: [], - originalRelays: [], - httpRead: [], - httpWrite: [], - httpOriginalRelays: [] - } - const me = client.pubkey?.trim() - const [authorRelayList, viewerRelayList] = await Promise.all([ - Promise.race([ - client.fetchRelayList(event.pubkey), - new Promise<{ read?: string[] }>((r) => setTimeout(() => r({}), 1500)) - ]).catch(() => undefined), - me - ? Promise.race([ - client.fetchRelayList(me), - new Promise((r) => setTimeout(() => r(emptyViewerRl), 1500)) - ]).catch(() => undefined) - : Promise.resolve(undefined) - ]) - // 7. Author's inboxes (read relays from kind 10002) - if (authorRelayList) { - userReadRelaysWithHttp(authorRelayList).slice(0, 10).forEach(add) - } - // 8. Logged-in viewer's inboxes (NIP-65 read + kind 10243 http read) — same events often land on personal relays. - if (viewerRelayList) { - userReadRelaysWithHttp(viewerRelayList).slice(0, 12).forEach(add) - } - - return feedRelayPolicyUrls([{ source: 'fallback', urls: Array.from(seen) }], { - operation: 'read', - applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true + return buildComprehensiveRelayList({ + authorPubkey: event.pubkey, + userPubkey: me, + relayHints, + includeUserOwnRelays: Boolean(me), + includeFavoriteRelays: Boolean(me), + includeFastReadRelays: useGlobal, + useGlobalRelayDefaults: useGlobal, + includeProfileFetchRelays: false, + includeSearchableRelays: false, + includeLocalRelays: true, + includeViewerHttpIndexRelays: true }) } @@ -1036,14 +998,7 @@ class NoteStatsService { if (!/^[0-9a-f]{64}$/i.test(rootHex)) return const hintRelays = client.eventService.getSessionRelayHintsForHexTarget(rootHex) - const urls = feedRelayPolicyUrls( - [{ source: 'fallback', urls: [...new Set([...hintRelays, ...relayUrls])] }], - { - operation: 'read', - applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true - } - ) + const urls = [...new Set([...relayUrls, ...hintRelays])] if (!urls.length) return const filters: Filter[] = [