| author | |
| committer | |
| log | c85dc642681a944e644cbbbea7257d274822c21a |
| tree | 7493ad81cf8ee41c14bed8ab7eaf63430bf515ba |
| parent | a0a0e94822a0c675fbe0792ef94ff218101aeb66 |
| signature | Signed by PGP key B5690EEEBB952194 |
42 files changed, 808 insertions(+), 126 deletions(-)
apps/mobile/app.config.ts+5| ... | @@ -264,6 +264,11 @@ const config: ExpoConfig = { | ... | @@ -264,6 +264,11 @@ const config: ExpoConfig = { |
| 264 | "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], | 264 | "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], |
| 265 | }, | 265 | }, |
| 266 | infoPlist: { | 266 | infoPlist: { |
| 267 | UISupportedInterfaceOrientations: [ | ||
| 268 | "UIInterfaceOrientationPortrait", | ||
| 269 | "UIInterfaceOrientationLandscapeLeft", | ||
| 270 | "UIInterfaceOrientationLandscapeRight", | ||
| 271 | ], | ||
| 267 | NSAppTransportSecurity: { | 272 | NSAppTransportSecurity: { |
| 268 | NSAllowsArbitraryLoads: true, | 273 | NSAllowsArbitraryLoads: true, |
| 269 | }, | 274 | }, |
apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz deleted| Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz and /dev/null differ | |||
apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz created| Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz differ | |||
apps/mobile/eas.json+15-3| ... | @@ -13,7 +13,10 @@ | ... | @@ -13,7 +13,10 @@ |
| 13 | }, | 13 | }, |
| 14 | "channel": "development", | 14 | "channel": "development", |
| 15 | "developmentClient": true, | 15 | "developmentClient": true, |
| 16 | "distribution": "internal" | 16 | "distribution": "internal", |
| 17 | "ios": { | ||
| 18 | "image": "macos-tahoe-26.6-xcode-27.1" | ||
| 19 | } | ||
| 17 | }, | 20 | }, |
| 18 | "preview": { | 21 | "preview": { |
| 19 | "corepack": true, | 22 | "corepack": true, |
| ... | @@ -23,7 +26,10 @@ | ... | @@ -23,7 +26,10 @@ |
| 23 | }, | 26 | }, |
| 24 | "channel": "preview", | 27 | "channel": "preview", |
| 25 | "environment": "preview", | 28 | "environment": "preview", |
| 26 | "distribution": "internal" | 29 | "distribution": "internal", |
| 30 | "ios": { | ||
| 31 | "image": "macos-tahoe-26.6-xcode-27.1" | ||
| 32 | } | ||
| 27 | }, | 33 | }, |
| 28 | "preview:dev": { | 34 | "preview:dev": { |
| 29 | "corepack": true, | 35 | "corepack": true, |
| ... | @@ -38,6 +44,9 @@ | ... | @@ -38,6 +44,9 @@ |
| 38 | "distribution": "internal", | 44 | "distribution": "internal", |
| 39 | "android": { | 45 | "android": { |
| 40 | "buildType": "apk" | 46 | "buildType": "apk" |
| 47 | }, | ||
| 48 | "ios": { | ||
| 49 | "image": "macos-tahoe-26.6-xcode-27.1" | ||
| 41 | } | 50 | } |
| 42 | }, | 51 | }, |
| 43 | "v2-preview": { | 52 | "v2-preview": { |
| ... | @@ -56,7 +65,10 @@ | ... | @@ -56,7 +65,10 @@ |
| 56 | }, | 65 | }, |
| 57 | "channel": "production", | 66 | "channel": "production", |
| 58 | "environment": "production", | 67 | "environment": "production", |
| 59 | "autoIncrement": true | 68 | "autoIncrement": true, |
| 69 | "ios": { | ||
| 70 | "image": "macos-tahoe-26.6-xcode-27.1" | ||
| 71 | } | ||
| 60 | } | 72 | } |
| 61 | }, | 73 | }, |
| 62 | "submit": { | 74 | "submit": { |
apps/mobile/modules/t3-native-controls/expo-module.config.json+2-1| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | { | 1 | { |
| 2 | "platforms": ["apple", "android"], | 2 | "platforms": ["apple", "android"], |
| 3 | "apple": { | 3 | "apple": { |
| 4 | "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] | 4 | "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"], |
| 5 | "appDelegateSubscribers": ["T3WorkspaceOrientationSubscriber"] | ||
| 5 | }, | 6 | }, |
| 6 | "android": { | 7 | "android": { |
| 7 | "modules": [ | 8 | "modules": [ |
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+6| ... | @@ -17,9 +17,13 @@ public final class T3KeyboardCommandsModule: Module { | ... | @@ -17,9 +17,13 @@ public final class T3KeyboardCommandsModule: Module { |
| 17 | public final class T3KeyboardCommandsView: ExpoView { | 17 | public final class T3KeyboardCommandsView: ExpoView { |
| 18 | let onCommand = EventDispatcher() | 18 | let onCommand = EventDispatcher() |
| 19 | private var enabledCommands = Set<String>() | 19 | private var enabledCommands = Set<String>() |
| 20 | private let shortcutInputView = UIView(frame: .zero) | ||
| 20 | 21 | ||
| 21 | public override var canBecomeFirstResponder: Bool { true } | 22 | public override var canBecomeFirstResponder: Bool { true } |
| 22 | 23 | ||
| 24 | // This responder handles hardware shortcuts, so reclaiming it must not show a software keyboard. | ||
| 25 | public override var inputView: UIView? { shortcutInputView } | ||
| 26 | |||
| 23 | public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { | 27 | public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| 24 | if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss), | 28 | if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss), |
| 25 | let input = window?.t3FirstResponder as? UITextInput, | 29 | let input = window?.t3FirstResponder as? UITextInput, |
| ... | @@ -89,6 +93,8 @@ public final class T3KeyboardCommandsView: ExpoView { | ... | @@ -89,6 +93,8 @@ public final class T3KeyboardCommandsView: ExpoView { |
| 89 | 93 | ||
| 90 | public required init(appContext: AppContext? = nil) { | 94 | public required init(appContext: AppContext? = nil) { |
| 91 | super.init(appContext: appContext) | 95 | super.init(appContext: appContext) |
| 96 | inputAssistantItem.leadingBarButtonGroups = [] | ||
| 97 | inputAssistantItem.trailingBarButtonGroups = [] | ||
| 92 | NotificationCenter.default.addObserver( | 98 | NotificationCenter.default.addObserver( |
| 93 | self, | 99 | self, |
| 94 | selector: #selector(reclaimFirstResponderIfAvailable), | 100 | selector: #selector(reclaimFirstResponderIfAvailable), |
apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift+49-2| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import ExpoModulesCore | 1 | import ExpoModulesCore |
| 2 | import UIKit | 2 | import UIKit |
| 3 | 3 | ||
| 4 | /// Reports the body geometry of a native navigation column. | 4 | /// Reports geometry in the observing view's coordinate space, including system |
| 5 | /// reservations that safe-area rectangles cannot represent, such as the fold. | ||
| 5 | final class T3LayoutMetricsView: ExpoView { | 6 | final class T3LayoutMetricsView: ExpoView { |
| 6 | let onMetricsChange = EventDispatcher() | 7 | let onMetricsChange = EventDispatcher() |
| 7 | private var lastMetrics: NSDictionary? | 8 | private var lastMetrics: NSDictionary? |
| 9 | private var hasHinge: Bool? = false | ||
| 8 | 10 | ||
| 9 | required init(appContext: AppContext? = nil) { | 11 | required init(appContext: AppContext? = nil) { |
| 10 | super.init(appContext: appContext) | 12 | super.init(appContext: appContext) |
| ... | @@ -12,6 +14,22 @@ final class T3LayoutMetricsView: ExpoView { | ... | @@ -12,6 +14,22 @@ final class T3LayoutMetricsView: ExpoView { |
| 12 | registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in | 14 | registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in |
| 13 | view.publishMetrics() | 15 | view.publishMetrics() |
| 14 | } | 16 | } |
| 17 | #if compiler(>=6.4) | ||
| 18 | if #available(iOS 27.1, *) { | ||
| 19 | hasHinge = nil | ||
| 20 | addInteraction(UIHingeInteraction { [weak self] _, update in | ||
| 21 | guard let self, self.window != nil else { return } | ||
| 22 | // Capability survives temporary detachment and does not follow the hinge angle. | ||
| 23 | let hasHinge = self.hasHinge == true || update.hinge != nil | ||
| 24 | guard self.hasHinge != hasHinge else { return } | ||
| 25 | self.hasHinge = hasHinge | ||
| 26 | self.publishMetrics() | ||
| 27 | }) | ||
| 28 | registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3LayoutMetricsView, _: UITraitCollection) in | ||
| 29 | view.publishMetrics() | ||
| 30 | } | ||
| 31 | } | ||
| 32 | #endif | ||
| 15 | } | 33 | } |
| 16 | 34 | ||
| 17 | override func didMoveToWindow() { | 35 | override func didMoveToWindow() { |
| ... | @@ -31,14 +49,43 @@ final class T3LayoutMetricsView: ExpoView { | ... | @@ -31,14 +49,43 @@ final class T3LayoutMetricsView: ExpoView { |
| 31 | } | 49 | } |
| 32 | 50 | ||
| 33 | private func publishMetrics() { | 51 | private func publishMetrics() { |
| 34 | guard window != nil, bounds.width > 0, bounds.height > 0 else { return } | 52 | guard let window, bounds.width > 0, bounds.height > 0, let hasHinge else { return } |
| 53 | if hasHinge { | ||
| 54 | T3WorkspaceOrientationSubscriber.allowHingeOrientations(in: window) | ||
| 55 | } | ||
| 56 | var verticalBarEdge = "none" | ||
| 57 | var regions: [[String: Any]] = [] | ||
| 58 | #if compiler(>=6.4) | ||
| 59 | if #available(iOS 27.1, *) { | ||
| 60 | let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft | ||
| 61 | switch traitCollection.verticalBarEdge { | ||
| 62 | case .leading: verticalBarEdge = rtl ? "right" : "left" | ||
| 63 | case .trailing: verticalBarEdge = rtl ? "left" : "right" | ||
| 64 | default: break | ||
| 65 | } | ||
| 66 | for kind in [UIView.ReservedRegion.Kind.division, .occlusion] { | ||
| 67 | for region in reservedRegions(kind: kind) where region.isActive { | ||
| 68 | let frame = region.frame.intersection(bounds) | ||
| 69 | guard !frame.isNull, !frame.isEmpty else { continue } | ||
| 70 | regions.append([ | ||
| 71 | "kind": kind == .division ? "division" : "occlusion", | ||
| 72 | "x": frame.minX, "y": frame.minY, | ||
| 73 | "width": frame.width, "height": frame.height, | ||
| 74 | ]) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | #endif | ||
| 35 | let metrics: [String: Any] = [ | 79 | let metrics: [String: Any] = [ |
| 36 | "width": bounds.width, "height": bounds.height, | 80 | "width": bounds.width, "height": bounds.height, |
| 81 | "hasHinge": hasHinge, | ||
| 37 | "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", | 82 | "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", |
| 83 | "verticalBarEdge": verticalBarEdge, | ||
| 38 | "safeArea": [ | 84 | "safeArea": [ |
| 39 | "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, | 85 | "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, |
| 40 | "left": safeAreaInsets.left, "right": safeAreaInsets.right, | 86 | "left": safeAreaInsets.left, "right": safeAreaInsets.right, |
| 41 | ], | 87 | ], |
| 88 | "reservedRegions": regions, | ||
| 42 | ] | 89 | ] |
| 43 | let next = metrics as NSDictionary | 90 | let next = metrics as NSDictionary |
| 44 | guard lastMetrics?.isEqual(next) != true else { return } | 91 | guard lastMetrics?.isEqual(next) != true else { return } |
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+3-1| ... | @@ -13,7 +13,9 @@ public final class T3NativeControlsModule: Module { | ... | @@ -13,7 +13,9 @@ public final class T3NativeControlsModule: Module { |
| 13 | public func definition() -> ModuleDefinition { | 13 | public func definition() -> ModuleDefinition { |
| 14 | Constants { | 14 | Constants { |
| 15 | if #available(iOS 26.0, *) { | 15 | if #available(iOS 26.0, *) { |
| 16 | return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil] | 16 | return [ |
| 17 | "supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil, | ||
| 18 | ] | ||
| 17 | } | 19 | } |
| 18 | return ["supportsWorkspaceColumns": false] | 20 | return ["supportsWorkspaceColumns": false] |
| 19 | } | 21 | } |
apps/mobile/modules/t3-native-controls/ios/T3WorkspaceOrientationSubscriber.swift created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | import ExpoModulesCore | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | /// Keep ordinary phones portrait-only while allowing a hinge-backed scene to rotate. | ||
| 5 | public final class T3WorkspaceOrientationSubscriber: ExpoAppDelegateSubscriber { | ||
| 6 | private static let hingeWindows = NSHashTable<UIWindow>.weakObjects() | ||
| 7 | |||
| 8 | static func allowHingeOrientations(in window: UIWindow) { | ||
| 9 | guard !hingeWindows.contains(window) else { return } | ||
| 10 | hingeWindows.add(window) | ||
| 11 | window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() | ||
| 12 | } | ||
| 13 | |||
| 14 | public func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { | ||
| 15 | if UIDevice.current.userInterfaceIdiom == .pad { | ||
| 16 | // Keep iPad orientation policy, including portrait-only showcase captures. | ||
| 17 | let orientations = Bundle.main.object(forInfoDictionaryKey: "UISupportedInterfaceOrientations~ipad") as? [String] ?? [] | ||
| 18 | return orientations == ["UIInterfaceOrientationPortrait"] ? .portrait : .all | ||
| 19 | } | ||
| 20 | return window.map { Self.hingeWindows.contains($0) } == true ? .allButUpsideDown : .portrait | ||
| 21 | } | ||
| 22 | } | ||
apps/mobile/package.json+1-1| ... | @@ -127,7 +127,7 @@ | ... | @@ -127,7 +127,7 @@ |
| 127 | "react-native-nitro-modules": "0.35.9", | 127 | "react-native-nitro-modules": "0.35.9", |
| 128 | "react-native-reanimated": "4.7.0", | 128 | "react-native-reanimated": "4.7.0", |
| 129 | "react-native-safe-area-context": "~5.9.1", | 129 | "react-native-safe-area-context": "~5.9.1", |
| 130 | "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz", | 130 | "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.8.tgz", |
| 131 | "react-native-shiki-engine": "^0.3.12", | 131 | "react-native-shiki-engine": "^0.3.12", |
| 132 | "react-native-svg": "15.15.5", | 132 | "react-native-svg": "15.15.5", |
| 133 | "react-native-webview": "14.0.1", | 133 | "react-native-webview": "14.0.1", |
apps/mobile/src/App.tsx+8-5| ... | @@ -27,6 +27,7 @@ import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; | ... | @@ -27,6 +27,7 @@ import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; |
| 27 | import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; | 27 | import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; |
| 28 | import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider"; | 28 | import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider"; |
| 29 | import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; | 29 | import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; |
| 30 | import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; | ||
| 30 | 31 | ||
| 31 | import "../global.css"; | 32 | import "../global.css"; |
| 32 | 33 | ||
| ... | @@ -91,11 +92,13 @@ function AppContent() { | ... | @@ -91,11 +92,13 @@ function AppContent() { |
| 91 | header (glass buttons, title, materials) is forced light even when | 92 | header (glass buttons, title, materials) is forced light even when |
| 92 | the system is in dark mode. */} | 93 | the system is in dark mode. */} |
| 93 | <GlobalVoiceInputControl> | 94 | <GlobalVoiceInputControl> |
| 94 | <IncomingShareProvider> | 95 | <NativeLayoutMetricsProvider> |
| 95 | <Navigation linking={appLinking} theme={navigationTheme} /> | 96 | <IncomingShareProvider> |
| 96 | </IncomingShareProvider> | 97 | <Navigation linking={appLinking} theme={navigationTheme} /> |
| 97 | <ConfirmDialogHost /> | 98 | </IncomingShareProvider> |
| 98 | <ThreadArrangementHost /> | 99 | <ConfirmDialogHost /> |
| 100 | <ThreadArrangementHost /> | ||
| 101 | </NativeLayoutMetricsProvider> | ||
| 99 | </GlobalVoiceInputControl> | 102 | </GlobalVoiceInputControl> |
| 100 | {/* Anchored-menu overlays render here — in-window, so the | 103 | {/* Anchored-menu overlays render here — in-window, so the |
| 101 | keyboard stays up while a dropdown is open. */} | 104 | keyboard stays up while a dropdown is open. */} |
apps/mobile/src/Stack.tsx+9-1| ... | @@ -128,6 +128,7 @@ import { | ... | @@ -128,6 +128,7 @@ import { |
| 128 | } from "./features/sharing/incoming-share-presentation"; | 128 | } from "./features/sharing/incoming-share-presentation"; |
| 129 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; | 129 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; |
| 130 | import { deriveLayout } from "./lib/layout"; | 130 | import { deriveLayout } from "./lib/layout"; |
| 131 | import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; | ||
| 131 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; | 132 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; |
| 132 | import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; | 133 | import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; |
| 133 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; | 134 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; |
| ... | @@ -192,6 +193,9 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { | ... | @@ -192,6 +193,9 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { |
| 192 | presentation: "fullScreenModal", | 193 | presentation: "fullScreenModal", |
| 193 | }; | 194 | }; |
| 194 | 195 | ||
| 196 | // A navigator container leaves horizontal safe-area handling to its leaf screens. | ||
| 197 | const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false }; | ||
| 198 | |||
| 195 | const SettingsContentStack = createV5SheetStackNavigator({ | 199 | const SettingsContentStack = createV5SheetStackNavigator({ |
| 196 | initialRouteName: "Settings", | 200 | initialRouteName: "Settings", |
| 197 | screenOptions: { | 201 | screenOptions: { |
| ... | @@ -410,6 +414,7 @@ const SettingsSheetStack = createV5SheetStackNavigator({ | ... | @@ -410,6 +414,7 @@ const SettingsSheetStack = createV5SheetStackNavigator({ |
| 410 | screens: { | 414 | screens: { |
| 411 | SettingsContent: createNativeStackScreen({ | 415 | SettingsContent: createNativeStackScreen({ |
| 412 | screen: SettingsContentStack, | 416 | screen: SettingsContentStack, |
| 417 | options: NESTED_NAVIGATOR_OPTIONS, | ||
| 413 | linking: "", | 418 | linking: "", |
| 414 | layout: ({ children }) => ( | 419 | layout: ({ children }) => ( |
| 415 | <SettingsEnvironmentFilterProvider> | 420 | <SettingsEnvironmentFilterProvider> |
| ... | @@ -845,6 +850,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ | ... | @@ -845,6 +850,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 845 | screen: SettingsSheetStack, | 850 | screen: SettingsSheetStack, |
| 846 | linking: "settings", | 851 | linking: "settings", |
| 847 | options: { | 852 | options: { |
| 853 | ...NESTED_NAVIGATOR_OPTIONS, | ||
| 848 | gestureEnabled: true, | 854 | gestureEnabled: true, |
| 849 | headerShown: false, | 855 | headerShown: false, |
| 850 | }, | 856 | }, |
| ... | @@ -911,6 +917,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ | ... | @@ -911,6 +917,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 911 | </GuardedScreenLayout> | 917 | </GuardedScreenLayout> |
| 912 | ), | 918 | ), |
| 913 | options: { | 919 | options: { |
| 920 | ...NESTED_NAVIGATOR_OPTIONS, | ||
| 914 | gestureEnabled: true, | 921 | gestureEnabled: true, |
| 915 | headerShown: false, | 922 | headerShown: false, |
| 916 | }, | 923 | }, |
| ... | @@ -951,8 +958,9 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: | ... | @@ -951,8 +958,9 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: |
| 951 | 958 | ||
| 952 | export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { | 959 | export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { |
| 953 | const { width, height } = useWindowDimensions(); | 960 | const { width, height } = useWindowDimensions(); |
| 961 | const nativeMetrics = useNativeLayoutMetrics(); | ||
| 954 | const usesWorkspaceFlowScreens = | 962 | const usesWorkspaceFlowScreens = |
| 955 | Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView; | 963 | Platform.OS === "android" || deriveLayout({ width, height, nativeMetrics }).usesSplitView; |
| 956 | 964 | ||
| 957 | return ( | 965 | return ( |
| 958 | <Navigator | 966 | <Navigator |
apps/mobile/src/components/ScreenHeader.tsx+12-4| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; | 1 | import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; |
| 2 | import { useId } from "react"; | 2 | import { useId } from "react"; |
| 3 | import { ActivityIndicator, View } from "react-native"; | 3 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 4 | import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; | 4 | import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; |
| 5 | import { ScreenHeaderButton } from "./ScreenHeaderButton"; | 5 | import { ScreenHeaderButton } from "./ScreenHeaderButton"; |
| 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader"; | 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader"; |
| 7 | import { useNativeWorkspaceColumnsSupported } from "../native/NativeWorkspaceColumns"; | ||
| 7 | import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout"; | 8 | import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout"; |
| 8 | import { | 9 | import { |
| 9 | createNativeMailSearchToolbarItem, | 10 | createNativeMailSearchToolbarItem, |
| ... | @@ -12,6 +13,7 @@ import { | ... | @@ -12,6 +13,7 @@ import { |
| 12 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | 13 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 13 | import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types"; | 14 | import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types"; |
| 14 | import type { AppSymbolName } from "./AppSymbol"; | 15 | import type { AppSymbolName } from "./AppSymbol"; |
| 16 | import { useNativeMailSearchToolbar } from "../native/use-native-mail-search-toolbar"; | ||
| 15 | 17 | ||
| 16 | function iosIcon(icon: AppSymbolName) { | 18 | function iosIcon(icon: AppSymbolName) { |
| 17 | return typeof icon === "string" ? icon : icon.ios; | 19 | return typeof icon === "string" ? icon : icon.ios; |
| ... | @@ -33,15 +35,17 @@ function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["it | ... | @@ -33,15 +35,17 @@ function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["it |
| 33 | } | 35 | } |
| 34 | 36 | ||
| 35 | export function ScreenHeader(props: ScreenHeaderProps) { | 37 | export function ScreenHeader(props: ScreenHeaderProps) { |
| 38 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | ||
| 36 | const headerId = useId(); | 39 | const headerId = useId(); |
| 37 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | 40 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
| 38 | const { themeVariables } = useAppearancePreferences(); | 41 | const { themeVariables } = useAppearancePreferences(); |
| 42 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); | ||
| 39 | const { search, menus } = props; | 43 | const { search, menus } = props; |
| 40 | const menu = menus?.[0]; | 44 | const menu = menus?.[0]; |
| 41 | const compactSearch = | 45 | const compactSearch = |
| 42 | search !== undefined && | 46 | search !== undefined && |
| 43 | (search.compactToolbar ?? !layout.usesSplitView) && | 47 | (search.compactToolbar ?? !layout.usesSplitView) && |
| 44 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | 48 | usesNativeMailSearchToolbar; |
| 45 | const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); | 49 | const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); |
| 46 | const refresh = search?.refreshInToolbar ? search.onRefresh : undefined; | 50 | const refresh = search?.refreshInToolbar ? search.onRefresh : undefined; |
| 47 | return ( | 51 | return ( |
| ... | @@ -88,7 +92,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { | ... | @@ -88,7 +92,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { |
| 88 | : undefined), | 92 | : undefined), |
| 89 | }), | 93 | }), |
| 90 | ] | 94 | ] |
| 91 | : undefined, | 95 | : () => [], |
| 92 | headerSearchBarOptions: compactSearch | 96 | headerSearchBarOptions: compactSearch |
| 93 | ? undefined | 97 | ? undefined |
| 94 | : { | 98 | : { |
| ... | @@ -116,8 +120,12 @@ export function ScreenHeader(props: ScreenHeaderProps) { | ... | @@ -116,8 +120,12 @@ export function ScreenHeader(props: ScreenHeaderProps) { |
| 116 | onPress={props.backInSplitView.onPress ?? props.onBack} | 120 | onPress={props.backInSplitView.onPress ?? props.onBack} |
| 117 | /> | 121 | /> |
| 118 | ) : null} | 122 | ) : null} |
| 119 | {props.sidebar !== false ? ( | 123 | {props.sidebar !== false && |
| 124 | (!usesNativeWorkspaceColumns || | ||
| 125 | (Platform.OS === "ios" && Platform.isPad) || | ||
| 126 | !panes.primarySidebarVisible) ? ( | ||
| 120 | <ScreenHeaderButton | 127 | <ScreenHeaderButton |
| 128 | axisBehavior={usesNativeWorkspaceColumns ? "horizontalOnly" : undefined} | ||
| 121 | accessibilityLabel={ | 129 | accessibilityLabel={ |
| 122 | panes.primarySidebarVisible | 130 | panes.primarySidebarVisible |
| 123 | ? `Maximize ${props.title.toLowerCase()}` | 131 | ? `Maximize ${props.title.toLowerCase()}` |
apps/mobile/src/features/home/HomeHeader.tsx+34-11| ... | @@ -1,25 +1,43 @@ | ... | @@ -1,25 +1,43 @@ |
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { use, useCallback, useRef } from "react"; | 2 | import { use, useCallback, useEffect, useRef } from "react"; |
| 3 | import { useNavigation, type ParamListBase } from "@react-navigation/native"; | ||
| 4 | import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; | ||
| 3 | import { Platform } from "react-native"; | 5 | import { Platform } from "react-native"; |
| 4 | import type { SearchBarCommands } from "react-native-screens"; | 6 | import type { SearchBarCommands } from "react-native-screens"; |
| 5 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; | 7 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; |
| 6 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 8 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 7 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 9 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 8 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | 10 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 9 | import { | 11 | import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; |
| 10 | createNativeMailSearchToolbarItem, | 12 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; |
| 11 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | ||
| 12 | } from "../layout/native-mail-search-toolbar"; | ||
| 13 | import { buildHomeListFilterMenu } from "./home-list-filter-menu"; | 13 | import { buildHomeListFilterMenu } from "./home-list-filter-menu"; |
| 14 | import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; | 14 | import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; |
| 15 | import type { HomeHeaderProps } from "./HomeHeader.types"; | 15 | import type { HomeHeaderProps } from "./HomeHeader.types"; |
| 16 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 16 | 17 | ||
| 17 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; | 18 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| 18 | 19 | ||
| 19 | export function HomeHeader(props: HomeHeaderProps) { | 20 | export function HomeHeader(props: HomeHeaderProps) { |
| 21 | const navigation = useNavigation<NativeStackNavigationProp<ParamListBase>>(); | ||
| 22 | const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | ||
| 23 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); | ||
| 20 | const primaryColumn = use(NativePrimaryColumnContext); | 24 | const primaryColumn = use(NativePrimaryColumnContext); |
| 21 | const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null; | 25 | const sidebarHeader = |
| 26 | Platform.OS === "ios" && | ||
| 27 | primaryColumn !== null && | ||
| 28 | (Platform.isPad || !usesNativeMailSearchToolbar); | ||
| 22 | const searchBarRef = useRef<SearchBarCommands>(null); | 29 | const searchBarRef = useRef<SearchBarCommands>(null); |
| 30 | const focusAfterReveal = useRef(false); | ||
| 31 | useEffect( | ||
| 32 | () => | ||
| 33 | navigation.addListener("transitionEnd", (event) => { | ||
| 34 | if (focusAfterReveal.current && !event.data.closing) { | ||
| 35 | focusAfterReveal.current = false; | ||
| 36 | searchBarRef.current?.focus(); | ||
| 37 | } | ||
| 38 | }), | ||
| 39 | [navigation], | ||
| 40 | ); | ||
| 23 | const iconColor = useUniwindTheme()["--color-icon"]; | 41 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 24 | // The list uses a fixed creation order and ignores sort/group options, so | 42 | // The list uses a fixed creation order and ignores sort/group options, so |
| 25 | // the filter menu only carries the filters and the "customized" icon state | 43 | // the filter menu only carries the filters and the "customized" icon state |
| ... | @@ -27,9 +45,14 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -27,9 +45,14 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 27 | const hasCustomListOptions = | 45 | const hasCustomListOptions = |
| 28 | props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; | 46 | props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; |
| 29 | const focusSearch = useCallback(() => { | 47 | const focusSearch = useCallback(() => { |
| 48 | if (primaryColumn && !panes.primarySidebarVisible) { | ||
| 49 | focusAfterReveal.current = true; | ||
| 50 | togglePrimarySidebar(); | ||
| 51 | return true; | ||
| 52 | } | ||
| 30 | searchBarRef.current?.focus(); | 53 | searchBarRef.current?.focus(); |
| 31 | return searchBarRef.current !== null; | 54 | return searchBarRef.current !== null; |
| 32 | }, []); | 55 | }, [primaryColumn, panes.primarySidebarVisible, togglePrimarySidebar]); |
| 33 | useHardwareKeyboardCommand("focusSearch", focusSearch); | 56 | useHardwareKeyboardCommand("focusSearch", focusSearch); |
| 34 | const filterMenu = buildHomeListFilterMenu(props); | 57 | const filterMenu = buildHomeListFilterMenu(props); |
| 35 | 58 | ||
| ... | @@ -42,7 +65,7 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -42,7 +65,7 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 42 | // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. | 65 | // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. |
| 43 | headerTintColor: iconColor, | 66 | headerTintColor: iconColor, |
| 44 | unstable_headerRightItems: () => | 67 | unstable_headerRightItems: () => |
| 45 | iPadSidebar | 68 | sidebarHeader |
| 46 | ? createSidebarHeaderItems({ | 69 | ? createSidebarHeaderItems({ |
| 47 | filterIcon: hasCustomListOptions | 70 | filterIcon: hasCustomListOptions |
| 48 | ? "line.3.horizontal.decrease.circle.fill" | 71 | ? "line.3.horizontal.decrease.circle.fill" |
| ... | @@ -62,7 +85,7 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -62,7 +85,7 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 62 | ], | 85 | ], |
| 63 | // The keys below are set per-branch (not `undefined`) so a later | 86 | // The keys below are set per-branch (not `undefined`) so a later |
| 64 | // reapply cannot clobber options owned by NativeHeaderToolbar. | 87 | // reapply cannot clobber options owned by NativeHeaderToolbar. |
| 65 | ...(iPadSidebar | 88 | ...(sidebarHeader |
| 66 | ? { | 89 | ? { |
| 67 | headerSearchBarOptions: { | 90 | headerSearchBarOptions: { |
| 68 | ref: searchBarRef, | 91 | ref: searchBarRef, |
| ... | @@ -78,7 +101,7 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -78,7 +101,7 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 78 | }, | 101 | }, |
| 79 | unstable_headerToolbarItems: () => [], | 102 | unstable_headerToolbarItems: () => [], |
| 80 | } | 103 | } |
| 81 | : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED | 104 | : usesNativeMailSearchToolbar |
| 82 | ? { | 105 | ? { |
| 83 | headerSearchBarOptions: { | 106 | headerSearchBarOptions: { |
| 84 | ref: searchBarRef, | 107 | ref: searchBarRef, |
| ... | @@ -121,7 +144,7 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -121,7 +144,7 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 121 | }} | 144 | }} |
| 122 | /> | 145 | /> |
| 123 | 146 | ||
| 124 | {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( | 147 | {sidebarHeader || usesNativeMailSearchToolbar ? null : ( |
| 125 | <NativeHeaderToolbar placement="bottom"> | 148 | <NativeHeaderToolbar placement="bottom"> |
| 126 | <NativeHeaderToolbar.Menu | 149 | <NativeHeaderToolbar.Menu |
| 127 | accessibilityLabel="Filter threads" | 150 | accessibilityLabel="Filter threads" |
apps/mobile/src/features/home/HomeScreen.tsx+14-2| ... | @@ -36,8 +36,12 @@ import type { SavedRemoteConnection } from "../../lib/connection"; | ... | @@ -36,8 +36,12 @@ import type { SavedRemoteConnection } from "../../lib/connection"; |
| 36 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; | 36 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 37 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; | 37 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; |
| 38 | import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; | 38 | import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; |
| 39 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | 39 | import { |
| 40 | useNativeColumnLayoutMetrics, | ||
| 41 | useNativeLayoutMetrics, | ||
| 42 | } from "../../native/native-layout-metrics"; | ||
| 40 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 43 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 44 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; | ||
| 41 | import { useThreadSearch } from "../../state/queries"; | 45 | import { useThreadSearch } from "../../state/queries"; |
| 42 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; | 46 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| 43 | import { usePendingThreadOrder } from "../../state/thread-order"; | 47 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| ... | @@ -264,6 +268,7 @@ function renderHomeScrollView(props: ScrollViewProps) { | ... | @@ -264,6 +268,7 @@ function renderHomeScrollView(props: ScrollViewProps) { |
| 264 | /* ─── Main screen ────────────────────────────────────────────────────── */ | 268 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 265 | 269 | ||
| 266 | export function HomeScreen(props: HomeScreenProps) { | 270 | export function HomeScreen(props: HomeScreenProps) { |
| 271 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | ||
| 267 | const primaryColumn = use(NativePrimaryColumnContext); | 272 | const primaryColumn = use(NativePrimaryColumnContext); |
| 268 | const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen"; | 273 | const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen"; |
| 269 | const containerClassName = cn( | 274 | const containerClassName = cn( |
| ... | @@ -277,6 +282,10 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -277,6 +282,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 277 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | 282 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 278 | const insets = useSafeAreaInsets(); | 283 | const insets = useSafeAreaInsets(); |
| 279 | const { fabClearance } = useAndroidControlSizing(); | 284 | const { fabClearance } = useAndroidControlSizing(); |
| 285 | const screenMetrics = useNativeLayoutMetrics(); | ||
| 286 | const contentSideInsets = usesNativeWorkspaceColumns | ||
| 287 | ? (columnMetrics ?? screenMetrics)?.safeArea | ||
| 288 | : undefined; | ||
| 280 | // UIKit's column safe area already includes its bottom toolbar. | 289 | // UIKit's column safe area already includes its bottom toolbar. |
| 281 | const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); | 290 | const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); |
| 282 | const searchEnvironmentIds = useMemo( | 291 | const searchEnvironmentIds = useMemo( |
| ... | @@ -908,6 +917,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -908,6 +917,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 908 | style={{ | 917 | style={{ |
| 909 | paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24), | 918 | paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24), |
| 910 | paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, | 919 | paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, |
| 920 | paddingLeft: 32 + (contentSideInsets?.left ?? 0), | ||
| 921 | paddingRight: 32 + (contentSideInsets?.right ?? 0), | ||
| 911 | }} | 922 | }} |
| 912 | > | 923 | > |
| 913 | <View className="w-full max-w-[430px]"> | 924 | <View className="w-full max-w-[430px]"> |
| ... | @@ -1038,7 +1049,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1038,7 +1049,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1038 | {...scrollGateHandlers} | 1049 | {...scrollGateHandlers} |
| 1039 | scrollEventThrottle={16} | 1050 | scrollEventThrottle={16} |
| 1040 | contentContainerStyle={{ | 1051 | contentContainerStyle={{ |
| 1041 | paddingHorizontal: primaryColumn ? 8 : 0, | 1052 | paddingLeft: (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0), |
| 1053 | paddingRight: (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0), | ||
| 1042 | paddingBottom: | 1054 | paddingBottom: |
| 1043 | Platform.OS === "ios" | 1055 | Platform.OS === "ios" |
| 1044 | ? iosBottomClearance | 1056 | ? iosBottomClearance |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+23-6| ... | @@ -66,6 +66,8 @@ import { | ... | @@ -66,6 +66,8 @@ import { |
| 66 | NativeWorkspaceInspectorContext, | 66 | NativeWorkspaceInspectorContext, |
| 67 | } from "../../native/v5-workspace-context"; | 67 | } from "../../native/v5-workspace-context"; |
| 68 | 68 | ||
| 69 | import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; | ||
| 70 | |||
| 69 | interface AdaptiveWorkspaceContextValue { | 71 | interface AdaptiveWorkspaceContextValue { |
| 70 | readonly layout: Layout; | 72 | readonly layout: Layout; |
| 71 | readonly panes: WorkspacePaneLayout; | 73 | readonly panes: WorkspacePaneLayout; |
| ... | @@ -239,7 +241,9 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -239,7 +241,9 @@ function AdaptiveWorkspaceLayoutContent( |
| 239 | ) { | 241 | ) { |
| 240 | const projectGroupingMode = props.projectGroupingMode; | 242 | const projectGroupingMode = props.projectGroupingMode; |
| 241 | const nativeWorkspace = use(NativeWorkspaceModeContext); | 243 | const nativeWorkspace = use(NativeWorkspaceModeContext); |
| 242 | const { width, height } = useWindowDimensions(); | 244 | const windowDimensions = useWindowDimensions(); |
| 245 | const nativeMetrics = useNativeLayoutMetrics(); | ||
| 246 | const { width, height } = nativeMetrics ?? windowDimensions; | ||
| 243 | const pathname = props.pathname; | 247 | const pathname = props.pathname; |
| 244 | const navigation = useNavigation(); | 248 | const navigation = useNavigation(); |
| 245 | const activeRoleOwner = useRef<symbol | null>(null); | 249 | const activeRoleOwner = useRef<symbol | null>(null); |
| ... | @@ -256,7 +260,10 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -256,7 +260,10 @@ function AdaptiveWorkspaceLayoutContent( |
| 256 | const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); | 260 | const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); |
| 257 | const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = | 261 | const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = |
| 258 | useState<WorkspaceAuxiliaryPaneRole | null>(null); | 262 | useState<WorkspaceAuxiliaryPaneRole | null>(null); |
| 259 | const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]); | 263 | const baseLayout = useMemo( |
| 264 | () => deriveLayout({ width, height, nativeMetrics }), | ||
| 265 | [height, width, nativeMetrics], | ||
| 266 | ); | ||
| 260 | const layout = baseLayout; | 267 | const layout = baseLayout; |
| 261 | // In split layouts the sidebar IS the thread list — it renders on every | 268 | // In split layouts the sidebar IS the thread list — it renders on every |
| 262 | // route, including Home (which shows an empty-detail pane instead of the | 269 | // route, including Home (which shows an empty-detail pane instead of the |
| ... | @@ -269,7 +276,9 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -269,7 +276,9 @@ function AdaptiveWorkspaceLayoutContent( |
| 269 | viewportWidth: width, | 276 | viewportWidth: width, |
| 270 | preferredWidth: fileInspectorPreferredWidth ?? undefined, | 277 | preferredWidth: fileInspectorPreferredWidth ?? undefined, |
| 271 | reservedLeadingWidth: | 278 | reservedLeadingWidth: |
| 272 | shouldRenderPrimarySidebar && showPrimarySidebar ? (layout.listPaneWidth ?? 0) : 0, | 279 | shouldRenderPrimarySidebar && showPrimarySidebar |
| 280 | ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) | ||
| 281 | : 0, | ||
| 273 | }), | 282 | }), |
| 274 | [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], | 283 | [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], |
| 275 | ); | 284 | ); |
| ... | @@ -470,13 +479,21 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -470,13 +479,21 @@ function AdaptiveWorkspaceLayoutContent( |
| 470 | ); | 479 | ); |
| 471 | 480 | ||
| 472 | const renderedSidebarWidth = useSharedValue( | 481 | const renderedSidebarWidth = useSharedValue( |
| 473 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, | 482 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, |
| 474 | ); | 483 | ); |
| 475 | useEffect(() => { | 484 | useEffect(() => { |
| 476 | if (nativeWorkspace) return; | 485 | if (nativeWorkspace) return; |
| 477 | const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; | 486 | const targetWidth = panes.primarySidebarVisible |
| 487 | ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) | ||
| 488 | : 0; | ||
| 478 | renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); | 489 | renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); |
| 479 | }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); | 490 | }, [ |
| 491 | nativeWorkspace, | ||
| 492 | layout.listPaneWidth, | ||
| 493 | layout.listPaneGap, | ||
| 494 | panes.primarySidebarVisible, | ||
| 495 | renderedSidebarWidth, | ||
| 496 | ]); | ||
| 480 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ | 497 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ |
| 481 | opacity: Math.min(1, renderedSidebarWidth.value / 80), | 498 | opacity: Math.min(1, renderedSidebarWidth.value / 80), |
| 482 | width: renderedSidebarWidth.value, | 499 | width: renderedSidebarWidth.value, |
apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx+59-9| ... | @@ -20,7 +20,7 @@ import { | ... | @@ -20,7 +20,7 @@ import { |
| 20 | type NativeStackTypeBag, | 20 | type NativeStackTypeBag, |
| 21 | } from "@react-navigation/native-stack"; | 21 | } from "@react-navigation/native-stack"; |
| 22 | import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; | 22 | import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; |
| 23 | import { View } from "react-native"; | 23 | import { Platform, View } from "react-native"; |
| 24 | import { Split, type SplitHostCommands } from "react-native-screens"; | 24 | import { Split, type SplitHostCommands } from "react-native-screens"; |
| 25 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 25 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 26 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 26 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| ... | @@ -33,8 +33,13 @@ import { | ... | @@ -33,8 +33,13 @@ import { |
| 33 | V5CardStackView, | 33 | V5CardStackView, |
| 34 | V5StackView, | 34 | V5StackView, |
| 35 | } from "../../native/createV5StackNavigator.ios"; | 35 | } from "../../native/createV5StackNavigator.ios"; |
| 36 | import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns"; | 36 | import { |
| 37 | useNativeWorkspaceColumnsReady, | ||
| 38 | useNativeWorkspaceColumnsSupported, | ||
| 39 | } from "../../native/NativeWorkspaceColumns"; | ||
| 37 | import { V5StackHeader } from "../../native/V5StackHeader.ios"; | 40 | import { V5StackHeader } from "../../native/V5StackHeader.ios"; |
| 41 | import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; | ||
| 42 | import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | ||
| 38 | import { | 43 | import { |
| 39 | nativeWorkspacePopAction, | 44 | nativeWorkspacePopAction, |
| 40 | projectWorkspaceStack, | 45 | projectWorkspaceStack, |
| ... | @@ -89,7 +94,12 @@ function ColumnScreen(props: { | ... | @@ -89,7 +94,12 @@ function ColumnScreen(props: { |
| 89 | canGoBack={!props.primary} | 94 | canGoBack={!props.primary} |
| 90 | primary={props.primary} | 95 | primary={props.primary} |
| 91 | /> | 96 | /> |
| 92 | <ColumnContent primary={props.primary}> | 97 | <ColumnContent |
| 98 | primary={props.primary} | ||
| 99 | insetHorizontally={ | ||
| 100 | (descriptor.options as AppNativeStackNavigationOptions).nativeContentInsetHorizontally | ||
| 101 | } | ||
| 102 | > | ||
| 93 | <NativePrimaryColumnContext value={primaryColumn}> | 103 | <NativePrimaryColumnContext value={primaryColumn}> |
| 94 | {descriptor.render()} | 104 | {descriptor.render()} |
| 95 | </NativePrimaryColumnContext> | 105 | </NativePrimaryColumnContext> |
| ... | @@ -107,6 +117,8 @@ function WorkspaceColumns( | ... | @@ -107,6 +117,8 @@ function WorkspaceColumns( |
| 107 | const inspector = use(NativeWorkspaceInspectorContext); | 117 | const inspector = use(NativeWorkspaceInspectorContext); |
| 108 | const hostRef = useRef<SplitHostCommands>(null); | 118 | const hostRef = useRef<SplitHostCommands>(null); |
| 109 | const activeDetailKey = props.detail.at(-1)?.key; | 119 | const activeDetailKey = props.detail.at(-1)?.key; |
| 120 | const compactColumn = activeDetailKey ? "secondary" : "primary"; | ||
| 121 | const shownCompactColumn = useRef(compactColumn); | ||
| 110 | const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; | 122 | const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; |
| 111 | const selectedThreadKey = | 123 | const selectedThreadKey = |
| 112 | threadParams && | 124 | threadParams && |
| ... | @@ -131,9 +143,12 @@ function WorkspaceColumns( | ... | @@ -131,9 +143,12 @@ function WorkspaceColumns( |
| 131 | useEffect(() => { | 143 | useEffect(() => { |
| 132 | // In compact size classes UIKit exposes one column. Selecting a thread | 144 | // In compact size classes UIKit exposes one column. Selecting a thread |
| 133 | // changes the visible column without rebuilding either navigation stack. | 145 | // changes the visible column without rebuilding either navigation stack. |
| 134 | if (layout.usesSplitView) return; | 146 | // The initial column is a native prop, so it is applied before the host |
| 135 | hostRef.current?.show(activeDetailKey ? "secondary" : "primary"); | 147 | // attaches. Commands only handle later navigation changes. |
| 136 | }, [activeDetailKey, layout.usesSplitView]); | 148 | if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return; |
| 149 | shownCompactColumn.current = compactColumn; | ||
| 150 | hostRef.current?.show(compactColumn); | ||
| 151 | }, [compactColumn, layout.usesSplitView]); | ||
| 137 | 152 | ||
| 138 | const primary = props.descriptors[props.primary.key]; | 153 | const primary = props.descriptors[props.primary.key]; |
| 139 | if (!primary) return null; | 154 | if (!primary) return null; |
| ... | @@ -142,6 +157,7 @@ function WorkspaceColumns( | ... | @@ -142,6 +157,7 @@ function WorkspaceColumns( |
| 142 | ref={hostRef} | 157 | ref={hostRef} |
| 143 | testID="adaptive-workspace-layout" | 158 | testID="adaptive-workspace-layout" |
| 144 | preferredSplitBehavior="tile" | 159 | preferredSplitBehavior="tile" |
| 160 | topColumnForCollapsing={compactColumn} | ||
| 145 | preferredDisplayMode={ | 161 | preferredDisplayMode={ |
| 146 | panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly" | 162 | panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly" |
| 147 | } | 163 | } |
| ... | @@ -153,7 +169,7 @@ function WorkspaceColumns( | ... | @@ -153,7 +169,7 @@ function WorkspaceColumns( |
| 153 | }} | 169 | }} |
| 154 | columnMetrics={{ | 170 | columnMetrics={{ |
| 155 | minimumPrimaryColumnWidth: 280, | 171 | minimumPrimaryColumnWidth: 280, |
| 156 | maximumPrimaryColumnWidth: 380, | 172 | maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0), |
| 157 | preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, | 173 | preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, |
| 158 | minimumSecondaryColumnWidth: 320, | 174 | minimumSecondaryColumnWidth: 320, |
| 159 | }} | 175 | }} |
| ... | @@ -179,6 +195,37 @@ function WorkspaceColumns( | ... | @@ -179,6 +195,37 @@ function WorkspaceColumns( |
| 179 | options={{ | 195 | options={{ |
| 180 | headerShown: true, | 196 | headerShown: true, |
| 181 | title: "", | 197 | title: "", |
| 198 | unstable_headerRightItems: () => | ||
| 199 | Platform.OS === "ios" && !Platform.isPad && layout.usesSplitView | ||
| 200 | ? [ | ||
| 201 | ...(!panes.primarySidebarVisible | ||
| 202 | ? [ | ||
| 203 | { | ||
| 204 | type: "button" as const, | ||
| 205 | axisBehavior: "verticalPreferred" as const, | ||
| 206 | accessibilityLabel: "Search threads", | ||
| 207 | label: "Search threads", | ||
| 208 | icon: { | ||
| 209 | type: "sfSymbol" as const, | ||
| 210 | name: "magnifyingglass" as const, | ||
| 211 | }, | ||
| 212 | onPress: () => { | ||
| 213 | dispatchHardwareKeyboardCommand("focusSearch"); | ||
| 214 | }, | ||
| 215 | }, | ||
| 216 | ] | ||
| 217 | : []), | ||
| 218 | { | ||
| 219 | type: "button", | ||
| 220 | axisBehavior: "verticalPreferred", | ||
| 221 | accessibilityLabel: "New task", | ||
| 222 | label: "New task", | ||
| 223 | icon: { type: "sfSymbol", name: "square.and.pencil" }, | ||
| 224 | onPress: () => | ||
| 225 | props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }), | ||
| 226 | }, | ||
| 227 | ] | ||
| 228 | : [], | ||
| 182 | unstable_headerLeftItems: () => [ | 229 | unstable_headerLeftItems: () => [ |
| 183 | { | 230 | { |
| 184 | type: "button", | 231 | type: "button", |
| ... | @@ -293,6 +340,8 @@ function WorkspaceStackNavigator({ | ... | @@ -293,6 +340,8 @@ function WorkspaceStackNavigator({ |
| 293 | UNSTABLE_router, | 340 | UNSTABLE_router, |
| 294 | ...rest | 341 | ...rest |
| 295 | }: NativeStackNavigatorProps) { | 342 | }: NativeStackNavigatorProps) { |
| 343 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | ||
| 344 | const nativeWorkspaceColumnsReady = useNativeWorkspaceColumnsReady(); | ||
| 296 | const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< | 345 | const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< |
| 297 | StackNavigationState<ParamListBase>, | 346 | StackNavigationState<ParamListBase>, |
| 298 | StackRouterOptions, | 347 | StackRouterOptions, |
| ... | @@ -310,9 +359,10 @@ function WorkspaceStackNavigator({ | ... | @@ -310,9 +359,10 @@ function WorkspaceStackNavigator({ |
| 310 | screenLayout, | 359 | screenLayout, |
| 311 | UNSTABLE_router, | 360 | UNSTABLE_router, |
| 312 | }); | 361 | }); |
| 313 | const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView; | 362 | if (!nativeWorkspaceColumnsReady) return null; |
| 363 | const WorkspaceView = usesNativeWorkspaceColumns ? WorkspaceStackView : V5StackView; | ||
| 314 | return ( | 364 | return ( |
| 315 | <NativeWorkspaceModeContext value={NATIVE_WORKSPACE_COLUMNS_SUPPORTED}> | 365 | <NativeWorkspaceModeContext value={usesNativeWorkspaceColumns}> |
| 316 | <NavigationContent> | 366 | <NavigationContent> |
| 317 | <WorkspaceView | 367 | <WorkspaceView |
| 318 | {...rest} | 368 | {...rest} |
apps/mobile/src/features/layout/native-glass-header-items.ts+1| ... | @@ -4,6 +4,7 @@ type NativeGlassHeaderItem = { | ... | @@ -4,6 +4,7 @@ type NativeGlassHeaderItem = { |
| 4 | readonly hidesSharedBackground?: boolean; | 4 | readonly hidesSharedBackground?: boolean; |
| 5 | readonly sharesBackground?: boolean; | 5 | readonly sharesBackground?: boolean; |
| 6 | readonly variant?: "plain" | "done" | "prominent"; | 6 | readonly variant?: "plain" | "done" | "prominent"; |
| 7 | readonly axisBehavior?: "automatic" | "horizontalOnly" | "verticalPreferred"; | ||
| 7 | readonly width?: number; | 8 | readonly width?: number; |
| 8 | }; | 9 | }; |
| 9 | 10 |
apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx+2-2| ... | @@ -19,8 +19,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac | ... | @@ -19,8 +19,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac |
| 19 | import { | 19 | import { |
| 20 | createNativeMailSearchToolbarItem, | 20 | createNativeMailSearchToolbarItem, |
| 21 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, | 21 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 22 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | ||
| 23 | } from "../layout/native-mail-search-toolbar"; | 22 | } from "../layout/native-mail-search-toolbar"; |
| 23 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; | ||
| 24 | 24 | ||
| 25 | import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; | 25 | import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; |
| 26 | 26 | ||
| ... | @@ -72,7 +72,7 @@ export function SettingsOpenSourceLicensesRouteScreen() { | ... | @@ -72,7 +72,7 @@ export function SettingsOpenSourceLicensesRouteScreen() { |
| 72 | const navigation = useNavigation(); | 72 | const navigation = useNavigation(); |
| 73 | const insets = useSafeAreaInsets(); | 73 | const insets = useSafeAreaInsets(); |
| 74 | const [query, setQuery] = useState(""); | 74 | const [query, setQuery] = useState(""); |
| 75 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | 75 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); |
| 76 | const manifest = useMobileThirdPartyLicenses(); | 76 | const manifest = useMobileThirdPartyLicenses(); |
| 77 | const entries = manifest?.entries ?? []; | 77 | const entries = manifest?.entries ?? []; |
| 78 | const filteredEntries = useMemo( | 78 | const filteredEntries = useMemo( |
apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx+2| ... | @@ -54,6 +54,7 @@ export function SettingsEnvironmentFilterHeader(props: { | ... | @@ -54,6 +54,7 @@ export function SettingsEnvironmentFilterHeader(props: { |
| 54 | options={{ | 54 | options={{ |
| 55 | unstable_headerRightItems: () => [ | 55 | unstable_headerRightItems: () => [ |
| 56 | withNativeGlassHeaderItem({ | 56 | withNativeGlassHeaderItem({ |
| 57 | axisBehavior: "horizontalOnly", | ||
| 57 | accessibilityLabel: "Filter settings environments and projects", | 58 | accessibilityLabel: "Filter settings environments and projects", |
| 58 | icon: { name: filterIcon, type: "sfSymbol" }, | 59 | icon: { name: filterIcon, type: "sfSymbol" }, |
| 59 | label: "", | 60 | label: "", |
| ... | @@ -120,6 +121,7 @@ export function SettingsEnvironmentFilterHeader(props: { | ... | @@ -120,6 +121,7 @@ export function SettingsEnvironmentFilterHeader(props: { |
| 120 | ...(closeSettings | 121 | ...(closeSettings |
| 121 | ? [ | 122 | ? [ |
| 122 | withNativeGlassHeaderItem({ | 123 | withNativeGlassHeaderItem({ |
| 124 | axisBehavior: "horizontalOnly", | ||
| 123 | accessibilityLabel: "Close settings", | 125 | accessibilityLabel: "Close settings", |
| 124 | icon: { name: "xmark", type: "sfSymbol" }, | 126 | icon: { name: "xmark", type: "sfSymbol" }, |
| 125 | identifier: "settings-close", | 127 | identifier: "settings-close", |
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+2-2| ... | @@ -37,8 +37,8 @@ import { vcsEnvironment } from "../../state/vcs"; | ... | @@ -37,8 +37,8 @@ import { vcsEnvironment } from "../../state/vcs"; |
| 37 | import { | 37 | import { |
| 38 | createNativeMailSearchToolbarItem, | 38 | createNativeMailSearchToolbarItem, |
| 39 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, | 39 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 40 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | ||
| 41 | } from "../layout/native-mail-search-toolbar"; | 40 | } from "../layout/native-mail-search-toolbar"; |
| 41 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; | ||
| 42 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 42 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 43 | import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; | 43 | import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; |
| 44 | 44 | ||
| ... | @@ -413,7 +413,7 @@ export function BranchPickerScreen(props: { | ... | @@ -413,7 +413,7 @@ export function BranchPickerScreen(props: { |
| 413 | }) { | 413 | }) { |
| 414 | const navigation = useNavigation(); | 414 | const navigation = useNavigation(); |
| 415 | const insets = useSafeAreaInsets(); | 415 | const insets = useSafeAreaInsets(); |
| 416 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | 416 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); |
| 417 | const selectedBranchName = | 417 | const selectedBranchName = |
| 418 | props.selectedBranchName ?? | 418 | props.selectedBranchName ?? |
| 419 | props.branches.find((branch) => branch.current)?.name ?? | 419 | props.branches.find((branch) => branch.current)?.name ?? |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+29-4| ... | @@ -14,6 +14,8 @@ import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp | ... | @@ -14,6 +14,8 @@ import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp |
| 14 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; | 14 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; |
| 15 | import type { LegendListRef } from "@legendapp/list/react-native"; | 15 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 16 | import { HeaderHeightContext } from "@react-navigation/elements"; | 16 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 17 | import { NativeLayoutObserver } from "../../native/NativeLayoutObserver"; | ||
| 18 | import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions"; | ||
| 17 | import type { | 19 | import type { |
| 18 | EnvironmentId, | 20 | EnvironmentId, |
| 19 | MessageId, | 21 | MessageId, |
| ... | @@ -84,6 +86,8 @@ import Animated, { | ... | @@ -84,6 +86,8 @@ import Animated, { |
| 84 | } from "react-native-reanimated"; | 86 | } from "react-native-reanimated"; |
| 85 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 87 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 86 | import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; | 88 | import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; |
| 89 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; | ||
| 90 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | ||
| 87 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 91 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 88 | import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; | 92 | import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; |
| 89 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 93 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| ... | @@ -317,6 +321,7 @@ const USER_INPUT_TOGGLE_TIMING = { | ... | @@ -317,6 +321,7 @@ const USER_INPUT_TOGGLE_TIMING = { |
| 317 | }; | 321 | }; |
| 318 | 322 | ||
| 319 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { | 323 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 324 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | ||
| 320 | const navigation = useNavigation(); | 325 | const navigation = useNavigation(); |
| 321 | const { session: voiceInputSession } = useGlobalVoiceInput(); | 326 | const { session: voiceInputSession } = useGlobalVoiceInput(); |
| 322 | const reportedModelSelection = useThreadReportedModelSelection({ | 327 | const reportedModelSelection = useThreadReportedModelSelection({ |
| ... | @@ -388,7 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -388,7 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 388 | } | 393 | } |
| 389 | }, []); | 394 | }, []); |
| 390 | const windowHeight = useWindowDimensions().height; | 395 | const windowHeight = useWindowDimensions().height; |
| 391 | const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; | 396 | const navigationHeaderHeight = useContext(HeaderHeightContext) ?? insets.top + 44; |
| 397 | const [screenMetrics, setScreenMetrics] = useState<NativeLayoutMetrics | null>(null); | ||
| 398 | const columnMetrics = useNativeColumnLayoutMetrics(); | ||
| 399 | const nativeMetrics = usesNativeWorkspaceColumns ? columnMetrics : screenMetrics; | ||
| 400 | const controlInsets = deriveBottomControlInsets(nativeMetrics); | ||
| 392 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; | 401 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 393 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); | 402 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 394 | const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; | 403 | const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; |
| ... | @@ -826,8 +835,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -826,8 +835,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 826 | // Assign both layouts explicitly so the dock always follows its current parent. | 835 | // Assign both layouts explicitly so the dock always follows its current parent. |
| 827 | const composerWidthStyle = useAnimatedStyle(() => | 836 | const composerWidthStyle = useAnimatedStyle(() => |
| 828 | isSplitLayout && workspaceContentWidth !== null | 837 | isSplitLayout && workspaceContentWidth !== null |
| 829 | ? { width: workspaceContentWidth.value } | 838 | ? { |
| 830 | : { width: "100%" }, | 839 | width: Math.max( |
| 840 | 0, | ||
| 841 | workspaceContentWidth.value - controlInsets.left - controlInsets.right, | ||
| 842 | ), | ||
| 843 | right: undefined, | ||
| 844 | } | ||
| 845 | : { width: undefined, right: controlInsets.right }, | ||
| 831 | ); | 846 | ); |
| 832 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; | 847 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; |
| 833 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); | 848 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| ... | @@ -1083,6 +1098,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -1083,6 +1098,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1083 | 1098 | ||
| 1084 | return ( | 1099 | return ( |
| 1085 | <View className="flex-1"> | 1100 | <View className="flex-1"> |
| 1101 | {!usesNativeWorkspaceColumns ? <NativeLayoutObserver onChange={setScreenMetrics} /> : null} | ||
| 1086 | {showContent ? ( | 1102 | {showContent ? ( |
| 1087 | <View | 1103 | <View |
| 1088 | style={{ flex: 1 }} | 1104 | style={{ flex: 1 }} |
| ... | @@ -1140,6 +1156,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -1140,6 +1156,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1140 | } | 1156 | } |
| 1141 | contentMaxWidth={contentMaxWidth} | 1157 | contentMaxWidth={contentMaxWidth} |
| 1142 | historyControls={props.historyControls} | 1158 | historyControls={props.historyControls} |
| 1159 | contentSideInsets={nativeMetrics?.safeArea} | ||
| 1143 | layoutVariant={layoutVariant} | 1160 | layoutVariant={layoutVariant} |
| 1144 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} | 1161 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} |
| 1145 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} | 1162 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} |
| ... | @@ -1183,7 +1200,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -1183,7 +1200,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1183 | <Animated.View | 1200 | <Animated.View |
| 1184 | layout={COMPOSER_LAYOUT_TRANSITION} | 1201 | layout={COMPOSER_LAYOUT_TRANSITION} |
| 1185 | pointerEvents="box-none" | 1202 | pointerEvents="box-none" |
| 1186 | style={[{ position: "absolute", bottom: 0, left: 0 }, composerWidthStyle]} | 1203 | style={[ |
| 1204 | { | ||
| 1205 | position: "absolute", | ||
| 1206 | bottom: controlInsets.bottom, | ||
| 1207 | left: controlInsets.left, | ||
| 1208 | right: controlInsets.right, | ||
| 1209 | }, | ||
| 1210 | composerWidthStyle, | ||
| 1211 | ]} | ||
| 1187 | > | 1212 | > |
| 1188 | {/* No paddingTop here: the overlay's measured height becomes the | 1213 | {/* No paddingTop here: the overlay's measured height becomes the |
| 1189 | list's bottom inset, so any padding above the pill/composer | 1214 | list's bottom inset, so any padding above the pill/composer |
apps/mobile/src/features/threads/ThreadFeed.tsx+40-8| ... | @@ -57,6 +57,7 @@ import { videoMimeType } from "@t3tools/shared/video"; | ... | @@ -57,6 +57,7 @@ import { videoMimeType } from "@t3tools/shared/video"; |
| 57 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; | 57 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 58 | import { HeaderHeightContext } from "@react-navigation/elements"; | 58 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 59 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; | 59 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 60 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; | ||
| 60 | import { | 61 | import { |
| 61 | createContext, | 62 | createContext, |
| 62 | memo, | 63 | memo, |
| ... | @@ -297,6 +298,7 @@ export interface ThreadFeedProps { | ... | @@ -297,6 +298,7 @@ export interface ThreadFeedProps { |
| 297 | readonly contentBottomInset?: number; | 298 | readonly contentBottomInset?: number; |
| 298 | readonly historyControls?: ThreadFeedHistoryControls; | 299 | readonly historyControls?: ThreadFeedHistoryControls; |
| 299 | readonly contentMaxWidth?: number; | 300 | readonly contentMaxWidth?: number; |
| 301 | readonly contentSideInsets?: { readonly left: number; readonly right: number }; | ||
| 300 | readonly layoutVariant?: LayoutVariant; | 302 | readonly layoutVariant?: LayoutVariant; |
| 301 | readonly usesAutomaticContentInsets?: boolean; | 303 | readonly usesAutomaticContentInsets?: boolean; |
| 302 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; | 304 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; |
| ... | @@ -1543,6 +1545,7 @@ function renderFeedEntry( | ... | @@ -1543,6 +1545,7 @@ function renderFeedEntry( |
| 1543 | readonly reviewCommentColors: ReviewCommentColors; | 1545 | readonly reviewCommentColors: ReviewCommentColors; |
| 1544 | readonly reviewCommentBubbleWidth: number; | 1546 | readonly reviewCommentBubbleWidth: number; |
| 1545 | readonly themeAppearance: "light" | "dark"; | 1547 | readonly themeAppearance: "light" | "dark"; |
| 1548 | readonly usesNativeWorkspaceColumns: boolean; | ||
| 1546 | readonly userBubbleMaxWidth: number; | 1549 | readonly userBubbleMaxWidth: number; |
| 1547 | /** Width assistant markdown lays out in, so images can size their frame before layout. */ | 1550 | /** Width assistant markdown lays out in, so images can size their frame before layout. */ |
| 1548 | readonly markdownContentWidth: number; | 1551 | readonly markdownContentWidth: number; |
| ... | @@ -1730,11 +1733,19 @@ function renderFeedEntry( | ... | @@ -1730,11 +1733,19 @@ function renderFeedEntry( |
| 1730 | className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5" | 1733 | className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5" |
| 1731 | style={{ | 1734 | style={{ |
| 1732 | backgroundColor: userBubbleColor, | 1735 | backgroundColor: userBubbleColor, |
| 1733 | maxWidth: props.userBubbleMaxWidth, | 1736 | maxWidth: |
| 1737 | props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad | ||
| 1738 | ? "85%" | ||
| 1739 | : props.userBubbleMaxWidth, | ||
| 1734 | ...(hasReviewCommentContext | 1740 | ...(hasReviewCommentContext |
| 1735 | ? { width: props.reviewCommentBubbleWidth } | 1741 | ? { width: props.reviewCommentBubbleWidth } |
| 1736 | : hasWideBlock | 1742 | : hasWideBlock |
| 1737 | ? { width: props.userBubbleMaxWidth } | 1743 | ? { |
| 1744 | width: | ||
| 1745 | props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad | ||
| 1746 | ? "85%" | ||
| 1747 | : props.userBubbleMaxWidth, | ||
| 1748 | } | ||
| 1738 | : null), | 1749 | : null), |
| 1739 | }} | 1750 | }} |
| 1740 | > | 1751 | > |
| ... | @@ -2174,6 +2185,7 @@ function ThreadFeedPlaceholder(props: { | ... | @@ -2174,6 +2185,7 @@ function ThreadFeedPlaceholder(props: { |
| 2174 | } | 2185 | } |
| 2175 | 2186 | ||
| 2176 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | 2187 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2188 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | ||
| 2177 | const navigation = useNavigation(); | 2189 | const navigation = useNavigation(); |
| 2178 | const { themeAppearance } = useAppearancePreferences(); | 2190 | const { themeAppearance } = useAppearancePreferences(); |
| 2179 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 2191 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| ... | @@ -2257,12 +2269,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2257,12 +2269,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2257 | setExpandedFile(null); | 2269 | setExpandedFile(null); |
| 2258 | }, [props.environmentId, props.threadId, props.contentPresentation.kind]); | 2270 | }, [props.environmentId, props.threadId, props.contentPresentation.kind]); |
| 2259 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; | 2271 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 2272 | const contentLeftInset = props.contentSideInsets?.left ?? 0; | ||
| 2273 | const contentRightInset = props.contentSideInsets?.right ?? 0; | ||
| 2274 | const usableViewportWidth = Math.max(0, viewportWidth - contentLeftInset - contentRightInset); | ||
| 2260 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ | 2275 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ |
| 2261 | viewportWidth, | 2276 | viewportWidth: usableViewportWidth, |
| 2262 | maxContentWidth: props.contentMaxWidth ?? null, | 2277 | maxContentWidth: props.contentMaxWidth ?? null, |
| 2263 | minimumPadding: horizontalPadding, | 2278 | minimumPadding: horizontalPadding, |
| 2264 | }); | 2279 | }); |
| 2265 | const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); | 2280 | const contentWidth = Math.max(0, usableViewportWidth - contentHorizontalPadding * 2); |
| 2266 | const userBubbleMaxWidth = contentWidth * 0.85; | 2281 | const userBubbleMaxWidth = contentWidth * 0.85; |
| 2267 | const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); | 2282 | const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); |
| 2268 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); | 2283 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); |
| ... | @@ -2285,7 +2300,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2285,7 +2300,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2285 | // header-providing screen) and fall back to the standard iOS bar height. | 2300 | // header-providing screen) and fall back to the standard iOS bar height. |
| 2286 | const navigationHeaderHeight = useContext(HeaderHeightContext); | 2301 | const navigationHeaderHeight = useContext(HeaderHeightContext); |
| 2287 | const anchorTopInset = usesNativeAutomaticInsets | 2302 | const anchorTopInset = usesNativeAutomaticInsets |
| 2288 | ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT | 2303 | ? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT) |
| 2289 | : topContentInset; | 2304 | : topContentInset; |
| 2290 | 2305 | ||
| 2291 | const theme = useUniwindTheme(); | 2306 | const theme = useUniwindTheme(); |
| ... | @@ -3030,6 +3045,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -3030,6 +3045,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3030 | reviewCommentColors, | 3045 | reviewCommentColors, |
| 3031 | reviewCommentBubbleWidth, | 3046 | reviewCommentBubbleWidth, |
| 3032 | themeAppearance, | 3047 | themeAppearance, |
| 3048 | usesNativeWorkspaceColumns, | ||
| 3033 | userBubbleMaxWidth, | 3049 | userBubbleMaxWidth, |
| 3034 | markdownContentWidth, | 3050 | markdownContentWidth, |
| 3035 | contentWidth, | 3051 | contentWidth, |
| ... | @@ -3067,6 +3083,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -3067,6 +3083,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3067 | reviewCommentColors, | 3083 | reviewCommentColors, |
| 3068 | reviewCommentBubbleWidth, | 3084 | reviewCommentBubbleWidth, |
| 3069 | themeAppearance, | 3085 | themeAppearance, |
| 3086 | usesNativeWorkspaceColumns, | ||
| 3070 | userBubbleMaxWidth, | 3087 | userBubbleMaxWidth, |
| 3071 | markdownContentWidth, | 3088 | markdownContentWidth, |
| 3072 | contentWidth, | 3089 | contentWidth, |
| ... | @@ -3119,7 +3136,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -3119,7 +3136,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3119 | // (facebook/react-native#54123); the anchored end space after a send | 3136 | // (facebook/react-native#54123); the anchored end space after a send |
| 3120 | // is pure inset, so without this the blank region can't be scrolled. | 3137 | // is pure inset, so without this the blank region can't be scrolled. |
| 3121 | applyWorkaroundForContentInsetHitTestBug | 3138 | applyWorkaroundForContentInsetHitTestBug |
| 3122 | contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"} | 3139 | // Horizontal Duo reservations are already included in the row padding. |
| 3140 | // Let UIKit adjust the scrolling axis without shifting content sideways. | ||
| 3141 | contentInsetAdjustmentBehavior={ | ||
| 3142 | usesNativeAutomaticInsets | ||
| 3143 | ? usesNativeWorkspaceColumns && | ||
| 3144 | Platform.OS === "ios" && | ||
| 3145 | !Platform.isPad && | ||
| 3146 | props.layoutVariant === "split" | ||
| 3147 | ? "scrollableAxes" | ||
| 3148 | : "automatic" | ||
| 3149 | : "never" | ||
| 3150 | } | ||
| 3123 | automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} | 3151 | automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} |
| 3124 | {...(usesNativeAutomaticInsets | 3152 | {...(usesNativeAutomaticInsets |
| 3125 | ? { | 3153 | ? { |
| ... | @@ -3231,7 +3259,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -3231,7 +3259,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3231 | } | 3259 | } |
| 3232 | contentContainerStyle={{ | 3260 | contentContainerStyle={{ |
| 3233 | paddingTop: 12, | 3261 | paddingTop: 12, |
| 3234 | paddingHorizontal: contentHorizontalPadding, | 3262 | paddingLeft: contentHorizontalPadding + contentLeftInset, |
| 3263 | paddingRight: contentHorizontalPadding + contentRightInset, | ||
| 3235 | }} | 3264 | }} |
| 3236 | /> | 3265 | /> |
| 3237 | </View> | 3266 | </View> |
| ... | @@ -3239,7 +3268,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -3239,7 +3268,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3239 | !props.worktreeSetup && | 3268 | !props.worktreeSetup && |
| 3240 | props.activeWorkStartedAt === null && | 3269 | props.activeWorkStartedAt === null && |
| 3241 | props.contentPresentation.kind === "ready" ? ( | 3270 | props.contentPresentation.kind === "ready" ? ( |
| 3242 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> | 3271 | <View |
| 3272 | pointerEvents="none" | ||
| 3273 | style={[StyleSheet.absoluteFill, { left: contentLeftInset, right: contentRightInset }]} | ||
| 3274 | > | ||
| 3243 | <ThreadFeedPlaceholder | 3275 | <ThreadFeedPlaceholder |
| 3244 | title="No conversation yet" | 3276 | title="No conversation yet" |
| 3245 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." | 3277 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+6-6| ... | @@ -73,8 +73,8 @@ import { | ... | @@ -73,8 +73,8 @@ import { |
| 73 | import { | 73 | import { |
| 74 | createNativeMailSearchToolbarItem, | 74 | createNativeMailSearchToolbarItem, |
| 75 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, | 75 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 76 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | ||
| 77 | } from "../layout/native-mail-search-toolbar"; | 76 | } from "../layout/native-mail-search-toolbar"; |
| 77 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; | ||
| 78 | import { ModelRow, ChoiceRow } from "./ThreadSettingsRows"; | 78 | import { ModelRow, ChoiceRow } from "./ThreadSettingsRows"; |
| 79 | import { | 79 | import { |
| 80 | compatibleRuntimeModeForChoices, | 80 | compatibleRuntimeModeForChoices, |
| ... | @@ -742,10 +742,10 @@ function ThreadSettingsOptionsItem(props: { | ... | @@ -742,10 +742,10 @@ function ThreadSettingsOptionsItem(props: { |
| 742 | .get(session.environmentId) | 742 | .get(session.environmentId) |
| 743 | ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null) | 743 | ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null) |
| 744 | : null; | 744 | : null; |
| 745 | const bottomToolbarInset = | 745 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); |
| 746 | Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED | 746 | const bottomToolbarInset = usesNativeMailSearchToolbar |
| 747 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET | 747 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET |
| 748 | : 0; | 748 | : 0; |
| 749 | 749 | ||
| 750 | return ( | 750 | return ( |
| 751 | <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}> | 751 | <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}> |
| ... | @@ -1107,7 +1107,7 @@ function ThreadSettingsModelsScreen() { | ... | @@ -1107,7 +1107,7 @@ function ThreadSettingsModelsScreen() { |
| 1107 | const session = useThreadSettingsSession(); | 1107 | const session = useThreadSettingsSession(); |
| 1108 | const presentation = useThreadSettingsPickerPresentation(); | 1108 | const presentation = useThreadSettingsPickerPresentation(); |
| 1109 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); | 1109 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); |
| 1110 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | 1110 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); |
| 1111 | const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; | 1111 | const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; |
| 1112 | const commitAndClose = useCallback(() => { | 1112 | const commitAndClose = useCallback(() => { |
| 1113 | if (!session.commitPendingModel()) return; | 1113 | if (!session.commitPendingModel()) return; |
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+3-2| ... | @@ -21,6 +21,7 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re | ... | @@ -21,6 +21,7 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re |
| 21 | 21 | ||
| 22 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; | 22 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; |
| 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | import { NativeSheetContent } from "../../../native/NativeSheetContent"; | ||
| 24 | import { useUniwindTheme } from "../../../lib/useUniwindTheme"; | 25 | import { useUniwindTheme } from "../../../lib/useUniwindTheme"; |
| 25 | import { | 26 | import { |
| 26 | AndroidHeaderIconButton, | 27 | AndroidHeaderIconButton, |
| ... | @@ -411,7 +412,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { | ... | @@ -411,7 +412,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 411 | scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} | 412 | scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} |
| 412 | style={{ backgroundColor: sheetColor, flex: 1 }} | 413 | style={{ backgroundColor: sheetColor, flex: 1 }} |
| 413 | > | 414 | > |
| 414 | {content} | 415 | <NativeSheetContent>{content}</NativeSheetContent> |
| 415 | <ScreenStackHeaderConfig | 416 | <ScreenStackHeaderConfig |
| 416 | backgroundColor="rgba(0,0,0,0)" | 417 | backgroundColor="rgba(0,0,0,0)" |
| 417 | color={foregroundColor} | 418 | color={foregroundColor} |
| ... | @@ -447,7 +448,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { | ... | @@ -447,7 +448,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 447 | scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} | 448 | scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} |
| 448 | style={{ backgroundColor: sheetColor, flex: 1 }} | 449 | style={{ backgroundColor: sheetColor, flex: 1 }} |
| 449 | > | 450 | > |
| 450 | {content} | 451 | <NativeSheetContent>{content}</NativeSheetContent> |
| 451 | <ScreenStackHeaderConfig | 452 | <ScreenStackHeaderConfig |
| 452 | backgroundColor="rgba(0,0,0,0)" | 453 | backgroundColor="rgba(0,0,0,0)" |
| 453 | color={foregroundColor} | 454 | color={foregroundColor} |
apps/mobile/src/features/threads/sidebar-native-header-items.ts+5-2| ... | @@ -34,7 +34,8 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead | ... | @@ -34,7 +34,8 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead |
| 34 | /** | 34 | /** |
| 35 | * Right-side UINavigationBar items for the sidebar column: the thread list | 35 | * Right-side UINavigationBar items for the sidebar column: the thread list |
| 36 | * filter/sort menu plus the settings button, sharing one glass capsule — | 36 | * filter/sort menu plus the settings button, sharing one glass capsule — |
| 37 | * the Messages-style grouped header buttons. | 37 | * the Messages-style grouped header buttons. The embedded sidebar keeps these |
| 38 | * beside its title; only the detail column uses the Duo's vertical bar. | ||
| 38 | */ | 39 | */ |
| 39 | export function createSidebarHeaderItems(input: { | 40 | export function createSidebarHeaderItems(input: { |
| 40 | readonly filterIcon: string; | 41 | readonly filterIcon: string; |
| ... | @@ -44,6 +45,7 @@ export function createSidebarHeaderItems(input: { | ... | @@ -44,6 +45,7 @@ export function createSidebarHeaderItems(input: { |
| 44 | return [ | 45 | return [ |
| 45 | withNativeGlassHeaderItem({ | 46 | withNativeGlassHeaderItem({ |
| 46 | type: "menu", | 47 | type: "menu", |
| 48 | axisBehavior: "horizontalOnly", | ||
| 47 | label: "", | 49 | label: "", |
| 48 | accessibilityLabel: "Filter threads", | 50 | accessibilityLabel: "Filter threads", |
| 49 | icon: sfSymbolIcon(input.filterIcon), | 51 | icon: sfSymbolIcon(input.filterIcon), |
| ... | @@ -54,7 +56,8 @@ export function createSidebarHeaderItems(input: { | ... | @@ -54,7 +56,8 @@ export function createSidebarHeaderItems(input: { |
| 54 | }), | 56 | }), |
| 55 | withNativeGlassHeaderItem({ | 57 | withNativeGlassHeaderItem({ |
| 56 | type: "button", | 58 | type: "button", |
| 57 | label: "", | 59 | axisBehavior: "horizontalOnly", |
| 60 | label: "Settings", | ||
| 58 | accessibilityLabel: "Open settings", | 61 | accessibilityLabel: "Open settings", |
| 59 | icon: sfSymbolIcon("gearshape"), | 62 | icon: sfSymbolIcon("gearshape"), |
| 60 | onPress: input.onOpenSettings, | 63 | onPress: input.onOpenSettings, |
apps/mobile/src/features/threads/useThreadHeaderOptions.tsx+83-16| ... | @@ -1,8 +1,11 @@ | ... | @@ -1,8 +1,11 @@ |
| 1 | import { StackActions, useNavigation } from "@react-navigation/native"; | 1 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 2 | import { useMemo } from "react"; | 2 | import { useMemo } from "react"; |
| 3 | import { Platform } from "react-native"; | ||
| 3 | import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; | 4 | import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; |
| 5 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; | ||
| 4 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 6 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 5 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | 7 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 8 | import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | ||
| 6 | import { | 9 | import { |
| 7 | ThreadGitControls, | 10 | ThreadGitControls, |
| 8 | useThreadGitCenterHeaderItems, | 11 | useThreadGitCenterHeaderItems, |
| ... | @@ -19,18 +22,24 @@ export function useThreadHeaderOptions(props: { | ... | @@ -19,18 +22,24 @@ export function useThreadHeaderOptions(props: { |
| 19 | readonly gitControls: Parameters<typeof ThreadGitControls>[0]; | 22 | readonly gitControls: Parameters<typeof ThreadGitControls>[0]; |
| 20 | readonly onReturnToThread?: () => void; | 23 | readonly onReturnToThread?: () => void; |
| 21 | }) { | 24 | }) { |
| 25 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | ||
| 26 | const usesDuoHeader = usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad; | ||
| 22 | const navigation = useNavigation(); | 27 | const navigation = useNavigation(); |
| 23 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | 28 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
| 24 | const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls); | 29 | const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls); |
| 25 | const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls); | 30 | const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls); |
| 26 | const splitLeftHeaderItems = useMemo<NativeHeaderItems>( | 31 | const splitLeftHeaderItems = useMemo<NativeHeaderItems>( |
| 27 | () => [ | 32 | () => [ |
| 28 | { | 33 | ...(!usesDuoHeader |
| 29 | // Match Mail's split-view detail toolbar: the first detail action sits | 34 | ? [ |
| 30 | // inside the content pane, not flush against the sidebar divider. | 35 | { |
| 31 | spacing: 18, | 36 | // Match Mail's split-view detail toolbar: the first detail action sits |
| 32 | type: "spacing" as const, | 37 | // inside the content pane, not flush against the sidebar divider. |
| 33 | }, | 38 | spacing: 18, |
| 39 | type: "spacing" as const, | ||
| 40 | }, | ||
| 41 | ] | ||
| 42 | : []), | ||
| 34 | ...(props.onReturnToThread | 43 | ...(props.onReturnToThread |
| 35 | ? [ | 44 | ? [ |
| 36 | withNativeGlassHeaderItem({ | 45 | withNativeGlassHeaderItem({ |
| ... | @@ -43,6 +52,7 @@ export function useThreadHeaderOptions(props: { | ... | @@ -43,6 +52,7 @@ export function useThreadHeaderOptions(props: { |
| 43 | ] | 52 | ] |
| 44 | : []), | 53 | : []), |
| 45 | withNativeGlassHeaderItem({ | 54 | withNativeGlassHeaderItem({ |
| 55 | axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, | ||
| 46 | accessibilityLabel: panes.primarySidebarVisible | 56 | accessibilityLabel: panes.primarySidebarVisible |
| 47 | ? "Maximize content" | 57 | ? "Maximize content" |
| 48 | : "Show thread sidebar", | 58 | : "Show thread sidebar", |
| ... | @@ -51,18 +61,31 @@ export function useThreadHeaderOptions(props: { | ... | @@ -51,18 +61,31 @@ export function useThreadHeaderOptions(props: { |
| 51 | type: "sfSymbol" as const, | 61 | type: "sfSymbol" as const, |
| 52 | }, | 62 | }, |
| 53 | identifier: "thread-left-sidebar", | 63 | identifier: "thread-left-sidebar", |
| 64 | label: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar", | ||
| 54 | onPress: togglePrimarySidebar, | 65 | onPress: togglePrimarySidebar, |
| 55 | type: "button" as const, | 66 | type: "button" as const, |
| 56 | }), | 67 | }), |
| 57 | withNativeGlassHeaderItem({ | 68 | ...(!usesDuoHeader |
| 58 | accessibilityLabel: "New task", | 69 | ? [ |
| 59 | icon: { name: "square.and.pencil", type: "sfSymbol" as const }, | 70 | withNativeGlassHeaderItem({ |
| 60 | identifier: "thread-left-new-task", | 71 | accessibilityLabel: "New task", |
| 61 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), | 72 | icon: { name: "square.and.pencil", type: "sfSymbol" as const }, |
| 62 | type: "button" as const, | 73 | identifier: "thread-left-new-task", |
| 63 | }), | 74 | label: "New task", |
| 75 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), | ||
| 76 | type: "button" as const, | ||
| 77 | }), | ||
| 78 | ] | ||
| 79 | : []), | ||
| 80 | ], | ||
| 81 | [ | ||
| 82 | panes.primarySidebarVisible, | ||
| 83 | props.onReturnToThread, | ||
| 84 | navigation, | ||
| 85 | togglePrimarySidebar, | ||
| 86 | usesDuoHeader, | ||
| 87 | usesNativeWorkspaceColumns, | ||
| 64 | ], | 88 | ], |
| 65 | [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], | ||
| 66 | ); | 89 | ); |
| 67 | // Deep links / cold starts land with Thread as the ONLY route, where the | 90 | // Deep links / cold starts land with Thread as the ONLY route, where the |
| 68 | // native back button does not render. Provide an explicit Home escape for | 91 | // native back button does not render. Provide an explicit Home escape for |
| ... | @@ -81,6 +104,41 @@ export function useThreadHeaderOptions(props: { | ... | @@ -81,6 +104,41 @@ export function useThreadHeaderOptions(props: { |
| 81 | [navigation], | 104 | [navigation], |
| 82 | ); | 105 | ); |
| 83 | 106 | ||
| 107 | const duoRightHeaderItems = useMemo<NativeHeaderItems>( | ||
| 108 | () => [ | ||
| 109 | ...threadCenterHeaderItems, | ||
| 110 | { type: "spacing", spacing: 8 }, | ||
| 111 | ...(layout.usesSplitView && | ||
| 112 | Platform.OS === "ios" && | ||
| 113 | !Platform.isPad && | ||
| 114 | !panes.primarySidebarVisible | ||
| 115 | ? [ | ||
| 116 | withNativeGlassHeaderItem({ | ||
| 117 | type: "button" as const, | ||
| 118 | axisBehavior: "verticalPreferred", | ||
| 119 | identifier: "thread-right-search", | ||
| 120 | label: "Search threads", | ||
| 121 | accessibilityLabel: "Search threads", | ||
| 122 | icon: { name: "magnifyingglass", type: "sfSymbol" as const }, | ||
| 123 | onPress: () => { | ||
| 124 | dispatchHardwareKeyboardCommand("focusSearch"); | ||
| 125 | }, | ||
| 126 | }), | ||
| 127 | ] | ||
| 128 | : []), | ||
| 129 | withNativeGlassHeaderItem({ | ||
| 130 | type: "button" as const, | ||
| 131 | axisBehavior: "verticalPreferred", | ||
| 132 | identifier: "thread-right-new-task", | ||
| 133 | label: "New task", | ||
| 134 | accessibilityLabel: "New task", | ||
| 135 | icon: { name: "square.and.pencil", type: "sfSymbol" as const }, | ||
| 136 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), | ||
| 137 | }), | ||
| 138 | ], | ||
| 139 | [navigation, threadCenterHeaderItems, layout.usesSplitView, panes.primarySidebarVisible], | ||
| 140 | ); | ||
| 141 | |||
| 84 | const options: AppNativeStackNavigationOptions = { | 142 | const options: AppNativeStackNavigationOptions = { |
| 85 | headerShown: true, | 143 | headerShown: true, |
| 86 | headerTitle: props.title, | 144 | headerTitle: props.title, |
| ... | @@ -104,7 +162,12 @@ export function useThreadHeaderOptions(props: { | ... | @@ -104,7 +162,12 @@ export function useThreadHeaderOptions(props: { |
| 104 | // the git controls on the RIGHT (no center items — center space is | 162 | // the git controls on the RIGHT (no center items — center space is |
| 105 | // reserved for future breadcrumbs/status). | 163 | // reserved for future breadcrumbs/status). |
| 106 | unstable_headerRightItems: () => | 164 | unstable_headerRightItems: () => |
| 107 | layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, | 165 | usesDuoHeader |
| 166 | ? duoRightHeaderItems | ||
| 167 | : layout.usesSplitView | ||
| 168 | ? threadCenterHeaderItems | ||
| 169 | : compactRightHeaderItems, | ||
| 170 | unstable_headerToolbarItems: () => [], | ||
| 108 | unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, | 171 | unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, |
| 109 | contentStyle: undefined, | 172 | contentStyle: undefined, |
| 110 | }; | 173 | }; |
| ... | @@ -116,7 +179,11 @@ export function useThreadHeaderOptions(props: { | ... | @@ -116,7 +179,11 @@ export function useThreadHeaderOptions(props: { |
| 116 | // callbacks also read state the items do not display (a "Push" item runs `push` or | 179 | // callbacks also read state the items do not display (a "Push" item runs `push` or |
| 117 | // `commit_push` depending on the default ref), so that state is keyed too. | 180 | // `commit_push` depending on the default ref), so that state is keyed too. |
| 118 | optionsVersion: [ | 181 | optionsVersion: [ |
| 119 | layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, | 182 | splitLeftHeaderItems, |
| 183 | threadCenterHeaderItems, | ||
| 184 | compactRightHeaderItems, | ||
| 185 | duoRightHeaderItems, | ||
| 186 | usesDuoHeader, | ||
| 120 | environmentId, | 187 | environmentId, |
| 121 | threadId, | 188 | threadId, |
| 122 | gitStatus?.isDefaultRef, | 189 | gitStatus?.isDefaultRef, |
apps/mobile/src/lib/layout.ts+37-10| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { scaledTypographyLineHeight } from "./appearancePreferences"; | 1 | import { scaledTypographyLineHeight } from "./appearancePreferences"; |
| 2 | import { MOBILE_TYPOGRAPHY } from "./typography"; | 2 | import { MOBILE_TYPOGRAPHY } from "./typography"; |
| 3 | import type { NativeLayoutMetrics } from "./reserved-regions"; | ||
| 3 | 4 | ||
| 4 | function clamp(value: number, min: number, max: number): number { | 5 | function clamp(value: number, min: number, max: number): number { |
| 5 | return Math.min(Math.max(value, min), max); | 6 | return Math.min(Math.max(value, min), max); |
| ... | @@ -56,6 +57,7 @@ export interface Layout { | ... | @@ -56,6 +57,7 @@ export interface Layout { |
| 56 | readonly variant: LayoutVariant; | 57 | readonly variant: LayoutVariant; |
| 57 | readonly usesSplitView: boolean; | 58 | readonly usesSplitView: boolean; |
| 58 | readonly listPaneWidth: number | null; | 59 | readonly listPaneWidth: number | null; |
| 60 | readonly listPaneGap?: number; | ||
| 59 | readonly shellPadding: number; | 61 | readonly shellPadding: number; |
| 60 | } | 62 | } |
| 61 | 63 | ||
| ... | @@ -87,9 +89,25 @@ export function deriveThreadFeedInitialContentInset(input: { | ... | @@ -87,9 +89,25 @@ export function deriveThreadFeedInitialContentInset(input: { |
| 87 | 89 | ||
| 88 | export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; | 90 | export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; |
| 89 | 91 | ||
| 90 | export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { | 92 | export function deriveLayout(input: { |
| 93 | readonly width: number; | ||
| 94 | readonly height: number; | ||
| 95 | readonly nativeMetrics?: NativeLayoutMetrics | null; | ||
| 96 | }): Layout { | ||
| 91 | const { width, height } = input; | 97 | const { width, height } = input; |
| 92 | const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; | 98 | const metrics = input.nativeMetrics; |
| 99 | const usableWidth = width - (metrics?.safeArea.left ?? 0) - (metrics?.safeArea.right ?? 0); | ||
| 100 | // UIKit's size class supports the shorter Duo inner display. Geometry remains | ||
| 101 | // a floor so a narrow multitasking window cannot squeeze both columns. | ||
| 102 | const hasDuoGeometry = | ||
| 103 | metrics && | ||
| 104 | (metrics.verticalBarEdge !== "none" || | ||
| 105 | metrics.reservedRegions.some((region) => region.kind === "division")); | ||
| 106 | const wideEnoughForSplit = hasDuoGeometry | ||
| 107 | ? metrics.horizontalSizeClass === "regular" && | ||
| 108 | usableWidth >= SPLIT_SIDEBAR_MIN_WIDTH + 320 && | ||
| 109 | height >= 400 | ||
| 110 | : width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; | ||
| 93 | 111 | ||
| 94 | if (!wideEnoughForSplit) { | 112 | if (!wideEnoughForSplit) { |
| 95 | return { | 113 | return { |
| ... | @@ -100,14 +118,20 @@ export function deriveLayout(input: { readonly width: number; readonly height: n | ... | @@ -100,14 +118,20 @@ export function deriveLayout(input: { readonly width: number; readonly height: n |
| 100 | }; | 118 | }; |
| 101 | } | 119 | } |
| 102 | 120 | ||
| 121 | const division = metrics?.reservedRegions.find( | ||
| 122 | (region) => | ||
| 123 | region.kind === "division" && | ||
| 124 | region.height >= height / 2 && | ||
| 125 | region.x - metrics.safeArea.left >= SPLIT_SIDEBAR_MIN_WIDTH && | ||
| 126 | width - region.x - region.width - metrics.safeArea.right >= 320, | ||
| 127 | ); | ||
| 103 | return { | 128 | return { |
| 104 | variant: "split", | 129 | variant: "split", |
| 105 | usesSplitView: true, | 130 | usesSplitView: true, |
| 106 | listPaneWidth: clamp( | 131 | listPaneWidth: |
| 107 | Math.round(width * 0.32), | 132 | division?.x ?? |
| 108 | SPLIT_SIDEBAR_MIN_WIDTH, | 133 | clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH), |
| 109 | SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH, | 134 | ...(division ? { listPaneGap: division.width } : {}), |
| 110 | ), | ||
| 111 | shellPadding: 0, | 135 | shellPadding: 0, |
| 112 | }; | 136 | }; |
| 113 | } | 137 | } |
| ... | @@ -125,7 +149,7 @@ export function deriveWorkspacePaneLayout(input: { | ... | @@ -125,7 +149,7 @@ export function deriveWorkspacePaneLayout(input: { |
| 125 | const preferredPrimarySidebarVisible = | 149 | const preferredPrimarySidebarVisible = |
| 126 | input.layout.usesSplitView && input.primarySidebarPreferredVisible; | 150 | input.layout.usesSplitView && input.primarySidebarPreferredVisible; |
| 127 | const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible | 151 | const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible |
| 128 | ? (input.layout.listPaneWidth ?? 0) | 152 | ? (input.layout.listPaneWidth ?? 0) + (input.layout.listPaneGap ?? 0) |
| 129 | : 0; | 153 | : 0; |
| 130 | 154 | ||
| 131 | if (auxiliaryPaneRole === "inspector") { | 155 | if (auxiliaryPaneRole === "inspector") { |
| ... | @@ -141,11 +165,14 @@ export function deriveWorkspacePaneLayout(input: { | ... | @@ -141,11 +165,14 @@ export function deriveWorkspacePaneLayout(input: { |
| 141 | auxiliaryPaneVisible && | 165 | auxiliaryPaneVisible && |
| 142 | fileInspector.width !== null && | 166 | fileInspector.width !== null && |
| 143 | input.layout.listPaneWidth !== null && | 167 | input.layout.listPaneWidth !== null && |
| 144 | viewportWidth - input.layout.listPaneWidth - fileInspector.width < | 168 | viewportWidth - |
| 169 | input.layout.listPaneWidth - | ||
| 170 | (input.layout.listPaneGap ?? 0) - | ||
| 171 | fileInspector.width < | ||
| 145 | FILE_INSPECTOR_MIN_MAIN_WIDTH; | 172 | FILE_INSPECTOR_MIN_MAIN_WIDTH; |
| 146 | const primarySidebarVisible = | 173 | const primarySidebarVisible = |
| 147 | preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; | 174 | preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; |
| 148 | const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0; | 175 | const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0; |
| 149 | 176 | ||
| 150 | return { | 177 | return { |
| 151 | primarySidebarVisible, | 178 | primarySidebarVisible, |
apps/mobile/src/lib/reserved-regions.test.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { deriveBottomControlInsets, type NativeLayoutMetrics } from "./reserved-regions"; | ||
| 3 | import { deriveLayout, deriveWorkspacePaneLayout } from "./layout"; | ||
| 4 | |||
| 5 | const innerDisplay: NativeLayoutMetrics = { | ||
| 6 | width: 960, | ||
| 7 | height: 560, | ||
| 8 | horizontalSizeClass: "regular", | ||
| 9 | verticalBarEdge: "right", | ||
| 10 | safeArea: { top: 0, bottom: 20, left: 8, right: 72 }, | ||
| 11 | reservedRegions: [], | ||
| 12 | }; | ||
| 13 | |||
| 14 | describe("bottom controls around reserved regions", () => { | ||
| 15 | it("respects asymmetric side controls without reserving the bottom twice", () => { | ||
| 16 | expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 }); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("leaves ordinary non-native clients unchanged", () => { | ||
| 20 | expect(deriveBottomControlInsets(null)).toEqual({ left: 0, right: 0, bottom: 0 }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("keeps the editor entirely on one side of a book fold", () => { | ||
| 24 | const insets = deriveBottomControlInsets({ | ||
| 25 | ...innerDisplay, | ||
| 26 | reservedRegions: [{ kind: "division", x: 456, y: 0, width: 48, height: 560 }], | ||
| 27 | }); | ||
| 28 | expect(insets).toEqual({ left: 8, right: 504, bottom: 0 }); | ||
| 29 | expect(innerDisplay.width - insets.left - insets.right).toBeGreaterThan(320); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("keeps controls below a horizontal fold in a hands-free pose", () => { | ||
| 33 | expect( | ||
| 34 | deriveBottomControlInsets({ | ||
| 35 | ...innerDisplay, | ||
| 36 | reservedRegions: [{ kind: "division", x: 0, y: 240, width: 960, height: 40 }], | ||
| 37 | }), | ||
| 38 | ).toEqual({ left: 8, right: 72, bottom: 0 }); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("moves a control away from a bottom camera while leaving a top camera alone", () => { | ||
| 42 | expect( | ||
| 43 | deriveBottomControlInsets({ | ||
| 44 | ...innerDisplay, | ||
| 45 | reservedRegions: [ | ||
| 46 | { kind: "occlusion", x: 8, y: 0, width: 60, height: 60 }, | ||
| 47 | { kind: "occlusion", x: 8, y: 510, width: 60, height: 50 }, | ||
| 48 | ], | ||
| 49 | }), | ||
| 50 | ).toEqual({ left: 68, right: 72, bottom: 0 }); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("restores the full editor width when the device opens flat", () => { | ||
| 54 | const folded = { | ||
| 55 | ...innerDisplay, | ||
| 56 | reservedRegions: [{ kind: "division" as const, x: 456, y: 0, width: 48, height: 560 }], | ||
| 57 | }; | ||
| 58 | expect(deriveBottomControlInsets(folded)).not.toEqual(deriveBottomControlInsets(innerDisplay)); | ||
| 59 | expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 }); | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | describe("Duo workspace sizing", () => { | ||
| 64 | it("shows both columns on a short regular-width inner display", () => { | ||
| 65 | const layout = deriveLayout({ ...innerDisplay, nativeMetrics: innerDisplay }); | ||
| 66 | expect(layout.usesSplitView).toBe(true); | ||
| 67 | expect( | ||
| 68 | deriveWorkspacePaneLayout({ | ||
| 69 | layout, | ||
| 70 | viewportWidth: innerDisplay.width, | ||
| 71 | primarySidebarPreferredVisible: true, | ||
| 72 | auxiliaryPanePreferredVisible: false, | ||
| 73 | }).contentPaneWidth, | ||
| 74 | ).toBeGreaterThan(320); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("keeps an ordinary phone compact when it rotates", () => { | ||
| 78 | expect(deriveLayout({ width: 852, height: 393 }).usesSplitView).toBe(false); | ||
| 79 | for (const [width, height] of [ | ||
| 80 | [932, 430], | ||
| 81 | [650, 500], | ||
| 82 | ]) { | ||
| 83 | const metrics: NativeLayoutMetrics = { | ||
| 84 | ...innerDisplay, | ||
| 85 | width: width!, | ||
| 86 | height: height!, | ||
| 87 | verticalBarEdge: "none", | ||
| 88 | safeArea: { top: 0, bottom: 20, left: 59, right: 59 }, | ||
| 89 | }; | ||
| 90 | expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false); | ||
| 91 | const cameraMetrics = { | ||
| 92 | ...metrics, | ||
| 93 | reservedRegions: [{ kind: "occlusion" as const, x: 0, y: 0, width: 60, height: 60 }], | ||
| 94 | }; | ||
| 95 | expect(deriveLayout({ ...cameraMetrics, nativeMetrics: cameraMetrics }).usesSplitView).toBe( | ||
| 96 | false, | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | expect( | ||
| 100 | deriveLayout({ | ||
| 101 | width: 960, | ||
| 102 | height: 560, | ||
| 103 | nativeMetrics: { | ||
| 104 | ...innerDisplay, | ||
| 105 | horizontalSizeClass: "compact", | ||
| 106 | }, | ||
| 107 | }).usesSplitView, | ||
| 108 | ).toBe(false); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("requires enough safe width for both panes in a multitasking window", () => { | ||
| 112 | const metrics = { ...innerDisplay, width: 660 }; | ||
| 113 | expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("aligns the panes to the book fold and accounts for the reserved gap", () => { | ||
| 117 | const metrics = { | ||
| 118 | ...innerDisplay, | ||
| 119 | reservedRegions: [{ kind: "division" as const, x: 456, y: 0, width: 48, height: 560 }], | ||
| 120 | }; | ||
| 121 | const layout = deriveLayout({ ...metrics, nativeMetrics: metrics }); | ||
| 122 | expect(layout.listPaneWidth).toBe(456); | ||
| 123 | expect(layout.listPaneGap).toBe(48); | ||
| 124 | const panes = deriveWorkspacePaneLayout({ | ||
| 125 | layout, | ||
| 126 | viewportWidth: 960, | ||
| 127 | primarySidebarPreferredVisible: true, | ||
| 128 | auxiliaryPanePreferredVisible: false, | ||
| 129 | }); | ||
| 130 | expect(panes.contentPaneWidth).toBe(456); | ||
| 131 | expect( | ||
| 132 | deriveWorkspacePaneLayout({ | ||
| 133 | layout, | ||
| 134 | viewportWidth: 960, | ||
| 135 | primarySidebarPreferredVisible: false, | ||
| 136 | auxiliaryPanePreferredVisible: false, | ||
| 137 | }).contentPaneWidth, | ||
| 138 | ).toBe(960); | ||
| 139 | }); | ||
| 140 | }); | ||
apps/mobile/src/lib/reserved-regions.ts+40-1| ... | @@ -1,12 +1,51 @@ | ... | @@ -1,12 +1,51 @@ |
| 1 | /** Geometry of the body inside a native navigation column. */ | 1 | export interface ReservedRegion { |
| 2 | readonly kind: "division" | "occlusion"; | ||
| 3 | readonly x: number; | ||
| 4 | readonly y: number; | ||
| 5 | readonly width: number; | ||
| 6 | readonly height: number; | ||
| 7 | } | ||
| 8 | |||
| 2 | export interface NativeLayoutMetrics { | 9 | export interface NativeLayoutMetrics { |
| 3 | readonly width: number; | 10 | readonly width: number; |
| 4 | readonly height: number; | 11 | readonly height: number; |
| 12 | readonly hasHinge?: boolean; | ||
| 5 | readonly horizontalSizeClass: "compact" | "regular"; | 13 | readonly horizontalSizeClass: "compact" | "regular"; |
| 14 | readonly verticalBarEdge: "none" | "left" | "right"; | ||
| 6 | readonly safeArea: { | 15 | readonly safeArea: { |
| 7 | readonly top: number; | 16 | readonly top: number; |
| 8 | readonly bottom: number; | 17 | readonly bottom: number; |
| 9 | readonly left: number; | 18 | readonly left: number; |
| 10 | readonly right: number; | 19 | readonly right: number; |
| 11 | }; | 20 | }; |
| 21 | readonly reservedRegions: ReadonlyArray<ReservedRegion>; | ||
| 22 | } | ||
| 23 | |||
| 24 | /** | ||
| 25 | * Keep bottom controls in one usable region. A book-like fold selects the | ||
| 26 | * larger side; a horizontal fold leaves the controls on the lower display. | ||
| 27 | * Text and other scrollable content can continue to use the full canvas. | ||
| 28 | */ | ||
| 29 | export function deriveBottomControlInsets(metrics: NativeLayoutMetrics | null) { | ||
| 30 | if (!metrics) return { left: 0, right: 0, bottom: 0 }; | ||
| 31 | let left = metrics.safeArea.left; | ||
| 32 | let right = metrics.width - metrics.safeArea.right; | ||
| 33 | let bottom = metrics.height; | ||
| 34 | for (const region of metrics.reservedRegions) { | ||
| 35 | const x = Math.max(left, region.x); | ||
| 36 | const endX = Math.min(right, region.x + region.width); | ||
| 37 | const y = Math.max(0, region.y); | ||
| 38 | const endY = Math.min(bottom, region.y + region.height); | ||
| 39 | if (endX <= x || endY <= y) continue; | ||
| 40 | if (region.kind === "division" && region.height >= metrics.height / 2) { | ||
| 41 | if (right - endX >= x - left) left = endX; | ||
| 42 | else right = x; | ||
| 43 | } else if (endY >= bottom - metrics.safeArea.bottom) { | ||
| 44 | // An occlusion at the bottom can also occupy just one corner. | ||
| 45 | if (region.width >= right - left) bottom = y; | ||
| 46 | else if (right - endX >= x - left) left = endX; | ||
| 47 | else right = x; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | return { left, right: metrics.width - right, bottom: metrics.height - bottom }; | ||
| 12 | } | 51 | } |
apps/mobile/src/native/NativeColumnContent.ios.tsx+5-1| ... | @@ -9,12 +9,16 @@ import { NativeLayoutObserver } from "./NativeLayoutObserver"; | ... | @@ -9,12 +9,16 @@ import { NativeLayoutObserver } from "./NativeLayoutObserver"; |
| 9 | export function NativeColumnContent(props: { | 9 | export function NativeColumnContent(props: { |
| 10 | readonly children: ReactNode; | 10 | readonly children: ReactNode; |
| 11 | readonly primary?: boolean; | 11 | readonly primary?: boolean; |
| 12 | readonly insetHorizontally?: boolean; | ||
| 12 | }) { | 13 | }) { |
| 13 | const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null); | 14 | const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null); |
| 14 | return ( | 15 | return ( |
| 15 | <View className={props.primary ? "flex-1 bg-drawer" : "flex-1 bg-screen"}> | 16 | <View className={props.primary ? "flex-1 bg-drawer" : "flex-1 bg-screen"}> |
| 16 | <SafeAreaProvider style={{ flex: 1 }}> | 17 | <SafeAreaProvider style={{ flex: 1 }}> |
| 17 | <SafeAreaView edges={["left", "right"]} style={{ flex: 1 }}> | 18 | <SafeAreaView |
| 19 | edges={props.insetHorizontally === false ? [] : ["left", "right"]} | ||
| 20 | style={{ flex: 1 }} | ||
| 21 | > | ||
| 18 | {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} | 22 | {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} |
| 19 | <View style={{ flex: 1 }}> | 23 | <View style={{ flex: 1 }}> |
| 20 | <NativeLayoutObserver onChange={setMetrics} /> | 24 | <NativeLayoutObserver onChange={setMetrics} /> |
apps/mobile/src/native/NativeHeaderToolbar.tsx+25-7| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | type ReactNode, | 14 | type ReactNode, |
| 15 | } from "react"; | 15 | } from "react"; |
| 16 | import type { ColorValue } from "react-native"; | 16 | import type { ColorValue } from "react-native"; |
| 17 | import type { HeaderBarButtonSearchBarPlacementItem } from "react-native-screens"; | ||
| 17 | 18 | ||
| 18 | function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null { | 19 | function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null { |
| 19 | return useNavigation<NativeStackNavigationProp<ParamListBase>>(); | 20 | return useNavigation<NativeStackNavigationProp<ParamListBase>>(); |
| ... | @@ -34,8 +35,13 @@ function labelFromChildren(children: ReactNode): string { | ... | @@ -34,8 +35,13 @@ function labelFromChildren(children: ReactNode): string { |
| 34 | type NativeStackHeaderIcon = NonNullable< | 35 | type NativeStackHeaderIcon = NonNullable< |
| 35 | Extract<NativeStackHeaderItem, { type: "button" }>["icon"] | 36 | Extract<NativeStackHeaderItem, { type: "button" }>["icon"] |
| 36 | >; | 37 | >; |
| 38 | type NativeStackHeaderAxisBehavior = Extract< | ||
| 39 | NativeStackHeaderItem, | ||
| 40 | { type: "button" } | ||
| 41 | >["axisBehavior"]; | ||
| 42 | type NativeToolbarItem = NativeStackHeaderItem | HeaderBarButtonSearchBarPlacementItem; | ||
| 37 | type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { | 43 | type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { |
| 38 | unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; | 44 | unstable_headerToolbarItems?: () => NativeToolbarItem[]; |
| 39 | }; | 45 | }; |
| 40 | 46 | ||
| 41 | function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { | 47 | function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { |
| ... | @@ -112,7 +118,7 @@ function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu" | ... | @@ -112,7 +118,7 @@ function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu" |
| 112 | return items; | 118 | return items; |
| 113 | } | 119 | } |
| 114 | 120 | ||
| 115 | function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { | 121 | function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { |
| 116 | if (!isValidElement<ToolbarElementProps>(child)) { | 122 | if (!isValidElement<ToolbarElementProps>(child)) { |
| 117 | return null; | 123 | return null; |
| 118 | } | 124 | } |
| ... | @@ -121,15 +127,22 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { | ... | @@ -121,15 +127,22 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { |
| 121 | if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) { | 127 | if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) { |
| 122 | return { type: "custom", element: child.props.children }; | 128 | return { type: "custom", element: child.props.children }; |
| 123 | } | 129 | } |
| 130 | if (typeName === "NativeHeaderToolbarSearchBarSlot") { | ||
| 131 | return { type: "searchBarPlacement" }; | ||
| 132 | } | ||
| 124 | 133 | ||
| 125 | if (typeName === "NativeHeaderToolbarButton") { | 134 | if (typeName === "NativeHeaderToolbarButton") { |
| 126 | return { | 135 | return { |
| 127 | type: "button", | 136 | type: "button", |
| 128 | label: typeof child.props.label === "string" ? child.props.label : "", | 137 | label: |
| 138 | typeof child.props.label === "string" | ||
| 139 | ? child.props.label | ||
| 140 | : String(child.props.accessibilityLabel ?? ""), | ||
| 129 | accessibilityLabel: | 141 | accessibilityLabel: |
| 130 | typeof child.props.accessibilityLabel === "string" | 142 | typeof child.props.accessibilityLabel === "string" |
| 131 | ? child.props.accessibilityLabel | 143 | ? child.props.accessibilityLabel |
| 132 | : undefined, | 144 | : undefined, |
| 145 | axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior, | ||
| 133 | disabled: Boolean(child.props.disabled), | 146 | disabled: Boolean(child.props.disabled), |
| 134 | icon: iconFromProp(child.props.icon), | 147 | icon: iconFromProp(child.props.icon), |
| 135 | onPress: | 148 | onPress: |
| ... | @@ -173,8 +186,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { | ... | @@ -173,8 +186,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { |
| 173 | return null; | 186 | return null; |
| 174 | } | 187 | } |
| 175 | 188 | ||
| 176 | function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { | 189 | function collectToolbarItems(children: ReactNode): NativeToolbarItem[] { |
| 177 | const items: NativeStackHeaderItem[] = []; | 190 | const items: NativeToolbarItem[] = []; |
| 178 | Children.forEach(children, (child) => { | 191 | Children.forEach(children, (child) => { |
| 179 | const item = convertToolbarChild(child); | 192 | const item = convertToolbarChild(child); |
| 180 | if (item) { | 193 | if (item) { |
| ... | @@ -211,13 +224,17 @@ function NativeHeaderToolbarRoot(props: { | ... | @@ -211,13 +224,17 @@ function NativeHeaderToolbarRoot(props: { |
| 211 | } as NativeStackOptionsWithToolbar); | 224 | } as NativeStackOptionsWithToolbar); |
| 212 | }; | 225 | }; |
| 213 | } | 226 | } |
| 227 | // UIKit's search placement item belongs to the toolbar, never a header group. | ||
| 228 | const headerItems = items.filter( | ||
| 229 | (item): item is NativeStackHeaderItem => item.type !== "searchBarPlacement", | ||
| 230 | ); | ||
| 214 | if (props.placement === "left") { | 231 | if (props.placement === "left") { |
| 215 | navigation.setOptions({ unstable_headerLeftItems: () => items }); | 232 | navigation.setOptions({ unstable_headerLeftItems: () => headerItems }); |
| 216 | return () => { | 233 | return () => { |
| 217 | navigation.setOptions({ unstable_headerLeftItems: () => [] }); | 234 | navigation.setOptions({ unstable_headerLeftItems: () => [] }); |
| 218 | }; | 235 | }; |
| 219 | } | 236 | } |
| 220 | navigation.setOptions({ unstable_headerRightItems: () => items }); | 237 | navigation.setOptions({ unstable_headerRightItems: () => headerItems }); |
| 221 | return () => { | 238 | return () => { |
| 222 | navigation.setOptions({ unstable_headerRightItems: () => [] }); | 239 | navigation.setOptions({ unstable_headerRightItems: () => [] }); |
| 223 | }; | 240 | }; |
| ... | @@ -228,6 +245,7 @@ function NativeHeaderToolbarRoot(props: { | ... | @@ -228,6 +245,7 @@ function NativeHeaderToolbarRoot(props: { |
| 228 | 245 | ||
| 229 | function NativeHeaderToolbarButton(_props: { | 246 | function NativeHeaderToolbarButton(_props: { |
| 230 | readonly accessibilityLabel?: string; | 247 | readonly accessibilityLabel?: string; |
| 248 | readonly axisBehavior?: NativeStackHeaderAxisBehavior; | ||
| 231 | readonly disabled?: boolean; | 249 | readonly disabled?: boolean; |
| 232 | readonly icon?: string; | 250 | readonly icon?: string; |
| 233 | readonly label?: string; | 251 | readonly label?: string; |
apps/mobile/src/native/NativeSheetContent.tsx created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; | ||
| 3 | |||
| 4 | import { useNativeWorkspaceColumnsSupported } from "./NativeWorkspaceColumns"; | ||
| 5 | |||
| 6 | /** Reserve the sheet's own side bars without moving its native navigation chrome. */ | ||
| 7 | export function NativeSheetContent(props: { readonly children: ReactNode }) { | ||
| 8 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | ||
| 9 | if (!usesNativeWorkspaceColumns) return <>{props.children}</>; | ||
| 10 | |||
| 11 | // A floating sheet can have different insets from the workspace underneath it. | ||
| 12 | return ( | ||
| 13 | <SafeAreaProvider style={{ flex: 1 }}> | ||
| 14 | <SafeAreaView edges={["left", "right"]} style={{ flex: 1 }}> | ||
| 15 | {props.children} | ||
| 16 | </SafeAreaView> | ||
| 17 | </SafeAreaProvider> | ||
| 18 | ); | ||
| 19 | } | ||
apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx+25-3| ... | @@ -1,7 +1,29 @@ | ... | @@ -1,7 +1,29 @@ |
| 1 | import { requireOptionalNativeModule } from "expo"; | 1 | import { requireOptionalNativeModule } from "expo"; |
| 2 | import { Platform } from "react-native"; | 2 | import { Platform } from "react-native"; |
| 3 | const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>( | 3 | import { useNativeLayoutMetrics } from "./native-layout-metrics"; |
| 4 | "T3NativeControls", | 4 | |
| 5 | ); | 5 | const nativeControls = requireOptionalNativeModule<{ |
| 6 | readonly supportsWorkspaceColumns?: boolean; | ||
| 7 | readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown }; | ||
| 8 | }>("T3NativeControls"); | ||
| 6 | export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = | 9 | export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = |
| 7 | Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true; | 10 | Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true; |
| 11 | |||
| 12 | export function useNativeWorkspaceColumnsReady() { | ||
| 13 | const metrics = useNativeLayoutMetrics(); | ||
| 14 | // Choose the phone's native host before attaching any screens. Older clients | ||
| 15 | // without the observer can immediately use the compact navigator. | ||
| 16 | return ( | ||
| 17 | (Platform.OS === "ios" && Platform.isPad) || | ||
| 18 | !nativeControls?.ViewPrototypes?.T3NativeControls_LayoutMetrics || | ||
| 19 | metrics !== null | ||
| 20 | ); | ||
| 21 | } | ||
| 22 | |||
| 23 | export function useNativeWorkspaceColumnsSupported() { | ||
| 24 | const metrics = useNativeLayoutMetrics(); | ||
| 25 | return ( | ||
| 26 | NATIVE_WORKSPACE_COLUMNS_SUPPORTED || | ||
| 27 | (nativeControls?.supportsWorkspaceColumns === true && metrics?.hasHinge === true) | ||
| 28 | ); | ||
| 29 | } |
apps/mobile/src/native/NativeWorkspaceColumns.tsx+8| ... | @@ -1,3 +1,11 @@ | ... | @@ -1,3 +1,11 @@ |
| 1 | import { Platform } from "react-native"; | 1 | import { Platform } from "react-native"; |
| 2 | // Android retains its existing navigator. The iOS file checks native availability. | 2 | // Android retains its existing navigator. The iOS file checks native availability. |
| 3 | export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad; | 3 | export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad; |
| 4 | |||
| 5 | export function useNativeWorkspaceColumnsReady() { | ||
| 6 | return true; | ||
| 7 | } | ||
| 8 | |||
| 9 | export function useNativeWorkspaceColumnsSupported() { | ||
| 10 | return NATIVE_WORKSPACE_COLUMNS_SUPPORTED; | ||
| 11 | } |
apps/mobile/src/native/StackHeader.tsx+2| ... | @@ -19,6 +19,8 @@ export type AppNativeStackNavigationOptions = Omit< | ... | @@ -19,6 +19,8 @@ export type AppNativeStackNavigationOptions = Omit< |
| 19 | NativeStackNavigationOptions, | 19 | NativeStackNavigationOptions, |
| 20 | "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems" | 20 | "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems" |
| 21 | > & { | 21 | > & { |
| 22 | /** Nested navigator leaves apply their own horizontal safe area. */ | ||
| 23 | readonly nativeContentInsetHorizontally?: boolean; | ||
| 22 | readonly headerTintColor?: string | ColorValue; | 24 | readonly headerTintColor?: string | ColorValue; |
| 23 | readonly unstable_headerCenterItems?: unknown; | 25 | readonly unstable_headerCenterItems?: unknown; |
| 24 | readonly unstable_headerLeftItems?: unknown; | 26 | readonly unstable_headerLeftItems?: unknown; |
apps/mobile/src/native/V5StackHeader.ios.tsx+23-1| ... | @@ -6,6 +6,7 @@ import type { | ... | @@ -6,6 +6,7 @@ import type { |
| 6 | import { isValidElement, useRef } from "react"; | 6 | import { isValidElement, useRef } from "react"; |
| 7 | import { Platform, StyleSheet } from "react-native"; | 7 | import { Platform, StyleSheet } from "react-native"; |
| 8 | import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; | 8 | import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; |
| 9 | import { useNativeLayoutMetrics } from "../native/native-layout-metrics"; | ||
| 9 | import { | 10 | import { |
| 10 | SearchBar, | 11 | SearchBar, |
| 11 | Stack, | 12 | Stack, |
| ... | @@ -110,6 +111,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte | ... | @@ -110,6 +111,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte |
| 110 | id, | 111 | id, |
| 111 | identifier: item.identifier, | 112 | identifier: item.identifier, |
| 112 | title: item.label || item.accessibilityLabel || undefined, | 113 | title: item.label || item.accessibilityLabel || undefined, |
| 114 | axisBehavior: item.axisBehavior, | ||
| 113 | icon: convertIcon(item.icon), | 115 | icon: convertIcon(item.icon), |
| 114 | menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), | 116 | menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), |
| 115 | hidesSharedBackground: item.hidesSharedBackground, | 117 | hidesSharedBackground: item.hidesSharedBackground, |
| ... | @@ -120,6 +122,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte | ... | @@ -120,6 +122,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte |
| 120 | id, | 122 | id, |
| 121 | identifier: item.identifier, | 123 | identifier: item.identifier, |
| 122 | title: item.label || item.accessibilityLabel || undefined, | 124 | title: item.label || item.accessibilityLabel || undefined, |
| 125 | axisBehavior: item.axisBehavior, | ||
| 123 | icon: convertIcon(item.icon), | 126 | icon: convertIcon(item.icon), |
| 124 | onPress: item.disabled ? undefined : item.onPress, | 127 | onPress: item.disabled ? undefined : item.onPress, |
| 125 | hidesSharedBackground: item.hidesSharedBackground, | 128 | hidesSharedBackground: item.hidesSharedBackground, |
| ... | @@ -136,6 +139,7 @@ export function V5StackHeader(props: { | ... | @@ -136,6 +139,7 @@ export function V5StackHeader(props: { |
| 136 | }) { | 139 | }) { |
| 137 | const { options } = props; | 140 | const { options } = props; |
| 138 | const theme = useMobileNavigationTheme(); | 141 | const theme = useMobileNavigationTheme(); |
| 142 | const nativeMetrics = useNativeLayoutMetrics(); | ||
| 139 | const searchRef = useRef<SearchBarCommands>(null); | 143 | const searchRef = useRef<SearchBarCommands>(null); |
| 140 | const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; | 144 | const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; |
| 141 | const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); | 145 | const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); |
| ... | @@ -162,6 +166,7 @@ export function V5StackHeader(props: { | ... | @@ -162,6 +166,7 @@ export function V5StackHeader(props: { |
| 162 | bottom.push({ | 166 | bottom.push({ |
| 163 | type: "item", | 167 | type: "item", |
| 164 | id: mailSearch.filterButtonId ?? "filter", | 168 | id: mailSearch.filterButtonId ?? "filter", |
| 169 | axisBehavior: bottomSearch ? "horizontalOnly" : undefined, | ||
| 165 | icon: { | 170 | icon: { |
| 166 | type: "sfSymbol", | 171 | type: "sfSymbol", |
| 167 | name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", | 172 | name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", |
| ... | @@ -171,7 +176,12 @@ export function V5StackHeader(props: { | ... | @@ -171,7 +176,12 @@ export function V5StackHeader(props: { |
| 171 | if (bottomSearch) { | 176 | if (bottomSearch) { |
| 172 | if (bottom.length) | 177 | if (bottom.length) |
| 173 | bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); | 178 | bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); |
| 174 | bottom.push({ type: "item", id: "home-search", searchBarPlacement: true }); | 179 | bottom.push({ |
| 180 | type: "item", | ||
| 181 | id: "home-search", | ||
| 182 | axisBehavior: "horizontalOnly", | ||
| 183 | searchBarPlacement: true, | ||
| 184 | }); | ||
| 175 | if (mailSearch.onComposePress) | 185 | if (mailSearch.onComposePress) |
| 176 | bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); | 186 | bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); |
| 177 | } | 187 | } |
| ... | @@ -179,10 +189,22 @@ export function V5StackHeader(props: { | ... | @@ -179,10 +189,22 @@ export function V5StackHeader(props: { |
| 179 | bottom.push({ | 189 | bottom.push({ |
| 180 | type: "item", | 190 | type: "item", |
| 181 | id: mailSearch.composeButtonId ?? "compose", | 191 | id: mailSearch.composeButtonId ?? "compose", |
| 192 | axisBehavior: bottomSearch ? "horizontalOnly" : undefined, | ||
| 182 | icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, | 193 | icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, |
| 183 | onPress: mailSearch.onComposePress, | 194 | onPress: mailSearch.onComposePress, |
| 184 | }); | 195 | }); |
| 185 | } | 196 | } |
| 197 | // Expanded Duo panes put toolbar actions in the side bar. Folded phones | ||
| 198 | // keep their bottom search toolbar. UIKit keeps the | ||
| 199 | // primary column's navigation items beside its title and the detail's in | ||
| 200 | // the rail; a conventional UIToolbar can otherwise leave them inaccessible. | ||
| 201 | if ( | ||
| 202 | nativeMetrics?.horizontalSizeClass === "regular" && | ||
| 203 | nativeMetrics.verticalBarEdge !== "none" | ||
| 204 | ) { | ||
| 205 | trailing.push(...bottom); | ||
| 206 | bottom.length = 0; | ||
| 207 | } | ||
| 186 | const left = options.headerLeft?.(itemProps); | 208 | const left = options.headerLeft?.(itemProps); |
| 187 | const right = options.headerRight?.(itemProps); | 209 | const right = options.headerRight?.(itemProps); |
| 188 | if (!options.unstable_headerLeftItems && isValidElement(left)) | 210 | if (!options.unstable_headerLeftItems && isValidElement(left)) |
apps/mobile/src/native/createV5StackNavigator.ios.tsx+9-1| ... | @@ -24,6 +24,7 @@ import { useCallback, useRef, useState, type ComponentProps } from "react"; | ... | @@ -24,6 +24,7 @@ import { useCallback, useRef, useState, type ComponentProps } from "react"; |
| 24 | import { StyleSheet, View } from "react-native"; | 24 | import { StyleSheet, View } from "react-native"; |
| 25 | import { FormSheet, Stack } from "react-native-screens"; | 25 | import { FormSheet, Stack } from "react-native-screens"; |
| 26 | import { V5StackHeader } from "./V5StackHeader.ios"; | 26 | import { V5StackHeader } from "./V5StackHeader.ios"; |
| 27 | import type { AppNativeStackNavigationOptions } from "./StackHeader"; | ||
| 27 | import { NativeColumnContent } from "./NativeColumnContent.ios"; | 28 | import { NativeColumnContent } from "./NativeColumnContent.ios"; |
| 28 | import { | 29 | import { |
| 29 | nativeStackPopAction, | 30 | nativeStackPopAction, |
| ... | @@ -232,7 +233,14 @@ export function V5CardStackView(props: V5StackViewProps) { | ... | @@ -232,7 +233,14 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 232 | : index > 0 | 233 | : index > 0 |
| 233 | } | 234 | } |
| 234 | /> | 235 | /> |
| 235 | <NativeColumnContent>{descriptor.render()}</NativeColumnContent> | 236 | <NativeColumnContent |
| 237 | insetHorizontally={ | ||
| 238 | (descriptor.options as AppNativeStackNavigationOptions) | ||
| 239 | .nativeContentInsetHorizontally | ||
| 240 | } | ||
| 241 | > | ||
| 242 | {descriptor.render()} | ||
| 243 | </NativeColumnContent> | ||
| 236 | </NavigationRouteContext> | 244 | </NavigationRouteContext> |
| 237 | </NavigationContext> | 245 | </NavigationContext> |
| 238 | </Stack.Screen> | 246 | </Stack.Screen> |
apps/mobile/src/native/use-native-mail-search-toolbar.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import { useNativeLayoutMetrics } from "./native-layout-metrics"; | ||
| 2 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native-glass"; | ||
| 3 | |||
| 4 | /** Folded phones keep search in the bottom toolbar; expanded Duo panes use the side bar. */ | ||
| 5 | export function useNativeMailSearchToolbar() { | ||
| 6 | const metrics = useNativeLayoutMetrics(); | ||
| 7 | return ( | ||
| 8 | NATIVE_LIQUID_GLASS_SUPPORTED && | ||
| 9 | (metrics === null || | ||
| 10 | metrics.horizontalSizeClass === "compact" || | ||
| 11 | metrics.verticalBarEdge === "none") | ||
| 12 | ); | ||
| 13 | } | ||
docs/operations/mobile-screens-fork.md+7-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | # Building the mobile Screens dependency | 1 | # Building the mobile Screens dependency |
| 2 | 2 | ||
| 3 | The mobile v5 stack uses a committed tarball from the | 3 | The mobile v5 stack uses a committed tarball from the |
| 4 | [T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3-v5.0.0-t3.7). | 4 | [T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3-v5.0.0-t3.8). |
| 5 | The fork owns the integration changes; the app does not patch this package. | 5 | The fork owns the integration changes; the app does not patch this package. |
| 6 | Each archive includes compiled JavaScript, declarations, native source and | 6 | Each archive includes compiled JavaScript, declarations, native source and |
| 7 | `t3-fork.json` recording its version, source commit and upstream base. | 7 | `t3-fork.json` recording its version, source commit and upstream base. |
| ... | @@ -9,7 +9,7 @@ Each archive includes compiled JavaScript, declarations, native source and | ... | @@ -9,7 +9,7 @@ Each archive includes compiled JavaScript, declarations, native source and |
| 9 | To reproduce the current archive with Node 24 and the repository's pinned Yarn: | 9 | To reproduce the current archive with Node 24 and the repository's pinned Yarn: |
| 10 | 10 | ||
| 11 | ```sh | 11 | ```sh |
| 12 | git clone --branch t3-v5.0.0-t3.7 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork | 12 | git clone --branch t3-v5.0.0-t3.8 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork |
| 13 | yarn --cwd /tmp/t3-screens-fork pack:t3 /absolute/path/to/t3code/apps/mobile/deps | 13 | yarn --cwd /tmp/t3-screens-fork pack:t3 /absolute/path/to/t3code/apps/mobile/deps |
| 14 | ``` | 14 | ``` |
| 15 | 15 | ||
| ... | @@ -20,3 +20,8 @@ Run `pack:t3` from that clean checkout and tag the source as `t3-v<version>`. | ... | @@ -20,3 +20,8 @@ Run `pack:t3` from that clean checkout and tag the source as `t3-v<version>`. |
| 20 | Replace the old archive, update `apps/mobile/package.json` and run `vp i` to | 20 | Replace the old archive, update `apps/mobile/package.json` and run `vp i` to |
| 21 | regenerate the lockfile. Verify mobile types and affected navigation tests. | 21 | regenerate the lockfile. Verify mobile types and affected navigation tests. |
| 22 | Changes to native source also require rebuilding and testing the native client. | 22 | Changes to native source also require rebuilding and testing the native client. |
| 23 | |||
| 24 | The same iOS binary supports iPhone, iPad, and Duo. UIKit hinge updates enable | ||
| 25 | phone workspace columns at runtime; regular phones retain portrait rotation. | ||
| 26 | Use the normal EAS build profiles and update channels. Duo support requires a | ||
| 27 | binary built with the iOS 27.1 SDK; an OTA cannot add the native APIs. |
patches/@react-navigation%2Fnative-stack@7.17.6.patch+10-2| ... | @@ -67,7 +67,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d | ... | @@ -67,7 +67,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d |
| 67 | } | 67 | } |
| 68 | return item; | 68 | return item; |
| 69 | } | 69 | } |
| 70 | + if (item.type === 'mailSearchToolbar') { | 70 | + if (item.type === 'mailSearchToolbar' || item.type === 'searchBarPlacement') { |
| 71 | + return { | 71 | + return { |
| 72 | + ...item, | 72 | + ...item, |
| 73 | + index | 73 | + index |
| ... | @@ -81,7 +81,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d | ... | @@ -81,7 +81,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d |
| 81 | return processedItem; | 81 | return processedItem; |
| 82 | } | 82 | } |
| 83 | - throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom' and 'spacing'.`); | 83 | - throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom' and 'spacing'.`); |
| 84 | + throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom', 'spacing' and 'mailSearchToolbar'.`); | 84 | + throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom', 'spacing', 'mailSearchToolbar' and 'searchBarPlacement'.`); |
| 85 | }).filter(item => item != null); | 85 | }).filter(item => item != null); |
| 86 | }; | 86 | }; |
| 87 | const transformIcon = icon => { | 87 | const transformIcon = icon => { |
| ... | @@ -190,6 +190,10 @@ index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb215 | ... | @@ -190,6 +190,10 @@ index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb215 |
| 190 | /** | 190 | /** |
| 191 | * A React Element to display as the item. | 191 | * A React Element to display as the item. |
| 192 | */ | 192 | */ |
| 193 | @@ -864,1 +864,3 @@ | ||
| 194 | sharesBackground?: boolean; | ||
| 195 | + /** Native bar axis preference on iOS 27.1 and later. */ | ||
| 196 | + axisBehavior?: 'automatic' | 'horizontalOnly' | 'verticalPreferred'; | ||
| 193 | diff --git a/src/types.tsx b/src/types.tsx | 197 | diff --git a/src/types.tsx b/src/types.tsx |
| 194 | index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644 | 198 | index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644 |
| 195 | --- a/src/types.tsx | 199 | --- a/src/types.tsx |
| ... | @@ -208,6 +212,10 @@ index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9e | ... | @@ -208,6 +212,10 @@ index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9e |
| 208 | /** | 212 | /** |
| 209 | * A React Element to display as the item. | 213 | * A React Element to display as the item. |
| 210 | */ | 214 | */ |
| 215 | @@ -920,1 +920,3 @@ | ||
| 216 | sharesBackground?: boolean; | ||
| 217 | + /** Native bar axis preference on iOS 27.1 and later. */ | ||
| 218 | + axisBehavior?: 'automatic' | 'horizontalOnly' | 'verticalPreferred'; | ||
| 211 | diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx | 219 | diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx |
| 212 | index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644 | 220 | index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644 |
| 213 | --- a/src/views/useHeaderConfigProps.tsx | 221 | --- a/src/views/useHeaderConfigProps.tsx |
pnpm-lock.yaml+10-10| ... | @@ -154,7 +154,7 @@ patchedDependencies: | ... | @@ -154,7 +154,7 @@ patchedDependencies: |
| 154 | '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0 | 154 | '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0 |
| 155 | '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d | 155 | '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d |
| 156 | '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba | 156 | '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba |
| 157 | '@react-navigation/native-stack@7.17.6': 3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87 | 157 | '@react-navigation/native-stack@7.17.6': d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5 |
| 158 | alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd | 158 | alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd |
| 159 | dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 | 159 | dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 |
| 160 | effect@4.0.2: ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688 | 160 | effect@4.0.2: ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688 |
| ... | @@ -346,7 +346,7 @@ importers: | ... | @@ -346,7 +346,7 @@ importers: |
| 346 | 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) | 346 | 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) |
| 347 | '@react-navigation/native-stack': | 347 | '@react-navigation/native-stack': |
| 348 | specifier: 7.17.6 | 348 | specifier: 7.17.6 |
| 349 | version: 7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06) | 349 | version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cc0272b81e52c9a28f5defc327cee1b5) |
| 350 | '@shikijs/core': | 350 | '@shikijs/core': |
| 351 | specifier: 4.2.0 | 351 | specifier: 4.2.0 |
| 352 | version: 4.2.0 | 352 | version: 4.2.0 |
| ... | @@ -549,8 +549,8 @@ importers: | ... | @@ -549,8 +549,8 @@ importers: |
| 549 | specifier: ~5.9.1 | 549 | specifier: ~5.9.1 |
| 550 | 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) | 550 | 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) |
| 551 | react-native-screens: | 551 | react-native-screens: |
| 552 | specifier: file:deps/react-native-screens-5.0.0-t3.7.tgz | 552 | specifier: file:deps/react-native-screens-5.0.0-t3.8.tgz |
| 553 | 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) | 553 | 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) |
| 554 | react-native-shiki-engine: | 554 | react-native-shiki-engine: |
| 555 | specifier: ^0.3.12 | 555 | specifier: ^0.3.12 |
| 556 | 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) | 556 | 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) |
| ... | @@ -10798,9 +10798,9 @@ packages: | ... | @@ -10798,9 +10798,9 @@ packages: |
| 10798 | react: '*' | 10798 | react: '*' |
| 10799 | react-native: '*' | 10799 | react-native: '*' |
| 10800 | 10800 | ||
| 10801 | react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz: | 10801 | react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz: |
| 10802 | resolution: {integrity: sha512-Di1+YTCuhWpk9helKBxkvXAVAQTCSRo24tpFBGXdbTAT4vPm2vGPbtPe+lnIXcePjNHbTEaI1ha5n9PqQKl5lg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz} | 10802 | resolution: {integrity: sha512-sgvyU7L9I1iKfFF5p/oe84duBpA2rJmY/WTgHgKmSI2JGLhWBWQ5qWjo/7WRyZkt36sjOkdNGxwNGhx7dCPzJA==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz} |
| 10803 | version: 5.0.0-t3.7 | 10803 | version: 5.0.0-t3.8 |
| 10804 | peerDependencies: | 10804 | peerDependencies: |
| 10805 | react: '*' | 10805 | react: '*' |
| 10806 | react-native: '*' | 10806 | react-native: '*' |
| ... | @@ -15971,7 +15971,7 @@ snapshots: | ... | @@ -15971,7 +15971,7 @@ snapshots: |
| 15971 | optionalDependencies: | 15971 | optionalDependencies: |
| 15972 | '@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) | 15972 | '@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) |
| 15973 | 15973 | ||
| 15974 | '@react-navigation/native-stack@7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06)': | 15974 | '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cc0272b81e52c9a28f5defc327cee1b5)': |
| 15975 | dependencies: | 15975 | dependencies: |
| 15976 | '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) | 15976 | '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) |
| 15977 | '@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) | 15977 | '@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) |
| ... | @@ -15979,7 +15979,7 @@ snapshots: | ... | @@ -15979,7 +15979,7 @@ snapshots: |
| 15979 | react: 19.3.0 | 15979 | react: 19.3.0 |
| 15980 | 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) | 15980 | 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) |
| 15981 | 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) | 15981 | 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) |
| 15982 | 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) | 15982 | 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) |
| 15983 | sf-symbols-typescript: 2.2.0 | 15983 | sf-symbols-typescript: 2.2.0 |
| 15984 | warn-once: 0.1.1 | 15984 | warn-once: 0.1.1 |
| 15985 | transitivePeerDependencies: | 15985 | transitivePeerDependencies: |
| ... | @@ -21967,7 +21967,7 @@ snapshots: | ... | @@ -21967,7 +21967,7 @@ snapshots: |
| 21967 | react: 19.3.0 | 21967 | react: 19.3.0 |
| 21968 | 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) | 21968 | 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) |
| 21969 | 21969 | ||
| 21970 | 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): | 21970 | 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): |
| 21971 | dependencies: | 21971 | dependencies: |
| 21972 | react: 19.3.0 | 21972 | react: 19.3.0 |
| 21973 | react-freeze: 1.0.4(react@19.3.0) | 21973 | react-freeze: 1.0.4(react@19.3.0) |