import { useEffect, useState } from "react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { ChevronLeft, ChevronRight, Download, FileIcon, Loader2, Trash2, X, } from "lucide-react"; import { useDownloadUrl } from "@/hooks/use-download-url"; import { Button } from "@/components/ui/button"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { platform } from "@/lib/platform"; export interface AttachmentItem { id: string; filename: string; mimeType: string; sizeBytes?: number; source: | { kind: "remote"; objectId: string } | { kind: "local"; file: File }; } interface AttachmentLightboxProps { items: AttachmentItem[]; openIndex: number | null; onOpenChange: (index: number | null) => void; /** When provided, enables the trash button + Backspace/Delete to remove. */ onRemove?: (item: AttachmentItem) => void; } /** * Return `"lightbox"` for mime types that preview in-app, `"external"` otherwise. * Callers use this to decide whether to open the lightbox or hand off to the OS. */ export function getAttachmentHandler(mimeType: string): "lightbox" | "external" { if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) { return "lightbox"; } return "external"; } function formatSize(bytes?: number): string | null { if (bytes == null) return null; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } export function AttachmentLightbox({ items, openIndex, onOpenChange, onRemove, }: AttachmentLightboxProps) { const current = openIndex !== null && openIndex >= 0 && openIndex < items.length ? items[openIndex] : null; const isOpen = current !== null; const hasMultiple = items.length > 1; // Remote items resolve through the signed-URL cache; disabled when not remote. const remoteObjectId = current?.source.kind === "remote" ? current.source.objectId : undefined; const { data: remoteUrl, isLoading: isRemoteLoading } = useDownloadUrl(remoteObjectId); // Local items get a fresh blob URL per item, revoked on change/close. const [localUrl, setLocalUrl] = useState(null); useEffect(() => { if (current?.source.kind !== "local") { setLocalUrl(null); return; } const url = URL.createObjectURL(current.source.file); setLocalUrl(url); return () => URL.revokeObjectURL(url); }, [current?.id, current?.source.kind]); const url = current?.source.kind === "remote" ? remoteUrl ?? null : localUrl; const canDownload = current?.source.kind === "remote" && !!url; const goTo = (delta: number) => { if (openIndex === null || items.length === 0) return; const next = (openIndex + delta + items.length) % items.length; onOpenChange(next); }; const handleDownload = () => { if (!current || !url || current.source.kind !== "remote") return; platform.attachment.download(url, current.filename); }; const handleRemove = () => { if (!current || !onRemove) return; const wasLast = items.length <= 1; const wasAtEnd = openIndex === items.length - 1; onRemove(current); if (wasLast) { onOpenChange(null); } else if (wasAtEnd) { onOpenChange(openIndex! - 1); } // Otherwise openIndex stays — the next item shifts into its place. }; useSuspendPlayback(isOpen, "attachment-lightbox"); // Keyboard handling — only listens while open. Registered in the capture // phase with stopImmediatePropagation so we consume keys (arrows, D, ⌫) // before global listeners like stream-view's particle-navigation. useEffect(() => { if (!isOpen) return; const handle = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; if ( target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) ) { return; } const consume = () => { e.preventDefault(); e.stopImmediatePropagation(); }; if (e.key === "Escape") { consume(); onOpenChange(null); } else if (e.key === "ArrowLeft" && hasMultiple) { consume(); goTo(-1); } else if (e.key === "ArrowRight" && hasMultiple) { consume(); goTo(1); } else if ((e.key === "d" || e.key === "D") && canDownload) { consume(); handleDownload(); } else if ((e.key === "Backspace" || e.key === "Delete") && onRemove) { consume(); handleRemove(); } }; window.addEventListener("keydown", handle, true); return () => window.removeEventListener("keydown", handle, true); }, [isOpen, openIndex, items, url, onOpenChange, onRemove, hasMultiple, canDownload]); const isImage = current?.mimeType.startsWith("image/"); const isVideo = current?.mimeType.startsWith("video/"); const sizeLabel = formatSize(current?.sizeBytes); return ( { if (!open) onOpenChange(null); }} > {current && ( <> {current.filename} {/* Top-left: filename chip */}
{current.filename} {sizeLabel && ( {sizeLabel} )} {hasMultiple && ( {openIndex! + 1} / {items.length} )}
{/* Top-right: actions */}
{canDownload && ( )} {onRemove && ( )}
{/* Side navigation */} {hasMultiple && ( <> )} {/* Media body */}
{!url && isRemoteLoading && ( )} {url && isImage && ( {current.filename} onOpenChange(null)} className="max-h-[85vh] max-w-[85vw] rounded-lg object-contain shadow-2xl" /> )} {url && isVideo && (
{/* Footer kbd hints */}
{hasMultiple && ( navigate )} {canDownload && ( D download )} {onRemove && ( remove )} Esc close
)}
); }