225 lines
7.0 KiB
TypeScript
225 lines
7.0 KiB
TypeScript
"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 <body>, 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<HTMLDivElement>(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(
|
|
<div className="fixed inset-0 z-[100] flex flex-col bg-black/95">
|
|
<div className="flex items-center justify-between gap-2 px-3 py-2 text-white">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="rounded-md p-2 hover:bg-white/10"
|
|
>
|
|
<X className="size-5" />
|
|
</button>
|
|
|
|
{items.length > 1 && (
|
|
<span className="text-xs tabular-nums text-white/70">
|
|
{index + 1} / {items.length}
|
|
</span>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={download}
|
|
disabled={downloading}
|
|
aria-label="Download"
|
|
className="rounded-md p-2 hover:bg-white/10 disabled:opacity-50"
|
|
>
|
|
{downloading ? (
|
|
<Loader2 className="size-5 animate-spin" />
|
|
) : (
|
|
<Download className="size-5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="relative flex-1 overflow-hidden">
|
|
<div
|
|
ref={trackRef}
|
|
onScroll={(e) => {
|
|
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) => (
|
|
<div
|
|
key={a.id}
|
|
className="flex h-full w-full shrink-0 snap-center items-center justify-center p-4"
|
|
>
|
|
{a.kind === "video" ? (
|
|
<video
|
|
src={attachmentUrl(a.id)}
|
|
controls
|
|
playsInline
|
|
className="max-h-full max-w-full"
|
|
/>
|
|
) : (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={attachmentUrl(a.id)}
|
|
alt={a.file_name ?? "Shared media"}
|
|
className="max-h-full max-w-full object-contain"
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{items.length > 1 && (
|
|
<>
|
|
<Arrow
|
|
side="left"
|
|
disabled={index === 0}
|
|
onClick={() => scrollTo(index - 1)}
|
|
/>
|
|
<Arrow
|
|
side="right"
|
|
disabled={index === items.length - 1}
|
|
onClick={() => scrollTo(index + 1)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
function Arrow({
|
|
side,
|
|
disabled,
|
|
onClick,
|
|
}: {
|
|
side: "left" | "right";
|
|
disabled: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
aria-label={side === "left" ? "Previous" : "Next"}
|
|
className={cn(
|
|
// Pointer devices only: on touch the swipe is the gesture, and arrows
|
|
// would sit over the image for no reason.
|
|
"absolute top-1/2 hidden -translate-y-1/2 rounded-full bg-white/10 p-2 text-white",
|
|
"hover:bg-white/20 disabled:opacity-0 sm:block",
|
|
side === "left" ? "left-3" : "right-3",
|
|
)}
|
|
>
|
|
{side === "left" ? (
|
|
<ChevronLeft className="size-5" />
|
|
) : (
|
|
<ChevronRight className="size-5" />
|
|
)}
|
|
</button>
|
|
);
|
|
}
|