diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz
new file mode 100644
index 000000000000..572dfa37cede
Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz differ
diff --git a/apps/mobile/fingerprint.config.cjs b/apps/mobile/fingerprint.config.cjs
new file mode 100644
index 000000000000..f1ac7e2c9d59
--- /dev/null
+++ b/apps/mobile/fingerprint.config.cjs
@@ -0,0 +1,3 @@
+// SDK 58 fingerprints native packages by version by default. Our pinned
+// Screens PoC needs native source changes to invalidate the development client.
+module.exports = { nativeModuleSourceType: "files" };
diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm
index 8fa5abfe2a8e..e15c60794e1e 100644
--- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm
+++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm
@@ -287,6 +287,7 @@ @implementation T3MarkdownText {
UILongPressGestureRecognizer *_longPressGestureRecognizer;
UITapGestureRecognizer *_pressGestureRecognizer;
NSArray *_contextAccessibilityElements;
+ BOOL _textLayoutNeedsUpdate;
}
+ (ComponentDescriptorProvider)componentDescriptorProvider
@@ -378,16 +379,18 @@ - (void)prepareForRecycle
_textView.frame = CGRectZero;
_textView.attributedText = nil;
_contextAccessibilityElements = nil;
+ _textLayoutNeedsUpdate = YES;
}
- (void)layoutSubviews
{
[super layoutSubviews];
- // _textView's frame is assigned inside drawRect, which only fires when
- // state changes. Trigger a redraw whenever the host frame moves out from
- // under it (rotation, parent relayout) so the text view resizes and
- // onTextLayout re-fires with the new line wrapping.
+ // Resize during UIKit's layout pass. Doing this in drawRect can leave the
+ // text view's drawing surface clipped to its previous width after a pane
+ // expands, even though TextKit has already rewrapped the lines.
if (!CGRectEqualToRect(_textView.frame, _view.frame)) {
+ _textView.frame = _view.frame;
+ _textLayoutNeedsUpdate = YES;
[self setNeedsDisplay];
}
}
@@ -449,7 +452,7 @@ - (void)drawRect:(CGRect)rect
// entirely when nothing actually changed so a JS-side state update made in
// response to onSelectionChange doesn't deselect what the user is selecting.
const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString];
- const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame);
+ const BOOL frameChanged = _textLayoutNeedsUpdate;
if (!textChanged && !frameChanged) {
return;
}
@@ -475,9 +478,7 @@ - (void)drawRect:(CGRect)rect
}
_suppressSelectionChange = NO;
}
- if (frameChanged) {
- _textView.frame = _view.frame;
- }
+ _textLayoutNeedsUpdate = NO;
// Text attachments have no native link element. Expose their existing runs
// at the measured glyph bounds, without inserting views into text layout.
diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift
new file mode 100644
index 000000000000..43070a614582
--- /dev/null
+++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift
@@ -0,0 +1,48 @@
+import ExpoModulesCore
+import UIKit
+
+/// Reports the body geometry of a native navigation column.
+final class T3LayoutMetricsView: ExpoView {
+ let onMetricsChange = EventDispatcher()
+ private var lastMetrics: NSDictionary?
+
+ required init(appContext: AppContext? = nil) {
+ super.init(appContext: appContext)
+ isUserInteractionEnabled = false
+ registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in
+ view.publishMetrics()
+ }
+ }
+
+ override func didMoveToWindow() {
+ super.didMoveToWindow()
+ lastMetrics = nil
+ publishMetrics()
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ publishMetrics()
+ }
+
+ override func safeAreaInsetsDidChange() {
+ super.safeAreaInsetsDidChange()
+ publishMetrics()
+ }
+
+ private func publishMetrics() {
+ guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
+ let metrics: [String: Any] = [
+ "width": bounds.width, "height": bounds.height,
+ "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",
+ "safeArea": [
+ "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom,
+ "left": safeAreaInsets.left, "right": safeAreaInsets.right,
+ ],
+ ]
+ let next = metrics as NSDictionary
+ guard lastMetrics?.isEqual(next) != true else { return }
+ lastMetrics = next
+ onMetricsChange(metrics)
+ }
+}
diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
index a5813c8a0e8b..e1a7f4d71650 100644
--- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
+++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
@@ -11,6 +11,14 @@ public final class T3NativeControlsModule: Module {
private var filePresentation: T3NativeFilePresentation?
public func definition() -> ModuleDefinition {
+ Constants {
+ ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil]
+ }
+ View(T3LayoutMetricsView.self) {
+ ViewName("LayoutMetrics")
+ Events("onMetricsChange")
+ }
+
AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
try self.presentVideo(
url: url,
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index ae52098e59dd..6b350d16e3f8 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -119,7 +119,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": "~4.28.0",
+ "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.2.tgz",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.15.5",
"react-native-webview": "14.0.1",
@@ -140,7 +140,8 @@
"expo": {
"install": {
"exclude": [
- "react-native-keyboard-controller"
+ "react-native-keyboard-controller",
+ "react-native-screens"
]
},
"autolinking": {
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 417473ba20e8..d448aa824df1 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -1,3 +1,5 @@
+import { createV5StackNavigator as createNativeStackNavigator } from "./native/createV5StackNavigator";
+import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator";
import {
createPathConfigForStaticNavigation,
getPathFromState,
@@ -6,7 +8,6 @@ import {
useNavigation,
} from "@react-navigation/native";
import {
- createNativeStackNavigator,
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
@@ -602,7 +603,7 @@ function NotFoundScreen() {
);
}
-const RootStackConfig = createNativeStackNavigator({
+const RootStackConfig = createWorkspaceStackNavigator({
initialRouteName: "Home",
layout: RootStackLayout,
screenOptions: {
diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx
index f64370c3cead..5120e7c1868d 100644
--- a/apps/mobile/src/components/CompactBrandTitle.tsx
+++ b/apps/mobile/src/components/CompactBrandTitle.tsx
@@ -1,3 +1,4 @@
+import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns";
import Constants from "expo-constants";
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { Platform, View } from "react-native";
@@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing";
*/
export function brandTitleOffset(): number {
if (Platform.OS !== "ios") return 0;
- return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0;
+ return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0;
}
/**
diff --git a/apps/mobile/src/components/ScreenHeader.tsx b/apps/mobile/src/components/ScreenHeader.tsx
index 6024ceec08d9..4a3877571966 100644
--- a/apps/mobile/src/components/ScreenHeader.tsx
+++ b/apps/mobile/src/components/ScreenHeader.tsx
@@ -53,6 +53,10 @@ export function ScreenHeader(props: ScreenHeaderProps) {
]}
options={{
headerShown: true,
+ headerBackVisible:
+ !layout.usesSplitView ||
+ !props.backInSplitView ||
+ !(props.backInSplitView.onPress || props.onBack),
title: props.title,
unstable_headerSubtitle: props.subtitle || undefined,
...(props.matchSearchSurface
diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
index 8d2a67000050..3123d785aced 100644
--- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
+++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
@@ -4,7 +4,7 @@ import {
deviceToolUpdatePolicy,
} from "@t3tools/client-runtime/state/device";
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
-import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx
index df18347f336c..8680f3b92be7 100644
--- a/apps/mobile/src/features/files/FileTreeBrowser.tsx
+++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx
@@ -258,7 +258,12 @@ export function FileTreeBrowser(props: {
updateCellsBatchingPeriod={16}
windowSize={5}
contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }}
- refreshControl={}
+ refreshControl={
+ 0}
+ onRefresh={props.onRefresh}
+ />
+ }
renderItem={renderItem}
ListHeaderComponent={
<>
diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx
index e1cc7448eb59..ac58bcc8c787 100644
--- a/apps/mobile/src/features/home/HomeHeader.tsx
+++ b/apps/mobile/src/features/home/HomeHeader.tsx
@@ -1,6 +1,8 @@
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
-import { useCallback, useRef } from "react";
+import { use, useCallback, useRef } from "react";
+import { Platform } from "react-native";
import type { SearchBarCommands } from "react-native-screens";
+import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
@@ -9,11 +11,14 @@ import {
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../layout/native-mail-search-toolbar";
import { buildHomeListFilterMenu } from "./home-list-filter-menu";
+import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items";
import type { HomeHeaderProps } from "./HomeHeader.types";
export type { HomeHeaderEnvironment } from "./HomeHeader.types";
export function HomeHeader(props: HomeHeaderProps) {
+ const primaryColumn = use(NativePrimaryColumnContext);
+ const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null;
const searchBarRef = useRef(null);
const iconColor = useUniwindTheme()["--color-icon"];
// The list uses a fixed creation order and ignores sort/group options, so
@@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) {
// Static header config (glass, title, fonts) lives in Stack.tsx
// (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
headerTintColor: iconColor,
- unstable_headerRightItems: () => [
- withNativeGlassHeaderItem({
- accessibilityLabel: "Open settings",
- icon: { name: "ellipsis", type: "sfSymbol" } as const,
- identifier: "home-settings",
- label: "",
- onPress: props.onOpenSettings,
- type: "button",
- }),
- ],
+ unstable_headerRightItems: () =>
+ iPadSidebar
+ ? createSidebarHeaderItems({
+ filterIcon: hasCustomListOptions
+ ? "line.3.horizontal.decrease.circle.fill"
+ : "line.3.horizontal.decrease.circle",
+ filterMenu,
+ onOpenSettings: props.onOpenSettings,
+ })
+ : [
+ withNativeGlassHeaderItem({
+ accessibilityLabel: "Open settings",
+ icon: { name: "ellipsis", type: "sfSymbol" } as const,
+ identifier: "home-settings",
+ label: "",
+ onPress: props.onOpenSettings,
+ type: "button",
+ }),
+ ],
// The keys below are set per-branch (not `undefined`) so a later
// reapply cannot clobber options owned by NativeHeaderToolbar.
- ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
+ ...(iPadSidebar
? {
- unstable_headerToolbarItems: () => [
- createNativeMailSearchToolbarItem({
- composeButtonId: "home-new-task",
- composeSystemImageName: "square.and.pencil",
- filterMenu,
- filterButtonId: "home-filter",
- filterSystemImageName: hasCustomListOptions
- ? "line.3.horizontal.decrease.circle.fill"
- : "line.3.horizontal.decrease",
- onComposePress: props.onStartNewTask,
- onSearchTextChange: props.onSearchQueryChange,
- placeholder: "Search",
- searchTextChangeId: "home-search-text",
- showsSearchDismissButton: true,
- }),
- ],
- }
- : {
- // Pre-Liquid-Glass iOS: standard pull-down search in the nav
- // bar; create + sort live in the plain bottom toolbar below.
headerSearchBarOptions: {
ref: searchBarRef,
autoCapitalize: "none" as const,
hideNavigationBar: false,
+ hideWhenScrolling: false,
+ obscureBackground: false,
+ placement: "stacked" as const,
+ allowToolbarIntegration: false,
placeholder: "Search",
- onCancelButtonPress: () => {
- props.onSearchQueryChange("");
+ onCancelButtonPress: () => props.onSearchQueryChange(""),
+ onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text),
+ },
+ unstable_headerToolbarItems: () => [],
+ }
+ : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
+ ? {
+ headerSearchBarOptions: {
+ ref: searchBarRef,
+ autoCapitalize: "none" as const,
+ onCancelButtonPress: () => props.onSearchQueryChange(""),
},
- onChangeText: (event) => {
- props.onSearchQueryChange(event.nativeEvent.text);
+ unstable_headerToolbarItems: () => [
+ createNativeMailSearchToolbarItem({
+ composeButtonId: "home-new-task",
+ composeSystemImageName: "square.and.pencil",
+ filterMenu,
+ filterButtonId: "home-filter",
+ filterSystemImageName: hasCustomListOptions
+ ? "line.3.horizontal.decrease.circle.fill"
+ : "line.3.horizontal.decrease",
+ onComposePress: props.onStartNewTask,
+ onSearchTextChange: props.onSearchQueryChange,
+ placeholder: "Search",
+ searchTextChangeId: "home-search-text",
+ showsSearchDismissButton: true,
+ }),
+ ],
+ }
+ : {
+ // Pre-Liquid-Glass iOS: standard pull-down search in the nav
+ // bar; create + sort live in the plain bottom toolbar below.
+ headerSearchBarOptions: {
+ ref: searchBarRef,
+ autoCapitalize: "none" as const,
+ hideNavigationBar: false,
+ placeholder: "Search",
+ onCancelButtonPress: () => {
+ props.onSearchQueryChange("");
+ },
+ onChangeText: (event) => {
+ props.onSearchQueryChange(event.nativeEvent.text);
+ },
},
- },
- }),
+ }),
}}
/>
- {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
+ {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
navigation.navigate("SettingsSheet", {
screen: "SettingsContent",
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index c54f1c12bf58..2f4a5c0d4f29 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -17,15 +17,18 @@ import {
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { useFocusEffect } from "@react-navigation/native";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import {
ActivityIndicator,
Platform,
+ ScrollView,
View,
+ type ScrollViewProps,
type GestureResponderEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
+import { ScrollViewMarker } from "react-native-screens";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -34,7 +37,10 @@ import { EmptyState } from "../../components/EmptyState";
import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
import type { SavedRemoteConnection } from "../../lib/connection";
-import { scopedProjectKey } from "../../lib/scopedEntities";
+import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
+import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
+import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects";
+import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { useThreadSearch } from "../../state/queries";
import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
@@ -221,9 +227,51 @@ function HomeTopContentSpacer() {
return ;
}
+function HomeScrollView(props: ComponentProps) {
+ const insets = useSafeAreaInsets();
+ if (Platform.OS !== "ios") return ;
+
+ // v5 needs the actual content scroll view registered with its screen controller.
+ // The layout observer and header siblings prevent UIKit from finding it implicitly.
+ return (
+ = 27 ? "automatic" : "soft",
+ }}
+ >
+
+
+ );
+}
+
+function renderHomeScrollView(props: ScrollViewProps) {
+ // FlatList clones this element with its cells and ref. Keep the marker inside
+ // the component so those cells remain children of the actual ScrollView.
+ return ;
+}
+
/* ─── Main screen ────────────────────────────────────────────────────── */
export function HomeScreen(props: HomeScreenProps) {
+ const primaryColumn = use(NativePrimaryColumnContext);
+ const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen";
+ const containerClassName = cn(
+ "flex-1",
+ Platform.OS === "android" ? "bg-header" : contentBackground,
+ );
+ const columnMetrics = useNativeColumnLayoutMetrics();
+ const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null;
+ const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined;
const queuedThreadKeys = useQueuedThreadKeys();
const openSwipeableRef = useRef(null);
const insets = useSafeAreaInsets();
@@ -623,10 +671,11 @@ export function HomeScreen(props: HomeScreenProps) {
now: new Date().toISOString(),
snoozedShelfExpanded,
settledShelfExpanded,
- selectedThreadKey: null,
+ selectedThreadKey,
});
}, [
pendingOrder,
+ selectedThreadKey,
queuedThreadKeys,
nowMinute,
snoozeWakeTick,
@@ -759,6 +808,9 @@ export function HomeScreen(props: HomeScreenProps) {
const thread = item.item.thread;
return (
+
+
{/* Shared with the iPad sidebar: cells are reused across data
@@ -1012,6 +1070,7 @@ export function HomeScreen(props: HomeScreenProps) {
onTouchStart={(event) => trackListTouches(event, true)}
onTouchEnd={(event) => trackListTouches(event, false)}
onTouchCancel={(event) => trackListTouches(event, false)}
+ renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined}
data={threadListV2Items}
renderItem={renderV2Item}
keyExtractor={v2KeyExtractor}
@@ -1040,6 +1099,7 @@ export function HomeScreen(props: HomeScreenProps) {
{...scrollGateHandlers}
scrollEventThrottle={16}
contentContainerStyle={{
+ paddingHorizontal: primaryColumn ? 8 : 0,
paddingBottom:
Platform.OS === "ios"
? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance
diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
index 60df38b30357..8a5af2026ec1 100644
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -61,6 +61,10 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE
import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
import { WorkspaceContentWidthContext } from "./workspace-content-width";
+import {
+ NativeWorkspaceModeContext,
+ NativeWorkspaceInspectorContext,
+} from "../../native/v5-workspace-context";
interface AdaptiveWorkspaceContextValue {
readonly layout: Layout;
@@ -234,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent(
},
) {
const projectGroupingMode = props.projectGroupingMode;
+ const nativeWorkspace = use(NativeWorkspaceModeContext);
const { width, height } = useWindowDimensions();
const pathname = props.pathname;
const navigation = useNavigation();
@@ -468,9 +473,10 @@ function AdaptiveWorkspaceLayoutContent(
panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,
);
useEffect(() => {
+ if (nativeWorkspace) return;
const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0;
renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
- }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]);
+ }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
opacity: Math.min(1, renderedSidebarWidth.value / 80),
width: renderedSidebarWidth.value,
@@ -565,6 +571,19 @@ function AdaptiveWorkspaceLayoutContent(
],
);
+ if (nativeWorkspace) {
+ return (
+
+
+ 0 }}
+ >
+ {props.children}
+
+
+
+ );
+ }
return (
diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx
new file mode 100644
index 000000000000..1b8084430605
--- /dev/null
+++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx
@@ -0,0 +1,344 @@
+import {
+ createNavigatorFactory,
+ NavigationContext,
+ NavigationRouteContext,
+ StackActions,
+ StackRouter,
+ useNavigationBuilder,
+ type NavigatorTypeBagBase,
+ type ParamListBase,
+ type StackActionHelpers,
+ type StackNavigationState,
+ type StackRouterOptions,
+ type StaticConfig,
+ type TypedNavigator,
+} from "@react-navigation/native";
+import {
+ NativeStackView,
+ type NativeStackNavigationEventMap,
+ type NativeStackNavigationOptions,
+ type NativeStackNavigatorProps,
+ type NativeStackTypeBag,
+} from "@react-navigation/native-stack";
+import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";
+import { View } from "react-native";
+import { Split, type SplitHostCommands } from "react-native-screens";
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { scopedThreadKey } from "../../lib/scopedEntities";
+
+import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";
+import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail";
+import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios";
+import {
+ modalEnvelopeOptions,
+ V5CardStackView,
+ V5StackView,
+} from "../../native/createV5StackNavigator.ios";
+import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns";
+import { V5StackHeader } from "../../native/V5StackHeader.ios";
+import {
+ nativeWorkspacePopCount,
+ projectWorkspaceStack,
+ partitionStackPresentations,
+} from "../../native/workspace-stack-projection";
+import {
+ NativePrimaryColumnContext,
+ NativeWorkspaceInspectorContext,
+ NativeWorkspaceModeContext,
+} from "../../native/v5-workspace-context";
+
+type ViewProps = ComponentProps;
+type Descriptor = ViewProps["descriptors"][string];
+type Route = ViewProps["state"]["routes"][number];
+
+function ColumnScreen(props: {
+ readonly descriptor: Descriptor;
+ readonly primary?: boolean;
+ readonly selectedThreadKey?: string | null;
+ readonly onDismiss: (routeKey: string) => void;
+ readonly navigation: ViewProps["navigation"];
+}) {
+ const { descriptor } = props;
+ const primaryColumn = useMemo(
+ () => (props.primary ? { selectedThreadKey: props.selectedThreadKey ?? null } : null),
+ [props.primary, props.selectedThreadKey],
+ );
+ const key = descriptor.route.key;
+ return (
+ {
+ if (event.nativeEvent.isNativeDismiss) props.onDismiss(key);
+ }}
+ onWillAppear={() =>
+ props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } })
+ }
+ onDidAppear={() =>
+ props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } })
+ }
+ onWillDisappear={() =>
+ props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } })
+ }
+ onDidDisappear={() =>
+ props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } })
+ }
+ >
+
+
+
+
+
+ {descriptor.render()}
+
+
+
+
+
+ );
+}
+
+function WorkspaceColumns(
+ props: ViewProps & { readonly primary: Route; readonly detail: Route[] },
+) {
+ const { layout, panes, togglePrimarySidebar, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
+ const inspector = use(NativeWorkspaceInspectorContext);
+ const hostRef = useRef(null);
+ const activeDetailKey = props.detail.at(-1)?.key;
+ const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params;
+ const selectedThreadKey =
+ threadParams &&
+ "environmentId" in threadParams &&
+ "threadId" in threadParams &&
+ typeof threadParams.environmentId === "string" &&
+ typeof threadParams.threadId === "string"
+ ? scopedThreadKey(
+ EnvironmentId.make(threadParams.environmentId),
+ ThreadId.make(threadParams.threadId),
+ )
+ : null;
+ const handleNativeDismiss = useCallback(
+ (key: string) => {
+ const state = props.navigation.getState();
+ const count = nativeWorkspacePopCount(state, key);
+ if (count)
+ props.navigation.dispatch({ ...StackActions.pop(count), source: key, target: state.key });
+ },
+ [props.navigation],
+ );
+
+ useEffect(() => {
+ // In compact size classes UIKit exposes one column. Selecting a thread
+ // changes the visible column without rebuilding either navigation stack.
+ if (layout.usesSplitView) return;
+ hostRef.current?.show(activeDetailKey ? "secondary" : "primary");
+ }, [activeDetailKey, layout.usesSplitView]);
+
+ const primary = props.descriptors[props.primary.key];
+ if (!primary) return null;
+ return (
+ {
+ if (inspector.visible) toggleAuxiliaryPane();
+ }}
+ columnMetrics={{
+ minimumPrimaryColumnWidth: 280,
+ maximumPrimaryColumnWidth: 380,
+ preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320,
+ minimumSecondaryColumnWidth: 320,
+ }}
+ >
+
+
+
+
+
+
+
+
+ [
+ {
+ type: "button",
+ label: "",
+ icon: { type: "sfSymbol", name: "sidebar.left" },
+ onPress: togglePrimarySidebar,
+ },
+ ],
+ }}
+ canGoBack={false}
+ />
+
+
+ props.navigation.navigate("NewTaskSheet", { screen: "NewTask" })
+ }
+ />
+
+
+ {props.detail.map((route) => {
+ const descriptor = props.descriptors[route.key];
+ return descriptor ? (
+
+ ) : null;
+ })}
+
+
+
+ {/* Inspector content supplies its own navigation header. */}
+
+
+ {inspector.render?.()}
+
+
+
+ );
+}
+
+function WorkspaceStackView(props: ViewProps) {
+ const projection = projectWorkspaceStack(props.state, (route) => {
+ const presentation = props.descriptors[route.key]?.options.presentation;
+ return presentation !== undefined && presentation !== "card";
+ });
+ // Linking normally restores Home via initialRouteName. A detail-only history
+ // must stay a single stack: placeholder descriptors cannot own an interactive
+ // sidebar because React Navigation rejects their actions and setOptions.
+ const primaryRoute = projection.primary;
+ if (!primaryRoute) return ;
+ const primaryDescriptor = props.descriptors[primaryRoute.key]!;
+ const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" };
+ const baseDescriptor: Descriptor = {
+ ...primaryDescriptor,
+ route: baseRoute,
+ options: { headerShown: false },
+ render: () => ,
+ };
+ const overlays = partitionStackPresentations(projection.overlays, (route) => {
+ const presentation = props.descriptors[route.key]?.options.presentation;
+ return presentation !== undefined && presentation !== "card";
+ });
+ const routes = [baseRoute, ...overlays.map((group) => group[0]!)];
+ return (
+ {
+ const route = group[0]!;
+ const descriptor = props.descriptors[route.key]!;
+ return [
+ route.key,
+ {
+ ...descriptor,
+ options: modalEnvelopeOptions(descriptor.options),
+ render: () => (
+
+ ),
+ },
+ ];
+ }),
+ ),
+ [baseRoute.key]: baseDescriptor,
+ }}
+ />
+ );
+}
+
+function WorkspaceStackNavigator({
+ id,
+ initialRouteName,
+ UNSTABLE_routeNamesChangeBehavior,
+ children,
+ layout,
+ screenListeners,
+ screenOptions,
+ screenLayout,
+ UNSTABLE_router,
+ ...rest
+}: NativeStackNavigatorProps) {
+ const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
+ StackNavigationState,
+ StackRouterOptions,
+ StackActionHelpers,
+ NativeStackNavigationOptions,
+ NativeStackNavigationEventMap
+ >(StackRouter, {
+ id,
+ initialRouteName,
+ UNSTABLE_routeNamesChangeBehavior,
+ children,
+ layout,
+ screenListeners,
+ screenOptions,
+ screenLayout,
+ UNSTABLE_router,
+ });
+ const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView;
+ return (
+
+
+
+
+
+ );
+}
+
+type WorkspaceTypeBag<
+ ParamList extends ParamListBase,
+ NavigatorID extends string | undefined,
+> = Omit, "Navigator"> & {
+ Navigator: typeof WorkspaceStackNavigator;
+};
+
+export function createWorkspaceStackNavigator<
+ const ParamList extends ParamListBase,
+ const NavigatorID extends string | undefined = string | undefined,
+ const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag,
+ const Config extends StaticConfig = StaticConfig,
+>(config?: Config): TypedNavigator {
+ return createNavigatorFactory(WorkspaceStackNavigator)(config);
+}
diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx
new file mode 100644
index 000000000000..fb22815dd2c1
--- /dev/null
+++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx
@@ -0,0 +1 @@
+export { createNativeStackNavigator as createWorkspaceStackNavigator } from "@react-navigation/native-stack";
diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts
index 34d5570e6109..44ef2a992e8b 100644
--- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts
+++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts
@@ -3,11 +3,9 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
/**
- * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass
- * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side
- * silently drops the item and hides the navigation toolbar entirely. Screens
- * that send it must fall back to standard search/toolbar primitives when this
- * is false.
+ * Group search, filtering, and composition on platforms with Liquid Glass.
+ * The v5 header adapter turns this intent into UISearchController and native
+ * toolbar items. Earlier iOS versions use separate search and toolbar options.
*/
export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED;
@@ -20,12 +18,7 @@ type NativeMailSearchToolbarInput = Omit<
>;
/**
- * Builds the patched react-native-screens Mail-style bottom search toolbar.
- *
- * Keeping this behind an app-level helper makes the iOS-only RNS patch an
- * explicit layout primitive instead of a per-screen object literal. Android can
- * keep using platform-specific header/search primitives without depending on
- * this helper.
+ * Describe the thread-list search and actions for the native header adapter.
*/
export function createNativeMailSearchToolbarItem(
input: NativeMailSearchToolbarInput,
diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx
index cf123a736559..5df9282d13f9 100644
--- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx
+++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx
@@ -29,7 +29,7 @@ import {
} from "@t3tools/client-runtime/state/thread-settled";
import { useState, type ReactNode } from "react";
import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native";
-import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
import { ScrollView, useWindowDimensions, View } from "react-native";
import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme";
import { NativeHeaderToolbar } from "../../native/StackHeader";
diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
index aedf415d02de..8445eafccd3a 100644
--- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
@@ -1,3 +1,4 @@
+import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
import type {
EnvironmentId,
ModelSelection,
@@ -15,10 +16,7 @@ import {
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native";
-import {
- createNativeStackNavigator,
- type NativeStackNavigationProp,
-} from "@react-navigation/native-stack";
+import { type NativeStackNavigationProp } from "@react-navigation/native-stack";
import * as Haptics from "expo-haptics";
import { AsyncResult } from "effect/unstable/reactivity";
import {
diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx
index 1198b06cb6a4..6f434ad656b7 100644
--- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx
+++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx
@@ -1,8 +1,6 @@
+import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native";
-import {
- createNativeStackNavigator,
- type NativeStackNavigationOptions,
-} from "@react-navigation/native-stack";
+import { type NativeStackNavigationOptions } from "@react-navigation/native-stack";
import type { ReactNode } from "react";
import { Platform } from "react-native";
diff --git a/apps/mobile/src/lib/reserved-regions.ts b/apps/mobile/src/lib/reserved-regions.ts
new file mode 100644
index 000000000000..318896239536
--- /dev/null
+++ b/apps/mobile/src/lib/reserved-regions.ts
@@ -0,0 +1,12 @@
+/** Geometry of the body inside a native navigation column. */
+export interface NativeLayoutMetrics {
+ readonly width: number;
+ readonly height: number;
+ readonly horizontalSizeClass: "compact" | "regular";
+ readonly safeArea: {
+ readonly top: number;
+ readonly bottom: number;
+ readonly left: number;
+ readonly right: number;
+ };
+}
diff --git a/apps/mobile/src/native/NativeColumnContent.ios.tsx b/apps/mobile/src/native/NativeColumnContent.ios.tsx
new file mode 100644
index 000000000000..93b24ec72085
--- /dev/null
+++ b/apps/mobile/src/native/NativeColumnContent.ios.tsx
@@ -0,0 +1,31 @@
+import { HeaderHeightContext } from "@react-navigation/elements";
+import { useState, type ReactNode } from "react";
+import { View } from "react-native";
+import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
+import { NativeColumnLayoutMetricsContext } from "./native-layout-metrics";
+import type { NativeLayoutMetrics } from "../lib/reserved-regions";
+import { NativeLayoutObserver } from "./NativeLayoutObserver";
+
+export function NativeColumnContent(props: {
+ readonly children: ReactNode;
+ readonly primary?: boolean;
+}) {
+ const [metrics, setMetrics] = useState(null);
+ return (
+
+
+
+ {/* Observe the inset body. UIKit can reserve other columns in its safe area. */}
+
+
+
+
+ {props.children}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/native/NativeLayoutObserver.ios.tsx b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx
new file mode 100644
index 000000000000..8d284d9edeec
--- /dev/null
+++ b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx
@@ -0,0 +1,33 @@
+import { requireNativeView, requireOptionalNativeModule } from "expo";
+import type { NativeSyntheticEvent, ViewProps } from "react-native";
+import type { NativeLayoutMetrics } from "../lib/reserved-regions";
+
+interface ObserverProps extends ViewProps {
+ readonly onMetricsChange?: (event: NativeSyntheticEvent) => void;
+}
+
+// An older development client can still run the compact app while it rebuilds.
+function resolveObserver() {
+ try {
+ const module = requireOptionalNativeModule<{
+ readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown };
+ }>("T3NativeControls");
+ if (!module?.ViewPrototypes?.T3NativeControls_LayoutMetrics) return null;
+ return requireNativeView("T3NativeControls", "LayoutMetrics");
+ } catch {
+ return null;
+ }
+}
+const Observer = resolveObserver();
+
+export function NativeLayoutObserver(props: {
+ readonly onChange?: (metrics: NativeLayoutMetrics) => void;
+}) {
+ return Observer ? (
+ props.onChange?.(event.nativeEvent) : undefined}
+ style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}
+ />
+ ) : null;
+}
diff --git a/apps/mobile/src/native/NativeLayoutObserver.tsx b/apps/mobile/src/native/NativeLayoutObserver.tsx
new file mode 100644
index 000000000000..e2b24a55a213
--- /dev/null
+++ b/apps/mobile/src/native/NativeLayoutObserver.tsx
@@ -0,0 +1,7 @@
+import type { NativeLayoutMetrics } from "../lib/reserved-regions";
+
+export function NativeLayoutObserver(_props: {
+ readonly onChange?: (metrics: NativeLayoutMetrics) => void;
+}) {
+ return null;
+}
diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx
new file mode 100644
index 000000000000..c85529b1dbf2
--- /dev/null
+++ b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx
@@ -0,0 +1,7 @@
+import { requireOptionalNativeModule } from "expo";
+import { Platform } from "react-native";
+const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>(
+ "T3NativeControls",
+);
+export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED =
+ Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true;
diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.tsx
new file mode 100644
index 000000000000..4bb7aa16ac9b
--- /dev/null
+++ b/apps/mobile/src/native/NativeWorkspaceColumns.tsx
@@ -0,0 +1,3 @@
+import { Platform } from "react-native";
+// Android retains its existing navigator. The iOS file checks native availability.
+export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad;
diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx
new file mode 100644
index 000000000000..75c7ead44f71
--- /dev/null
+++ b/apps/mobile/src/native/V5StackHeader.ios.tsx
@@ -0,0 +1,250 @@
+import type {
+ NativeStackHeaderItem,
+ NativeStackHeaderItemMenu,
+ NativeStackNavigationOptions,
+} from "@react-navigation/native-stack";
+import { isValidElement, useRef } from "react";
+import { Platform, StyleSheet } from "react-native";
+import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme";
+import {
+ SearchBar,
+ Stack,
+ type HeaderBarButtonMailSearchToolbarItem,
+ type SearchBarCommands,
+ type StackHeaderConfigProps,
+ type StackHeaderMenuElementIOS,
+ type StackHeaderMenuIOS,
+ type HeaderBarButtonItemWithMenu,
+ type HeaderBarButtonSearchBarPlacementItem,
+} from "react-native-screens";
+
+type HeaderItem = NonNullable["leadingItems"]>[number];
+type ToolbarItem =
+ | NativeStackHeaderItem
+ | HeaderBarButtonMailSearchToolbarItem
+ | HeaderBarButtonSearchBarPlacementItem;
+type Options = NativeStackNavigationOptions & {
+ readonly unstable_navigationItemStyle?: "editor" | "navigator";
+ readonly headerSubtitle?: string;
+ readonly unstable_headerSubtitle?: string;
+ readonly headerSubtitleStyle?: { color?: string; fontSize?: number; fontFamily?: string };
+ readonly unstable_headerCenterItems?: () => NativeStackHeaderItem[];
+ readonly unstable_headerToolbarItems?: () => ToolbarItem[];
+};
+
+type MenuElementInput =
+ | NativeStackHeaderItemMenu["menu"]["items"][number]
+ | HeaderBarButtonItemWithMenu["menu"]["items"][number];
+type MenuInput = { readonly title?: string; readonly items: readonly MenuElementInput[] };
+
+function convertIcon(
+ icon:
+ | Extract["icon"]
+ | HeaderBarButtonItemWithMenu["icon"],
+) {
+ if (!icon) return undefined;
+ if (icon.type === "image")
+ return icon.tinted === false
+ ? { type: "imageSource" as const, imageSource: icon.source }
+ : { type: "templateSource" as const, templateSource: icon.source };
+ return icon;
+}
+
+function convertMenu(menu: MenuInput, id: string): StackHeaderMenuIOS {
+ const children = menu.items.flatMap((item, index) => {
+ if ("hidden" in item && item.hidden) return [];
+ const childId = `${id}:${index}`;
+ if (item.type === "submenu") {
+ return [
+ {
+ ...convertMenu({ items: item.items }, childId),
+ title: "label" in item ? item.label : item.title,
+ displayInline:
+ "inline" in item
+ ? item.inline
+ : "displayInline" in item
+ ? item.displayInline
+ : undefined,
+ },
+ ];
+ }
+ // Use actions even for checked entries: the app owns their durable state.
+ return [
+ {
+ id: childId,
+ type: "menuItem" as const,
+ itemType: "action" as const,
+ title: "label" in item ? item.label : item.title,
+ icon: convertIcon(item.icon),
+ onPress: item.disabled ? undefined : item.onPress,
+ keepsMenuPresented: item.keepsMenuPresented,
+ disabled: item.disabled,
+ destructive: item.destructive,
+ subtitle: "subtitle" in item ? item.subtitle : undefined,
+ state: item.state,
+ },
+ ];
+ });
+ return { id, type: "menu", title: menu.title, children };
+}
+
+function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderItem[] {
+ return items.map((item, index): HeaderItem => {
+ const id =
+ item.type === "spacing" ? `${prefix}:${index}` : (item.identifier ?? `${prefix}:${index}`);
+ switch (item.type) {
+ case "spacing":
+ return { type: "spacer", id, sizing: "fixed", width: item.spacing };
+ case "custom":
+ return {
+ type: "item",
+ id,
+ render: () => item.element,
+ hidesSharedBackground: item.hidesSharedBackground,
+ };
+ case "menu":
+ return {
+ type: "item",
+ id,
+ identifier: item.identifier,
+ title: item.label || undefined,
+ icon: convertIcon(item.icon),
+ menu: convertMenu(item.menu, `${id}:menu`),
+ hidesSharedBackground: item.hidesSharedBackground,
+ };
+ case "button":
+ return {
+ type: "item",
+ id,
+ identifier: item.identifier,
+ title: item.label || undefined,
+ icon: convertIcon(item.icon),
+ onPress: item.disabled ? undefined : item.onPress,
+ hidesSharedBackground: item.hidesSharedBackground,
+ };
+ }
+ });
+}
+
+/** Translate the existing screen options to v5's native header API. */
+export function V5StackHeader(props: {
+ readonly options: Options;
+ readonly canGoBack: boolean;
+ readonly primary?: boolean;
+}) {
+ const { options } = props;
+ const theme = useMobileNavigationTheme();
+ const searchRef = useRef(null);
+ const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack };
+ const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading");
+ const trailing = convertItems(
+ [
+ ...(options.unstable_headerCenterItems?.() ?? []),
+ ...(options.unstable_headerRightItems?.(itemProps) ?? []),
+ ],
+ "trailing",
+ );
+ const toolbar = options.unstable_headerToolbarItems?.() ?? [];
+ const mailSearch = toolbar.find(
+ (item): item is HeaderBarButtonMailSearchToolbarItem => item.type === "mailSearchToolbar",
+ );
+ const bottomSearch = Boolean(mailSearch) && Platform.OS === "ios" && !Platform.isPad;
+ const bottom = toolbar.flatMap((item, index) => {
+ if (item.type === "mailSearchToolbar") return [];
+ if (item.type === "searchBarPlacement")
+ return [{ type: "item", id: `toolbar-search:${index}`, searchBarPlacement: true }];
+ return convertItems([item], `toolbar:${index}`);
+ });
+ if (mailSearch) {
+ if (mailSearch.filterMenu)
+ bottom.push({
+ type: "item",
+ id: mailSearch.filterButtonId ?? "filter",
+ icon: {
+ type: "sfSymbol",
+ name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease",
+ },
+ menu: convertMenu(mailSearch.filterMenu, "filter-menu"),
+ });
+ if (bottomSearch) {
+ if (bottom.length)
+ bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 });
+ bottom.push({ type: "item", id: "home-search", searchBarPlacement: true });
+ if (mailSearch.onComposePress)
+ bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 });
+ }
+ if (mailSearch.onComposePress)
+ bottom.push({
+ type: "item",
+ id: mailSearch.composeButtonId ?? "compose",
+ icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" },
+ onPress: mailSearch.onComposePress,
+ });
+ }
+ const left = options.headerLeft?.(itemProps);
+ const right = options.headerRight?.(itemProps);
+ if (!options.unstable_headerLeftItems && isValidElement(left))
+ leading.push({ type: "item", id: "custom-left", render: () => left });
+ if (!options.unstable_headerRightItems && isValidElement(right))
+ trailing.push({ type: "item", id: "custom-right", render: () => right });
+ const title = typeof options.headerTitle === "string" ? options.headerTitle : options.title;
+ const titleElement =
+ typeof options.headerTitle === "function"
+ ? options.headerTitle({ children: title ?? "", tintColor: options.headerTintColor })
+ : undefined;
+ const searchOptions = options.headerSearchBarOptions;
+ const titleStyle = StyleSheet.flatten(options.headerTitleStyle);
+ const headerStyle = StyleSheet.flatten(options.headerStyle);
+ const appearance = {
+ backgroundColor:
+ headerStyle?.backgroundColor ??
+ (options.headerTransparent ? "transparent" : theme.colors.card),
+ shadowColor: options.headerShadowVisible === false ? "transparent" : undefined,
+ titleFontSize: titleStyle?.fontSize,
+ titleFontFamily: titleStyle?.fontFamily,
+ titleFontWeight: titleStyle?.fontWeight,
+ titleFontColor: titleStyle?.color,
+ subtitleFontSize: options.headerSubtitleStyle?.fontSize,
+ subtitleFontColor: options.headerSubtitleStyle?.color,
+ };
+ return (
+ {
+ mailSearch?.onSearchTextChange?.(event.nativeEvent.text);
+ searchOptions?.onChangeText?.(event);
+ }}
+ />
+ ) : undefined,
+ toolbarItems: bottom,
+ leadingItems: leading,
+ trailingItems: trailing,
+ titleItem: isValidElement(titleElement)
+ ? { id: "title", render: () => titleElement }
+ : undefined,
+ largeTitleEnabled: options.headerLargeTitle,
+ backButtonDisplayMode: options.headerBackButtonDisplayMode ?? "minimal",
+ backButtonMenuEnabled: options.headerBackButtonMenuEnabled ?? true,
+ backButtonTitle: options.headerBackTitle,
+ standardAppearance: appearance,
+ scrollEdgeAppearance: appearance,
+ }}
+ />
+ );
+}
diff --git a/apps/mobile/src/native/createV5StackNavigator.ios.tsx b/apps/mobile/src/native/createV5StackNavigator.ios.tsx
new file mode 100644
index 000000000000..426fca9fc0c0
--- /dev/null
+++ b/apps/mobile/src/native/createV5StackNavigator.ios.tsx
@@ -0,0 +1,257 @@
+import {
+ createNavigatorFactory,
+ NavigationContext,
+ NavigationRouteContext,
+ StackActions,
+ StackRouter,
+ useNavigationBuilder,
+ usePreventRemoveContext,
+ type NavigatorTypeBagBase,
+ type ParamListBase,
+ type StackActionHelpers,
+ type StackNavigationState,
+ type StackRouterOptions,
+ type StaticConfig,
+ type TypedNavigator,
+} from "@react-navigation/native";
+import {
+ NativeStackView,
+ type NativeStackNavigationEventMap,
+ type NativeStackNavigationOptions,
+ type NativeStackNavigatorProps,
+ type NativeStackTypeBag,
+} from "@react-navigation/native-stack";
+import { useCallback, useState, type ComponentProps } from "react";
+import { View } from "react-native";
+import { Stack } from "react-native-screens";
+import { V5StackHeader } from "./V5StackHeader.ios";
+import { NativeColumnContent } from "./NativeColumnContent.ios";
+import {
+ nativeWorkspacePopCount,
+ partitionStackPresentations,
+ reconcileStackScreens,
+} from "./workspace-stack-projection";
+
+export type V5StackViewProps = ComponentProps;
+
+/** The presentation envelope must not mount a second search bar or header. */
+export function modalEnvelopeOptions(options: NativeStackNavigationOptions) {
+ return {
+ ...Object.fromEntries(
+ Object.entries(options).filter(
+ ([key]) =>
+ !key.startsWith("header") &&
+ !key.startsWith("unstable_header") &&
+ key !== "unstable_navigationItemStyle",
+ ),
+ ),
+ headerShown: false,
+ };
+}
+
+/** Keep outgoing screens until UIKit completes its pop, as required by v5. */
+export function V5CardStackView(props: V5StackViewProps) {
+ const { preventedRoutes } = usePreventRemoveContext();
+ const [screens, setScreens] = useState({
+ routes: props.state.routes,
+ descriptors: props.descriptors,
+ observedRoutes: props.state.routes,
+ observedDescriptors: props.descriptors,
+ });
+ if (
+ screens.observedRoutes !== props.state.routes ||
+ screens.observedDescriptors !== props.descriptors
+ ) {
+ setScreens({
+ routes: reconcileStackScreens(screens.routes, props.state.routes),
+ descriptors: { ...screens.descriptors, ...props.descriptors },
+ observedRoutes: props.state.routes,
+ observedDescriptors: props.descriptors,
+ });
+ }
+ // Keep the real descriptor through dismissal. describe(route, true) creates
+ // a placeholder whose navigation rejects setOptions and dispatches.
+ const nativeDismiss = useCallback(
+ (key: string) => {
+ const state = props.navigation.getState();
+ const count = nativeWorkspacePopCount(state, key);
+ if (count)
+ props.navigation.dispatch({ ...StackActions.pop(count), source: key, target: state.key });
+ },
+ [props.navigation],
+ );
+ const removeDismissed = useCallback(
+ (key: string) => {
+ if (!props.navigation.getState().routes.some((route) => route.key === key)) {
+ setScreens((current) => {
+ const descriptors = { ...current.descriptors };
+ delete descriptors[key];
+ return {
+ ...current,
+ routes: current.routes.filter((route) => route.key !== key),
+ descriptors,
+ };
+ });
+ }
+ },
+ [props.navigation],
+ );
+ return (
+
+
+ {screens.routes.map((route, index) => {
+ const descriptor = props.descriptors[route.key] ?? screens.descriptors[route.key];
+ if (!descriptor) return null;
+ const attached = props.state.routes.some((current) => current.key === route.key);
+ return (
+ descriptor.navigation.goBack()}
+ onWillAppear={() =>
+ props.navigation.emit({
+ type: "transitionStart",
+ target: route.key,
+ data: { closing: false },
+ })
+ }
+ onDidAppear={() =>
+ props.navigation.emit({
+ type: "transitionEnd",
+ target: route.key,
+ data: { closing: false },
+ })
+ }
+ onWillDisappear={() =>
+ props.navigation.emit({
+ type: "transitionStart",
+ target: route.key,
+ data: { closing: true },
+ })
+ }
+ onDidDisappear={() =>
+ props.navigation.emit({
+ type: "transitionEnd",
+ target: route.key,
+ data: { closing: true },
+ })
+ }
+ >
+
+
+ 0} />
+ {descriptor.render()}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+/** v5 owns pushes and headers; the legacy renderer only presents modal groups. */
+export function V5StackView(props: V5StackViewProps) {
+ const groups = partitionStackPresentations(props.state.routes, (route) => {
+ const presentation = props.descriptors[route.key]?.options.presentation;
+ return (
+ ["SettingsSheet", "NewTaskSheet"].includes(route.name) ||
+ (presentation !== undefined && presentation !== "card")
+ );
+ });
+ const routes = groups.map((group) => group[0]!);
+ const descriptors = Object.fromEntries(
+ groups.map((group) => {
+ const first = group[0]!;
+ const descriptor = props.descriptors[first.key]!;
+ return [
+ first.key,
+ {
+ ...descriptor,
+ options: modalEnvelopeOptions(descriptor.options),
+ render: () => (
+
+ ),
+ },
+ ];
+ }),
+ );
+ return (
+
+ );
+}
+
+function V5StackNavigator({
+ id,
+ initialRouteName,
+ UNSTABLE_routeNamesChangeBehavior,
+ children,
+ layout,
+ screenListeners,
+ screenOptions,
+ screenLayout,
+ UNSTABLE_router,
+ ...rest
+}: NativeStackNavigatorProps) {
+ const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
+ StackNavigationState,
+ StackRouterOptions,
+ StackActionHelpers,
+ NativeStackNavigationOptions,
+ NativeStackNavigationEventMap
+ >(StackRouter, {
+ id,
+ initialRouteName,
+ UNSTABLE_routeNamesChangeBehavior,
+ children,
+ layout,
+ screenListeners,
+ screenOptions,
+ screenLayout,
+ UNSTABLE_router,
+ });
+ return (
+
+
+
+ );
+}
+
+type V5TypeBag = Omit<
+ NativeStackTypeBag,
+ "Navigator"
+> & { Navigator: typeof V5StackNavigator };
+export function createV5StackNavigator<
+ const ParamList extends ParamListBase,
+ const NavigatorID extends string | undefined = string | undefined,
+ const TypeBag extends NavigatorTypeBagBase = V5TypeBag,
+ const Config extends StaticConfig = StaticConfig,
+>(config?: Config): TypedNavigator {
+ return createNavigatorFactory(V5StackNavigator)(config);
+}
diff --git a/apps/mobile/src/native/createV5StackNavigator.tsx b/apps/mobile/src/native/createV5StackNavigator.tsx
new file mode 100644
index 000000000000..44ca075614c0
--- /dev/null
+++ b/apps/mobile/src/native/createV5StackNavigator.tsx
@@ -0,0 +1 @@
+export { createNativeStackNavigator as createV5StackNavigator } from "@react-navigation/native-stack";
diff --git a/apps/mobile/src/native/native-layout-metrics.tsx b/apps/mobile/src/native/native-layout-metrics.tsx
new file mode 100644
index 000000000000..bd2d5db18339
--- /dev/null
+++ b/apps/mobile/src/native/native-layout-metrics.tsx
@@ -0,0 +1,25 @@
+import { createContext, use, useState, type ReactNode } from "react";
+import type { NativeLayoutMetrics } from "../lib/reserved-regions";
+import { NativeLayoutObserver } from "./NativeLayoutObserver";
+
+const NativeLayoutContext = createContext(null);
+// Column reservations come from UIKit's layout, outside the resizing navigator.
+export const NativeColumnLayoutMetricsContext = createContext(null);
+
+export function NativeLayoutMetricsProvider(props: { readonly children: ReactNode }) {
+ const [metrics, setMetrics] = useState(null);
+ return (
+
+
+ {props.children}
+
+ );
+}
+
+export function useNativeLayoutMetrics() {
+ return use(NativeLayoutContext);
+}
+
+export function useNativeColumnLayoutMetrics() {
+ return use(NativeColumnLayoutMetricsContext);
+}
diff --git a/apps/mobile/src/native/v5-workspace-context.tsx b/apps/mobile/src/native/v5-workspace-context.tsx
new file mode 100644
index 000000000000..3c650f6a4fba
--- /dev/null
+++ b/apps/mobile/src/native/v5-workspace-context.tsx
@@ -0,0 +1,11 @@
+import { createContext, type ReactNode } from "react";
+
+// The router is shared with the other platforms; only iOS uses native columns.
+export const NativeWorkspaceModeContext = createContext(false);
+export const NativePrimaryColumnContext = createContext<{
+ readonly selectedThreadKey: string | null;
+} | null>(null);
+export const NativeWorkspaceInspectorContext = createContext<{
+ readonly render: (() => ReactNode) | undefined;
+ readonly visible: boolean;
+}>({ render: undefined, visible: false });
diff --git a/apps/mobile/src/native/workspace-stack-projection.test.ts b/apps/mobile/src/native/workspace-stack-projection.test.ts
new file mode 100644
index 000000000000..39a19ac8f663
--- /dev/null
+++ b/apps/mobile/src/native/workspace-stack-projection.test.ts
@@ -0,0 +1,129 @@
+import type { ParamListBase, StackNavigationState } from "@react-navigation/native";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ nativeWorkspacePopCount,
+ projectWorkspaceStack,
+ partitionStackPresentations,
+ reconcileStackScreens,
+} from "./workspace-stack-projection";
+
+const home = { key: "home", name: "Home" };
+const thread = { key: "thread", name: "Thread", params: { threadId: "draft-thread" } };
+const files = { key: "files", name: "ThreadFiles", params: thread.params };
+const settings = { key: "settings", name: "SettingsSheet" };
+const legal = { key: "legal", name: "SettingsLegal" };
+
+function history(
+ routes: StackNavigationState["routes"],
+): StackNavigationState {
+ return {
+ key: "router",
+ type: "stack",
+ stale: false,
+ index: routes.length - 1,
+ routeNames: ["Home", "Thread", "ThreadFiles", "SettingsSheet", "SettingsLegal"],
+ routes,
+ preloadedRoutes: [],
+ };
+}
+
+describe("workspace router projection", () => {
+ it("keeps workspace flows beside Home and restores them after a modal closes", () => {
+ const draft = { key: "draft", name: "NewTaskSheet" };
+ const modal = { key: "connect", name: "ConnectOnboarding" };
+ const routes = [home, thread, draft, settings, legal];
+ const isOverlay = (route: { name: string }) => route.name === "ConnectOnboarding";
+ expect(projectWorkspaceStack(history([...routes, modal]), isOverlay)).toEqual({
+ primary: home,
+ detail: [thread, draft, settings, legal],
+ overlays: [modal],
+ });
+ expect(projectWorkspaceStack(history(routes), isOverlay)).toEqual({
+ primary: home,
+ detail: [thread, draft, settings, legal],
+ overlays: [],
+ });
+ });
+
+ it("keeps the thread and file history in the detail column when a modal is opened", () => {
+ const state = history([home, thread, files, settings, legal]);
+ const projection = projectWorkspaceStack(state, (route) => route.name === "SettingsSheet");
+ expect(projection).toEqual({
+ primary: home,
+ detail: [thread, files],
+ overlays: [settings, legal],
+ });
+ expect(projection.detail[0]).toBe(thread);
+ expect(state.routes).toEqual([home, thread, files, settings, legal]);
+ });
+
+ it("retains a cold-linked detail route without changing the router history", () => {
+ const state = history([thread, files]);
+ expect(projectWorkspaceStack(state, () => false)).toEqual({
+ primary: undefined,
+ detail: [thread, files],
+ overlays: [],
+ });
+ expect(state.routes[0]).toBe(thread);
+ });
+
+ it("restores the empty detail column after Back reaches the thread list", () => {
+ expect(projectWorkspaceStack(history([home]), () => false)).toEqual({
+ primary: home,
+ detail: [],
+ overlays: [],
+ });
+ });
+});
+
+describe("native workspace dismissal", () => {
+ it("pops a native dismissed file while keeping the conversation and its draft mounted", () => {
+ expect(nativeWorkspacePopCount(history([home, thread, files]), files.key)).toBe(1);
+ });
+
+ it("removes the descendants when UIKit dismisses their parent conversation", () => {
+ expect(nativeWorkspacePopCount(history([home, thread, files]), thread.key)).toBe(2);
+ });
+
+ it("ignores delayed callbacks from JS removal and replacement", () => {
+ expect(nativeWorkspacePopCount(history([home, thread]), files.key)).toBe(0);
+ expect(
+ nativeWorkspacePopCount(history([home, { ...thread, key: "replacement" }]), thread.key),
+ ).toBe(0);
+ });
+
+ it("never removes the root or a route ahead of the active index", () => {
+ const state = history([home, thread, files]);
+ expect(nativeWorkspacePopCount(state, home.key)).toBe(0);
+ expect(nativeWorkspacePopCount({ ...state, index: 1 }, files.key)).toBe(0);
+ });
+});
+
+describe("v5 stack handoff", () => {
+ it("retains removed native screens through a pop followed immediately by a push", () => {
+ const popped = reconcileStackScreens([home, thread, files], [home, thread]);
+ const next = { ...files, key: "new-files" };
+ const screens = reconcileStackScreens(popped, [home, thread, next]);
+ expect(screens.map((route) => route.key)).toEqual(["files", "home", "thread", "new-files"]);
+ expect(screens.filter((route) => [home, thread, next].includes(route))).toEqual([
+ home,
+ thread,
+ next,
+ ]);
+ expect(screens[0]).toBe(files);
+ });
+ it("updates params without retaining duplicate copies of the same screen", () => {
+ const updated = { ...thread, params: { threadId: "another-thread" } };
+ expect(reconcileStackScreens([home, thread], [home, updated])).toEqual([home, updated]);
+ });
+ it("keeps card pushes inside the modal they belong to", () => {
+ const secondModal = { ...settings, key: "another-settings" };
+ expect(
+ partitionStackPresentations(
+ [home, thread, settings, legal, secondModal],
+ (route) => route.name === "SettingsSheet",
+ ),
+ ).toEqual([[home, thread], [settings, legal], [secondModal]]);
+ });
+});
diff --git a/apps/mobile/src/native/workspace-stack-projection.ts b/apps/mobile/src/native/workspace-stack-projection.ts
new file mode 100644
index 000000000000..450b4790ee5f
--- /dev/null
+++ b/apps/mobile/src/native/workspace-stack-projection.ts
@@ -0,0 +1,51 @@
+import type {
+ NavigationState,
+ ParamListBase,
+ StackNavigationState,
+} from "@react-navigation/native";
+
+type Route = StackNavigationState["routes"][number];
+
+/** Project one router history into native columns and a modal presentation stack. */
+export function projectWorkspaceStack(
+ state: StackNavigationState,
+ isOverlay: (route: Route) => boolean,
+) {
+ const firstOverlay = state.routes.findIndex(isOverlay);
+ const workspaceRoutes = state.routes.slice(0, firstOverlay < 0 ? undefined : firstOverlay);
+ const primary = workspaceRoutes.find((route) => route.name === "Home");
+ const detail = workspaceRoutes.filter((route) => route.name !== "Home");
+ const overlays = firstOverlay < 0 ? [] : state.routes.slice(firstOverlay);
+ return { primary, detail, overlays };
+}
+
+/** Native callbacks can arrive after a JS pop or replace; those must not pop the next screen. */
+export function nativeWorkspacePopCount(
+ state: Pick,
+ dismissedKey: string,
+): number {
+ const index = state.routes.findIndex((route) => route.key === dismissedKey);
+ return index <= 0 || index > state.index ? 0 : state.index - index + 1;
+}
+
+/** Group pushes with the modal that owns their native stack. */
+export function partitionStackPresentations(
+ routes: readonly T[],
+ isModal: (route: T) => boolean,
+): T[][] {
+ const groups: T[][] = [];
+ for (const route of routes) {
+ if (groups.length === 0 || isModal(route)) groups.push([]);
+ groups.at(-1)!.push(route);
+ }
+ return groups;
+}
+
+/** Preserve UIKit's outgoing screens, before newly pushed screens, until dismissal. */
+export function reconcileStackScreens(
+ previous: readonly T[],
+ current: readonly T[],
+): T[] {
+ const active = new Set(current.map((route) => route.key));
+ return [...previous.filter((route) => !active.has(route.key)), ...current];
+}
diff --git a/docs/operations/mobile-screens-fork.md b/docs/operations/mobile-screens-fork.md
new file mode 100644
index 000000000000..b4517bb9402c
--- /dev/null
+++ b/docs/operations/mobile-screens-fork.md
@@ -0,0 +1,22 @@
+# Building the mobile Screens dependency
+
+The mobile v5 stack uses a committed tarball from the
+[T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3/main).
+The fork owns the integration changes; the app does not patch this package.
+Each archive includes compiled JavaScript, declarations, native source and
+`t3-fork.json` recording its version, source commit and upstream base.
+
+To reproduce the current archive with Node 24 and the repository's pinned Yarn:
+
+```sh
+git clone --branch t3-v5.0.0-t3.2 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork
+yarn --cwd /tmp/t3-screens-fork pack:t3 /absolute/path/to/t3code/apps/mobile/deps
+```
+
+For an update, sync the fork's `main` with upstream, merge it and the required
+proposal branches into `t3/main`, increment the `5.0.0-t3.N` version, pin `t3Fork.upstreamCommit` to the
+integrated upstream SHA and commit.
+Run `pack:t3` from that clean checkout and tag the source as `t3-v`.
+Replace the old archive, update `apps/mobile/package.json` and run `vp i` to
+regenerate the lockfile. Verify mobile types and affected navigation tests.
+Changes to native source also require rebuilding and testing the native client.
diff --git a/knip.jsonc b/knip.jsonc
index 1bdd744eafdc..a63d116346dc 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -67,6 +67,7 @@
// Expo loads plugins by string; Metro embeds the browser entry in the native WebView.
"entry": [
"index.ts!",
+ "fingerprint.config.cjs",
"plugins/*.cjs",
"scripts/generate-device-stream.mts",
"src/features/devices/device-stream.browser.ts!",
diff --git a/patches/react-native-gesture-handler@3.2.1.patch b/patches/react-native-gesture-handler@3.2.1.patch
index 8fe606a1fad6..1908c1edc214 100644
--- a/patches/react-native-gesture-handler@3.2.1.patch
+++ b/patches/react-native-gesture-handler@3.2.1.patch
@@ -149,3 +149,56 @@ index 1e1246f709feb04c01cf3221472cc1219a8fffac..f526d4fe2d2738becbe8d29c59d1105a
}>;
export interface SwipeableMethods {
+diff --git a/apple/Handlers/RNPanHandler.m b/apple/Handlers/RNPanHandler.m
+--- a/apple/Handlers/RNPanHandler.m
++++ b/apple/Handlers/RNPanHandler.m
+@@ -31,6 +31,7 @@
+ @property (nonatomic) CGFloat failOffsetYStart;
+ @property (nonatomic) CGFloat failOffsetYEnd;
+ @property (nonatomic) CGFloat activateAfterLongPress;
++@property (nonatomic) BOOL handlingTouchStart;
+
+ #if !TARGET_OS_OSX && !TARGET_OS_TV
+ @property (atomic, readonly, strong) RNGHStylusData *stylusData;
+@@ -126,9 +127,14 @@
+
+ - (void)interactionsBegan:(NSSet *)touches withEvent:(UIEvent *)event
+ {
+- if (self.state == UIGestureRecognizerStatePossible && ![self.delegate gestureRecognizerShouldBegin:self]) {
+- self.state = UIGestureRecognizerStateFailed;
+- return;
++ if (self.state == UIGestureRecognizerStatePossible) {
++ self.handlingTouchStart = touches.count > 0;
++ BOOL shouldBegin = [self.delegate gestureRecognizerShouldBegin:self];
++ self.handlingTouchStart = NO;
++ if (!shouldBegin) {
++ self.state = UIGestureRecognizerStateFailed;
++ return;
++ }
+ }
+
+ if (touches.count == 0) {
+@@ -476,6 +482,23 @@
+
+ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
+ {
++#if !TARGET_OS_OSX && !TARGET_OS_TV
++ RNBetterPanGestureRecognizer *recognizer = (RNBetterPanGestureRecognizer *)gestureRecognizer;
++ if (!recognizer.handlingTouchStart && recognizer.allowedScrollTypesMask != 0 && recognizer.numberOfTouches == 0) {
++ // Trackpad scroll events can begin without touchesMoved enforcing failure
++ // offsets. Reject vertical scrolling before a horizontal row pan cancels
++ // the enclosing UIScrollView. The manual touch-start preflight above has
++ // no current translation yet, so leave that path to the touch callbacks.
++ CGPoint translation = [recognizer translationInView:recognizer.view.window];
++ BOOL horizontalOnly = (!isnan(recognizer.activeOffsetXStart) || !isnan(recognizer.activeOffsetXEnd)) &&
++ isnan(recognizer.activeOffsetYStart) && isnan(recognizer.activeOffsetYEnd);
++ if ([recognizer shouldFailUnderCustomCriteria] ||
++ (horizontalOnly && fabs(translation.y) > fabs(translation.x))) {
++ return NO;
++ }
++ }
++#endif
++
+ RNGestureHandlerState savedState = _lastState;
+ BOOL shouldBegin = [super gestureRecognizerShouldBegin:gestureRecognizer];
+ _lastState = savedState;
diff --git a/patches/react-native-screens@4.28.0.patch b/patches/react-native-screens@4.28.0.patch
deleted file mode 100644
index 8b07155c5afe..000000000000
--- a/patches/react-native-screens@4.28.0.patch
+++ /dev/null
@@ -1,2232 +0,0 @@
-diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt
-index fa1b773..270c81c 100644
---- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt
-+++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt
-@@ -124,6 +124,34 @@ class ScreenStackHeaderConfigViewManager :
- config.setTitle(title)
- }
-
-+ // The t3 patch adds these iOS glass-chrome props to the native spec; they have
-+ // no Android implementation, but the codegen-generated interface requires the
-+ // setters to exist. No-ops keep the Android build compiling.
-+ override fun setSubtitle(
-+ config: ScreenStackHeaderConfig,
-+ subtitle: String?,
-+ ) = Unit
-+
-+ override fun setLargeSubtitle(
-+ config: ScreenStackHeaderConfig,
-+ largeSubtitle: String?,
-+ ) = Unit
-+
-+ override fun setNavigationItemStyle(
-+ config: ScreenStackHeaderConfig,
-+ navigationItemStyle: String?,
-+ ) = Unit
-+
-+ override fun setHeaderCenterBarButtonItems(
-+ config: ScreenStackHeaderConfig,
-+ headerCenterBarButtonItems: ReadableArray?,
-+ ) = Unit
-+
-+ override fun setHeaderToolbarItems(
-+ config: ScreenStackHeaderConfig,
-+ headerToolbarItems: ReadableArray?,
-+ ) = Unit
-+
- override fun setTitleFontFamily(
- config: ScreenStackHeaderConfig,
- titleFontFamily: String?,
-diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt
-index 64f14f0..6cb1d69 100644
---- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt
-+++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt
-@@ -47,6 +47,12 @@ class ScreenStackHeaderSubviewManager :
- Log.w("[RNScreens]", "hidesSharedBackground prop is not available on Android")
- }
-
-+ // Bar button transition identifiers only apply on iOS.
-+ override fun setIdentifier(
-+ view: ScreenStackHeaderSubview,
-+ identifier: String?,
-+ ) = Unit
-+
- // synchronousShadowStateUpdatesEnabled is not available on Android atm,
- // however we must override their setters
- override fun setSynchronousShadowStateUpdatesEnabled(
-diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h
-index a26d125..62eea42 100644
---- a/ios/RNSBarButtonItem.h
-+++ b/ios/RNSBarButtonItem.h
-@@ -15,6 +15,10 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId);
- action:(RNSBarButtonItemAction)action
- menuAction:(RNSBarButtonMenuItemAction)menuAction
- imageLoader:(RCTImageLoader *)imageLoader;
-+
-++ (UIMenu *)initUIMenuWithDict:(NSDictionary *)dict
-+ menuAction:(RNSBarButtonMenuItemAction)menuAction
-+ imageLoader:(RCTImageLoader *)imageLoader;
- #endif // defined(__cplusplus)
-
- @end
-diff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mm
-index 76a39d4..15bd5a9 100644
---- a/ios/RNSBarButtonItem.mm
-+++ b/ios/RNSBarButtonItem.mm
-@@ -110,6 +110,58 @@ - (instancetype)initWithConfig:(NSDictionary *)dict
- if (dict[@"accessibilityHint"]) {
- self.accessibilityHint = dict[@"accessibilityHint"];
- }
-+#if !TARGET_OS_TV && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-+ NSNumber *glassEffectNum = dict[@"glassEffect"];
-+ if (@available(iOS 26.0, *)) {
-+ if ([glassEffectNum boolValue]) {
-+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
-+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
-+ UIBackgroundConfiguration *background = configuration.background;
-+ background.strokeWidth = 0.0;
-+ background.strokeColor = UIColor.clearColor;
-+ configuration.background = background;
-+ configuration.contentInsets = NSDirectionalEdgeInsetsZero;
-+ if (title != nil) {
-+ configuration.title = title;
-+ }
-+
-+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
-+ button.translatesAutoresizingMaskIntoConstraints = NO;
-+ button.enabled = self.enabled;
-+ button.accessibilityLabel = self.accessibilityLabel;
-+ button.accessibilityHint = self.accessibilityHint;
-+
-+ CGFloat resolvedWidth = width != nil ? width.doubleValue : 56.0;
-+ [button.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
-+ [button.heightAnchor constraintEqualToConstant:50.0].active = YES;
-+
-+ [[self class] resolveImageFromConfig:dict
-+ imageLoader:imageLoader
-+ completionBlock:^(UIImage *img) {
-+ UIButtonConfiguration *updatedConfiguration = button.configuration;
-+ updatedConfiguration.image = img;
-+ button.configuration = updatedConfiguration;
-+ }];
-+
-+ NSDictionary *menu = dict[@"menu"];
-+ if (menu) {
-+ button.menu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader];
-+ button.showsMenuAsPrimaryAction = YES;
-+ } else {
-+ NSString *buttonId = dict[@"buttonId"];
-+ if (buttonId && action) {
-+ UIAction *pressAction = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull sender) {
-+ action(buttonId);
-+ }];
-+ [button addAction:pressAction forControlEvents:UIControlEventTouchUpInside];
-+ }
-+ }
-+
-+ self.customView = button;
-+ return self;
-+ }
-+ }
-+#endif
-
- #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000
- if (@available(tvOS 17.0, *)) {
-diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
-index c233036..0c2db42 100644
---- a/ios/RNSScreenStack.mm
-+++ b/ios/RNSScreenStack.mm
-@@ -1076,6 +1076,15 @@ - (BOOL)scrollViewHasHorizontalPart:(UIScrollView *)scrollView
- return scrollView.contentSize.width > scrollView.frame.size.width;
- }
-
-+// Whether a horizontally scrollable ScrollView is resting at its leading edge, so a
-+// pan in the back-gesture direction has nothing left to scroll. Checked in the
-+// ScrollView's own coordinate space: React Native mirrors the view for RTL layout,
-+// which keeps the leading edge at the minimum content offset in both directions.
-+- (BOOL)scrollViewIsAtLeadingEdge:(UIScrollView *)scrollView
-+{
-+ return scrollView.contentOffset.x <= -scrollView.adjustedContentInset.left + 1.0;
-+}
-+
- // Custom method for compatibility with iOS < 13.4
- // RNSScreenStackView is a UIGestureRecognizerDelegate for three types of gesture recognizers:
- // RNSPanGestureRecognizer, RNSScreenEdgeGestureRecognizer, _UIParallaxTransitionPanGestureRecognizer
-@@ -1169,9 +1178,11 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
- if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
- [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) {
- // ScrollView should take precedence when scrolling horizontally (it should be required to fail).
-- // However, if it does not allow for horizontal scrolling, there should be no such restriction,
-+ // However, if it does not allow for horizontal scrolling, or is already resting at its leading
-+ // edge so a back swipe has nothing left to scroll, there should be no such restriction,
- // and swiping horizontally should dismiss the screen.
-- return [self scrollViewHasHorizontalPart:static_cast(otherGestureRecognizer.view)];
-+ UIScrollView *scrollView = static_cast(otherGestureRecognizer.view);
-+ return [self scrollViewHasHorizontalPart:scrollView] && ![self scrollViewIsAtLeadingEdge:scrollView];
- }
- }
-
-@@ -1191,6 +1202,15 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
- // gestureRecognizer:shouldRequireFailureOfGestureRecognizer
- isEdgeSwipeGestureRecognizer =
- isEdgeSwipeGestureRecognizer && gestureRecognizer != _controller.interactiveContentPopGestureRecognizer;
-+
-+ // The exception is a horizontal ScrollView resting at its leading edge: its pan recognizer would
-+ // still begin (and merely bounce) on a back swipe, so it must yield to the pop gesture the same
-+ // way plain content does.
-+ if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
-+ [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) {
-+ UIScrollView *scrollView = static_cast(otherGestureRecognizer.view);
-+ return [self scrollViewHasHorizontalPart:scrollView] && [self scrollViewIsAtLeadingEdge:scrollView];
-+ }
- }
- #endif // check for iOS >= 26
-
-diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h
-index 8a41697..309ba2f 100644
---- a/ios/RNSScreenStackHeaderConfig.h
-+++ b/ios/RNSScreenStackHeaderConfig.h
-@@ -23,6 +23,8 @@
- NS_ASSUME_NONNULL_BEGIN
-
- @property (nonatomic, retain) NSString *title;
-+@property (nonatomic, retain) NSString *subtitle;
-+@property (nonatomic, retain) NSString *largeSubtitle;
- @property (nonatomic, retain) NSString *titleFontFamily;
- @property (nonatomic, retain) NSNumber *titleFontSize;
- @property (nonatomic, retain) NSString *titleFontWeight;
-@@ -47,9 +49,12 @@ NS_ASSUME_NONNULL_BEGIN
- @property (nonatomic) BOOL backButtonInCustomView;
- @property (nonatomic) UISemanticContentAttribute direction;
- @property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode;
-+@property (nonatomic) NSInteger navigationItemStyle;
- @property (nonatomic) RNSBlurEffectStyle blurEffect;
- @property (nonatomic, copy, nullable) NSArray *> *headerRightBarButtonItems;
- @property (nonatomic, copy, nullable) NSArray *> *headerLeftBarButtonItems;
-+@property (nonatomic, copy, nullable) NSArray *> *headerCenterBarButtonItems;
-+@property (nonatomic, copy, nullable) NSArray *> *headerToolbarItems;
- @property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled;
-
- NS_ASSUME_NONNULL_END
-diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm
-index a54cb63..4ff30b3 100644
---- a/ios/RNSScreenStackHeaderConfig.mm
-+++ b/ios/RNSScreenStackHeaderConfig.mm
-@@ -24,11 +24,46 @@
- #import "RNSSearchBar.h"
- #import "UINavigationBar+RNSUtility.h"
-
-+#import
-+
- namespace react = facebook::react;
-
- static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17;
-+
-+#if !TARGET_OS_TV
-+// Each placement keeps its own cache so changes to one side do not move live
-+// items on the other side into new groups during a navigation transition.
-+static char RNSAppliedLeadingHeaderItemsKey;
-+static char RNSAppliedTrailingHeaderItemsKey;
-+static char RNSAppliedCenterHeaderItemsKey;
-+
-+// Records a placement's owner and contents, returning whether they changed.
-+static BOOL RNSUpdateHeaderItemsCache(UINavigationItem *navitem, const void *key, NSArray *itemsKey)
-+{
-+ if ([objc_getAssociatedObject(navitem, key) isEqual:itemsKey]) {
-+ return NO;
-+ }
-+ objc_setAssociatedObject(navitem, key, itemsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
-+ return YES;
-+}
-+#endif
-+static char RNSAppliedToolbarConfigsKey;
- static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34;
-
-+static NSInteger navigationItemStyleFromCppEquivalent(
-+ react::RNSScreenStackHeaderConfigNavigationItemStyle navigationItemStyle)
-+{
-+ switch (navigationItemStyle) {
-+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Browser:
-+ return UINavigationItemStyleBrowser;
-+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Editor:
-+ return UINavigationItemStyleEditor;
-+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Navigator:
-+ default:
-+ return UINavigationItemStyleNavigator;
-+ }
-+}
-+
- @interface RCTImageLoader (Private)
- - (id)imageCache;
- @end
-@@ -46,6 +81,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string
- @end
-
- @interface RNSScreenStackHeaderConfig ()
-+#if !TARGET_OS_TV
-+- (NSArray *)barButtonItemGroupsFromItems:(NSArray *)items;
-+#endif
- @end
-
- @implementation RNSScreenStackHeaderConfig {
-@@ -80,6 +118,7 @@ - (void)initProps
- self.hidden = YES;
- _reactSubviews = [NSMutableArray new];
- _backTitleVisible = YES;
-+ _navigationItemStyle = UINavigationItemStyleNavigator;
- _blurEffect = RNSBlurEffectStyleNone;
- _synchronousShadowStateUpdatesEnabled = YES;
- }
-@@ -507,6 +546,10 @@ + (void)updateViewController:(UIViewController *)vc
-
- if (shouldHide) {
- navitem.title = config.title;
-+ if (@available(iOS 26.0, *)) {
-+ navitem.subtitle = config.subtitle;
-+ navitem.largeSubtitle = config.largeSubtitle;
-+ }
-
- // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items.
- [navctr setNavigationBarHidden:YES animated:animated];
-@@ -523,11 +566,19 @@ + (void)updateViewController:(UIViewController *)vc
- }
- navitem.largeTitleDisplayMode =
- config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever;
-+
-+ if (@available(iOS 16.0, *)) {
-+ navitem.style = (UINavigationItemStyle)config.navigationItemStyle;
-+ }
- #endif
-
- UINavigationBarAppearance *appearance = [self buildAppearance:vc withConfig:config];
- navitem.standardAppearance = appearance;
- navitem.compactAppearance = appearance;
-+ if (@available(iOS 26.0, *)) {
-+ navitem.subtitle = config.subtitle;
-+ navitem.largeSubtitle = config.largeSubtitle;
-+ }
-
- // appearance does not apply to the tvOS so we need to use lagacy customization
- #if TARGET_OS_TV
-@@ -559,8 +610,6 @@ + (void)updateViewController:(UIViewController *)vc
- navitem.leftItemsSupplementBackButton = config.backButtonInCustomView;
- #endif
- navitem.titleView = nil;
-- navitem.leftBarButtonItems = nil;
-- navitem.rightBarButtonItems = nil;
-
- #if !TARGET_OS_TV
- // We want to set navitem.searchController to nil only if we are sure
-@@ -568,22 +617,18 @@ + (void)updateViewController:(UIViewController *)vc
- bool searchBarPresent = false;
- #endif /* !TARGET_OS_TV */
-
-+ NSMutableArray *subviewLeftItems = [NSMutableArray array];
-+ NSMutableArray *subviewRightItems = [NSMutableArray array];
- for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) {
- // This code should be kept in sync on Fabric with analogous switch statement in
- // `- [RNSScreenStackHeaderConfig replaceNavigationBarViewsWithSnapshotOfSubview:]` method.
- switch (subview.type) {
- case RNSScreenStackHeaderSubviewTypeLeft: {
-- NSArray *currentItems = navitem.leftBarButtonItems ?: @[];
-- NSMutableArray *mutableItems = [currentItems mutableCopy];
-- [mutableItems addObject:[subview getUIBarButtonItem]];
-- navitem.leftBarButtonItems = mutableItems;
-+ [subviewLeftItems addObject:[subview getUIBarButtonItem]];
- break;
- }
- case RNSScreenStackHeaderSubviewTypeRight: {
-- NSArray *currentItems = navitem.rightBarButtonItems ?: @[];
-- NSMutableArray *mutableItems = [currentItems mutableCopy];
-- [mutableItems addObject:[subview getUIBarButtonItem]];
-- navitem.rightBarButtonItems = mutableItems;
-+ [subviewRightItems addObject:[subview getUIBarButtonItem]];
- break;
- }
- case RNSScreenStackHeaderSubviewTypeCenter:
-@@ -648,10 +693,479 @@ + (void)updateViewController:(UIViewController *)vc
- // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).
- // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)
- navitem.title = config.title;
-- navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
-- withCurrentItems:navitem.leftBarButtonItems];
-- navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
-- withCurrentItems:navitem.rightBarButtonItems];
-+ // Retain the config in the key so its address cannot be reused while cached.
-+ // Items capture this config's event emitter in their press handlers, so a
-+ // remounted header-config view with value-equal configs must still rebuild
-+ // instead of dispatching presses into the old config's emitter.
-+ // getUIBarButtonItem is stable for the lifetime of its React header
-+ // subview. Include those native identities in the key so React-backed
-+ // items can use the same cache without reusing a group after its view was
-+ // replaced.
-+ // A bar button item can belong to only one group. Even if the item itself
-+ // is reused, constructing a new group removes it from the old group that
-+ // UIKit may still be animating. Preserve each unchanged placement's group,
-+ // including the empty leading group beside the system back button.
-+#if !TARGET_OS_TV
-+ if (@available(iOS 16.0, *)) {
-+ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[];
-+ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[];
-+ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[];
-+ if (RNSUpdateHeaderItemsCache(
-+ navitem, &RNSAppliedLeadingHeaderItemsKey, @[ config, headerLeftConfigs, subviewLeftItems ])) {
-+ NSArray *items = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
-+ withCurrentItems:subviewLeftItems
-+ navigationItem:navitem
-+ allowsSearchBarPlacement:NO];
-+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:items];
-+ }
-+ if (RNSUpdateHeaderItemsCache(
-+ navitem, &RNSAppliedTrailingHeaderItemsKey, @[ config, headerRightConfigs, subviewRightItems ])) {
-+ NSArray *items = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
-+ withCurrentItems:subviewRightItems
-+ navigationItem:navitem
-+ allowsSearchBarPlacement:NO];
-+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:items];
-+ }
-+ if (@available(iOS 26.0, *)) {
-+ if (RNSUpdateHeaderItemsCache(navitem, &RNSAppliedCenterHeaderItemsKey, @[ config, headerCenterConfigs ])) {
-+ NSArray *items = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems
-+ withCurrentItems:@[]
-+ navigationItem:navitem
-+ allowsSearchBarPlacement:NO];
-+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:items];
-+ }
-+ }
-+ } else
-+#endif
-+ {
-+ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
-+ withCurrentItems:subviewLeftItems
-+ navigationItem:navitem
-+ allowsSearchBarPlacement:NO];
-+ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
-+ withCurrentItems:subviewRightItems
-+ navigationItem:navitem
-+ allowsSearchBarPlacement:NO];
-+ navitem.leftBarButtonItems = leftBarButtonItems;
-+ navitem.rightBarButtonItems = rightBarButtonItems;
-+ }
-+ NSDictionary *mailSearchToolbarConfig = nil;
-+ NSMutableArray *> *nonMailSearchToolbarConfigs = [NSMutableArray array];
-+ for (NSDictionary *toolbarConfig in config.headerToolbarItems) {
-+ if (toolbarConfig[@"mailSearchToolbar"]) {
-+ if (mailSearchToolbarConfig == nil) {
-+ mailSearchToolbarConfig = toolbarConfig;
-+ }
-+ } else {
-+ [nonMailSearchToolbarConfigs addObject:toolbarConfig];
-+ }
-+ }
-+ NSArray *> *navigationToolbarConfigs = [nonMailSearchToolbarConfigs copy];
-+ if (mailSearchToolbarConfig == nil) {
-+ for (NSDictionary *rightConfig in config.headerRightBarButtonItems) {
-+ if ([rightConfig[@"bottomMailSearchToolbar"] boolValue]) {
-+ mailSearchToolbarConfig = rightConfig;
-+ break;
-+ }
-+ }
-+ }
-+
-+ static NSInteger const RNSMailSearchToolbarViewTag = 260628;
-+ UIView *chromeHostView = vc.view;
-+ UIView *existingMailSearchToolbar = [chromeHostView viewWithTag:RNSMailSearchToolbarViewTag];
-+ if (existingMailSearchToolbar == nil) {
-+ existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag];
-+ }
-+ // Reuse the live toolbar when nothing it was built from changed: rebuilding
-+ // replaces the fallback UISearchTextField with a fresh empty one, dropping
-+ // the user's in-progress search text and first responder on every unrelated
-+ // header update. Keyed on the config instance (the button/search blocks
-+ // capture its event emitter), the toolbar config values, and the host width
-+ // (the width constraint constant is resolved from it at build time).
-+ static char RNSAppliedMailSearchToolbarConfigKey;
-+ NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil
-+ ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ]
-+ : nil;
-+ BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil &&
-+ [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey)
-+ isEqual:mailSearchToolbarKey];
-+ if (!reuseMailSearchToolbar) {
-+ [existingMailSearchToolbar removeFromSuperview];
-+ }
-+
-+ if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) {
-+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-+ if (@available(iOS 26.0, *)) {
-+ CGFloat horizontalInset = 18.0;
-+ NSNumber *width = mailSearchToolbarConfig[@"width"];
-+ CGFloat hostWidth = chromeHostView.bounds.size.width;
-+ if (hostWidth <= 0.0) {
-+ hostWidth = vc.view.bounds.size.width;
-+ }
-+ if (hostWidth <= 0.0) {
-+ hostWidth = [UIScreen mainScreen].bounds.size.width;
-+ }
-+ CGFloat fallbackWidth = MIN(560.0, MAX(300.0, hostWidth - (horizontalInset * 2.0)));
-+ CGFloat resolvedWidth = width != nil ? width.doubleValue : fallbackWidth;
-+ if (hostWidth > 0.0) {
-+ resolvedWidth = MIN(resolvedWidth, MAX(260.0, hostWidth - 12.0));
-+ }
-+
-+ CGFloat toolbarHeight = 48.0;
-+ CGFloat toolbarRadius = toolbarHeight / 2.0;
-+ CGFloat buttonSize = toolbarHeight;
-+ CGFloat sideButtonReserve = buttonSize + 8.0;
-+ CGFloat searchFieldHeight = 42.0;
-+ CGFloat toolbarBottomSpacing = 4.0;
-+ UIFont *searchFont = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle3];
-+ NSDictionary *placeholderAttributes = @{
-+ NSForegroundColorAttributeName : UIColor.secondaryLabelColor,
-+ NSFontAttributeName : searchFont,
-+ };
-+ UIKeyboardLayoutGuide *keyboardLayoutGuide = chromeHostView.keyboardLayoutGuide;
-+
-+ UIView *toolbarHost = [[UIView alloc] init];
-+ toolbarHost.tag = RNSMailSearchToolbarViewTag;
-+ objc_setAssociatedObject(
-+ toolbarHost,
-+ &RNSAppliedMailSearchToolbarConfigKey,
-+ mailSearchToolbarKey,
-+ OBJC_ASSOCIATION_RETAIN_NONATOMIC);
-+ toolbarHost.translatesAutoresizingMaskIntoConstraints = NO;
-+ [chromeHostView addSubview:toolbarHost];
-+ // The screen stays mounted beneath pushed routes, so its keyboard layout
-+ // guide can track a keyboard owned by another screen. Keep the toolbar at
-+ // rest unless its own search field is editing.
-+ NSLayoutConstraint *keyboardAvoidConstraint =
-+ [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor
-+ constant:-toolbarBottomSpacing];
-+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow;
-+ NSLayoutConstraint *restingBottomConstraint =
-+ [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
-+ constant:-toolbarBottomSpacing];
-+ restingBottomConstraint.priority = UILayoutPriorityDefaultHigh;
-+ [NSLayoutConstraint activateConstraints:@[
-+ [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor],
-+ [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
-+ constant:-toolbarBottomSpacing],
-+ keyboardAvoidConstraint,
-+ restingBottomConstraint,
-+ [toolbarHost.widthAnchor constraintEqualToConstant:resolvedWidth],
-+ [toolbarHost.heightAnchor constraintEqualToConstant:toolbarHeight],
-+ ]];
-+ [chromeHostView bringSubviewToFront:toolbarHost];
-+
-+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];
-+ glassEffect.interactive = YES;
-+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];
-+ glassView.clipsToBounds = YES;
-+ glassView.layer.cornerRadius = toolbarRadius;
-+ glassView.translatesAutoresizingMaskIntoConstraints = NO;
-+ [toolbarHost addSubview:glassView];
-+ BOOL hasFilterButton =
-+ mailSearchToolbarConfig[@"filterButtonId"] != nil || mailSearchToolbarConfig[@"filterMenu"] != nil;
-+ BOOL hasComposeButton =
-+ mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil;
-+ CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0;
-+ CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0;
-+ NSLayoutConstraint *glassLeadingConstraint =
-+ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset];
-+ NSLayoutConstraint *glassTrailingConstraint =
-+ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset];
-+ [NSLayoutConstraint activateConstraints:@[
-+ glassLeadingConstraint,
-+ glassTrailingConstraint,
-+ [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
-+ [glassView.heightAnchor constraintEqualToConstant:toolbarHeight],
-+ ]];
-+
-+ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) {
-+ auto eventEmitter = std::static_pointer_cast(
-+ config->_eventEmitter);
-+ if (eventEmitter && buttonId) {
-+ eventEmitter->onPressHeaderBarButtonItem(
-+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{
-+ .buttonId = std::string([buttonId UTF8String])});
-+ }
-+ };
-+
-+ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) {
-+ auto eventEmitter = std::static_pointer_cast(
-+ config->_eventEmitter);
-+ if (eventEmitter && menuId) {
-+ eventEmitter->onPressHeaderBarButtonMenuItem(
-+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{
-+ .menuId = std::string([menuId UTF8String])});
-+ }
-+ };
-+
-+ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) =
-+ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) {
-+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
-+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
-+ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"];
-+ configuration.baseForegroundColor = UIColor.labelColor;
-+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
-+ if (menuConfig != nil) {
-+ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig
-+ menuAction:emitMenuPress
-+ imageLoader:config->_imageLoader];
-+ button.showsMenuAsPrimaryAction = YES;
-+ } else if (buttonId != nil) {
-+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
-+ emitButtonPress(buttonId);
-+ }];
-+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
-+ }
-+ return button;
-+ };
-+
-+ BOOL useFallbackSearchField = [mailSearchToolbarConfig[@"useFallbackSearchField"] boolValue];
-+ UISearchBar *searchBar =
-+ !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil;
-+ NSString *placeholder = mailSearchToolbarConfig[@"placeholder"];
-+ UITextField *resolvedSearchTextField = nil;
-+ if (searchBar != nil) {
-+ if (placeholder != nil) {
-+ searchBar.placeholder = placeholder;
-+ }
-+ searchBar.hidden = NO;
-+ searchBar.alpha = 1.0;
-+ searchBar.userInteractionEnabled = YES;
-+ searchBar.searchBarStyle = UISearchBarStyleMinimal;
-+ searchBar.backgroundImage = [UIImage new];
-+ searchBar.searchTextField.hidden = NO;
-+ searchBar.searchTextField.alpha = 1.0;
-+ searchBar.searchTextField.backgroundColor = UIColor.clearColor;
-+ searchBar.searchTextField.font = searchFont;
-+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;
-+ searchBar.searchTextField.textColor = UIColor.labelColor;
-+ searchBar.searchTextField.tintColor = UIColor.labelColor;
-+ resolvedSearchTextField = searchBar.searchTextField;
-+ if (placeholder != nil) {
-+ searchBar.searchTextField.attributedPlaceholder =
-+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
-+ }
-+ searchBar.translatesAutoresizingMaskIntoConstraints = NO;
-+ [glassView.contentView addSubview:searchBar];
-+ [NSLayoutConstraint activateConstraints:@[
-+ [searchBar.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:8.0],
-+ [searchBar.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-8.0],
-+ [searchBar.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor],
-+ [searchBar.heightAnchor constraintEqualToConstant:searchFieldHeight],
-+ ]];
-+ } else {
-+ UISearchTextField *searchField = [UISearchTextField new];
-+ if (placeholder != nil) {
-+ searchField.placeholder = placeholder;
-+ searchField.attributedPlaceholder =
-+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
-+ }
-+ searchField.borderStyle = UITextBorderStyleNone;
-+ searchField.font = searchFont;
-+ searchField.adjustsFontForContentSizeCategory = YES;
-+ searchField.textColor = UIColor.labelColor;
-+ searchField.tintColor = UIColor.labelColor;
-+ resolvedSearchTextField = searchField;
-+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
-+ [glassView.contentView addSubview:searchField];
-+ [NSLayoutConstraint activateConstraints:@[
-+ [searchField.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:14.0],
-+ [searchField.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-14.0],
-+ [searchField.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor],
-+ [searchField.heightAnchor constraintEqualToConstant:searchFieldHeight],
-+ ]];
-+ }
-+
-+ // A bare UISearchTextField keeps UITextField defaults (sentence
-+ // capitalization, autocorrect), unlike UISearchBar. Search queries are
-+ // identifiers, so match the autoCapitalize "none" the JS fallbacks use.
-+ if (resolvedSearchTextField != nil) {
-+ resolvedSearchTextField.autocapitalizationType = UITextAutocapitalizationTypeNone;
-+ resolvedSearchTextField.autocorrectionType = UITextAutocorrectionTypeNo;
-+ resolvedSearchTextField.spellCheckingType = UITextSpellCheckingTypeNo;
-+ }
-+
-+ NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"];
-+ NSString *searchTextChangeActionIdentifier = @"org.react-native-screens.mail-search-toolbar.text-change";
-+ if (resolvedSearchTextField != nil) {
-+ [resolvedSearchTextField removeActionForIdentifier:searchTextChangeActionIdentifier
-+ forControlEvents:UIControlEventEditingChanged];
-+ }
-+ if (searchTextChangeId != nil && resolvedSearchTextField != nil) {
-+ [resolvedSearchTextField
-+ addAction:[UIAction actionWithTitle:@""
-+ image:nil
-+ identifier:searchTextChangeActionIdentifier
-+ handler:^(__kindof UIAction *_Nonnull action) {
-+ UITextField *field = (UITextField *)action.sender;
-+ emitButtonPress(
-+ [NSString stringWithFormat:@"%@:%@", searchTextChangeId, field.text ?: @""]);
-+ }]
-+ forControlEvents:UIControlEventEditingChanged];
-+ }
-+
-+ UIButton *filterButton = nil;
-+ if (hasFilterButton) {
-+ filterButton = makeGlassButton(
-+ mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
-+ mailSearchToolbarConfig[@"filterButtonId"],
-+ mailSearchToolbarConfig[@"filterMenu"]);
-+ filterButton.translatesAutoresizingMaskIntoConstraints = NO;
-+ [toolbarHost addSubview:filterButton];
-+ [NSLayoutConstraint activateConstraints:@[
-+ [filterButton.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor],
-+ [filterButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
-+ [filterButton.widthAnchor constraintEqualToConstant:buttonSize],
-+ [filterButton.heightAnchor constraintEqualToConstant:buttonSize],
-+ ]];
-+ }
-+
-+ UIButton *composeButton = nil;
-+ if (hasComposeButton) {
-+ composeButton = makeGlassButton(
-+ mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil",
-+ mailSearchToolbarConfig[@"composeButtonId"],
-+ mailSearchToolbarConfig[@"composeMenu"]);
-+ composeButton.translatesAutoresizingMaskIntoConstraints = NO;
-+ [toolbarHost addSubview:composeButton];
-+ [NSLayoutConstraint activateConstraints:@[
-+ [composeButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor],
-+ [composeButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
-+ [composeButton.widthAnchor constraintEqualToConstant:buttonSize],
-+ [composeButton.heightAnchor constraintEqualToConstant:buttonSize],
-+ ]];
-+ }
-+
-+ BOOL showsSearchDismissButton =
-+ [mailSearchToolbarConfig[@"showsSearchDismissButton"] boolValue] && resolvedSearchTextField != nil;
-+ UIButton *searchDismissButton = nil;
-+ if (showsSearchDismissButton) {
-+ searchDismissButton = makeGlassButton(@"xmark", nil, nil);
-+ searchDismissButton.accessibilityLabel = @"Dismiss search keyboard";
-+ searchDismissButton.alpha = 0.0;
-+ searchDismissButton.hidden = YES;
-+ searchDismissButton.translatesAutoresizingMaskIntoConstraints = NO;
-+ [toolbarHost addSubview:searchDismissButton];
-+ [NSLayoutConstraint activateConstraints:@[
-+ [searchDismissButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor],
-+ [searchDismissButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
-+ [searchDismissButton.widthAnchor constraintEqualToConstant:buttonSize],
-+ [searchDismissButton.heightAnchor constraintEqualToConstant:buttonSize],
-+ ]];
-+ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
-+ [searchDismissButton
-+ addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
-+ [weakSearchTextField resignFirstResponder];
-+ }]
-+ forControlEvents:UIControlEventTouchUpInside];
-+ }
-+
-+ __weak UIButton *weakFilterButton = filterButton;
-+ __weak UIButton *weakComposeButton = composeButton;
-+ __weak UIButton *weakSearchDismissButton = searchDismissButton;
-+ __weak NSLayoutConstraint *weakGlassLeadingConstraint = glassLeadingConstraint;
-+ __weak NSLayoutConstraint *weakGlassTrailingConstraint = glassTrailingConstraint;
-+ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint;
-+ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint;
-+ __weak UIView *weakToolbarHost = toolbarHost;
-+ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
-+ void (^setSearchEditingAppearance)(BOOL, BOOL) = ^(BOOL isEditing, BOOL animated) {
-+ weakKeyboardAvoidConstraint.priority =
-+ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow;
-+ weakRestingBottomConstraint.priority =
-+ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh;
-+
-+ if (showsSearchDismissButton) {
-+ if (isEditing) {
-+ weakSearchDismissButton.hidden = NO;
-+ } else {
-+ weakFilterButton.hidden = NO;
-+ weakComposeButton.hidden = NO;
-+ }
-+ weakGlassLeadingConstraint.constant = isEditing ? 0.0 : glassLeadingInset;
-+ weakGlassTrailingConstraint.constant = isEditing ? -sideButtonReserve : glassTrailingInset;
-+
-+ void (^changes)(void) = ^{
-+ weakFilterButton.alpha = isEditing ? 0.0 : 1.0;
-+ weakComposeButton.alpha = isEditing ? 0.0 : 1.0;
-+ weakSearchDismissButton.alpha = isEditing ? 1.0 : 0.0;
-+ [weakToolbarHost layoutIfNeeded];
-+ };
-+ void (^completion)(BOOL) = ^(BOOL finished) {
-+ if (!finished || weakSearchTextField.isFirstResponder != isEditing) {
-+ return;
-+ }
-+ weakFilterButton.hidden = isEditing;
-+ weakComposeButton.hidden = isEditing;
-+ weakSearchDismissButton.hidden = !isEditing;
-+ };
-+ if (animated) {
-+ [UIView animateWithDuration:0.2
-+ delay:0.0
-+ options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut
-+ animations:changes
-+ completion:completion];
-+ } else {
-+ changes();
-+ completion(YES);
-+ }
-+ }
-+ [weakToolbarHost setNeedsLayout];
-+ };
-+
-+ setSearchEditingAppearance(resolvedSearchTextField.isFirstResponder, NO);
-+ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin";
-+ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end";
-+ [resolvedSearchTextField removeActionForIdentifier:beginActionIdentifier
-+ forControlEvents:UIControlEventEditingDidBegin];
-+ [resolvedSearchTextField removeActionForIdentifier:endActionIdentifier
-+ forControlEvents:UIControlEventEditingDidEnd];
-+ [resolvedSearchTextField
-+ addAction:[UIAction actionWithTitle:@""
-+ image:nil
-+ identifier:beginActionIdentifier
-+ handler:^(__kindof UIAction *_Nonnull action) {
-+ setSearchEditingAppearance(YES, YES);
-+ }]
-+ forControlEvents:UIControlEventEditingDidBegin];
-+ [resolvedSearchTextField
-+ addAction:[UIAction actionWithTitle:@""
-+ image:nil
-+ identifier:endActionIdentifier
-+ handler:^(__kindof UIAction *_Nonnull action) {
-+ setSearchEditingAppearance(NO, YES);
-+ }]
-+ forControlEvents:UIControlEventEditingDidEnd];
-+ }
-+#endif
-+ }
-+
-+ NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[];
-+ // Same reuse rule as the header item groups above (including the config
-+ // identity — cached toolbar items capture this config's event emitter).
-+ // The top-view-controller and hidden-state checks scope the skip to
-+ // same-screen refreshes, so transitions between screens with different
-+ // toolbars still reapply.
-+ NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ];
-+ BOOL reuseToolbarItems = navctr.topViewController == vc &&
-+ navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) &&
-+ [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarCacheKey];
-+ if (navctr.topViewController == vc && !reuseToolbarItems) {
-+ NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs
-+ withCurrentItems:@[]
-+ navigationItem:navitem
-+ allowsSearchBarPlacement:YES];
-+ if (toolbarItems.count > 0) {
-+ vc.toolbarItems = toolbarItems;
-+ [navctr setToolbarHidden:NO animated:animated];
-+ } else {
-+ vc.toolbarItems = nil;
-+ [navctr setToolbarHidden:YES animated:animated];
-+ }
-+ objc_setAssociatedObject(
-+ navitem, &RNSAppliedToolbarConfigsKey, toolbarCacheKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
-+ }
-
- // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items
- // (setting nav bar visibility should be done after `navitem.*BarButtonItems`).
-@@ -784,6 +1298,8 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
-
- - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts
- withCurrentItems:(NSArray *)currentItems
-+ navigationItem:(UINavigationItem *)navitem
-+ allowsSearchBarPlacement:(BOOL)allowsSearchBarPlacement
- {
- if (dicts.count == 0) {
- return currentItems;
-@@ -792,7 +1308,200 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
- [items addObjectsFromArray:currentItems];
- for (NSUInteger i = 0; i < dicts.count; i++) {
- NSDictionary *dict = dicts[i];
-- if (dict[@"buttonId"] || dict[@"menu"]) {
-+ if (dict[@"mailSearchToolbar"]) {
-+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-+ if (@available(iOS 26.0, *)) {
-+ NSNumber *width = dict[@"width"];
-+ CGFloat resolvedWidth = width != nil ? width.doubleValue : MAX(320.0, [UIScreen mainScreen].bounds.size.width - 56.0);
-+ CGFloat resolvedHeight = 58.0;
-+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)];
-+ container.translatesAutoresizingMaskIntoConstraints = NO;
-+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
-+ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES;
-+
-+ UIStackView *stackView = [[UIStackView alloc] initWithFrame:container.bounds];
-+ stackView.axis = UILayoutConstraintAxisHorizontal;
-+ stackView.alignment = UIStackViewAlignmentCenter;
-+ stackView.distribution = UIStackViewDistributionFill;
-+ stackView.spacing = 10.0;
-+ stackView.translatesAutoresizingMaskIntoConstraints = NO;
-+ [container addSubview:stackView];
-+ [NSLayoutConstraint activateConstraints:@[
-+ [stackView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor],
-+ [stackView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor],
-+ [stackView.centerYAnchor constraintEqualToAnchor:container.centerYAnchor],
-+ [stackView.heightAnchor constraintEqualToConstant:50.0],
-+ ]];
-+
-+ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) {
-+ auto eventEmitter = std::static_pointer_cast(
-+ self->_eventEmitter);
-+ if (eventEmitter && buttonId) {
-+ eventEmitter->onPressHeaderBarButtonItem(
-+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{
-+ .buttonId = std::string([buttonId UTF8String])});
-+ }
-+ };
-+
-+ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) {
-+ auto eventEmitter = std::static_pointer_cast(
-+ self->_eventEmitter);
-+ if (eventEmitter && menuId) {
-+ eventEmitter->onPressHeaderBarButtonMenuItem(
-+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{
-+ .menuId = std::string([menuId UTF8String])});
-+ }
-+ };
-+
-+ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) =
-+ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) {
-+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
-+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
-+ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"];
-+
-+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
-+ button.translatesAutoresizingMaskIntoConstraints = NO;
-+ [button.widthAnchor constraintEqualToConstant:58.0].active = YES;
-+ [button.heightAnchor constraintEqualToConstant:50.0].active = YES;
-+ if (menuConfig != nil) {
-+ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig
-+ menuAction:emitMenuPress
-+ imageLoader:self->_imageLoader];
-+ button.showsMenuAsPrimaryAction = YES;
-+ } else if (buttonId != nil) {
-+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
-+ emitButtonPress(buttonId);
-+ }];
-+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
-+ }
-+ return button;
-+ };
-+
-+ BOOL hasFilterButton = dict[@"filterButtonId"] != nil || dict[@"filterMenu"] != nil;
-+ BOOL hasComposeButton = dict[@"composeButtonId"] != nil || dict[@"composeMenu"] != nil;
-+
-+ if (hasFilterButton) {
-+ UIButton *filterButton = makeGlassButton(
-+ dict[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
-+ dict[@"filterButtonId"],
-+ dict[@"filterMenu"]);
-+ [stackView addArrangedSubview:filterButton];
-+ }
-+
-+ UISearchBar *searchBar = navitem.searchController != nil ? navitem.searchController.searchBar : [UISearchBar new];
-+ NSString *placeholder = dict[@"placeholder"];
-+ if (placeholder != nil) {
-+ searchBar.placeholder = placeholder;
-+ }
-+ searchBar.searchBarStyle = UISearchBarStyleMinimal;
-+ searchBar.translatesAutoresizingMaskIntoConstraints = NO;
-+ [searchBar.heightAnchor constraintEqualToConstant:50.0].active = YES;
-+ [stackView addArrangedSubview:searchBar];
-+
-+ if (hasComposeButton) {
-+ UIButton *composeButton = makeGlassButton(
-+ dict[@"composeSystemImageName"] ?: @"square.and.pencil",
-+ dict[@"composeButtonId"],
-+ dict[@"composeMenu"]);
-+ [stackView addArrangedSubview:composeButton];
-+ }
-+
-+ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container];
-+ [items addObject:item];
-+ }
-+#endif
-+ } else if (dict[@"searchField"]) {
-+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-+ if (@available(iOS 26.0, *)) {
-+ NSNumber *width = dict[@"width"];
-+ CGFloat resolvedWidth = width != nil ? width.doubleValue : 280.0;
-+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, 44.0)];
-+ container.translatesAutoresizingMaskIntoConstraints = NO;
-+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
-+ [container.heightAnchor constraintEqualToConstant:44.0].active = YES;
-+
-+ UISearchTextField *searchField = [UISearchTextField new];
-+ NSString *placeholder = dict[@"placeholder"];
-+ if (placeholder != nil) {
-+ searchField.placeholder = placeholder;
-+ }
-+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
-+ [container addSubview:searchField];
-+ [NSLayoutConstraint activateConstraints:@[
-+ [searchField.leadingAnchor constraintEqualToAnchor:container.leadingAnchor],
-+ [searchField.trailingAnchor constraintEqualToAnchor:container.trailingAnchor],
-+ [searchField.centerYAnchor constraintEqualToAnchor:container.centerYAnchor],
-+ [searchField.heightAnchor constraintEqualToConstant:40.0],
-+ ]];
-+
-+ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container];
-+ [items addObject:item];
-+ }
-+#endif
-+ } else if (dict[@"searchBarPlacement"]) {
-+ if (!allowsSearchBarPlacement) {
-+ continue;
-+ }
-+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-+ if (@available(iOS 26.0, *)) {
-+ NSNumber *width = dict[@"width"];
-+ UIBarButtonItem *item = nil;
-+ if ([dict[@"activatesSearchController"] boolValue]) {
-+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
-+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
-+ configuration.image = [UIImage systemImageNamed:@"magnifyingglass"];
-+ configuration.baseForegroundColor = UIColor.labelColor;
-+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
-+ button.translatesAutoresizingMaskIntoConstraints = NO;
-+ [button.widthAnchor constraintEqualToConstant:56.0].active = YES;
-+ [button.heightAnchor constraintEqualToConstant:56.0].active = YES;
-+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
-+ if (navitem.searchController != nil) {
-+ [navitem.searchController setActive:YES];
-+ [navitem.searchController.searchBar becomeFirstResponder];
-+ }
-+ }];
-+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
-+ item = [[UIBarButtonItem alloc] initWithCustomView:button];
-+ } else if ([dict[@"customView"] boolValue] && navitem.searchController != nil) {
-+ UISearchBar *searchBar = navitem.searchController.searchBar;
-+ CGFloat resolvedWidth = width != nil ? width.doubleValue : searchBar.frame.size.width;
-+ CGFloat resolvedHeight = searchBar.frame.size.height > 0 ? searchBar.frame.size.height : 44.0;
-+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)];
-+ container.translatesAutoresizingMaskIntoConstraints = NO;
-+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
-+ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES;
-+ searchBar.frame = container.bounds;
-+ searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
-+ [container addSubview:searchBar];
-+ item = [[UIBarButtonItem alloc] initWithCustomView:container];
-+ } else {
-+ item = navitem.searchBarPlacementBarButtonItem;
-+ if (width != nil) {
-+ item.width = width.doubleValue;
-+ }
-+ }
-+ NSNumber *index = dict[@"index"];
-+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-+ if (@available(iOS 26.0, *)) {
-+ NSNumber *sharesBackground = dict[@"sharesBackground"];
-+ if (sharesBackground != nil) {
-+ item.sharesBackground = sharesBackground.boolValue;
-+ }
-+ NSNumber *hidesSharedBackground = dict[@"hidesSharedBackground"];
-+ if (hidesSharedBackground != nil) {
-+ item.hidesSharedBackground = hidesSharedBackground.boolValue;
-+ }
-+ }
-+#endif
-+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
-+ [items insertObject:item atIndex:index.integerValue];
-+ } else {
-+ [items addObject:item];
-+ }
-+ }
-+#endif
-+ } else if (dict[@"buttonId"] || dict[@"menu"]) {
- RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict
- action:^(NSString *buttonId) {
- auto eventEmitter = std::static_pointer_cast(
-@@ -814,19 +1523,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
- }
- imageLoader:_imageLoader];
- NSNumber *index = dict[@"index"];
-- if (index.integerValue < items.count) {
-+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
- [items insertObject:item atIndex:index.integerValue];
- } else {
- [items addObject:item];
- }
- } else if (dict[@"spacing"]) {
-- UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace
-- target:nil
-- action:nil];
-+ BOOL flexible = [dict[@"flexible"] boolValue];
-+ UIBarButtonItem *item = [[UIBarButtonItem alloc]
-+ initWithBarButtonSystemItem:(flexible ? UIBarButtonSystemItemFlexibleSpace : UIBarButtonSystemItemFixedSpace)
-+ target:nil
-+ action:nil];
- NSNumber *spacingValue = dict[@"spacing"];
-- item.width = [spacingValue doubleValue];
-+ if (!flexible) {
-+ item.width = [spacingValue doubleValue];
-+ }
- NSNumber *index = dict[@"index"];
-- if (index.integerValue < items.count) {
-+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
- [items insertObject:item atIndex:index.integerValue];
- } else {
- [items addObject:item];
-@@ -836,6 +1549,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
- return items;
- }
-
-+#if !TARGET_OS_TV
-+- (NSArray *)barButtonItemGroupsFromItems:(NSArray *)items
-+{
-+ if (items.count == 0) {
-+ return @[];
-+ }
-+
-+ NSMutableArray *groups = [NSMutableArray array];
-+ NSMutableArray *sharedRun = [NSMutableArray array];
-+
-+ void (^flushSharedRun)(void) = ^{
-+ if (sharedRun.count == 0) {
-+ return;
-+ }
-+ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil
-+ items:[sharedRun copy]]];
-+ [sharedRun removeAllObjects];
-+ };
-+
-+ for (UIBarButtonItem *item in items) {
-+ BOOL shouldShareBackground = NO;
-+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-+ if (@available(iOS 26.0, *)) {
-+ shouldShareBackground = item.sharesBackground && !item.hidesSharedBackground;
-+ }
-+#endif
-+
-+ if (shouldShareBackground) {
-+ [sharedRun addObject:item];
-+ continue;
-+ }
-+
-+ flushSharedRun();
-+ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil items:@[ item ]]];
-+ }
-+
-+ flushSharedRun();
-+ return groups;
-+}
-+#endif
-+
- RNS_IGNORE_SUPER_CALL_BEGIN
- - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex
- {
-@@ -1025,6 +1779,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
- }
-
- _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);
-+ _subtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.subtitle);
-+ _largeSubtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.largeSubtitle);
- if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {
- _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);
- }
-@@ -1050,6 +1806,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
- _disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
- _backButtonDisplayMode =
- [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];
-+ _navigationItemStyle = navigationItemStyleFromCppEquivalent(newScreenProps.navigationItemStyle);
-
- if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {
- _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle];
-@@ -1096,6 +1853,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
- _headerRightBarButtonItems = array;
- }
-
-+ if (newScreenProps.headerCenterBarButtonItems != oldScreenProps.headerCenterBarButtonItems) {
-+ const auto &vec = newScreenProps.headerCenterBarButtonItems;
-+ NSMutableArray *> *array = [NSMutableArray arrayWithCapacity:vec.size()];
-+ for (const auto &item : vec) {
-+ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item];
-+ if ([dict isKindOfClass:[NSDictionary class]]) {
-+ [array addObject:dict];
-+ }
-+ }
-+ _headerCenterBarButtonItems = array;
-+ }
-+
-+ if (newScreenProps.headerToolbarItems != oldScreenProps.headerToolbarItems) {
-+ const auto &vec = newScreenProps.headerToolbarItems;
-+ NSMutableArray *> *array = [NSMutableArray arrayWithCapacity:vec.size()];
-+ for (const auto &item : vec) {
-+ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item];
-+ if ([dict isKindOfClass:[NSDictionary class]]) {
-+ [array addObject:dict];
-+ }
-+ }
-+ _headerToolbarItems = array;
-+ }
-+
- [self updateViewControllerIfNeeded];
-
- if (needsNavigationControllerLayout) {
-diff --git a/ios/RNSScreenStackHeaderSubview.mm b/ios/RNSScreenStackHeaderSubview.mm
-index add33c4..489afa5 100644
---- a/ios/RNSScreenStackHeaderSubview.mm
-+++ b/ios/RNSScreenStackHeaderSubview.mm
-@@ -20,7 +20,9 @@ @implementation RNSScreenStackHeaderSubview {
- // This is a strong reference to UIBarButtonItem which creates a retain cycle.
- // The cycle is cleared via `invalidateUIBarButtonItem` method, called by `invalidate` callback.
- UIBarButtonItem *_barButtonItem;
-+ NSString *_barButtonItemIdentifier;
- BOOL _hidesSharedBackground;
-+ UILabel *_titleScrollEdgeEffectGuide;
- }
-
- #pragma mark - Common
-@@ -102,6 +104,25 @@ - (void)updateShadowStateWithFrame:(CGRect)frame
- - (void)layoutSubviews
- {
- [super layoutSubviews];
-+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-+ if (@available(iOS 26.0, *)) {
-+ if (_type == RNSScreenStackHeaderSubviewTypeTitle || _type == RNSScreenStackHeaderSubviewTypeCenter) {
-+ // UIKit shapes the navigation bar's scroll-edge fade around native labels,
-+ // but does not recognize Fabric's custom text views. An empty, non-interactive
-+ // label supplies the title's geometry without drawing or becoming a bar item.
-+ if (_titleScrollEdgeEffectGuide == nil) {
-+ _titleScrollEdgeEffectGuide = [UILabel new];
-+ _titleScrollEdgeEffectGuide.accessibilityElementsHidden = YES;
-+ // Append after Fabric's children so their mount/unmount indices stay intact.
-+ [self addSubview:_titleScrollEdgeEffectGuide];
-+ }
-+ _titleScrollEdgeEffectGuide.frame = self.bounds;
-+ } else {
-+ [_titleScrollEdgeEffectGuide removeFromSuperview];
-+ _titleScrollEdgeEffectGuide = nil;
-+ }
-+ }
-+#endif
- [self updateShadowStateInContextOfAncestorView:[self findNavigationBar]];
- }
-
-@@ -124,6 +145,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
- const auto &newHeaderSubviewProps = *std::static_pointer_cast(props);
-
- [self setType:[RNSConvert RNSScreenStackHeaderSubviewTypeFromCppEquivalent:newHeaderSubviewProps.type]];
-+ [self setBarButtonItemIdentifier:RCTNSStringFromStringNilIfEmpty(newHeaderSubviewProps.identifier)];
- [self setHidesSharedBackground:newHeaderSubviewProps.hidesSharedBackground];
- [self setSynchronousShadowStateUpdatesEnabled:newHeaderSubviewProps.synchronousShadowStateUpdatesEnabled];
- [super updateProps:props oldProps:oldProps];
-@@ -276,7 +298,14 @@ - (void)configureBarButtonItem
- #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
- if (@available(iOS 26.0, *)) {
- if (_barButtonItem != nil) {
-- [_barButtonItem setHidesSharedBackground:_hidesSharedBackground];
-+ NSString *currentIdentifier = _barButtonItem.identifier;
-+ if (currentIdentifier != _barButtonItemIdentifier &&
-+ ![currentIdentifier isEqualToString:_barButtonItemIdentifier]) {
-+ _barButtonItem.identifier = _barButtonItemIdentifier;
-+ }
-+ if (_barButtonItem.hidesSharedBackground != _hidesSharedBackground) {
-+ _barButtonItem.hidesSharedBackground = _hidesSharedBackground;
-+ }
- }
- }
- #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-@@ -284,10 +313,22 @@ - (void)configureBarButtonItem
-
- - (void)setHidesSharedBackground:(BOOL)hidesSharedBackground
- {
-+ if (_hidesSharedBackground == hidesSharedBackground) {
-+ return;
-+ }
- _hidesSharedBackground = hidesSharedBackground;
- [self configureBarButtonItem];
- }
-
-+- (void)setBarButtonItemIdentifier:(nullable NSString *)identifier
-+{
-+ if (_barButtonItemIdentifier == identifier || [_barButtonItemIdentifier isEqualToString:identifier]) {
-+ return;
-+ }
-+ _barButtonItemIdentifier = [identifier copy];
-+ [self configureBarButtonItem];
-+}
-+
- #pragma mark - Dynamic frameworks support
-
- // Needed because of this: https://github.com/facebook/react-native/pull/37274
-diff --git a/lib/commonjs/components/ScreenStackHeaderConfig.js b/lib/commonjs/components/ScreenStackHeaderConfig.js
-index 6e68cd1..9e09485 100644
---- a/lib/commonjs/components/ScreenStackHeaderConfig.js
-+++ b/lib/commonjs/components/ScreenStackHeaderConfig.js
-@@ -31,17 +31,42 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
- } = (0, _EdgeInsetApplicationContext.useEdgeInsetApplication)(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false);
- const {
- headerLeftBarButtonItems,
-- headerRightBarButtonItems
-+ headerRightBarButtonItems,
-+ headerCenterBarButtonItems,
-+ headerToolbarItems
- } = props;
- const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerLeftBarButtonItems, 'left') : undefined;
- const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerRightBarButtonItems, 'right') : undefined;
-- const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length);
-+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'center') : undefined;
-+ const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'toolbar') : undefined;
-+ const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length);
-
- // Handle bar button item presses
- const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => {
-- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId);
-+ const buttonId = event.nativeEvent.buttonId;
-+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
-+ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId);
- if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) {
- pressedItem.onPress();
-+ return;
-+ }
-+ for (const item of allItems) {
-+ if (!item || item.type !== 'mailSearchToolbar') {
-+ continue;
-+ }
-+ if (item.filterButtonId === buttonId) {
-+ item.onFilterPress?.();
-+ return;
-+ }
-+ if (item.composeButtonId === buttonId) {
-+ item.onComposePress?.();
-+ return;
-+ }
-+ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined;
-+ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) {
-+ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length));
-+ return;
-+ }
- }
- } : undefined;
-
-@@ -64,7 +89,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
- };
-
- // Check each bar-button item with a menu
-- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])];
-+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
- for (const item of allItems) {
- if (item && item.type === 'menu' && item.menu) {
- const action = findInMenu(item.menu, event.nativeEvent.menuId);
-@@ -72,6 +97,15 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
- action.onPress();
- return;
- }
-+ } else if (item && item.type === 'mailSearchToolbar') {
-+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean);
-+ for (const toolbarMenu of toolbarMenus) {
-+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
-+ if (action) {
-+ action.onPress();
-+ return;
-+ }
-+ }
- }
- }
- } : undefined;
-@@ -79,6 +113,8 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
- userInterfaceStyle: props.experimental_userInterfaceStyle,
- headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems,
- headerRightBarButtonItems: preparedHeaderRightBarButtonItems,
-+ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems,
-+ headerToolbarItems: preparedHeaderToolbarItems,
- onPressHeaderBarButtonItem: onPressHeaderBarButtonItem,
- onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem,
- ref: ref,
-diff --git a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
-index 589b6cc..b584624 100644
---- a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
-+++ b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
-@@ -8,7 +8,7 @@ var _reactNative = require("react-native");
- // Local nominal type so declaration emit doesn't resolve the RN alias down to
- // the non-public `types_generated/.../AssetSourceResolver#ResolvedAssetSource`.
-
--const prepareMenu = (menu, index, side, path = '') => {
-+const prepareMenu = (menu, index, placement, path = '') => {
- return {
- ...menu,
- items: menu.items.map((menuItem, menuIndex) => {
-@@ -29,7 +29,7 @@ const prepareMenu = (menu, index, side, path = '') => {
- xcassetName,
- imageSource,
- templateSource,
-- ...prepareMenu(menuItem, index, side, currentPath)
-+ ...prepareMenu(menuItem, index, placement, currentPath)
- };
- }
- return {
-@@ -38,16 +38,40 @@ const prepareMenu = (menu, index, side, path = '') => {
- xcassetName,
- imageSource,
- templateSource,
-- menuId: `${currentPath}-${index}-${side}`
-+ menuId: `${currentPath}-${index}-${placement}`
- };
- })
- };
- };
--const prepareHeaderBarButtonItems = (barButtonItems, side) => {
-- return barButtonItems?.map((item, index) => {
-+const prepareHeaderBarButtonItems = (barButtonItems, placement) => {
-+ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined;
-+ return items?.map((item, index) => {
- if (item.type === 'spacing') {
- return item;
- }
-+ if (item.type === 'searchBarPlacement') {
-+ if (placement !== 'toolbar') {
-+ return null;
-+ }
-+ return {
-+ ...item,
-+ searchBarPlacement: true
-+ };
-+ }
-+ if (item.type === 'searchField') {
-+ return {
-+ ...item,
-+ searchField: true
-+ };
-+ }
-+ if (item.type === 'mailSearchToolbar') {
-+ return {
-+ ...item,
-+ mailSearchToolbar: true,
-+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined,
-+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined
-+ };
-+ }
- let imageSource, templateSource;
- if (item.icon?.type === 'imageSource') {
- imageSource = _reactNative.Image.resolveAssetSource(item.icon.imageSource);
-@@ -80,17 +104,17 @@ const prepareHeaderBarButtonItems = (barButtonItems, side) => {
- if (item.type === 'button') {
- return {
- ...processedItem,
-- buttonId: `${index}-${side}`
-+ buttonId: `${index}-${placement}`
- };
- }
- if (item.type === 'menu') {
- return {
- ...processedItem,
-- menu: prepareMenu(item.menu, index, side)
-+ menu: prepareMenu(item.menu, index, placement)
- };
- }
- return null;
-- });
-+ }).filter(item => item !== null);
- };
- exports.prepareHeaderBarButtonItems = prepareHeaderBarButtonItems;
- //# sourceMappingURL=prepareHeaderBarButtonItems.js.map
-\ No newline at end of file
-diff --git a/lib/module/components/ScreenStackHeaderConfig.js b/lib/module/components/ScreenStackHeaderConfig.js
-index 242898c..7cebaa3 100644
---- a/lib/module/components/ScreenStackHeaderConfig.js
-+++ b/lib/module/components/ScreenStackHeaderConfig.js
-@@ -27,17 +27,42 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
- } = useEdgeInsetApplication(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false);
- const {
- headerLeftBarButtonItems,
-- headerRightBarButtonItems
-+ headerRightBarButtonItems,
-+ headerCenterBarButtonItems,
-+ headerToolbarItems
- } = props;
- const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerLeftBarButtonItems, 'left') : undefined;
- const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') : undefined;
-- const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length);
-+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center') : undefined;
-+ const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar') : undefined;
-+ const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length);
-
- // Handle bar button item presses
- const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => {
-- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId);
-+ const buttonId = event.nativeEvent.buttonId;
-+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
-+ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId);
- if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) {
- pressedItem.onPress();
-+ return;
-+ }
-+ for (const item of allItems) {
-+ if (!item || item.type !== 'mailSearchToolbar') {
-+ continue;
-+ }
-+ if (item.filterButtonId === buttonId) {
-+ item.onFilterPress?.();
-+ return;
-+ }
-+ if (item.composeButtonId === buttonId) {
-+ item.onComposePress?.();
-+ return;
-+ }
-+ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined;
-+ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) {
-+ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length));
-+ return;
-+ }
- }
- } : undefined;
-
-@@ -60,7 +85,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
- };
-
- // Check each bar-button item with a menu
-- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])];
-+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
- for (const item of allItems) {
- if (item && item.type === 'menu' && item.menu) {
- const action = findInMenu(item.menu, event.nativeEvent.menuId);
-@@ -68,6 +93,15 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
- action.onPress();
- return;
- }
-+ } else if (item && item.type === 'mailSearchToolbar') {
-+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean);
-+ for (const toolbarMenu of toolbarMenus) {
-+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
-+ if (action) {
-+ action.onPress();
-+ return;
-+ }
-+ }
- }
- }
- } : undefined;
-@@ -75,6 +109,8 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
- userInterfaceStyle: props.experimental_userInterfaceStyle,
- headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems,
- headerRightBarButtonItems: preparedHeaderRightBarButtonItems,
-+ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems,
-+ headerToolbarItems: preparedHeaderToolbarItems,
- onPressHeaderBarButtonItem: onPressHeaderBarButtonItem,
- onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem,
- ref: ref,
-diff --git a/lib/module/components/helpers/prepareHeaderBarButtonItems.js b/lib/module/components/helpers/prepareHeaderBarButtonItems.js
-index 24bf271..693cfad 100644
---- a/lib/module/components/helpers/prepareHeaderBarButtonItems.js
-+++ b/lib/module/components/helpers/prepareHeaderBarButtonItems.js
-@@ -3,7 +3,7 @@ import { Image, processColor } from 'react-native';
- // Local nominal type so declaration emit doesn't resolve the RN alias down to
- // the non-public `types_generated/.../AssetSourceResolver#ResolvedAssetSource`.
-
--const prepareMenu = (menu, index, side, path = '') => {
-+const prepareMenu = (menu, index, placement, path = '') => {
- return {
- ...menu,
- items: menu.items.map((menuItem, menuIndex) => {
-@@ -24,7 +24,7 @@ const prepareMenu = (menu, index, side, path = '') => {
- xcassetName,
- imageSource,
- templateSource,
-- ...prepareMenu(menuItem, index, side, currentPath)
-+ ...prepareMenu(menuItem, index, placement, currentPath)
- };
- }
- return {
-@@ -33,16 +33,40 @@ const prepareMenu = (menu, index, side, path = '') => {
- xcassetName,
- imageSource,
- templateSource,
-- menuId: `${currentPath}-${index}-${side}`
-+ menuId: `${currentPath}-${index}-${placement}`
- };
- })
- };
- };
--export const prepareHeaderBarButtonItems = (barButtonItems, side) => {
-- return barButtonItems?.map((item, index) => {
-+export const prepareHeaderBarButtonItems = (barButtonItems, placement) => {
-+ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined;
-+ return items?.map((item, index) => {
- if (item.type === 'spacing') {
- return item;
- }
-+ if (item.type === 'searchBarPlacement') {
-+ if (placement !== 'toolbar') {
-+ return null;
-+ }
-+ return {
-+ ...item,
-+ searchBarPlacement: true
-+ };
-+ }
-+ if (item.type === 'searchField') {
-+ return {
-+ ...item,
-+ searchField: true
-+ };
-+ }
-+ if (item.type === 'mailSearchToolbar') {
-+ return {
-+ ...item,
-+ mailSearchToolbar: true,
-+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined,
-+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined
-+ };
-+ }
- let imageSource, templateSource;
- if (item.icon?.type === 'imageSource') {
- imageSource = Image.resolveAssetSource(item.icon.imageSource);
-@@ -75,16 +99,16 @@ export const prepareHeaderBarButtonItems = (barButtonItems, side) => {
- if (item.type === 'button') {
- return {
- ...processedItem,
-- buttonId: `${index}-${side}`
-+ buttonId: `${index}-${placement}`
- };
- }
- if (item.type === 'menu') {
- return {
- ...processedItem,
-- menu: prepareMenu(item.menu, index, side)
-+ menu: prepareMenu(item.menu, index, placement)
- };
- }
- return null;
-- });
-+ }).filter(item => item !== null);
- };
- //# sourceMappingURL=prepareHeaderBarButtonItems.js.map
-\ No newline at end of file
-diff --git a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts
-index 4704a14..e2e2b78 100644
---- a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts
-+++ b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts
-@@ -2,7 +2,7 @@ import type { ImageResolvedAssetSource } from 'react-native';
- import type { HeaderBarButtonItem } from '../../types';
- export interface ResolvedImageAsset extends ImageResolvedAssetSource {
- }
--export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right") => (import("../..").HeaderBarButtonItemSpacing | {
-+export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right" | "center" | "toolbar") => (import("../..").HeaderBarButtonItemSpacing | {
- buttonId: string;
- imageSource: ResolvedImageAsset | undefined;
- templateSource: ResolvedImageAsset | undefined;
-@@ -164,5 +164,5 @@ export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButt
- identifier?: string | undefined;
- accessibilityLabel?: string | undefined;
- accessibilityHint?: string | undefined;
--} | null)[];
-+})[];
- //# sourceMappingURL=prepareHeaderBarButtonItems.d.ts.map
-\ No newline at end of file
-diff --git a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
-index ec39792..434bccf 100644
---- a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
-+++ b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
-@@ -9,6 +9,7 @@ type OnPressHeaderBarButtonMenuItemEvent = Readonly<{
- menuId: string;
- }>;
- type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';
-+type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
- type BlurEffect = 'none' | 'extraLight' | 'light' | 'dark' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark';
- type UserInterfaceStyle = 'unspecified' | 'light' | 'dark';
- export interface NativeProps extends ViewProps {
-@@ -32,18 +33,23 @@ export interface NativeProps extends ViewProps {
- largeTitleColor?: ColorValue | undefined;
- translucent?: boolean | undefined;
- title?: string | undefined;
-+ subtitle?: string | undefined;
-+ largeSubtitle?: string | undefined;
- titleFontFamily?: string | undefined;
- titleFontSize?: CT.Int32 | undefined;
- titleFontWeight?: string | undefined;
- titleColor?: ColorValue | undefined;
- disableBackButtonMenu?: boolean | undefined;
- backButtonDisplayMode?: CT.WithDefault;
-+ navigationItemStyle?: CT.WithDefault;
- hideBackButton?: boolean | undefined;
- backButtonInCustomView?: boolean | undefined;
- blurEffect?: CT.WithDefault;
- topInsetEnabled?: boolean | undefined;
- headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined;
- headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined;
-+ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined;
-+ headerToolbarItems?: CT.UnsafeMixed[] | undefined;
- onPressHeaderBarButtonItem?: CT.DirectEventHandler | undefined;
- onPressHeaderBarButtonMenuItem?: CT.DirectEventHandler | undefined;
- synchronousShadowStateUpdatesEnabled?: CT.WithDefault;
-diff --git a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts
-index 2cb1a79..d6ca5a4 100644
---- a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts
-+++ b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts
-@@ -3,6 +3,7 @@ export type HeaderSubviewTypes = 'back' | 'right' | 'left' | 'title' | 'center'
- export interface NativeProps extends ViewProps {
- type?: CT.WithDefault;
- hidesSharedBackground?: boolean | undefined;
-+ identifier?: string | undefined;
- synchronousShadowStateUpdatesEnabled?: CT.WithDefault;
- }
- declare const _default: HostComponent;
-diff --git a/lib/typescript/types.d.ts b/lib/typescript/types.d.ts
-index ff37881..b487303 100644
---- a/lib/typescript/types.d.ts
-+++ b/lib/typescript/types.d.ts
-@@ -19,6 +19,7 @@ export type SearchBarCommands = {
- cancelSearch: () => void;
- };
- export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal';
-+export type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
- export type StackPresentationTypes = 'push' | 'modal' | 'transparentModal' | 'containedModal' | 'containedTransparentModal' | 'fullScreenModal' | 'formSheet' | 'pageSheet';
- export type StackAnimationTypes = 'default' | 'fade' | 'fade_from_bottom' | 'flip' | 'none' | 'simple_push' | 'slide_from_bottom' | 'slide_from_right' | 'slide_from_left' | 'ios_from_right' | 'ios_from_left';
- export type BlurEffectTypes = BlurEffect;
-@@ -650,6 +651,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
- * @platform ios
- */
- backButtonDisplayMode?: BackButtonDisplayMode | undefined;
-+ /**
-+ * Controls the UIKit `UINavigationItem.style` used by native iOS headers.
-+ *
-+ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls.
-+ *
-+ * @platform ios
-+ */
-+ navigationItemStyle?: NavigationItemStyle | undefined;
- /**
- * Array of UIBarButtomItems to the left side of the header.
- *
-@@ -662,6 +671,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
- * @platform ios
- */
- headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined;
-+ /**
-+ * Array of UIBarButtonItems to the centered item group in modern iOS headers.
-+ *
-+ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets
-+ * document/editor-style chrome keep primary actions visually centered while
-+ * leaving trailing search separate.
-+ *
-+ * @platform ios
-+ */
-+ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined;
-+ /**
-+ * Array of UIBarButtonItems to display in the native toolbar attached to
-+ * the current screen's navigation controller.
-+ *
-+ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit
-+ * render integrated Mail-style search chrome using the attached SearchBar.
-+ *
-+ * @platform ios
-+ */
-+ headerToolbarItems?: HeaderBarButtonItem[] | undefined;
- /**
- * When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false.
- */
-@@ -723,6 +752,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
- * String that can be displayed in the header as a fallback for `headerTitle`.
- */
- title?: string | undefined;
-+ /**
-+ * String displayed below the compact navigation title on iOS 26+.
-+ *
-+ * @platform ios
-+ */
-+ subtitle?: string | undefined;
-+ /**
-+ * String displayed below the large navigation title on iOS 26+.
-+ *
-+ * Falls back to `subtitle` when omitted.
-+ *
-+ * @platform ios
-+ */
-+ largeSubtitle?: string | undefined;
- /**
- * Allows for setting text color of the title.
- */
-@@ -1018,6 +1061,13 @@ export interface SearchBarProps {
- shouldShowHintSearchIcon?: boolean | undefined;
- }
- export interface ScreenStackHeaderSubviewProps {
-+ /**
-+ * An identifier used to match this item across navigation bar transitions.
-+ * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later.
-+ *
-+ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier
-+ */
-+ identifier?: string | undefined;
- /**
- * A boolean value indicating whether the background this item may share with other items in the bar should be hidden.
- * Only applicable to type="right" and type="left" subviews.
-@@ -1056,6 +1106,11 @@ interface SharedHeaderBarButtonItem {
- * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property
- */
- variant?: 'plain' | 'done' | 'prominent' | undefined;
-+ /**
-+ * Render the item with UIButtonConfiguration.glassButtonConfiguration.
-+ * Only available from iOS 26.0 and later.
-+ */
-+ glassEffect?: boolean | undefined;
- /**
- * The tint color to apply to the item.
- *
-@@ -1201,8 +1256,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
- export interface HeaderBarButtonItemSpacing {
- type: 'spacing';
- spacing: number;
-+ flexible?: boolean | undefined;
-+}
-+export interface HeaderBarButtonSearchBarPlacementItem {
-+ type: 'searchBarPlacement';
-+ activatesSearchController?: boolean | undefined;
-+ customView?: boolean | undefined;
-+ index?: number | undefined;
-+ width?: number | undefined;
-+}
-+export interface HeaderBarButtonSearchFieldItem {
-+ type: 'searchField';
-+ placeholder?: string | undefined;
-+ width?: number | undefined;
-+}
-+export interface HeaderBarButtonMailSearchToolbarItem {
-+ type: 'mailSearchToolbar';
-+ composeButtonId?: string | undefined;
-+ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
-+ composeSystemImageName?: string | undefined;
-+ filterButtonId?: string | undefined;
-+ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
-+ filterSystemImageName?: string | undefined;
-+ onComposePress?: (() => void) | undefined;
-+ onFilterPress?: (() => void) | undefined;
-+ onSearchTextChange?: ((text: string) => void) | undefined;
-+ placeholder?: string | undefined;
-+ searchTextChangeId?: string | undefined;
-+ showsSearchDismissButton?: boolean | undefined;
-+ useFallbackSearchField?: boolean | undefined;
-+ width?: number | undefined;
- }
--export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonItemSpacing;
-+export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonMailSearchToolbarItem | HeaderBarButtonSearchFieldItem | HeaderBarButtonSearchBarPlacementItem | HeaderBarButtonItemSpacing;
- /**
- * Custom Screen Transition
- */
-diff --git a/src/components/ScreenStackHeaderConfig.tsx b/src/components/ScreenStackHeaderConfig.tsx
-index 509d6cd..761e49b 100644
---- a/src/components/ScreenStackHeaderConfig.tsx
-+++ b/src/components/ScreenStackHeaderConfig.tsx
-@@ -54,7 +54,12 @@ export const ScreenStackHeaderConfig = React.forwardRef<
- props.disableBottomInsetApplication ?? false,
- );
-
-- const { headerLeftBarButtonItems, headerRightBarButtonItems } = props;
-+ const {
-+ headerLeftBarButtonItems,
-+ headerRightBarButtonItems,
-+ headerCenterBarButtonItems,
-+ headerToolbarItems,
-+ } = props;
-
- const preparedHeaderLeftBarButtonItems =
- headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
-@@ -64,22 +69,36 @@ export const ScreenStackHeaderConfig = React.forwardRef<
- headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
- ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right')
- : undefined;
-+ const preparedHeaderCenterBarButtonItems =
-+ headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
-+ ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center')
-+ : undefined;
-+ const preparedHeaderToolbarItems =
-+ headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform
-+ ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar')
-+ : undefined;
- const hasHeaderBarButtonItems =
- isHeaderBarButtonsAvailableForCurrentPlatform &&
- (preparedHeaderLeftBarButtonItems?.length ||
-- preparedHeaderRightBarButtonItems?.length);
-+ preparedHeaderRightBarButtonItems?.length ||
-+ preparedHeaderCenterBarButtonItems?.length ||
-+ preparedHeaderToolbarItems?.length);
-
- // Handle bar button item presses
- const onPressHeaderBarButtonItem = hasHeaderBarButtonItems
- ? (event: NativeSyntheticEvent<{ buttonId: string }>) => {
-- const pressedItem = [
-+ const buttonId = event.nativeEvent.buttonId;
-+ const allItems = [
- ...(preparedHeaderLeftBarButtonItems ?? []),
- ...(preparedHeaderRightBarButtonItems ?? []),
-- ].find(
-+ ...(preparedHeaderCenterBarButtonItems ?? []),
-+ ...(preparedHeaderToolbarItems ?? []),
-+ ];
-+ const pressedItem = allItems.find(
- item =>
- item &&
- 'buttonId' in item &&
-- item.buttonId === event.nativeEvent.buttonId,
-+ item.buttonId === buttonId,
- );
- if (
- pressedItem &&
-@@ -87,6 +106,32 @@ export const ScreenStackHeaderConfig = React.forwardRef<
- pressedItem.onPress
- ) {
- pressedItem.onPress();
-+ return;
-+ }
-+ for (const item of allItems) {
-+ if (!item || item.type !== 'mailSearchToolbar') {
-+ continue;
-+ }
-+ if (item.filterButtonId === buttonId) {
-+ item.onFilterPress?.();
-+ return;
-+ }
-+ if (item.composeButtonId === buttonId) {
-+ item.onComposePress?.();
-+ return;
-+ }
-+ const searchTextChangePrefix = item.searchTextChangeId
-+ ? `${item.searchTextChangeId}:`
-+ : undefined;
-+ if (
-+ searchTextChangePrefix &&
-+ buttonId.startsWith(searchTextChangePrefix)
-+ ) {
-+ item.onSearchTextChange?.(
-+ buttonId.slice(searchTextChangePrefix.length),
-+ );
-+ return;
-+ }
- }
- }
- : undefined;
-@@ -117,6 +162,8 @@ export const ScreenStackHeaderConfig = React.forwardRef<
- const allItems = [
- ...(preparedHeaderLeftBarButtonItems ?? []),
- ...(preparedHeaderRightBarButtonItems ?? []),
-+ ...(preparedHeaderCenterBarButtonItems ?? []),
-+ ...(preparedHeaderToolbarItems ?? []),
- ];
- for (const item of allItems) {
- if (item && item.type === 'menu' && item.menu) {
-@@ -125,6 +172,17 @@ export const ScreenStackHeaderConfig = React.forwardRef<
- action.onPress();
- return;
- }
-+ } else if (item && item.type === 'mailSearchToolbar') {
-+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(
-+ Boolean,
-+ );
-+ for (const toolbarMenu of toolbarMenus) {
-+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
-+ if (action) {
-+ action.onPress();
-+ return;
-+ }
-+ }
- }
- }
- }
-@@ -136,6 +194,8 @@ export const ScreenStackHeaderConfig = React.forwardRef<
- userInterfaceStyle={props.experimental_userInterfaceStyle}
- headerLeftBarButtonItems={preparedHeaderLeftBarButtonItems}
- headerRightBarButtonItems={preparedHeaderRightBarButtonItems}
-+ headerCenterBarButtonItems={preparedHeaderCenterBarButtonItems}
-+ headerToolbarItems={preparedHeaderToolbarItems}
- onPressHeaderBarButtonItem={onPressHeaderBarButtonItem}
- onPressHeaderBarButtonMenuItem={onPressHeaderBarButtonMenuItem}
- ref={ref}
-diff --git a/src/components/helpers/prepareHeaderBarButtonItems.ts b/src/components/helpers/prepareHeaderBarButtonItems.ts
-index 19ab7bc..9fd5ceb 100644
---- a/src/components/helpers/prepareHeaderBarButtonItems.ts
-+++ b/src/components/helpers/prepareHeaderBarButtonItems.ts
-@@ -12,7 +12,7 @@ export interface ResolvedImageAsset extends ImageResolvedAssetSource {}
- const prepareMenu = (
- menu: HeaderBarButtonItemWithMenu['menu'],
- index: number,
-- side: 'left' | 'right',
-+ placement: 'left' | 'right' | 'center' | 'toolbar',
- path: string = '',
- ): HeaderBarButtonItemWithMenu['menu'] => {
- return {
-@@ -39,7 +39,7 @@ const prepareMenu = (
- xcassetName,
- imageSource,
- templateSource,
-- ...prepareMenu(menuItem, index, side, currentPath),
-+ ...prepareMenu(menuItem, index, placement, currentPath),
- };
- }
- return {
-@@ -48,20 +48,53 @@ const prepareMenu = (
- xcassetName,
- imageSource,
- templateSource,
-- menuId: `${currentPath}-${index}-${side}`,
-+ menuId: `${currentPath}-${index}-${placement}`,
- };
- }),
- };
- };
-
- export const prepareHeaderBarButtonItems = (
-- barButtonItems: HeaderBarButtonItem[],
-- side: 'left' | 'right',
-+ barButtonItems:
-+ | HeaderBarButtonItem[]
-+ | HeaderBarButtonItem
-+ | null
-+ | undefined,
-+ placement: 'left' | 'right' | 'center' | 'toolbar',
- ) => {
-- return barButtonItems?.map((item, index) => {
-+ const items = Array.isArray(barButtonItems)
-+ ? barButtonItems
-+ : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems
-+ ? [barButtonItems]
-+ : undefined;
-+
-+ return items?.map((item, index) => {
- if (item.type === 'spacing') {
- return item;
- }
-+ if (item.type === 'searchBarPlacement') {
-+ if (placement !== 'toolbar') {
-+ return null;
-+ }
-+ return {
-+ ...item,
-+ searchBarPlacement: true,
-+ };
-+ }
-+ if (item.type === 'searchField') {
-+ return {
-+ ...item,
-+ searchField: true,
-+ };
-+ }
-+ if (item.type === 'mailSearchToolbar') {
-+ return {
-+ ...item,
-+ mailSearchToolbar: true,
-+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined,
-+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined,
-+ };
-+ }
- let imageSource: ResolvedImageAsset | undefined,
- templateSource: ResolvedImageAsset | undefined;
- if (item.icon?.type === 'imageSource') {
-@@ -97,15 +130,15 @@ export const prepareHeaderBarButtonItems = (
- if (item.type === 'button') {
- return {
- ...processedItem,
-- buttonId: `${index}-${side}`,
-+ buttonId: `${index}-${placement}`,
- };
- }
- if (item.type === 'menu') {
- return {
- ...processedItem,
-- menu: prepareMenu(item.menu, index, side),
-+ menu: prepareMenu(item.menu, index, placement),
- };
- }
- return null;
-- });
-+ }).filter(item => item !== null);
- };
-diff --git a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
-index da28c51..1136622 100644
---- a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
-+++ b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
-@@ -19,6 +19,7 @@ type OnPressHeaderBarButtonItemEvent = Readonly<{ buttonId: string }>;
- type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ menuId: string }>;
-
- type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';
-+type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
-
- type BlurEffect =
- | 'none'
-@@ -66,12 +67,15 @@ export interface NativeProps extends ViewProps {
- largeTitleColor?: ColorValue | undefined;
- translucent?: boolean | undefined;
- title?: string | undefined;
-+ subtitle?: string | undefined;
-+ largeSubtitle?: string | undefined;
- titleFontFamily?: string | undefined;
- titleFontSize?: CT.Int32 | undefined;
- titleFontWeight?: string | undefined;
- titleColor?: ColorValue | undefined;
- disableBackButtonMenu?: boolean | undefined;
- backButtonDisplayMode?: CT.WithDefault;
-+ navigationItemStyle?: CT.WithDefault;
- hideBackButton?: boolean | undefined;
- backButtonInCustomView?: boolean | undefined;
- blurEffect?: CT.WithDefault;
-@@ -79,6 +83,8 @@ export interface NativeProps extends ViewProps {
- topInsetEnabled?: boolean | undefined;
- headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined;
- headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined;
-+ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined;
-+ headerToolbarItems?: CT.UnsafeMixed[] | undefined;
- onPressHeaderBarButtonItem?:
- | CT.DirectEventHandler
- | undefined;
-diff --git a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
-index 9a6af57..03aa2b2 100644
---- a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
-+++ b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
-@@ -18,6 +18,7 @@ export type HeaderSubviewTypes =
- export interface NativeProps extends ViewProps {
- type?: CT.WithDefault;
- hidesSharedBackground?: boolean | undefined;
-+ identifier?: string | undefined;
- synchronousShadowStateUpdatesEnabled?: CT.WithDefault;
- }
-
-diff --git a/src/types.tsx b/src/types.tsx
-index 8594b73..20c6e88 100644
---- a/src/types.tsx
-+++ b/src/types.tsx
-@@ -35,6 +35,7 @@ export type SearchBarCommands = {
- };
-
- export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal';
-+export type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
- export type StackPresentationTypes =
- | 'push'
- | 'modal'
-@@ -754,6 +755,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
- * @platform ios
- */
- backButtonDisplayMode?: BackButtonDisplayMode | undefined;
-+ /**
-+ * Controls the UIKit `UINavigationItem.style` used by native iOS headers.
-+ *
-+ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls.
-+ *
-+ * @platform ios
-+ */
-+ navigationItemStyle?: NavigationItemStyle | undefined;
- /**
- * Array of UIBarButtomItems to the left side of the header.
- *
-@@ -766,6 +775,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
- * @platform ios
- */
- headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined;
-+ /**
-+ * Array of UIBarButtonItems to the centered item group in modern iOS headers.
-+ *
-+ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets
-+ * document/editor-style chrome keep primary actions visually centered while
-+ * leaving trailing search separate.
-+ *
-+ * @platform ios
-+ */
-+ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined;
-+ /**
-+ * Array of UIBarButtonItems to display in the native toolbar attached to
-+ * the current screen's navigation controller.
-+ *
-+ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit
-+ * render integrated Mail-style search chrome using the attached SearchBar.
-+ *
-+ * @platform ios
-+ */
-+ headerToolbarItems?: HeaderBarButtonItem[] | undefined;
- /**
- * When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false.
- */
-@@ -827,6 +856,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
- * String that can be displayed in the header as a fallback for `headerTitle`.
- */
- title?: string | undefined;
-+ /**
-+ * String displayed below the compact navigation title on iOS 26+.
-+ *
-+ * @platform ios
-+ */
-+ subtitle?: string | undefined;
-+ /**
-+ * String displayed below the large navigation title on iOS 26+.
-+ *
-+ * Falls back to `subtitle` when omitted.
-+ *
-+ * @platform ios
-+ */
-+ largeSubtitle?: string | undefined;
- /**
- * Allows for setting text color of the title.
- */
-@@ -1139,6 +1182,13 @@ export interface SearchBarProps {
- }
-
- export interface ScreenStackHeaderSubviewProps {
-+ /**
-+ * An identifier used to match this item across navigation bar transitions.
-+ * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later.
-+ *
-+ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier
-+ */
-+ identifier?: string | undefined;
- /**
- * A boolean value indicating whether the background this item may share with other items in the bar should be hidden.
- * Only applicable to type="right" and type="left" subviews.
-@@ -1180,6 +1230,11 @@ interface SharedHeaderBarButtonItem {
- * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property
- */
- variant?: 'plain' | 'done' | 'prominent' | undefined;
-+ /**
-+ * Render the item with UIButtonConfiguration.glassButtonConfiguration.
-+ * Only available from iOS 26.0 and later.
-+ */
-+ glassEffect?: boolean | undefined;
- /**
- * The tint color to apply to the item.
- *
-@@ -1335,11 +1390,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
- export interface HeaderBarButtonItemSpacing {
- type: 'spacing';
- spacing: number;
-+ flexible?: boolean | undefined;
-+}
-+
-+export interface HeaderBarButtonSearchBarPlacementItem {
-+ type: 'searchBarPlacement';
-+ activatesSearchController?: boolean | undefined;
-+ customView?: boolean | undefined;
-+ index?: number | undefined;
-+ width?: number | undefined;
-+}
-+
-+export interface HeaderBarButtonSearchFieldItem {
-+ type: 'searchField';
-+ placeholder?: string | undefined;
-+ width?: number | undefined;
-+}
-+
-+export interface HeaderBarButtonMailSearchToolbarItem {
-+ type: 'mailSearchToolbar';
-+ composeButtonId?: string | undefined;
-+ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
-+ composeSystemImageName?: string | undefined;
-+ filterButtonId?: string | undefined;
-+ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
-+ filterSystemImageName?: string | undefined;
-+ onComposePress?: (() => void) | undefined;
-+ onFilterPress?: (() => void) | undefined;
-+ onSearchTextChange?: ((text: string) => void) | undefined;
-+ placeholder?: string | undefined;
-+ searchTextChangeId?: string | undefined;
-+ showsSearchDismissButton?: boolean | undefined;
-+ useFallbackSearchField?: boolean | undefined;
-+ width?: number | undefined;
- }
-
- export type HeaderBarButtonItem =
- | HeaderBarButtonItemWithAction
- | HeaderBarButtonItemWithMenu
-+ | HeaderBarButtonMailSearchToolbarItem
-+ | HeaderBarButtonSearchFieldItem
-+ | HeaderBarButtonSearchBarPlacementItem
- | HeaderBarButtonItemSpacing;
-
- /**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5da642b3b618..13ab5e45e26c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -108,11 +108,10 @@ patchedDependencies:
expo-sharing@58.0.12: 5c85c709d951b5522b5bca897204b73ba7c17dec76e0dd155f41f8c2e833a7a8
expo-widgets@58.0.9: 94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b
node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414
- react-native-gesture-handler@3.2.1: d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9
+ react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad
react-native-keyboard-controller@1.22.4: a438114039aed2e280a81d95c25723132ad071e4768fe492bd92aa628ad74597
react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c
react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2
- react-native-screens@4.28.0: cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483
uniwind@1.11.0: a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625
importers:
@@ -282,7 +281,7 @@ importers:
version: 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
'@react-navigation/native-stack':
specifier: 7.17.6
- version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(cdeee8948497d4b80ffe015e2a003650)
+ version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(e715e944bcffabf5b93194c3d9dbe462)
'@shikijs/core':
specifier: 4.2.0
version: 4.2.0
@@ -441,7 +440,7 @@ importers:
version: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
react-native-gesture-handler:
specifier: ~3.2.1
- version: 3.2.1(patch_hash=d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9)(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)
+ version: 3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
react-native-image-viewing:
specifier: ^0.2.2
version: 0.2.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)
@@ -461,8 +460,8 @@ importers:
specifier: ~5.9.1
version: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
react-native-screens:
- specifier: ~4.28.0
- version: 4.28.0(patch_hash=cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
+ specifier: file:deps/react-native-screens-5.0.0-t3.2.tgz
+ version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
react-native-shiki-engine:
specifier: ^0.3.12
version: 0.3.12(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
@@ -9481,8 +9480,9 @@ packages:
react: '*'
react-native: '*'
- react-native-screens@4.28.0:
- resolution: {integrity: sha512-0/79Z8RCibuaAHti+DJ2Dq0m6UFWh4+p1bH58K3cjiQ7IDWs5kU9tCAj4lXcJMHHonbF2mIE2BeaPSEvOpIiFg==}
+ react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz:
+ resolution: {integrity: sha512-VLRJulLFO90VqUuCsxhkeffGIRLYK075jtqGhgEu80H+BNhQilMIPfQ58vBP7YPMbuqStLwT/YLlkMuZ0CxmAA==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz}
+ version: 5.0.0-t3.2
peerDependencies:
react: '*'
react-native: '*'
@@ -14366,7 +14366,7 @@ snapshots:
optionalDependencies:
'@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
- '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(cdeee8948497d4b80ffe015e2a003650)':
+ '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(e715e944bcffabf5b93194c3d9dbe462)':
dependencies:
'@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be)
'@react-navigation/native': 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
@@ -14374,7 +14374,7 @@ snapshots:
react: 19.3.0
react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
react-native-safe-area-context: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
- react-native-screens: 4.28.0(patch_hash=cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
+ react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
sf-symbols-typescript: 2.2.0
warn-once: 0.1.1
transitivePeerDependencies:
@@ -19833,7 +19833,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-native-gesture-handler@3.2.1(patch_hash=d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
+ react-native-gesture-handler@3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
dependencies:
'@types/react-test-renderer': 19.1.0
invariant: 2.2.4
@@ -19883,7 +19883,7 @@ snapshots:
react: 19.3.0
react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
- react-native-screens@4.28.0(patch_hash=cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
+ react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
dependencies:
react: 19.3.0
react-freeze: 1.0.4(react@19.3.0)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 9a887cb06045..3604cc7fe1bf 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -252,7 +252,6 @@ patchedDependencies:
react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch
react-native-keyboard-controller@1.22.4: patches/react-native-keyboard-controller@1.22.4.patch
react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch
- react-native-screens@4.28.0: patches/react-native-screens@4.28.0.patch
uniwind@1.11.0: patches/uniwind@1.11.0.patch
peerDependencyRules:
diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts
index 96af9d410280..963232dbbbd2 100644
--- a/scripts/release-smoke.ts
+++ b/scripts/release-smoke.ts
@@ -41,6 +41,13 @@ function copyWorkspaceManifestFixture(targetRoot: string): void {
NodeFS.cpSync(sourcePath, destinationPath);
}
+ const mobileDependencies = NodePath.resolve(repoRoot, "apps/mobile/deps");
+ if (NodeFS.existsSync(mobileDependencies)) {
+ NodeFS.cpSync(mobileDependencies, NodePath.resolve(targetRoot, "apps/mobile/deps"), {
+ recursive: true,
+ });
+ }
+
const patchesDirectory = NodePath.resolve(repoRoot, "patches");
if (NodeFS.existsSync(patchesDirectory)) {
NodeFS.cpSync(patchesDirectory, NodePath.resolve(targetRoot, "patches"), { recursive: true });