import React, { useCallback, useMemo, useState } from 'react'; import { Image, Modal, PanResponder, Pressable, Text, View } from 'react-native'; import { ChevronLeft, ChevronRight, ImageOff, X } from 'lucide-react-native'; import { imageGalleryStyles } from './imageGalleryStyles'; import { themedColor } from '../theme'; // Horizontal travel (px) past which a lightbox swipe pages to the next/prev image. const SWIPE_THRESHOLD = 50; export type GalleryImage = { /** Stable key for React lists (and for tracking the active lightbox image). */ key: string; /** Public image URL once resolved; null/undefined while loading or on failure. */ url?: string | null; /** Source file name, used for alt text and the failed-tile label. */ fileName?: string; /** True while the image is still resolving (renders a placeholder tile). */ loading?: boolean; /** True once the image failed to resolve (renders a failed tile). */ failed?: boolean; }; /** Generic image gallery: a single resolved image renders inline (full width, * natural aspect ratio); a batch collapses into a 2-column grid. Tapping a * resolved image opens a full-screen lightbox that pages through the resolved * images (swipe or arrows). Loading/failed images render placeholder tiles. * * Shared by the `send_image` tool renderer (`SendImageGallery`) and the * image-attachment preview in chat messages. */ export function ImageGallery({ images, onOpen }: { images: GalleryImage[]; onOpen?: (image: GalleryImage) => void }) { const viewable = useMemo(() => images.filter((img) => img.url), [images]); // Track the active lightbox image by key, not index: as earlier images in a // batch resolve they shift positions, so a stored index would silently point // at a different image. The key is stable. const [activeKey, setActiveKey] = useState(null); const closeLightbox = useCallback(() => setActiveKey(null), []); // Step to the prev/next resolved image by key (no-op at the ends). const stepLightbox = useCallback( (delta: number) => setActiveKey((key) => { if (key == null) return key; const next = viewable[viewable.findIndex((v) => v.key === key) + delta]; return next ? next.key : key; }), [viewable], ); const showPrev = useCallback(() => stepLightbox(-1), [stepLightbox]); const showNext = useCallback(() => stepLightbox(1), [stepLightbox]); // Swipe left/right inside the lightbox to page through images. Claim the // gesture only for deliberate horizontal drags so a plain tap still bubbles to // the backdrop (which closes) and vertical motion is ignored. const panResponder = useMemo( () => PanResponder.create({ onMoveShouldSetPanResponder: (_evt, g) => Math.abs(g.dx) > 12 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5, onPanResponderRelease: (_evt, g) => { if (g.dx <= -SWIPE_THRESHOLD) showNext(); else if (g.dx >= SWIPE_THRESHOLD) showPrev(); }, }), [showPrev, showNext], ); if (images.length === 0) return null; // Only the resolved single-image case uses the full-width SingleImage; a lone // loading/failed image falls through to the tile path, which renders a proper // placeholder instead of an Image with a null URI. const isSingle = images.length === 1 && !!images[0].url; const openLightbox = (img: GalleryImage) => { onOpen?.(img); setActiveKey(img.key); }; // Derive the active image and its position from the key each render, so the // lightbox stays on the same image as `viewable` grows/reorders. A key that's // no longer viewable yields a null active, which closes the Modal. const activeIdx = activeKey != null ? viewable.findIndex((v) => v.key === activeKey) : -1; const active = activeIdx >= 0 ? viewable[activeIdx] : null; const canGoPrev = activeIdx > 0; const canGoNext = activeIdx >= 0 && activeIdx < viewable.length - 1; return ( {isSingle ? ( openLightbox(images[0])} /> ) : ( {images.map((img) => ( // GalleryTile only renders a pressable for resolved images, so the // handler is a no-op for loading/failed tiles. openLightbox(img)} /> ))} )} {active ? ( ) : null} {canGoPrev ? ( ) : null} {canGoNext ? ( ) : null} {viewable.length > 1 ? ( {activeIdx + 1} / {viewable.length} ) : null} ); } function SingleImage({ image, onPress }: { image: GalleryImage; onPress: () => void }) { // Lock the aspect ratio once the natural size loads (falls back to 3:2). const [aspectRatio, setAspectRatio] = useState(null); return ( { const { height, width } = event.nativeEvent.source; if (width > 0 && height > 0) setAspectRatio(width / height); }} resizeMode="contain" source={{ uri: image.url as string }} style={[imageGalleryStyles.sendImageSingleImage, { aspectRatio: aspectRatio ?? 1.5 }]} /> ); } function GalleryTile({ image, onPress }: { image: GalleryImage; onPress?: () => void }) { if (image.loading) { return ; } if (image.failed || !image.url) { return ( {image.fileName || 'Image failed'} ); } return ( ); }