/** * 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 {useCallback, useEffect, useMemo, useState} from 'react' import {PixelRatio, StyleSheet, useWindowDimensions, 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 AnimatableValue, type AnimatedRef, cancelAnimation, interpolate, measure, type MeasuredDimensions, ReduceMotion, type SharedValue, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue, useSharedValue, withDecay, withSpring, type WithSpringConfig, } from 'react-native-reanimated' import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets' import {Image} from 'expo-image' import * as ScreenOrientation from 'expo-screen-orientation' import {type Dimensions} from '#/lib/media/types' import {useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' import {type Lightbox} from '#/components/Lightbox/state' import {useAnalytics} from '#/analytics' import {IS_IOS} from '#/env' import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' import {Footer} from '../chrome/Footer' import {Header} from '../chrome/Header' import { type ImageSource, type LightboxTransforms, type Transform, } from '../types' import ImageItem from './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: IS_IOS ? 1.25 : 0.75, damping: 300, stiffness: 800, } const FAST_SPRING: WithSpringConfig = { mass: IS_IOS ? 1.25 : 0.75, damping: 150, stiffness: 900, } function canAnimate(lightbox: Lightbox): boolean { if (PlatformInfo.getIsReducedMotionEnabled()) { return false } const img = lightbox.images[lightbox.index] return !!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) // Lives here rather than in ImageView so it survives the remount // when the orientation-based key below changes on rotation. const [imageIndex, setImageIndex] = useState(nextLightbox?.index ?? 0) const [orientation, setOrientation] = useState<'portrait' | 'landscape'>( 'portrait', ) const openProgress = useSharedValue(0) const thumbRects = useSharedValue>( {}, ) if (!activeLightbox && nextLightbox) { setActiveLightbox(nextLightbox) setImageIndex(nextLightbox.index) } useEffect(() => { if (!nextLightbox) { return } const initial: Record = {} nextLightbox.images.forEach((img, i) => { initial[i] = img.thumbRect ?? null }) thumbRects.set(initial) 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, thumbRects]) const onFullyClosed = useCallback(() => { setActiveLightbox(null) scheduleOnUI(() => { 'worklet' thumbRects.set({}) }) requestIdleCallback(() => { void Image.clearMemoryCache() }) }, [thumbRects]) useAnimatedReaction( () => openProgress.get() === 0, (isGone, wasGone) => { if (isGone && !wasGone) { scheduleOnRN(onFullyClosed) } }, ) // 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) { scheduleOnRN(ScreenOrientation.unlockAsync) } else if (!isOpen && wasOpen) { // default is PORTRAIT_UP - set via config plugin in app.config.js -sfn scheduleOnRN(ScreenOrientation.lockAsync, PORTRAIT_UP) } }, ) const onFlyAway = useCallback(() => { 'worklet' openProgress.set(0) scheduleOnRN(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, imageIndex, setImageIndex, orientation, onRequestClose, onPressSave, onPressShare, onFlyAway, safeAreaRef, openProgress, thumbRects, }: { lightbox: Lightbox imageIndex: number setImageIndex: React.Dispatch> orientation: 'portrait' | 'landscape' onRequestClose: () => void onPressSave: (uri: string) => void onPressShare: (uri: string) => void onFlyAway: () => void safeAreaRef: AnimatedRef openProgress: SharedValue thumbRects: SharedValue> }) { const {images, metricsContext} = lightbox // Capture at mount: after a rotation remount this is the preserved // current index, so the pager re-opens on the same image. const [initialImageIndex] = useState(imageIndex) const ax = useAnalytics() const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) const [isScaled, setIsScaled] = useState(false) const [isDragging, setIsDragging] = useState(false) const [showControls, setShowControls] = useState(true) const [isAltExpanded, setIsAltExpanded] = 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 = IS_IOS ? 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 handleRequestClose = useCallback(() => { const activeRef = images[imageIndex]?.thumbRef if (isAnimated && activeRef) { scheduleOnUI(() => { 'worklet' const rect = measure(activeRef) thumbRects.modify(rects => { 'worklet' rects[imageIndex] = rect return rects }) scheduleOnRN(onRequestClose) }) } else { onRequestClose() } }, [isAnimated, images, imageIndex, thumbRects, onRequestClose]) 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 (