import { useCallback, useEffect } 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 { useObjectUrl } from '@/hooks/use-object-url'; import { Button } from '@/components/ui/button'; import { KeyHint } from '@/components/key-hint'; 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; const positionIndicator = openIndex !== null ? openIndex + 1 : null; // 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 resolve to a blob URL; remote items use the signed-URL cache. const localFile = current?.source.kind === 'local' ? current.source.file : null; const localUrl = useObjectUrl(localFile); const url = current?.source.kind === 'remote' ? (remoteUrl ?? null) : localUrl; const canDownload = current?.source.kind === 'remote' && !!url; const goTo = useCallback( (delta: number) => { if (openIndex === null || items.length === 0) return; const next = (openIndex + delta + items.length) % items.length; onOpenChange(next); }, [openIndex, items.length, onOpenChange], ); const handleDownload = useCallback(() => { if (!current || !url || current.source.kind !== 'remote') return; platform.attachment.download(url, current.filename); }, [current, url]); const handleRemove = useCallback(() => { 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(items.length - 2); } // Otherwise openIndex stays — the next item shifts into its place. }, [current, onRemove, items.length, openIndex, onOpenChange]); 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, onOpenChange, onRemove, hasMultiple, canDownload, goTo, handleDownload, handleRemove, ]); 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 && ( {positionIndicator} / {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 && ( goTo(-1)} title="Previous (or press ←)" aria-label="Previous attachment" /> goTo(1)} title="Next (or press →)" aria-label="Next attachment" /> navigate )} {canDownload && ( download )} {onRemove && ( remove )} onOpenChange(null)} title="Close (or press Esc)" > close
)}
); }