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.
 
 
 
 

487 lines
16 KiB

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<string, number | null>()
/**
* 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<string>()
/**
* 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<number | null> {
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<HTMLDivElement | null>,
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<HTMLDivElement>(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 `<video>`. */
const isVideoAvatar = useMemo(() => isVideo(currentSrc), [currentSrc])
// Reset error state when src changes
useEffect(() => {
setImgError(false)
setCurrentSrc(avatarSrc)
}, [avatarSrc])
const handleImageError = () => {
if (profile?.avatar && defaultAvatar && currentSrc !== defaultAvatar) {
setCurrentSrc(defaultAvatar)
setImgError(false)
} else {
setImgError(true)
}
}
const handleImageLoad = () => {
setImgError(false)
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}
const displayPubkey = profile?.pubkey || pubkey || ''
const profileNavTarget =
userId.startsWith('npub1') || userId.startsWith('nprofile1') ? userId : displayPubkey
if (!userId.trim()) {
return (
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
)
}
// Render image directly instead of using Radix UI Avatar for better reliability
return (
<div
ref={containerRef}
data-user-avatar
className={cn('shrink-0 cursor-pointer block overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
style={{ position: 'relative', zIndex: 10, isolation: 'isolate', display: 'block' }}
onClick={(e) => {
e.stopPropagation()
if (!profileNavTarget) return
if (profile) seedProfileForNavigation(profile)
navigateToProfile(toProfile(profileNavTarget))
}}
>
{!imgError && currentSrc ? (
isVideoAvatar ? (
<video
src={currentSrc}
className="block w-full h-full object-cover object-center"
autoPlay
muted
loop
playsInline
onCanPlay={() => {
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}}
onError={handleImageError}
/>
) : (
<img
src={currentSrc}
alt=""
className="block w-full h-full object-cover object-center"
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
onError={handleImageError}
onLoad={handleImageLoad}
loading={isHttpOrHttpsUrl(currentSrc) ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={isHttpOrHttpsUrl(currentSrc) ? 'high' : undefined}
/>
)
) : (
// Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">
{(displayPubkey || userId).slice(0, 2).toUpperCase()}
</div>
)}
</div>
)
}
export function SimpleUserAvatar({
userId,
size = 'normal',
className,
prefetchedProfile,
maxFileSizeKb = 2048,
deferRemoteAvatar = true
}: {
userId: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
className?: string
prefetchedProfile?: TProfile
maxFileSizeKb?: number
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])
// Always generate default avatar from userId/pubkey, even if profile isn't loaded yet
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<HTMLDivElement>(null)
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 `<video>`. */
const isVideoAvatar = useMemo(() => isVideo(currentSrc), [currentSrc])
// Reset error state when src changes
useEffect(() => {
setImgError(false)
setCurrentSrc(avatarSrc)
}, [avatarSrc])
const handleImageError = () => {
if (profile?.avatar && defaultAvatar && currentSrc !== defaultAvatar) {
setCurrentSrc(defaultAvatar)
setImgError(false)
} else {
setImgError(true)
}
}
const handleImageLoad = () => {
setImgError(false)
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}
if (!userId.trim()) {
return (
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
)
}
const displayPubkey = profile?.pubkey || pubkey || ''
// Render image directly instead of using Radix UI Avatar for better reliability
return (
<div
ref={containerRef}
className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
>
{!imgError && currentSrc ? (
isVideoAvatar ? (
<video
src={currentSrc}
className="block w-full h-full object-cover object-center"
autoPlay
muted
loop
playsInline
onCanPlay={() => {
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}}
onError={handleImageError}
/>
) : (
<img
src={currentSrc}
alt=""
className="block w-full h-full object-cover object-center"
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
onError={handleImageError}
onLoad={handleImageLoad}
loading={isHttpOrHttpsUrl(currentSrc) ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={isHttpOrHttpsUrl(currentSrc) ? 'high' : undefined}
/>
)
) : (
// Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">
{(displayPubkey || userId).slice(0, 2).toUpperCase()}
</div>
)}
</div>
)
}