diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index f61798d53d63..57717d2d8458 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -264,6 +264,11 @@ const config: ExpoConfig = { "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], }, infoPlist: { + UISupportedInterfaceOrientations: [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ], NSAppTransportSecurity: { NSAllowsArbitraryLoads: true, }, diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz deleted file mode 100644 index 860521672c8a..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz new file mode 100644 index 000000000000..bcb0e35191fc Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index ac00a91e91fe..2947275d106e 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -13,7 +13,10 @@ }, "channel": "development", "developmentClient": true, - "distribution": "internal" + "distribution": "internal", + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" + } }, "preview": { "corepack": true, @@ -23,7 +26,10 @@ }, "channel": "preview", "environment": "preview", - "distribution": "internal" + "distribution": "internal", + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" + } }, "preview:dev": { "corepack": true, @@ -38,6 +44,9 @@ "distribution": "internal", "android": { "buildType": "apk" + }, + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" } }, "v2-preview": { @@ -56,7 +65,10 @@ }, "channel": "production", "environment": "production", - "autoIncrement": true + "autoIncrement": true, + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" + } } }, "submit": { diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 8481d61cb5b6..0c37ea9a24ad 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -1,7 +1,8 @@ { "platforms": ["apple", "android"], "apple": { - "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] + "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"], + "appDelegateSubscribers": ["T3WorkspaceOrientationSubscriber"] }, "android": { "modules": [ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index 3be5df2eb547..2d92537bf431 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -17,9 +17,13 @@ public final class T3KeyboardCommandsModule: Module { public final class T3KeyboardCommandsView: ExpoView { let onCommand = EventDispatcher() private var enabledCommands = Set() + private let shortcutInputView = UIView(frame: .zero) public override var canBecomeFirstResponder: Bool { true } + // This responder handles hardware shortcuts, so reclaiming it must not show a software keyboard. + public override var inputView: UIView? { shortcutInputView } + public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss), let input = window?.t3FirstResponder as? UITextInput, @@ -89,6 +93,8 @@ public final class T3KeyboardCommandsView: ExpoView { public required init(appContext: AppContext? = nil) { super.init(appContext: appContext) + inputAssistantItem.leadingBarButtonGroups = [] + inputAssistantItem.trailingBarButtonGroups = [] NotificationCenter.default.addObserver( self, selector: #selector(reclaimFirstResponderIfAvailable), diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift index 43070a614582..4e0fcf6d2206 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -1,10 +1,12 @@ import ExpoModulesCore import UIKit -/// Reports the body geometry of a native navigation column. +/// Reports geometry in the observing view's coordinate space, including system +/// reservations that safe-area rectangles cannot represent, such as the fold. final class T3LayoutMetricsView: ExpoView { let onMetricsChange = EventDispatcher() private var lastMetrics: NSDictionary? + private var hasHinge: Bool? = false required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -12,6 +14,22 @@ final class T3LayoutMetricsView: ExpoView { registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in view.publishMetrics() } + #if compiler(>=6.4) + if #available(iOS 27.1, *) { + hasHinge = nil + addInteraction(UIHingeInteraction { [weak self] _, update in + guard let self, self.window != nil else { return } + // Capability survives temporary detachment and does not follow the hinge angle. + let hasHinge = self.hasHinge == true || update.hinge != nil + guard self.hasHinge != hasHinge else { return } + self.hasHinge = hasHinge + self.publishMetrics() + }) + registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3LayoutMetricsView, _: UITraitCollection) in + view.publishMetrics() + } + } + #endif } override func didMoveToWindow() { @@ -31,14 +49,43 @@ final class T3LayoutMetricsView: ExpoView { } private func publishMetrics() { - guard window != nil, bounds.width > 0, bounds.height > 0 else { return } + guard let window, bounds.width > 0, bounds.height > 0, let hasHinge else { return } + if hasHinge { + T3WorkspaceOrientationSubscriber.allowHingeOrientations(in: window) + } + var verticalBarEdge = "none" + var regions: [[String: Any]] = [] + #if compiler(>=6.4) + if #available(iOS 27.1, *) { + let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft + switch traitCollection.verticalBarEdge { + case .leading: verticalBarEdge = rtl ? "right" : "left" + case .trailing: verticalBarEdge = rtl ? "left" : "right" + default: break + } + for kind in [UIView.ReservedRegion.Kind.division, .occlusion] { + for region in reservedRegions(kind: kind) where region.isActive { + let frame = region.frame.intersection(bounds) + guard !frame.isNull, !frame.isEmpty else { continue } + regions.append([ + "kind": kind == .division ? "division" : "occlusion", + "x": frame.minX, "y": frame.minY, + "width": frame.width, "height": frame.height, + ]) + } + } + } + #endif let metrics: [String: Any] = [ "width": bounds.width, "height": bounds.height, + "hasHinge": hasHinge, "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", + "verticalBarEdge": verticalBarEdge, "safeArea": [ "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, "left": safeAreaInsets.left, "right": safeAreaInsets.right, ], + "reservedRegions": regions, ] let next = metrics as NSDictionary guard lastMetrics?.isEqual(next) != true else { return } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 3749badcd5ea..32f5df0f50a5 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -13,7 +13,9 @@ public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Constants { if #available(iOS 26.0, *) { - return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil] + return [ + "supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil, + ] } return ["supportsWorkspaceColumns": false] } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3WorkspaceOrientationSubscriber.swift b/apps/mobile/modules/t3-native-controls/ios/T3WorkspaceOrientationSubscriber.swift new file mode 100644 index 000000000000..8fa0a232d100 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3WorkspaceOrientationSubscriber.swift @@ -0,0 +1,22 @@ +import ExpoModulesCore +import UIKit + +/// Keep ordinary phones portrait-only while allowing a hinge-backed scene to rotate. +public final class T3WorkspaceOrientationSubscriber: ExpoAppDelegateSubscriber { + private static let hingeWindows = NSHashTable.weakObjects() + + static func allowHingeOrientations(in window: UIWindow) { + guard !hingeWindows.contains(window) else { return } + hingeWindows.add(window) + window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } + + public func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + if UIDevice.current.userInterfaceIdiom == .pad { + // Keep iPad orientation policy, including portrait-only showcase captures. + let orientations = Bundle.main.object(forInfoDictionaryKey: "UISupportedInterfaceOrientations~ipad") as? [String] ?? [] + return orientations == ["UIInterfaceOrientationPortrait"] ? .portrait : .all + } + return window.map { Self.hingeWindows.contains($0) } == true ? .allButUpsideDown : .portrait + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 08307ad65c2a..dcb97defd886 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -123,7 +123,7 @@ "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.7.0", "react-native-safe-area-context": "~5.9.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.8.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.5", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 86166278108b..9be57907b358 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -27,6 +27,7 @@ import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; +import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; import "../global.css"; @@ -91,11 +92,13 @@ function AppContent() { header (glass buttons, title, materials) is forced light even when the system is in dark mode. */} - - - - - + + + + + + + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 714eded82d68..ec4b2565e174 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -128,6 +128,7 @@ import { } from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; +import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -192,6 +193,9 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { presentation: "fullScreenModal", }; +// A navigator container leaves horizontal safe-area handling to its leaf screens. +const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false }; + const SettingsContentStack = createV5SheetStackNavigator({ initialRouteName: "Settings", screenOptions: { @@ -410,6 +414,7 @@ const SettingsSheetStack = createV5SheetStackNavigator({ screens: { SettingsContent: createNativeStackScreen({ screen: SettingsContentStack, + options: NESTED_NAVIGATOR_OPTIONS, linking: "", layout: ({ children }) => ( @@ -845,6 +850,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: SettingsSheetStack, linking: "settings", options: { + ...NESTED_NAVIGATOR_OPTIONS, gestureEnabled: true, headerShown: false, }, @@ -911,6 +917,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ), options: { + ...NESTED_NAVIGATOR_OPTIONS, gestureEnabled: true, headerShown: false, }, @@ -951,8 +958,9 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { const { width, height } = useWindowDimensions(); + const nativeMetrics = useNativeLayoutMetrics(); const usesWorkspaceFlowScreens = - Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView; + Platform.OS === "android" || deriveLayout({ width, height, nativeMetrics }).usesSplitView; return ( ): MailMenu["it } export function ScreenHeader(props: ScreenHeaderProps) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const headerId = useId(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const { themeVariables } = useAppearancePreferences(); + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const { search, menus } = props; const menu = menus?.[0]; const compactSearch = search !== undefined && (search.compactToolbar ?? !layout.usesSplitView) && - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + usesNativeMailSearchToolbar; const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); const refresh = search?.refreshInToolbar ? search.onRefresh : undefined; return ( @@ -88,7 +92,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { : undefined), }), ] - : undefined, + : () => [], headerSearchBarOptions: compactSearch ? undefined : { @@ -116,8 +120,12 @@ export function ScreenHeader(props: ScreenHeaderProps) { onPress={props.backInSplitView.onPress ?? props.onBack} /> ) : null} - {props.sidebar !== false ? ( + {props.sidebar !== false && + (!usesNativeWorkspaceColumns || + (Platform.OS === "ios" && Platform.isPad) || + !panes.primarySidebarVisible) ? ( >(); + const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const primaryColumn = use(NativePrimaryColumnContext); - const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null; + const sidebarHeader = + Platform.OS === "ios" && + primaryColumn !== null && + (Platform.isPad || !usesNativeMailSearchToolbar); const searchBarRef = useRef(null); + const focusAfterReveal = useRef(false); + useEffect( + () => + navigation.addListener("transitionEnd", (event) => { + if (focusAfterReveal.current && !event.data.closing) { + focusAfterReveal.current = false; + searchBarRef.current?.focus(); + } + }), + [navigation], + ); const iconColor = useUniwindTheme()["--color-icon"]; // The list uses a fixed creation order and ignores sort/group options, so // the filter menu only carries the filters and the "customized" icon state @@ -27,9 +45,14 @@ export function HomeHeader(props: HomeHeaderProps) { const hasCustomListOptions = props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; const focusSearch = useCallback(() => { + if (primaryColumn && !panes.primarySidebarVisible) { + focusAfterReveal.current = true; + togglePrimarySidebar(); + return true; + } searchBarRef.current?.focus(); return searchBarRef.current !== null; - }, []); + }, [primaryColumn, panes.primarySidebarVisible, togglePrimarySidebar]); useHardwareKeyboardCommand("focusSearch", focusSearch); const filterMenu = buildHomeListFilterMenu(props); @@ -42,7 +65,7 @@ export function HomeHeader(props: HomeHeaderProps) { // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. headerTintColor: iconColor, unstable_headerRightItems: () => - iPadSidebar + sidebarHeader ? createSidebarHeaderItems({ filterIcon: hasCustomListOptions ? "line.3.horizontal.decrease.circle.fill" @@ -62,7 +85,7 @@ export function HomeHeader(props: HomeHeaderProps) { ], // The keys below are set per-branch (not `undefined`) so a later // reapply cannot clobber options owned by NativeHeaderToolbar. - ...(iPadSidebar + ...(sidebarHeader ? { headerSearchBarOptions: { ref: searchBarRef, @@ -78,7 +101,7 @@ export function HomeHeader(props: HomeHeaderProps) { }, unstable_headerToolbarItems: () => [], } - : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + : usesNativeMailSearchToolbar ? { headerSearchBarOptions: { ref: searchBarRef, @@ -121,7 +144,7 @@ export function HomeHeader(props: HomeHeaderProps) { }} /> - {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( + {sidebarHeader || usesNativeMailSearchToolbar ? null : ( (null); const insets = useSafeAreaInsets(); const { fabClearance } = useAndroidControlSizing(); + const screenMetrics = useNativeLayoutMetrics(); + const contentSideInsets = usesNativeWorkspaceColumns + ? (columnMetrics ?? screenMetrics)?.safeArea + : undefined; // UIKit's column safe area already includes its bottom toolbar. const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); const searchEnvironmentIds = useMemo( @@ -908,6 +917,8 @@ export function HomeScreen(props: HomeScreenProps) { style={{ paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24), paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, + paddingLeft: 32 + (contentSideInsets?.left ?? 0), + paddingRight: 32 + (contentSideInsets?.right ?? 0), }} > @@ -1038,7 +1049,8 @@ export function HomeScreen(props: HomeScreenProps) { {...scrollGateHandlers} scrollEventThrottle={16} contentContainerStyle={{ - paddingHorizontal: primaryColumn ? 8 : 0, + paddingLeft: (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0), + paddingRight: (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0), paddingBottom: Platform.OS === "ios" ? iosBottomClearance diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 801d5e85da80..ee2574fcee04 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -66,6 +66,8 @@ import { NativeWorkspaceInspectorContext, } from "../../native/v5-workspace-context"; +import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; + interface AdaptiveWorkspaceContextValue { readonly layout: Layout; readonly panes: WorkspacePaneLayout; @@ -239,7 +241,9 @@ function AdaptiveWorkspaceLayoutContent( ) { const projectGroupingMode = props.projectGroupingMode; const nativeWorkspace = use(NativeWorkspaceModeContext); - const { width, height } = useWindowDimensions(); + const windowDimensions = useWindowDimensions(); + const nativeMetrics = useNativeLayoutMetrics(); + const { width, height } = nativeMetrics ?? windowDimensions; const pathname = props.pathname; const navigation = useNavigation(); const activeRoleOwner = useRef(null); @@ -256,7 +260,10 @@ function AdaptiveWorkspaceLayoutContent( const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = useState(null); - const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]); + const baseLayout = useMemo( + () => deriveLayout({ width, height, nativeMetrics }), + [height, width, nativeMetrics], + ); const layout = baseLayout; // In split layouts the sidebar IS the thread list — it renders on every // route, including Home (which shows an empty-detail pane instead of the @@ -269,7 +276,9 @@ function AdaptiveWorkspaceLayoutContent( viewportWidth: width, preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: - shouldRenderPrimarySidebar && showPrimarySidebar ? (layout.listPaneWidth ?? 0) : 0, + shouldRenderPrimarySidebar && showPrimarySidebar + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0, }), [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], ); @@ -470,13 +479,21 @@ function AdaptiveWorkspaceLayoutContent( ); const renderedSidebarWidth = useSharedValue( - panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, + panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, ); useEffect(() => { if (nativeWorkspace) return; - const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; + const targetWidth = panes.primarySidebarVisible + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0; renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); - }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); + }, [ + nativeWorkspace, + layout.listPaneWidth, + layout.listPaneGap, + panes.primarySidebarVisible, + renderedSidebarWidth, + ]); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index e642754a013b..7a3c9de54831 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -20,7 +20,7 @@ import { type NativeStackTypeBag, } from "@react-navigation/native-stack"; import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { Split, type SplitHostCommands } from "react-native-screens"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -33,8 +33,13 @@ import { V5CardStackView, V5StackView, } from "../../native/createV5StackNavigator.ios"; -import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns"; +import { + useNativeWorkspaceColumnsReady, + useNativeWorkspaceColumnsSupported, +} from "../../native/NativeWorkspaceColumns"; import { V5StackHeader } from "../../native/V5StackHeader.ios"; +import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; +import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { nativeWorkspacePopAction, projectWorkspaceStack, @@ -89,7 +94,12 @@ function ColumnScreen(props: { canGoBack={!props.primary} primary={props.primary} /> - + {descriptor.render()} @@ -107,6 +117,8 @@ function WorkspaceColumns( const inspector = use(NativeWorkspaceInspectorContext); const hostRef = useRef(null); const activeDetailKey = props.detail.at(-1)?.key; + const compactColumn = activeDetailKey ? "secondary" : "primary"; + const shownCompactColumn = useRef(compactColumn); const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; const selectedThreadKey = threadParams && @@ -131,9 +143,12 @@ function WorkspaceColumns( useEffect(() => { // In compact size classes UIKit exposes one column. Selecting a thread // changes the visible column without rebuilding either navigation stack. - if (layout.usesSplitView) return; - hostRef.current?.show(activeDetailKey ? "secondary" : "primary"); - }, [activeDetailKey, layout.usesSplitView]); + // The initial column is a native prop, so it is applied before the host + // attaches. Commands only handle later navigation changes. + if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return; + shownCompactColumn.current = compactColumn; + hostRef.current?.show(compactColumn); + }, [compactColumn, layout.usesSplitView]); const primary = props.descriptors[props.primary.key]; if (!primary) return null; @@ -142,6 +157,7 @@ function WorkspaceColumns( ref={hostRef} testID="adaptive-workspace-layout" preferredSplitBehavior="tile" + topColumnForCollapsing={compactColumn} preferredDisplayMode={ panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly" } @@ -153,7 +169,7 @@ function WorkspaceColumns( }} columnMetrics={{ minimumPrimaryColumnWidth: 280, - maximumPrimaryColumnWidth: 380, + maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0), preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, minimumSecondaryColumnWidth: 320, }} @@ -179,6 +195,37 @@ function WorkspaceColumns( options={{ headerShown: true, title: "", + unstable_headerRightItems: () => + Platform.OS === "ios" && !Platform.isPad && layout.usesSplitView + ? [ + ...(!panes.primarySidebarVisible + ? [ + { + type: "button" as const, + axisBehavior: "verticalPreferred" as const, + accessibilityLabel: "Search threads", + label: "Search threads", + icon: { + type: "sfSymbol" as const, + name: "magnifyingglass" as const, + }, + onPress: () => { + dispatchHardwareKeyboardCommand("focusSearch"); + }, + }, + ] + : []), + { + type: "button", + axisBehavior: "verticalPreferred", + accessibilityLabel: "New task", + label: "New task", + icon: { type: "sfSymbol", name: "square.and.pencil" }, + onPress: () => + props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + }, + ] + : [], unstable_headerLeftItems: () => [ { type: "button", @@ -293,6 +340,8 @@ function WorkspaceStackNavigator({ UNSTABLE_router, ...rest }: NativeStackNavigatorProps) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); + const nativeWorkspaceColumnsReady = useNativeWorkspaceColumnsReady(); const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< StackNavigationState, StackRouterOptions, @@ -310,9 +359,10 @@ function WorkspaceStackNavigator({ screenLayout, UNSTABLE_router, }); - const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView; + if (!nativeWorkspaceColumnsReady) return null; + const WorkspaceView = usesNativeWorkspaceColumns ? WorkspaceStackView : V5StackView; return ( - + [ withNativeGlassHeaderItem({ + axisBehavior: "horizontalOnly", accessibilityLabel: "Filter settings environments and projects", icon: { name: filterIcon, type: "sfSymbol" }, label: "", @@ -120,6 +121,7 @@ export function SettingsEnvironmentFilterHeader(props: { ...(closeSettings ? [ withNativeGlassHeaderItem({ + axisBehavior: "horizontalOnly", accessibilityLabel: "Close settings", icon: { name: "xmark", type: "sfSymbol" }, identifier: "settings-close", diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 2f8fc3a33e7a..8330a6b1b54f 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -37,8 +37,8 @@ import { vcsEnvironment } from "../../state/vcs"; import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; +import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; @@ -413,7 +413,7 @@ export function BranchPickerScreen(props: { }) { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const selectedBranchName = props.selectedBranchName ?? props.branches.find((branch) => branch.current)?.name ?? diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b6930deb5e86..9229c117d8da 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -14,6 +14,8 @@ import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { LegendListRef } from "@legendapp/list/react-native"; import { HeaderHeightContext } from "@react-navigation/elements"; +import { NativeLayoutObserver } from "../../native/NativeLayoutObserver"; +import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions"; import type { EnvironmentId, MessageId, @@ -84,6 +86,8 @@ import Animated, { } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; +import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; @@ -317,6 +321,7 @@ const USER_INPUT_TOGGLE_TIMING = { }; export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const navigation = useNavigation(); const { session: voiceInputSession } = useGlobalVoiceInput(); const reportedModelSelection = useThreadReportedModelSelection({ @@ -388,7 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } }, []); const windowHeight = useWindowDimensions().height; - const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; + const navigationHeaderHeight = useContext(HeaderHeightContext) ?? insets.top + 44; + const [screenMetrics, setScreenMetrics] = useState(null); + const columnMetrics = useNativeColumnLayoutMetrics(); + const nativeMetrics = usesNativeWorkspaceColumns ? columnMetrics : screenMetrics; + const controlInsets = deriveBottomControlInsets(nativeMetrics); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; @@ -826,8 +835,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // Assign both layouts explicitly so the dock always follows its current parent. const composerWidthStyle = useAnimatedStyle(() => isSplitLayout && workspaceContentWidth !== null - ? { width: workspaceContentWidth.value } - : { width: "100%" }, + ? { + width: Math.max( + 0, + workspaceContentWidth.value - controlInsets.left - controlInsets.right, + ), + right: undefined, + } + : { width: undefined, right: controlInsets.right }, ); const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); @@ -1083,6 +1098,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return ( + {!usesNativeWorkspaceColumns ? : null} {showContent ? ( {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 44673dcf2759..a210ffcb1dc6 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -57,6 +57,7 @@ import { videoMimeType } from "@t3tools/shared/video"; import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; +import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { createContext, memo, @@ -297,6 +298,7 @@ export interface ThreadFeedProps { readonly contentBottomInset?: number; readonly historyControls?: ThreadFeedHistoryControls; readonly contentMaxWidth?: number; + readonly contentSideInsets?: { readonly left: number; readonly right: number }; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; @@ -1543,6 +1545,7 @@ function renderFeedEntry( readonly reviewCommentColors: ReviewCommentColors; readonly reviewCommentBubbleWidth: number; readonly themeAppearance: "light" | "dark"; + readonly usesNativeWorkspaceColumns: boolean; readonly userBubbleMaxWidth: number; /** Width assistant markdown lays out in, so images can size their frame before layout. */ readonly markdownContentWidth: number; @@ -1730,11 +1733,19 @@ function renderFeedEntry( className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5" style={{ backgroundColor: userBubbleColor, - maxWidth: props.userBubbleMaxWidth, + maxWidth: + props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad + ? "85%" + : props.userBubbleMaxWidth, ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : hasWideBlock - ? { width: props.userBubbleMaxWidth } + ? { + width: + props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad + ? "85%" + : props.userBubbleMaxWidth, + } : null), }} > @@ -2174,6 +2185,7 @@ function ThreadFeedPlaceholder(props: { } export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const navigation = useNavigation(); const { themeAppearance } = useAppearancePreferences(); const copyFeedbackTimeoutRef = useRef | null>(null); @@ -2257,12 +2269,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedFile(null); }, [props.environmentId, props.threadId, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; + const contentLeftInset = props.contentSideInsets?.left ?? 0; + const contentRightInset = props.contentSideInsets?.right ?? 0; + const usableViewportWidth = Math.max(0, viewportWidth - contentLeftInset - contentRightInset); const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ - viewportWidth, + viewportWidth: usableViewportWidth, maxContentWidth: props.contentMaxWidth ?? null, minimumPadding: horizontalPadding, }); - const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); + const contentWidth = Math.max(0, usableViewportWidth - contentHorizontalPadding * 2); const userBubbleMaxWidth = contentWidth * 0.85; const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); @@ -2285,7 +2300,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // header-providing screen) and fall back to the standard iOS bar height. const navigationHeaderHeight = useContext(HeaderHeightContext); const anchorTopInset = usesNativeAutomaticInsets - ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT + ? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT) : topContentInset; const theme = useUniwindTheme(); @@ -3030,6 +3045,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentColors, reviewCommentBubbleWidth, themeAppearance, + usesNativeWorkspaceColumns, userBubbleMaxWidth, markdownContentWidth, contentWidth, @@ -3067,6 +3083,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentColors, reviewCommentBubbleWidth, themeAppearance, + usesNativeWorkspaceColumns, userBubbleMaxWidth, markdownContentWidth, contentWidth, @@ -3119,7 +3136,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // (facebook/react-native#54123); the anchored end space after a send // is pure inset, so without this the blank region can't be scrolled. applyWorkaroundForContentInsetHitTestBug - contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"} + // Horizontal Duo reservations are already included in the row padding. + // Let UIKit adjust the scrolling axis without shifting content sideways. + contentInsetAdjustmentBehavior={ + usesNativeAutomaticInsets + ? usesNativeWorkspaceColumns && + Platform.OS === "ios" && + !Platform.isPad && + props.layoutVariant === "split" + ? "scrollableAxes" + : "automatic" + : "never" + } automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} {...(usesNativeAutomaticInsets ? { @@ -3231,7 +3259,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } contentContainerStyle={{ paddingTop: 12, - paddingHorizontal: contentHorizontalPadding, + paddingLeft: contentHorizontalPadding + contentLeftInset, + paddingRight: contentHorizontalPadding + contentRightInset, }} /> @@ -3239,7 +3268,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { !props.worktreeSetup && props.activeWorkStartedAt === null && props.contentPresentation.kind === "ready" ? ( - + provider.instanceId === session.providerInstanceId) ?? null) : null; - const bottomToolbarInset = - Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED - ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET - : 0; + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); + const bottomToolbarInset = usesNativeMailSearchToolbar + ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + : 0; return ( @@ -1107,7 +1107,7 @@ function ThreadSettingsModelsScreen() { const session = useThreadSettingsSession(); const presentation = useThreadSettingsPickerPresentation(); const navigation = useNavigation>(); - const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; const commitAndClose = useCallback(() => { if (!session.commitPendingModel()) return; diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 1bcb225bf810..1741c527d91d 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -21,6 +21,7 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { NativeSheetContent } from "../../../native/NativeSheetContent"; import { useUniwindTheme } from "../../../lib/useUniwindTheme"; import { AndroidHeaderIconButton, @@ -411,7 +412,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} style={{ backgroundColor: sheetColor, flex: 1 }} > - {content} + {content} - {content} + {content} [0]; readonly onReturnToThread?: () => void; }) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); + const usesDuoHeader = usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad; const navigation = useNavigation(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls); const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls); const splitLeftHeaderItems = useMemo( () => [ - { - // Match Mail's split-view detail toolbar: the first detail action sits - // inside the content pane, not flush against the sidebar divider. - spacing: 18, - type: "spacing" as const, - }, + ...(!usesDuoHeader + ? [ + { + // Match Mail's split-view detail toolbar: the first detail action sits + // inside the content pane, not flush against the sidebar divider. + spacing: 18, + type: "spacing" as const, + }, + ] + : []), ...(props.onReturnToThread ? [ withNativeGlassHeaderItem({ @@ -43,6 +52,7 @@ export function useThreadHeaderOptions(props: { ] : []), withNativeGlassHeaderItem({ + axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, accessibilityLabel: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar", @@ -51,18 +61,31 @@ export function useThreadHeaderOptions(props: { type: "sfSymbol" as const, }, identifier: "thread-left-sidebar", + label: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar", onPress: togglePrimarySidebar, type: "button" as const, }), - withNativeGlassHeaderItem({ - accessibilityLabel: "New task", - icon: { name: "square.and.pencil", type: "sfSymbol" as const }, - identifier: "thread-left-new-task", - onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), - type: "button" as const, - }), + ...(!usesDuoHeader + ? [ + withNativeGlassHeaderItem({ + accessibilityLabel: "New task", + icon: { name: "square.and.pencil", type: "sfSymbol" as const }, + identifier: "thread-left-new-task", + label: "New task", + onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + type: "button" as const, + }), + ] + : []), + ], + [ + panes.primarySidebarVisible, + props.onReturnToThread, + navigation, + togglePrimarySidebar, + usesDuoHeader, + usesNativeWorkspaceColumns, ], - [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], ); // Deep links / cold starts land with Thread as the ONLY route, where the // native back button does not render. Provide an explicit Home escape for @@ -81,6 +104,41 @@ export function useThreadHeaderOptions(props: { [navigation], ); + const duoRightHeaderItems = useMemo( + () => [ + ...threadCenterHeaderItems, + { type: "spacing", spacing: 8 }, + ...(layout.usesSplitView && + Platform.OS === "ios" && + !Platform.isPad && + !panes.primarySidebarVisible + ? [ + withNativeGlassHeaderItem({ + type: "button" as const, + axisBehavior: "verticalPreferred", + identifier: "thread-right-search", + label: "Search threads", + accessibilityLabel: "Search threads", + icon: { name: "magnifyingglass", type: "sfSymbol" as const }, + onPress: () => { + dispatchHardwareKeyboardCommand("focusSearch"); + }, + }), + ] + : []), + withNativeGlassHeaderItem({ + type: "button" as const, + axisBehavior: "verticalPreferred", + identifier: "thread-right-new-task", + label: "New task", + accessibilityLabel: "New task", + icon: { name: "square.and.pencil", type: "sfSymbol" as const }, + onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + }), + ], + [navigation, threadCenterHeaderItems, layout.usesSplitView, panes.primarySidebarVisible], + ); + const options: AppNativeStackNavigationOptions = { headerShown: true, headerTitle: props.title, @@ -104,7 +162,12 @@ export function useThreadHeaderOptions(props: { // the git controls on the RIGHT (no center items — center space is // reserved for future breadcrumbs/status). unstable_headerRightItems: () => - layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, + usesDuoHeader + ? duoRightHeaderItems + : layout.usesSplitView + ? threadCenterHeaderItems + : compactRightHeaderItems, + unstable_headerToolbarItems: () => [], unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, contentStyle: undefined, }; @@ -116,7 +179,11 @@ export function useThreadHeaderOptions(props: { // callbacks also read state the items do not display (a "Push" item runs `push` or // `commit_push` depending on the default ref), so that state is keyed too. optionsVersion: [ - layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, + splitLeftHeaderItems, + threadCenterHeaderItems, + compactRightHeaderItems, + duoRightHeaderItems, + usesDuoHeader, environmentId, threadId, gitStatus?.isDefaultRef, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index ee38ac020e74..ac4cde7789b0 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -1,5 +1,6 @@ import { scaledTypographyLineHeight } from "./appearancePreferences"; import { MOBILE_TYPOGRAPHY } from "./typography"; +import type { NativeLayoutMetrics } from "./reserved-regions"; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); @@ -56,6 +57,7 @@ export interface Layout { readonly variant: LayoutVariant; readonly usesSplitView: boolean; readonly listPaneWidth: number | null; + readonly listPaneGap?: number; readonly shellPadding: number; } @@ -87,9 +89,25 @@ export function deriveThreadFeedInitialContentInset(input: { export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; -export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { +export function deriveLayout(input: { + readonly width: number; + readonly height: number; + readonly nativeMetrics?: NativeLayoutMetrics | null; +}): Layout { const { width, height } = input; - const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; + const metrics = input.nativeMetrics; + const usableWidth = width - (metrics?.safeArea.left ?? 0) - (metrics?.safeArea.right ?? 0); + // UIKit's size class supports the shorter Duo inner display. Geometry remains + // a floor so a narrow multitasking window cannot squeeze both columns. + const hasDuoGeometry = + metrics && + (metrics.verticalBarEdge !== "none" || + metrics.reservedRegions.some((region) => region.kind === "division")); + const wideEnoughForSplit = hasDuoGeometry + ? metrics.horizontalSizeClass === "regular" && + usableWidth >= SPLIT_SIDEBAR_MIN_WIDTH + 320 && + height >= 400 + : width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; if (!wideEnoughForSplit) { return { @@ -100,14 +118,20 @@ export function deriveLayout(input: { readonly width: number; readonly height: n }; } + const division = metrics?.reservedRegions.find( + (region) => + region.kind === "division" && + region.height >= height / 2 && + region.x - metrics.safeArea.left >= SPLIT_SIDEBAR_MIN_WIDTH && + width - region.x - region.width - metrics.safeArea.right >= 320, + ); return { variant: "split", usesSplitView: true, - listPaneWidth: clamp( - Math.round(width * 0.32), - SPLIT_SIDEBAR_MIN_WIDTH, - SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH, - ), + listPaneWidth: + division?.x ?? + clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH), + ...(division ? { listPaneGap: division.width } : {}), shellPadding: 0, }; } @@ -125,7 +149,7 @@ export function deriveWorkspacePaneLayout(input: { const preferredPrimarySidebarVisible = input.layout.usesSplitView && input.primarySidebarPreferredVisible; const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible - ? (input.layout.listPaneWidth ?? 0) + ? (input.layout.listPaneWidth ?? 0) + (input.layout.listPaneGap ?? 0) : 0; if (auxiliaryPaneRole === "inspector") { @@ -141,11 +165,14 @@ export function deriveWorkspacePaneLayout(input: { auxiliaryPaneVisible && fileInspector.width !== null && input.layout.listPaneWidth !== null && - viewportWidth - input.layout.listPaneWidth - fileInspector.width < + viewportWidth - + input.layout.listPaneWidth - + (input.layout.listPaneGap ?? 0) - + fileInspector.width < FILE_INSPECTOR_MIN_MAIN_WIDTH; const primarySidebarVisible = preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; - const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0; + const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0; return { primarySidebarVisible, diff --git a/apps/mobile/src/lib/reserved-regions.test.ts b/apps/mobile/src/lib/reserved-regions.test.ts new file mode 100644 index 000000000000..e742169bd94a --- /dev/null +++ b/apps/mobile/src/lib/reserved-regions.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vite-plus/test"; +import { deriveBottomControlInsets, type NativeLayoutMetrics } from "./reserved-regions"; +import { deriveLayout, deriveWorkspacePaneLayout } from "./layout"; + +const innerDisplay: NativeLayoutMetrics = { + width: 960, + height: 560, + horizontalSizeClass: "regular", + verticalBarEdge: "right", + safeArea: { top: 0, bottom: 20, left: 8, right: 72 }, + reservedRegions: [], +}; + +describe("bottom controls around reserved regions", () => { + it("respects asymmetric side controls without reserving the bottom twice", () => { + expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 }); + }); + + it("leaves ordinary non-native clients unchanged", () => { + expect(deriveBottomControlInsets(null)).toEqual({ left: 0, right: 0, bottom: 0 }); + }); + + it("keeps the editor entirely on one side of a book fold", () => { + const insets = deriveBottomControlInsets({ + ...innerDisplay, + reservedRegions: [{ kind: "division", x: 456, y: 0, width: 48, height: 560 }], + }); + expect(insets).toEqual({ left: 8, right: 504, bottom: 0 }); + expect(innerDisplay.width - insets.left - insets.right).toBeGreaterThan(320); + }); + + it("keeps controls below a horizontal fold in a hands-free pose", () => { + expect( + deriveBottomControlInsets({ + ...innerDisplay, + reservedRegions: [{ kind: "division", x: 0, y: 240, width: 960, height: 40 }], + }), + ).toEqual({ left: 8, right: 72, bottom: 0 }); + }); + + it("moves a control away from a bottom camera while leaving a top camera alone", () => { + expect( + deriveBottomControlInsets({ + ...innerDisplay, + reservedRegions: [ + { kind: "occlusion", x: 8, y: 0, width: 60, height: 60 }, + { kind: "occlusion", x: 8, y: 510, width: 60, height: 50 }, + ], + }), + ).toEqual({ left: 68, right: 72, bottom: 0 }); + }); + + it("restores the full editor width when the device opens flat", () => { + const folded = { + ...innerDisplay, + reservedRegions: [{ kind: "division" as const, x: 456, y: 0, width: 48, height: 560 }], + }; + expect(deriveBottomControlInsets(folded)).not.toEqual(deriveBottomControlInsets(innerDisplay)); + expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 }); + }); +}); + +describe("Duo workspace sizing", () => { + it("shows both columns on a short regular-width inner display", () => { + const layout = deriveLayout({ ...innerDisplay, nativeMetrics: innerDisplay }); + expect(layout.usesSplitView).toBe(true); + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: innerDisplay.width, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: false, + }).contentPaneWidth, + ).toBeGreaterThan(320); + }); + + it("keeps an ordinary phone compact when it rotates", () => { + expect(deriveLayout({ width: 852, height: 393 }).usesSplitView).toBe(false); + for (const [width, height] of [ + [932, 430], + [650, 500], + ]) { + const metrics: NativeLayoutMetrics = { + ...innerDisplay, + width: width!, + height: height!, + verticalBarEdge: "none", + safeArea: { top: 0, bottom: 20, left: 59, right: 59 }, + }; + expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false); + const cameraMetrics = { + ...metrics, + reservedRegions: [{ kind: "occlusion" as const, x: 0, y: 0, width: 60, height: 60 }], + }; + expect(deriveLayout({ ...cameraMetrics, nativeMetrics: cameraMetrics }).usesSplitView).toBe( + false, + ); + } + expect( + deriveLayout({ + width: 960, + height: 560, + nativeMetrics: { + ...innerDisplay, + horizontalSizeClass: "compact", + }, + }).usesSplitView, + ).toBe(false); + }); + + it("requires enough safe width for both panes in a multitasking window", () => { + const metrics = { ...innerDisplay, width: 660 }; + expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false); + }); + + it("aligns the panes to the book fold and accounts for the reserved gap", () => { + const metrics = { + ...innerDisplay, + reservedRegions: [{ kind: "division" as const, x: 456, y: 0, width: 48, height: 560 }], + }; + const layout = deriveLayout({ ...metrics, nativeMetrics: metrics }); + expect(layout.listPaneWidth).toBe(456); + expect(layout.listPaneGap).toBe(48); + const panes = deriveWorkspacePaneLayout({ + layout, + viewportWidth: 960, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: false, + }); + expect(panes.contentPaneWidth).toBe(456); + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 960, + primarySidebarPreferredVisible: false, + auxiliaryPanePreferredVisible: false, + }).contentPaneWidth, + ).toBe(960); + }); +}); diff --git a/apps/mobile/src/lib/reserved-regions.ts b/apps/mobile/src/lib/reserved-regions.ts index 318896239536..38496931f9de 100644 --- a/apps/mobile/src/lib/reserved-regions.ts +++ b/apps/mobile/src/lib/reserved-regions.ts @@ -1,12 +1,51 @@ -/** Geometry of the body inside a native navigation column. */ +export interface ReservedRegion { + readonly kind: "division" | "occlusion"; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + export interface NativeLayoutMetrics { readonly width: number; readonly height: number; + readonly hasHinge?: boolean; readonly horizontalSizeClass: "compact" | "regular"; + readonly verticalBarEdge: "none" | "left" | "right"; readonly safeArea: { readonly top: number; readonly bottom: number; readonly left: number; readonly right: number; }; + readonly reservedRegions: ReadonlyArray; +} + +/** + * Keep bottom controls in one usable region. A book-like fold selects the + * larger side; a horizontal fold leaves the controls on the lower display. + * Text and other scrollable content can continue to use the full canvas. + */ +export function deriveBottomControlInsets(metrics: NativeLayoutMetrics | null) { + if (!metrics) return { left: 0, right: 0, bottom: 0 }; + let left = metrics.safeArea.left; + let right = metrics.width - metrics.safeArea.right; + let bottom = metrics.height; + for (const region of metrics.reservedRegions) { + const x = Math.max(left, region.x); + const endX = Math.min(right, region.x + region.width); + const y = Math.max(0, region.y); + const endY = Math.min(bottom, region.y + region.height); + if (endX <= x || endY <= y) continue; + if (region.kind === "division" && region.height >= metrics.height / 2) { + if (right - endX >= x - left) left = endX; + else right = x; + } else if (endY >= bottom - metrics.safeArea.bottom) { + // An occlusion at the bottom can also occupy just one corner. + if (region.width >= right - left) bottom = y; + else if (right - endX >= x - left) left = endX; + else right = x; + } + } + return { left, right: metrics.width - right, bottom: metrics.height - bottom }; } diff --git a/apps/mobile/src/native/NativeColumnContent.ios.tsx b/apps/mobile/src/native/NativeColumnContent.ios.tsx index 77b03098b36c..8a8fa0f599c8 100644 --- a/apps/mobile/src/native/NativeColumnContent.ios.tsx +++ b/apps/mobile/src/native/NativeColumnContent.ios.tsx @@ -9,12 +9,16 @@ import { NativeLayoutObserver } from "./NativeLayoutObserver"; export function NativeColumnContent(props: { readonly children: ReactNode; readonly primary?: boolean; + readonly insetHorizontally?: boolean; }) { const [metrics, setMetrics] = useState(null); return ( - + {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} diff --git a/apps/mobile/src/native/NativeHeaderToolbar.tsx b/apps/mobile/src/native/NativeHeaderToolbar.tsx index 5bf5aa27c91d..71a18fa5c2c6 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.tsx @@ -14,6 +14,7 @@ import { type ReactNode, } from "react"; import type { ColorValue } from "react-native"; +import type { HeaderBarButtonSearchBarPlacementItem } from "react-native-screens"; function useNativeStackNavigation(): NativeStackNavigationProp | null { return useNavigation>(); @@ -34,8 +35,13 @@ function labelFromChildren(children: ReactNode): string { type NativeStackHeaderIcon = NonNullable< Extract["icon"] >; +type NativeStackHeaderAxisBehavior = Extract< + NativeStackHeaderItem, + { type: "button" } +>["axisBehavior"]; +type NativeToolbarItem = NativeStackHeaderItem | HeaderBarButtonSearchBarPlacementItem; type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { - unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; + unstable_headerToolbarItems?: () => NativeToolbarItem[]; }; function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { @@ -112,7 +118,7 @@ function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu" return items; } -function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { +function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { if (!isValidElement(child)) { return null; } @@ -121,15 +127,22 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) { return { type: "custom", element: child.props.children }; } + if (typeName === "NativeHeaderToolbarSearchBarSlot") { + return { type: "searchBarPlacement" }; + } if (typeName === "NativeHeaderToolbarButton") { return { type: "button", - label: typeof child.props.label === "string" ? child.props.label : "", + label: + typeof child.props.label === "string" + ? child.props.label + : String(child.props.accessibilityLabel ?? ""), accessibilityLabel: typeof child.props.accessibilityLabel === "string" ? child.props.accessibilityLabel : undefined, + axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior, disabled: Boolean(child.props.disabled), icon: iconFromProp(child.props.icon), onPress: @@ -173,8 +186,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { return null; } -function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { - const items: NativeStackHeaderItem[] = []; +function collectToolbarItems(children: ReactNode): NativeToolbarItem[] { + const items: NativeToolbarItem[] = []; Children.forEach(children, (child) => { const item = convertToolbarChild(child); if (item) { @@ -211,13 +224,17 @@ function NativeHeaderToolbarRoot(props: { } as NativeStackOptionsWithToolbar); }; } + // UIKit's search placement item belongs to the toolbar, never a header group. + const headerItems = items.filter( + (item): item is NativeStackHeaderItem => item.type !== "searchBarPlacement", + ); if (props.placement === "left") { - navigation.setOptions({ unstable_headerLeftItems: () => items }); + navigation.setOptions({ unstable_headerLeftItems: () => headerItems }); return () => { navigation.setOptions({ unstable_headerLeftItems: () => [] }); }; } - navigation.setOptions({ unstable_headerRightItems: () => items }); + navigation.setOptions({ unstable_headerRightItems: () => headerItems }); return () => { navigation.setOptions({ unstable_headerRightItems: () => [] }); }; @@ -228,6 +245,7 @@ function NativeHeaderToolbarRoot(props: { function NativeHeaderToolbarButton(_props: { readonly accessibilityLabel?: string; + readonly axisBehavior?: NativeStackHeaderAxisBehavior; readonly disabled?: boolean; readonly icon?: string; readonly label?: string; diff --git a/apps/mobile/src/native/NativeSheetContent.tsx b/apps/mobile/src/native/NativeSheetContent.tsx new file mode 100644 index 000000000000..7b508f3bc944 --- /dev/null +++ b/apps/mobile/src/native/NativeSheetContent.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; + +import { useNativeWorkspaceColumnsSupported } from "./NativeWorkspaceColumns"; + +/** Reserve the sheet's own side bars without moving its native navigation chrome. */ +export function NativeSheetContent(props: { readonly children: ReactNode }) { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); + if (!usesNativeWorkspaceColumns) return <>{props.children}; + + // A floating sheet can have different insets from the workspace underneath it. + return ( + + + {props.children} + + + ); +} diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx index c85529b1dbf2..9aee432ed28d 100644 --- a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx +++ b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx @@ -1,7 +1,29 @@ import { requireOptionalNativeModule } from "expo"; import { Platform } from "react-native"; -const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>( - "T3NativeControls", -); +import { useNativeLayoutMetrics } from "./native-layout-metrics"; + +const nativeControls = requireOptionalNativeModule<{ + readonly supportsWorkspaceColumns?: boolean; + readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown }; +}>("T3NativeControls"); export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true; + +export function useNativeWorkspaceColumnsReady() { + const metrics = useNativeLayoutMetrics(); + // Choose the phone's native host before attaching any screens. Older clients + // without the observer can immediately use the compact navigator. + return ( + (Platform.OS === "ios" && Platform.isPad) || + !nativeControls?.ViewPrototypes?.T3NativeControls_LayoutMetrics || + metrics !== null + ); +} + +export function useNativeWorkspaceColumnsSupported() { + const metrics = useNativeLayoutMetrics(); + return ( + NATIVE_WORKSPACE_COLUMNS_SUPPORTED || + (nativeControls?.supportsWorkspaceColumns === true && metrics?.hasHinge === true) + ); +} diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.tsx index 4bb7aa16ac9b..d8fd7b664c17 100644 --- a/apps/mobile/src/native/NativeWorkspaceColumns.tsx +++ b/apps/mobile/src/native/NativeWorkspaceColumns.tsx @@ -1,3 +1,11 @@ import { Platform } from "react-native"; // Android retains its existing navigator. The iOS file checks native availability. export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad; + +export function useNativeWorkspaceColumnsReady() { + return true; +} + +export function useNativeWorkspaceColumnsSupported() { + return NATIVE_WORKSPACE_COLUMNS_SUPPORTED; +} diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 4f8eadcc5a11..6857e244f71f 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -19,6 +19,8 @@ export type AppNativeStackNavigationOptions = Omit< NativeStackNavigationOptions, "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems" > & { + /** Nested navigator leaves apply their own horizontal safe area. */ + readonly nativeContentInsetHorizontally?: boolean; readonly headerTintColor?: string | ColorValue; readonly unstable_headerCenterItems?: unknown; readonly unstable_headerLeftItems?: unknown; diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx index 2c843dab7cdc..e37647f4e76d 100644 --- a/apps/mobile/src/native/V5StackHeader.ios.tsx +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -6,6 +6,7 @@ import type { import { isValidElement, useRef } from "react"; import { Platform, StyleSheet } from "react-native"; import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; +import { useNativeLayoutMetrics } from "../native/native-layout-metrics"; import { SearchBar, Stack, @@ -110,6 +111,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte id, identifier: item.identifier, title: item.label || item.accessibilityLabel || undefined, + axisBehavior: item.axisBehavior, icon: convertIcon(item.icon), menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), hidesSharedBackground: item.hidesSharedBackground, @@ -120,6 +122,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte id, identifier: item.identifier, title: item.label || item.accessibilityLabel || undefined, + axisBehavior: item.axisBehavior, icon: convertIcon(item.icon), onPress: item.disabled ? undefined : item.onPress, hidesSharedBackground: item.hidesSharedBackground, @@ -136,6 +139,7 @@ export function V5StackHeader(props: { }) { const { options } = props; const theme = useMobileNavigationTheme(); + const nativeMetrics = useNativeLayoutMetrics(); const searchRef = useRef(null); const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); @@ -162,6 +166,7 @@ export function V5StackHeader(props: { bottom.push({ type: "item", id: mailSearch.filterButtonId ?? "filter", + axisBehavior: bottomSearch ? "horizontalOnly" : undefined, icon: { type: "sfSymbol", name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", @@ -171,7 +176,12 @@ export function V5StackHeader(props: { if (bottomSearch) { if (bottom.length) bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); - bottom.push({ type: "item", id: "home-search", searchBarPlacement: true }); + bottom.push({ + type: "item", + id: "home-search", + axisBehavior: "horizontalOnly", + searchBarPlacement: true, + }); if (mailSearch.onComposePress) bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); } @@ -179,10 +189,22 @@ export function V5StackHeader(props: { bottom.push({ type: "item", id: mailSearch.composeButtonId ?? "compose", + axisBehavior: bottomSearch ? "horizontalOnly" : undefined, icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, onPress: mailSearch.onComposePress, }); } + // Expanded Duo panes put toolbar actions in the side bar. Folded phones + // keep their bottom search toolbar. UIKit keeps the + // primary column's navigation items beside its title and the detail's in + // the rail; a conventional UIToolbar can otherwise leave them inaccessible. + if ( + nativeMetrics?.horizontalSizeClass === "regular" && + nativeMetrics.verticalBarEdge !== "none" + ) { + trailing.push(...bottom); + bottom.length = 0; + } const left = options.headerLeft?.(itemProps); const right = options.headerRight?.(itemProps); if (!options.unstable_headerLeftItems && isValidElement(left)) diff --git a/apps/mobile/src/native/createV5StackNavigator.ios.tsx b/apps/mobile/src/native/createV5StackNavigator.ios.tsx index 47edb3c1e816..9c8cc25f4102 100644 --- a/apps/mobile/src/native/createV5StackNavigator.ios.tsx +++ b/apps/mobile/src/native/createV5StackNavigator.ios.tsx @@ -24,6 +24,7 @@ import { useCallback, useRef, useState, type ComponentProps } from "react"; import { StyleSheet, View } from "react-native"; import { FormSheet, Stack } from "react-native-screens"; import { V5StackHeader } from "./V5StackHeader.ios"; +import type { AppNativeStackNavigationOptions } from "./StackHeader"; import { NativeColumnContent } from "./NativeColumnContent.ios"; import { nativeStackPopAction, @@ -232,7 +233,14 @@ export function V5CardStackView(props: V5StackViewProps) { : index > 0 } /> - {descriptor.render()} + + {descriptor.render()} + diff --git a/apps/mobile/src/native/use-native-mail-search-toolbar.ts b/apps/mobile/src/native/use-native-mail-search-toolbar.ts new file mode 100644 index 000000000000..b7791ff926a9 --- /dev/null +++ b/apps/mobile/src/native/use-native-mail-search-toolbar.ts @@ -0,0 +1,13 @@ +import { useNativeLayoutMetrics } from "./native-layout-metrics"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native-glass"; + +/** Folded phones keep search in the bottom toolbar; expanded Duo panes use the side bar. */ +export function useNativeMailSearchToolbar() { + const metrics = useNativeLayoutMetrics(); + return ( + NATIVE_LIQUID_GLASS_SUPPORTED && + (metrics === null || + metrics.horizontalSizeClass === "compact" || + metrics.verticalBarEdge === "none") + ); +} diff --git a/docs/operations/mobile-screens-fork.md b/docs/operations/mobile-screens-fork.md index c62310462794..f0df0350f59e 100644 --- a/docs/operations/mobile-screens-fork.md +++ b/docs/operations/mobile-screens-fork.md @@ -1,7 +1,7 @@ # Building the mobile Screens dependency The mobile v5 stack uses a committed tarball from the -[T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3-v5.0.0-t3.7). +[T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3-v5.0.0-t3.8). The fork owns the integration changes; the app does not patch this package. Each archive includes compiled JavaScript, declarations, native source and `t3-fork.json` recording its version, source commit and upstream base. @@ -9,7 +9,7 @@ Each archive includes compiled JavaScript, declarations, native source and To reproduce the current archive with Node 24 and the repository's pinned Yarn: ```sh -git clone --branch t3-v5.0.0-t3.7 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork +git clone --branch t3-v5.0.0-t3.8 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork yarn --cwd /tmp/t3-screens-fork pack:t3 /absolute/path/to/t3code/apps/mobile/deps ``` @@ -20,3 +20,8 @@ Run `pack:t3` from that clean checkout and tag the source as `t3-v`. Replace the old archive, update `apps/mobile/package.json` and run `vp i` to regenerate the lockfile. Verify mobile types and affected navigation tests. Changes to native source also require rebuilding and testing the native client. + +The same iOS binary supports iPhone, iPad, and Duo. UIKit hinge updates enable +phone workspace columns at runtime; regular phones retain portrait rotation. +Use the normal EAS build profiles and update channels. Duo support requires a +binary built with the iOS 27.1 SDK; an OTA cannot add the native APIs. diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch index 288bfcc7c7f0..ee4f4a0b5e32 100644 --- a/patches/@react-navigation%2Fnative-stack@7.17.6.patch +++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch @@ -67,7 +67,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d } return item; } -+ if (item.type === 'mailSearchToolbar') { ++ if (item.type === 'mailSearchToolbar' || item.type === 'searchBarPlacement') { + return { + ...item, + index @@ -81,7 +81,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d return processedItem; } - throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom' and 'spacing'.`); -+ throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom', 'spacing' and 'mailSearchToolbar'.`); ++ throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom', 'spacing', 'mailSearchToolbar' and 'searchBarPlacement'.`); }).filter(item => item != null); }; const transformIcon = icon => { @@ -190,6 +190,10 @@ index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb215 /** * A React Element to display as the item. */ +@@ -864,1 +864,3 @@ + sharesBackground?: boolean; ++ /** Native bar axis preference on iOS 27.1 and later. */ ++ axisBehavior?: 'automatic' | 'horizontalOnly' | 'verticalPreferred'; diff --git a/src/types.tsx b/src/types.tsx index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644 --- a/src/types.tsx @@ -208,6 +212,10 @@ index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9e /** * A React Element to display as the item. */ +@@ -920,1 +920,3 @@ + sharesBackground?: boolean; ++ /** Native bar axis preference on iOS 27.1 and later. */ ++ axisBehavior?: 'automatic' | 'horizontalOnly' | 'verticalPreferred'; diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644 --- a/src/views/useHeaderConfigProps.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4502702d9c19..13e83f1f8bad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,7 +139,7 @@ patchedDependencies: '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba - '@react-navigation/native-stack@7.17.6': 3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87 + '@react-navigation/native-stack@7.17.6': d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5 alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 effect@4.0.1: a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f @@ -325,7 +325,7 @@ importers: version: 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06) + version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cc0272b81e52c9a28f5defc327cee1b5) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -516,8 +516,8 @@ importers: specifier: ~5.9.1 version: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-screens: - specifier: file:deps/react-native-screens-5.0.0-t3.7.tgz - version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + specifier: file:deps/react-native-screens-5.0.0-t3.8.tgz + version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -10288,9 +10288,9 @@ packages: react: '*' react-native: '*' - react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz: - resolution: {integrity: sha512-Di1+YTCuhWpk9helKBxkvXAVAQTCSRo24tpFBGXdbTAT4vPm2vGPbtPe+lnIXcePjNHbTEaI1ha5n9PqQKl5lg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz} - version: 5.0.0-t3.7 + react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz: + resolution: {integrity: sha512-sgvyU7L9I1iKfFF5p/oe84duBpA2rJmY/WTgHgKmSI2JGLhWBWQ5qWjo/7WRyZkt36sjOkdNGxwNGhx7dCPzJA==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz} + version: 5.0.0-t3.8 peerDependencies: react: '*' react-native: '*' @@ -15420,7 +15420,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) - '@react-navigation/native-stack@7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06)': + '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cc0272b81e52c9a28f5defc327cee1b5)': dependencies: '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) '@react-navigation/native': 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -15428,7 +15428,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) - react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -21395,7 +21395,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-freeze: 1.0.4(react@19.3.0)