mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
+41
-38
@@ -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() {
|
||||
<RpcClientProvider>
|
||||
<View style={styles.root} onLayout={onNavigatorLayout}>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgPanel },
|
||||
headerTintColor: colors.textPrimary,
|
||||
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
|
||||
contentStyle: { backgroundColor: colors.bgBase },
|
||||
headerShadowVisible: false
|
||||
// Why: deliberately no `orientation` screenOption. react-native-screens
|
||||
// has no value that respects the device rotation lock — even 'default'
|
||||
// calls setRequestedOrientation(UNSPECIFIED) at runtime, overriding the
|
||||
// manifest. Leaving it unset lets the manifest's "fullUser" (set by the
|
||||
// android-respect-rotation-lock config plugin) honor the auto-rotate lock.
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
headerTitle: () => <OrcaLogo size={22} />
|
||||
<SideSafeArea>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgPanel },
|
||||
headerTintColor: colors.textPrimary,
|
||||
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
|
||||
contentStyle: { backgroundColor: colors.bgBase },
|
||||
headerShadowVisible: false
|
||||
// Why: deliberately no `orientation` screenOption. react-native-screens
|
||||
// has no value that respects the device rotation lock — even 'default'
|
||||
// calls setRequestedOrientation(UNSPECIFIED) at runtime, overriding the
|
||||
// manifest. Leaving it unset lets the manifest's "fullUser" (set by the
|
||||
// android-respect-rotation-lock config plugin) honor the auto-rotate lock.
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="mobile-onboarding"
|
||||
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
headerTitle: () => <OrcaLogo size={22} />
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="mobile-onboarding"
|
||||
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</SideSafeArea>
|
||||
</View>
|
||||
</RpcClientProvider>
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
<SafeAreaFrameContext.Provider value={frame}>
|
||||
<SafeAreaInsetsContext.Provider value={insets}>
|
||||
<View style={styles.root}>
|
||||
<Slot />
|
||||
<SideSafeArea>
|
||||
<Slot />
|
||||
</SideSafeArea>
|
||||
</View>
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</SafeAreaFrameContext.Provider>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ? <Pressable style={styles.backdropPressable} onPress={dismiss} /> : null}
|
||||
</Animated.View>
|
||||
|
||||
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
|
||||
<View
|
||||
style={[
|
||||
styles.anchor,
|
||||
isWideLayout && styles.anchorWide,
|
||||
{ paddingLeft: insets.left, paddingRight: insets.right }
|
||||
]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<Animated.View
|
||||
// Why: remount per window hand-back — see the windowEpoch effect.
|
||||
key={windowEpoch}
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock('react-native', () => ({
|
||||
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', () => {
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const { width, height } = useWindowDimensions()
|
||||
const { width, height } = useSafeAreaFrame()
|
||||
const routePreviewSource = useMemo(
|
||||
() => (previewParams ? previewSourceFromRoute(previewParams) : null),
|
||||
[previewParams]
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={[
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.emptyContainer,
|
||||
{ paddingBottom: props.bottomInset },
|
||||
props.isWideLayout && {
|
||||
@@ -60,14 +61,14 @@ export function MobileHomeEmptyState(props: {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
emptyContainer: { flex: 1 },
|
||||
emptyContainer: { flexGrow: 1 },
|
||||
emptyHero: {
|
||||
flex: 1,
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
|
||||
@@ -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: <T,>(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<EdgeInsets | null>(null)
|
||||
const SafeAreaFrameContext = createContext<Rect | null>(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(
|
||||
<SafeAreaFrameContext.Provider value={FRAME}>
|
||||
<SafeAreaInsetsContext.Provider value={WINDOW}>
|
||||
<SideSafeArea>
|
||||
<Probe />
|
||||
</SideSafeArea>
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</SafeAreaFrameContext.Provider>
|
||||
)
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<EdgeInsets | null>(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 = (
|
||||
<WindowSafeAreaInsetsContext.Provider value={insets}>
|
||||
<SafeAreaFrameContext.Provider value={areaFrame}>
|
||||
<SafeAreaInsetsContext.Provider value={areaInsets}>
|
||||
{children}
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</SafeAreaFrameContext.Provider>
|
||||
</WindowSafeAreaInsetsContext.Provider>
|
||||
)
|
||||
// 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 (
|
||||
<View style={[styles.area, { paddingLeft: insets.left, paddingRight: insets.right }]}>
|
||||
{Platform.OS === 'web' ? area : <SafeAreaProvider>{area}</SafeAreaProvider>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
})
|
||||
@@ -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 (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose}>
|
||||
<View style={[styles.screen, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.screen,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Select text</Text>
|
||||
<Pressable
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { ActivityIndicator, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Maximize2, X } from 'lucide-react-native'
|
||||
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
|
||||
import { useWindowSafeAreaInsets } from '../layout/side-safe-area'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read'
|
||||
import { MOBILE_NATIVE_CHAT_VISUAL_INITIAL_HEIGHT } from './mobile-native-chat-visual-host-document'
|
||||
@@ -113,10 +113,20 @@ function MobileNativeChatVisualFullscreen({
|
||||
onClose: () => void
|
||||
onFailed: () => void
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const insets = useWindowSafeAreaInsets()
|
||||
return (
|
||||
<Modal visible animationType="slide" presentationStyle="fullScreen" onRequestClose={onClose}>
|
||||
<View style={[styles.sheet, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{title}
|
||||
|
||||
Reference in New Issue
Block a user