From b4463d9cb0524350a562991c3296df43e71be6d9 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 20 May 2026 12:24:26 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteList/index.tsx | 30 +++--- src/constants.ts | 3 + src/lib/index-relay-http.ts | 3 + src/lib/profile-author-warmup-spec.test.ts | 12 ++- src/lib/profile-author-warmup-spec.ts | 5 + src/lib/profile-relay-search-filters.ts | 3 +- src/lib/replaceable-fetch-kinds.test.ts | 20 ++++ src/lib/replaceable-fetch-kinds.ts | 8 ++ src/lib/replaceable-list-latest.ts | 5 +- .../client-replaceable-events.service.ts | 95 +++++++++++-------- src/services/client.service.ts | 9 +- 11 files changed, 134 insertions(+), 59 deletions(-) create mode 100644 src/lib/replaceable-fetch-kinds.test.ts create mode 100644 src/lib/replaceable-fetch-kinds.ts diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 816deca8..e3338757 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -80,7 +80,8 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { buildFeedFullSearchRelayUrls } from '@/lib/feed-full-search-relays' import { getProfileAuthorWarmupRelayUrls, - getProfileAuthorWarmupSpec + getProfileAuthorWarmupSpec, + isProfileTimelineSubscriptionKey } from '@/lib/profile-author-warmup-spec' import type { TProfile } from '@/types' import { Button } from '@/components/ui/button' @@ -1982,6 +1983,7 @@ const NoteList = forwardRef( preserveTimelineOnSubRequestsChange && !userPulledRefresh && !feedScopeChanged && + eventsRef.current.length > 0 && (prevSubKey === subRequestsKey || isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) || (mergeTimelineWhenSubRequestFiltersMatch && @@ -2058,6 +2060,10 @@ const NoteList = forwardRef( return undefined } + const isProfileTimelineFeed = + hostPrimaryPageNameRef.current === 'profile' || + isProfileTimelineSubscriptionKey(timelineSubscriptionKey) + /** * Relay kindless firehose: keep the full batch. Else when the kind picker applies, narrow like * {@link applyKindPickerInUi}. Remaining spell paths use kinds-only narrowing when client-side kind filter runs. @@ -2074,11 +2080,7 @@ const NoteList = forwardRef( showKind1111Ref.current ) ) - if ( - out.length > 0 || - hostPrimaryPageNameRef.current !== 'profile' || - mappedSubRequests.length === 0 - ) { + if (out.length > 0 || !isProfileTimelineFeed || mappedSubRequests.length === 0) { return out } return filterEvsToMappedTimelineReqKinds(evs, mappedSubRequests) @@ -2086,18 +2088,14 @@ const NoteList = forwardRef( if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs if (!withKindFilterRef.current) return evs const byPicker = evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind)) - if ( - byPicker.length > 0 || - hostPrimaryPageNameRef.current !== 'profile' || - mappedSubRequests.length === 0 - ) { + if (byPicker.length > 0 || !isProfileTimelineFeed || mappedSubRequests.length === 0) { return byPicker } return filterEvsToMappedTimelineReqKinds(evs, mappedSubRequests) } const eventMatchesProfileTimelineRequest = (event: Event) => - hostPrimaryPageNameRef.current === 'profile' && + isProfileTimelineFeed && mappedSubRequests.some(({ filter }) => eventMatchesSubRequestFilterWithWindow(event, filter as Filter) ) @@ -2377,7 +2375,7 @@ const NoteList = forwardRef( }> const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped) if ( - hostPrimaryPageName === 'profile' && + isProfileTimelineFeed && profileAuthorWarmSpec && !timelineEffectStale() ) { @@ -3235,7 +3233,8 @@ const NoteList = forwardRef( } const eventMatchesProfileDeltaRequest = (event: Event) => - hostPrimaryPageNameRef.current === 'profile' && + (hostPrimaryPageNameRef.current === 'profile' || + isProfileTimelineSubscriptionKey(timelineSubscriptionKey)) && mappedDelta.some(({ filter }) => eventMatchesSubRequestFilterWithWindow(event, filter as Filter) ) @@ -3629,7 +3628,8 @@ const NoteList = forwardRef( publicReadFallbackAttemptedRef.current = true const profileWarm = - hostPrimaryPageNameRef.current === 'profile' + hostPrimaryPageNameRef.current === 'profile' || + isProfileTimelineSubscriptionKey(timelineSubscriptionKey) ? getProfileAuthorWarmupSpec( mapped as Array<{ urls: string[]; filter: TSubRequestFilter }> ) diff --git a/src/constants.ts b/src/constants.ts index 47d31184..fbdc42e9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -613,6 +613,9 @@ export function isAuthorProfileMetadataPublishKind(kind: number): boolean { * Author-published replaceables refetched on profile-view refresh, profile editor “Refresh cache”, * settings “Refresh cache”, and {@link ReplaceableEventService.refreshAuthorPublishedReplaceablesFromRelays}. */ +/** Kinds requested in the same REQ whenever the app fetches author metadata (kind 0). */ +export const METADATA_CO_FETCH_KINDS: readonly number[] = [kinds.Metadata, ExtendedKind.PAYMENT_INFO] + export const AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS: readonly number[] = [ kinds.Metadata, kinds.Contacts, diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts index 68a6f153..a739d43a 100644 --- a/src/lib/index-relay-http.ts +++ b/src/lib/index-relay-http.ts @@ -227,6 +227,9 @@ export async function queryIndexRelay( status: res.status }) } + if (res.status >= 500) { + throw new IndexRelayTransportError(new Error(`HTTP ${res.status}`)) + } continue } const json = (await res.json()) as { data?: unknown } diff --git a/src/lib/profile-author-warmup-spec.test.ts b/src/lib/profile-author-warmup-spec.test.ts index 9632d472..677dcdb5 100644 --- a/src/lib/profile-author-warmup-spec.test.ts +++ b/src/lib/profile-author-warmup-spec.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import { ExtendedKind } from '@/constants' -import { getProfileAuthorWarmupSpec } from './profile-author-warmup-spec' +import { + getProfileAuthorWarmupSpec, + isProfileTimelineSubscriptionKey +} from './profile-author-warmup-spec' describe('getProfileAuthorWarmupSpec', () => { const authorHex = 'a'.repeat(64) @@ -23,6 +26,13 @@ describe('getProfileAuthorWarmupSpec', () => { expect(spec).toEqual({ author: authorHex, kinds: [1] }) }) + it('detects profile feed subscription keys', () => { + expect(isProfileTimelineSubscriptionKey('profile-posts-abc-1-200')).toBe(true) + expect(isProfileTimelineSubscriptionKey('profile-media-abc')).toBe(true) + expect(isProfileTimelineSubscriptionKey('home-all-favorites')).toBe(false) + expect(isProfileTimelineSubscriptionKey(null)).toBe(false) + }) + it('returns null when no author shards', () => { expect( getProfileAuthorWarmupSpec([ diff --git a/src/lib/profile-author-warmup-spec.ts b/src/lib/profile-author-warmup-spec.ts index 7c324e1a..6d952481 100644 --- a/src/lib/profile-author-warmup-spec.ts +++ b/src/lib/profile-author-warmup-spec.ts @@ -2,6 +2,11 @@ import type { TSubRequestFilter } from '@/types' import { normalizeHexPubkey } from '@/lib/pubkey' import type { Filter } from 'nostr-tools' +/** Profile Posts/Media tabs pass stable keys like `profile-posts-…` / `profile-media-…`. */ +export function isProfileTimelineSubscriptionKey(key: string | undefined | null): boolean { + return typeof key === 'string' && key.startsWith('profile-') +} + /** * Profile feeds may include calendar invite shards (`#p`) without `authors`. Local session/IDB * warmup and relay fallback only need the single-author + kinds REQ shards. diff --git a/src/lib/profile-relay-search-filters.ts b/src/lib/profile-relay-search-filters.ts index a0189e20..71000e9b 100644 --- a/src/lib/profile-relay-search-filters.ts +++ b/src/lib/profile-relay-search-filters.ts @@ -1,3 +1,4 @@ +import { METADATA_CO_FETCH_KINDS } from '@/constants' import type { Filter } from 'nostr-tools' import { kinds } from 'nostr-tools' import { splitNip05Identifier } from '@/lib/nip05' @@ -22,7 +23,7 @@ export function buildProfileKind0SearchFilters(opts: { const limit = Math.max(1, Math.min(opts.limit ?? 50, 500)) const time = typeof opts.until === 'number' && opts.until > 0 ? ({ until: opts.until } as Pick) : {} - const k = [kinds.Metadata] as number[] + const k = [...METADATA_CO_FETCH_KINDS] as number[] const pubkeyHex = decodeProfileSearchQueryToPubkeyHex(searchRaw) if (pubkeyHex) { diff --git a/src/lib/replaceable-fetch-kinds.test.ts b/src/lib/replaceable-fetch-kinds.test.ts new file mode 100644 index 00000000..6038cf65 --- /dev/null +++ b/src/lib/replaceable-fetch-kinds.test.ts @@ -0,0 +1,20 @@ +import { ExtendedKind } from '@/constants' +import { networkKindsForReplaceableFetch } from '@/lib/replaceable-fetch-kinds' +import { kinds } from 'nostr-tools' +import { describe, expect, it } from 'vitest' + +describe('networkKindsForReplaceableFetch', () => { + it('includes kind 10133 when fetching kind 0', () => { + expect(networkKindsForReplaceableFetch(kinds.Metadata)).toEqual([ + kinds.Metadata, + ExtendedKind.PAYMENT_INFO + ]) + }) + + it('leaves other replaceable kinds unchanged', () => { + expect(networkKindsForReplaceableFetch(kinds.Contacts)).toEqual([kinds.Contacts]) + expect(networkKindsForReplaceableFetch(ExtendedKind.PAYMENT_INFO)).toEqual([ + ExtendedKind.PAYMENT_INFO + ]) + }) +}) diff --git a/src/lib/replaceable-fetch-kinds.ts b/src/lib/replaceable-fetch-kinds.ts new file mode 100644 index 00000000..787560a6 --- /dev/null +++ b/src/lib/replaceable-fetch-kinds.ts @@ -0,0 +1,8 @@ +import { METADATA_CO_FETCH_KINDS } from '@/constants' +import { kinds } from 'nostr-tools' + +/** Network `kinds` for a replaceable fetch: kind 0 always includes NIP-A3 payment info (10133). */ +export function networkKindsForReplaceableFetch(kind: number): number[] { + if (kind === kinds.Metadata) return [...METADATA_CO_FETCH_KINDS] + return [kind] +} diff --git a/src/lib/replaceable-list-latest.ts b/src/lib/replaceable-list-latest.ts index 49875b55..ac638193 100644 --- a/src/lib/replaceable-list-latest.ts +++ b/src/lib/replaceable-list-latest.ts @@ -1,4 +1,5 @@ import { ExtendedKind, METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants' +import { networkKindsForReplaceableFetch } from '@/lib/replaceable-fetch-kinds' import { normalizeHexPubkey } from '@/lib/pubkey' import { normalizeAnyRelayUrl } from '@/lib/url' import client, { eventService } from '@/services/client.service' @@ -48,11 +49,11 @@ export async function fetchLatestReplaceableListEvent( const rows = await client.fetchEvents( allUrls, - { authors: [pk], kinds: [kind], limit: 80 }, + { authors: [pk], kinds: networkKindsForReplaceableFetch(kind), limit: 80 }, replaceableListFetchQueryOpts(kind) ) - return newestReplaceableEvent(rows) + return newestReplaceableEvent(rows.filter((e) => e.kind === kind)) } /** diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 6eb9e899..6bd4ce4c 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -34,6 +34,7 @@ import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eli import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { isPromiseTimeoutError, racePromiseWithTimeout } from '@/lib/async-timeout' +import { networkKindsForReplaceableFetch } from '@/lib/replaceable-fetch-kinds' export class ReplaceableEventService { /** Limits parallel Step 2/3 profile network work (relay list + wide metadata REQ). */ @@ -244,7 +245,7 @@ export class ReplaceableEventService { relayUrls, { authors: [pubkey], - kinds: [kind] + kinds: networkKindsForReplaceableFetch(kind) }, undefined, { @@ -253,7 +254,12 @@ export class ReplaceableEventService { globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } ) - const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + if (kind === kinds.Metadata) { + this.ingestMetadataCoFetchSidecars(events) + } + const sortedEvents = events + .filter((e) => e.kind === kind) + .sort((a, b) => b.created_at - a.created_at) event = sortedEvents.length > 0 ? sortedEvents[0] : undefined } else { // Use DataLoader for batching (IndexedDB checks and network fetches are batched) @@ -650,10 +656,13 @@ export class ReplaceableEventService { kind === kinds.Metadata ? false : !isSlowReplaceableBatch || !chunkMulti const evts = await this.queryService.query( relayUrls, - { authors: chunkPubkeys, kinds: [kind] }, + { authors: chunkPubkeys, kinds: networkKindsForReplaceableFetch(kind) }, undefined, { ...queryOpts, replaceableRace: chunkRace } ) + if (kind === kinds.Metadata) { + this.ingestMetadataCoFetchSidecars(evts) + } merged.push(...evts) } events = merged @@ -662,11 +671,14 @@ export class ReplaceableEventService { relayUrls, { authors: pubkeys, - kinds: [kind] + kinds: networkKindsForReplaceableFetch(kind) }, undefined, queryOpts ) + if (kind === kinds.Metadata) { + this.ingestMetadataCoFetchSidecars(events) + } } // CRITICAL: Limit the number of events processed to prevent memory issues during rapid scrolling @@ -690,32 +702,12 @@ export class ReplaceableEventService { const limitedEvents = Array.from(eventsByPubkey.values()).slice(0, 500) // Use limited events for processing for (const event of limitedEvents) { - const key = `${event.pubkey}:${event.kind}` - const existing = eventsMap.get(key) - if (!existing || existing.created_at < event.created_at) { - eventsMap.set(key, event) - // Update results array for this event - const itemIndex = missingItems.findIndex(item => item.pubkey === event.pubkey) - if (itemIndex >= 0) { - const paramIndex = missingItems[itemIndex]!.index - results[paramIndex] = event - } - } + this.applyNetworkReplaceableEventToBatch(event, kind, missingItems, results, eventsMap) } } else { // Normal processing for smaller batches for (const event of events) { - const key = `${event.pubkey}:${event.kind}` - const existing = eventsMap.get(key) - if (!existing || existing.created_at < event.created_at) { - eventsMap.set(key, event) - // Update results array for this event - const itemIndex = missingItems.findIndex(item => item.pubkey === event.pubkey) - if (itemIndex >= 0) { - const paramIndex = missingItems[itemIndex]!.index - results[paramIndex] = event - } - } + this.applyNetworkReplaceableEventToBatch(event, kind, missingItems, results, eventsMap) } } @@ -743,12 +735,8 @@ export class ReplaceableEventService { // Step 3: Persist hits only. Do not write negative cache rows (`value: null`) — optional kinds // (e.g. 10432 cache relays, 10001 pins) are missing for most pubkeys and would flood IndexedDB. await Promise.allSettled( - missingParams.map(async ({ pubkey, kind }) => { - const key = `${pubkey}:${kind}` - const event = eventsMap.get(key) - if (event) { - await indexedDb.putReplaceableEvent(event) - } + Array.from(eventsMap.values()).map(async (event) => { + await indexedDb.putReplaceableEvent(event) }) ) @@ -812,6 +800,33 @@ export class ReplaceableEventService { }) } + /** Persist kind 10133 rows returned alongside a kind-0 REQ (same filter, separate cache slots). */ + private ingestMetadataCoFetchSidecars(events: readonly NEvent[]): void { + for (const event of events) { + if (event.kind !== ExtendedKind.PAYMENT_INFO || shouldDropEventOnIngest(event)) continue + void this.updateReplaceableEventFromBigRelaysCache(event) + } + } + + private applyNetworkReplaceableEventToBatch( + event: NEvent, + requestedKind: number, + missingItems: { pubkey: string; index: number }[], + results: (NEvent | null)[], + eventsMap: Map + ): void { + const kindKey = `${event.pubkey}:${event.kind}` + const existing = eventsMap.get(kindKey) + if (!existing || existing.created_at < event.created_at) { + eventsMap.set(kindKey, event) + } + if (event.kind !== requestedKind) return + const itemIndex = missingItems.findIndex((item) => item.pubkey === event.pubkey) + if (itemIndex < 0) return + const paramIndex = missingItems[itemIndex]!.index + results[paramIndex] = event + } + /** * Private: Update cache for replaceable event from big relays */ @@ -858,7 +873,7 @@ export class ReplaceableEventService { try { const events = await this.queryService.query( relays, - { authors: [pk], kinds: [kinds.Metadata], limit: 1 }, + { authors: [pk], kinds: networkKindsForReplaceableFetch(kinds.Metadata), limit: 4 }, undefined, { replaceableRace: false, @@ -868,8 +883,10 @@ export class ReplaceableEventService { relayOpSource: 'ReplaceableEventService.fetchKind0FromProfileRelays' } ) - if (events.length === 0) return undefined - const sorted = events.sort((a, b) => b.created_at - a.created_at) + this.ingestMetadataCoFetchSidecars(events) + const metadataRows = events.filter((e) => e.kind === kinds.Metadata) + if (metadataRows.length === 0) return undefined + const sorted = metadataRows.sort((a, b) => b.created_at - a.created_at) return sorted[0] } catch (error) { logger.warn('[ReplaceableEventService] fetchKind0FromProfileRelays failed', { @@ -1038,7 +1055,7 @@ export class ReplaceableEventService { relaysForQuery, { authors: [pubkey], - kinds: [kinds.Metadata] + kinds: networkKindsForReplaceableFetch(kinds.Metadata) }, undefined, { @@ -1049,8 +1066,10 @@ export class ReplaceableEventService { } ) - if (events.length > 0) { - const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + this.ingestMetadataCoFetchSidecars(events) + const metadataRows = events.filter((e) => e.kind === kinds.Metadata) + if (metadataRows.length > 0) { + const sortedEvents = metadataRows.sort((a, b) => b.created_at - a.created_at) const found = sortedEvents[0]! await this.indexProfile(found) return found diff --git a/src/services/client.service.ts b/src/services/client.service.ts index d2a639bd..cdb45857 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,5 +1,6 @@ import { FAST_READ_RELAY_URLS, + METADATA_CO_FETCH_KINDS, ExtendedKind, FAST_WRITE_RELAY_URLS, DOCUMENT_RELAY_URLS, @@ -3797,9 +3798,9 @@ class ClientService extends EventTarget { limit: limitCap, until: filter.until }) - return built.length > 0 ? built : [{ ...filter, kinds: [kinds.Metadata] }] + return built.length > 0 ? built : [{ ...filter, kinds: [...METADATA_CO_FETCH_KINDS] }] })() - : { ...filter, kinds: [kinds.Metadata] } + : { ...filter, kinds: [...METADATA_CO_FETCH_KINDS] } /** NIP-50 text on many index relays: per-relay EOSE can be ~38s; global cap was 9s so subs were torn down early. */ const filtersArr = Array.isArray(queryFilter) ? queryFilter : [queryFilter] @@ -3819,6 +3820,10 @@ class ClientService extends EventTarget { const byPk = new Map() for (const e of events) { + if (e.kind === ExtendedKind.PAYMENT_INFO && !shouldDropEventOnIngest(e)) { + void this.replaceableEventService.updateReplaceableEventCache(e) + continue + } if (e.kind !== kinds.Metadata) continue const prev = byPk.get(e.pubkey) if (!prev || e.created_at > prev.created_at) {