From ed3de2c1c3c122c8b1fd60c1bec0ee822c9e0dd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 20:55:17 +0000 Subject: [PATCH] Present the native lightbox as a transparentModal screen Register a new Lightbox route (native only) with presentation: 'transparentModal', animation: 'none', orientation: 'all'. The per-screen orientation option lets the lightbox rotate without taking the underlying shell screen along for the ride. openLightbox() still measures the thumbnail on the UI thread and sets activeLightbox synchronously before dispatching navigate('Lightbox'), so the screen mounts with state populated and the opening spring animates from the first frame. usePreventRemove holds the pop until the close spring finishes (onAnimationEnd), covering hardware back, programmatic goBack, and the internal close/swipe-dismiss paths with one callback surface. The ImageViewRoot animation engine is unchanged other than accepting onAnimationEnd and losing the now-redundant ScreenOrientation unlock/lock reaction. Web keeps its existing shell-level overlay. --- src/Navigation.tsx | 15 ++++++ src/components/Lightbox/Lightbox.tsx | 29 ++--------- src/components/Lightbox/pager/ImagePager.tsx | 21 ++------ src/components/Lightbox/state.tsx | 11 ++++ src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/screens/Lightbox/index.tsx | 55 ++++++++++++++++++++ src/screens/Lightbox/index.web.tsx | 8 +++ 8 files changed, 100 insertions(+), 41 deletions(-) create mode 100644 src/screens/Lightbox/index.tsx create mode 100644 src/screens/Lightbox/index.web.tsx diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 6f661e03e9..8e56375d93 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -75,6 +75,7 @@ import {BookmarksScreen} from '#/screens/Bookmarks' import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen' import HashtagScreen from '#/screens/Hashtag' +import {LightboxScreen} from '#/screens/Lightbox' import {LogScreen} from '#/screens/Log' import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' @@ -644,6 +645,20 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { gestureEnabled: false, }} /> + {IS_NATIVE && ( + LightboxScreen} + options={{ + headerShown: false, + presentation: 'transparentModal', + animation: 'none', + orientation: 'all', + gestureEnabled: false, + contentStyle: {backgroundColor: 'transparent'}, + }} + /> + )} ) } diff --git a/src/components/Lightbox/Lightbox.tsx b/src/components/Lightbox/Lightbox.tsx index c283cc89cb..901ffe04fe 100644 --- a/src/components/Lightbox/Lightbox.tsx +++ b/src/components/Lightbox/Lightbox.tsx @@ -1,26 +1,7 @@ -import {useCallback} from 'react' - -import {shareImageModal} from '#/lib/media/manip' -import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' -import ImageView from '#/components/Lightbox/pager/ImagePager' -import {useLightbox, useLightboxControls} from '#/components/Lightbox/state' - +// On native the lightbox is presented as a transparentModal screen +// (see src/screens/Lightbox/index.tsx) so that react-native-screens can +// allow per-screen orientation. The shell-level therefore +// renders nothing on native — the route handles mounting the viewer. export function Lightbox() { - const {activeLightbox} = useLightbox() - const {closeLightbox} = useLightboxControls() - - const onClose = useCallback(() => { - closeLightbox() - }, [closeLightbox]) - - const saveImageToAlbum = useSaveImageToMediaLibrary() - - return ( - shareImageModal({uri})} - /> - ) + return null } diff --git a/src/components/Lightbox/pager/ImagePager.tsx b/src/components/Lightbox/pager/ImagePager.tsx index 1c8836a0fd..a8417cbe71 100644 --- a/src/components/Lightbox/pager/ImagePager.tsx +++ b/src/components/Lightbox/pager/ImagePager.tsx @@ -32,7 +32,6 @@ import Animated, { withSpring, type WithSpringConfig, } from 'react-native-reanimated' -import * as ScreenOrientation from 'expo-screen-orientation' import {type Dimensions} from '#/lib/media/types' import {useTheme} from '#/alf' @@ -51,7 +50,6 @@ 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 = { @@ -80,11 +78,13 @@ export default function ImageViewRoot({ onRequestClose, onPressSave, onPressShare, + onAnimationEnd, }: { lightbox: Lightbox | null onRequestClose: () => void onPressSave: (uri: string) => void onPressShare: (uri: string) => void + onAnimationEnd?: () => void }) { 'use no memo' const ref = useAnimatedRef() @@ -136,7 +136,8 @@ export default function ImageViewRoot({ 'worklet' thumbRects.set({}) })() - }, [thumbRects]) + onAnimationEnd?.() + }, [thumbRects, onAnimationEnd]) useAnimatedReaction( () => openProgress.get() === 0, @@ -147,20 +148,6 @@ export default function ImageViewRoot({ }, ) - // 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 = useCallback(() => { 'worklet' openProgress.set(0) diff --git a/src/components/Lightbox/state.tsx b/src/components/Lightbox/state.tsx index 23af527734..f360dc052b 100644 --- a/src/components/Lightbox/state.tsx +++ b/src/components/Lightbox/state.tsx @@ -10,6 +10,8 @@ import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useHotkeysContext} from '#/lib/hotkeys' import {type ImageSource} from '#/components/Lightbox/types' +import {IS_NATIVE} from '#/env' +import {navigate} from '#/Navigation' export type Lightbox = { id: string @@ -46,15 +48,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, [activeLightbox, disableScope, enableScope]) const doOpen = useNonReactiveCallback((lightbox: Omit) => { + let didOpen = false setActiveLightbox(prevLightbox => { if (prevLightbox) { // Ignore duplicate open requests. If it's already open, // the user has to explicitly close the previous one first. return prevLightbox } else { + didOpen = true return {...lightbox, id: nanoid()} } }) + // On native the lightbox is a transparentModal screen so that + // react-native-screens can allow per-screen orientation. State is set + // synchronously above, so the screen mounts with activeLightbox already + // populated and the opening spring runs from the first frame. + if (didOpen && IS_NATIVE) { + navigate('Lightbox') + } }) const openLightbox = useNonReactiveCallback( diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index e87aad55c3..6259711109 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -91,6 +91,7 @@ export type CommonNavigatorParams = { VideoFeed: VideoFeedSourceContext Bookmarks: undefined FindContactsFlow: undefined + Lightbox: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/routes.ts b/src/routes.ts index 387b1ca410..bee054bfc5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -95,4 +95,5 @@ export const router = new Router({ VideoFeed: '/video-feed', Bookmarks: '/saved', FindContactsFlow: '/find-contacts', + Lightbox: '/lightbox', }) diff --git a/src/screens/Lightbox/index.tsx b/src/screens/Lightbox/index.tsx new file mode 100644 index 0000000000..a984b3e5f1 --- /dev/null +++ b/src/screens/Lightbox/index.tsx @@ -0,0 +1,55 @@ +import {useCallback, useRef} from 'react' +import {type NavigationAction, usePreventRemove} from '@react-navigation/native' + +import {shareImageModal} from '#/lib/media/manip' +import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useLightbox, useLightboxControls} from '#/state/lightbox' +import ImageView from '#/view/com/lightbox/ImageViewing' + +type Props = NativeStackScreenProps + +export function LightboxScreen({navigation}: Props) { + const {activeLightbox} = useLightbox() + const {closeLightbox} = useLightboxControls() + const saveImageToAlbum = useSaveImageToMediaLibrary() + const pendingAction = useRef(null) + + // Block the pop while the lightbox is still open. When a pop is attempted + // (hardware back, programmatic goBack, etc.), start the close spring and + // stash the action to replay once the animation completes. + usePreventRemove(!!activeLightbox, ({data}) => { + pendingAction.current = data.action + closeLightbox() + }) + + const onRequestClose = useCallback(() => { + closeLightbox() + }, [closeLightbox]) + + // Fires from ImageViewRoot once the openProgress spring reaches 0. By then + // activeLightbox is already null, so usePreventRemove no longer blocks and + // the stashed action (or a fresh goBack) pops the screen cleanly. + const onAnimationEnd = useCallback(() => { + const action = pendingAction.current + pendingAction.current = null + if (action) { + navigation.dispatch(action) + } else if (navigation.canGoBack()) { + navigation.goBack() + } + }, [navigation]) + + return ( + shareImageModal({uri})} + /> + ) +} diff --git a/src/screens/Lightbox/index.web.tsx b/src/screens/Lightbox/index.web.tsx new file mode 100644 index 0000000000..33d044dbe6 --- /dev/null +++ b/src/screens/Lightbox/index.web.tsx @@ -0,0 +1,8 @@ +// The native lightbox is presented as a transparentModal screen so that +// react-native-screens can manage per-screen orientation. On web the lightbox +// remains a global overlay (see src/view/com/lightbox/Lightbox.web.tsx) and +// this screen is never registered — this stub exists only to satisfy imports +// on the web bundler. +export function LightboxScreen() { + return null +}