You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

708 lines
26 KiB

import { FEED_PROFILE_PENDING_BATCH_ESCAPE_MS, PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed'
import { normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey'
import { useNostrOptional } from '@/providers/nostr-context'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { eventService, replaceableEventService } from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
import indexedDb from '@/services/indexed-db.service'
import { TProfile } from '@/types'
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()
const sessionEv = eventService.getSessionMetadataForPubkey(pk)
if (sessionEv) {
return getProfileFromEvent(sessionEv)
}
return null
}
/** Single-flight IndexedDB kind-0 read (never blocks callers until they await the promise). */
function profileFromIdbPromise(pubkey: string, skipCache: boolean): Promise<TProfile | null> {
if (skipCache) return Promise.resolve(null)
const pk = pubkey.toLowerCase()
return indexedDb
.getReplaceableEvent(pk, kinds.Metadata)
.then((idbEv) => {
if (idbEv && !shouldDropEventOnIngest(idbEv)) {
return getProfileFromEvent(idbEv)
}
return null
})
.catch(() => null)
}
/**
* Session LRU + IndexedDB kind 0 without ReplaceableEventService / batched DataLoader.
* Used when the hook's fetch race times out or the batch path is slow while disk/session already has metadata.
*/
async function tryHydrateProfileFromLocalCaches(
pubkey: string,
skipCache: boolean
): Promise<TProfile | null> {
const fromSession = tryHydrateProfileFromSessionOnly(pubkey, skipCache)
if (fromSession) return fromSession
return profileFromIdbPromise(pubkey, skipCache)
}
// CRITICAL: Global deduplication - shared across ALL hook instances
// This prevents multiple components from fetching the same profile simultaneously
const globalFetchPromises = new Map<string, Promise<TProfile | null>>()
const globalFetchingPubkeys = new Set<string>()
// Cooldown period after timeout to prevent cascade of duplicate fetches
const globalFetchCooldowns = new Map<string, number>() // pubkey -> timestamp when cooldown expires
export function useFetchProfile(id?: string, skipCache = false) {
const nostr = useNostrOptional()
const currentAccountProfile = nostr?.profile ?? null
const noteFeed = useNoteFeedProfileContext()
/** Hex/npub ids can show npub fallback immediately; avoid a skeleton frame before the first effect. */
const [isFetching, setIsFetching] = useState(() => {
if (!id) return false
const pk = userIdToPubkey(id)
return !(pk.length === 64 && /^[0-9a-f]{64}$/.test(pk))
})
const [error, setError] = useState<Error | null>(null)
const [profile, setProfile] = useState<TProfile | null>(null)
const [pubkey, setPubkey] = useState<string | null>(null)
const checkIntervalRef = useRef<NodeJS.Timeout | null>(null)
const processingPubkeyRef = useRef<string | null>(null) // Track which pubkey we're currently processing (prevents duplicate fetches)
const effectRunCountRef = useRef<Map<string, number>>(new Map()) // Track how many times effect has run for each pubkey (safety guard against infinite loops)
const initializedPubkeysRef = useRef<Set<string>>(new Set()) // Track pubkeys we've successfully initialized (have profile or failed)
const pkLowerResolved = useMemo(() => {
if (!id) return null as string | null
const pk = userIdToPubkey(id)
if (pk.length !== 64 || !/^[0-9a-f]{64}$/i.test(pk)) return null
return pk.toLowerCase()
}, [id])
const isPendingInFeed = Boolean(
pkLowerResolved && noteFeed?.pendingPubkeys.has(pkLowerResolved)
)
/**
* Changes when this row's batched profile row appears/updates — **not** on every feed-wide
* `version` tick (that remounted thousands of avatars and spammed relay fetches).
*/
const feedProfileSyncKey = useMemo(() => {
if (!pkLowerResolved || !noteFeed) return ''
const row = noteFeed.profiles.get(pkLowerResolved)
if (!row) return isPendingInFeed ? 'p:wait' : 'p:none'
return [
row.batchPlaceholder ? 'ph' : 'ok',
row.username ?? '',
row.avatar ?? '',
row.npub ?? ''
].join('\x1e')
}, [pkLowerResolved, noteFeed?.profiles, isPendingInFeed])
// Function to check for profile updates with GLOBAL deduplication
// fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays)
// Memoize to prevent recreation on every render
const checkProfile = useCallback(async (pubkey: string, cancelled: { current: boolean }): Promise<TProfile | null> => {
if (cancelled.current) {
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).
const cooldownExpiry = globalFetchCooldowns.get(pubkey)
if (cooldownExpiry && Date.now() < cooldownExpiry) {
const cachedDuringCooldown = await tryHydrateProfileFromLocalCaches(pubkey, skipCache)
if (!cancelled.current && cachedDuringCooldown) {
setProfile(cachedDuringCooldown)
setIsFetching(false)
initializedPubkeysRef.current.add(pubkey)
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
effectRunCountRef.current.delete(pubkey)
return cachedDuringCooldown
}
return null
}
// Clean up expired cooldowns
if (cooldownExpiry && Date.now() >= cooldownExpiry) {
globalFetchCooldowns.delete(pubkey)
}
// CRITICAL: Check if another hook instance is already fetching this pubkey
// If so, wait for that fetch to complete instead of starting a new one
// Add timeout protection to prevent infinite waits
const existingPromise = globalFetchPromises.get(pubkey)
if (existingPromise) {
try {
// Await the shared promise only — it already races fetchProfileEvent with
// PROFILE_FETCH_PROMISE_TIMEOUT_MS. Per-waiter Promise.race timers caused N identical
// "timeout" warnings (one per mounted component) and premature map deletion.
const existingProfile = await existingPromise
if (cancelled.current) return null
if (existingProfile) {
// Update state for this instance
setProfile(existingProfile)
setIsFetching(false)
initializedPubkeysRef.current.add(pubkey)
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
effectRunCountRef.current.delete(pubkey)
return existingProfile
} else {
setIsFetching(false)
return null
}
} catch (err) {
// If the existing promise failed, we'll try again below
void err
// Clear the failed promise so we can start fresh
globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey)
}
}
// Mark as fetching globally to prevent other instances from starting
if (globalFetchingPubkeys.has(pubkey)) {
// Another instance is fetching, wait a bit and check again
await new Promise(resolve => setTimeout(resolve, 50))
const retryPromise = globalFetchPromises.get(pubkey)
if (retryPromise) {
try {
const retryProfile = await retryPromise
if (cancelled.current) return null
if (retryProfile) {
// Update state for this instance
setProfile(retryProfile)
setIsFetching(false)
initializedPubkeysRef.current.add(pubkey)
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
effectRunCountRef.current.delete(pubkey)
return retryProfile
} else {
setIsFetching(false)
return null
}
} catch (err) {
void err
// Clear the failed promise
globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey)
// Fall through to start our own fetch
}
}
}
// Create a new fetch promise with timeout protection
const fetchPromise = (async (): Promise<TProfile | null> => {
let idbEarlyP: Promise<TProfile | null> | null = null
try {
globalFetchingPubkeys.add(pubkey)
/** Session-only fast path removed: {@link replaceableEventService.fetchProfileEvent} still refreshes from relays while session primes the loader. */
/** Disk read runs in parallel with `fetchProfileEvent` — never block network on IDB. */
idbEarlyP = profileFromIdbPromise(pubkey, skipCache)
// CRITICAL: Add timeout to prevent infinite hangs (must exceed batched metadata query globalTimeout)
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(
new Error(
`Profile fetch timeout after ${PROFILE_FETCH_PROMISE_TIMEOUT_MS}ms for pubkey ${pubkey.substring(0, 8)}`
)
)
}, PROFILE_FETCH_PROMISE_TIMEOUT_MS)
})
// Use fetchProfileEvent which includes author's relay list for better profile discovery
const profileEvent = await Promise.race([
replaceableEventService.fetchProfileEvent(pubkey, skipCache),
timeoutPromise
])
if (profileEvent) {
// getProfileFromEvent always returns a profile object (with fallback username)
const newProfile = getProfileFromEvent(profileEvent)
// CRITICAL: Always return the profile from this shared promise, even when the
// originating hook cleaned up (list virtualization, Strict Mode, feed switch).
// Returning null here made every waiter treat the result like a timeout, applied
// cooldowns, and left avatars empty (especially busy feeds e.g. all-favorites).
return newProfile
}
const afterMiss =
(idbEarlyP != null ? await idbEarlyP : null) ?? tryHydrateProfileFromSessionOnly(pubkey, skipCache)
if (afterMiss) {
return afterMiss
}
return null
} catch (err) {
const isTimeout = err instanceof Error && err.message.includes('timeout')
if (isTimeout) {
// Set cooldown period after timeout to prevent cascade of duplicate fetches
globalFetchCooldowns.set(pubkey, Date.now() + 10000) // 10 second cooldown
const fallback =
tryHydrateProfileFromSessionOnly(pubkey, skipCache) ??
(idbEarlyP != null ? await idbEarlyP : null)
if (fallback) {
return fallback
}
// Return null on timeout instead of throwing - allows UI to show fallback
return null
}
logger.error('[useFetchProfile] Profile fetch error', {
pubkey: pubkey.substring(0, 8),
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
cancelled: cancelled.current
})
// For non-timeout errors, still throw to allow retry logic
throw err
} finally {
// Clean up global tracking
globalFetchingPubkeys.delete(pubkey)
// Keep promise in cache for a short time to allow other instances to reuse it
// But remove it immediately on timeout/error to allow retries
setTimeout(() => {
globalFetchPromises.delete(pubkey)
}, 1000) // 1 second cache retention
}
})()
// Store the promise globally so other instances can reuse it
globalFetchPromises.set(pubkey, fetchPromise)
try {
const profile = await fetchPromise
if (cancelled.current) return null
if (profile) {
setProfile(profile)
setIsFetching(false)
// Mark as initialized
initializedPubkeysRef.current.add(pubkey)
// Keep processingPubkeyRef set so we don't re-fetch
// Clear interval once we have a profile
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
// Clear run count when profile is found
effectRunCountRef.current.delete(pubkey)
return profile
}
if (!cancelled.current) {
setIsFetching(false)
}
return null
} catch (err) {
if (!cancelled.current) {
setError(err as Error)
setIsFetching(false)
}
return null
}
}, [skipCache])
useEffect(() => {
if (!id) {
setProfile(null)
setPubkey(null)
setIsFetching(false)
setError(new Error('No id provided'))
processingPubkeyRef.current = null
return
}
const extractedPubkey = userIdToPubkey(id)
// Note feeds: profiles are batch-fetched in NoteList — skip per-row relay storms while pending.
if (extractedPubkey && noteFeed && !skipCache) {
const pkL = extractedPubkey.toLowerCase()
const fromBatch = noteFeed.profiles.get(pkL) ?? noteFeed.profiles.get(extractedPubkey)
if (fromBatch && !fromBatch.batchPlaceholder) {
setProfile(fromBatch)
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
processingPubkeyRef.current = extractedPubkey
initializedPubkeysRef.current.add(extractedPubkey)
effectRunCountRef.current.delete(extractedPubkey)
return
}
if (fromBatch?.batchPlaceholder) {
initializedPubkeysRef.current.delete(extractedPubkey)
setProfile(fromBatch)
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
}
if (noteFeed.pendingPubkeys.has(pkL)) {
const sessionEv = eventService.getSessionMetadataForPubkey(pkL)
if (sessionEv) {
const quick = getProfileFromEvent(sessionEv)
setProfile(quick)
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
processingPubkeyRef.current = extractedPubkey
initializedPubkeysRef.current.add(extractedPubkey)
effectRunCountRef.current.delete(extractedPubkey)
return
}
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
const pendingCancelled = { current: false }
void tryHydrateProfileFromLocalCaches(pkL, false).then((quick) => {
if (pendingCancelled.current || !quick) return
setProfile(quick)
setIsFetching(false)
setError(null)
processingPubkeyRef.current = extractedPubkey
initializedPubkeysRef.current.add(extractedPubkey)
effectRunCountRef.current.delete(extractedPubkey)
})
const pendingEscapeTimer = window.setTimeout(() => {
if (pendingCancelled.current) return
const s2 = eventService.getSessionMetadataForPubkey(pkL)
if (s2) {
const q = getProfileFromEvent(s2)
setProfile(q)
setIsFetching(false)
setError(null)
processingPubkeyRef.current = extractedPubkey
initializedPubkeysRef.current.add(extractedPubkey)
effectRunCountRef.current.delete(extractedPubkey)
return
}
void checkProfile(extractedPubkey, pendingCancelled)
}, FEED_PROFILE_PENDING_BATCH_ESCAPE_MS)
return () => {
pendingCancelled.current = true
window.clearTimeout(pendingEscapeTimer)
if (processingPubkeyRef.current === extractedPubkey) {
processingPubkeyRef.current = null
}
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
if (extractedPubkey) {
effectRunCountRef.current.delete(extractedPubkey)
}
}
}
if (fromBatch?.batchPlaceholder) {
const placeholderCancelled = { current: false }
const staggerMs = feedProfileBatchRetryStaggerMs(pkL)
const placeholderTimer = window.setTimeout(() => {
if (placeholderCancelled.current) return
void checkProfile(extractedPubkey, placeholderCancelled)
}, staggerMs)
return () => {
placeholderCancelled.current = true
window.clearTimeout(placeholderTimer)
if (processingPubkeyRef.current === extractedPubkey) {
processingPubkeyRef.current = null
}
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
if (extractedPubkey) {
effectRunCountRef.current.delete(extractedPubkey)
}
}
}
}
// Userbadge → profile panel: feed row already had this profile, but secondary stack is outside NoteFeedProfileContext.
if (extractedPubkey && !skipCache) {
const fromNavigation = getSeededProfileForNavigation(extractedPubkey)
if (fromNavigation) {
setProfile(fromNavigation)
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
processingPubkeyRef.current = extractedPubkey
initializedPubkeysRef.current.add(extractedPubkey)
effectRunCountRef.current.delete(extractedPubkey)
return
}
}
// Skip only when this pubkey already has an in-flight fetch (global dedupe + local flag).
if (extractedPubkey) {
if (processingPubkeyRef.current === extractedPubkey) {
const sharedPromise = globalFetchPromises.get(extractedPubkey)
const busy =
Boolean(sharedPromise) ||
globalFetchingPubkeys.has(extractedPubkey) ||
isFetching
if (busy) return
if (profile?.pubkey === extractedPubkey && !profile.batchPlaceholder) return
}
processingPubkeyRef.current = extractedPubkey
}
if (extractedPubkey && profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) {
if (processingPubkeyRef.current !== extractedPubkey) {
processingPubkeyRef.current = extractedPubkey
}
initializedPubkeysRef.current.add(extractedPubkey)
if (isFetching) {
setIsFetching(false)
}
effectRunCountRef.current.delete(extractedPubkey)
return
}
if (extractedPubkey && initializedPubkeysRef.current.has(extractedPubkey) && !profile) {
if (skipCache) {
initializedPubkeysRef.current.delete(extractedPubkey)
effectRunCountRef.current.delete(extractedPubkey)
} else {
if (isFetching) {
setIsFetching(false)
}
return
}
}
if (extractedPubkey) {
const runCount = effectRunCountRef.current.get(extractedPubkey) || 0
const pkLower = extractedPubkey.toLowerCase()
const feedBatchPlaceholder =
noteFeed?.profiles.get(pkLower)?.batchPlaceholder === true ||
noteFeed?.profiles.get(extractedPubkey)?.batchPlaceholder === true
const maxRunsBeforeCircuitBreak = feedBatchPlaceholder ? 6 : 3
if (runCount >= maxRunsBeforeCircuitBreak) {
logger.warn('[useFetchProfile] Too many effect runs for this pubkey, preventing infinite loop', {
extractedPubkey,
runCount
})
setTimeout(() => {
effectRunCountRef.current.delete(extractedPubkey)
}, 30000)
processingPubkeyRef.current = null
if (isFetching) setIsFetching(false)
return
}
effectRunCountRef.current.set(extractedPubkey, runCount + 1)
}
if (extractedPubkey && processingPubkeyRef.current && processingPubkeyRef.current !== extractedPubkey) {
const oldPubkey = processingPubkeyRef.current
effectRunCountRef.current.delete(oldPubkey)
initializedPubkeysRef.current.delete(oldPubkey)
processingPubkeyRef.current = null
}
const cancelled = { current: false }
if (!extractedPubkey) {
setProfile(null)
setPubkey(null)
setIsFetching(false)
setError(new Error('Invalid id: could not extract pubkey'))
processingPubkeyRef.current = null
return
}
if (extractedPubkey.length !== 64 || !/^[0-9a-f]{64}$/.test(extractedPubkey)) {
setProfile(null)
setPubkey(null)
setIsFetching(false)
setError(new Error(`Invalid pubkey format: expected 64 hex chars, got ${extractedPubkey.length}`))
processingPubkeyRef.current = null
return
}
if (processingPubkeyRef.current !== extractedPubkey) {
logger.warn('[useFetchProfile] processingPubkeyRef mismatch (safety check)', {
extractedPubkey,
processingPubkey: processingPubkeyRef.current
})
processingPubkeyRef.current = extractedPubkey
}
if (profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) {
setIsFetching(false)
effectRunCountRef.current.delete(extractedPubkey)
return
}
if (pubkey !== extractedPubkey) {
setPubkey(extractedPubkey)
}
const run = async () => {
try {
setError(null)
const earlyProfile =
tryHydrateProfileFromSessionOnly(extractedPubkey, skipCache) ??
(await profileFromIdbPromise(extractedPubkey, skipCache))
if (!cancelled.current && earlyProfile) {
setProfile(earlyProfile)
setIsFetching(false)
} else if (!cancelled.current) {
setIsFetching(true)
}
const profile = await checkProfile(extractedPubkey, cancelled)
if (cancelled.current) {
setIsFetching(false)
return
}
if (profile) {
return
}
setIsFetching(false)
setError(null)
if (skipCache) {
let checkCount = 0
const maxChecks = 3
const startTime = Date.now()
const maxTotalTime = 20000
checkIntervalRef.current = setInterval(async () => {
const elapsed = Date.now() - startTime
if (elapsed > maxTotalTime) {
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
return
}
if (cancelled.current || checkCount >= maxChecks) {
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
return
}
checkCount++
const profile = await checkProfile(extractedPubkey, cancelled)
if (profile || cancelled.current) {
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
}
}, 10000)
}
} catch (err) {
logger.error('[useFetchProfile] run() error', {
pubkey: extractedPubkey,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
})
if (!cancelled.current) {
setError(err as Error)
setIsFetching(false)
}
}
}
run().catch((err) => {
logger.error('[useFetchProfile] Unhandled error in run()', {
pubkey: extractedPubkey,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
})
})
return () => {
cancelled.current = true
if (processingPubkeyRef.current === extractedPubkey) {
processingPubkeyRef.current = null
}
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
if (extractedPubkey) {
effectRunCountRef.current.delete(extractedPubkey)
}
}
}, [id, skipCache, feedProfileSyncKey, checkProfile])
useEffect(() => {
const acc = currentAccountProfile
const accPk = acc?.pubkey
if (!accPk || !id) return
const targetPk = userIdToPubkey(id)
if (targetPk.length !== 64 || !/^[0-9a-f]{64}$/i.test(targetPk)) return
if (targetPk !== accPk.toLowerCase()) return
const profilePk = profile?.pubkey?.trim()
const haveFullLocal =
!!profilePk &&
/^[0-9a-f]{64}$/i.test(profilePk) &&
normalizeHexPubkey(profilePk) === targetPk &&
!profile?.batchPlaceholder
if (haveFullLocal) return
setProfile(acc)
setIsFetching(false)
setError(null)
processingPubkeyRef.current = targetPk
initializedPubkeysRef.current.add(targetPk)
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
effectRunCountRef.current.delete(targetPk)
}, [currentAccountProfile, id, profile])
const profileRefreshCancelledRef = useRef(false)
useEffect(() => {
profileRefreshCancelledRef.current = false
return () => {
profileRefreshCancelledRef.current = true
}
}, [pkLowerResolved])
useEffect(() => {
if (!pkLowerResolved) return
const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => {
const detailPk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase()
if (detailPk !== pkLowerResolved) return
void checkProfile(pkLowerResolved, { current: profileRefreshCancelledRef.current })
}
window.addEventListener(
ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT,
onAuthorReplaceablesRefreshed
)
return () =>
window.removeEventListener(
ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT,
onAuthorReplaceablesRefreshed
)
}, [pkLowerResolved, checkProfile])
return { isFetching, error, profile }
}