diff --git a/src/PageManager.tsx b/src/PageManager.tsx index c2f8e831..ee4fae92 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -14,6 +14,8 @@ import { NavigationService } from '@/services/navigation.service' import { ImwaldBrandBar } from '@/assets/Logo' import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' import NoteDrawer from '@/components/NoteDrawer' +import { PROFILE_SECONDARY_PANEL_DEFER_MS } from '@/constants' +import { extendProfileNetworkDeferral } from '@/lib/profile-batch-coordinator' import client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import { navigationEventStore } from '@/services/navigation-event-store' @@ -2172,6 +2174,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { useLayoutEffect(() => { noteStatsService.setBackgroundStatsPaused(primaryFrozen) if (primaryFrozen) { + extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS) client.interruptBackgroundQueries() } }, [primaryFrozen]) diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index 652861f2..41ffb7ce 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -41,15 +41,49 @@ function formatAmount(amount: number) { return `${Math.round(amount / 100000) / 10}M` } +type ZapRecipientResolveResult = { + profile: TProfile | null + profileEvent: Event | undefined + paymentInfo: ReturnType | null +} + +const zapRecipientResolveByPubkey = new Map>() + +function feedProfileRowSyncKey( + profile: TProfile | undefined | null, + pendingInFeed: boolean +): string { + if (!profile) return pendingInFeed ? 'p:wait' : 'p:none' + return [ + profile.batchPlaceholder ? 'ph' : 'ok', + profile.username ?? '', + profile.avatar ?? '', + profile.npub ?? '' + ].join('\x1e') +} + /** 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 -}> { +): Promise { + const pk = authorPubkey.toLowerCase() + const inFlight = zapRecipientResolveByPubkey.get(pk) + if (inFlight) return inFlight + + const run = resolveZapRecipientDataBody(pk, feedProfile).finally(() => { + if (zapRecipientResolveByPubkey.get(pk) === run) { + zapRecipientResolveByPubkey.delete(pk) + } + }) + zapRecipientResolveByPubkey.set(pk, run) + return run +} + +async function resolveZapRecipientDataBody( + authorPubkey: string, + feedProfile: TProfile | undefined | null +): Promise { const cachedFeed = feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null const deferNetwork = shouldDeferPerPubkeyProfileNetwork(authorPubkey) @@ -114,6 +148,10 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut const feedProfile = feedProfiles?.profiles.get(authorPubkey) const feedProfileRef = useRef(feedProfile) feedProfileRef.current = feedProfile + const feedProfileSyncKey = feedProfileRowSyncKey( + feedProfile, + Boolean(feedProfiles?.pendingPubkeys.has(authorPubkey)) + ) const [disable, setDisable] = useState(true) const [tipPaymentData, setTipPaymentData] = useState(null) @@ -144,7 +182,7 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut if (isSelf) return if (!feedProfile || feedProfile.batchPlaceholder) return applyTipAvailability(feedProfile, null, null) - }, [isSelf, feedProfile, feedProfiles?.version, applyTipAvailability]) + }, [isSelf, feedProfile, feedProfileSyncKey, applyTipAvailability]) useEffect(() => { if (isSelf) { @@ -167,7 +205,7 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut return () => { cancelled = true } - }, [authorPubkey, isSelf, feedProfiles?.version, applyTipAvailability]) + }, [authorPubkey, isSelf, feedProfileSyncKey, applyTipAvailability]) const handleOpenPaymentMethods = (e: React.MouseEvent) => { e.stopPropagation() @@ -248,6 +286,10 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB const feedProfile = feedProfiles?.profiles.get(authorPubkey) const feedProfileRef = useRef(feedProfile) feedProfileRef.current = feedProfile + const feedProfileSyncKey = feedProfileRowSyncKey( + feedProfile, + Boolean(feedProfiles?.pendingPubkeys.has(authorPubkey)) + ) const [disable, setDisable] = useState(true) const [canLightningZap, setCanLightningZap] = useState(false) @@ -289,7 +331,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB if (isSelf) return if (!feedProfile || feedProfile.batchPlaceholder) return applyTipAvailability(feedProfile, null, null, true) - }, [isSelf, feedProfile, feedProfiles?.version, applyTipAvailability]) + }, [isSelf, feedProfile, feedProfileSyncKey, applyTipAvailability]) useEffect(() => { if (isSelf) { @@ -314,7 +356,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB return () => { cancelled = true } - }, [authorPubkey, isSelf, feedProfiles?.version, applyTipAvailability]) + }, [authorPubkey, isSelf, feedProfileSyncKey, applyTipAvailability]) const handleZap = async () => { try { diff --git a/src/constants.ts b/src/constants.ts index da632457..20daffdc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -269,6 +269,13 @@ export const PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS = 12_000 */ export const PROFILE_BATCH_POST_COOLDOWN_MS = 90_000 +/** + * While a note (or other heavy secondary panel) is open, block per-row metadata/payment relay REQs + * on the feed behind it — {@link ZapButton} must not re-fetch every author when thread batch bumps + * {@link NoteFeedProfileContext.version}. + */ +export const PROFILE_SECONDARY_PANEL_DEFER_MS = 120_000 + /** * Hex-id / replaceable-coordinate note lookup ({@link EventService.tryHarderToFetchEvent}, big-relays dataloader). */ diff --git a/src/lib/profile-batch-coordinator.ts b/src/lib/profile-batch-coordinator.ts index 095d7018..0e91281d 100644 --- a/src/lib/profile-batch-coordinator.ts +++ b/src/lib/profile-batch-coordinator.ts @@ -8,6 +8,8 @@ import { PROFILE_BATCH_POST_COOLDOWN_MS } from '@/constants' const awaitingBatch = new Set() const batchCooldownUntil = new Map() +/** Blocks all per-pubkey metadata/payment relay traffic (e.g. while a note panel is open). */ +let globalProfileNetworkDeferUntil = 0 function norm(pk: string): string { return pk.trim().toLowerCase() @@ -47,8 +49,18 @@ export function isPubkeyInProfileBatchCooldown(pubkey: string): boolean { return true } -/** True while batch is in flight or during the post-batch cooldown window. */ +/** Extend the global defer window (e.g. opening a note panel — avoids re-storming the whole feed). */ +export function extendProfileNetworkDeferral(durationMs: number): void { + if (durationMs <= 0) return + const until = Date.now() + durationMs + if (until > globalProfileNetworkDeferUntil) { + globalProfileNetworkDeferUntil = until + } +} + +/** True while batch is in flight, post-batch cooldown, or a global defer window is active. */ export function shouldDeferPerPubkeyProfileNetwork(pubkey: string): boolean { + if (Date.now() < globalProfileNetworkDeferUntil) return true return isPubkeyAwaitingProfileBatch(pubkey) || isPubkeyInProfileBatchCooldown(pubkey) } diff --git a/src/providers/ThreadProfileBatchProvider.tsx b/src/providers/ThreadProfileBatchProvider.tsx index 9d119bdc..50a0bf85 100644 --- a/src/providers/ThreadProfileBatchProvider.tsx +++ b/src/providers/ThreadProfileBatchProvider.tsx @@ -1,5 +1,9 @@ +import { PROFILE_SECONDARY_PANEL_DEFER_MS } from '@/constants' import client from '@/services/client.service' -import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator' +import { + collectProfilePubkeysFromEvents, + extendProfileNetworkDeferral +} from '@/lib/profile-batch-coordinator' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { NoteFeedProfileContext, @@ -105,6 +109,7 @@ export function ThreadProfileBatchProvider({ ) useLayoutEffect(() => { + extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS) genRef.current += 1 const gen = genRef.current loadedRef.current.clear() diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 929684ef..aa2a8bd3 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -132,6 +132,9 @@ export class ReplaceableEventService { private authorReplaceablesRefreshByPubkey = new Map>() /** Per-author cooldown after a successful profile-view replaceable sweep (avoids reopen loops). */ private authorProfileViewRefreshNotBeforeMs = new Map() + /** Coalesce IDB-hit background refreshes so feed paint does not open one REQ per row per 100ms window. */ + private backgroundRefreshByKey = new Map() + private backgroundRefreshFlushTimer: ReturnType | null = null private replaceableEventFromBigRelaysDataloader: DataLoader< { pubkey: string; kind: number }, NEvent | null, @@ -258,7 +261,7 @@ export class ReplaceableEventService { this.replaceableEventFromBigRelaysDataloader.prime({ pubkey, kind }, Promise.resolve(pick)) void indexedDb.putReplaceableEvent(pick).catch(() => {}) if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) { - void this.refreshInBackground(pubkey, kind, d).catch(() => {}) + this.refreshInBackground(pubkey, kind, d) } return pick } @@ -273,7 +276,7 @@ export class ReplaceableEventService { const indexedDbCached = await indexedDb.getReplaceableEvent(pubkey, kind, d) if (indexedDbCached) { // Refresh in background - this.refreshInBackground(pubkey, kind, d).catch(() => {}) + this.refreshInBackground(pubkey, kind, d) return indexedDbCached } } catch (error) { @@ -340,18 +343,49 @@ export class ReplaceableEventService { } /** - * Refresh event in background (non-blocking) + * Refresh event in background (non-blocking). Batched via {@link flushScheduledBackgroundRefresh}. */ - private async refreshInBackground(pubkey: string, kind: number, d?: string): Promise { + private refreshInBackground(pubkey: string, kind: number, d?: string): void { if (shouldDeferPerPubkeyProfileNetwork(pubkey)) return - try { - if (d) { + const key = d ? `${pubkey}:${kind}:${d}` : `${pubkey}:${kind}` + if (!this.backgroundRefreshByKey.has(key)) { + this.backgroundRefreshByKey.set(key, { pubkey, kind, d }) + } + if (this.backgroundRefreshFlushTimer != null) return + this.backgroundRefreshFlushTimer = setTimeout(() => { + this.backgroundRefreshFlushTimer = null + void this.flushScheduledBackgroundRefresh() + }, 250) + } + + private async flushScheduledBackgroundRefresh(): Promise { + const entries = [...this.backgroundRefreshByKey.values()] + this.backgroundRefreshByKey.clear() + if (entries.length === 0) return + + const withoutD = entries.filter((e) => !e.d) + const withD = entries.filter((e) => e.d) + + if (withoutD.length > 0) { + const eligible = withoutD.filter((e) => !shouldDeferPerPubkeyProfileNetwork(e.pubkey)) + if (eligible.length > 0) { + try { + await this.replaceableEventFromBigRelaysDataloader.loadMany( + eligible.map((e) => ({ pubkey: e.pubkey, kind: e.kind })) + ) + } catch { + /* ignore */ + } + } + } + + for (const { pubkey, kind, d } of withD) { + if (shouldDeferPerPubkeyProfileNetwork(pubkey)) continue + try { await this.replaceableEventDataLoader.load({ pubkey, kind, d }) - } else { - await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind }) + } catch { + /* ignore */ } - } catch { - // Ignore errors in background refresh } } @@ -523,7 +557,7 @@ export class ReplaceableEventService { if (event && event !== null) { results[index] = event eventsMap.set(`${pubkey}:${kind}`, event) - this.refreshInBackground(pubkey, kind).catch(() => {}) + this.refreshInBackground(pubkey, kind) } else { missingParams.push({ pubkey, kind, index }) } @@ -1000,7 +1034,7 @@ export class ReplaceableEventService { await this.indexProfile(sessionEv) void indexedDb.putReplaceableEvent(sessionEv).catch(() => {}) if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) { - void this.refreshInBackground(pubkey, kinds.Metadata).catch(() => {}) + this.refreshInBackground(pubkey, kinds.Metadata) } return sessionEv } @@ -1013,7 +1047,7 @@ export class ReplaceableEventService { ) await this.indexProfile(idbEv) if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) { - void this.refreshInBackground(pubkey, kinds.Metadata).catch(() => {}) + this.refreshInBackground(pubkey, kinds.Metadata) } return idbEv }