import { ImageOff } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; type BubbleImageProps = { src: string | undefined; /** * Optional absolute URL(s) to try if `src` fails to load. Useful when `src` * is an optimized-but-fragile thumbnail (e.g. a root-relative thumbnailUrl) * and we want to fall back to the reliable full-size asset before giving up. */ fallbackSrc?: string | (string | undefined)[]; className?: string; loading?: "lazy" | "eager"; /** * Called once the active source loads, with the image's intrinsic pixel * size. Lets callers (e.g. single-image bubbles) adopt the real aspect * ratio without depending on backend-provided width/height. */ onNaturalSize?: (width: number, height: number) => void; }; /** * Thumbnail for message bubbles. Renders with an empty alt (decorative) * and, if every candidate source fails to load, falls back to a neutral * placeholder instead of the browser's broken-image box — which would otherwise * expose the raw file name via alt text. */ export function BubbleImage({ src, fallbackSrc, className, loading, onNaturalSize, }: BubbleImageProps) { // Ordered, de-duplicated list of sources to attempt in turn. const candidates = useMemo(() => { const extra = Array.isArray(fallbackSrc) ? fallbackSrc : [fallbackSrc]; return [src, ...extra].filter( (value, index, all): value is string => !!value && all.indexOf(value) === index, ); }, [src, fallbackSrc]); const [attempt, setAttempt] = useState(0); // Reset when the candidate set changes so a reused element re-attempts. useEffect(() => { setAttempt(0); }, [candidates]); const current = candidates[attempt]; if (!current) { return (
); } return ( { const img = e.currentTarget; // Fade each image in as soon as it loads, so they appear progressively // instead of the page seeming to wait for everything. img.classList.add("is-loaded"); if (img.naturalWidth && img.naturalHeight) onNaturalSize?.(img.naturalWidth, img.naturalHeight); }} onError={() => setAttempt((i) => i + 1)} /> ); }