"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(
{items.length > 1 && ( {index + 1} / {items.length} )}
{ const el = e.currentTarget; const next = Math.round(el.scrollLeft / el.clientWidth); if (next !== index) setIndex(next); }} className="flex h-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" > {items.map((a) => (
{a.kind === "video" ? (
))}
{items.length > 1 && ( <> scrollTo(index - 1)} /> scrollTo(index + 1)} /> )}
, document.body, ); } function Arrow({ side, disabled, onClick, }: { side: "left" | "right"; disabled: boolean; onClick: () => void; }) { return ( ); }