From 6d519f7835a880936b207c698d1fccf4f7a7339c Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:57:56 +0000 Subject: [PATCH] fix(mobile): keep content clear of the side insets in landscape (#28749) In landscape the iPhone sensor housing and Android's side nav bar are left/right insets, which no screen or over-the-air page padded for. Fixes #21190 --- .../mobile-web-app-safe-area-render.test.mjs | 17 +++- mobile/app/_layout.tsx | 79 ++++++++++--------- mobile/app/_layout.web.tsx | 7 +- mobile/src/components/RightDrawer.tsx | 6 +- .../src/components/mounted-bottom-drawer.tsx | 13 ++- .../right-drawer-hardware-back.test.tsx | 1 + mobile/src/files/MobileFilePreviewScreen.tsx | 6 +- .../files/mobile-file-preview-retry.test.tsx | 5 +- mobile/src/home/MobileHomeEmptyState.tsx | 13 +-- mobile/src/layout/side-safe-area.test.tsx | 71 +++++++++++++++++ mobile/src/layout/side-safe-area.tsx | 57 +++++++++++++ .../MobileNativeChatMessageActionsSheet.tsx | 16 +++- mobile/src/session/MobileNativeChatVisual.tsx | 16 +++- 13 files changed, 244 insertions(+), 63 deletions(-) create mode 100644 mobile/src/layout/side-safe-area.test.tsx create mode 100644 mobile/src/layout/side-safe-area.tsx diff --git a/config/scripts/mobile-web-app-safe-area-render.test.mjs b/config/scripts/mobile-web-app-safe-area-render.test.mjs index 5a3c5260afc..f311a45bca9 100644 --- a/config/scripts/mobile-web-app-safe-area-render.test.mjs +++ b/config/scripts/mobile-web-app-safe-area-render.test.mjs @@ -14,7 +14,7 @@ import { } from './mobile-web-app-render-harness.mjs' // The page lays out edge-to-edge under the shell's system bars and pads through its own -// SafeAreaViews, by the insets `init` carries. Measured in a real browser because what is under +// SafeAreaViews, and its root for the sides, by the insets `init` carries. Measured in a real browser because what is under // test is which providers and navigators expo-router actually mounts above a screen. const HOST_ROUTE = '/h/render-check-host' const HOST = { @@ -106,7 +106,9 @@ function readEdges(page) { ) return { titleTop: title === null ? null : range.getBoundingClientRect().top, + titleLeft: title === null ? null : range.getBoundingClientRect().left, fabBottom: fab?.getBoundingClientRect().bottom ?? null, + fabRight: fab?.getBoundingClientRect().right ?? null, envTop: (() => { const probe = document.createElement('div') probe.style.paddingTop = 'env(safe-area-inset-top)' @@ -142,6 +144,19 @@ describeRender('the page pads for the system bars once, by the shell insets', () expect(padded.titleTop - zero.titleTop).toBe(INSETS.top) }, 90_000) + // Landscape puts the iPhone sensor housing on a side. Unequal sides, so a swapped edge fails. + it('keeps the screens clear of the side insets, padding them once', async () => { + const upright = await openHostRoute(INSETS) + const base = await readEdges(upright.page) + await upright.page.close() + const sideways = await openHostRoute({ ...INSETS, left: 59, right: 44 }) + const inset = await readEdges(sideways.page) + await sideways.page.close() + expect([...upright.errors, ...sideways.errors]).toEqual([]) + expect(inset.titleLeft - base.titleLeft).toBe(59) + expect(base.fabRight - inset.fabRight).toBe(44) + }, 60_000) + it('follows a re-sent init, which is how the keyboard and rotation reach the page', async () => { const opened = await openHostRoute(INSETS) const before = await readEdges(opened.page) diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 8b9a62c8391..ad7ee9ccee0 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -25,6 +25,7 @@ import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' import { appUpdateChecker } from '../src/app-update/app-update-runtime' +import { SideSafeArea } from '../src/layout/side-safe-area' // Why: keeps the native splash screen visible until the React tree is mounted // and ready to render. Without this the user sees a blank white/black frame @@ -200,45 +201,47 @@ export default function RootLayout() { - - + + - - - - - - - - - - - - - - - + > + + }} + /> + + + + + + + + + + + + + + + + ) diff --git a/mobile/app/_layout.web.tsx b/mobile/app/_layout.web.tsx index e33906939aa..7b20142574d 100644 --- a/mobile/app/_layout.web.tsx +++ b/mobile/app/_layout.web.tsx @@ -3,13 +3,14 @@ import { StyleSheet, useWindowDimensions, View } from 'react-native' import { Slot } from 'expo-router' import { SafeAreaFrameContext, SafeAreaInsetsContext } from 'react-native-safe-area-context' import { ZERO_SAFE_AREA_INSETS } from '../src/mobile-web-shell/bridge/bridge-safe-area-insets' +import { SideSafeArea } from '../src/layout/side-safe-area' import { usePageBridgeClient } from '../src/transport/client-context.web' import { colors } from '../src/theme/mobile-theme' /** * The page's root layout, standing where the native `_layout.tsx` does. Without one expo-router * mounts `DefaultNavigator`, an all-edges `SafeAreaView`, which would pad each screen a second time. - * Like the native root it adds no padding. It supplies the insets the shell sent in `init` below + * Like the native root it pads only the sides. It supplies the insets the shell sent in `init` below * ExpoRoot's own provider, whose web half reads `env(safe-area-inset-*)`: 0 in both WebViews. */ export default function PageRootLayout() { @@ -24,7 +25,9 @@ export default function PageRootLayout() { - + + + diff --git a/mobile/src/components/RightDrawer.tsx b/mobile/src/components/RightDrawer.tsx index 86d2709cc0a..f8de3a9dc3f 100644 --- a/mobile/src/components/RightDrawer.tsx +++ b/mobile/src/components/RightDrawer.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useCallback, useEffect, useState } from 'react' -import { View, Pressable, StyleSheet, Platform, useWindowDimensions, Keyboard } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { View, Pressable, StyleSheet, Platform, Keyboard } from 'react-native' +import { useSafeAreaFrame, useSafeAreaInsets } from 'react-native-safe-area-context' import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' import Animated, { useSharedValue, @@ -78,7 +78,7 @@ function MountedRightDrawer({ }: MountedRightDrawerProps) { const translateX = useSharedValue(0) const progress = useSharedValue(0) - const { width: screenWidth } = useWindowDimensions() + const { width: screenWidth } = useSafeAreaFrame() const insets = useSafeAreaInsets() const { isWideLayout } = useResponsiveLayout() const panelWidth = resolveRightDrawerPanelWidth(screenWidth, isWideLayout, widthPx) diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index 764a3c5437e..2d2f3e8a371 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -1,6 +1,5 @@ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { View, Pressable, useWindowDimensions, ScrollView, Keyboard, Modal } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' import Animated, { useSharedValue, @@ -19,6 +18,7 @@ import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants' import { bottomDrawerStyles as styles } from './bottom-drawer-styles' import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host' import { useResponsiveLayout } from '../layout/responsive-layout' +import { useWindowSafeAreaInsets } from '../layout/side-safe-area' import { useBackClaim } from '../navigation/use-back-claim' import { currentSoftKeyboardHeight, subscribeSoftKeyboard } from '../platform/keyboard-occlusion' import { hostOs } from '../platform/host-os' @@ -68,7 +68,7 @@ export function MountedBottomDrawer({ // edge stays under the status bar. const [keyboardInset, setKeyboardInset] = useState(0) const { height: screenHeight } = useWindowDimensions() - const insets = useSafeAreaInsets() + const insets = useWindowSafeAreaInsets() // Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and // center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard // transforms below) is unchanged, so phone behavior stays identical. @@ -376,7 +376,14 @@ export function MountedBottomDrawer({ {interactive ? : null} - + ({ useWindowDimensions: () => ({ width: 390, height: 844 }) })) vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaFrame: () => ({ x: 0, y: 0, width: 390, height: 844 }), useSafeAreaInsets: () => ({ top: 62, bottom: 34, left: 0, right: 0 }) })) vi.mock('react-native-gesture-handler', () => { diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index e71da8e2833..973751491a2 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Pressable, Text, View, useWindowDimensions } from 'react-native' -import { SafeAreaView } from 'react-native-safe-area-context' +import { Pressable, Text, View } from 'react-native' +import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context' import { ChevronLeft, Save } from 'lucide-react-native' import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff' import { getWorktreeLabel } from '../session/worktree-label' @@ -51,7 +51,7 @@ export function MobileFilePreviewScreen({ route }: Props) { const draftContentRef = useRef(draftContent) const savedContentRef = useRef(savedContent) const draftSourceKeyRef = useRef(null) - const { width, height } = useWindowDimensions() + const { width, height } = useSafeAreaFrame() const routePreviewSource = useMemo( () => (previewParams ? previewSourceFromRoute(previewParams) : null), [previewParams] diff --git a/mobile/src/files/mobile-file-preview-retry.test.tsx b/mobile/src/files/mobile-file-preview-retry.test.tsx index 46ed986c1bc..54d626d8283 100644 --- a/mobile/src/files/mobile-file-preview-retry.test.tsx +++ b/mobile/src/files/mobile-file-preview-retry.test.tsx @@ -26,7 +26,10 @@ vi.mock('react-native', () => ({ View: 'View', useWindowDimensions: () => ({ width: 390, height: 844 }) })) -vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' })) +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'SafeAreaView', + useSafeAreaFrame: () => ({ x: 0, y: 0, width: 390, height: 844 }) +})) vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Save: 'Icon' })) vi.mock('../navigation/route-handoff', () => ({ useRouteHandoff: () => ({ back: () => {}, canGoBack: () => false }) diff --git a/mobile/src/home/MobileHomeEmptyState.tsx b/mobile/src/home/MobileHomeEmptyState.tsx index eda2496094d..e5d9c6b826e 100644 --- a/mobile/src/home/MobileHomeEmptyState.tsx +++ b/mobile/src/home/MobileHomeEmptyState.tsx @@ -1,5 +1,5 @@ import { QrCode } from 'lucide-react-native' -import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import { colors, radii, spacing } from '../theme/mobile-theme' const ONBOARDING_STEPS = [ @@ -23,9 +23,10 @@ export function MobileHomeEmptyState(props: { isWideLayout: boolean onPairDesktop: () => void }) { + // Scrolls because a phone in landscape is shorter than the hero and the steps together. return ( - ))} - + ) } const styles = StyleSheet.create({ - emptyContainer: { flex: 1 }, + emptyContainer: { flexGrow: 1 }, emptyHero: { - flex: 1, + flexGrow: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32, diff --git a/mobile/src/layout/side-safe-area.test.tsx b/mobile/src/layout/side-safe-area.test.tsx new file mode 100644 index 00000000000..a46f4122004 --- /dev/null +++ b/mobile/src/layout/side-safe-area.test.tsx @@ -0,0 +1,71 @@ +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' +import type { EdgeInsets, Rect } from 'react-native-safe-area-context' + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + View: 'View', + StyleSheet: { create: (styles: T) => styles } +})) +// The library's hooks are plain reads of its two contexts, which is all this stands in for. +vi.mock('react-native-safe-area-context', async () => { + const { createContext, useContext } = await import('react') + const SafeAreaInsetsContext = createContext(null) + const SafeAreaFrameContext = createContext(null) + return { + SafeAreaInsetsContext, + SafeAreaFrameContext, + SafeAreaProvider: ({ children }: { children: ReactNode }) => children, + useSafeAreaInsets: () => useContext(SafeAreaInsetsContext), + useSafeAreaFrame: () => useContext(SafeAreaFrameContext) + } +}) + +const { SafeAreaFrameContext, SafeAreaInsetsContext, useSafeAreaFrame, useSafeAreaInsets } = + await import('react-native-safe-area-context') +const { SideSafeArea, useWindowSafeAreaInsets } = await import('./side-safe-area') + +// Unequal sides, so a swapped edge shows. +const WINDOW = { top: 0, right: 48, bottom: 21, left: 59 } +const FRAME = { x: 0, y: 0, width: 852, height: 393 } + +function renderInLandscape() { + const read: { screen?: EdgeInsets; frame?: Rect; modal?: EdgeInsets } = {} + function Probe() { + read.screen = useSafeAreaInsets() + read.frame = useSafeAreaFrame() + read.modal = useWindowSafeAreaInsets() + return null + } + let tree: ReactTestRenderer | undefined + act(() => { + tree = create( + + + + + + + + ) + }) + return { tree: tree!, read } +} + +describe('the app laid out clear of the side insets', () => { + it('pads the sides once, and screens below see the padded area', () => { + const { tree, read } = renderInLandscape() + const area = tree.root.find((node) => String(node.type) === 'View') + expect(Object.assign({}, ...[area.props.style].flat())).toMatchObject({ + paddingLeft: 59, + paddingRight: 48 + }) + expect(read.screen).toEqual({ top: 0, right: 0, bottom: 21, left: 0 }) + expect(read.frame).toEqual({ x: 59, y: 0, width: 745, height: 393 }) + }) + + it('gives a Modal, which opens a window of its own, the window insets', () => { + expect(renderInLandscape().read.modal).toEqual(WINDOW) + }) +}) diff --git a/mobile/src/layout/side-safe-area.tsx b/mobile/src/layout/side-safe-area.tsx new file mode 100644 index 00000000000..1bab737976a --- /dev/null +++ b/mobile/src/layout/side-safe-area.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useMemo, type ReactNode } from 'react' +import { Platform, StyleSheet, View } from 'react-native' +import { + SafeAreaFrameContext, + SafeAreaInsetsContext, + SafeAreaProvider, + useSafeAreaFrame, + useSafeAreaInsets, + type EdgeInsets +} from 'react-native-safe-area-context' + +const WindowSafeAreaInsetsContext = createContext(null) + +/** + * Lays the app out clear of the window's side insets, which landscape brings: the iPhone sensor + * housing, an Android side navigation bar or cutout. Below it the insets and frame describe the + * padded area, so no screen pads a side twice or sizes past it. + */ +export function SideSafeArea({ children }: { children: ReactNode }) { + const insets = useSafeAreaInsets() + const frame = useSafeAreaFrame() + const areaInsets = useMemo(() => ({ ...insets, left: 0, right: 0 }), [insets]) + const areaFrame = useMemo( + () => ({ + ...frame, + x: frame.x + insets.left, + width: Math.max(0, frame.width - insets.left - insets.right) + }), + [frame, insets.left, insets.right] + ) + const area = ( + + + + {children} + + + + ) + // A native SafeAreaView measures against the nearest native provider rather than the context, so + // one goes inside the padding; on the web it reads the context. + return ( + + {Platform.OS === 'web' ? area : {area}} + + ) +} + +/** The window's insets, for a Modal: it opens a window of its own, outside `SideSafeArea`. */ +export function useWindowSafeAreaInsets(): EdgeInsets { + const insets = useSafeAreaInsets() + return useContext(WindowSafeAreaInsetsContext) ?? insets +} + +const styles = StyleSheet.create({ + area: { flex: 1 } +}) diff --git a/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx index 4d9cf71c6ba..b8f1fa908de 100644 --- a/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx +++ b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx @@ -1,11 +1,11 @@ import { useRef, useState } from 'react' import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Copy, TextSelect, X } from 'lucide-react-native' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { ActionSheetContent } from '../components/ActionSheetModal' import { BottomDrawer } from '../components/BottomDrawer' import { useClipboardWriter } from '../platform/clipboard' +import { useWindowSafeAreaInsets } from '../layout/side-safe-area' import { colors, spacing, typography } from '../theme/mobile-theme' import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text' @@ -75,10 +75,20 @@ function SelectTextScreen({ text: string onClose: () => void }): React.JSX.Element { - const insets = useSafeAreaInsets() + const insets = useWindowSafeAreaInsets() return ( - + Select text void onFailed: () => void }) { - const insets = useSafeAreaInsets() + const insets = useWindowSafeAreaInsets() return ( - + {title}