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
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> |
|
) |
|
} |