/** * ImageLightbox — full-screen image viewer opened by clicking an * image attachment in the chat. Shows a file info card (filename + * dimensions) on the left, an action card (zoom toggle, download, * open-external) on the right, and a standalone close card. Clicking * the image toggles between fit-to-viewport and native-size modes — * when zoomed, the backdrop scrolls so the user can pan through the * overflow. * * Layout mirrors the Brycord/Fluxer reference 1:1, with Matrix- * specific bits (useMxcUrl, SavedMediaStore, MobileImageActionsSheet) * stripped out. The `src` prop is a blob URL already decrypted by * EncryptedAttachment — no async resolution happens here. * * Keyboard: Escape closes, `+` / `-` toggle zoom, `0` resets. */ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion } from 'framer-motion'; import { useMutation, useQuery } from 'convex/react'; import { Download, ArrowSquareOut, Star, X, MagnifyingGlassPlus, MagnifyingGlassMinus, } from '@phosphor-icons/react'; import { api } from '../../../../../convex/_generated/api'; import type { Id } from '../../../../../convex/_generated/dataModel'; import type { AttachmentMetadata } from './EncryptedAttachment'; import styles from './ImageLightbox.module.css'; interface ImageLightboxProps { isOpen: boolean; onClose: () => void; /** Blob URL of the decrypted image bytes. */ src: string; /** Filename to show in the info card and to use for the download * attribute. Falls back to `alt` then to a generic "image". */ filename?: string; /** Legacy alias for `filename` kept so existing MessageGroup callers * that only pass `alt` still render a label. */ alt?: string; /** Pixel dimensions used by the info card's `WxH` readout. */ width?: number; height?: number; /** Byte size of the attachment — rendered alongside the dimensions * when present. */ size?: number; mimeType?: string; /** * Original encrypted attachment metadata. When present, the * lightbox shows a star button so the user can save the image to * their personal media library — the saved entry stores the same * url + per-file key + iv so it can be re-posted later without * re-uploading the bytes. */ attachment?: AttachmentMetadata; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } export function ImageLightbox({ isOpen, onClose, src, filename, alt, width, height, size, attachment, }: ImageLightboxProps) { const [zoomed, setZoomed] = useState(false); const label = filename ?? alt ?? 'image'; // Saved-media wiring — fetches the user's library once so we can // flip the star button between "save" and "unsave" states. The // list query is cheap (per-user, indexed) and stays cached. const myUserId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; const savedList = useQuery( api.savedMedia.list, myUserId && isOpen ? { userId: myUserId as Id<'userProfiles'> } : 'skip', ); const isSaved = !!( attachment && savedList?.some((m) => m.url === attachment.url) ); const saveMutation = useMutation(api.savedMedia.save); const removeMutation = useMutation(api.savedMedia.remove); const handleToggleSaved = async () => { if (!attachment || !myUserId) return; try { if (isSaved) { await removeMutation({ userId: myUserId as Id<'userProfiles'>, url: attachment.url, }); } else { const kind = attachment.mimeType.split('/')[0]; // image | video | audio await saveMutation({ userId: myUserId as Id<'userProfiles'>, url: attachment.url, kind, filename: attachment.filename, mimeType: attachment.mimeType, width: attachment.width, height: attachment.height, size: attachment.size, encryptionKey: attachment.key, encryptionIv: attachment.iv, }); } } catch (err) { console.warn('Failed to toggle saved media:', err); } }; // Close on Escape, toggle zoom via keyboard shortcuts. Also lock // body scroll while the lightbox is open so the background chat // doesn't jitter when the backdrop consumes the viewport. useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); else if (e.key === '+' || e.key === '=') setZoomed(true); else if (e.key === '-' || e.key === '_') setZoomed(false); else if (e.key === '0') setZoomed(false); }; document.addEventListener('keydown', handleKey); const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', handleKey); document.body.style.overflow = prevOverflow; }; }, [isOpen, onClose]); // Reset transient state whenever the modal closes so a previously // zoomed session doesn't bleed into the next attachment clicked. useEffect(() => { if (!isOpen) setZoomed(false); }, [isOpen]); const dimensionsLabel = width && height ? `${width}×${height}` : ''; const sizeLabel = size ? formatBytes(size) : ''; const metaLabel = [dimensionsLabel, sizeLabel].filter(Boolean).join(' · '); const handleDownload = () => { if (!src) return; // Trigger a download via a temporary . Blob URLs // honour the `download` attribute in all modern browsers. const a = document.createElement('a'); a.href = src; a.download = label; document.body.appendChild(a); a.click(); document.body.removeChild(a); }; const handleOpenExternal = () => { if (!src) return; window.open(src, '_blank', 'noopener,noreferrer'); }; const handleToggleZoom = () => setZoomed((z) => !z); // Wrap every action button click so it doesn't bubble up to the // backdrop (which would close the lightbox). const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); }; return createPortal( {isOpen && ( {/* Desktop header — file info card on the left, action card + close card on the right. Each sits in its own `--background-textarea` pill. */}
e.stopPropagation()}>
{label}
{metaLabel &&
{metaLabel}
}
{attachment && ( )}
{/* Centered image. Clicking it toggles zoom — event is stopped so the click doesn't propagate to the backdrop's close handler. */}
)}
, document.body, ); }