Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
5700eb4ca4
  1. 109
      src/components/NoteStats/ZapButton.tsx
  2. 6
      src/constants.ts
  3. 40
      src/hooks/useFetchProfile.tsx
  4. 27
      src/lib/profile-batch-coordinator.ts
  5. 88
      src/services/client-replaceable-events.service.ts

109
src/components/NoteStats/ZapButton.tsx

@ -11,6 +11,7 @@ import { @@ -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) { @@ -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<typeof getPaymentInfoFromEvent> | 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 @@ -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]) => {
void resolveZapRecipientData(authorPubkey, feedProfileRef.current).then(
({ profile, profileEvent, paymentInfo }) => {
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)
})
}
)
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 @@ -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]) => {
void resolveZapRecipientData(authorPubkey, feedProfileRef.current).then(
({ profile, profileEvent, paymentInfo }) => {
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)
})
}
)
return () => {
cancelled = true
}
}, [authorPubkey, isSelf, applyTipAvailability])
}, [authorPubkey, isSelf, feedProfiles?.version, applyTipAvailability])
const handleZap = async () => {
try {

6
src/constants.ts

@ -263,6 +263,12 @@ export const FEED_PROFILE_PENDING_BATCH_ESCAPE_MS = FEED_PROFILE_BATCH_FETCH_TIM @@ -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).
*/

40
src/hooks/useFetchProfile.tsx

@ -2,7 +2,10 @@ import { FEED_PROFILE_PENDING_BATCH_ESCAPE_MS, PROFILE_FETCH_PROMISE_TIMEOUT_MS @@ -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' @@ -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) { @@ -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) { @@ -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
}

27
src/lib/profile-batch-coordinator.ts

@ -1,10 +1,13 @@ @@ -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<string>()
const batchCooldownUntil = new Map<string, number>()
function norm(pk: string): string {
return pk.trim().toLowerCase()
@ -19,8 +22,11 @@ export function registerProfileBatchPubkeys(pubkeys: readonly string[]): void { @@ -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 { @@ -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

88
src/services/client-replaceable-events.service.ts

@ -34,18 +34,48 @@ import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eli @@ -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<void> {
if (
ReplaceableEventService.kind0ProfileRelaySlotsInUse <
ReplaceableEventService.MAX_CONCURRENT_KIND0_PROFILE_RELAY_REQS
) {
ReplaceableEventService.kind0ProfileRelaySlotsInUse++
return
}
await new Promise<void>((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<void> {
if (ReplaceableEventService.profileFallbackSlotsInUse < MAX_CONCURRENT_RELAY_CONNECTIONS) {
ReplaceableEventService.profileFallbackSlotsInUse++
@ -867,7 +897,10 @@ export class ReplaceableEventService { @@ -867,7 +897,10 @@ export class ReplaceableEventService {
private async fetchKind0FromProfileRelays(pubkey: string): Promise<NEvent | undefined> {
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 { @@ -902,6 +935,9 @@ export class ReplaceableEventService {
})
return undefined
}
} finally {
ReplaceableEventService.releaseKind0ProfileRelaySlot()
}
}
/**
@ -937,8 +973,7 @@ export class ReplaceableEventService { @@ -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 { @@ -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 { @@ -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 { @@ -1383,7 +1442,20 @@ export class ReplaceableEventService {
/**
* Fetch payment info event
*/
async getPaymentInfoFromIndexedDB(pubkey: string): Promise<NEvent | undefined> {
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<NEvent | undefined> {
if (shouldDeferPerPubkeyProfileNetwork(pubkey)) {
return this.getPaymentInfoFromIndexedDB(pubkey)
}
return await this.fetchReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO)
}

Loading…
Cancel
Save