All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
299 lines
9.2 KiB
TypeScript
299 lines
9.2 KiB
TypeScript
/**
|
||
* 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 <a download>. 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(
|
||
<AnimatePresence>
|
||
{isOpen && (
|
||
<motion.div
|
||
className={styles.backdrop}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.15 }}
|
||
onClick={onClose}
|
||
>
|
||
{/* Desktop header — file info card on the left, action
|
||
card + close card on the right. Each sits in its
|
||
own `--background-textarea` pill. */}
|
||
<div className={styles.header} onClick={(e) => e.stopPropagation()}>
|
||
<div className={styles.fileInfoCard}>
|
||
<div className={styles.filename} title={label}>
|
||
{label}
|
||
</div>
|
||
{metaLabel && <div className={styles.dimensions}>{metaLabel}</div>}
|
||
</div>
|
||
|
||
<div className={styles.headerRight}>
|
||
<div className={styles.actionCard}>
|
||
<button
|
||
type="button"
|
||
className={styles.actionButton}
|
||
onClick={stop(handleToggleZoom)}
|
||
aria-label={zoomed ? 'Zoom out' : 'Zoom in'}
|
||
title={zoomed ? 'Zoom out' : 'Zoom in'}
|
||
>
|
||
{zoomed ? (
|
||
<MagnifyingGlassMinus size={18} weight="regular" />
|
||
) : (
|
||
<MagnifyingGlassPlus size={18} weight="regular" />
|
||
)}
|
||
</button>
|
||
{attachment && (
|
||
<button
|
||
type="button"
|
||
className={styles.actionButton}
|
||
onClick={stop(() => void handleToggleSaved())}
|
||
aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
|
||
title={isSaved ? 'Unfavorite' : 'Favorite'}
|
||
style={
|
||
isSaved
|
||
? { color: 'var(--brand-primary, #5865f2)' }
|
||
: undefined
|
||
}
|
||
>
|
||
<Star
|
||
size={18}
|
||
weight={isSaved ? 'fill' : 'regular'}
|
||
/>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={styles.actionButton}
|
||
onClick={stop(handleDownload)}
|
||
aria-label="Download"
|
||
title="Download"
|
||
disabled={!src}
|
||
>
|
||
<Download size={18} weight="regular" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.actionButton}
|
||
onClick={stop(handleOpenExternal)}
|
||
aria-label="Open in new tab"
|
||
title="Open in new tab"
|
||
disabled={!src}
|
||
>
|
||
<ArrowSquareOut size={18} weight="regular" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.closeCard}>
|
||
<button
|
||
type="button"
|
||
className={styles.actionButton}
|
||
onClick={stop(onClose)}
|
||
aria-label="Close"
|
||
title="Close (Esc)"
|
||
>
|
||
<X size={20} weight="bold" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Centered image. Clicking it toggles zoom — event is
|
||
stopped so the click doesn't propagate to the
|
||
backdrop's close handler. */}
|
||
<motion.img
|
||
key={src || 'loading'}
|
||
src={src || undefined}
|
||
alt={label}
|
||
className={`${styles.image} ${zoomed ? styles.imageZoomed : ''}`}
|
||
initial={{ opacity: 0, scale: 0.96 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.96 }}
|
||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||
onClick={stop(handleToggleZoom)}
|
||
draggable={false}
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>,
|
||
document.body,
|
||
);
|
||
}
|