740fc1c63c
Pulls in 387 commits from ditto/main while preserving Agora-specific features. Where the two codebases diverged on the same concept, kept the Agora side per project direction. Kept Agora-specific: - SparkWallet stack (over Ditto's nostr-derived Bitcoin wallet) - Communities (NIP-72 + chat + members), Messages, Organizers, Actions, Verified, Appearance settings - DMProviderWrapper, country/organizer moderation in NoteMoreMenu - 'Agora' branding, pub.agora.app bundle ID, version 2.8.0 - Built-in theme system (src/themes.ts) only Rejected from Ditto: - All Blobbi virtual pet code (80+ files, route, provider, sidebar, kind labels, feed setting, NIP.md entries, CSS animations) - Custom theme events (kinds 36767/16767) — ThemesPage, ThemeContent, active profile themes, theme snapshot recovery - On-chain zaps (kind 8333) and the entire Bitcoin wallet implementation (useBitcoinWallet, bitcoin-signers, BitcoinContentHeader, bitcoinjs-lib / @bitcoinerlab/secp256k1 / ecpair / tiny-secp256k1) - ZapSuccessScreen (depended on dropped bitcoin lib) Pulled in from Ditto: - .agents/skills/* (12 new specialized skills, slim AGENTS.md) - @nostrify bumps to 0.52 / 0.6 / 0.37 - New routes/pages: Music, Podcasts, Videos, Vines, Wikipedia, Books, Bluesky, Archive, AIChat, Trends, Webxdc, Highlights, Decks, Emojis, Development, Treasures, Colors, Packs - Birdstar feed integration (kinds 2473, 12473, 30621) - Wikipedia/Wikidata/Scryfall lookup in ExternalContentPage - release-notes CI job + extract-release-notes.mjs script - nsite:// URI handling in feed/sidebar - iOS fastlane setup - src/lib/avatarShape.ts + Avatar shape prop (kept for new Music/People components that depend on it) Preserved Agora's ABSOLUTE 'NEVER COMMIT' rule at the top of AGENTS.md and dropped Ditto's contradicting 'Commit at the end of every task' section. Validation: npm run test passes (tsc, eslint, 40/40 vitest, vite build).
944 lines
35 KiB
TypeScript
944 lines
35 KiB
TypeScript
import { useState, useCallback, useEffect, useRef } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
|
||
import { Blurhash } from 'react-blurhash';
|
||
import { cn } from '@/lib/utils';
|
||
import { isValidBlurhash } from '@/lib/blurhash';
|
||
import { openUrl } from '@/lib/downloadFile';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
||
import { VideoPlayer } from '@/components/VideoPlayer';
|
||
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
/** Minimal imeta fields needed for pre-load sizing. */
|
||
interface ImetaDimensions {
|
||
dim?: string;
|
||
blurhash?: string;
|
||
}
|
||
|
||
interface ImageGalleryProps {
|
||
images: string[];
|
||
className?: string;
|
||
/** Max images to show in the grid (default 4). */
|
||
maxVisible?: number;
|
||
/** Max height for images in the grid. */
|
||
maxGridHeight?: string;
|
||
/**
|
||
* Optional map from image URL to imeta metadata (dim, blurhash).
|
||
* Used to size skeleton placeholders correctly before the image loads.
|
||
*/
|
||
imetaMap?: Map<string, ImetaDimensions>;
|
||
/** Forwarded to Lightbox — custom content pinned to the bottom of the overlay. */
|
||
lightboxBottomBar?: React.ReactNode;
|
||
/** Forwarded to Lightbox — custom left top-bar content. */
|
||
lightboxTopBarLeft?: React.ReactNode;
|
||
/** Controlled lightbox index (optional). When provided the component is semi-controlled. */
|
||
lightboxIndex?: number | null;
|
||
/** Called when the lightbox wants to open at an index. */
|
||
onLightboxOpen?: (index: number) => void;
|
||
/** Called when the lightbox wants to close. */
|
||
onLightboxClose?: () => void;
|
||
}
|
||
|
||
/**
|
||
* Image grid + full-screen lightbox gallery.
|
||
* Clicking an image opens a cinematic overlay with prev/next navigation,
|
||
* keyboard support (arrow keys, Escape), and swipe gestures.
|
||
*/
|
||
export function ImageGallery({
|
||
images,
|
||
className,
|
||
maxVisible = 4,
|
||
maxGridHeight = '400px',
|
||
imetaMap,
|
||
lightboxBottomBar,
|
||
lightboxTopBarLeft,
|
||
lightboxIndex: controlledIndex,
|
||
onLightboxOpen,
|
||
onLightboxClose,
|
||
}: ImageGalleryProps) {
|
||
const [internalIndex, setInternalIndex] = useState<number | null>(null);
|
||
|
||
// Support both controlled and uncontrolled lightbox index
|
||
const lightboxIndex = controlledIndex !== undefined ? controlledIndex : internalIndex;
|
||
|
||
const visibleImages = images.slice(0, maxVisible);
|
||
const overflow = images.length - maxVisible;
|
||
|
||
const openLightbox = (index: number, e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (onLightboxOpen) onLightboxOpen(index);
|
||
else setInternalIndex(index);
|
||
};
|
||
|
||
const closeLightbox = useCallback((e?: React.MouseEvent) => {
|
||
if (e) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
}
|
||
if (onLightboxClose) onLightboxClose();
|
||
else setInternalIndex(null);
|
||
}, [onLightboxClose]);
|
||
|
||
const goNext = useCallback(() => {
|
||
if (onLightboxOpen && lightboxIndex !== null && lightboxIndex !== undefined) {
|
||
onLightboxOpen(lightboxIndex + 1 < images.length ? lightboxIndex + 1 : lightboxIndex);
|
||
} else {
|
||
setInternalIndex((prev) => (prev !== null ? Math.min(prev + 1, images.length - 1) : null));
|
||
}
|
||
}, [images.length, lightboxIndex, onLightboxOpen]);
|
||
|
||
const goPrev = useCallback(() => {
|
||
if (onLightboxOpen && lightboxIndex !== null && lightboxIndex !== undefined) {
|
||
onLightboxOpen(lightboxIndex - 1 >= 0 ? lightboxIndex - 1 : lightboxIndex);
|
||
} else {
|
||
setInternalIndex((prev) => (prev !== null ? Math.max(prev - 1, 0) : null));
|
||
}
|
||
}, [lightboxIndex, onLightboxOpen]);
|
||
|
||
if (images.length === 0) return null;
|
||
|
||
return (
|
||
<>
|
||
{/* Thumbnail grid */}
|
||
<div
|
||
className={cn(
|
||
'mt-3 rounded-2xl overflow-hidden',
|
||
visibleImages.length > 1 && 'grid grid-cols-2 gap-0.5',
|
||
className,
|
||
)}
|
||
>
|
||
{visibleImages.map((url, i) => (
|
||
<GridImage
|
||
key={i}
|
||
url={url}
|
||
index={i}
|
||
visibleCount={visibleImages.length}
|
||
maxGridHeight={maxGridHeight}
|
||
overflow={i === visibleImages.length - 1 ? overflow : 0}
|
||
onOpen={(e) => openLightbox(i, e)}
|
||
dim={imetaMap?.get(url)?.dim}
|
||
blurhash={imetaMap?.get(url)?.blurhash}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Lightbox (portals to document.body internally to escape stacking contexts) */}
|
||
{lightboxIndex !== null && lightboxIndex !== undefined && (
|
||
<Lightbox
|
||
images={images}
|
||
currentIndex={lightboxIndex}
|
||
onClose={closeLightbox}
|
||
onNext={goNext}
|
||
onPrev={goPrev}
|
||
topBarLeft={lightboxTopBarLeft}
|
||
bottomBar={lightboxBottomBar}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Parses a NIP-94 `dim` string like "1280x720" into `{ width, height }`.
|
||
* Returns undefined if the string is missing or malformed.
|
||
*/
|
||
function parseDim(dim: string | undefined): { width: number; height: number } | undefined {
|
||
if (!dim) return undefined;
|
||
const [w, h] = dim.split('x').map(Number);
|
||
if (!w || !h || isNaN(w) || isNaN(h)) return undefined;
|
||
return { width: w, height: h };
|
||
}
|
||
|
||
/** Single image tile with a blurhash/skeleton shown until the image loads. */
|
||
function GridImage({
|
||
url,
|
||
index,
|
||
visibleCount,
|
||
maxGridHeight,
|
||
overflow,
|
||
onOpen,
|
||
dim,
|
||
blurhash,
|
||
}: {
|
||
url: string;
|
||
index: number;
|
||
visibleCount: number;
|
||
maxGridHeight: string;
|
||
overflow: number;
|
||
onOpen: (e: React.MouseEvent) => void;
|
||
/** NIP-94 `dim` tag value, e.g. "1280x720". Used to size the placeholder before load. */
|
||
dim?: string;
|
||
/** NIP-94 `blurhash` tag value. Rendered as a canvas placeholder before the image loads. */
|
||
blurhash?: string;
|
||
}) {
|
||
const [loaded, setLoaded] = useState(false);
|
||
const [probedAspectRatio, setProbedAspectRatio] = useState<string | undefined>(undefined);
|
||
const imgRef = useRef<HTMLImageElement>(null);
|
||
const { src, onError } = useBlossomFallback(url);
|
||
|
||
// If the image is already cached by the browser, onLoad may have
|
||
// fired before the ref was attached. Check on mount.
|
||
useEffect(() => {
|
||
if (imgRef.current?.complete && imgRef.current.naturalWidth > 0) {
|
||
setLoaded(true);
|
||
if (!dim) {
|
||
const { naturalWidth: w, naturalHeight: h } = imgRef.current;
|
||
if (w && h) setProbedAspectRatio(`${w} / ${h}`);
|
||
}
|
||
}
|
||
}, [dim]);
|
||
|
||
const isSingle = visibleCount === 1;
|
||
|
||
// Derive intrinsic aspect ratio from the imeta `dim` tag, or from probed
|
||
// naturalWidth/naturalHeight after the image loads (for images with no dim).
|
||
const dimensions = parseDim(dim);
|
||
const aspectRatio = dimensions ? `${dimensions.width} / ${dimensions.height}` : probedAspectRatio;
|
||
|
||
// The button container owns all sizing so that both the placeholder and the
|
||
// <img> can be absolutely positioned to fill it cleanly.
|
||
//
|
||
// - Single image with dim or probed ratio → aspect-ratio drives the height naturally, capped at 85dvh
|
||
// - Single image without dim (loading) → fallback min-height of 200px until probed
|
||
// - Grid tile → fixed height derived from maxGridHeight
|
||
const containerStyle: React.CSSProperties = isSingle
|
||
? {
|
||
aspectRatio,
|
||
maxHeight: '85dvh',
|
||
minHeight: aspectRatio ? undefined : '200px',
|
||
}
|
||
: {
|
||
height:
|
||
visibleCount === 3 && index === 0
|
||
? maxGridHeight
|
||
: `calc(${maxGridHeight} / 2)`,
|
||
};
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'relative block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||
visibleCount === 3 && index === 0 && 'row-span-2',
|
||
)}
|
||
style={containerStyle}
|
||
onClick={onOpen}
|
||
>
|
||
{/* Placeholder shown while the image is loading */}
|
||
{!loaded && (
|
||
isValidBlurhash(blurhash) ? (
|
||
// Blurhash canvas fills the container via CSS — pass small integer decode
|
||
// resolution; the canvas is stretched to 100%×100% by the style prop.
|
||
<Blurhash
|
||
hash={blurhash}
|
||
width={32}
|
||
height={32}
|
||
resolutionX={32}
|
||
resolutionY={32}
|
||
punch={1}
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
width: '100%',
|
||
height: '100%',
|
||
}}
|
||
/>
|
||
) : (
|
||
<Skeleton className="absolute inset-0 w-full h-full rounded-none" />
|
||
)
|
||
)}
|
||
<img
|
||
ref={imgRef}
|
||
src={src}
|
||
alt=""
|
||
width={dimensions?.width}
|
||
height={dimensions?.height}
|
||
className={cn(
|
||
'absolute inset-0 w-full h-full object-cover transition-all duration-300 hover:scale-[1.02]',
|
||
loaded ? 'opacity-100' : 'opacity-0',
|
||
)}
|
||
loading="lazy"
|
||
onLoad={(e) => {
|
||
setLoaded(true);
|
||
if (!dim) {
|
||
const img = e.currentTarget;
|
||
if (img.naturalWidth && img.naturalHeight) {
|
||
setProbedAspectRatio(`${img.naturalWidth} / ${img.naturalHeight}`);
|
||
}
|
||
}
|
||
}}
|
||
onError={onError}
|
||
/>
|
||
{/* "+N" overlay on last visible image */}
|
||
{overflow > 0 && (
|
||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-[2px]">
|
||
<span className="text-white text-2xl font-bold">+{overflow}</span>
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** Sentinel URL — pass as an image entry to render a loading spinner slot in the lightbox. */
|
||
export const LOADING_SENTINEL = '__lightbox_loading__';
|
||
|
||
export interface LightboxMediaMeta {
|
||
mime?: string;
|
||
dim?: string;
|
||
blurhash?: string;
|
||
avatarUrl?: string;
|
||
avatarFallback?: string;
|
||
/** Nostr pubkey — used by audio slot to resolve author avatar via useAuthor. */
|
||
pubkey?: string;
|
||
}
|
||
|
||
export interface LightboxProps {
|
||
images: string[];
|
||
currentIndex: number;
|
||
onClose: () => void;
|
||
onNext: () => void;
|
||
onPrev: () => void;
|
||
/** Per-slot media types — 'image' | 'video' | 'audio'. Defaults to all 'image'. */
|
||
mediaTypes?: ('image' | 'video' | 'audio')[];
|
||
/** Per-slot NIP-94 metadata (mime, dim, blurhash). */
|
||
mediaMeta?: LightboxMediaMeta[];
|
||
/** Custom content rendered on the left side of the top bar (replaces default counter). */
|
||
topBarLeft?: React.ReactNode;
|
||
/** Whether to show the download button (default true). */
|
||
showDownload?: boolean;
|
||
/** Max number of images before dot indicators are hidden on mobile (default 10). */
|
||
maxDotIndicators?: number;
|
||
/**
|
||
* Custom content rendered in a bar pinned to the bottom of the lightbox.
|
||
* Use this to add author info, reactions, captions, etc.
|
||
*/
|
||
bottomBar?: React.ReactNode;
|
||
}
|
||
|
||
export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaTypes, mediaMeta, topBarLeft, showDownload = true, maxDotIndicators = 10, bottomBar }: LightboxProps) {
|
||
// Track loaded state per URL so navigating to an already-loaded neighbour
|
||
// doesn't show the spinner again.
|
||
const [loadedUrls, setLoadedUrls] = useState<Set<string>>(new Set());
|
||
const markLoaded = useCallback((url: string) => {
|
||
setLoadedUrls((prev) => { const next = new Set(prev); next.add(url); return next; });
|
||
}, []);
|
||
|
||
const currentUrl = images[currentIndex];
|
||
const isLoaded = loadedUrls.has(currentUrl);
|
||
const hasMultiple = images.length > 1;
|
||
const canGoNext = currentIndex < images.length - 1;
|
||
const canGoPrev = currentIndex > 0;
|
||
|
||
// Lock body scroll
|
||
useEffect(() => {
|
||
const original = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => { document.body.style.overflow = original; };
|
||
}, []);
|
||
|
||
// Keyboard navigation
|
||
useEffect(() => {
|
||
const handler = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
if (e.key === 'ArrowRight' && canGoNext) onNext();
|
||
if (e.key === 'ArrowLeft' && canGoPrev) onPrev();
|
||
};
|
||
document.addEventListener('keydown', handler);
|
||
return () => document.removeEventListener('keydown', handler);
|
||
}, [onClose, onNext, onPrev, canGoNext, canGoPrev]);
|
||
|
||
// ── Per-image absolute positioning ────────────────────────────────────────────
|
||
// Each image is rendered at a stable key (its URL) and positioned absolutely
|
||
// at translateX((index - currentIndex) * 100vw + dragOffsetPx).
|
||
// Because each slot keeps its key across navigation, the <img> element is
|
||
// never destroyed — the browser's decoded image stays in memory, eliminating
|
||
// the reload-on-swipe problem.
|
||
//
|
||
// Only images within 1 step of currentIndex are rendered to cap DOM size.
|
||
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
// dragOffsetPx is mutated directly on the DOM (no React state) for 60fps feel
|
||
const dragOffsetRef = useRef(0);
|
||
|
||
const dragX = useRef<number | null>(null);
|
||
const dragY = useRef<number | null>(null);
|
||
const axis = useRef<'h' | 'v' | null>(null);
|
||
const animating = useRef(false);
|
||
const EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||
const DURATION = 280;
|
||
|
||
// ── Vertical swipe-to-dismiss state ───────────────────────────────────────
|
||
const verticalOffsetRef = useRef(0);
|
||
/** Whether a child LightboxImage is currently zoomed (scale > 1). */
|
||
const childZoomedRef = useRef(false);
|
||
|
||
// Refs to each rendered slot keyed by image index
|
||
const slotRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||
|
||
const setSlotTransform = useCallback((idx: number, offsetPx: number, transition: string) => {
|
||
const el = slotRefs.current.get(idx);
|
||
if (!el) return;
|
||
const base = (idx - currentIndex) * window.innerWidth;
|
||
el.style.transition = transition;
|
||
el.style.transform = `translateX(${base + offsetPx}px)`;
|
||
}, [currentIndex]);
|
||
|
||
// Apply current positions to all rendered slots without animation
|
||
const snapAll = useCallback((offsetPx = 0) => {
|
||
slotRefs.current.forEach((_, idx) => setSlotTransform(idx, offsetPx, 'none'));
|
||
}, [setSlotTransform]);
|
||
|
||
/** Apply vertical drag offset + opacity to the lightbox for swipe-to-dismiss.
|
||
* Backdrop fades in-place; all visible content (top bar, nav, images, dots,
|
||
* bottom bar) translates together via [data-lightbox-content]. */
|
||
const applyVerticalDismiss = useCallback((offsetY: number, transition: string) => {
|
||
const el = containerRef.current;
|
||
if (!el) return;
|
||
const progress = Math.min(Math.abs(offsetY) / (window.innerHeight * 0.4), 1);
|
||
el.style.transition = transition ? `opacity ${transition.split(' ').slice(1).join(' ')}` : 'none';
|
||
el.style.opacity = String(1 - progress * 0.6);
|
||
const content = el.querySelector<HTMLDivElement>('[data-lightbox-content]');
|
||
if (content) {
|
||
content.style.transition = transition;
|
||
content.style.transform = `translateY(${offsetY}px)`;
|
||
}
|
||
}, []);
|
||
|
||
// When currentIndex changes (keyboard/button nav), snap all slots into position instantly
|
||
useEffect(() => {
|
||
dragOffsetRef.current = 0;
|
||
snapAll(0);
|
||
}, [currentIndex, snapAll]);
|
||
|
||
// Safety: clear animating lock on unmount so stale refs can't block controls
|
||
useEffect(() => () => { animating.current = false; }, []);
|
||
|
||
const onTouchStart = (e: React.TouchEvent) => {
|
||
if (animating.current) return;
|
||
// Pinch gesture — don't start a swipe
|
||
if (e.touches.length >= 2) { dragX.current = null; dragY.current = null; return; }
|
||
dragX.current = e.touches[0].clientX;
|
||
dragY.current = e.touches[0].clientY;
|
||
axis.current = null;
|
||
// Kill any in-flight transition
|
||
slotRefs.current.forEach((_, idx) => setSlotTransform(idx, dragOffsetRef.current, 'none'));
|
||
// Reset vertical dismiss offset
|
||
applyVerticalDismiss(0, 'none');
|
||
verticalOffsetRef.current = 0;
|
||
};
|
||
|
||
// Registered via addEventListener with { passive: false } to allow preventDefault
|
||
const onTouchMoveRef = useRef((_e: TouchEvent) => {});
|
||
onTouchMoveRef.current = (e: TouchEvent) => {
|
||
if (dragX.current === null || dragY.current === null || animating.current) return;
|
||
const dx = e.touches[0].clientX - dragX.current;
|
||
const dy = e.touches[0].clientY - dragY.current;
|
||
if (!axis.current) {
|
||
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
|
||
axis.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
||
}
|
||
if (axis.current === 'v') {
|
||
// Vertical swipe-to-dismiss — only when not zoomed
|
||
if (childZoomedRef.current) return;
|
||
e.preventDefault();
|
||
verticalOffsetRef.current = dy;
|
||
applyVerticalDismiss(dy, 'none');
|
||
return;
|
||
}
|
||
if (axis.current !== 'h') return;
|
||
e.preventDefault();
|
||
const atEdge = (dx > 0 && !canGoPrev) || (dx < 0 && !canGoNext);
|
||
dragOffsetRef.current = atEdge ? dx * 0.2 : dx;
|
||
slotRefs.current.forEach((_, idx) => setSlotTransform(idx, dragOffsetRef.current, 'none'));
|
||
};
|
||
|
||
useEffect(() => {
|
||
const el = containerRef.current;
|
||
if (!el) return;
|
||
const handler = (e: TouchEvent) => onTouchMoveRef.current(e);
|
||
el.addEventListener('touchmove', handler, { passive: false });
|
||
return () => el.removeEventListener('touchmove', handler);
|
||
}, []);
|
||
|
||
const onTouchEnd = (e: React.TouchEvent) => {
|
||
// Handle vertical swipe-to-dismiss
|
||
if (axis.current === 'v' && dragY.current !== null && !childZoomedRef.current) {
|
||
const dy = e.changedTouches[0].clientY - dragY.current;
|
||
dragX.current = null; dragY.current = null; axis.current = null;
|
||
const committed = Math.abs(dy) > window.innerHeight * 0.15;
|
||
if (committed) {
|
||
// Animate out in the swipe direction and dismiss
|
||
animating.current = true;
|
||
const targetY = dy > 0 ? window.innerHeight : -window.innerHeight;
|
||
applyVerticalDismiss(targetY, `transform ${DURATION}ms ${EASING}`);
|
||
setTimeout(() => {
|
||
verticalOffsetRef.current = 0;
|
||
onClose();
|
||
animating.current = false;
|
||
}, DURATION);
|
||
} else {
|
||
// Spring back
|
||
applyVerticalDismiss(0, `transform ${DURATION}ms ${EASING}`);
|
||
verticalOffsetRef.current = 0;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (dragX.current === null || axis.current !== 'h') {
|
||
dragX.current = null; dragY.current = null; axis.current = null;
|
||
// Spring back
|
||
slotRefs.current.forEach((_, idx) =>
|
||
setSlotTransform(idx, 0, `transform ${DURATION}ms ${EASING}`)
|
||
);
|
||
dragOffsetRef.current = 0;
|
||
return;
|
||
}
|
||
const dx = e.changedTouches[0].clientX - dragX.current;
|
||
dragX.current = null; dragY.current = null; axis.current = null;
|
||
|
||
const committed = Math.abs(dx) > window.innerWidth * 0.2;
|
||
const goingNext = dx < 0 && canGoNext && committed;
|
||
const goingPrev = dx > 0 && canGoPrev && committed;
|
||
|
||
if (goingNext || goingPrev) {
|
||
animating.current = true;
|
||
const targetOffset = goingNext ? -window.innerWidth : window.innerWidth;
|
||
const transition = `transform ${DURATION}ms ${EASING}`;
|
||
slotRefs.current.forEach((_, idx) => setSlotTransform(idx, targetOffset, transition));
|
||
setTimeout(() => {
|
||
animating.current = false;
|
||
dragOffsetRef.current = 0;
|
||
if (goingNext) onNext();
|
||
else onPrev();
|
||
}, DURATION);
|
||
} else {
|
||
// Not committed — spring back
|
||
slotRefs.current.forEach((_, idx) =>
|
||
setSlotTransform(idx, 0, `transform ${DURATION}ms ${EASING}`)
|
||
);
|
||
dragOffsetRef.current = 0;
|
||
}
|
||
};
|
||
|
||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.tagName === 'IMG' || target.closest('button') || target.closest('[data-gallery-topbar]')) return;
|
||
e.stopPropagation(); e.preventDefault();
|
||
onClose();
|
||
};
|
||
|
||
const handleDownload = (e: React.MouseEvent) => {
|
||
e.stopPropagation(); e.preventDefault();
|
||
openUrl(currentUrl);
|
||
};
|
||
|
||
// Only render the current image and its immediate neighbours
|
||
const visibleIndices = [currentIndex - 1, currentIndex, currentIndex + 1].filter(
|
||
(i) => i >= 0 && i < images.length,
|
||
);
|
||
|
||
return createPortal(
|
||
<div
|
||
ref={containerRef}
|
||
className="fixed inset-0 z-[100] animate-in fade-in duration-200"
|
||
onClick={handleBackdropClick}
|
||
onTouchStart={onTouchStart}
|
||
onTouchEnd={onTouchEnd}
|
||
>
|
||
{/* Backdrop — fades in-place, never translates */}
|
||
<div className="absolute inset-0 bg-black/90 backdrop-blur-md" />
|
||
|
||
{/* All interactive content — translated together during swipe-to-dismiss */}
|
||
<div data-lightbox-content className="absolute inset-0">
|
||
{/* Top bar */}
|
||
<div data-gallery-topbar className="absolute left-0 right-0 z-10 flex items-center justify-between px-4 py-3 safe-area-inset-top">
|
||
{topBarLeft !== undefined ? topBarLeft : (
|
||
<>
|
||
{hasMultiple && <span className="text-white/80 text-sm font-medium tabular-nums">{currentIndex + 1} / {images.length}</span>}
|
||
{!hasMultiple && <span />}
|
||
</>
|
||
)}
|
||
<div className="flex items-center gap-1">
|
||
{showDownload && (
|
||
<button onClick={handleDownload} className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="Open original">
|
||
<Download className="size-5" />
|
||
</button>
|
||
)}
|
||
<button onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClose(); }} className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="Close (Esc)">
|
||
<X className="size-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Prev/next buttons (desktop) */}
|
||
{canGoPrev && (
|
||
<button onClick={(e) => { e.stopPropagation(); onPrev(); }} className="absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex" title="Previous">
|
||
<ChevronLeft className="size-6" />
|
||
</button>
|
||
)}
|
||
{canGoNext && (
|
||
<button onClick={(e) => { e.stopPropagation(); onNext(); }} className="absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex" title="Next">
|
||
<ChevronRight className="size-6" />
|
||
</button>
|
||
)}
|
||
|
||
{/* Per-image slots — each absolutely positioned by index offset */}
|
||
<div data-lightbox-strip className="absolute inset-0 overflow-hidden">
|
||
{visibleIndices.map((i) => {
|
||
const url = images[i];
|
||
const isCurrent = i === currentIndex;
|
||
const initialX = (i - currentIndex) * window.innerWidth;
|
||
return (
|
||
<div
|
||
key={url}
|
||
ref={(el) => {
|
||
if (el) slotRefs.current.set(i, el);
|
||
else slotRefs.current.delete(i);
|
||
}}
|
||
className={cn(
|
||
'absolute inset-0 flex items-center justify-center will-change-transform',
|
||
bottomBar ? 'pb-24 pt-14 px-4 sm:px-12' : 'py-6 pt-14 px-4 sm:px-12',
|
||
)}
|
||
style={{ transform: `translateX(${initialX}px)` }}
|
||
>
|
||
{isCurrent && !isLoaded && (mediaTypes?.[i] ?? 'image') === 'image' && (
|
||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||
<div className="size-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
<LightboxSlot
|
||
url={url}
|
||
type={mediaTypes?.[i] ?? 'image'}
|
||
meta={mediaMeta?.[i]}
|
||
isActive={isCurrent}
|
||
isLoaded={isCurrent ? isLoaded : true}
|
||
onLoad={markLoaded}
|
||
onSwipeBlocked={() => { dragX.current = null; axis.current = null; }}
|
||
onZoomChange={(zoomed) => { childZoomedRef.current = zoomed; }}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Dot indicators */}
|
||
{hasMultiple && images.length <= maxDotIndicators && (
|
||
<div className={cn('absolute left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5 sm:hidden', bottomBar ? 'bottom-20' : 'bottom-6')}>
|
||
{images.map((_, i) => (
|
||
<div key={i} className={cn('rounded-full transition-all duration-200', i === currentIndex ? 'size-2 bg-white' : 'size-1.5 bg-white/40')} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Bottom bar — author info, reactions, captions, etc. */}
|
||
{bottomBar && (
|
||
<div className="absolute inset-x-0 bottom-0 z-10" onClick={(e) => e.stopPropagation()}>
|
||
{bottomBar}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
const MIN_SCALE = 1;
|
||
const MAX_SCALE = 8;
|
||
|
||
/** Lightbox image with pinch/wheel zoom and pan support. */
|
||
function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked, onZoomChange }: {
|
||
url: string;
|
||
isLoaded: boolean;
|
||
onLoad: (url: string) => void;
|
||
/** Called when a horizontal swipe is intercepted by pan (image is zoomed). */
|
||
onSwipeBlocked?: () => void;
|
||
/** Called when the image zoom state changes (zoomed in or back to 1x). */
|
||
onZoomChange?: (zoomed: boolean) => void;
|
||
}) {
|
||
const { src, onError } = useBlossomFallback(url);
|
||
const imgRef = useRef<HTMLImageElement>(null);
|
||
const wrapRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Zoom/pan state — mutated directly on DOM for 60fps
|
||
const scale = useRef(1);
|
||
const panX = useRef(0);
|
||
const panY = useRef(0);
|
||
|
||
// Pinch tracking
|
||
const pinchStart = useRef<{ dist: number; midX: number; midY: number; scale: number; panX: number; panY: number } | null>(null);
|
||
// Pan tracking (single finger when zoomed)
|
||
const panStart = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||
// Double-tap
|
||
const lastTap = useRef(0);
|
||
// Mouse drag when zoomed
|
||
const mouseDrag = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||
|
||
const handleLoaded = useCallback(() => onLoad(url), [onLoad, url]);
|
||
|
||
// If the image is already cached, onLoad may not fire — check on mount.
|
||
useEffect(() => {
|
||
if (imgRef.current?.complete && imgRef.current.naturalWidth > 0) handleLoaded();
|
||
}, [src, handleLoaded]);
|
||
|
||
/** Notify parent when zoom state changes. */
|
||
const notifyZoom = useCallback(() => {
|
||
onZoomChange?.(scale.current > 1);
|
||
}, [onZoomChange]);
|
||
|
||
// Reset zoom when url changes
|
||
useEffect(() => {
|
||
scale.current = 1;
|
||
panX.current = 0;
|
||
panY.current = 0;
|
||
applyTransform();
|
||
notifyZoom();
|
||
}, [url, notifyZoom]);
|
||
|
||
function applyTransform(animated = false) {
|
||
const el = wrapRef.current;
|
||
if (!el) return;
|
||
el.style.transition = animated ? 'transform 0.25s ease' : 'none';
|
||
el.style.transform = `translate(${panX.current}px, ${panY.current}px) scale(${scale.current})`;
|
||
}
|
||
|
||
function clampPan(s = scale.current) {
|
||
const el = imgRef.current;
|
||
const wrap = wrapRef.current;
|
||
if (!el || !wrap) return;
|
||
const iw = el.offsetWidth * s;
|
||
const ih = el.offsetHeight * s;
|
||
const cw = wrap.parentElement?.offsetWidth ?? window.innerWidth;
|
||
const ch = wrap.parentElement?.offsetHeight ?? window.innerHeight;
|
||
const maxX = Math.max(0, (iw - cw) / 2);
|
||
const maxY = Math.max(0, (ih - ch) / 2);
|
||
panX.current = Math.max(-maxX, Math.min(maxX, panX.current));
|
||
panY.current = Math.max(-maxY, Math.min(maxY, panY.current));
|
||
}
|
||
|
||
function dist(t: React.TouchList | TouchList) {
|
||
const dx = t[1].clientX - t[0].clientX;
|
||
const dy = t[1].clientY - t[0].clientY;
|
||
return Math.sqrt(dx * dx + dy * dy);
|
||
}
|
||
|
||
const handleTouchStart = (e: React.TouchEvent) => {
|
||
if (e.touches.length === 2) {
|
||
// Pinch start
|
||
pinchStart.current = {
|
||
dist: dist(e.touches),
|
||
midX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||
midY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||
scale: scale.current,
|
||
panX: panX.current,
|
||
panY: panY.current,
|
||
};
|
||
panStart.current = null;
|
||
} else if (e.touches.length === 1) {
|
||
if (scale.current > 1) {
|
||
// Single finger pan when zoomed
|
||
panStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY, panX: panX.current, panY: panY.current };
|
||
}
|
||
// Double-tap to zoom
|
||
const now = Date.now();
|
||
if (now - lastTap.current < 300) {
|
||
e.preventDefault();
|
||
if (scale.current > 1) {
|
||
scale.current = 1; panX.current = 0; panY.current = 0;
|
||
} else {
|
||
scale.current = 2.5;
|
||
// Zoom toward tap point
|
||
const rect = wrapRef.current?.getBoundingClientRect();
|
||
if (rect) {
|
||
const cx = e.touches[0].clientX - rect.left - rect.width / 2;
|
||
const cy = e.touches[0].clientY - rect.top - rect.height / 2;
|
||
panX.current = -cx * (scale.current - 1) / scale.current;
|
||
panY.current = -cy * (scale.current - 1) / scale.current;
|
||
clampPan();
|
||
}
|
||
}
|
||
applyTransform(true);
|
||
notifyZoom();
|
||
}
|
||
lastTap.current = now;
|
||
}
|
||
};
|
||
|
||
const handleTouchMove = (e: TouchEvent) => {
|
||
if (e.touches.length === 2 && pinchStart.current) {
|
||
e.preventDefault();
|
||
const p = pinchStart.current;
|
||
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, p.scale * dist(e.touches) / p.dist));
|
||
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
||
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
||
scale.current = newScale;
|
||
panX.current = p.panX + (midX - p.midX);
|
||
panY.current = p.panY + (midY - p.midY);
|
||
clampPan(newScale);
|
||
applyTransform();
|
||
notifyZoom();
|
||
} else if (e.touches.length === 1 && panStart.current && scale.current > 1) {
|
||
e.preventDefault();
|
||
const p = panStart.current;
|
||
panX.current = p.panX + (e.touches[0].clientX - p.x);
|
||
panY.current = p.panY + (e.touches[0].clientY - p.y);
|
||
clampPan();
|
||
applyTransform();
|
||
onSwipeBlocked?.();
|
||
}
|
||
};
|
||
|
||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||
if (e.touches.length < 2) pinchStart.current = null;
|
||
if (e.touches.length === 0) {
|
||
panStart.current = null;
|
||
// Snap back to min scale if under-pinched
|
||
if (scale.current < MIN_SCALE) {
|
||
scale.current = MIN_SCALE; panX.current = 0; panY.current = 0;
|
||
applyTransform(true);
|
||
notifyZoom();
|
||
} else {
|
||
clampPan();
|
||
applyTransform(true);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Wheel: ctrl+wheel = zoom, plain wheel = pan when zoomed
|
||
const handleWheel = (e: WheelEvent) => {
|
||
if (e.ctrlKey || e.metaKey) {
|
||
e.preventDefault();
|
||
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||
scale.current = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale.current * factor));
|
||
if (scale.current === MIN_SCALE) { panX.current = 0; panY.current = 0; }
|
||
else clampPan();
|
||
applyTransform();
|
||
notifyZoom();
|
||
} else if (scale.current > 1) {
|
||
e.preventDefault();
|
||
panX.current -= e.deltaX;
|
||
panY.current -= e.deltaY;
|
||
clampPan();
|
||
applyTransform();
|
||
}
|
||
};
|
||
|
||
// Mouse drag when zoomed
|
||
const handleMouseDown = (e: React.MouseEvent) => {
|
||
if (scale.current <= 1) return;
|
||
e.preventDefault();
|
||
mouseDrag.current = { x: e.clientX, y: e.clientY, panX: panX.current, panY: panY.current };
|
||
};
|
||
const handleMouseMove = (e: React.MouseEvent) => {
|
||
if (!mouseDrag.current) return;
|
||
panX.current = mouseDrag.current.panX + (e.clientX - mouseDrag.current.x);
|
||
panY.current = mouseDrag.current.panY + (e.clientY - mouseDrag.current.y);
|
||
clampPan();
|
||
applyTransform();
|
||
};
|
||
const handleMouseUp = () => {
|
||
if (!mouseDrag.current) return;
|
||
mouseDrag.current = null;
|
||
clampPan();
|
||
applyTransform(true);
|
||
};
|
||
|
||
// Register non-passive touch/wheel listeners
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
const el = containerRef.current;
|
||
if (!el) return;
|
||
const tm = (e: TouchEvent) => handleTouchMove(e);
|
||
const wh = (e: WheelEvent) => handleWheel(e);
|
||
el.addEventListener('touchmove', tm, { passive: false });
|
||
el.addEventListener('wheel', wh, { passive: false });
|
||
return () => { el.removeEventListener('touchmove', tm); el.removeEventListener('wheel', wh); };
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className="w-full h-full flex items-center justify-center overflow-hidden"
|
||
onTouchStart={handleTouchStart}
|
||
onTouchEnd={handleTouchEnd}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={handleMouseUp}
|
||
style={{ cursor: scale.current > 1 ? 'grab' : 'default' }}
|
||
>
|
||
<div ref={wrapRef} style={{ transformOrigin: 'center center', willChange: 'transform', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<img
|
||
ref={imgRef}
|
||
src={src}
|
||
alt=""
|
||
className={cn(
|
||
'block max-w-full max-h-full object-contain select-none transition-opacity duration-300',
|
||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||
)}
|
||
onLoad={handleLoaded}
|
||
onError={onError}
|
||
draggable={false}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Renders the correct player for a given media type inside a strip slot. */
|
||
function LightboxSlot({
|
||
url,
|
||
type,
|
||
meta,
|
||
isLoaded,
|
||
onLoad,
|
||
onSwipeBlocked,
|
||
onZoomChange,
|
||
}: {
|
||
url: string;
|
||
type: 'image' | 'video' | 'audio';
|
||
meta?: LightboxMediaMeta;
|
||
isActive: boolean;
|
||
isLoaded: boolean;
|
||
onLoad: (url: string) => void;
|
||
onSwipeBlocked?: () => void;
|
||
onZoomChange?: (zoomed: boolean) => void;
|
||
}) {
|
||
const author = useAuthor(type === 'audio' ? meta?.pubkey : undefined);
|
||
const authorMeta = author.data?.metadata;
|
||
const fallback = meta?.pubkey ? genUserName(meta.pubkey) : '?';
|
||
|
||
if (url === LOADING_SENTINEL) {
|
||
return <div className="size-10 border-2 border-white/20 border-t-white/80 rounded-full animate-spin" />;
|
||
}
|
||
|
||
if (type === 'video') {
|
||
return (
|
||
<div className="w-full flex items-center justify-center px-4" onClick={(e) => e.stopPropagation()}>
|
||
<VideoPlayer
|
||
src={url}
|
||
dim={meta?.dim}
|
||
blurhash={meta?.blurhash}
|
||
className="w-full max-w-lg"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
if (type === 'audio') {
|
||
return (
|
||
<div className="w-full flex items-center justify-center px-4" onClick={(e) => e.stopPropagation()}>
|
||
<AudioVisualizer
|
||
src={url}
|
||
mime={meta?.mime}
|
||
avatarUrl={authorMeta?.picture}
|
||
avatarFallback={fallback[0]?.toUpperCase()}
|
||
className="w-full max-w-lg"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
return <LightboxImage url={url} isLoaded={isLoaded} onLoad={onLoad} onSwipeBlocked={onSwipeBlocked} onZoomChange={onZoomChange} />;
|
||
}
|