From 4237090623ffe2db28f0325f3be3414c906337cd Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 15 May 2026 22:03:45 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteList/index.tsx | 14 ++++- src/components/ReplyNoteList/index.tsx | 25 +++++++- .../client-replaceable-events.service.ts | 63 +++++++++++++++++-- src/services/client.service.ts | 40 +++++++++++- 4 files changed, 131 insertions(+), 11 deletions(-) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 1629340d..9c89d7f9 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -24,7 +24,7 @@ import { isSpellSubRequestsSameFiltersDifferentRelays } from '@/lib/spell-feed-request-identity' import logger from '@/lib/logger' -import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url' +import { isLocalNetworkUrl, normalizeAnyRelayUrl, 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' @@ -1596,12 +1596,22 @@ 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)) + chunks.map((chunk) => + client.fetchProfilesForPubkeys(chunk, { contextualReadRelays }) + ) ) if (gen !== feedProfileBatchGenRef.current) return diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 94c184f3..f1b352aa 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -661,6 +661,20 @@ 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) => @@ -745,12 +759,13 @@ 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)) + chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk, { contextualReadRelays })) ) if (gen !== threadProfileBatchGenRef.current) return @@ -788,7 +803,13 @@ function ReplyNoteList({ })() }, THREAD_PROFILE_BATCH_DEBOUNCE_MS) return () => window.clearTimeout(handle) - }, [event, mergedFeed, parentNoteFeed?.profiles, parentNoteFeed?.pendingPubkeys]) + }, [ + event, + mergedFeed, + parentNoteFeed?.profiles, + parentNoteFeed?.pendingPubkeys, + threadProfileContextRelays + ]) const [timelineKey] = useState(undefined) const [until, setUntil] = useState(undefined) diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index e90c077a..f6ce0b98 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -14,7 +14,7 @@ import { import { kinds, nip19 } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools' import DataLoader from 'dataloader' -import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url' +import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag' @@ -1044,13 +1044,18 @@ export class ReplaceableEventService { /** * 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[]): Promise { + async fetchProfilesForPubkeys( + pubkeys: string[], + options?: { contextualReadRelays?: 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), + this.fetchProfilesForPubkeysBody(deduped, options), FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS, 'fetchProfilesForPubkeys' ) @@ -1105,7 +1110,10 @@ export class ReplaceableEventService { return profiles } - private async fetchProfilesForPubkeysBody(deduped: string[]): Promise { + private async fetchProfilesForPubkeysBody( + deduped: string[], + options?: { contextualReadRelays?: string[] } + ): Promise { let events = await this.fetchReplaceableEventsFromProfileFetchRelays(deduped, kinds.Metadata) const gapIdx: number[] = [] for (let i = 0; i < deduped.length; i++) { @@ -1158,6 +1166,53 @@ 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 32085ea6..2a6c483d 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -98,7 +98,12 @@ function canonicalSeenOnEventId(eventId: string): string { return /^[0-9a-f]{64}$/i.test(t) ? t.toLowerCase() : t } import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter' -import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' +import { + getHttpRelayListFromEvent, + getProfileFromEvent, + getRelayListFromEvent, + getRelaySetFromEvent +} from '@/lib/event-metadata' import logger from '@/lib/logger' import { patchPoolRelayAuthRaceAndFeedback } from '@/lib/nostr-relay-auth-patch' import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue' @@ -3463,15 +3468,41 @@ class ClientService extends EventTarget { if (!favoriteRelaysEvent) return [] const relays: string[] = [] + const relaySetIds: string[] = [] favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => { if (tagName === 'relay' && tagValue) { const normalized = normalizeUrl(tagValue) if (normalized) { relays.push(normalized) } + } else if (tagName === 'a' && tagValue) { + const [kindStr, author, d] = tagValue.split(':') + if ( + kindStr === String(kinds.Relaysets) && + author === pubkey && + d && + !relaySetIds.includes(d) + ) { + relaySetIds.push(d) + } } }) + // NIP-51 relay sets on kind 10012: same expansion as {@link FavoriteRelaysProvider} (not only `relay` tags). + for (const id of relaySetIds) { + try { + const ev = await indexedDb.getReplaceableEvent(pubkey, kinds.Relaysets, id) + if (!ev || shouldDropEventOnIngest(ev)) continue + const set = getRelaySetFromEvent(ev) + for (const u of set.relayUrls) { + const n = normalizeUrl(u) || normalizeAnyRelayUrl(u) + if (n && !relays.includes(n)) relays.push(n) + } + } catch { + /* ignore */ + } + } + return Array.from(new Set(relays)) } catch { return [] @@ -4044,8 +4075,11 @@ class ClientService extends EventTarget { return this.replaceableEventService.fetchProfile(id, skipCache) } - async fetchProfilesForPubkeys(pubkeys: string[]): Promise { - return this.replaceableEventService.fetchProfilesForPubkeys(pubkeys) + async fetchProfilesForPubkeys( + pubkeys: string[], + options?: { contextualReadRelays?: string[] } + ): Promise { + return this.replaceableEventService.fetchProfilesForPubkeys(pubkeys, options) } async getProfileFromIndexedDB(id: string): Promise {