import { PlatformPressable } from '@react-navigation/elements' import colorFunction from 'color' import React, { useMemo, useState, Dispatch, SetStateAction, useCallback, useRef } from 'react' import { StatusBar, StyleSheet, Modal, View, Linking, Dimensions, NativeSyntheticEvent, NativeScrollEvent, ViewToken, } from 'react-native' import { FlatList, Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler' import Animated, { runOnJS, useAnimatedStyle, useAnimatedReaction, useSharedValue, withSpring, withDecay, SharedValue, } from 'react-native-reanimated' import { useSafeAreaInsets, SafeAreaProvider } from 'react-native-safe-area-context' import { useTheme } from '../../../hooks' import { DenormalizedMessageAttachmentResource } from '../../../types/resources/denormalized_attachment_resource' import { Haptic, platformFontWeightMedium } from '../../../utils' import { formatDatePreview } from '../../../utils/date' import { tokens } from '../../../vendor/tapestry/tokens' import { IconButton, Image, Heading, Text } from '../../display' import { AttachmentDeletingOverlay } from './attachment_deleting_overlay' const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window') const DISMISS_PAN_THRESHOLD = 175 const MIN_DISTANCE_FOR_PAN = 10 // Higher threshold gives pinching priority const SINGLE_FINGER_POINTER = 1 // Single-finger panning / tapping helps to avoid conflicts with pinching const DEFAULT_OPACITY = 1 const DEFAULT_TRANSLATE_X = 0 const DEFAULT_TRANSLATE_Y = 0 const DEFAULT_SCALE = 1 const MIN_SCALE = 0.5 const MAX_SCALE = 5 const MAX_SCALE_OVERSHOOT = 5 const DOUBLE_TAP_ZOOM_SCALE = 2 const DECAY_VELOCITY_FACTOR = 0.4 const RESET_SPRING_CONFIG = { damping: 20, stiffness: 150, } export type MetaProps = { authorName: string createdAt: string } export function ImageAttachment({ attachment, imageAttachments, currentImageIndex, metaProps, onMessageAttachmentLongPress, isDeleting, }: { attachment: DenormalizedMessageAttachmentResource imageAttachments: DenormalizedMessageAttachmentResource[] currentImageIndex: number metaProps: MetaProps onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void isDeleting: boolean }) { const { attributes } = attachment const { url, urlMedium, filename, metadata = {} } = attributes const { colors } = useTheme() const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height }) const [visible, setVisible] = useState(false) // Force modal to remount with fresh state // Fixes a bug where dismissing the modal too quickly causes the Reanimated shared values (like toolbarVisible) to not reset. const [modalKey, setModalKey] = useState(0) return ( <> { if (!isDeleting) { setModalKey(prev => prev + 1) setVisible(true) } }} onLongPress={() => !isDeleting && onMessageAttachmentLongPress(attachment)} android_ripple={{ color: colors.androidRippleNeutral, foreground: true }} accessibilityHint="Long press for more options" disabled={isDeleting} > {filename} {isDeleting && } ) } interface LightboxModalProps { visible: boolean setModalVisible: Dispatch> imageAttachments: DenormalizedMessageAttachmentResource[] initialImageIndex: number metaProps: MetaProps } const LightboxModal = ({ visible, setModalVisible, imageAttachments, initialImageIndex, metaProps, }: LightboxModalProps) => { const onRequestClose = useCallback(() => { setModalVisible(false) }, [setModalVisible]) return ( ) } interface LightboxGalleryProps { imageAttachments: DenormalizedMessageAttachmentResource[] initialImageIndex: number metaProps: MetaProps onRequestClose: () => void } const LightboxGallery = ({ imageAttachments, initialImageIndex, metaProps, onRequestClose, }: LightboxGalleryProps) => { const styles = useStyles() const insets = useSafeAreaInsets() const [currentImageIndex, setCurrentImageIndex] = useState(initialImageIndex) const { soft: triggerMaxZoomHaptic, rigid: triggerDismissHaptic } = Haptic // Get current image data const currentImage = imageAttachments[currentImageIndex] const { url, urlMedium, metadata = {} } = currentImage.attributes const imageWidth = metadata.width const imageHeight = metadata.height // Calculate available space for image display const availableWindowWidth = WINDOW_WIDTH const availableWindowHeight = WINDOW_HEIGHT - insets.top - insets.bottom /* ============================ ANIMATION VALUES ============================ */ // Native State: const dismissY = useSharedValue(0) // vertical distance to dismiss modal const opacity = useSharedValue(DEFAULT_OPACITY) // opacity of modal const scale = useSharedValue(DEFAULT_SCALE) // zoom level of image const focalX = useSharedValue(0) // focal point of image between fingers const focalY = useSharedValue(0) // focal point of image between fingers const translateX = useSharedValue(DEFAULT_TRANSLATE_X) // horizontal distance to pan image const translateY = useSharedValue(DEFAULT_TRANSLATE_Y) // vertical distance to pan image const savedScale = useSharedValue(DEFAULT_SCALE) // previous zoom level const savedTranslateX = useSharedValue(DEFAULT_TRANSLATE_X) // previous horizontal position const savedTranslateY = useSharedValue(DEFAULT_TRANSLATE_Y) // previous vertical position const lightboxToolbarVisible = useSharedValue(1) // toolbar visibility state // React (JS) State: const [isStatusBarHidden, setIsStatusBarHidden] = useState(false) const [flatListScrollEnabled, setFlatListScrollEnabled] = useState(true) // Syncs toolbar useSharedValue state with React's state so that the status bar can be hidden/shown based on the toolbar's animation useAnimatedReaction( () => lightboxToolbarVisible.value, value => { runOnJS(setIsStatusBarHidden)(value === 0) } ) /* ============================ HANDLERS ============================ */ const handleOpenInBrowser = useCallback(() => { Linking.openURL(urlMedium || url) }, [urlMedium, url]) // Helper to enable or disable FlatList scroll state after gestures have ended const enableFlatListScroll = useCallback((enableScroll: boolean) => { setFlatListScrollEnabled(enableScroll) }, []) const resetDismissGestures = useCallback(() => { dismissY.value = withSpring(0, RESET_SPRING_CONFIG) opacity.value = withSpring(DEFAULT_OPACITY, RESET_SPRING_CONFIG) }, [dismissY, opacity]) const resetAllGestures = useCallback(() => { resetDismissGestures() scale.value = withSpring(DEFAULT_SCALE, RESET_SPRING_CONFIG) translateX.value = withSpring(DEFAULT_TRANSLATE_X, RESET_SPRING_CONFIG) translateY.value = withSpring(DEFAULT_TRANSLATE_Y, RESET_SPRING_CONFIG) savedScale.value = DEFAULT_SCALE savedTranslateX.value = DEFAULT_TRANSLATE_X savedTranslateY.value = DEFAULT_TRANSLATE_Y lightboxToolbarVisible.value = withSpring(1, RESET_SPRING_CONFIG) enableFlatListScroll(true) }, [ resetDismissGestures, scale, translateX, translateY, savedScale, savedTranslateX, savedTranslateY, lightboxToolbarVisible, enableFlatListScroll, ]) const handleCloseModal = useCallback(() => { onRequestClose() resetAllGestures() }, [onRequestClose, resetAllGestures]) /* ============================ UTILITY WORKLET FUNCTIONS 'worklet' runs functions on the UI thread, instead of the JS thread for better performance. ============================ */ const getImageContainedToWindowDimensions = () => { 'worklet' if (!imageWidth || !imageHeight) { return { width: availableWindowWidth, height: availableWindowHeight } } const imageAspectRatio = imageWidth / imageHeight const windowAspectRatio = availableWindowWidth / availableWindowHeight // Constrain image width if its wider than window if (imageAspectRatio > windowAspectRatio) { return { width: availableWindowWidth, height: availableWindowWidth / imageAspectRatio, } } // Constrain image height if its taller than window return { width: availableWindowHeight * imageAspectRatio, height: availableWindowHeight, } } const zoomToFocalPoint = ({ focalPointX, focalPointY, targetScale, currentSavedScale, currentSavedTranslateX, currentSavedTranslateY, }: { focalPointX: number focalPointY: number targetScale: number currentSavedScale: number currentSavedTranslateX: number currentSavedTranslateY: number }) => { 'worklet' // How far the focal point is from the center of the window const windowCenterX = WINDOW_WIDTH / 2 const windowCenterY = WINDOW_HEIGHT / 2 // Position of focal point relative to current image position const focalRelativeX = focalPointX - windowCenterX - currentSavedTranslateX const focalRelativeY = focalPointY - windowCenterY - currentSavedTranslateY // Calculate new translation to keep focal point under fingers const scaleRatio = targetScale / currentSavedScale const newTranslateX = currentSavedTranslateX + focalRelativeX * (1 - scaleRatio) const newTranslateY = currentSavedTranslateY + focalRelativeY * (1 - scaleRatio) return { translateX: newTranslateX, translateY: newTranslateY, } } const getMaxTranslationLimits = (currentScale: number) => { 'worklet' const { width: containedImageWidth, height: containedImageHeight } = getImageContainedToWindowDimensions() // Image dimensions after scaling/zooming const scaledWidth = containedImageWidth * currentScale const scaledHeight = containedImageHeight * currentScale // How much the scaled image exceeds the window container const excessWidth = scaledWidth - availableWindowWidth const excessHeight = scaledHeight - availableWindowHeight // How far the image can move in each direction before hitting window edges const maxTranslateX = Math.max(DEFAULT_TRANSLATE_X, excessWidth / 2) const maxTranslateY = Math.max(DEFAULT_TRANSLATE_Y, excessHeight / 2) return { maxTranslateX, maxTranslateY } } const clampDecay = (currentScale: number): { x: [number, number]; y: [number, number] } => { 'worklet' const { maxTranslateX, maxTranslateY } = getMaxTranslationLimits(currentScale) return { x: [-maxTranslateX, maxTranslateX], y: [-maxTranslateY, maxTranslateY], } } const clampTranslation = ({ x, y, currentScale, }: { x: number y: number currentScale: number }) => { 'worklet' const { maxTranslateX, maxTranslateY } = getMaxTranslationLimits(currentScale) return { x: Math.min(maxTranslateX, Math.max(-maxTranslateX, x)), y: Math.min(maxTranslateY, Math.max(-maxTranslateY, y)), } } const isImageAtVerticalBoundry = (currentScale: number) => { 'worklet' const { height: containedImageHeight } = getImageContainedToWindowDimensions() // Calculate how much the image can exceed the window container const scaledHeight = containedImageHeight * currentScale const excessHeight = scaledHeight - availableWindowHeight const maxTranslateY = Math.max(DEFAULT_TRANSLATE_Y, excessHeight / 2) const currentTranslateY = translateY.value const panPositionTolerance = 1 // buffer to account for translateY being at a subpixel position const atTopBoundry = currentTranslateY >= maxTranslateY - panPositionTolerance const atBottomBoundry = currentTranslateY <= -maxTranslateY + panPositionTolerance return atTopBoundry || atBottomBoundry } /* ============================ UTILITY FLATLIST FUNCTIONS Supports the image gallery layout and swipe functionality. ============================ */ // Use refs to avoid re-rendering FlatList callbacks... const stableCurrentImageIndexRef = useRef(currentImageIndex) const stableImageAttachmentsLengthRef = useRef(imageAttachments.length) // ...while still ensuring they are updated with the latest values stableCurrentImageIndexRef.current = currentImageIndex stableImageAttachmentsLengthRef.current = imageAttachments.length // Used in tandem with FlatList's initialScrollIndex to quickly calculate the position and size of each image before they load. const getItemLayout = useCallback( (_: unknown, index: number) => ({ length: WINDOW_WIDTH, offset: WINDOW_WIDTH * index, index, }), [] ) // Captures the current image's index after the FlatList finishes its scroll animation. // Used in tandem with onViewableItemsChanged to ensure the final value for currentImageIndex is set. const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent) => { // Calculate the index of the image that is currently visible const imageOffsetX = event.nativeEvent.contentOffset.x const newImageIndex = Math.round(imageOffsetX / WINDOW_WIDTH) // Check if the image index has changed and the FlatList didn't scroll past the first or last image const didImageIndexChange = newImageIndex !== stableCurrentImageIndexRef.current const isImageIndexWithinBounds = newImageIndex >= 0 && newImageIndex < stableImageAttachmentsLengthRef.current if (didImageIndexChange && isImageIndexWithinBounds) { setCurrentImageIndex(newImageIndex) } }, []) // Supplements onMomentumScrollEnd by capturing the current image's index while the FlatList is actively scrolling. // Used in tandem with viewabilityConfig to trigger when the image is 50% visible in the window. const onViewableItemsChanged = useCallback( ({ viewableItems }: { viewableItems: ViewToken[] }) => { if (viewableItems.length === 0) return // Use the first viewable item which is enforced by the FlatList's pagingEnabled prop that allows only two images to be visible at a time when scrolling. const firstViewableItem = viewableItems[0] const newIndex = firstViewableItem.index if (newIndex !== null && newIndex !== stableCurrentImageIndexRef.current) { setCurrentImageIndex(newIndex) } }, [] ) /* ============================ GESTURES ============================ */ const singleTapGesture = Gesture.Tap() .minPointers(SINGLE_FINGER_POINTER) .numberOfTaps(1) .onStart(() => { lightboxToolbarVisible.value = withSpring( lightboxToolbarVisible.value > 0.5 ? 0 : 1, RESET_SPRING_CONFIG ) }) const doubleTapGesture = Gesture.Tap() .minPointers(SINGLE_FINGER_POINTER) .numberOfTaps(2) .onStart(e => { const isZoomedIn = scale.value > DEFAULT_SCALE if (isZoomedIn) { runOnJS(resetAllGestures)() } else { // Hide toolbar when starting to zoom lightboxToolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG) // Zoom to 2x at tap location const newTranslation = zoomToFocalPoint({ focalPointX: e.x, focalPointY: e.y, targetScale: DOUBLE_TAP_ZOOM_SCALE, currentSavedScale: savedScale.value, currentSavedTranslateX: savedTranslateX.value, currentSavedTranslateY: savedTranslateY.value, }) // Apply clamping to ensure image edges remain within window boundaries const clampedTranslate = clampTranslation({ x: newTranslation.translateX, y: newTranslation.translateY, currentScale: DOUBLE_TAP_ZOOM_SCALE, }) // Animate to new scale and translation scale.value = withSpring(DOUBLE_TAP_ZOOM_SCALE, RESET_SPRING_CONFIG) translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG) translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG) // Update saved state for next gesture savedScale.value = DOUBLE_TAP_ZOOM_SCALE savedTranslateX.value = clampedTranslate.x savedTranslateY.value = clampedTranslate.y // Disable FlatList scroll since image is now zoomed runOnJS(enableFlatListScroll)(false) } }) const pinchGesture = Gesture.Pinch() .onStart(e => { focalX.value = e.focalX focalY.value = e.focalY // Hide toolbar when starting to zoom lightboxToolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG) // Ensure that pinch accounts for the decay animation and starts from the true current position. savedTranslateX.value = translateX.value savedTranslateY.value = translateY.value }) .onUpdate(e => { // Zoom image in/out within scale limits const newScale = savedScale.value * e.scale const newScaleClamped = Math.min( MAX_SCALE + MAX_SCALE_OVERSHOOT, Math.max(MIN_SCALE, newScale) ) // Check if we just reached the minimum scale threshold const previousScale = scale.value const reacjedMinScale = previousScale > MIN_SCALE && newScaleClamped <= MIN_SCALE if (reacjedMinScale) { runOnJS(triggerDismissHaptic)() } scale.value = newScaleClamped // Calculate new translation to keep focal point under fingers const newTranslation = zoomToFocalPoint({ focalPointX: focalX.value, focalPointY: focalY.value, targetScale: newScaleClamped, currentSavedScale: savedScale.value, currentSavedTranslateX: savedTranslateX.value, currentSavedTranslateY: savedTranslateY.value, }) // Apply translation without clamping to ensure focal point doesn't jump during gesture translateX.value = newTranslation.translateX translateY.value = newTranslation.translateY }) .onEnd(() => { const currentScale = scale.value // Dismiss modal if fully zoomed out if (currentScale <= MIN_SCALE) { runOnJS(handleCloseModal)() return } // Reset all gestures if image is near default scale const isNearDefaultScale = currentScale <= DEFAULT_SCALE + 0.1 if (isNearDefaultScale) { runOnJS(resetAllGestures)() return } // Check if overshooting the max scale and clamp it const finalScale = currentScale > MAX_SCALE ? MAX_SCALE : currentScale if (finalScale === MAX_SCALE) { runOnJS(triggerMaxZoomHaptic)() } // Recalculate translation using focal point to always return to the same position when image is overshooting the max scale const newTranslation = zoomToFocalPoint({ focalPointX: focalX.value, focalPointY: focalY.value, targetScale: finalScale, currentSavedScale: savedScale.value, currentSavedTranslateX: savedTranslateX.value, currentSavedTranslateY: savedTranslateY.value, }) // Ensure image edges remain within window boundaries const clampedTranslate = clampTranslation({ x: newTranslation.translateX, y: newTranslation.translateY, currentScale: finalScale, }) // Animate to position and scale limits with a spring scale.value = withSpring(finalScale, RESET_SPRING_CONFIG) translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG) translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG) // Save state for next gesture savedScale.value = finalScale savedTranslateX.value = clampedTranslate.x savedTranslateY.value = clampedTranslate.y // Disable FlatList scroll since image is still zoomed runOnJS(enableFlatListScroll)(false) }) const panGesture = Gesture.Pan() .minDistance(MIN_DISTANCE_FOR_PAN) .minPointers(SINGLE_FINGER_POINTER) .maxPointers(SINGLE_FINGER_POINTER) .onStart(() => { // Only start pan if image is zoomed in - prevents conflicts with FlatList if (scale.value <= DEFAULT_SCALE) return // Update saved position to current animated position // This ensures smooth continuation if decay is running savedTranslateX.value = translateX.value savedTranslateY.value = translateY.value }) .onUpdate(e => { // Only pan if image is zoomed in - prevents conflicts with FlatList if (scale.value <= DEFAULT_SCALE) return const newTranslateX = savedTranslateX.value + e.translationX const newTranslateY = savedTranslateY.value + e.translationY // Ensure image edges remain within window boundaries const clampedTranslate = clampTranslation({ x: newTranslateX, y: newTranslateY, currentScale: scale.value, }) translateX.value = clampedTranslate.x translateY.value = clampedTranslate.y }) .onEnd(e => { // Prevents saving pan position if image is zoomed out while panning if (scale.value <= DEFAULT_SCALE) return const clampDecayBounds = clampDecay(scale.value) translateX.value = withDecay( { velocity: e.velocityX * DECAY_VELOCITY_FACTOR, clamp: clampDecayBounds.x, deceleration: 0.996, }, finished => { if (finished) { savedTranslateX.value = translateX.value } } ) translateY.value = withDecay( { velocity: e.velocityY * DECAY_VELOCITY_FACTOR, clamp: clampDecayBounds.y, deceleration: 0.996, }, finished => { if (finished) { savedTranslateY.value = translateY.value } } ) }) const panToDismissModalGesture = Gesture.Pan() .onUpdate(e => { const atDefaultScale = scale.value === DEFAULT_SCALE // Calculate zoom conditions for dismissing modal const atVerticalBoundry = isImageAtVerticalBoundry(scale.value) const panDirectionIsPrimarilyVertical = Math.abs(e.translationY) > Math.abs(e.translationX) const canDismissWhileZoomed = atVerticalBoundry && panDirectionIsPrimarilyVertical if (!(atDefaultScale || canDismissWhileZoomed)) return // Fade image if its been panned past 50% of the dismiss threshold const panDistance = Math.abs(e.translationY) const halfThreshold = DISMISS_PAN_THRESHOLD / 2 const fadeDistance = Math.max(0, panDistance - halfThreshold) const fadeProgress = fadeDistance / halfThreshold // Check if we just crossed the dismiss threshold const previousDistance = Math.abs(dismissY.value) const crossedDismissThreshold = previousDistance < DISMISS_PAN_THRESHOLD && panDistance >= DISMISS_PAN_THRESHOLD if (crossedDismissThreshold) { runOnJS(triggerDismissHaptic)() } opacity.value = Math.max(0, DEFAULT_OPACITY - fadeProgress) dismissY.value = e.translationY }) .onEnd(() => { const exceededDismissThreshold = Math.abs(dismissY.value) > DISMISS_PAN_THRESHOLD if (exceededDismissThreshold) { runOnJS(handleCloseModal)() } else { runOnJS(resetDismissGestures)() } }) /* ============================== COMPOSE GESTURES ================================= */ // Race between pinch and pan ensures only one is active at a time, preserving focal point logic const pinchOrPanGestures = Gesture.Race(pinchGesture, panGesture) // Exclusive race ensures single tap doesn't interfere with double tap const tapGestures = Gesture.Exclusive(doubleTapGesture, singleTapGesture) // Race between tap gestures and pinch/pan const transformImageGestures = Gesture.Race(tapGestures, pinchOrPanGestures) // Dismiss can work simultaneously with all gestures const composedGesture = Gesture.Simultaneous(transformImageGestures, panToDismissModalGesture) return ( <>