import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/utils' import { isRenderableMediaUrl, isSafeMediaUrl } from '@/lib/url' import { TImetaInfo } from '@/types' import { decode } from 'blurhash' import { ImageOff } from 'lucide-react' import { CSSProperties, HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' /** Browsers often never fire `onError` for invalid URIs, ORB, or stalled fetches — this forces a visible error. */ const IMAGE_LOAD_TIMEOUT_MS = 10_000 /** Without reserved height, `absolute` skeleton + `opacity-0` img collapse to 0×0 — looks like “nothing”. */ function wrapperReserveStyle( dim: { width: number; height: number } | undefined, showError: boolean ): CSSProperties | undefined { if (showError) return undefined if (dim && dim.width > 0 && dim.height > 0) { return { aspectRatio: `${dim.width} / ${dim.height}` } } return { minHeight: 'min(30vh, 280px)' } } function formatFileSize(bytes: number): string { if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB` if (bytes >= 1_024) return `${Math.round(bytes / 1_024)} KB` return `${bytes} B` } export default function Image({ image: { url, blurHash, dim, alt: imetaAlt, fallback, size: fileSizeBytes }, alt, className = '', classNames = {}, hideIfError = false, errorPlaceholder = , style: wrapperStyleProp, holdUntilClick = false, ...props }: HTMLAttributes & { classNames?: { wrapper?: string errorPlaceholder?: string } image: TImetaInfo alt?: string hideIfError?: boolean errorPlaceholder?: React.ReactNode /** * When true, the full image is NOT loaded until the user interacts. * Shows a blurhash canvas if available, otherwise a skeleton placeholder. * Intended for inline note images: clicking opens the lightbox (via the * onClick handler passed from MarkdownArticle) without ever loading the * full image inline. */ holdUntilClick?: boolean }) { const { t } = useTranslation() const urlOk = !!url?.trim() // When holdUntilClick is active we start in the "held" state (regardless of blurHash). const shouldHold = holdUntilClick const [revealed, setRevealed] = useState(!shouldHold) const [isLoading, setIsLoading] = useState(urlOk && revealed) const [displaySkeleton, setDisplaySkeleton] = useState(urlOk) const [hasError, setHasError] = useState(!urlOk) const [imageUrl, setImageUrl] = useState(url) const [fallbackIndex, setFallbackIndex] = useState(0) const loadWatchRef = useRef(null) // Track whether this image started in the held state (required an explicit click to reveal). // The timeout is only meaningful when the user already triggered a load — for auto-revealed // images, delays the browser request until the element nears the viewport, // so a 10 s timeout would fire before off-screen images are even fetched. const wasInitiallyHeldRef = useRef(holdUntilClick) const finalAlt = imetaAlt || alt const openLinkHref = (isSafeMediaUrl(url) && url.trim()) || (isSafeMediaUrl(imageUrl) && imageUrl.trim()) || '' const badSrc = !imageUrl?.trim() || !isRenderableMediaUrl(imageUrl.trim()) const showErrorState = hasError || badSrc const clearLoadWatch = () => { if (loadWatchRef.current != null) { clearTimeout(loadWatchRef.current) loadWatchRef.current = null } } useEffect(() => { setImageUrl(url) setRevealed(!shouldHold) setIsLoading(!!url?.trim() && !shouldHold) setHasError(false) setDisplaySkeleton(true) setFallbackIndex(0) clearLoadWatch() if (!url?.trim()) { setIsLoading(false) setHasError(true) setDisplaySkeleton(false) } // shouldHold is derived from props — intentionally not in deps to avoid reset loops // eslint-disable-next-line react-hooks/exhaustive-deps }, [url]) useEffect(() => { clearLoadWatch() if (badSrc || !url?.trim() || !revealed) return // Skip the timeout for auto-load images (holdUntilClick was false from mount). // Their request hasn't necessarily started yet when revealed // becomes true, so the timeout would fire before the browser even fetches the image. // For those images, onError is sufficient — it fires whenever the browser does try. if (!wasInitiallyHeldRef.current) return loadWatchRef.current = window.setTimeout(() => { loadWatchRef.current = null setIsLoading(false) setDisplaySkeleton(false) setHasError(true) }, IMAGE_LOAD_TIMEOUT_MS) return clearLoadWatch }, [imageUrl, badSrc, url, revealed]) if (hideIfError && showErrorState) return null const handleError = () => { clearLoadWatch() if (fallback && fallbackIndex < fallback.length) { const next = fallback[fallbackIndex] setFallbackIndex((prev) => prev + 1) setImageUrl(next) return } setIsLoading(false) setDisplaySkeleton(false) setHasError(true) } const handleLoad = () => { clearLoadWatch() setIsLoading(false) setHasError(false) setTimeout(() => setDisplaySkeleton(false), 600) } const reserveStyle = wrapperReserveStyle(dim, showErrorState) const mergedWrapperStyle: CSSProperties | undefined = reserveStyle || wrapperStyleProp ? { ...reserveStyle, ...wrapperStyleProp } : undefined const handleReveal = () => { if (revealed) return setRevealed(true) setIsLoading(true) } return ( {displaySkeleton && !showErrorState && ( {blurHash ? ( ) : !revealed && !isLoading ? ( // Static bg when held — no shimmer animation flashing indefinitely ) : ( )} {!revealed && holdUntilClick && fileSizeBytes != null && ( {formatFileSize(fileSizeBytes)} )} )} {!showErrorState && revealed && ( {finalAlt} )} {showErrorState && ( // All children are so this block is inline-safe when Image is placed // inside a

by MarkdownArticle (avoids validateDOMNesting violations). {errorPlaceholder} {t('This image could not be loaded.')} {badSrc && !hasError ? ( {t('Invalid or unsupported image address.')} ) : null} {openLinkHref ? ( e.stopPropagation()} > {t('Open image link')} ) : null} )} ) } const blurHashWidth = 32 const blurHashHeight = 32 function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; className?: string }) { const canvasRef = useRef(null) const pixels = useMemo(() => { if (!blurHash) return null try { return decode(blurHash, blurHashWidth, blurHashHeight) } catch { return null } }, [blurHash]) useEffect(() => { if (!pixels || !canvasRef.current) return const canvas = canvasRef.current const ctx = canvas.getContext('2d') if (!ctx) return const imageData = ctx.createImageData(blurHashWidth, blurHashHeight) imageData.data.set(pixels) ctx.putImageData(imageData, 0, 0) }, [pixels]) if (!blurHash) return null return ( ) }