Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2fc0797
feat(mobile): prototype Duo with v5 native column stacks
juliusmarminge Sep 19, 2026
6d64fdf
fix(mobile): expose primary actions on Duo vertical bars
juliusmarminge Sep 19, 2026
c54d340
fix(mobile): reconcile Duo geometry with current shared navigation
juliusmarminge Sep 30, 2026
f57e585
fix(mobile): keep native toolbar geometry below feature boundary
juliusmarminge Sep 30, 2026
76aa04f
style(mobile): format restacked Duo gating and geometry
juliusmarminge Sep 30, 2026
b419fce
fix(mobile): preserve zero header height on Duo after V2 rebase
juliusmarminge Oct 2, 2026
16886b4
style(mobile): format synced Duo providers
juliusmarminge Oct 6, 2026
bcc883c
fix(mobile): restore Duo phone toolbar and action groups
juliusmarminge Oct 8, 2026
7ae1057
fix(mobile): give expanded Duo sidebar search its own row
juliusmarminge Oct 8, 2026
0997092
fix(mobile): apply Duo Settings side insets once
juliusmarminge Oct 8, 2026
34bcaa8
feat(mobile): add thread search to the Duo action rail
juliusmarminge Oct 8, 2026
5ed51b8
fix(mobile): hide redundant Duo rail search
juliusmarminge Oct 8, 2026
bca52ad
fix(mobile): preserve iPad headers and reveal Duo search
juliusmarminge Oct 8, 2026
565ef28
fix(mobile): isolate Duo previews from standard layouts
juliusmarminge Oct 8, 2026
eb6ed98
fix(mobile): align Duo folds without changing iPad scroll insets
juliusmarminge Oct 8, 2026
2bf9610
refactor(mobile): remove unused Duo header branches
juliusmarminge Oct 8, 2026
100a7eb
fix(mobile): apply nested inset ownership in workspace columns
juliusmarminge Oct 8, 2026
2bb5af3
fix(mobile): separate Duo native runtime fingerprints
juliusmarminge Oct 8, 2026
a467d20
fix(mobile): detect Duo in the shared iOS app
juliusmarminge Oct 9, 2026
b8969e0
fix(mobile): match Duo sheets to native workspace layout
juliusmarminge Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ const config: ExpoConfig = {
"keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`],
},
infoPlist: {
UISupportedInterfaceOrientations: [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
],
NSAppTransportSecurity: {
NSAllowsArbitraryLoads: true,
},
Expand Down
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]
"modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"],
"appDelegateSubscribers": ["T3WorkspaceOrientationSubscriber"]
},
"android": {
"modules": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ public final class T3KeyboardCommandsModule: Module {
public final class T3KeyboardCommandsView: ExpoView {
let onCommand = EventDispatcher()
private var enabledCommands = Set<String>()
private let shortcutInputView = UIView(frame: .zero)

public override var canBecomeFirstResponder: Bool { true }

// This responder handles hardware shortcuts, so reclaiming it must not show a software keyboard.
public override var inputView: UIView? { shortcutInputView }

public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss),
let input = window?.t3FirstResponder as? UITextInput,
Expand Down Expand Up @@ -89,6 +93,8 @@ public final class T3KeyboardCommandsView: ExpoView {

public required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
inputAssistantItem.leadingBarButtonGroups = []
inputAssistantItem.trailingBarButtonGroups = []
NotificationCenter.default.addObserver(
self,
selector: #selector(reclaimFirstResponderIfAvailable),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import ExpoModulesCore
import UIKit

/// Reports the body geometry of a native navigation column.
/// Reports geometry in the observing view's coordinate space, including system
/// reservations that safe-area rectangles cannot represent, such as the fold.
final class T3LayoutMetricsView: ExpoView {
let onMetricsChange = EventDispatcher()
private var lastMetrics: NSDictionary?
private var hasHinge: Bool? = false

required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
isUserInteractionEnabled = false
registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in
view.publishMetrics()
}
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
hasHinge = nil
addInteraction(UIHingeInteraction { [weak self] _, update in
guard let self, self.window != nil else { return }
// Capability survives temporary detachment and does not follow the hinge angle.
let hasHinge = self.hasHinge == true || update.hinge != nil
guard self.hasHinge != hasHinge else { return }
self.hasHinge = hasHinge
self.publishMetrics()
})
registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3LayoutMetricsView, _: UITraitCollection) in
view.publishMetrics()
}
}
#endif
}

