| 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 | 264 | "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], |
| 265 | 265 | }, |
| 266 | 266 | infoPlist: { |
| 267 | UISupportedInterfaceOrientations: [ | |
| 268 | "UIInterfaceOrientationPortrait", | |
| 269 | "UIInterfaceOrientationLandscapeLeft", | |
| 270 | "UIInterfaceOrientationLandscapeRight", | |
| 271 | ], | |
| 267 | 272 | NSAppTransportSecurity: { |
| 268 | 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 | 13 | }, |
| 14 | 14 | "channel": "development", |
| 15 | 15 | "developmentClient": true, |
| 16 | "distribution": "internal" | |
| 16 | "distribution": "internal", | |
| 17 | "ios": { | |
| 18 | "image": "macos-tahoe-26.6-xcode-27.1" | |
| 19 | } | |
| 17 | 20 | }, |
| 18 | 21 | "preview": { |
| 19 | 22 | "corepack": true, |
| ... | ... | @@ -23,7 +26,10 @@ |
| 23 | 26 | }, |
| 24 | 27 | "channel": "preview", |
| 25 | 28 | "environment": "preview", |
| 26 | "distribution": "internal" | |
| 29 | "distribution": "internal", | |
| 30 | "ios": { | |
| 31 | "image": "macos-tahoe-26.6-xcode-27.1" | |
| 32 | } | |
| 27 | 33 | }, |
| 28 | 34 | "preview:dev": { |
| 29 | 35 | "corepack": true, |
| ... | ... | @@ -38,6 +44,9 @@ |
| 38 | 44 | "distribution": "internal", |
| 39 | 45 | "android": { |
| 40 | 46 | "buildType": "apk" |
| 47 | }, | |
| 48 | "ios": { | |
| 49 | "image": "macos-tahoe-26.6-xcode-27.1" | |
| 41 | 50 | } |
| 42 | 51 | }, |
| 43 | 52 | "v2-preview": { |
| ... | ... | @@ -56,7 +65,10 @@ |
| 56 | 65 | }, |
| 57 | 66 | "channel": "production", |
| 58 | 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 | 74 | "submit": { |
apps/mobile/modules/t3-native-controls/expo-module.config.json+2-1| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | { |
| 2 | 2 | "platforms": ["apple", "android"], |
| 3 | 3 | "apple": { |
| 4 | "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] | |
| 4 | "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"], | |
| 5 | "appDelegateSubscribers": ["T3WorkspaceOrientationSubscriber"] | |
| 5 | 6 | }, |
| 6 | 7 | "android": { |
| 7 | 8 | "modules": [ |
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+6| ... | ... | @@ -17,9 +17,13 @@ public final class T3KeyboardCommandsModule: Module { |
| 17 | 17 | public final class T3KeyboardCommandsView: ExpoView { |
| 18 | 18 | let onCommand = EventDispatcher() |
| 19 | 19 | private var enabledCommands = Set<String>() |
| 20 | private let shortcutInputView = UIView(frame: .zero) | |
| 20 | 21 | |
| 21 | 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 | 27 | public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| 24 | 28 | if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss), |
| 25 | 29 | let input = window?.t3FirstResponder as? UITextInput, |
| ... | ... | @@ -89,6 +93,8 @@ public final class T3KeyboardCommandsView: ExpoView { |
| 89 | 93 | |
| 90 | 94 | public required init(appContext: AppContext? = nil) { |
| 91 | 95 | super.init(appContext: appContext) |
| 96 | inputAssistantItem.leadingBarButtonGroups = [] | |
| 97 | inputAssistantItem.trailingBarButtonGroups = [] | |
| 92 | 98 | NotificationCenter.default.addObserver( |
| 93 | 99 | self, |
| 94 | 100 | selector: #selector(reclaimFirstResponderIfAvailable), |
apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift+49-2| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | import ExpoModulesCore |
| 2 | 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 | 6 | final class T3LayoutMetricsView: ExpoView { |
| 6 | 7 | let onMetricsChange = EventDispatcher() |
| 7 | 8 | private var lastMetrics: NSDictionary? |
| 9 | private var hasHinge: Bool? = false | |
| 8 | 10 | |
| 9 | 11 | required init(appContext: AppContext? = nil) { |
| 10 | 12 | super.init(appContext: appContext) |
| ... | ... | @@ -12,6 +14,22 @@ final class T3LayoutMetricsView: ExpoView { |
| 12 | 14 | registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in |
| 13 | 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 | 35 | override func didMoveToWindow() { |
| ... | ... | @@ -31,14 +49,43 @@ final class T3LayoutMetricsView: ExpoView { |
| 31 | 49 | } |
| 32 | 50 | |
| 33 | 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 | 79 | let metrics: [String: Any] = [ |
| 36 | 80 | "width": bounds.width, "height": bounds.height, |
| 81 | "hasHinge": hasHinge, | |
| 37 | 82 | "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", |
| 83 | "verticalBarEdge": verticalBarEdge, | |
| 38 | 84 | "safeArea": [ |
| 39 | 85 | "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, |
| 40 | 86 | "left": safeAreaInsets.left, "right": safeAreaInsets.right, |
| 41 | 87 | ], |
| 88 | "reservedRegions": regions, | |
| 42 | 89 | ] |
| 43 | 90 | let next = metrics as NSDictionary |
| 44 | 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 | 13 | public func definition() -> ModuleDefinition { |
| 14 | 14 | Constants { |
| 15 | 15 | if #available(iOS 26.0, *) { |
| 16 | return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil] | |
| 16 | return [ | |
| 17 | "supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil, | |
| 18 | ] | |
| 17 | 19 | } |
| 18 | 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 | 127 | "react-native-nitro-modules": "0.35.9", |
| 128 | 128 | "react-native-reanimated": "4.7.0", |
| 129 | 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 | 131 | "react-native-shiki-engine": "^0.3.12", |
| 132 | 132 | "react-native-svg": "15.15.5", |
| 133 | 133 | "react-native-webview": "14.0.1", |
apps/mobile/src/App.tsx+8-5| ... | ... | @@ -27,6 +27,7 @@ import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; |
| 27 | 27 | import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; |
| 28 | 28 | import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider"; |
| 29 | 29 | import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; |
| 30 | import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; | |
| 30 | 31 | |
| 31 | 32 | import "../global.css"; |
| 32 | 33 | |
| ... | ... | @@ -91,11 +92,13 @@ function AppContent() { |
| 91 | 92 | header (glass buttons, title, materials) is forced light even when |
| 92 | 93 | the system is in dark mode. */} |
| 93 | 94 | <GlobalVoiceInputControl> |
| 94 | <IncomingShareProvider> | |
| 95 | <Navigation linking={appLinking} theme={navigationTheme} /> | |
| 96 | </IncomingShareProvider> | |
| 97 | <ConfirmDialogHost /> | |
| 98 | <ThreadArrangementHost /> | |
| 95 | <NativeLayoutMetricsProvider> | |
| 96 | <IncomingShareProvider> | |
| 97 | <Navigation linking={appLinking} theme={navigationTheme} /> | |
| 98 | </IncomingShareProvider> | |
| 99 | <ConfirmDialogHost /> | |
| 100 | <ThreadArrangementHost /> | |
| 101 | </NativeLayoutMetricsProvider> | |
| 99 | 102 | </GlobalVoiceInputControl> |
| 100 | 103 | {/* Anchored-menu overlays render here — in-window, so the |
| 101 | 104 | keyboard stays up while a dropdown is open. */} |
apps/mobile/src/Stack.tsx+9-1| ... | ... | @@ -128,6 +128,7 @@ import { |
| 128 | 128 | } from "./features/sharing/incoming-share-presentation"; |
| 129 | 129 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; |
| 130 | 130 | import { deriveLayout } from "./lib/layout"; |
| 131 | import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; | |
| 131 | 132 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; |
| 132 | 133 | import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; |
| 133 | 134 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; |
| ... | ... | @@ -192,6 +193,9 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { |
| 192 | 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 | 199 | const SettingsContentStack = createV5SheetStackNavigator({ |
| 196 | 200 | initialRouteName: "Settings", |
| 197 | 201 | screenOptions: { |
| ... | ... | @@ -410,6 +414,7 @@ const SettingsSheetStack = createV5SheetStackNavigator({ |
| 410 | 414 | screens: { |
| 411 | 415 | SettingsContent: createNativeStackScreen({ |
| 412 | 416 | screen: SettingsContentStack, |
| 417 | options: NESTED_NAVIGATOR_OPTIONS, | |
| 413 | 418 | linking: "", |
| 414 | 419 | layout: ({ children }) => ( |
| 415 | 420 | <SettingsEnvironmentFilterProvider> |
| ... | ... | @@ -845,6 +850,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 845 | 850 | screen: SettingsSheetStack, |
| 846 | 851 | linking: "settings", |
| 847 | 852 | options: { |
| 853 | ...NESTED_NAVIGATOR_OPTIONS, | |
| 848 | 854 | gestureEnabled: true, |
| 849 | 855 | headerShown: false, |
| 850 | 856 | }, |
| ... | ... | @@ -911,6 +917,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 911 | 917 | </GuardedScreenLayout> |
| 912 | 918 | ), |
| 913 | 919 | options: { |
| 920 | ...NESTED_NAVIGATOR_OPTIONS, | |
| 914 | 921 | gestureEnabled: true, |
| 915 | 922 | headerShown: false, |
| 916 | 923 | }, |
| ... | ... | @@ -951,8 +958,9 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: |
| 951 | 958 | |
| 952 | 959 | export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { |
| 953 | 960 | const { width, height } = useWindowDimensions(); |
| 961 | const nativeMetrics = useNativeLayoutMetrics(); | |
| 954 | 962 | const usesWorkspaceFlowScreens = |
| 955 | Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView; | |
| 963 | Platform.OS === "android" || deriveLayout({ width, height, nativeMetrics }).usesSplitView; | |
| 956 | 964 | |
| 957 | 965 | return ( |
| 958 | 966 | <Navigator |
apps/mobile/src/components/ScreenHeader.tsx+12-4| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; |
| 2 | 2 | import { useId } from "react"; |
| 3 | import { ActivityIndicator, View } from "react-native"; | |
| 3 | import { ActivityIndicator, Platform, View } from "react-native"; | |
| 4 | 4 | import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; |
| 5 | 5 | import { ScreenHeaderButton } from "./ScreenHeaderButton"; |
| 6 | 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader"; |
| 7 | import { useNativeWorkspaceColumnsSupported } from "../native/NativeWorkspaceColumns"; | |
| 7 | 8 | import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout"; |
| 8 | 9 | import { |
| 9 | 10 | createNativeMailSearchToolbarItem, |
| ... | ... | @@ -12,6 +13,7 @@ import { |
| 12 | 13 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 13 | 14 | import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types"; |
| 14 | 15 | import type { AppSymbolName } from "./AppSymbol"; |
| 16 | import { useNativeMailSearchToolbar } from "../native/use-native-mail-search-toolbar"; | |
| 15 | 17 | |
| 16 | 18 | function iosIcon(icon: AppSymbolName) { |
| 17 | 19 | return typeof icon === "string" ? icon : icon.ios; |
| ... | ... | @@ -33,15 +35,17 @@ function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["it |
| 33 | 35 | } |
| 34 | 36 | |
| 35 | 37 | export function ScreenHeader(props: ScreenHeaderProps) { |
| 38 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | |
| 36 | 39 | const headerId = useId(); |
| 37 | 40 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
| 38 | 41 | const { themeVariables } = useAppearancePreferences(); |
| 42 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); | |
| 39 | 43 | const { search, menus } = props; |
| 40 | 44 | const menu = menus?.[0]; |
| 41 | 45 | const compactSearch = |
| 42 | 46 | search !== undefined && |
| 43 | 47 | (search.compactToolbar ?? !layout.usesSplitView) && |
| 44 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | |
| 48 | usesNativeMailSearchToolbar; | |
| 45 | 49 | const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); |
| 46 | 50 | const refresh = search?.refreshInToolbar ? search.onRefresh : undefined; |
| 47 | 51 | return ( |
| ... | ... | @@ -88,7 +92,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { |
| 88 | 92 | : undefined), |
| 89 | 93 | }), |
| 90 | 94 | ] |
| 91 | : undefined, | |
| 95 | : () => [], | |
| 92 | 96 | headerSearchBarOptions: compactSearch |
| 93 | 97 | ? undefined |
| 94 | 98 | : { |
| ... | ... | @@ -116,8 +120,12 @@ export function ScreenHeader(props: ScreenHeaderProps) { |
| 116 | 120 | onPress={props.backInSplitView.onPress ?? props.onBack} |
| 117 | 121 | /> |
| 118 | 122 | ) : null} |
| 119 | {props.sidebar !== false ? ( | |
| 123 | {props.sidebar !== false && | |
| 124 | (!usesNativeWorkspaceColumns || | |
| 125 | (Platform.OS === "ios" && Platform.isPad) || | |
| 126 | !panes.primarySidebarVisible) ? ( | |
| 120 | 127 | <ScreenHeaderButton |
| 128 | axisBehavior={usesNativeWorkspaceColumns ? "horizontalOnly" : undefined} | |
| 121 | 129 | accessibilityLabel={ |
| 122 | 130 | panes.primarySidebarVisible |
| 123 | 131 | ? `Maximize ${props.title.toLowerCase()}` |
apps/mobile/src/features/home/HomeHeader.tsx+34-11| ... | ... | @@ -1,25 +1,43 @@ |
| 1 | 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 | 5 | import { Platform } from "react-native"; |
| 4 | 6 | import type { SearchBarCommands } from "react-native-screens"; |
| 5 | 7 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; |
| 6 | 8 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 7 | 9 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 8 | 10 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 9 | import { | |
| 10 | createNativeMailSearchToolbarItem, | |
| 11 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | |
| 12 | } from "../layout/native-mail-search-toolbar"; | |
| 11 | import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; | |
| 12 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; | |
| 13 | 13 | import { buildHomeListFilterMenu } from "./home-list-filter-menu"; |
| 14 | 14 | import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; |
| 15 | 15 | import type { HomeHeaderProps } from "./HomeHeader.types"; |
| 16 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | |
| 16 | 17 | |
| 17 | 18 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| 18 | 19 | |
| 19 | 20 | export function HomeHeader(props: HomeHeaderProps) { |
| 21 | const navigation = useNavigation<NativeStackNavigationProp<ParamListBase>>(); | |
| 22 | const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | |
| 23 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); | |
| 20 | 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 | 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 | 41 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 24 | 42 | // The list uses a fixed creation order and ignores sort/group options, so |
| 25 | 43 | // the filter menu only carries the filters and the "customized" icon state |
| ... | ... | @@ -27,9 +45,14 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 27 | 45 | const hasCustomListOptions = |
| 28 | 46 | props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; |
| 29 | 47 | const focusSearch = useCallback(() => { |
| 48 | if (primaryColumn && !panes.primarySidebarVisible) { | |
| 49 | focusAfterReveal.current = true; | |
| 50 | togglePrimarySidebar(); | |
| 51 | return true; | |
| 52 | } | |
| 30 | 53 | searchBarRef.current?.focus(); |
| 31 | 54 | return searchBarRef.current !== null; |
| 32 | }, []); | |
| 55 | }, [primaryColumn, panes.primarySidebarVisible, togglePrimarySidebar]); | |
| 33 | 56 | useHardwareKeyboardCommand("focusSearch", focusSearch); |
| 34 | 57 | const filterMenu = buildHomeListFilterMenu(props); |
| 35 | 58 | |
| ... | ... | @@ -42,7 +65,7 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 42 | 65 | // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. |
| 43 | 66 | headerTintColor: iconColor, |
| 44 | 67 | unstable_headerRightItems: () => |
| 45 | iPadSidebar | |
| 68 | sidebarHeader | |
| 46 | 69 | ? createSidebarHeaderItems({ |
| 47 | 70 | filterIcon: hasCustomListOptions |
| 48 | 71 | ? "line.3.horizontal.decrease.circle.fill" |
| ... | ... | @@ -62,7 +85,7 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 62 | 85 | ], |
| 63 | 86 | // The keys below are set per-branch (not `undefined`) so a later |
| 64 | 87 | // reapply cannot clobber options owned by NativeHeaderToolbar. |
| 65 | ...(iPadSidebar | |
| 88 | ...(sidebarHeader | |
| 66 | 89 | ? { |
| 67 | 90 | headerSearchBarOptions: { |
| 68 | 91 | ref: searchBarRef, |
| ... | ... | @@ -78,7 +101,7 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 78 | 101 | }, |
| 79 | 102 | unstable_headerToolbarItems: () => [], |
| 80 | 103 | } |
| 81 | : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED | |
| 104 | : usesNativeMailSearchToolbar | |
| 82 | 105 | ? { |
| 83 | 106 | headerSearchBarOptions: { |
| 84 | 107 | ref: searchBarRef, |
| ... | ... | @@ -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 | 148 | <NativeHeaderToolbar placement="bottom"> |
| 126 | 149 | <NativeHeaderToolbar.Menu |
| 127 | 150 | accessibilityLabel="Filter threads" |
apps/mobile/src/features/home/HomeScreen.tsx+14-2| ... | ... | @@ -36,8 +36,12 @@ import type { SavedRemoteConnection } from "../../lib/connection"; |
| 36 | 36 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 37 | 37 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; |
| 38 | 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 | 43 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 44 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; | |
| 41 | 45 | import { useThreadSearch } from "../../state/queries"; |
| 42 | 46 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| 43 | 47 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| ... | ... | @@ -264,6 +268,7 @@ function renderHomeScrollView(props: ScrollViewProps) { |
| 264 | 268 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 265 | 269 | |
| 266 | 270 | export function HomeScreen(props: HomeScreenProps) { |
| 271 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | |
| 267 | 272 | const primaryColumn = use(NativePrimaryColumnContext); |
| 268 | 273 | const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen"; |
| 269 | 274 | const containerClassName = cn( |
| ... | ... | @@ -277,6 +282,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 277 | 282 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 278 | 283 | const insets = useSafeAreaInsets(); |
| 279 | 284 | const { fabClearance } = useAndroidControlSizing(); |
| 285 | const screenMetrics = useNativeLayoutMetrics(); | |
| 286 | const contentSideInsets = usesNativeWorkspaceColumns | |
| 287 | ? (columnMetrics ?? screenMetrics)?.safeArea | |
| 288 | : undefined; | |
| 280 | 289 | // UIKit's column safe area already includes its bottom toolbar. |
| 281 | 290 | const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); |
| 282 | 291 | const searchEnvironmentIds = useMemo( |
| ... | ... | @@ -908,6 +917,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 908 | 917 | style={{ |
| 909 | 918 | paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24), |
| 910 | 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 | 924 | <View className="w-full max-w-[430px]"> |
| ... | ... | @@ -1038,7 +1049,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1038 | 1049 | {...scrollGateHandlers} |
| 1039 | 1050 | scrollEventThrottle={16} |
| 1040 | 1051 | contentContainerStyle={{ |
| 1041 | paddingHorizontal: primaryColumn ? 8 : 0, | |
| 1052 | paddingLeft: (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0), | |
| 1053 | paddingRight: (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0), | |
| 1042 | 1054 | paddingBottom: |
| 1043 | 1055 | Platform.OS === "ios" |
| 1044 | 1056 | ? iosBottomClearance |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+23-6| ... | ... | @@ -66,6 +66,8 @@ import { |
| 66 | 66 | NativeWorkspaceInspectorContext, |
| 67 | 67 | } from "../../native/v5-workspace-context"; |
| 68 | 68 | |
| 69 | import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; | |
| 70 | ||
| 69 | 71 | interface AdaptiveWorkspaceContextValue { |
| 70 | 72 | readonly layout: Layout; |
| 71 | 73 | readonly panes: WorkspacePaneLayout; |
| ... | ... | @@ -239,7 +241,9 @@ function AdaptiveWorkspaceLayoutContent( |
| 239 | 241 | ) { |
| 240 | 242 | const projectGroupingMode = props.projectGroupingMode; |
| 241 | 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 | 247 | const pathname = props.pathname; |
| 244 | 248 | const navigation = useNavigation(); |
| 245 | 249 | const activeRoleOwner = useRef<symbol | null>(null); |
| ... | ... | @@ -256,7 +260,10 @@ function AdaptiveWorkspaceLayoutContent( |
| 256 | 260 | const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); |
| 257 | 261 | const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = |
| 258 | 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 | 267 | const layout = baseLayout; |
| 261 | 268 | // In split layouts the sidebar IS the thread list — it renders on every |
| 262 | 269 | // route, including Home (which shows an empty-detail pane instead of the |
| ... | ... | @@ -269,7 +276,9 @@ function AdaptiveWorkspaceLayoutContent( |
| 269 | 276 | viewportWidth: width, |
| 270 | 277 | preferredWidth: fileInspectorPreferredWidth ?? undefined, |
| 271 | 278 | reservedLeadingWidth: |
| 272 | shouldRenderPrimarySidebar && showPrimarySidebar ? (layout.listPaneWidth ?? 0) : 0, | |
| 279 | shouldRenderPrimarySidebar && showPrimarySidebar | |
| 280 | ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) | |
| 281 | : 0, | |
| 273 | 282 | }), |
| 274 | 283 | [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], |
| 275 | 284 | ); |
| ... | ... | @@ -470,13 +479,21 @@ function AdaptiveWorkspaceLayoutContent( |
| 470 | 479 | ); |
| 471 | 480 | |
| 472 | 481 | const renderedSidebarWidth = useSharedValue( |
| 473 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, | |
| 482 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, | |
| 474 | 483 | ); |
| 475 | 484 | useEffect(() => { |
| 476 | 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 | 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 | 497 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ |
| 481 | 498 | opacity: Math.min(1, renderedSidebarWidth.value / 80), |
| 482 | 499 | width: renderedSidebarWidth.value, |
apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx+59-9| ... | ... | @@ -20,7 +20,7 @@ import { |
| 20 | 20 | type NativeStackTypeBag, |
| 21 | 21 | } from "@react-navigation/native-stack"; |
| 22 | 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 | 24 | import { Split, type SplitHostCommands } from "react-native-screens"; |
| 25 | 25 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 26 | 26 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| ... | ... | @@ -33,8 +33,13 @@ import { |
| 33 | 33 | V5CardStackView, |
| 34 | 34 | V5StackView, |
| 35 | 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 | 40 | import { V5StackHeader } from "../../native/V5StackHeader.ios"; |
| 41 | import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; | |
| 42 | import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | |
| 38 | 43 | import { |
| 39 | 44 | nativeWorkspacePopAction, |
| 40 | 45 | projectWorkspaceStack, |
| ... | ... | @@ -89,7 +94,12 @@ function ColumnScreen(props: { |
| 89 | 94 | canGoBack={!props.primary} |
| 90 | 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 | 103 | <NativePrimaryColumnContext value={primaryColumn}> |
| 94 | 104 | {descriptor.render()} |
| 95 | 105 | </NativePrimaryColumnContext> |
| ... | ... | @@ -107,6 +117,8 @@ function WorkspaceColumns( |
| 107 | 117 | const inspector = use(NativeWorkspaceInspectorContext); |
| 108 | 118 | const hostRef = useRef<SplitHostCommands>(null); |
| 109 | 119 | const activeDetailKey = props.detail.at(-1)?.key; |
| 120 | const compactColumn = activeDetailKey ? "secondary" : "primary"; | |
| 121 | const shownCompactColumn = useRef(compactColumn); | |
| 110 | 122 | const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; |
| 111 | 123 | const selectedThreadKey = |
| 112 | 124 | threadParams && |
| ... | ... | @@ -131,9 +143,12 @@ function WorkspaceColumns( |
| 131 | 143 | useEffect(() => { |
| 132 | 144 | // In compact size classes UIKit exposes one column. Selecting a thread |
| 133 | 145 | // changes the visible column without rebuilding either navigation stack. |
| 134 | if (layout.usesSplitView) return; | |
| 135 | hostRef.current?.show(activeDetailKey ? "secondary" : "primary"); | |
| 136 | }, [activeDetailKey, layout.usesSplitView]); | |
| 146 | // The initial column is a native prop, so it is applied before the host | |
| 147 | // attaches. Commands only handle later navigation changes. | |
| 148 | if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return; | |
| 149 | shownCompactColumn.current = compactColumn; | |
| 150 | hostRef.current?.show(compactColumn); | |
| 151 | }, [compactColumn, layout.usesSplitView]); | |
| 137 | 152 | |
| 138 | 153 | const primary = props.descriptors[props.primary.key]; |
| 139 | 154 | if (!primary) return null; |
| ... | ... | @@ -142,6 +157,7 @@ function WorkspaceColumns( |
| 142 | 157 | ref={hostRef} |
| 143 | 158 | testID="adaptive-workspace-layout" |
| 144 | 159 | preferredSplitBehavior="tile" |
| 160 | topColumnForCollapsing={compactColumn} | |
| 145 | 161 | preferredDisplayMode={ |
| 146 | 162 | panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly" |
| 147 | 163 | } |
| ... | ... | @@ -153,7 +169,7 @@ function WorkspaceColumns( |
| 153 | 169 | }} |
| 154 | 170 | columnMetrics={{ |
| 155 | 171 | minimumPrimaryColumnWidth: 280, |
| 156 | maximumPrimaryColumnWidth: 380, | |
| 172 | maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0), | |
| 157 | 173 | preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, |
| 158 | 174 | minimumSecondaryColumnWidth: 320, |
| 159 | 175 | }} |
| ... | ... | @@ -179,6 +195,37 @@ function WorkspaceColumns( |
| 179 | 195 | options={{ |
| 180 | 196 | headerShown: true, |
| 181 | 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 | 229 | unstable_headerLeftItems: () => [ |
| 183 | 230 | { |
| 184 | 231 | type: "button", |
| ... | ... | @@ -293,6 +340,8 @@ function WorkspaceStackNavigator({ |
| 293 | 340 | UNSTABLE_router, |
| 294 | 341 | ...rest |
| 295 | 342 | }: NativeStackNavigatorProps) { |
| 343 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | |
| 344 | const nativeWorkspaceColumnsReady = useNativeWorkspaceColumnsReady(); | |
| 296 | 345 | const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< |
| 297 | 346 | StackNavigationState<ParamListBase>, |
| 298 | 347 | StackRouterOptions, |
| ... | ... | @@ -310,9 +359,10 @@ function WorkspaceStackNavigator({ |
| 310 | 359 | screenLayout, |
| 311 | 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 | 364 | return ( |
| 315 | <NativeWorkspaceModeContext value={NATIVE_WORKSPACE_COLUMNS_SUPPORTED}> | |
| 365 | <NativeWorkspaceModeContext value={usesNativeWorkspaceColumns}> | |
| 316 | 366 | <NavigationContent> |
| 317 | 367 | <WorkspaceView |
| 318 | 368 | {...rest} |
apps/mobile/src/features/layout/native-glass-header-items.ts+1| ... | ... | @@ -4,6 +4,7 @@ type NativeGlassHeaderItem = { |
| 4 | 4 | readonly hidesSharedBackground?: boolean; |
| 5 | 5 | readonly sharesBackground?: boolean; |
| 6 | 6 | readonly variant?: "plain" | "done" | "prominent"; |
| 7 | readonly axisBehavior?: "automatic" | "horizontalOnly" | "verticalPreferred"; | |
| 7 | 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 | 19 | import { |
| 20 | 20 | createNativeMailSearchToolbarItem, |
| 21 | 21 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 22 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | |
| 23 | 22 | } from "../layout/native-mail-search-toolbar"; |
| 23 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; | |
| 24 | 24 | |
| 25 | 25 | import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; |
| 26 | 26 | |
| ... | ... | @@ -72,7 +72,7 @@ export function SettingsOpenSourceLicensesRouteScreen() { |
| 72 | 72 | const navigation = useNavigation(); |
| 73 | 73 | const insets = useSafeAreaInsets(); |
| 74 | 74 | const [query, setQuery] = useState(""); |
| 75 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | |
| 75 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); | |
| 76 | 76 | const manifest = useMobileThirdPartyLicenses(); |
| 77 | 77 | const entries = manifest?.entries ?? []; |
| 78 | 78 | const filteredEntries = useMemo( |
apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx+2| ... | ... | @@ -54,6 +54,7 @@ export function SettingsEnvironmentFilterHeader(props: { |
| 54 | 54 | options={{ |
| 55 | 55 | unstable_headerRightItems: () => [ |
| 56 | 56 | withNativeGlassHeaderItem({ |
| 57 | axisBehavior: "horizontalOnly", | |
| 57 | 58 | accessibilityLabel: "Filter settings environments and projects", |
| 58 | 59 | icon: { name: filterIcon, type: "sfSymbol" }, |
| 59 | 60 | label: "", |
| ... | ... | @@ -120,6 +121,7 @@ export function SettingsEnvironmentFilterHeader(props: { |
| 120 | 121 | ...(closeSettings |
| 121 | 122 | ? [ |
| 122 | 123 | withNativeGlassHeaderItem({ |
| 124 | axisBehavior: "horizontalOnly", | |
| 123 | 125 | accessibilityLabel: "Close settings", |
| 124 | 126 | icon: { name: "xmark", type: "sfSymbol" }, |
| 125 | 127 | identifier: "settings-close", |
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+2-2| ... | ... | @@ -37,8 +37,8 @@ import { vcsEnvironment } from "../../state/vcs"; |
| 37 | 37 | import { |
| 38 | 38 | createNativeMailSearchToolbarItem, |
| 39 | 39 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 40 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | |
| 41 | 40 | } from "../layout/native-mail-search-toolbar"; |
| 41 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; | |
| 42 | 42 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 43 | 43 | import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; |
| 44 | 44 | |
| ... | ... | @@ -413,7 +413,7 @@ export function BranchPickerScreen(props: { |
| 413 | 413 | }) { |
| 414 | 414 | const navigation = useNavigation(); |
| 415 | 415 | const insets = useSafeAreaInsets(); |
| 416 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | |
| 416 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); | |
| 417 | 417 | const selectedBranchName = |
| 418 | 418 | props.selectedBranchName ?? |
| 419 | 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 | 14 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; |
| 15 | 15 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 16 | 16 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 17 | import { NativeLayoutObserver } from "../../native/NativeLayoutObserver"; | |
| 18 | import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions"; | |
| 17 | 19 | import type { |
| 18 | 20 | EnvironmentId, |
| 19 | 21 | MessageId, |
| ... | ... | @@ -84,6 +86,8 @@ import Animated, { |
| 84 | 86 | } from "react-native-reanimated"; |
| 85 | 87 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 86 | 88 | import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; |
| 89 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; | |
| 90 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | |
| 87 | 91 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 88 | 92 | import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; |
| 89 | 93 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| ... | ... | @@ -317,6 +321,7 @@ const USER_INPUT_TOGGLE_TIMING = { |
| 317 | 321 | }; |
| 318 | 322 | |
| 319 | 323 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 324 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | |
| 320 | 325 | const navigation = useNavigation(); |
| 321 | 326 | const { session: voiceInputSession } = useGlobalVoiceInput(); |
| 322 | 327 | const reportedModelSelection = useThreadReportedModelSelection({ |
| ... | ... | @@ -388,7 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 388 | 393 | } |
| 389 | 394 | }, []); |
| 390 | 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 | 401 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 393 | 402 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 394 | 403 | const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; |
| ... | ... | @@ -826,8 +835,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 826 | 835 | // Assign both layouts explicitly so the dock always follows its current parent. |
| 827 | 836 | const composerWidthStyle = useAnimatedStyle(() => |
| 828 | 837 | isSplitLayout && workspaceContentWidth !== null |
| 829 | ? { width: workspaceContentWidth.value } | |
| 830 | : { width: "100%" }, | |
| 838 | ? { | |
| 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 | 847 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; |
| 833 | 848 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| ... | ... | @@ -1083,6 +1098,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1083 | 1098 | |
| 1084 | 1099 | return ( |
| 1085 | 1100 | <View className="flex-1"> |
| 1101 | {!usesNativeWorkspaceColumns ? <NativeLayoutObserver onChange={setScreenMetrics} /> : null} | |
| 1086 | 1102 | {showContent ? ( |
| 1087 | 1103 | <View |
| 1088 | 1104 | style={{ flex: 1 }} |
| ... | ... | @@ -1140,6 +1156,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1140 | 1156 | } |
| 1141 | 1157 | contentMaxWidth={contentMaxWidth} |
| 1142 | 1158 | historyControls={props.historyControls} |
| 1159 | contentSideInsets={nativeMetrics?.safeArea} | |
| 1143 | 1160 | layoutVariant={layoutVariant} |
| 1144 | 1161 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} |
| 1145 | 1162 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} |
| ... | ... | @@ -1183,7 +1200,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1183 | 1200 | <Animated.View |
| 1184 | 1201 | layout={COMPOSER_LAYOUT_TRANSITION} |
| 1185 | 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 | 1213 | {/* No paddingTop here: the overlay's measured height becomes the |
| 1189 | 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 | 57 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 58 | 58 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 59 | 59 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 60 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; | |
| 60 | 61 | import { |
| 61 | 62 | createContext, |
| 62 | 63 | memo, |
| ... | ... | @@ -297,6 +298,7 @@ export interface ThreadFeedProps { |
| 297 | 298 | readonly contentBottomInset?: number; |
| 298 | 299 | readonly historyControls?: ThreadFeedHistoryControls; |
| 299 | 300 | readonly contentMaxWidth?: number; |
| 301 | readonly contentSideInsets?: { readonly left: number; readonly right: number }; | |
| 300 | 302 | readonly layoutVariant?: LayoutVariant; |
| 301 | 303 | readonly usesAutomaticContentInsets?: boolean; |
| 302 | 304 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; |
| ... | ... | @@ -1543,6 +1545,7 @@ function renderFeedEntry( |
| 1543 | 1545 | readonly reviewCommentColors: ReviewCommentColors; |
| 1544 | 1546 | readonly reviewCommentBubbleWidth: number; |
| 1545 | 1547 | readonly themeAppearance: "light" | "dark"; |
| 1548 | readonly usesNativeWorkspaceColumns: boolean; | |
| 1546 | 1549 | readonly userBubbleMaxWidth: number; |
| 1547 | 1550 | /** Width assistant markdown lays out in, so images can size their frame before layout. */ |
| 1548 | 1551 | readonly markdownContentWidth: number; |
| ... | ... | @@ -1730,11 +1733,19 @@ function renderFeedEntry( |
| 1730 | 1733 | className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5" |
| 1731 | 1734 | style={{ |
| 1732 | 1735 | backgroundColor: userBubbleColor, |
| 1733 | maxWidth: props.userBubbleMaxWidth, | |
| 1736 | maxWidth: | |
| 1737 | props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad | |
| 1738 | ? "85%" | |
| 1739 | : props.userBubbleMaxWidth, | |
| 1734 | 1740 | ...(hasReviewCommentContext |
| 1735 | 1741 | ? { width: props.reviewCommentBubbleWidth } |
| 1736 | 1742 | : hasWideBlock |
| 1737 | ? { width: props.userBubbleMaxWidth } | |
| 1743 | ? { | |
| 1744 | width: | |
| 1745 | props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad | |
| 1746 | ? "85%" | |
| 1747 | : props.userBubbleMaxWidth, | |
| 1748 | } | |
| 1738 | 1749 | : null), |
| 1739 | 1750 | }} |
| 1740 | 1751 | > |
| ... | ... | @@ -2174,6 +2185,7 @@ function ThreadFeedPlaceholder(props: { |
| 2174 | 2185 | } |
| 2175 | 2186 | |
| 2176 | 2187 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2188 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | |
| 2177 | 2189 | const navigation = useNavigation(); |
| 2178 | 2190 | const { themeAppearance } = useAppearancePreferences(); |
| 2179 | 2191 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| ... | ... | @@ -2257,12 +2269,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2257 | 2269 | setExpandedFile(null); |
| 2258 | 2270 | }, [props.environmentId, props.threadId, props.contentPresentation.kind]); |
| 2259 | 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 | 2275 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ |
| 2261 | viewportWidth, | |
| 2276 | viewportWidth: usableViewportWidth, | |
| 2262 | 2277 | maxContentWidth: props.contentMaxWidth ?? null, |
| 2263 | 2278 | minimumPadding: horizontalPadding, |
| 2264 | 2279 | }); |
| 2265 | const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); | |
| 2280 | const contentWidth = Math.max(0, usableViewportWidth - contentHorizontalPadding * 2); | |
| 2266 | 2281 | const userBubbleMaxWidth = contentWidth * 0.85; |
| 2267 | 2282 | const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); |
| 2268 | 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 | 2300 | // header-providing screen) and fall back to the standard iOS bar height. |
| 2286 | 2301 | const navigationHeaderHeight = useContext(HeaderHeightContext); |
| 2287 | 2302 | const anchorTopInset = usesNativeAutomaticInsets |
| 2288 | ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT | |
| 2303 | ? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT) | |
| 2289 | 2304 | : topContentInset; |
| 2290 | 2305 | |
| 2291 | 2306 | const theme = useUniwindTheme(); |
| ... | ... | @@ -3030,6 +3045,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3030 | 3045 | reviewCommentColors, |
| 3031 | 3046 | reviewCommentBubbleWidth, |
| 3032 | 3047 | themeAppearance, |
| 3048 | usesNativeWorkspaceColumns, | |
| 3033 | 3049 | userBubbleMaxWidth, |
| 3034 | 3050 | markdownContentWidth, |
| 3035 | 3051 | contentWidth, |
| ... | ... | @@ -3067,6 +3083,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3067 | 3083 | reviewCommentColors, |
| 3068 | 3084 | reviewCommentBubbleWidth, |
| 3069 | 3085 | themeAppearance, |
| 3086 | usesNativeWorkspaceColumns, | |
| 3070 | 3087 | userBubbleMaxWidth, |
| 3071 | 3088 | markdownContentWidth, |
| 3072 | 3089 | contentWidth, |
| ... | ... | @@ -3119,7 +3136,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3119 | 3136 | // (facebook/react-native#54123); the anchored end space after a send |
| 3120 | 3137 | // is pure inset, so without this the blank region can't be scrolled. |
| 3121 | 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 | 3151 | automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} |
| 3124 | 3152 | {...(usesNativeAutomaticInsets |
| 3125 | 3153 | ? { |
| ... | ... | @@ -3231,7 +3259,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3231 | 3259 | } |
| 3232 | 3260 | contentContainerStyle={{ |
| 3233 | 3261 | paddingTop: 12, |
| 3234 | paddingHorizontal: contentHorizontalPadding, | |
| 3262 | paddingLeft: contentHorizontalPadding + contentLeftInset, | |
| 3263 | paddingRight: contentHorizontalPadding + contentRightInset, | |
| 3235 | 3264 | }} |
| 3236 | 3265 | /> |
| 3237 | 3266 | </View> |
| ... | ... | @@ -3239,7 +3268,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 3239 | 3268 | !props.worktreeSetup && |
| 3240 | 3269 | props.activeWorkStartedAt === null && |
| 3241 | 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 | 3275 | <ThreadFeedPlaceholder |
| 3244 | 3276 | title="No conversation yet" |
| 3245 | 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 | 73 | import { |
| 74 | 74 | createNativeMailSearchToolbarItem, |
| 75 | 75 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 76 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | |
| 77 | 76 | } from "../layout/native-mail-search-toolbar"; |
| 77 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; | |
| 78 | 78 | import { ModelRow, ChoiceRow } from "./ThreadSettingsRows"; |
| 79 | 79 | import { |
| 80 | 80 | compatibleRuntimeModeForChoices, |
| ... | ... | @@ -742,10 +742,10 @@ function ThreadSettingsOptionsItem(props: { |
| 742 | 742 | .get(session.environmentId) |
| 743 | 743 | ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null) |
| 744 | 744 | : null; |
| 745 | const bottomToolbarInset = | |
| 746 | Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED | |
| 747 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET | |
| 748 | : 0; | |
| 745 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); | |
| 746 | const bottomToolbarInset = usesNativeMailSearchToolbar | |
| 747 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET | |
| 748 | : 0; | |
| 749 | 749 | |
| 750 | 750 | return ( |
| 751 | 751 | <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}> |
| ... | ... | @@ -1107,7 +1107,7 @@ function ThreadSettingsModelsScreen() { |
| 1107 | 1107 | const session = useThreadSettingsSession(); |
| 1108 | 1108 | const presentation = useThreadSettingsPickerPresentation(); |
| 1109 | 1109 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); |
| 1110 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | |
| 1110 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); | |
| 1111 | 1111 | const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; |
| 1112 | 1112 | const commitAndClose = useCallback(() => { |
| 1113 | 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 | 21 | |
| 22 | 22 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; |
| 23 | 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | import { NativeSheetContent } from "../../../native/NativeSheetContent"; | |
| 24 | 25 | import { useUniwindTheme } from "../../../lib/useUniwindTheme"; |
| 25 | 26 | import { |
| 26 | 27 | AndroidHeaderIconButton, |
| ... | ... | @@ -411,7 +412,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 411 | 412 | scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} |
| 412 | 413 | style={{ backgroundColor: sheetColor, flex: 1 }} |
| 413 | 414 | > |
| 414 | {content} | |
| 415 | <NativeSheetContent>{content}</NativeSheetContent> | |
| 415 | 416 | <ScreenStackHeaderConfig |
| 416 | 417 | backgroundColor="rgba(0,0,0,0)" |
| 417 | 418 | color={foregroundColor} |
| ... | ... | @@ -447,7 +448,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 447 | 448 | scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} |
| 448 | 449 | style={{ backgroundColor: sheetColor, flex: 1 }} |
| 449 | 450 | > |
| 450 | {content} | |
| 451 | <NativeSheetContent>{content}</NativeSheetContent> | |
| 451 | 452 | <ScreenStackHeaderConfig |
| 452 | 453 | backgroundColor="rgba(0,0,0,0)" |
| 453 | 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 | 34 | /** |
| 35 | 35 | * Right-side UINavigationBar items for the sidebar column: the thread list |
| 36 | 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 | 40 | export function createSidebarHeaderItems(input: { |
| 40 | 41 | readonly filterIcon: string; |
| ... | ... | @@ -44,6 +45,7 @@ export function createSidebarHeaderItems(input: { |
| 44 | 45 | return [ |
| 45 | 46 | withNativeGlassHeaderItem({ |
| 46 | 47 | type: "menu", |
| 48 | axisBehavior: "horizontalOnly", | |
| 47 | 49 | label: "", |
| 48 | 50 | accessibilityLabel: "Filter threads", |
| 49 | 51 | icon: sfSymbolIcon(input.filterIcon), |
| ... | ... | @@ -54,7 +56,8 @@ export function createSidebarHeaderItems(input: { |
| 54 | 56 | }), |
| 55 | 57 | withNativeGlassHeaderItem({ |
| 56 | 58 | type: "button", |
| 57 | label: "", | |
| 59 | axisBehavior: "horizontalOnly", | |
| 60 | label: "Settings", | |
| 58 | 61 | accessibilityLabel: "Open settings", |
| 59 | 62 | icon: sfSymbolIcon("gearshape"), |
| 60 | 63 | onPress: input.onOpenSettings, |
apps/mobile/src/features/threads/useThreadHeaderOptions.tsx+83-16| ... | ... | @@ -1,8 +1,11 @@ |
| 1 | 1 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 2 | 2 | import { useMemo } from "react"; |
| 3 | import { Platform } from "react-native"; | |
| 3 | 4 | import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; |
| 5 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; | |
| 4 | 6 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 5 | 7 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 8 | import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | |
| 6 | 9 | import { |
| 7 | 10 | ThreadGitControls, |
| 8 | 11 | useThreadGitCenterHeaderItems, |
| ... | ... | @@ -19,18 +22,24 @@ export function useThreadHeaderOptions(props: { |
| 19 | 22 | readonly gitControls: Parameters<typeof ThreadGitControls>[0]; |
| 20 | 23 | readonly onReturnToThread?: () => void; |
| 21 | 24 | }) { |
| 25 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); | |
| 26 | const usesDuoHeader = usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad; | |
| 22 | 27 | const navigation = useNavigation(); |
| 23 | 28 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
| 24 | 29 | const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls); |
| 25 | 30 | const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls); |
| 26 | 31 | const splitLeftHeaderItems = useMemo<NativeHeaderItems>( |
| 27 | 32 | () => [ |
| 28 | { | |
| 29 | // Match Mail's split-view detail toolbar: the first detail action sits | |
| 30 | // inside the content pane, not flush against the sidebar divider. | |
| 31 | spacing: 18, | |
| 32 | type: "spacing" as const, | |
| 33 | }, | |
| 33 | ...(!usesDuoHeader | |
| 34 | ? [ | |
| 35 | { | |
| 36 | // Match Mail's split-view detail toolbar: the first detail action sits | |
| 37 | // inside the content pane, not flush against the sidebar divider. | |
| 38 | spacing: 18, | |
| 39 | type: "spacing" as const, | |
| 40 | }, | |
| 41 | ] | |
| 42 | : []), | |
| 34 | 43 | ...(props.onReturnToThread |
| 35 | 44 | ? [ |
| 36 | 45 | withNativeGlassHeaderItem({ |
| ... | ... | @@ -43,6 +52,7 @@ export function useThreadHeaderOptions(props: { |
| 43 | 52 | ] |
| 44 | 53 | : []), |
| 45 | 54 | withNativeGlassHeaderItem({ |
| 55 | axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, | |
| 46 | 56 | accessibilityLabel: panes.primarySidebarVisible |
| 47 | 57 | ? "Maximize content" |
| 48 | 58 | : "Show thread sidebar", |
| ... | ... | @@ -51,18 +61,31 @@ export function useThreadHeaderOptions(props: { |
| 51 | 61 | type: "sfSymbol" as const, |
| 52 | 62 | }, |
| 53 | 63 | identifier: "thread-left-sidebar", |
| 64 | label: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar", | |
| 54 | 65 | onPress: togglePrimarySidebar, |
| 55 | 66 | type: "button" as const, |
| 56 | 67 | }), |
| 57 | withNativeGlassHeaderItem({ | |
| 58 | accessibilityLabel: "New task", | |
| 59 | icon: { name: "square.and.pencil", type: "sfSymbol" as const }, | |
| 60 | identifier: "thread-left-new-task", | |
| 61 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), | |
| 62 | type: "button" as const, | |
| 63 | }), | |
| 68 | ...(!usesDuoHeader | |
| 69 | ? [ | |
| 70 | withNativeGlassHeaderItem({ | |
| 71 | accessibilityLabel: "New task", | |
| 72 | icon: { name: "square.and.pencil", type: "sfSymbol" as const }, | |
| 73 | identifier: "thread-left-new-task", | |
| 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 | 90 | // Deep links / cold starts land with Thread as the ONLY route, where the |
| 68 | 91 | // native back button does not render. Provide an explicit Home escape for |
| ... | ... | @@ -81,6 +104,41 @@ export function useThreadHeaderOptions(props: { |
| 81 | 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 | 142 | const options: AppNativeStackNavigationOptions = { |
| 85 | 143 | headerShown: true, |
| 86 | 144 | headerTitle: props.title, |
| ... | ... | @@ -104,7 +162,12 @@ export function useThreadHeaderOptions(props: { |
| 104 | 162 | // the git controls on the RIGHT (no center items — center space is |
| 105 | 163 | // reserved for future breadcrumbs/status). |
| 106 | 164 | unstable_headerRightItems: () => |
| 107 | layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, | |
| 165 | usesDuoHeader | |
| 166 | ? duoRightHeaderItems | |
| 167 | : layout.usesSplitView | |
| 168 | ? threadCenterHeaderItems | |
| 169 | : compactRightHeaderItems, | |
| 170 | unstable_headerToolbarItems: () => [], | |
| 108 | 171 | unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, |
| 109 | 172 | contentStyle: undefined, |
| 110 | 173 | }; |
| ... | ... | @@ -116,7 +179,11 @@ export function useThreadHeaderOptions(props: { |
| 116 | 179 | // callbacks also read state the items do not display (a "Push" item runs `push` or |
| 117 | 180 | // `commit_push` depending on the default ref), so that state is keyed too. |
| 118 | 181 | optionsVersion: [ |
| 119 | layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, | |
| 182 | splitLeftHeaderItems, | |
| 183 | threadCenterHeaderItems, | |
| 184 | compactRightHeaderItems, | |
| 185 | duoRightHeaderItems, | |
| 186 | usesDuoHeader, | |
| 120 | 187 | environmentId, |
| 121 | 188 | threadId, |
| 122 | 189 | gitStatus?.isDefaultRef, |
apps/mobile/src/lib/layout.ts+37-10| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { scaledTypographyLineHeight } from "./appearancePreferences"; |
| 2 | 2 | import { MOBILE_TYPOGRAPHY } from "./typography"; |
| 3 | import type { NativeLayoutMetrics } from "./reserved-regions"; | |
| 3 | 4 | |
| 4 | 5 | function clamp(value: number, min: number, max: number): number { |
| 5 | 6 | return Math.min(Math.max(value, min), max); |
| ... | ... | @@ -56,6 +57,7 @@ export interface Layout { |
| 56 | 57 | readonly variant: LayoutVariant; |
| 57 | 58 | readonly usesSplitView: boolean; |
| 58 | 59 | readonly listPaneWidth: number | null; |
| 60 | readonly listPaneGap?: number; | |
| 59 | 61 | readonly shellPadding: number; |
| 60 | 62 | } |
| 61 | 63 | |
| ... | ... | @@ -87,9 +89,25 @@ export function deriveThreadFeedInitialContentInset(input: { |
| 87 | 89 | |
| 88 | 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 | 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 | 112 | if (!wideEnoughForSplit) { |
| 95 | 113 | return { |
| ... | ... | @@ -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 | 128 | return { |
| 104 | 129 | variant: "split", |
| 105 | 130 | usesSplitView: true, |
| 106 | listPaneWidth: clamp( | |
| 107 | Math.round(width * 0.32), | |
| 108 | SPLIT_SIDEBAR_MIN_WIDTH, | |
| 109 | SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH, | |
| 110 | ), | |
| 131 | listPaneWidth: | |
| 132 | division?.x ?? | |
| 133 | clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH), | |
| 134 | ...(division ? { listPaneGap: division.width } : {}), | |
| 111 | 135 | shellPadding: 0, |
| 112 | 136 | }; |
| 113 | 137 | } |
| ... | ... | @@ -125,7 +149,7 @@ export function deriveWorkspacePaneLayout(input: { |
| 125 | 149 | const preferredPrimarySidebarVisible = |
| 126 | 150 | input.layout.usesSplitView && input.primarySidebarPreferredVisible; |
| 127 | 151 | const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible |
| 128 | ? (input.layout.listPaneWidth ?? 0) | |
| 152 | ? (input.layout.listPaneWidth ?? 0) + (input.layout.listPaneGap ?? 0) | |
| 129 | 153 | : 0; |
| 130 | 154 | |
| 131 | 155 | if (auxiliaryPaneRole === "inspector") { |
| ... | ... | @@ -141,11 +165,14 @@ export function deriveWorkspacePaneLayout(input: { |
| 141 | 165 | auxiliaryPaneVisible && |
| 142 | 166 | fileInspector.width !== null && |
| 143 | 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 | 172 | FILE_INSPECTOR_MIN_MAIN_WIDTH; |
| 146 | 173 | const primarySidebarVisible = |
| 147 | 174 | preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; |
| 148 | const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0; | |
| 175 | const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0; | |
| 149 | 176 | |
| 150 | 177 | return { |
| 151 | 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 | /** 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 | 9 | export interface NativeLayoutMetrics { |
| 3 | 10 | readonly width: number; |
| 4 | 11 | readonly height: number; |
| 12 | readonly hasHinge?: boolean; | |
| 5 | 13 | readonly horizontalSizeClass: "compact" | "regular"; |
| 14 | readonly verticalBarEdge: "none" | "left" | "right"; | |
| 6 | 15 | readonly safeArea: { |
| 7 | 16 | readonly top: number; |
| 8 | 17 | readonly bottom: number; |
| 9 | 18 | readonly left: number; |
| 10 | 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 | 9 | export function NativeColumnContent(props: { |
| 10 | 10 | readonly children: ReactNode; |
| 11 | 11 | readonly primary?: boolean; |
| 12 | readonly insetHorizontally?: boolean; | |
| 12 | 13 | }) { |
| 13 | 14 | const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null); |
| 14 | 15 | return ( |
| 15 | 16 | <View className={props.primary ? "flex-1 bg-drawer" : "flex-1 bg-screen"}> |
| 16 | 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 | 22 | {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} |
| 19 | 23 | <View style={{ flex: 1 }}> |
| 20 | 24 | <NativeLayoutObserver onChange={setMetrics} /> |
apps/mobile/src/native/NativeHeaderToolbar.tsx+25-7| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | type ReactNode, |
| 15 | 15 | } from "react"; |
| 16 | 16 | import type { ColorValue } from "react-native"; |
| 17 | import type { HeaderBarButtonSearchBarPlacementItem } from "react-native-screens"; | |
| 17 | 18 | |
| 18 | 19 | function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null { |
| 19 | 20 | return useNavigation<NativeStackNavigationProp<ParamListBase>>(); |
| ... | ... | @@ -34,8 +35,13 @@ function labelFromChildren(children: ReactNode): string { |
| 34 | 35 | type NativeStackHeaderIcon = NonNullable< |
| 35 | 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 | 43 | type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { |
| 38 | unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; | |
| 44 | unstable_headerToolbarItems?: () => NativeToolbarItem[]; | |
| 39 | 45 | }; |
| 40 | 46 | |
| 41 | 47 | function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { |
| ... | ... | @@ -112,7 +118,7 @@ function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu" |
| 112 | 118 | return items; |
| 113 | 119 | } |
| 114 | 120 | |
| 115 | function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { | |
| 121 | function convertToolbarChild(child: ReactNode): NativeToolbarItem | null { | |
| 116 | 122 | if (!isValidElement<ToolbarElementProps>(child)) { |
| 117 | 123 | return null; |
| 118 | 124 | } |
| ... | ... | @@ -121,15 +127,22 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { |
| 121 | 127 | if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) { |
| 122 | 128 | return { type: "custom", element: child.props.children }; |
| 123 | 129 | } |
| 130 | if (typeName === "NativeHeaderToolbarSearchBarSlot") { | |
| 131 | return { type: "searchBarPlacement" }; | |
| 132 | } | |
| 124 | 133 | |
| 125 | 134 | if (typeName === "NativeHeaderToolbarButton") { |
| 126 | 135 | return { |
| 127 | 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 | 141 | accessibilityLabel: |
| 130 | 142 | typeof child.props.accessibilityLabel === "string" |
| 131 | 143 | ? child.props.accessibilityLabel |
| 132 | 144 | : undefined, |
| 145 | axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior, | |
| 133 | 146 | disabled: Boolean(child.props.disabled), |
| 134 | 147 | icon: iconFromProp(child.props.icon), |
| 135 | 148 | onPress: |
| ... | ... | @@ -173,8 +186,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { |
| 173 | 186 | return null; |
| 174 | 187 | } |
| 175 | 188 | |
| 176 | function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { | |
| 177 | const items: NativeStackHeaderItem[] = []; | |
| 189 | function collectToolbarItems(children: ReactNode): NativeToolbarItem[] { | |
| 190 | const items: NativeToolbarItem[] = []; | |
| 178 | 191 | Children.forEach(children, (child) => { |
| 179 | 192 | const item = convertToolbarChild(child); |
| 180 | 193 | if (item) { |
| ... | ... | @@ -211,13 +224,17 @@ function NativeHeaderToolbarRoot(props: { |
| 211 | 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 | 231 | if (props.placement === "left") { |
| 215 | navigation.setOptions({ unstable_headerLeftItems: () => items }); | |
| 232 | navigation.setOptions({ unstable_headerLeftItems: () => headerItems }); | |
| 216 | 233 | return () => { |
| 217 | 234 | navigation.setOptions({ unstable_headerLeftItems: () => [] }); |
| 218 | 235 | }; |
| 219 | 236 | } |
| 220 | navigation.setOptions({ unstable_headerRightItems: () => items }); | |
| 237 | navigation.setOptions({ unstable_headerRightItems: () => headerItems }); | |
| 221 | 238 | return () => { |
| 222 | 239 | navigation.setOptions({ unstable_headerRightItems: () => [] }); |
| 223 | 240 | }; |
| ... | ... | @@ -228,6 +245,7 @@ function NativeHeaderToolbarRoot(props: { |
| 228 | 245 | |
| 229 | 246 | function NativeHeaderToolbarButton(_props: { |
| 230 | 247 | readonly accessibilityLabel?: string; |
| 248 | readonly axisBehavior?: NativeStackHeaderAxisBehavior; | |
| 231 | 249 | readonly disabled?: boolean; |
| 232 | 250 | readonly icon?: string; |
| 233 | 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 | 1 | import { requireOptionalNativeModule } from "expo"; |
| 2 | 2 | import { Platform } from "react-native"; |
| 3 | const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>( | |
| 4 | "T3NativeControls", | |
| 5 | ); | |
| 3 | import { useNativeLayoutMetrics } from "./native-layout-metrics"; | |
| 4 | ||
| 5 | const nativeControls = requireOptionalNativeModule<{ | |
| 6 | readonly supportsWorkspaceColumns?: boolean; | |
| 7 | readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown }; | |
| 8 | }>("T3NativeControls"); | |
| 6 | 9 | export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = |
| 7 | 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 | 1 | import { Platform } from "react-native"; |
| 2 | 2 | // Android retains its existing navigator. The iOS file checks native availability. |
| 3 | 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 | 19 | NativeStackNavigationOptions, |
| 20 | 20 | "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems" |
| 21 | 21 | > & { |
| 22 | /** Nested navigator leaves apply their own horizontal safe area. */ | |
| 23 | readonly nativeContentInsetHorizontally?: boolean; | |
| 22 | 24 | readonly headerTintColor?: string | ColorValue; |
| 23 | 25 | readonly unstable_headerCenterItems?: unknown; |
| 24 | 26 | readonly unstable_headerLeftItems?: unknown; |
apps/mobile/src/native/V5StackHeader.ios.tsx+23-1| ... | ... | @@ -6,6 +6,7 @@ import type { |
| 6 | 6 | import { isValidElement, useRef } from "react"; |
| 7 | 7 | import { Platform, StyleSheet } from "react-native"; |
| 8 | 8 | import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; |
| 9 | import { useNativeLayoutMetrics } from "../native/native-layout-metrics"; | |
| 9 | 10 | import { |
| 10 | 11 | SearchBar, |
| 11 | 12 | Stack, |
| ... | ... | @@ -110,6 +111,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte |
| 110 | 111 | id, |
| 111 | 112 | identifier: item.identifier, |
| 112 | 113 | title: item.label || item.accessibilityLabel || undefined, |
| 114 | axisBehavior: item.axisBehavior, | |
| 113 | 115 | icon: convertIcon(item.icon), |
| 114 | 116 | menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), |
| 115 | 117 | hidesSharedBackground: item.hidesSharedBackground, |
| ... | ... | @@ -120,6 +122,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte |
| 120 | 122 | id, |
| 121 | 123 | identifier: item.identifier, |
| 122 | 124 | title: item.label || item.accessibilityLabel || undefined, |
| 125 | axisBehavior: item.axisBehavior, | |
| 123 | 126 | icon: convertIcon(item.icon), |
| 124 | 127 | onPress: item.disabled ? undefined : item.onPress, |
| 125 | 128 | hidesSharedBackground: item.hidesSharedBackground, |
| ... | ... | @@ -136,6 +139,7 @@ export function V5StackHeader(props: { |
| 136 | 139 | }) { |
| 137 | 140 | const { options } = props; |
| 138 | 141 | const theme = useMobileNavigationTheme(); |
| 142 | const nativeMetrics = useNativeLayoutMetrics(); | |
| 139 | 143 | const searchRef = useRef<SearchBarCommands>(null); |
| 140 | 144 | const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; |
| 141 | 145 | const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); |
| ... | ... | @@ -162,6 +166,7 @@ export function V5StackHeader(props: { |
| 162 | 166 | bottom.push({ |
| 163 | 167 | type: "item", |
| 164 | 168 | id: mailSearch.filterButtonId ?? "filter", |
| 169 | axisBehavior: bottomSearch ? "horizontalOnly" : undefined, | |
| 165 | 170 | icon: { |
| 166 | 171 | type: "sfSymbol", |
| 167 | 172 | name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", |
| ... | ... | @@ -171,7 +176,12 @@ export function V5StackHeader(props: { |
| 171 | 176 | if (bottomSearch) { |
| 172 | 177 | if (bottom.length) |
| 173 | 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 | 185 | if (mailSearch.onComposePress) |
| 176 | 186 | bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); |
| 177 | 187 | } |
| ... | ... | @@ -179,10 +189,22 @@ export function V5StackHeader(props: { |
| 179 | 189 | bottom.push({ |
| 180 | 190 | type: "item", |
| 181 | 191 | id: mailSearch.composeButtonId ?? "compose", |
| 192 | axisBehavior: bottomSearch ? "horizontalOnly" : undefined, | |
| 182 | 193 | icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, |
| 183 | 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 | 208 | const left = options.headerLeft?.(itemProps); |
| 187 | 209 | const right = options.headerRight?.(itemProps); |
| 188 | 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 | 24 | import { StyleSheet, View } from "react-native"; |
| 25 | 25 | import { FormSheet, Stack } from "react-native-screens"; |
| 26 | 26 | import { V5StackHeader } from "./V5StackHeader.ios"; |
| 27 | import type { AppNativeStackNavigationOptions } from "./StackHeader"; | |
| 27 | 28 | import { NativeColumnContent } from "./NativeColumnContent.ios"; |
| 28 | 29 | import { |
| 29 | 30 | nativeStackPopAction, |
| ... | ... | @@ -232,7 +233,14 @@ export function V5CardStackView(props: V5StackViewProps) { |
| 232 | 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 | 244 | </NavigationRouteContext> |
| 237 | 245 | </NavigationContext> |
| 238 | 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 | 1 | # Building the mobile Screens dependency |
| 2 | 2 | |
| 3 | 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 | 5 | The fork owns the integration changes; the app does not patch this package. |
| 6 | 6 | Each archive includes compiled JavaScript, declarations, native source and |
| 7 | 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 | 9 | To reproduce the current archive with Node 24 and the repository's pinned Yarn: |
| 10 | 10 | |
| 11 | 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 | 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 | 20 | Replace the old archive, update `apps/mobile/package.json` and run `vp i` to |
| 21 | 21 | regenerate the lockfile. Verify mobile types and affected navigation tests. |
| 22 | 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 | 67 | } |
| 68 | 68 | return item; |
| 69 | 69 | } |
| 70 | + if (item.type === 'mailSearchToolbar') { | |
| 70 | + if (item.type === 'mailSearchToolbar' || item.type === 'searchBarPlacement') { | |
| 71 | 71 | + return { |
| 72 | 72 | + ...item, |
| 73 | 73 | + index |
| ... | ... | @@ -81,7 +81,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d |
| 81 | 81 | return processedItem; |
| 82 | 82 | } |
| 83 | 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 | 85 | }).filter(item => item != null); |
| 86 | 86 | }; |
| 87 | 87 | const transformIcon = icon => { |
| ... | ... | @@ -190,6 +190,10 @@ index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb215 |
| 190 | 190 | /** |
| 191 | 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 | 197 | diff --git a/src/types.tsx b/src/types.tsx |
| 194 | 198 | index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644 |
| 195 | 199 | --- a/src/types.tsx |
| ... | ... | @@ -208,6 +212,10 @@ index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9e |
| 208 | 212 | /** |
| 209 | 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 | 219 | diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx |
| 212 | 220 | index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644 |
| 213 | 221 | --- a/src/views/useHeaderConfigProps.tsx |
pnpm-lock.yaml+10-10| ... | ... | @@ -154,7 +154,7 @@ patchedDependencies: |
| 154 | 154 | '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0 |
| 155 | 155 | '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d |
| 156 | 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 | 158 | alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd |
| 159 | 159 | dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 |
| 160 | 160 | effect@4.0.2: ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688 |
| ... | ... | @@ -346,7 +346,7 @@ importers: |
| 346 | 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 | 347 | '@react-navigation/native-stack': |
| 348 | 348 | specifier: 7.17.6 |
| 349 | version: 7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06) | |
| 349 | version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cc0272b81e52c9a28f5defc327cee1b5) | |
| 350 | 350 | '@shikijs/core': |
| 351 | 351 | specifier: 4.2.0 |
| 352 | 352 | version: 4.2.0 |
| ... | ... | @@ -549,8 +549,8 @@ importers: |
| 549 | 549 | specifier: ~5.9.1 |
| 550 | 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 | 551 | react-native-screens: |
| 552 | specifier: file:deps/react-native-screens-5.0.0-t3.7.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) | |
| 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.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 | 554 | react-native-shiki-engine: |
| 555 | 555 | specifier: ^0.3.12 |
| 556 | 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 | 10798 | react: '*' |
| 10799 | 10799 | react-native: '*' |
| 10800 | 10800 | |
| 10801 | react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz: | |
| 10802 | resolution: {integrity: sha512-Di1+YTCuhWpk9helKBxkvXAVAQTCSRo24tpFBGXdbTAT4vPm2vGPbtPe+lnIXcePjNHbTEaI1ha5n9PqQKl5lg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz} | |
| 10803 | version: 5.0.0-t3.7 | |
| 10801 | react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.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.8 | |
| 10804 | 10804 | peerDependencies: |
| 10805 | 10805 | react: '*' |
| 10806 | 10806 | react-native: '*' |
| ... | ... | @@ -15971,7 +15971,7 @@ snapshots: |
| 15971 | 15971 | optionalDependencies: |
| 15972 | 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 | 15975 | dependencies: |
| 15976 | 15976 | '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) |
| 15977 | 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 | 15979 | react: 19.3.0 |
| 15980 | 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 | 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 | 15983 | sf-symbols-typescript: 2.2.0 |
| 15984 | 15984 | warn-once: 0.1.1 |
| 15985 | 15985 | transitivePeerDependencies: |
| ... | ... | @@ -21967,7 +21967,7 @@ snapshots: |
| 21967 | 21967 | react: 19.3.0 |
| 21968 | 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 | 21971 | dependencies: |
| 21972 | 21972 | react: 19.3.0 |
| 21973 | 21973 | react-freeze: 1.0.4(react@19.3.0) |