diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index 533bcd83..863df51f 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -11,6 +11,7 @@ import { type RecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments' import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' +import { shouldDeferPerPubkeyProfileNetwork } from '@/lib/profile-batch-coordinator' import { cn } from '@/lib/utils' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { useNostr } from '@/providers/NostrProvider' @@ -40,6 +41,60 @@ function formatAmount(amount: number) { return `${Math.round(amount / 100000) / 10}M` } +/** Avoid one metadata + payment REQ per visible note while feed profile batch runs. */ +async function resolveZapRecipientData( + authorPubkey: string, + feedProfile: TProfile | undefined | null +): Promise<{ + profile: TProfile | null + profileEvent: Event | undefined + paymentInfo: ReturnType | null +}> { + const cachedFeed = + feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null + const deferNetwork = shouldDeferPerPubkeyProfileNetwork(authorPubkey) + + const paymentPromise = deferNetwork + ? replaceableEventService.getPaymentInfoFromIndexedDB(authorPubkey) + : client.fetchPaymentInfoEvent(authorPubkey) + + if (cachedFeed) { + const paymentEvent = await paymentPromise.catch(() => undefined) + return { + profile: cachedFeed, + profileEvent: undefined, + paymentInfo: paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null + } + } + + const idbProfile = await replaceableEventService.getProfileFromIndexedDB(authorPubkey) + + if (deferNetwork) { + const paymentEvent = await paymentPromise.catch(() => undefined) + return { + profile: idbProfile ?? null, + profileEvent: undefined, + paymentInfo: paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null + } + } + + const [profileRes, paymentRes] = await Promise.allSettled([ + replaceableEventService.fetchReplaceableEvent(authorPubkey, kinds.Metadata), + paymentPromise + ]) + const profileEvent = + profileRes.status === 'fulfilled' ? profileRes.value : undefined + const paymentEvent = + paymentRes.status === 'fulfilled' ? paymentRes.value : undefined + const profile = + (profileEvent ? getProfileFromEvent(profileEvent) : null) ?? idbProfile ?? null + return { + profile, + profileEvent, + paymentInfo: paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null + } +} + /** Zap tally + payment-methods dialog when {@link ZAP_SENDING_ENABLED} is false. */ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapButtonProps) { const { t } = useTranslation() @@ -102,34 +157,17 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut setTipPaymentData(null) let cancelled = false - void Promise.allSettled([ - replaceableEventService.fetchReplaceableEvent(authorPubkey, kinds.Metadata), - client.fetchPaymentInfoEvent(authorPubkey), - replaceableEventService.getProfileFromIndexedDB(authorPubkey) - ]).then(([profileRes, paymentRes, idbRes]) => { - if (cancelled) return - - const profileEvent = - profileRes.status === 'fulfilled' ? profileRes.value : undefined - const paymentEvent = - paymentRes.status === 'fulfilled' ? paymentRes.value : undefined - const idbProfile = idbRes.status === 'fulfilled' ? idbRes.value : undefined - - const cachedFeed = feedProfileRef.current - const profile = - (profileEvent ? getProfileFromEvent(profileEvent) : null) ?? - (cachedFeed && !cachedFeed.batchPlaceholder ? cachedFeed : null) ?? - idbProfile ?? - null - const paymentInfo = paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null - - applyTipAvailability(profile, profileEvent ?? null, paymentInfo) - }) + void resolveZapRecipientData(authorPubkey, feedProfileRef.current).then( + ({ profile, profileEvent, paymentInfo }) => { + if (cancelled) return + applyTipAvailability(profile, profileEvent ?? null, paymentInfo) + } + ) return () => { cancelled = true } - }, [authorPubkey, isSelf, applyTipAvailability]) + }, [authorPubkey, isSelf, feedProfiles?.version, applyTipAvailability]) const handleOpenPaymentMethods = (e: React.MouseEvent) => { e.stopPropagation() @@ -266,34 +304,17 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB setTipPaymentData(null) let cancelled = false - void Promise.allSettled([ - replaceableEventService.fetchReplaceableEvent(authorPubkey, kinds.Metadata), - client.fetchPaymentInfoEvent(authorPubkey), - replaceableEventService.getProfileFromIndexedDB(authorPubkey) - ]).then(([profileRes, paymentRes, idbRes]) => { - if (cancelled) return - - const profileEvent = - profileRes.status === 'fulfilled' ? profileRes.value : undefined - const paymentEvent = - paymentRes.status === 'fulfilled' ? paymentRes.value : undefined - const idbProfile = idbRes.status === 'fulfilled' ? idbRes.value : undefined - - const cachedFeed = feedProfileRef.current - const profile = - (profileEvent ? getProfileFromEvent(profileEvent) : null) ?? - (cachedFeed && !cachedFeed.batchPlaceholder ? cachedFeed : null) ?? - idbProfile ?? - null - const paymentInfo = paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null - - applyTipAvailability(profile, profileEvent ?? null, paymentInfo, true) - }) + void resolveZapRecipientData(authorPubkey, feedProfileRef.current).then( + ({ profile, profileEvent, paymentInfo }) => { + if (cancelled) return + applyTipAvailability(profile, profileEvent ?? null, paymentInfo, true) + } + ) return () => { cancelled = true } - }, [authorPubkey, isSelf, applyTipAvailability]) + }, [authorPubkey, isSelf, feedProfiles?.version, applyTipAvailability]) const handleZap = async () => { try { diff --git a/src/constants.ts b/src/constants.ts index 9097caf5..42ceb00d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -263,6 +263,12 @@ export const FEED_PROFILE_PENDING_BATCH_ESCAPE_MS = FEED_PROFILE_BATCH_FETCH_TIM /** Network-only cap on {@link ReplaceableEventService.fetchReplaceableEventsFromProfileFetchRelays} `loadMany`. */ export const PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS = 12_000 +/** + * After a feed/thread profile batch finishes, block per-row metadata/payment relay REQs so + * {@link ZapButton} and {@link useFetchProfile} do not fan out hundreds of parallel queries. + */ +export const PROFILE_BATCH_POST_COOLDOWN_MS = 45_000 + /** * Hex-id / replaceable-coordinate note lookup ({@link EventService.tryHarderToFetchEvent}, big-relays dataloader). */ diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 62b0ce08..b93d19ca 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -2,7 +2,10 @@ import { FEED_PROFILE_PENDING_BATCH_ESCAPE_MS, PROFILE_FETCH_PROMISE_TIMEOUT_MS import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getProfileFromEvent } from '@/lib/event-metadata' import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed' -import { isPubkeyAwaitingProfileBatch } from '@/lib/profile-batch-coordinator' +import { + isPubkeyAwaitingProfileBatch, + shouldDeferPerPubkeyProfileNetwork +} from '@/lib/profile-batch-coordinator' import { normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey' import { useNostrOptional } from '@/providers/nostr-context' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' @@ -14,10 +17,6 @@ import { kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import logger from '@/lib/logger' -function feedProfileBatchRetryStaggerMs(pubkeyLower: string): number { - return (parseInt(pubkeyLower.slice(0, 8), 16) % 40) * 400 -} - function tryHydrateProfileFromSessionOnly(pubkey: string, skipCache: boolean): TProfile | null { if (skipCache) return null const pk = pubkey.toLowerCase() @@ -116,6 +115,22 @@ export function useFetchProfile(id?: string, skipCache = false) { return null } + if (shouldDeferPerPubkeyProfileNetwork(pubkey)) { + const cachedDuringBatch = await tryHydrateProfileFromLocalCaches(pubkey, skipCache) + if (!cancelled.current && cachedDuringBatch) { + setProfile(cachedDuringBatch) + setIsFetching(false) + initializedPubkeysRef.current.add(pubkey) + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current) + checkIntervalRef.current = null + } + effectRunCountRef.current.delete(pubkey) + return cachedDuringBatch + } + return null + } + // CRITICAL: Check cooldown period first to prevent cascade of duplicate fetches after timeout. // Still hydrate from session/IndexedDB — otherwise new rows remount after a timeout and stay on // identicons until cooldown ends with no effect re-run (deps unchanged). @@ -413,14 +428,17 @@ export function useFetchProfile(id?: string, skipCache = false) { } if (fromBatch?.batchPlaceholder) { const placeholderCancelled = { current: false } - const staggerMs = feedProfileBatchRetryStaggerMs(pkL) - const placeholderTimer = window.setTimeout(() => { - if (placeholderCancelled.current) return - void checkProfile(extractedPubkey, placeholderCancelled) - }, staggerMs) + void tryHydrateProfileFromLocalCaches(pkL, false).then((quick) => { + if (placeholderCancelled.current || !quick) return + setProfile(quick) + setIsFetching(false) + setError(null) + processingPubkeyRef.current = extractedPubkey + initializedPubkeysRef.current.add(extractedPubkey) + effectRunCountRef.current.delete(extractedPubkey) + }) return () => { placeholderCancelled.current = true - window.clearTimeout(placeholderTimer) if (processingPubkeyRef.current === extractedPubkey) { processingPubkeyRef.current = null } diff --git a/src/lib/profile-batch-coordinator.ts b/src/lib/profile-batch-coordinator.ts index e94b57ea..095d7018 100644 --- a/src/lib/profile-batch-coordinator.ts +++ b/src/lib/profile-batch-coordinator.ts @@ -1,10 +1,13 @@ /** * Tracks pubkeys currently loaded via {@link ReplaceableEventService.fetchProfilesForPubkeys} * so per-avatar {@link ReplaceableEventService.fetchProfileEvent} does not open parallel - * 17-relay fallback REQ storms while a batch is in flight. + * 17-relay fallback REQ storms while a batch is in flight or immediately after it ends. */ +import { PROFILE_BATCH_POST_COOLDOWN_MS } from '@/constants' + const awaitingBatch = new Set() +const batchCooldownUntil = new Map() function norm(pk: string): string { return pk.trim().toLowerCase() @@ -19,8 +22,11 @@ export function registerProfileBatchPubkeys(pubkeys: readonly string[]): void { } export function unregisterProfileBatchPubkeys(pubkeys: readonly string[]): void { + const until = Date.now() + PROFILE_BATCH_POST_COOLDOWN_MS for (const pk of pubkeys) { - awaitingBatch.delete(norm(pk)) + const n = norm(pk) + awaitingBatch.delete(n) + batchCooldownUntil.set(n, until) } } @@ -29,6 +35,23 @@ export function isPubkeyAwaitingProfileBatch(pubkey: string): boolean { return pk.length === 64 && awaitingBatch.has(pk) } +export function isPubkeyInProfileBatchCooldown(pubkey: string): boolean { + const pk = norm(pubkey) + if (pk.length !== 64) return false + const until = batchCooldownUntil.get(pk) + if (until == null) return false + if (Date.now() >= until) { + batchCooldownUntil.delete(pk) + return false + } + return true +} + +/** True while batch is in flight or during the post-batch cooldown window. */ +export function shouldDeferPerPubkeyProfileNetwork(pubkey: string): boolean { + return isPubkeyAwaitingProfileBatch(pubkey) || isPubkeyInProfileBatchCooldown(pubkey) +} + export function collectProfilePubkeysFromEvents( events: readonly { pubkey: string; tags: string[][] }[], maxPTagsPerEvent = 4 diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index ece7c94b..0a379545 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -34,18 +34,48 @@ import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eli import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { - isPubkeyAwaitingProfileBatch, registerProfileBatchPubkeys, + shouldDeferPerPubkeyProfileNetwork, unregisterProfileBatchPubkeys } from '@/lib/profile-batch-coordinator' import { isPromiseTimeoutError, racePromiseWithTimeout } from '@/lib/async-timeout' import { networkKindsForReplaceableFetch } from '@/lib/replaceable-fetch-kinds' export class ReplaceableEventService { + /** Limits parallel {@link fetchKind0FromProfileRelays} (7-relay REQ per author). */ + private static kind0ProfileRelaySlotsInUse = 0 + private static kind0ProfileRelayWaitQueue: Array<() => void> = [] + private static readonly MAX_CONCURRENT_KIND0_PROFILE_RELAY_REQS = 3 + /** Limits parallel Step 2/3 profile network work (relay list + wide metadata REQ). */ private static profileFallbackSlotsInUse = 0 private static profileFallbackWaitQueue: Array<() => void> = [] + private static async acquireKind0ProfileRelaySlot(): Promise { + if ( + ReplaceableEventService.kind0ProfileRelaySlotsInUse < + ReplaceableEventService.MAX_CONCURRENT_KIND0_PROFILE_RELAY_REQS + ) { + ReplaceableEventService.kind0ProfileRelaySlotsInUse++ + return + } + await new Promise((resolve) => { + ReplaceableEventService.kind0ProfileRelayWaitQueue.push(() => { + ReplaceableEventService.kind0ProfileRelaySlotsInUse++ + resolve() + }) + }) + } + + private static releaseKind0ProfileRelaySlot(): void { + ReplaceableEventService.kind0ProfileRelaySlotsInUse = Math.max( + 0, + ReplaceableEventService.kind0ProfileRelaySlotsInUse - 1 + ) + const next = ReplaceableEventService.kind0ProfileRelayWaitQueue.shift() + if (next) next() + } + private static async acquireProfileFallbackNetworkSlot(): Promise { if (ReplaceableEventService.profileFallbackSlotsInUse < MAX_CONCURRENT_RELAY_CONNECTIONS) { ReplaceableEventService.profileFallbackSlotsInUse++ @@ -867,7 +897,10 @@ export class ReplaceableEventService { private async fetchKind0FromProfileRelays(pubkey: string): Promise { const pk = pubkey.trim().toLowerCase() if (!/^[0-9a-f]{64}$/.test(pk)) return undefined + if (shouldDeferPerPubkeyProfileNetwork(pk)) return undefined + await ReplaceableEventService.acquireKind0ProfileRelaySlot() + try { const relays = prependAggrNostrLandIfViewerEligible( stripLocalNetworkRelaysForWssReq( Array.from( @@ -902,6 +935,9 @@ export class ReplaceableEventService { }) return undefined } + } finally { + ReplaceableEventService.releaseKind0ProfileRelaySlot() + } } /** @@ -937,8 +973,7 @@ export class ReplaceableEventService { throw new Error('Invalid id') } - /** Used only when relay steps miss — UI should already show this from {@link useFetchProfile} IDB/session first. */ - let sessionFallback: NEvent | undefined + // Local-first: session LRU, then IndexedDB — before any PROFILE_RELAY / DataLoader / wide relay pass. if (!_skipCache) { const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey) if (sessionEv && !shouldDropEventOnIngest(sessionEv)) { @@ -948,14 +983,42 @@ export class ReplaceableEventService { ) await this.indexProfile(sessionEv) void indexedDb.putReplaceableEvent(sessionEv).catch(() => {}) + void this.refreshInBackground(pubkey, kinds.Metadata).catch(() => {}) + return sessionEv + } + try { + const idbEv = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) + if (idbEv && !shouldDropEventOnIngest(idbEv)) { + this.replaceableEventFromBigRelaysDataloader.prime( + { pubkey, kind: kinds.Metadata }, + Promise.resolve(idbEv) + ) + await this.indexProfile(idbEv) + void this.refreshInBackground(pubkey, kinds.Metadata).catch(() => {}) + return idbEv + } + } catch { + /* ignore IDB read errors — fall through to network */ + } + } + + /** When batch or wide relay steps miss, return session row if we had one earlier in the call. */ + let sessionFallback: NEvent | undefined + if (!_skipCache) { + const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey) + if (sessionEv && !shouldDropEventOnIngest(sessionEv)) { sessionFallback = sessionEv } } + if (shouldDeferPerPubkeyProfileNetwork(pubkey)) { + return sessionFallback + } + // Relay hints from bech32 (nprofile, etc.) — highest priority in later steps const relayHints = relays.length > 0 ? [...relays] : [] - // Step 0: {@link PROFILE_RELAY_URLS} by `authors` — reliable for npub/hex; avoids batched DataLoader + abort races. + // Step 0: {@link PROFILE_RELAY_URLS} by `authors` — after local caches miss. const fromProfileRelays = await this.fetchKind0FromProfileRelays(pubkey) if (fromProfileRelays) { this.replaceableEventFromBigRelaysDataloader.prime( @@ -980,10 +1043,6 @@ export class ReplaceableEventService { return profileEvent } - if (isPubkeyAwaitingProfileBatch(pubkey)) { - return sessionFallback - } - await ReplaceableEventService.acquireProfileFallbackNetworkSlot() try { // Step 2: Only after cache + default relays miss — NIP-65 relay list (timeout-capped), then hints + outbox/inbox + defaults. @@ -1383,7 +1442,20 @@ export class ReplaceableEventService { /** * Fetch payment info event */ + async getPaymentInfoFromIndexedDB(pubkey: string): Promise { + try { + const row = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO) + if (!row || row === null) return undefined + return row as NEvent + } catch { + return undefined + } + } + async fetchPaymentInfoEvent(pubkey: string): Promise { + if (shouldDeferPerPubkeyProfileNetwork(pubkey)) { + return this.getPaymentInfoFromIndexedDB(pubkey) + } return await this.fetchReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO) }