override func didMoveToWindow() {
Expand All @@ -31,14 +49,43 @@ final class T3LayoutMetricsView: ExpoView {
}

private func publishMetrics() {
guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
guard let window, bounds.width > 0, bounds.height > 0, let hasHinge else { return }
if hasHinge {
T3WorkspaceOrientationSubscriber.allowHingeOrientations(in: window)
}
var verticalBarEdge = "none"
var regions: [[String: Any]] = []
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft
switch traitCollection.verticalBarEdge {
case .leading: verticalBarEdge = rtl ? "right" : "left"
case .trailing: verticalBarEdge = rtl ? "left" : "right"
default: break
}
for kind in [UIView.ReservedRegion.Kind.division, .occlusion] {
for region in reservedRegions(kind: kind) where region.isActive {
let frame = region.frame.intersection(bounds)
guard !frame.isNull, !frame.isEmpty else { continue }
regions.append([
"kind": kind == .division ? "division" : "occlusion",
"x": frame.minX, "y": frame.minY,
"width": frame.width, "height": frame.height,
])
}
}
}
#endif
let metrics: [String: Any] = [
"width": bounds.width, "height": bounds.height,
"hasHinge": hasHinge,
"horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",
"verticalBarEdge": verticalBarEdge,
"safeArea": [
"top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom,
"left": safeAreaInsets.left, "right": safeAreaInsets.right,
],
"reservedRegions": regions,
]
let next = metrics as NSDictionary
guard lastMetrics?.isEqual(next) != true else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public final class T3NativeControlsModule: Module {
public func definition() -> ModuleDefinition {
Constants {
if #available(iOS 26.0, *) {
return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil]
return [
"supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil,
]
}
return ["supportsWorkspaceColumns": false]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import ExpoModulesCore
import UIKit

/// Keep ordinary phones portrait-only while allowing a hinge-backed scene to rotate.
public final class T3WorkspaceOrientationSubscriber: ExpoAppDelegateSubscriber {
private static let hingeWindows = NSHashTable<UIWindow>.weakObjects()

static func allowHingeOrientations(in window: UIWindow) {
guard !hingeWindows.contains(window) else { return }
hingeWindows.add(window)
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
}

public func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .pad {
// Keep iPad orientation policy, including portrait-only showcase captures.
let orientations = Bundle.main.object(forInfoDictionaryKey: "UISupportedInterfaceOrientations~ipad") as? [String] ?? []
return orientations == ["UIInterfaceOrientationPortrait"] ? .portrait : .all
}
return window.map { Self.hingeWindows.contains($0) } == true ? .allButUpsideDown : .portrait
}
}
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
"react-native-nitro-modules": "0.35.9",
"react-native-reanimated": "4.7.0",
"react-native-safe-area-context": "~5.9.1",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.8.tgz",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.15.5",
"react-native-webview": "14.0.1",
Expand Down
13 changes: 8 additions & 5 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics";

import "../global.css";

Expand Down Expand Up @@ -91,11 +92,13 @@ function AppContent() {
header (glass buttons, title, materials) is forced light even when
the system is in dark mode. */}
<GlobalVoiceInputControl>
<IncomingShareProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
</IncomingShareProvider>
<ConfirmDialogHost />
<ThreadArrangementHost />
<NativeLayoutMetricsProvider>
<IncomingShareProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
</IncomingShareProvider>
<ConfirmDialogHost />
<ThreadArrangementHost />
</NativeLayoutMetricsProvider>
</GlobalVoiceInputControl>
{/* Anchored-menu overlays render here — in-window, so the
keyboard stays up while a dropdown is open. */}
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import {
} from "./features/sharing/incoming-share-presentation";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
import { deriveLayout } from "./lib/layout";
import { useNativeLayoutMetrics } from "./native/native-layout-metrics";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
Expand Down Expand Up @@ -192,6 +193,9 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
presentation: "fullScreenModal",
};

