Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
cf629ee7a2
  1. 3
      src/PageManager.tsx
  2. 60
      src/components/NoteStats/ZapButton.tsx
  3. 7
      src/constants.ts
  4. 14
      src/lib/profile-batch-coordinator.ts
  5. 7
      src/providers/ThreadProfileBatchProvider.tsx
  6. 58
      src/services/client-replaceable-events.service.ts

3
src/PageManager.tsx

@ -14,6 +14,8 @@ import { NavigationService } from '@/services/navigation.service'
import { ImwaldBrandBar } from '@/assets/Logo' import { ImwaldBrandBar } from '@/assets/Logo'
import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' import LiveActivitiesStrip from '@/components/LiveActivitiesStrip'
import NoteDrawer from '@/components/NoteDrawer' 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 client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { navigationEventStore } from '@/services/navigation-event-store' import { navigationEventStore } from '@/services/navigation-event-store'
@ -2172,6 +2174,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
useLayoutEffect(() => { useLayoutEffect(() => {
noteStatsService.setBackgroundStatsPaused(primaryFrozen) noteStatsService.setBackgroundStatsPaused(primaryFrozen)
if (primaryFrozen) { if (primaryFrozen) {
extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS)
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
} }
}, [primaryFrozen]) }, [primaryFrozen])

60
src/components/NoteStats/ZapButton.tsx

