"use client";
/**
* Full-screen viewer for a message's media.
*
* A horizontally scroll-snapped carousel rather than a slide animation: swipe
* comes free on touch, the scrollbar reflects position, and the browser handles
* momentum. Arrow keys and the on-screen buttons scroll to a slide rather than
* setting state, so every input path drives the same mechanism.
*
* Portalled to
, and that is load-bearing rather than tidiness: every
* bubble sits inside MessageGestures, which sets a transform for swipe-to-reply
* — and a transformed ancestor becomes the containing block for `position:
* fixed`. Rendered in place, `inset-0` would resolve against the bubble, and
* the message list's overflow would then clip it.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, Download, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { attachmentUrl, type Attachment } from "@/lib/chat";
import { cn } from "@/lib/utils";
export function MediaLightbox({
items,
startIndex = 0,
onClose,
}: {
items: Attachment[];
startIndex?: number;
onClose: () => void;
}) {
const trackRef = useRef(null);
const [index, setIndex] = useState(startIndex);
const [downloading, setDownloading] = useState(false);
const scrollTo = useCallback((i: number, smooth = true) => {
const track = trackRef.current;
if (!track) return;
track.scrollTo({
left: i * track.clientWidth,
behavior: smooth ? "smooth" : "auto",
});
}, []);
// Jump to the tapped item before the first paint, so opening the third photo
// doesn't visibly scroll from the first.
useEffect(() => {
scrollTo(startIndex, false);
}, [startIndex, scrollTo]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowRight") scrollTo(Math.min(index + 1, items.length - 1));
if (e.key === "ArrowLeft") scrollTo(Math.max(index - 1, 0));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [index, items.length, onClose, scrollTo]);
// A full-screen viewer over a scrollable thread: without this the thread
// scrolls behind the image once the carousel reaches its own edge.
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
const current = items[index];
/**
* Download through a blob so the file keeps its name. The `download`
* attribute is ignored cross-origin, and chat media is served from the BFF,
* so a plain link would just navigate. If the fetch is refused (the signed
* storage URL needs CORS), fall back to opening it — better than nothing.
*/
const download = async () => {
if (!current) return;
setDownloading(true);
const href = attachmentUrl(current.id);
try {
const res = await fetch(href, { credentials: "include" });
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = current.file_name ?? `attachment-${current.id}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(href, "_blank", "noopener,noreferrer");
toast.message("Opened in a new tab");
} finally {
setDownloading(false);
}
};
if (typeof document === "undefined") return null;
return createPortal(