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.
 
 
 
 

409 lines
14 KiB

import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { isRenderableMediaUrl, isSafeMediaUrl, resolvePrimalBlossomPlayableUrl } from '@/lib/url'
import { TImetaInfo } from '@/types'
import { blurHashPlaceholderForMediaUrl } from '@/lib/media-placeholder-blurhash'
import { decode } from 'blurhash'
import { ImageOff } from 'lucide-react'
import {
CSSProperties,
HTMLAttributes,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
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”.
* The tall `minHeight` fallback is only for that placeholder phase; keeping it after load (with no `dim`)
* leaves a box taller than the `<img>` when `height:100%` cannot resolve, which often reads as a white band
* under transparent GIFs or in dark UI.
*/
function wrapperReserveStyle(
dim: { width: number; height: number } | undefined,
showError: boolean,
useMinHeightPlaceholder: boolean
): CSSProperties | undefined {
if (showError) return undefined
if (dim && dim.width > 0 && dim.height > 0) {
return { aspectRatio: `${dim.width} / ${dim.height}` }
}
if (useMinHeightPlaceholder) {
return { minHeight: 'min(30vh, 280px)' }
}
return undefined
}
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 = <ImageOff />,
style: wrapperStyleProp,
holdUntilClick = false,
fetchPriority,
onClick,
showAltCaption = false,
caption,
/** Native tooltip on hover (e.g. Markdown `![alt](url "title")`). When set, overrides alt-as-title on `<img>`. */
tooltipTitle,
...props
}: HTMLAttributes<HTMLSpanElement> & {
classNames?: {
wrapper?: string
errorPlaceholder?: string
}
image: TImetaInfo
alt?: string
/** Shown as the `<img title>` tooltip when non-empty. */
tooltipTitle?: string
/** When true, show {@link caption} or non-empty alt below the image (lightbox-style caption). */
showAltCaption?: boolean
/** Caption below the image; defaults to resolved alt when {@link showAltCaption} is true. */
caption?: string
hideIfError?: boolean
errorPlaceholder?: React.ReactNode
/** Passed to the inner `<img>` (e.g. profile banner vs avatar load order). */
fetchPriority?: 'high' | 'low' | 'auto'
/**
* When true, the full image is not loaded until the user interacts.
* The first click runs {@link onClick} (e.g. open lightbox) and also reveals the
* inline `<img>` so after the lightbox closes the real image can show from cache.
*
* Under {@link ContentPolicyProvider}, the user’s media auto-load setting overrides a stale
* `holdUntilClick` from parents (e.g. MarkdownArticle’s default `lazyMedia`).
*/
holdUntilClick?: boolean
}) {
const { t } = useTranslation()
const contentPolicy = useContentPolicyOptional()
/** Tap-to-load only if the parent asked and policy allows (or there is no policy — trust the parent). */
const effectiveHoldUntilClick =
holdUntilClick && (contentPolicy !== undefined ? !contentPolicy.autoLoadMedia : true)
const urlOk = !!url?.trim()
const [revealed, setRevealed] = useState(!effectiveHoldUntilClick)
const [isLoading, setIsLoading] = useState(urlOk && !effectiveHoldUntilClick)
const [displaySkeleton, setDisplaySkeleton] = useState(urlOk)
const [hasError, setHasError] = useState(!urlOk)
const [imageUrl, setImageUrl] = useState(() => resolvePrimalBlossomPlayableUrl(url ?? ''))
const [fallbackIndex, setFallbackIndex] = useState(0)
const loadWatchRef = useRef<number | null>(null)
// Kept in sync in the reset effect; load-timeout runs only while tap-to-load is actually active.
const wasInitiallyHeldRef = useRef(effectiveHoldUntilClick)
const imgRef = useRef<HTMLImageElement | null>(null)
/** Deduplicate onLoad vs sync cache hit vs decode() — otherwise blurhash can stick when `onLoad` never runs. */
const loadSettledRef = useRef(false)
const finalAlt = imetaAlt || alt
const imgTitle =
tooltipTitle != null && String(tooltipTitle).trim() !== ''
? String(tooltipTitle).trim()
: (() => {
const a = (finalAlt ?? '').trim()
// Markdown uses `alt="image"` when `![](url)` has no label — not a real caption/tooltip.
return a && a !== 'image' ? a : undefined
})()
const captionLine = (() => {
if (!showAltCaption) return ''
const c = (caption ?? finalAlt ?? '').trim()
if (c && c !== 'image') return c
return ''
})()
const openLinkHref =
(isSafeMediaUrl(url) && url.trim()) || (isSafeMediaUrl(imageUrl) && imageUrl.trim()) || ''
const badSrc = !imageUrl?.trim() || !isRenderableMediaUrl(imageUrl.trim())
const showErrorState = hasError || badSrc
/** NIP-94 blurhash when present; otherwise a stable URL-derived placeholder (many events omit blurhash). */
const effectiveBlurHash = useMemo(() => {
const fromTag = blurHash?.trim()
if (fromTag) return fromTag
const u = url?.trim()
if (!u) return undefined
return blurHashPlaceholderForMediaUrl(u)
}, [blurHash, url])
const clearLoadWatch = () => {
if (loadWatchRef.current != null) {
clearTimeout(loadWatchRef.current)
loadWatchRef.current = null
}
}
useEffect(() => {
setImageUrl(resolvePrimalBlossomPlayableUrl(url ?? ''))
loadSettledRef.current = false
wasInitiallyHeldRef.current = effectiveHoldUntilClick
const shouldHold = effectiveHoldUntilClick
setRevealed(!shouldHold)
setHasError(false)
setDisplaySkeleton(true)
setFallbackIndex(0)
clearLoadWatch()
if (!url?.trim()) {
setIsLoading(false)
setHasError(true)
setDisplaySkeleton(false)
return
}
setIsLoading(!shouldHold)
}, [url, effectiveHoldUntilClick])
const notifyLoaded = useCallback(() => {
if (loadSettledRef.current) return
loadSettledRef.current = true
clearLoadWatch()
setIsLoading(false)
setHasError(false)
// Unmount blurhash/skeleton immediately — keeping z-10 overlay (even at opacity-0) leaves bg-muted/40
// and canvas layers visible as odd tinted bands until delayed teardown.
setDisplaySkeleton(false)
}, [])
// Cached images are often `complete` before `onLoad` is attached (feed mounts many cards at once).
useLayoutEffect(() => {
if (!revealed || badSrc || !imageUrl?.trim() || loadSettledRef.current) return
const el = imgRef.current
if (!el) return
if (el.complete && el.naturalWidth > 0) {
notifyLoaded()
return
}
if (!effectiveHoldUntilClick && typeof el.decode === 'function') {
let cancelled = false
el.decode().then(() => {
if (!cancelled && el.naturalWidth > 0) notifyLoaded()
}).catch(() => {})
return () => {
cancelled = true
}
}
}, [revealed, badSrc, imageUrl, effectiveHoldUntilClick, notifyLoaded])
useEffect(() => {
clearLoadWatch()
if (badSrc || !url?.trim() || !revealed) return
// No stall-timeout when not in tap-to-load mode; only that path waits on user-driven reveal.
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)
loadSettledRef.current = false
setImageUrl(resolvePrimalBlossomPlayableUrl(next))
return
}
setIsLoading(false)
setDisplaySkeleton(false)
setHasError(true)
}
const handleLoad = () => {
notifyLoaded()
}
const reserveStyle = wrapperReserveStyle(
dim,
showErrorState,
displaySkeleton && !showErrorState
)
const mergedWrapperStyle: CSSProperties | undefined =
reserveStyle || wrapperStyleProp
? { ...reserveStyle, ...wrapperStyleProp }
: undefined
const handleReveal = () => {
if (revealed) return
setRevealed(true)
setIsLoading(true)
}
const handleWrapperClick = (e: React.MouseEvent<HTMLSpanElement>) => {
if (effectiveHoldUntilClick && !revealed) handleReveal()
onClick?.(e)
}
const hasHoverTip = Boolean(imgTitle)
return (
<span className={cn('block w-full not-prose', classNames.wrapper)}>
<span
className={cn(
'relative overflow-hidden block w-full rounded-lg bg-background',
hasHoverTip && 'cursor-help ring-1 ring-inset ring-dotted ring-muted-foreground/45'
)}
style={mergedWrapperStyle}
title={imgTitle}
onClick={handleWrapperClick}
{...props}
>
{displaySkeleton && !showErrorState && (
<span className="absolute inset-0 z-10 block rounded-lg bg-muted/40">
{effectiveBlurHash ? (
<BlurHashCanvas
blurHash={effectiveBlurHash}
className={cn(
'absolute inset-0 transition-opacity duration-500 rounded-lg',
// Keep placeholder visible while the full image is still loading (auto-load),
// otherwise both blur and <img> are opacity-0 and only a faint bg shows (looks like a white box).
!revealed || isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
) : !revealed && !isLoading ? (
// Static bg when held — no shimmer animation flashing indefinitely
<span className="absolute inset-0 h-full w-full rounded-lg bg-muted" />
) : (
<Skeleton
className={cn(
'absolute inset-0 h-full min-h-[8rem] w-full transition-opacity duration-500 rounded-lg',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
)}
{!revealed && effectiveHoldUntilClick && fileSizeBytes != null && (
<span className="absolute bottom-2 right-2 z-20 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white/90 backdrop-blur-sm select-none pointer-events-none">
{formatFileSize(fileSizeBytes)}
</span>
)}
</span>
)}
{!showErrorState && revealed && (
<img
ref={imgRef}
src={imageUrl}
alt={finalAlt}
referrerPolicy="no-referrer"
decoding={effectiveHoldUntilClick ? 'async' : 'sync'}
// `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly.
loading="eager"
{...(fetchPriority ? { fetchpriority: fetchPriority } : {})}
draggable={false}
onLoad={handleLoad}
onError={handleError}
className={cn(
'object-cover rounded-lg w-full h-full transition-opacity duration-500 pointer-events-none',
isLoading ? 'opacity-0' : 'opacity-100',
className
)}
width={dim?.width}
height={dim?.height}
/>
)}
{showErrorState && (
// All children are <span> so this block is inline-safe when Image is placed
// inside a <p> by MarkdownArticle (avoids validateDOMNesting violations).
<span
role="alert"
className={cn(
'flex flex-col items-center justify-center gap-2 w-full min-h-[120px] p-4 rounded-lg bg-muted text-muted-foreground text-center',
className,
classNames.errorPlaceholder
)}
>
<span className="flex shrink-0 text-muted-foreground [&_svg]:size-10">{errorPlaceholder}</span>
<span className="text-sm leading-snug">{t('This image could not be loaded.')}</span>
{badSrc && !hasError ? (
<span className="text-xs opacity-80 break-all max-w-full block">{t('Invalid or unsupported image address.')}</span>
) : null}
{openLinkHref ? (
<a
href={openLinkHref}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline-offset-4 hover:underline break-all max-w-full"
onClick={(e) => e.stopPropagation()}
>
{t('Open image link')}
</a>
) : null}
</span>
)}
</span>
{captionLine ? (
<span className="mt-1 block px-1 text-center text-xs leading-snug text-muted-foreground">{captionLine}</span>
) : null}
</span>
)
}
const blurHashWidth = 32
const blurHashHeight = 32
function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const pixels = useMemo(() => {
if (!blurHash) return null
try {
return decode(blurHash, blurHashWidth, blurHashHeight)
} catch {
return null
}
}, [blurHash])
useLayoutEffect(() => {
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
// Failed decode or unsupported hash: empty <canvas> often paints as solid white — use muted fill instead.
if (!pixels) {
return (
<span
className={cn('block h-full w-full rounded-lg bg-muted object-cover', className)}
aria-hidden
/>
)
}
return (
<canvas
ref={canvasRef}
width={blurHashWidth}
height={blurHashHeight}
className={cn('h-full w-full object-cover rounded-lg', className)}
style={{
imageRendering: 'auto',
filter: 'blur(0.5px)'
}}
/>
)
}