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 +}