import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' import { toNostrBuildThumbUrl } from '@/lib/nostr-build' import { isImage, isMedia, isVideo } from '@/lib/url' import { generateImageByPubkey, isValidPubkey, userIdToPubkey } from '@/lib/pubkey' import { toProfile } from '@/lib/link' import { seedProfileForNavigation } from '@/lib/profile-navigation-seed' import { cn } from '@/lib/utils' import { useSmartProfileNavigationOptional } from '@/PageManager' import type { TProfile } from '@/types' import { useMemo, useState, useEffect, useLayoutEffect, useRef, type RefObject } from 'react' /** Only defer network fetches for typical profile picture URLs (not data:, blob:, etc.). */ function isHttpOrHttpsUrl(url: string): boolean { return /^https?:\/\//i.test(url.trim()) } /** Module-level cache: URL → file size in bytes, or null if unknown (CORS blocked / no header). */ const urlSizeCache = new Map() /** * URLs that have fired onLoad successfully this session. * When a URL is here the image is already in the browser's HTTP cache, so we can * skip both the IntersectionObserver delay and the HEAD-request size check. */ const loadedAvatarUrls = new Set() /** * Non-blocking HEAD request to get Content-Length for a URL. * Result is cached permanently in memory. Resolves null on CORS failure or missing header. */ const AVATAR_HEAD_TIMEOUT_MS = 3000 /** Pixels beyond the viewport edge to treat as “visible” for avatar load (matches IO rootMargin intent). */ const AVATAR_VIEWPORT_MARGIN_PX = 320 function elementIsNearViewport(el: HTMLElement, marginPx: number): boolean { const rect = el.getBoundingClientRect() const vh = window.innerHeight const vw = window.innerWidth return ( rect.bottom >= -marginPx && rect.top <= vh + marginPx && rect.right >= -marginPx && rect.left <= vw + marginPx ) } function isSameOriginUrl(url: string): boolean { if (typeof window === 'undefined') return false try { return new URL(url).origin === window.location.origin } catch { return false } } async function fetchUrlSizeBytes(url: string): Promise { if (urlSizeCache.has(url)) return urlSizeCache.get(url)! // Cross-origin HEAD almost never exposes Content-Length to JS without CORS; browsers still log CORS failures. // Skip HEAD for images/media (no point) and for all other cross-origin URLs (HTML homepages, libravatar, etc.). if (isImage(url) || isMedia(url) || !isSameOriginUrl(url)) { urlSizeCache.set(url, null) return null } try { const ctrl = new AbortController() const timer = window.setTimeout(() => ctrl.abort(), AVATAR_HEAD_TIMEOUT_MS) const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal }) clearTimeout(timer) if (!res.ok) { urlSizeCache.set(url, null) return null } const cl = res.headers.get('content-length') const size = cl ? parseInt(cl, 10) : null if (size != null && !Number.isFinite(size)) { urlSizeCache.set(url, null) return null } urlSizeCache.set(url, size) return size } catch { urlSizeCache.set(url, null) return null } } /** * Defer loading remote profile pictures until the avatar is near the viewport so handles/text * can paint first; identicon (data URL) shows until then. * Also enforces an optional maxFileSizeBytes cap — shows fallback for avatars that are confirmed * larger than the cap (based on a cached HEAD request). */ function useDeferRemoteProfileAvatar( profileAvatar: string | undefined, fallbackSrc: string, containerRef: RefObject, maxFileSizeBytes?: number, /** When false, load remote avatars immediately (threads / small lists where every face should appear fast). */ deferRemote = true ): string { const remoteHttp = useMemo(() => { const a = profileAvatar?.trim() if (!a || !isHttpOrHttpsUrl(a)) return '' // Video files don't have a /thumb/ route — serve them as-is. if (isVideo(a)) return a // i.nostr.build serves /thumb/… for images (cdn.nostr.build does not). return toNostrBuildThumbUrl(a) }, [profileAvatar]) const alreadyCached = remoteHttp ? loadedAvatarUrls.has(remoteHttp) : false const [sizeBlocked, setSizeBlocked] = useState(false) useEffect(() => { if (!remoteHttp || !maxFileSizeBytes || alreadyCached) { setSizeBlocked(false) return } if (urlSizeCache.has(remoteHttp)) { const cached = urlSizeCache.get(remoteHttp) setSizeBlocked(cached != null && cached > maxFileSizeBytes) return } fetchUrlSizeBytes(remoteHttp).then((size) => { setSizeBlocked(size != null && size > maxFileSizeBytes) }) }, [remoteHttp, maxFileSizeBytes, alreadyCached]) const nonHttpAvatar = useMemo(() => { const a = profileAvatar?.trim() if (a && !isHttpOrHttpsUrl(a)) return a return '' }, [profileAvatar]) const [allowRemote, setAllowRemote] = useState( () => !deferRemote || remoteHttp === '' || alreadyCached ) // When metadata arrives, avoid resetting to identicon + waiting for IO on rows that are // already on screen (previously: useEffect(false) then IntersectionObserver → noticeable delay). useLayoutEffect(() => { if (!deferRemote) { setAllowRemote(true) return } if (remoteHttp === '' || alreadyCached) { setAllowRemote(true) return } const el = containerRef.current if (el && elementIsNearViewport(el, AVATAR_VIEWPORT_MARGIN_PX)) { setAllowRemote(true) return } setAllowRemote(false) }, [remoteHttp, alreadyCached, deferRemote]) useEffect(() => { if (!deferRemote || !remoteHttp || allowRemote) return if (typeof IntersectionObserver === 'undefined') { setAllowRemote(true) return } const el = containerRef.current if (!el) return const io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) { setAllowRemote(true) } }, { root: null, rootMargin: `${AVATAR_VIEWPORT_MARGIN_PX}px`, threshold: 0.01 } ) io.observe(el) return () => io.disconnect() }, [remoteHttp, allowRemote, containerRef, deferRemote]) if (sizeBlocked) return fallbackSrc return nonHttpAvatar || (remoteHttp && allowRemote ? remoteHttp : '') || fallbackSrc } const UserAvatarSizeCnMap = { large: 'w-24 h-24', big: 'w-16 h-16', semiBig: 'w-12 h-12', normal: 'w-10 h-10', medium: 'w-9 h-9', small: 'w-7 h-7', xSmall: 'w-5 h-5', tiny: 'w-4 h-4' } export default function UserAvatar({ userId, className, size = 'normal', prefetchedProfile, maxFileSizeKb = 2048, deferRemoteAvatar = true }: { userId: string className?: string size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny' /** Same pubkey as userId; use avatar from search/cache until fetch completes. */ prefetchedProfile?: TProfile /** * Skip avatar images larger than this (KB) — uses the generated placeholder instead. * Non-nostr.build sizes are checked via a cached HEAD request; unknown sizes are shown. * Defaults to 2048 (2 MB). Pass a lower value (e.g. 500) for dense feed contexts. */ maxFileSizeKb?: number /** * When false, start loading the remote picture as soon as metadata exists (no viewport deferral). * Use in threads and short lists so participants are recognizable immediately. */ deferRemoteAvatar?: boolean }) { const { profile: fetchedProfile } = useFetchProfile(userId) const profile = useMemo(() => { const idPk = userId ? userIdToPubkey(userId) : '' if (prefetchedProfile && idPk && prefetchedProfile.pubkey === idPk) { return fetchedProfile ?? prefetchedProfile } return fetchedProfile }, [userId, prefetchedProfile, fetchedProfile]) const { navigateToProfile } = useSmartProfileNavigationOptional() // Extract pubkey from userId if it's npub/nprofile format const pubkey = useMemo(() => { if (!userId) return '' const decodedPubkey = userIdToPubkey(userId) if (isValidPubkey(decodedPubkey)) return decodedPubkey const fromProfile = profile?.pubkey return fromProfile && isValidPubkey(fromProfile) ? fromProfile : '' }, [userId, profile?.pubkey]) const identiconSource = pubkey || userId.trim() const defaultAvatar = useMemo( () => (identiconSource ? generateImageByPubkey(identiconSource) : ''), [identiconSource] ) const containerRef = useRef(null) // Seed the size cache from imeta data on the profile event — avoids a HEAD request // when the kind-0 event already carries the file size. useMemo(() => { if (profile?.avatar && profile.pictureSize != null) { const thumbUrl = toNostrBuildThumbUrl(profile.avatar) if (!urlSizeCache.has(thumbUrl)) { urlSizeCache.set(thumbUrl, profile.pictureSize) } } }, [profile?.avatar, profile?.pictureSize]) const avatarSrc = useDeferRemoteProfileAvatar( profile?.avatar, defaultAvatar, containerRef, maxFileSizeKb != null ? maxFileSizeKb * 1024 : undefined, deferRemoteAvatar ) // All hooks must be called before any early returns const [imgError, setImgError] = useState(false) const [currentSrc, setCurrentSrc] = useState(avatarSrc) /** Must match `currentSrc`: deferred / fallback identicon is SVG — never pass it to `