// A navigator container leaves horizontal safe-area handling to its leaf screens.
const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false };

const SettingsContentStack = createV5SheetStackNavigator({
initialRouteName: "Settings",
screenOptions: {
Expand Down Expand Up @@ -410,6 +414,7 @@ const SettingsSheetStack = createV5SheetStackNavigator({
screens: {
SettingsContent: createNativeStackScreen({
screen: SettingsContentStack,
options: NESTED_NAVIGATOR_OPTIONS,
linking: "",
layout: ({ children }) => (
<SettingsEnvironmentFilterProvider>
Expand Down Expand Up @@ -845,6 +850,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
screen: SettingsSheetStack,
linking: "settings",
options: {
...NESTED_NAVIGATOR_OPTIONS,
gestureEnabled: true,
headerShown: false,
},
Expand Down Expand Up @@ -911,6 +917,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
</GuardedScreenLayout>
),
options: {
...NESTED_NAVIGATOR_OPTIONS,
gestureEnabled: true,
headerShown: false,
},
Expand Down Expand Up @@ -951,8 +958,9 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName:

export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
const { width, height } = useWindowDimensions();
const nativeMetrics = useNativeLayoutMetrics();
const usesWorkspaceFlowScreens =
Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView;
Platform.OS === "android" || deriveLayout({ width, height, nativeMetrics }).usesSplitView;

return (
<Navigator
Expand Down
16 changes: 12 additions & 4 deletions apps/mobile/src/components/ScreenHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
import { useId } from "react";
import { ActivityIndicator, View } from "react-native";
import { ActivityIndicator, Platform, View } from "react-native";
import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
import { ScreenHeaderButton } from "./ScreenHeaderButton";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";
import { useNativeWorkspaceColumnsSupported } from "../native/NativeWorkspaceColumns";
import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout";
import {
createNativeMailSearchToolbarItem,
Expand All @@ -12,6 +13,7 @@ import {
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types";
import type { AppSymbolName } from "./AppSymbol";
import { useNativeMailSearchToolbar } from "../native/use-native-mail-search-toolbar";

function iosIcon(icon: AppSymbolName) {
return typeof icon === "string" ? icon : icon.ios;
Expand All @@ -33,15 +35,17 @@ function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["it
}

export function ScreenHeader(props: ScreenHeaderProps) {
const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
const headerId = useId();
const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
const { themeVariables } = useAppearancePreferences();
const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
const { search, menus } = props;
const menu = menus?.[0];
const compactSearch =
search !== undefined &&
(search.compactToolbar ?? !layout.usesSplitView) &&
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
usesNativeMailSearchToolbar;
const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);
const refresh = search?.refreshInToolbar ? search.onRefresh : undefined;
return (
Expand Down Expand Up @@ -88,7 +92,7 @@ export function ScreenHeader(props: ScreenHeaderProps) {
: undefined),
}),
]
: undefined,
: () => [],
headerSearchBarOptions: compactSearch
? undefined
: {
Expand Down Expand Up @@ -116,8 +120,12 @@ export function ScreenHeader(props: ScreenHeaderProps) {
onPress={props.backInSplitView.onPress ?? props.onBack}
/>
) : null}
{props.sidebar !== false ? (
{props.sidebar !== false &&
(!usesNativeWorkspaceColumns ||
(Platform.OS === "ios" && Platform.isPad) ||
!panes.primarySidebarVisible) ? (
<ScreenHeaderButton
axisBehavior={usesNativeWorkspaceColumns ? "horizontalOnly" : undefined}
accessibilityLabel={
panes.primarySidebarVisible
? `Maximize ${props.title.toLowerCase()}`
Expand Down
Loading
Loading