@ -41,15 +41,49 @@ function formatAmount(amount: number) {
return `${Math.round(amount / 100000) / 10}M` return `${Math.round(amount / 100000) / 10}M`
} }
type ZapRecipientResolveResult = {
profile: TProfile | null
profileEvent: Event | undefined
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null
}
const zapRecipientResolveByPubkey = new Map<string, Promise<ZapRecipientResolveResult>>()
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. */ /** Avoid one metadata + payment REQ per visible note while feed profile batch runs. */
async function resolveZapRecipientData( async function resolveZapRecipientData(
authorPubkey: string, authorPubkey: string,
feedProfile: TProfile | undefined | null feedProfile: TProfile | undefined | null
): Promise<{ ): Promise<ZapRecipientResolveResult> {
profile: TProfile | null const pk = authorPubkey.toLowerCase()
profileEvent: Event | undefined const inFlight = zapRecipientResolveByPubkey.get(pk)
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null 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<ZapRecipientResolveResult> {
const cachedFeed = const cachedFeed =
feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null
const deferNetwork = shouldDeferPerPubkeyProfileNetwork(authorPubkey) const deferNetwork = shouldDeferPerPubkeyProfileNetwork(authorPubkey)
@ -114,6 +148,10 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
const feedProfile = feedProfiles?.profiles.get(authorPubkey) const feedProfile = feedProfiles?.profiles.get(authorPubkey)
const feedProfileRef = useRef(feedProfile) const feedProfileRef = useRef(feedProfile)
feedProfileRef.current = feedProfile feedProfileRef.current = feedProfile
const feedProfileSyncKey = feedProfileRowSyncKey(
feedProfile,
Boolean(feedProfiles?.pendingPubkeys.has(authorPubkey))
)
const [disable, setDisable] = useState(true) const [disable, setDisable] = useState(true)
const [tipPaymentData, setTipPaymentData] = useState<RecipientZapPaymentData | null>(null) const [tipPaymentData, setTipPaymentData] = useState<RecipientZapPaymentData | null>(null)
@ -144,7 +182,7 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
if (isSelf) return if (isSelf) return
if (!feedProfile || feedProfile.batchPlaceholder) return if (!feedProfile || feedProfile.batchPlaceholder) return
applyTipAvailability(feedProfile, null, null) applyTipAvailability(feedProfile, null, null)
}, [isSelf, feedProfile, feedProfiles?.version, applyTipAvailability]) }, [isSelf, feedProfile, feedProfileSyncKey, applyTipAvailability])
useEffect(() => { useEffect(() => {
if (isSelf) { if (isSelf) {
@ -167,7 +205,7 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
return () => { return () => {
cancelled = true cancelled = true
} }
}, [authorPubkey, isSelf, feedProfiles?.version, applyTipAvailability]) }, [authorPubkey, isSelf, feedProfileSyncKey, applyTipAvailability])
const handleOpenPaymentMethods = (e: React.MouseEvent) => { const handleOpenPaymentMethods = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
@ -248,6 +286,10 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
const feedProfile = feedProfiles?.profiles.get(authorPubkey) const feedProfile = feedProfiles?.profiles.get(authorPubkey)
const feedProfileRef = useRef(feedProfile) const feedProfileRef = useRef(feedProfile)
feedProfileRef.current = feedProfile feedProfileRef.current = feedProfile
const feedProfileSyncKey = feedProfileRowSyncKey(
feedProfile,
Boolean(feedProfiles?.pendingPubkeys.has(authorPubkey))
)
const [disable, setDisable] = useState(true) const [disable, setDisable] = useState(true)
const [canLightningZap, setCanLightningZap] = useState(false) const [canLightningZap, setCanLightningZap] = useState(false)
@ -289,7 +331,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
if (isSelf) return if (isSelf) return
if (!feedProfile || feedProfile.batchPlaceholder) return if (!feedProfile || feedProfile.batchPlaceholder) return
applyTipAvailability(feedProfile, null, null, true) applyTipAvailability(feedProfile, null, null, true)
}, [isSelf, feedProfile, feedProfiles?.version, applyTipAvailability]) }, [isSelf, feedProfile, feedProfileSyncKey, applyTipAvailability])
useEffect(() => { useEffect(() => {
if (isSelf) { if (isSelf) {
@ -314,7 +356,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
return () => { return () => {
cancelled = true cancelled = true
} }
}, [authorPubkey, isSelf, feedProfiles?.version, applyTipAvailability]) }, [authorPubkey, isSelf, feedProfileSyncKey, applyTipAvailability])
const handleZap = async () => { const handleZap = async () => {
try { try {

7
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 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). * Hex-id / replaceable-coordinate note lookup ({@link EventService.tryHarderToFetchEvent}, big-relays dataloader).
*/ */

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

@ -8,6 +8,8 @@ import { PROFILE_BATCH_POST_COOLDOWN_MS } from '@/constants'
const awaitingBatch = new Set<string>() const awaitingBatch = new Set<string>()
const batchCooldownUntil = new Map<string, number>() const batchCooldownUntil = new Map<string, number>()
/** Blocks all per-pubkey metadata/payment relay traffic (e.g. while a note panel is open). */
let globalProfileNetworkDeferUntil = 0
function norm(pk: string): string { function norm(pk: string): string {
return pk.trim().toLowerCase() return pk.trim().toLowerCase()
@ -47,8 +49,18 @@ export function isPubkeyInProfileBatchCooldown(pubkey: string): boolean {
return true 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 { export function shouldDeferPerPubkeyProfileNetwork(pubkey: string): boolean {
if (Date.now() < globalProfileNetworkDeferUntil) return true
return isPubkeyAwaitingProfileBatch(pubkey) || isPubkeyInProfileBatchCooldown(pubkey) return isPubkeyAwaitingProfileBatch(pubkey) || isPubkeyInProfileBatchCooldown(pubkey)
} }

7
src/providers/ThreadProfileBatchProvider.tsx

@ -1,5 +1,9 @@
import { PROFILE_SECONDARY_PANEL_DEFER_MS } from '@/constants'
import client from '@/services/client.service' 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 { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { import {
NoteFeedProfileContext, NoteFeedProfileContext,
@ -105,6 +109,7 @@ export function ThreadProfileBatchProvider({
) )
useLayoutEffect(() => { useLayoutEffect(() => {
extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS)
genRef.current += 1 genRef.current += 1
const gen = genRef.current const gen = genRef.current
loadedRef.current.clear() loadedRef.current.clear()

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

@ -132,6 +132,9 @@ export class ReplaceableEventService {
private authorReplaceablesRefreshByPubkey = new Map<string, Promise<void>>() private authorReplaceablesRefreshByPubkey = new Map<string, Promise<void>>()
/** Per-author cooldown after a successful profile-view replaceable sweep (avoids reopen loops). */ /** Per-author cooldown after a successful profile-view replaceable sweep (avoids reopen loops). */
private authorProfileViewRefreshNotBeforeMs = new Map<string, number>() private authorProfileViewRefreshNotBeforeMs = new Map<string, number>()
/** Coalesce IDB-hit background refreshes so feed paint does not open one REQ per row per 100ms window. */
private backgroundRefreshByKey = new Map<string, { pubkey: string; kind: number; d?: string }>()
private backgroundRefreshFlushTimer: ReturnType<typeof setTimeout> | null = null
private replaceableEventFromBigRelaysDataloader: DataLoader< private replaceableEventFromBigRelaysDataloader: DataLoader<
{ pubkey: string; kind: number }, { pubkey: string; kind: number },
NEvent | null, NEvent | null,
@ -258,7 +261,7 @@ export class ReplaceableEventService {
this.replaceableEventFromBigRelaysDataloader.prime({ pubkey, kind }, Promise.resolve(pick)) this.replaceableEventFromBigRelaysDataloader.prime({ pubkey, kind }, Promise.resolve(pick))
void indexedDb.putReplaceableEvent(pick).catch(() => {}) void indexedDb.putReplaceableEvent(pick).catch(() => {})
if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) { if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) {
void this.refreshInBackground(pubkey, kind, d).catch(() => {}) this.refreshInBackground(pubkey, kind, d)
} }
return pick return pick
} }
@ -273,7 +276,7 @@ export class ReplaceableEventService {
const indexedDbCached = await indexedDb.getReplaceableEvent(pubkey, kind, d) const indexedDbCached = await indexedDb.getReplaceableEvent(pubkey, kind, d)
if (indexedDbCached) { if (indexedDbCached) {
// Refresh in background // Refresh in background
this.refreshInBackground(pubkey, kind, d).catch(() => {}) this.refreshInBackground(pubkey, kind, d)
return indexedDbCached return indexedDbCached
} }
} catch (error) { } 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<void> { private refreshInBackground(pubkey: string, kind: number, d?: string): void {
if (shouldDeferPerPubkeyProfileNetwork(pubkey)) return if (shouldDeferPerPubkeyProfileNetwork(pubkey)) return
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<void> {
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 { try {
if (d) { await this.replaceableEventFromBigRelaysDataloader.loadMany(
await this.replaceableEventDataLoader.load({ pubkey, kind, d }) eligible.map((e) => ({ pubkey: e.pubkey, kind: e.kind }))
} else { )
await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind }) } catch {
/* ignore */
}
} }
}
for (const { pubkey, kind, d } of withD) {
if (shouldDeferPerPubkeyProfileNetwork(pubkey)) continue
try {
await this.replaceableEventDataLoader.load({ pubkey, kind, d })
} catch { } catch {
// Ignore errors in background refresh /* ignore */
}
} }
} }
@ -523,7 +557,7 @@ export class ReplaceableEventService {
if (event && event !== null) { if (event && event !== null) {
results[index] = event results[index] = event
eventsMap.set(`${pubkey}:${kind}`, event) eventsMap.set(`${pubkey}:${kind}`, event)
this.refreshInBackground(pubkey, kind).catch(() => {}) this.refreshInBackground(pubkey, kind)
} else { } else {
missingParams.push({ pubkey, kind, index }) missingParams.push({ pubkey, kind, index })
} }
@ -1000,7 +1034,7 @@ export class ReplaceableEventService {
await this.indexProfile(sessionEv) await this.indexProfile(sessionEv)
void indexedDb.putReplaceableEvent(sessionEv).catch(() => {}) void indexedDb.putReplaceableEvent(sessionEv).catch(() => {})
if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) { if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) {
void this.refreshInBackground(pubkey, kinds.Metadata).catch(() => {}) this.refreshInBackground(pubkey, kinds.Metadata)
} }
return sessionEv return sessionEv
} }
@ -1013,7 +1047,7 @@ export class ReplaceableEventService {
) )
await this.indexProfile(idbEv) await this.indexProfile(idbEv)
if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) { if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) {
void this.refreshInBackground(pubkey, kinds.Metadata).catch(() => {}) this.refreshInBackground(pubkey, kinds.Metadata)
} }
return idbEv return idbEv
} }

Loading…
Cancel
Save