/** * Copyright (c) JOB TODAY S.A. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ // Original code copied and simplified from the link below as the codebase is currently not maintained: // https://github.com/jobtoday/react-native-image-viewing import React, {useCallback, useEffect, useMemo, useState} from 'react' import {LayoutAnimation, PixelRatio, StyleSheet, View} from 'react-native' import {SystemBars} from 'react-native-edge-to-edge' import {Gesture} from 'react-native-gesture-handler' import PagerView from 'react-native-pager-view' import Animated, { type AnimatedRef, cancelAnimation, interpolate, measure, runOnJS, type SharedValue, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue, useSharedValue, withDecay, withSpring, type WithSpringConfig, } from 'react-native-reanimated' import { SafeAreaView, useSafeAreaFrame, useSafeAreaInsets, } from 'react-native-safe-area-context' import * as ScreenOrientation from 'expo-screen-orientation' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Trans} from '@lingui/macro' import {type Dimensions} from '#/lib/media/types' import {colors, s} from '#/lib/styles' import {isIOS} from '#/platform/detection' import {type Lightbox} from '#/state/lightbox' import {Button} from '#/view/com/util/forms/Button' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' import {useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' import {type ImageSource, type Transform} from './@types' import ImageDefaultHeader from './components/ImageDefaultHeader' import ImageItem from './components/ImageItem/ImageItem' type Rect = {x: number; y: number; width: number; height: number} const PORTRAIT_UP = ScreenOrientation.OrientationLock.PORTRAIT_UP const PIXEL_RATIO = PixelRatio.get() const SLOW_SPRING: WithSpringConfig = { mass: isIOS ? 1.25 : 0.75, damping: 300, stiffness: 800, restDisplacementThreshold: 0.01, } const FAST_SPRING: WithSpringConfig = { mass: isIOS ? 1.25 : 0.75, damping: 150, stiffness: 900, restDisplacementThreshold: 0.01, } function canAnimate(lightbox: Lightbox): boolean { return ( !PlatformInfo.getIsReducedMotionEnabled() && lightbox.images.every( img => img.thumbRect && (img.dimensions || img.thumbDimensions), ) ) } export default function ImageViewRoot({ lightbox: nextLightbox, onRequestClose, onPressSave, onPressShare, }: { lightbox: Lightbox | null onRequestClose: () => void onPressSave: (uri: string) => void onPressShare: (uri: string) => void }) { 'use no memo' const ref = useAnimatedRef() const [activeLightbox, setActiveLightbox] = useState(nextLightbox) const [orientation, setOrientation] = useState<'portrait' | 'landscape'>( 'portrait', ) const openProgress = useSharedValue(0) if (!activeLightbox && nextLightbox) { setActiveLightbox(nextLightbox) } React.useEffect(() => { if (!nextLightbox) { return } const isAnimated = canAnimate(nextLightbox) // https://github.com/software-mansion/react-native-reanimated/issues/6677 rAF_FIXED(() => { openProgress.set(() => isAnimated ? withClampedSpring(1, SLOW_SPRING) : 1, ) }) return () => { // https://github.com/software-mansion/react-native-reanimated/issues/6677 rAF_FIXED(() => { openProgress.set(() => isAnimated ? withClampedSpring(0, SLOW_SPRING) : 0, ) }) } }, [nextLightbox, openProgress]) useAnimatedReaction( () => openProgress.get() === 0, (isGone, wasGone) => { if (isGone && !wasGone) { runOnJS(setActiveLightbox)(null) } }, ) // Delay the unlock until after we've finished the scale up animation. // It's complicated to do the same for locking it back so we don't attempt that. useAnimatedReaction( () => openProgress.get() === 1, (isOpen, wasOpen) => { if (isOpen && !wasOpen) { runOnJS(ScreenOrientation.unlockAsync)() } else if (!isOpen && wasOpen) { // default is PORTRAIT_UP - set via config plugin in app.config.js -sfn runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP) } }, ) const onFlyAway = React.useCallback(() => { 'worklet' openProgress.set(0) runOnJS(onRequestClose)() }, [onRequestClose, openProgress]) return ( // Keep it always mounted to avoid flicker on the first frame. { const layout = e.nativeEvent.layout setOrientation( layout.height > layout.width ? 'portrait' : 'landscape', ) }}> {activeLightbox && ( )} ) } function ImageView({ lightbox, orientation, onRequestClose, onPressSave, onPressShare, onFlyAway, safeAreaRef, openProgress, }: { lightbox: Lightbox orientation: 'portrait' | 'landscape' onRequestClose: () => void onPressSave: (uri: string) => void onPressShare: (uri: string) => void onFlyAway: () => void safeAreaRef: AnimatedRef openProgress: SharedValue }) { const {images, index: initialImageIndex} = lightbox const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) const [isScaled, setIsScaled] = useState(false) const [isDragging, setIsDragging] = useState(false) const [imageIndex, setImageIndex] = useState(initialImageIndex) const [showControls, setShowControls] = useState(true) const [isAltExpanded, setAltExpanded] = React.useState(false) const dismissSwipeTranslateY = useSharedValue(0) const isFlyingAway = useSharedValue(false) const containerStyle = useAnimatedStyle(() => { if (openProgress.get() < 1) { return { pointerEvents: 'none', opacity: isAnimated ? 1 : 0, } } if (isFlyingAway.get()) { return { pointerEvents: 'none', opacity: 1, } } return {pointerEvents: 'auto', opacity: 1} }) const backdropStyle = useAnimatedStyle(() => { const screenSize = measure(safeAreaRef) let opacity = 1 const openProgressValue = openProgress.get() if (openProgressValue < 1) { opacity = Math.sqrt(openProgressValue) } else if (screenSize && orientation === 'portrait') { const dragProgress = Math.min( Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2), 1, ) opacity -= dragProgress } const factor = isIOS ? 100 : 50 return { opacity: Math.round(opacity * factor) / factor, } }) const animatedHeaderStyle = useAnimatedStyle(() => { const show = showControls && dismissSwipeTranslateY.get() === 0 return { pointerEvents: show ? 'box-none' : 'none', opacity: withClampedSpring( show && openProgress.get() === 1 ? 1 : 0, FAST_SPRING, ), transform: [ { translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING), }, ], } }) const animatedFooterStyle = useAnimatedStyle(() => { const show = showControls && dismissSwipeTranslateY.get() === 0 return { flexGrow: 1, pointerEvents: show ? 'box-none' : 'none', opacity: withClampedSpring( show && openProgress.get() === 1 ? 1 : 0, FAST_SPRING, ), transform: [ { translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING), }, ], } }) const onTap = useCallback(() => { setShowControls(show => !show) }, []) const onZoom = useCallback((nextIsScaled: boolean) => { setIsScaled(nextIsScaled) if (nextIsScaled) { setShowControls(false) } }, []) useAnimatedReaction( () => { const screenSize = measure(safeAreaRef) return ( !screenSize || Math.abs(dismissSwipeTranslateY.get()) > screenSize.height ) }, (isOut, wasOut) => { if (isOut && !wasOut) { // Stop the animation from blocking the screen forever. cancelAnimation(dismissSwipeTranslateY) onFlyAway() } }, ) // style system ui on android const t = useTheme() useEffect(() => { setSystemUITheme('lightbox', t) return () => { setSystemUITheme('theme', t) } }, [t]) return (