import React, { useCallback, useEffect, useState } from 'react'; import { Image, ImageStyle, StyleSheet, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; import { AnimatedGalleryImage } from './components/AnimatedGalleryImage'; import { AnimatedGalleryVideo } from './components/AnimatedGalleryVideo'; import type { ImageGalleryFooterProps, ImageGalleryGridProps, ImageGalleryHeaderProps, } from './components/types'; import { useImageGalleryGestures } from './hooks/useImageGalleryGestures'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { ImageGalleryProviderProps, useImageGalleryContext, } from '../../contexts/imageGalleryContext/ImageGalleryContext'; import { OverlayContextValue, useOverlayContext, } from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStateStore } from '../../hooks'; import { useViewport } from '../../hooks/useViewport'; import { IconProps } from '../../icons/utils/base'; import { ImageGalleryState } from '../../state-store/image-gallery-state-store'; import { FileTypes } from '../../types/types'; import { dismissKeyboard } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { BottomSheetModal } from '../UIComponents'; export type ImageGalleryActionHandler = () => Promise | void; export type ImageGalleryActionItem = { action: ImageGalleryActionHandler; Icon: React.ComponentType; id: 'showInChat' | 'save' | 'reply' | 'delete' | string; label: string; type: 'destructive' | 'standard'; }; const MARGIN = 32; export enum HasPinched { FALSE = 0, TRUE, } export enum IsSwiping { UNDETERMINED = 0, TRUE, FALSE, } const imageGallerySelector = (state: ImageGalleryState) => ({ assets: state.assets, currentIndex: state.currentIndex, }); type ImageGalleryWithContextProps = Pick< ImageGalleryProviderProps, 'numberOfImageGalleryGridColumns' > & Pick & { ImageGalleryHeader?: React.ComponentType; ImageGalleryFooter?: React.ComponentType; ImageGalleryGrid?: React.ComponentType; }; export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => { const { numberOfImageGalleryGridColumns, overlayOpacity, ImageGalleryHeader, ImageGalleryFooter, ImageGalleryGrid, } = props; const [isGridViewVisible, setIsGridViewVisible] = useState(false); const { theme: { imageGallery: { backgroundColor, pager, slide }, semantics, }, } = useTheme(); const { imageGalleryStateStore } = useImageGalleryContext(); const { assets, currentIndex } = useStateStore( imageGalleryStateStore.state, imageGallerySelector, ); const { videoPlayerPool } = imageGalleryStateStore; const { vh, vw } = useViewport(); const fullWindowHeight = vh(100); const fullWindowWidth = vw(100); const halfScreenWidth = fullWindowWidth / 2; const halfScreenHeight = fullWindowHeight / 2; const quarterScreenHeight = fullWindowHeight / 4; /** * Fade animation for screen, it is always rendered with pointerEvents * set to none for fast opening */ const screenTranslateY = useSharedValue(fullWindowHeight); const showScreen = useCallback(() => { 'worklet'; screenTranslateY.value = withTiming(0, { duration: 250, easing: Easing.out(Easing.ease), }); }, [screenTranslateY]); /** * Run the fade animation on visible change */ useEffect(() => { dismissKeyboard(); showScreen(); }, [showScreen]); /** * Image height from URL or default to full screen height */ const [currentImageHeight, setCurrentImageHeight] = useState(fullWindowHeight); /** * Header visible value for animating in out */ const headerFooterVisible = useSharedValue(1); /** * Shared values for movement */ const translateX = useSharedValue(0); const translateY = useSharedValue(0); const offsetScale = useSharedValue(1); const scale = useSharedValue(1); const translationX = useSharedValue(-(fullWindowWidth + MARGIN) * currentIndex); useAnimatedReaction( () => currentIndex, (index) => { translationX.value = -(fullWindowWidth + MARGIN) * index; }, [currentIndex, fullWindowWidth], ); /** * Image heights are not provided and therefore need to be calculated. * We start by allowing the image to be the full height then reduce it * to the proper scaled height based on the width being restricted to the * screen width when the dimensions are received. */ useEffect(() => { let currentImageHeight = fullWindowHeight; const photo = assets[currentIndex]; const height = photo?.original_height; const width = photo?.original_width; if (height && width) { const imageHeight = Math.floor(height * (fullWindowWidth / width)); currentImageHeight = imageHeight > fullWindowHeight ? fullWindowHeight : imageHeight; } else if (photo?.uri) { if (photo.type === FileTypes.Image) { Image.getSize(photo.uri, (width, height) => { const imageHeight = Math.floor(height * (fullWindowWidth / width)); currentImageHeight = imageHeight > fullWindowHeight ? fullWindowHeight : imageHeight; }); } } setCurrentImageHeight(currentImageHeight); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentIndex]); // If you change the current index, pause the active video player. useEffect(() => { const activePlayer = videoPlayerPool.getActivePlayer(); if (activePlayer) { activePlayer.pause(); } }, [currentIndex, videoPlayerPool]); const { doubleTap, pan, pinch, singleTap } = useImageGalleryGestures({ currentImageHeight, halfScreenHeight, halfScreenWidth, headerFooterVisible, offsetScale, overlayOpacity, scale, screenHeight: fullWindowHeight, screenWidth: fullWindowWidth, translateX, translateY, translationX, }); /** * If the header is visible we scale down the opacity of it as the * image is swiped downward */ const headerFooterOpacity = useDerivedValue( () => currentImageHeight * scale.value < fullWindowHeight && translateY.value > 0 ? 1 - translateY.value / quarterScreenHeight : currentImageHeight * scale.value > fullWindowHeight && translateY.value > (currentImageHeight / 2) * scale.value - halfScreenHeight ? 1 - (translateY.value - ((currentImageHeight / 2) * scale.value - halfScreenHeight)) / quarterScreenHeight : 1, [currentImageHeight], ); /** * This transition and scaleX reverse lets use scroll right */ const pagerStyle = useAnimatedStyle( () => ({ transform: [ { scaleX: 1 }, { translateX: translationX.value, }, ], }), [], ); /** * Simple background color animation on Y movement */ const containerBackground = useAnimatedStyle( () => ({ backgroundColor: backgroundColor || semantics.backgroundCoreApp, opacity: headerFooterOpacity.value, }), [headerFooterOpacity], ); /** * Show screen style as component is always rendered we hide it * down and up and set opacity to 0 for good measure */ const showScreenStyle = useAnimatedStyle( () => ({ transform: [ { translateY: screenTranslateY.value, }, ], }), [], ); /** * Functions toclose BottomSheetModal with image grid */ const closeGridView = () => { setIsGridViewVisible(false); }; /** * Function to open BottomSheetModal with image grid */ const openGridView = () => { setIsGridViewVisible(true); }; return ( {assets.map((photo, i) => photo.type === FileTypes.Video ? ( ) : ( ), )} {ImageGalleryHeader ? ( ) : null} {ImageGalleryFooter ? ( ) : null} { setIsGridViewVisible(false); }} visible={isGridViewVisible} > {ImageGalleryGrid ? ( ) : null} ); }; export type ImageGalleryProps = Partial; export const ImageGallery = (props: ImageGalleryProps) => { const { numberOfImageGalleryGridColumns } = useImageGalleryContext(); const { ImageGalleryHeader, ImageGalleryFooter, ImageGalleryGrid } = useComponentsContext(); const { overlayOpacity } = useOverlayContext(); return ( ); }; /** * Clamping worklet to clamp the scaling */ export const clamp = (value: number, lowerBound: number, upperBound: number) => { 'worklet'; return Math.min(Math.max(lowerBound, value), upperBound); }; const styles = StyleSheet.create({ animatedContainer: { alignItems: 'center', direction: 'ltr', flexDirection: 'row', }, }); ImageGallery.displayName = 'ImageGallery{imageGallery}';