| author | |
| committer | |
| log | 468ade30495c450ae4e731483187f77bc3c5e42b |
| tree | f4e33b1fe991c9ad541f3bc63180cee0e94cd56c |
| parent | a9fb6a80635c77a1d4fd988e0018059e13628eff |
| signature | Signed by PGP key B5690EEEBB952194 |
47 files changed, 1916 insertions(+), 2408 deletions(-)
apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz created| Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz differ | |||
apps/mobile/fingerprint.config.js+2| ... | @@ -14,5 +14,7 @@ if (!majorVersion) { | ... | @@ -14,5 +14,7 @@ if (!majorVersion) { |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | module.exports = { | 16 | module.exports = { |
| 17 | // Hash the pinned Screens fork's native source, rather than only its version. | ||
| 18 | nativeModuleSourceType: "files", | ||
| 17 | extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }], | 19 | extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }], |
| 18 | }; | 20 | }; |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+9-6| ... | @@ -287,6 +287,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) | ... | @@ -287,6 +287,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 287 | UILongPressGestureRecognizer *_longPressGestureRecognizer; | 287 | UILongPressGestureRecognizer *_longPressGestureRecognizer; |
| 288 | UITapGestureRecognizer *_pressGestureRecognizer; | 288 | UITapGestureRecognizer *_pressGestureRecognizer; |
| 289 | NSArray *_contextAccessibilityElements; | 289 | NSArray *_contextAccessibilityElements; |
| 290 | BOOL _textLayoutNeedsUpdate; | ||
| 290 | } | 291 | } |
| 291 | 292 | ||
| 292 | + (ComponentDescriptorProvider)componentDescriptorProvider | 293 | + (ComponentDescriptorProvider)componentDescriptorProvider |
| ... | @@ -384,16 +385,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) | ... | @@ -384,16 +385,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 384 | _textView.frame = CGRectZero; | 385 | _textView.frame = CGRectZero; |
| 385 | _textView.attributedText = nil; | 386 | _textView.attributedText = nil; |
| 386 | _contextAccessibilityElements = nil; | 387 | _contextAccessibilityElements = nil; |
| 388 | _textLayoutNeedsUpdate = YES; | ||
| 387 | } | 389 | } |
| 388 | 390 | ||
| 389 | - (void)layoutSubviews | 391 | - (void)layoutSubviews |
| 390 | { | 392 | { |
| 391 | [super layoutSubviews]; | 393 | [super layoutSubviews]; |
| 392 | // _textView's frame is assigned inside drawRect, which only fires when | 394 | // Resize during UIKit's layout pass. Doing this in drawRect can leave the |
| 393 | // state changes. Trigger a redraw whenever the host frame moves out from | 395 | // text view's drawing surface clipped to its previous width after a pane |
| 394 | // under it (rotation, parent relayout) so the text view resizes and | 396 | // expands, even though TextKit has already rewrapped the lines. |
| 395 | // onTextLayout re-fires with the new line wrapping. | ||
| 396 | if (!CGRectEqualToRect(_textView.frame, _view.frame)) { | 397 | if (!CGRectEqualToRect(_textView.frame, _view.frame)) { |
| 398 | _textView.frame = _view.frame; | ||
| 399 | _textLayoutNeedsUpdate = YES; | ||
| 397 | [self setNeedsDisplay]; | 400 | [self setNeedsDisplay]; |
| 398 | } | 401 | } |
| 399 | } | 402 | } |
| ... | @@ -455,7 +458,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) | ... | @@ -455,7 +458,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 455 | // entirely when nothing actually changed so a JS-side state update made in | 458 | // entirely when nothing actually changed so a JS-side state update made in |
| 456 | // response to onSelectionChange doesn't deselect what the user is selecting. | 459 | // response to onSelectionChange doesn't deselect what the user is selecting. |
| 457 | const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; | 460 | const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; |
| 458 | const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); | 461 | const BOOL frameChanged = _textLayoutNeedsUpdate; |
| 459 | if (!textChanged && !frameChanged) { | 462 | if (!textChanged && !frameChanged) { |
| 460 | return; | 463 | return; |
| 461 | } | 464 | } |
| ... | @@ -482,9 +485,9 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) | ... | @@ -482,9 +485,9 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 482 | _suppressSelectionChange = NO; | 485 | _suppressSelectionChange = NO; |
| 483 | } | 486 | } |
| 484 | if (frameChanged) { | 487 | if (frameChanged) { |
| 485 | _textView.frame = _view.frame; | ||
| 486 | _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); | 488 | _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); |
| 487 | } | 489 | } |
| 490 | _textLayoutNeedsUpdate = NO; | ||
| 488 | 491 | ||
| 489 | // Text attachments have no native link element. Expose their existing runs | 492 | // Text attachments have no native link element. Expose their existing runs |
| 490 | // at the measured glyph bounds, without inserting views into text layout. | 493 | // at the measured glyph bounds, without inserting views into text layout. |
apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import ExpoModulesCore | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | /// Reports the body geometry of a native navigation column. | ||
| 5 | final class T3LayoutMetricsView: ExpoView { | ||
| 6 | let onMetricsChange = EventDispatcher() | ||
| 7 | private var lastMetrics: NSDictionary? | ||
| 8 | |||
| 9 | required init(appContext: AppContext? = nil) { | ||
| 10 | super.init(appContext: appContext) | ||
| 11 | isUserInteractionEnabled = false | ||
| 12 | registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in | ||
| 13 | view.publishMetrics() | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | override func didMoveToWindow() { | ||
| 18 | super.didMoveToWindow() | ||
| 19 | lastMetrics = nil | ||
| 20 | publishMetrics() | ||
| 21 | } | ||
| 22 | |||
| 23 | override func layoutSubviews() { | ||
| 24 | super.layoutSubviews() | ||
| 25 | publishMetrics() | ||
| 26 | } | ||
| 27 | |||
| 28 | override func safeAreaInsetsDidChange() { | ||
| 29 | super.safeAreaInsetsDidChange() | ||
| 30 | publishMetrics() | ||
| 31 | } | ||
| 32 | |||
| 33 | private func publishMetrics() { | ||
| 34 | guard window != nil, bounds.width > 0, bounds.height > 0 else { return } | ||
| 35 | let metrics: [String: Any] = [ | ||
| 36 | "width": bounds.width, "height": bounds.height, | ||
| 37 | "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", | ||
| 38 | "safeArea": [ | ||
| 39 | "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, | ||
| 40 | "left": safeAreaInsets.left, "right": safeAreaInsets.right, | ||
| 41 | ], | ||
| 42 | ] | ||
| 43 | let next = metrics as NSDictionary | ||
| 44 | guard lastMetrics?.isEqual(next) != true else { return } | ||
| 45 | lastMetrics = next | ||
| 46 | onMetricsChange(metrics) | ||
| 47 | } | ||
| 48 | } | ||
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+11| ... | @@ -11,6 +11,17 @@ public final class T3NativeControlsModule: Module { | ... | @@ -11,6 +11,17 @@ public final class T3NativeControlsModule: Module { |
| 11 | private var filePresentation: T3NativeFilePresentation? | 11 | private var filePresentation: T3NativeFilePresentation? |
| 12 | 12 | ||
| 13 | public func definition() -> ModuleDefinition { | 13 | public func definition() -> ModuleDefinition { |
| 14 | Constants { | ||
| 15 | if #available(iOS 26.0, *) { | ||
| 16 | return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil] | ||
| 17 | } | ||
| 18 | return ["supportsWorkspaceColumns": false] | ||
| 19 | } | ||
| 20 | View(T3LayoutMetricsView.self) { | ||
| 21 | ViewName("LayoutMetrics") | ||
| 22 | Events("onMetricsChange") | ||
| 23 | } | ||
| 24 | |||
| 14 | AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in | 25 | AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in |
| 15 | try self.presentVideo( | 26 | try self.presentVideo( |
| 16 | url: url, | 27 | url: url, |
apps/mobile/package.json+3-2| ... | @@ -120,7 +120,7 @@ | ... | @@ -120,7 +120,7 @@ |
| 120 | "react-native-nitro-modules": "0.35.9", | 120 | "react-native-nitro-modules": "0.35.9", |
| 121 | "react-native-reanimated": "4.7.0", | 121 | "react-native-reanimated": "4.7.0", |
| 122 | "react-native-safe-area-context": "~5.9.1", | 122 | "react-native-safe-area-context": "~5.9.1", |
| 123 | "react-native-screens": "~4.28.0", | 123 | "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz", |
| 124 | "react-native-shiki-engine": "^0.3.12", | 124 | "react-native-shiki-engine": "^0.3.12", |
| 125 | "react-native-svg": "15.15.5", | 125 | "react-native-svg": "15.15.5", |
| 126 | "react-native-webview": "14.0.1", | 126 | "react-native-webview": "14.0.1", |
| ... | @@ -142,7 +142,8 @@ | ... | @@ -142,7 +142,8 @@ |
| 142 | "install": { | 142 | "install": { |
| 143 | "exclude": [ | 143 | "exclude": [ |
| 144 | "react-native-keyboard-controller", | 144 | "react-native-keyboard-controller", |
| 145 | "typescript" | 145 | "typescript", |
| 146 | "react-native-screens" | ||
| 146 | ] | 147 | ] |
| 147 | }, | 148 | }, |
| 148 | "autolinking": { | 149 | "autolinking": { |
apps/mobile/src/Stack.tsx+8-4| ... | @@ -1,3 +1,8 @@ | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | ||
| 2 | createV5StackNavigator as createNativeStackNavigator, | ||
| 3 | createV5SheetStackNavigator, | ||
| 4 | } from "./native/createV5StackNavigator"; | ||
| 5 | import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; | ||
| 1 | import { | 6 | import { |
| 2 | createPathConfigForStaticNavigation, | 7 | createPathConfigForStaticNavigation, |
| 3 | getPathFromState, | 8 | getPathFromState, |
| ... | @@ -6,7 +11,6 @@ import { | ... | @@ -6,7 +11,6 @@ import { |
| 6 | useNavigation, | 11 | useNavigation, |
| 7 | } from "@react-navigation/native"; | 12 | } from "@react-navigation/native"; |
| 8 | import { | 13 | import { |
| 9 | createNativeStackNavigator, | ||
| 10 | createNativeStackScreen, | 14 | createNativeStackScreen, |
| 11 | type NativeStackNavigationOptions, | 15 | type NativeStackNavigationOptions, |
| 12 | } from "@react-navigation/native-stack"; | 16 | } from "@react-navigation/native-stack"; |
| ... | @@ -189,7 +193,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { | ... | @@ -189,7 +193,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { |
| 189 | presentation: "fullScreenModal", | 193 | presentation: "fullScreenModal", |
| 190 | }; | 194 | }; |
| 191 | 195 | ||
| 192 | const SettingsContentStack = createNativeStackNavigator({ | 196 | const SettingsContentStack = createV5SheetStackNavigator({ |
| 193 | initialRouteName: "Settings", | 197 | initialRouteName: "Settings", |
| 194 | screenOptions: { | 198 | screenOptions: { |
| 195 | ...GLASS_HEADER_OPTIONS, | 199 | ...GLASS_HEADER_OPTIONS, |
| ... | @@ -399,7 +403,7 @@ const SettingsContentStack = createNativeStackNavigator({ | ... | @@ -399,7 +403,7 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 399 | // The outer stack never owns visible chrome. Settings routes render inside a | 403 | // The outer stack never owns visible chrome. Settings routes render inside a |
| 400 | // nested stack whose native header remains mounted, while Clerk owns auth chrome. | 404 | // nested stack whose native header remains mounted, while Clerk owns auth chrome. |
| 401 | // Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. | 405 | // Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. |
| 402 | const SettingsSheetStack = createNativeStackNavigator({ | 406 | const SettingsSheetStack = createV5SheetStackNavigator({ |
| 403 | initialRouteName: "SettingsContent", | 407 | initialRouteName: "SettingsContent", |
| 404 | screenOptions: { | 408 | screenOptions: { |
| 405 | headerShown: false, | 409 | headerShown: false, |
| ... | @@ -670,7 +674,7 @@ function NotFoundScreen() { | ... | @@ -670,7 +674,7 @@ function NotFoundScreen() { |
| 670 | ); | 674 | ); |
| 671 | } | 675 | } |
| 672 | 676 | ||
| 673 | const RootStackConfig = createNativeStackNavigator({ | 677 | const RootStackConfig = createWorkspaceStackNavigator({ |
| 674 | initialRouteName: "Home", | 678 | initialRouteName: "Home", |
| 675 | layout: RootStackLayout, | 679 | layout: RootStackLayout, |
| 676 | screenOptions: { | 680 | screenOptions: { |
apps/mobile/src/components/CompactBrandTitle.tsx+2-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns"; | ||
| 1 | import Constants from "expo-constants"; | 2 | import Constants from "expo-constants"; |
| 2 | import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; | 3 | import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; |
| 3 | import { Platform, View } from "react-native"; | 4 | import { Platform, View } from "react-native"; |
| ... | @@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing"; | ... | @@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing"; |
| 14 | */ | 15 | */ |
| 15 | export function brandTitleOffset(): number { | 16 | export function brandTitleOffset(): number { |
| 16 | if (Platform.OS !== "ios") return 0; | 17 | if (Platform.OS !== "ios") return 0; |
| 17 | return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; | 18 | return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0; |
| 18 | } | 19 | } |
| 19 | 20 | ||
| 20 | /** | 21 | /** |
apps/mobile/src/components/ScreenHeader.tsx+4| ... | @@ -53,6 +53,10 @@ export function ScreenHeader(props: ScreenHeaderProps) { | ... | @@ -53,6 +53,10 @@ export function ScreenHeader(props: ScreenHeaderProps) { |
| 53 | ]} | 53 | ]} |
| 54 | options={{ | 54 | options={{ |
| 55 | headerShown: true, | 55 | headerShown: true, |
| 56 | headerBackVisible: | ||
| 57 | !layout.usesSplitView || | ||
| 58 | !props.backInSplitView || | ||
| 59 | !(props.backInSplitView.onPress || props.onBack), | ||
| 56 | title: props.title, | 60 | title: props.title, |
| 57 | unstable_headerSubtitle: props.subtitle || undefined, | 61 | unstable_headerSubtitle: props.subtitle || undefined, |
| 58 | ...(props.matchSearchSurface | 62 | ...(props.matchSearchSurface |
apps/mobile/src/features/files/FileTreeBrowser.tsx+47-34| ... | @@ -1,14 +1,14 @@ | ... | @@ -1,14 +1,14 @@ |
| 1 | import { LegendList } from "@legendapp/list/react-native"; | ||
| 1 | import type { ProjectEntry } from "@t3tools/contracts"; | 2 | import type { ProjectEntry } from "@t3tools/contracts"; |
| 2 | import { SymbolView } from "../../components/AppSymbol"; | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; | 5 | import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 7 | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | 8 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; | 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 9 | import { cn } from "../../lib/cn"; | 10 | import { cn } from "../../lib/cn"; |
| 10 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; | 11 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 11 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | ||
| 12 | import { | 12 | import { |
| 13 | buildFileTree, | 13 | buildFileTree, |
| 14 | flattenFileTree, | 14 | flattenFileTree, |
| ... | @@ -17,8 +17,6 @@ import { | ... | @@ -17,8 +17,6 @@ import { |
| 17 | } from "./fileTree"; | 17 | } from "./fileTree"; |
| 18 | 18 | ||
| 19 | const fileTreeCache = new WeakMap<ReadonlyArray<ProjectEntry>, ReadonlyArray<FileTreeNode>>(); | 19 | const fileTreeCache = new WeakMap<ReadonlyArray<ProjectEntry>, ReadonlyArray<FileTreeNode>>(); |
| 20 | const FILE_TREE_INITIAL_RENDER_COUNT = 20; | ||
| 21 | const FILE_TREE_RENDER_BATCH_SIZE = 12; | ||
| 22 | const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; | 20 | const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; |
| 23 | 21 | ||
| 24 | function cachedFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> { | 22 | function cachedFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> { |
| ... | @@ -45,7 +43,8 @@ const FileTreeRow = memo(function FileTreeRow(props: { | ... | @@ -45,7 +43,8 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 45 | readonly selected: boolean; | 43 | readonly selected: boolean; |
| 46 | readonly expanded: boolean; | 44 | readonly expanded: boolean; |
| 47 | readonly loaded: boolean; | 45 | readonly loaded: boolean; |
| 48 | readonly onPressDirectory: (path: string) => void; | 46 | readonly loading: boolean; |
| 47 | readonly onPressDirectory: (path: string, expand: boolean) => void; | ||
| 49 | readonly onPreviewFile?: (path: string) => void; | 48 | readonly onPreviewFile?: (path: string) => void; |
| 50 | readonly onPressFile: (path: string) => void; | 49 | readonly onPressFile: (path: string) => void; |
| 51 | }) { | 50 | }) { |
| ... | @@ -62,7 +61,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { | ... | @@ -62,7 +61,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 62 | }} | 61 | }} |
| 63 | onPress={() => { | 62 | onPress={() => { |
| 64 | if (node.kind === "directory") { | 63 | if (node.kind === "directory") { |
| 65 | props.onPressDirectory(node.path); | 64 | props.onPressDirectory(node.path, !props.expanded); |
| 66 | return; | 65 | return; |
| 67 | } | 66 | } |
| 68 | props.onPressFile(node.path); | 67 | props.onPressFile(node.path); |
| ... | @@ -97,7 +96,9 @@ const FileTreeRow = memo(function FileTreeRow(props: { | ... | @@ -97,7 +96,9 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 97 | > | 96 | > |
| 98 | {node.name} | 97 | {node.name} |
| 99 | </Text> | 98 | </Text> |
| 100 | {node.kind === "directory" && props.loaded ? ( | 99 | {node.kind === "directory" && props.expanded && props.loading ? ( |
| 100 | <ActivityIndicator size="small" accessibilityLabel={`Loading ${node.name}`} /> | ||
| 101 | ) : node.kind === "directory" && props.loaded ? ( | ||
| 101 | <Text className="text-2xs font-t3-medium text-foreground-tertiary"> | 102 | <Text className="text-2xs font-t3-medium text-foreground-tertiary"> |
| 102 | {node.children.length} | 103 | {node.children.length} |
| 103 | </Text> | 104 | </Text> |
| ... | @@ -110,10 +111,12 @@ export function FileTreeBrowser(props: { | ... | @@ -110,10 +111,12 @@ export function FileTreeBrowser(props: { |
| 110 | readonly entries: ReadonlyArray<ProjectEntry>; | 111 | readonly entries: ReadonlyArray<ProjectEntry>; |
| 111 | readonly error: string | null; | 112 | readonly error: string | null; |
| 112 | readonly isPending: boolean; | 113 | readonly isPending: boolean; |
| 114 | readonly isRefreshing: boolean; | ||
| 113 | readonly searchQuery: string; | 115 | readonly searchQuery: string; |
| 114 | readonly searchTruncated: boolean; | 116 | readonly searchTruncated: boolean; |
| 115 | readonly selectedPath: string | null; | 117 | readonly selectedPath: string | null; |
| 116 | readonly loadedDirectories: ReadonlySet<string>; | 118 | readonly loadedDirectories: ReadonlySet<string>; |
| 119 | readonly loadingDirectories: ReadonlySet<string>; | ||
| 117 | readonly onLoadDirectory: (path: string) => void; | 120 | readonly onLoadDirectory: (path: string) => void; |
| 118 | readonly onPreviewFile?: (path: string) => void; | 121 | readonly onPreviewFile?: (path: string) => void; |
| 119 | readonly onRefresh: () => void; | 122 | readonly onRefresh: () => void; |
| ... | @@ -127,12 +130,13 @@ export function FileTreeBrowser(props: { | ... | @@ -127,12 +130,13 @@ export function FileTreeBrowser(props: { |
| 127 | const insets = useSafeAreaInsets(); | 130 | const insets = useSafeAreaInsets(); |
| 128 | // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the | 131 | // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the |
| 129 | // observed adjustedContentInset bottom (~102) seen in the native trace. | 132 | // observed adjustedContentInset bottom (~102) seen in the native trace. |
| 130 | const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; | 133 | const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0; |
| 131 | const { | 134 | const { |
| 132 | onLoadDirectory, | 135 | onLoadDirectory, |
| 133 | onPreviewFile, | 136 | onPreviewFile, |
| 134 | onSelectFile, | 137 | onSelectFile, |
| 135 | loadedDirectories, | 138 | loadedDirectories, |
| 139 | loadingDirectories, | ||
| 136 | selectedPath: controlledSelectedPath, | 140 | selectedPath: controlledSelectedPath, |
| 137 | } = props; | 141 | } = props; |
| 138 | const controlledSelectedPathRef = useRef(controlledSelectedPath); | 142 | const controlledSelectedPathRef = useRef(controlledSelectedPath); |
| ... | @@ -184,17 +188,21 @@ export function FileTreeBrowser(props: { | ... | @@ -184,17 +188,21 @@ export function FileTreeBrowser(props: { |
| 184 | [], | 188 | [], |
| 185 | ); | 189 | ); |
| 186 | 190 | ||
| 187 | const toggleDirectory = useCallback((path: string) => { | 191 | const toggleDirectory = useCallback( |
| 188 | setExpandedPaths((current) => { | 192 | (path: string, expand: boolean) => { |
| 189 | const next = new Set(current); | 193 | if (expand) onLoadDirectory(path); |
| 190 | if (next.has(path)) { | 194 | setExpandedPaths((current) => { |
| 191 | next.delete(path); | 195 | const next = new Set(current); |
| 192 | } else { | 196 | if (next.has(path)) { |
| 193 | next.add(path); | 197 | next.delete(path); |
| 194 | } | 198 | } else { |
| 195 | return next; | 199 | next.add(path); |
| 196 | }); | 200 | } |
| 197 | }, []); | 201 | return next; |
| 202 | }); | ||
| 203 | }, | ||
| 204 | [onLoadDirectory], | ||
| 205 | ); | ||
| 198 | const handleSelectFile = useCallback( | 206 | const handleSelectFile = useCallback( |
| 199 | (path: string) => { | 207 | (path: string) => { |
| 200 | if (pendingSelectionTimeoutRef.current !== null) { | 208 | if (pendingSelectionTimeoutRef.current !== null) { |
| ... | @@ -219,6 +227,7 @@ export function FileTreeBrowser(props: { | ... | @@ -219,6 +227,7 @@ export function FileTreeBrowser(props: { |
| 219 | selected={item.node.kind === "file" && item.node.path === selectedPath} | 227 | selected={item.node.kind === "file" && item.node.path === selectedPath} |
| 220 | expanded={expandedPaths.has(item.node.path)} | 228 | expanded={expandedPaths.has(item.node.path)} |
| 221 | loaded={loadedDirectories.has(item.node.path)} | 229 | loaded={loadedDirectories.has(item.node.path)} |
| 230 | loading={loadingDirectories.has(item.node.path)} | ||
| 222 | onPressDirectory={toggleDirectory} | 231 | onPressDirectory={toggleDirectory} |
| 223 | onPreviewFile={onPreviewFile} | 232 | onPreviewFile={onPreviewFile} |
| 224 | onPressFile={handleSelectFile} | 233 | onPressFile={handleSelectFile} |
| ... | @@ -229,36 +238,40 @@ export function FileTreeBrowser(props: { | ... | @@ -229,36 +238,40 @@ export function FileTreeBrowser(props: { |
| 229 | handleSelectFile, | 238 | handleSelectFile, |
| 230 | onPreviewFile, | 239 | onPreviewFile, |
| 231 | loadedDirectories, | 240 | loadedDirectories, |
| 241 | loadingDirectories, | ||
| 232 | selectedPath, | 242 | selectedPath, |
| 233 | toggleDirectory, | 243 | toggleDirectory, |
| 234 | ], | 244 | ], |
| 235 | ); | 245 | ); |
| 236 | 246 | ||
| 237 | // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and | 247 | const extraData = useMemo( |
| 238 | // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"` | 248 | () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), |
| 239 | // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge | 249 | [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], |
| 240 | // blur to a scroll view in the screen's primary position; a scroll view buried in | 250 | ); |
| 241 | // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. | 251 | |
| 252 | // UIKit owns the header inset on every supported iOS version. Keep the | ||
| 253 | // list as direct screen content so automatic inset adjustment can find it. | ||
| 242 | return ( | 254 | return ( |
| 243 | <FlatList | 255 | <LegendList |
| 256 | contentInsetStartAdjustment={headerInset} | ||
| 244 | alwaysBounceVertical | 257 | alwaysBounceVertical |
| 245 | className="flex-1" | 258 | className="flex-1" |
| 246 | data={visibleNodes} | 259 | data={visibleNodes} |
| 247 | keyExtractor={(item) => item.node.path} | 260 | keyExtractor={(item) => item.node.path} |
| 248 | contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} | 261 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} |
| 249 | scrollIndicatorInsets={ | 262 | scrollIndicatorInsets={ |
| 250 | NATIVE_LIQUID_GLASS_SUPPORTED | 263 | Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined |
| 251 | ? { top: headerInset, left: 0, right: 0, bottom: 0 } | ||
| 252 | : undefined | ||
| 253 | } | 264 | } |
| 254 | keyboardDismissMode="on-drag" | 265 | keyboardDismissMode="on-drag" |
| 255 | keyboardShouldPersistTaps="handled" | 266 | keyboardShouldPersistTaps="handled" |
| 256 | initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} | 267 | estimatedItemSize={42} |
| 257 | maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} | 268 | recycleItems |
| 258 | updateCellsBatchingPeriod={16} | 269 | maintainVisibleContentPosition |
| 259 | windowSize={5} | 270 | extraData={extraData} |
| 260 | contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} | 271 | contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} |
| 261 | refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />} | 272 | refreshControl={ |
| 273 | <RefreshControl refreshing={props.isRefreshing} onRefresh={props.onRefresh} /> | ||
| 274 | } | ||
| 262 | renderItem={renderItem} | 275 | renderItem={renderItem} |
| 263 | ListHeaderComponent={ | 276 | ListHeaderComponent={ |
| 264 | <> | 277 | <> |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+11-2| ... | @@ -43,6 +43,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe | ... | @@ -43,6 +43,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe |
| 43 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; | 43 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; |
| 44 | import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; | 44 | import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; |
| 45 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | 45 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 46 | import { ThreadInspectorContentStack } from "../threads/thread-inspector-content-stack"; | ||
| 46 | import { FileTreeBrowser } from "./FileTreeBrowser"; | 47 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 47 | import { useFileTreeEntries } from "./useFileTreeEntries"; | 48 | import { useFileTreeEntries } from "./useFileTreeEntries"; |
| 48 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; | 49 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| ... | @@ -561,9 +562,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | ... | @@ -561,9 +562,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 561 | key={JSON.stringify([environmentId, cwd])} | 562 | key={JSON.stringify([environmentId, cwd])} |
| 562 | entries={entriesQuery.entries} | 563 | entries={entriesQuery.entries} |
| 563 | loadedDirectories={entriesQuery.loadedDirectories} | 564 | loadedDirectories={entriesQuery.loadedDirectories} |
| 565 | loadingDirectories={entriesQuery.loadingDirectories} | ||
| 564 | onLoadDirectory={entriesQuery.loadDirectory} | 566 | onLoadDirectory={entriesQuery.loadDirectory} |
| 565 | error={entriesQuery.error} | 567 | error={entriesQuery.error} |
| 566 | isPending={entriesQuery.isPending} | 568 | isPending={entriesQuery.isPending} |
| 569 | isRefreshing={entriesQuery.isRefreshing} | ||
| 567 | searchQuery={searchQuery} | 570 | searchQuery={searchQuery} |
| 568 | searchTruncated={entriesQuery.searchTruncated} | 571 | searchTruncated={entriesQuery.searchTruncated} |
| 569 | selectedPath={null} | 572 | selectedPath={null} |
| ... | @@ -751,8 +754,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -751,8 +754,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 751 | // Hand the file navigator to the workspace so it renders beside the | 754 | // Hand the file navigator to the workspace so it renders beside the |
| 752 | // navigator, outside this screen's native header. | 755 | // navigator, outside this screen's native header. |
| 753 | const renderWorkspaceInspector = useCallback( | 756 | const renderWorkspaceInspector = useCallback( |
| 754 | () => renderInspector(inspectorHeaderInset), | 757 | () => ( |
| 755 | [inspectorHeaderInset, renderInspector], | 758 | <ThreadInspectorContentStack |
| 759 | mode="files" | ||
| 760 | resetKeys={[threadId, cwd]} | ||
| 761 | renderFiles={() => renderInspector(inspectorHeaderInset)} | ||
| 762 | /> | ||
| 763 | ), | ||
| 764 | [cwd, inspectorHeaderInset, renderInspector, threadId], | ||
| 756 | ); | 765 | ); |
| 757 | useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); | 766 | useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); |
| 758 | 767 |
apps/mobile/src/features/files/fileTree.ts+3-1| ... | @@ -77,6 +77,8 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode { | ... | @@ -77,6 +77,8 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode { |
| 77 | }; | 77 | }; |
| 78 | } | 78 | } |
| 79 | 79 | ||
| 80 | const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); | ||
| 81 | |||
| 80 | function compareNodes( | 82 | function compareNodes( |
| 81 | left: Pick<FileTreeNode, "kind" | "name">, | 83 | left: Pick<FileTreeNode, "kind" | "name">, |
| 82 | right: Pick<FileTreeNode, "kind" | "name">, | 84 | right: Pick<FileTreeNode, "kind" | "name">, |
| ... | @@ -84,7 +86,7 @@ function compareNodes( | ... | @@ -84,7 +86,7 @@ function compareNodes( |
| 84 | if (left.kind !== right.kind) { | 86 | if (left.kind !== right.kind) { |
| 85 | return left.kind === "directory" ? -1 : 1; | 87 | return left.kind === "directory" ? -1 : 1; |
| 86 | } | 88 | } |
| 87 | return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }); | 89 | return fileNameCollator.compare(left.name, right.name); |
| 88 | } | 90 | } |
| 89 | 91 | ||
| 90 | export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> { | 92 | export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> { |
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+2-1| ... | @@ -78,7 +78,6 @@ export function ThreadFileNavigatorPane(props: { | ... | @@ -78,7 +78,6 @@ export function ThreadFileNavigatorPane(props: { |
| 78 | sharesBackground: false, | 78 | sharesBackground: false, |
| 79 | tintColor: foregroundColor, | 79 | tintColor: foregroundColor, |
| 80 | type: "button" as const, | 80 | type: "button" as const, |
| 81 | width: 44, | ||
| 82 | }, | 81 | }, |
| 83 | ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"], | 82 | ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"], |
| 84 | [foregroundColor, toggleAuxiliaryPane], | 83 | [foregroundColor, toggleAuxiliaryPane], |
| ... | @@ -89,6 +88,7 @@ export function ThreadFileNavigatorPane(props: { | ... | @@ -89,6 +88,7 @@ export function ThreadFileNavigatorPane(props: { |
| 89 | key={JSON.stringify([props.environmentId, props.cwd])} | 88 | key={JSON.stringify([props.environmentId, props.cwd])} |
| 90 | entries={entriesQuery.entries} | 89 | entries={entriesQuery.entries} |
| 91 | loadedDirectories={entriesQuery.loadedDirectories} | 90 | loadedDirectories={entriesQuery.loadedDirectories} |
| 91 | loadingDirectories={entriesQuery.loadingDirectories} | ||
| 92 | onLoadDirectory={entriesQuery.loadDirectory} | 92 | onLoadDirectory={entriesQuery.loadDirectory} |
| 93 | error={ | 93 | error={ |
| 94 | canReadFiles | 94 | canReadFiles |
| ... | @@ -98,6 +98,7 @@ export function ThreadFileNavigatorPane(props: { | ... | @@ -98,6 +98,7 @@ export function ThreadFileNavigatorPane(props: { |
| 98 | : (fileAccess.error ?? "This connection cannot read host files.") | 98 | : (fileAccess.error ?? "This connection cannot read host files.") |
| 99 | } | 99 | } |
| 100 | isPending={fileAccess.isPending || entriesQuery.isPending} | 100 | isPending={fileAccess.isPending || entriesQuery.isPending} |
| 101 | isRefreshing={entriesQuery.isRefreshing} | ||
| 101 | searchQuery={searchQuery} | 102 | searchQuery={searchQuery} |
| 102 | searchTruncated={entriesQuery.searchTruncated} | 103 | searchTruncated={entriesQuery.searchTruncated} |
| 103 | selectedPath={props.selectedPath} | 104 | selectedPath={props.selectedPath} |
apps/mobile/src/features/files/useFileTreeEntries.ts+38-5| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; | 1 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 2 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 3 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 4 | import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; | 4 | import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; |
| 5 | 5 | ||
| 6 | import { appAtomRegistry } from "../../state/atom-registry"; | 6 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 7 | import { projectEnvironment } from "../../state/projects"; | 7 | import { projectEnvironment } from "../../state/projects"; |
| ... | @@ -30,7 +30,8 @@ export function useFileTreeEntries(input: { | ... | @@ -30,7 +30,8 @@ export function useFileTreeEntries(input: { |
| 30 | }) | 30 | }) |
| 31 | : null, | 31 | : null, |
| 32 | ); | 32 | ); |
| 33 | const [revision, render] = useReducer((value: number) => value + 1, 0); | 33 | const [, render] = useReducer((value: number) => value + 1, 0); |
| 34 | const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0); | ||
| 34 | const refreshVersion = useRef(0); | 35 | const refreshVersion = useRef(0); |
| 35 | const directories = useMemo( | 36 | const directories = useMemo( |
| 36 | () => ({ | 37 | () => ({ |
| ... | @@ -43,6 +44,9 @@ export function useFileTreeEntries(input: { | ... | @@ -43,6 +44,9 @@ export function useFileTreeEntries(input: { |
| 43 | }), | 44 | }), |
| 44 | [cwd, environmentId], | 45 | [cwd, environmentId], |
| 45 | ); | 46 | ); |
| 47 | const [refreshingDirectories, setRefreshingDirectories] = useState<typeof directories | null>( | ||
| 48 | null, | ||
| 49 | ); | ||
| 46 | useEffect( | 50 | useEffect( |
| 47 | () => () => { | 51 | () => () => { |
| 48 | refreshVersion.current++; | 52 | refreshVersion.current++; |
| ... | @@ -83,6 +87,7 @@ export function useFileTreeEntries(input: { | ... | @@ -83,6 +87,7 @@ export function useFileTreeEntries(input: { |
| 83 | entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, | 87 | entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, |
| 84 | ), | 88 | ), |
| 85 | ); | 89 | ); |
| 90 | entriesChanged(); | ||
| 86 | } else { | 91 | } else { |
| 87 | const error = Cause.squash(result.cause); | 92 | const error = Cause.squash(result.cause); |
| 88 | directories.errors.set( | 93 | directories.errors.set( |
| ... | @@ -113,8 +118,8 @@ export function useFileTreeEntries(input: { | ... | @@ -113,8 +118,8 @@ export function useFileTreeEntries(input: { |
| 113 | } | 118 | } |
| 114 | }; | 119 | }; |
| 115 | visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); | 120 | visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); |
| 116 | return { revision, entries: [...merged.values()], reachableDirectories }; | 121 | return { revision: entriesRevision, entries: [...merged.values()], reachableDirectories }; |
| 117 | }, [directories, revision, rootData, searchData, searching]); | 122 | }, [directories, entriesRevision, rootData, searchData, searching]); |
| 118 | 123 | ||
| 119 | const refresh = useCallback(() => { | 124 | const refresh = useCallback(() => { |
| 120 | refreshRoot(); | 125 | refreshRoot(); |
| ... | @@ -126,6 +131,7 @@ export function useFileTreeEntries(input: { | ... | @@ -126,6 +131,7 @@ export function useFileTreeEntries(input: { |
| 126 | directories.pending.clear(); | 131 | directories.pending.clear(); |
| 127 | directories.errors.clear(); | 132 | directories.errors.clear(); |
| 128 | const version = ++refreshVersion.current; | 133 | const version = ++refreshVersion.current; |
| 134 | setRefreshingDirectories(directories); | ||
| 129 | const remaining = paths.values(); | 135 | const remaining = paths.values(); |
| 130 | const worker = async () => { | 136 | const worker = async () => { |
| 131 | while (version === refreshVersion.current) { | 137 | while (version === refreshVersion.current) { |
| ... | @@ -134,9 +140,34 @@ export function useFileTreeEntries(input: { | ... | @@ -134,9 +140,34 @@ export function useFileTreeEntries(input: { |
| 134 | await loadDirectory(next.value, true); | 140 | await loadDirectory(next.value, true); |
| 135 | } | 141 | } |
| 136 | }; | 142 | }; |
| 137 | for (let index = 0; index < Math.min(4, paths.size); index++) void worker(); | 143 | const queries = |
| 144 | cwd !== null && environmentId !== null | ||
| 145 | ? [ | ||
| 146 | projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }), | ||
| 147 | ...(searching | ||
| 148 | ? [ | ||
| 149 | projectEnvironment.searchEntries({ | ||
| 150 | environmentId, | ||
| 151 | input: { cwd, query: debouncedQuery, limit: 200 }, | ||
| 152 | }), | ||
| 153 | ] | ||
| 154 | : []), | ||
| 155 | ] | ||
| 156 | : []; | ||
| 157 | const work = [ | ||
| 158 | ...queries.map((atom) => | ||
| 159 | executeAtomQuery(appAtomRegistry, atom, { reportFailure: false, reportDefect: false }), | ||
| 160 | ), | ||
| 161 | ...Array.from({ length: Math.min(4, paths.size) }, () => worker()), | ||
| 162 | ]; | ||
| 138 | render(); | 163 | render(); |
| 164 | return Promise.all(work).finally(() => { | ||
| 165 | if (version === refreshVersion.current) setRefreshingDirectories(null); | ||
| 166 | }); | ||
| 139 | }, [ | 167 | }, [ |
| 168 | cwd, | ||
| 169 | debouncedQuery, | ||
| 170 | environmentId, | ||
| 140 | directories, | 171 | directories, |
| 141 | loadDirectory, | 172 | loadDirectory, |
| 142 | refreshRoot, | 173 | refreshRoot, |
| ... | @@ -147,6 +178,7 @@ export function useFileTreeEntries(input: { | ... | @@ -147,6 +178,7 @@ export function useFileTreeEntries(input: { |
| 147 | 178 | ||
| 148 | return { | 179 | return { |
| 149 | entries: snapshot.entries, | 180 | entries: snapshot.entries, |
| 181 | isRefreshing: refreshingDirectories === directories, | ||
| 150 | error: | 182 | error: |
| 151 | root.error ?? | 183 | root.error ?? |
| 152 | (searching ? search.error : null) ?? | 184 | (searching ? search.error : null) ?? |
| ... | @@ -158,6 +190,7 @@ export function useFileTreeEntries(input: { | ... | @@ -158,6 +190,7 @@ export function useFileTreeEntries(input: { |
| 158 | (searching && (query !== debouncedQuery || search.isPending)), | 190 | (searching && (query !== debouncedQuery || search.isPending)), |
| 159 | searchTruncated: searching && (search.data?.truncated ?? false), | 191 | searchTruncated: searching && (search.data?.truncated ?? false), |
| 160 | loadedDirectories: new Set(directories.entries.keys()), | 192 | loadedDirectories: new Set(directories.entries.keys()), |
| 193 | loadingDirectories: new Set(directories.pending.keys()), | ||
| 161 | loadDirectory, | 194 | loadDirectory, |
| 162 | refresh, | 195 | refresh, |
| 163 | }; | 196 | }; |
apps/mobile/src/features/home/HomeHeader.tsx+74-39| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { useCallback, useRef } from "react"; | 2 | import { use, useCallback, useRef } from "react"; |
| 3 | import { Platform } from "react-native"; | ||
| 3 | import type { SearchBarCommands } from "react-native-screens"; | 4 | import type { SearchBarCommands } from "react-native-screens"; |
| 5 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; | ||
| 4 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 6 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 5 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 7 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 6 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | 8 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| ... | @@ -9,11 +11,14 @@ import { | ... | @@ -9,11 +11,14 @@ import { |
| 9 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | 11 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 10 | } from "../layout/native-mail-search-toolbar"; | 12 | } from "../layout/native-mail-search-toolbar"; |
| 11 | import { buildHomeListFilterMenu } from "./home-list-filter-menu"; | 13 | import { buildHomeListFilterMenu } from "./home-list-filter-menu"; |
| 14 | import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; | ||
| 12 | import type { HomeHeaderProps } from "./HomeHeader.types"; | 15 | import type { HomeHeaderProps } from "./HomeHeader.types"; |
| 13 | 16 | ||
| 14 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; | 17 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| 15 | 18 | ||
| 16 | export function HomeHeader(props: HomeHeaderProps) { | 19 | export function HomeHeader(props: HomeHeaderProps) { |
| 20 | const primaryColumn = use(NativePrimaryColumnContext); | ||
| 21 | const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null; | ||
| 17 | const searchBarRef = useRef<SearchBarCommands>(null); | 22 | const searchBarRef = useRef<SearchBarCommands>(null); |
| 18 | const iconColor = useUniwindTheme()["--color-icon"]; | 23 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 19 | // The list uses a fixed creation order and ignores sort/group options, so | 24 | // The list uses a fixed creation order and ignores sort/group options, so |
| ... | @@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 36 | // Static header config (glass, title, fonts) lives in Stack.tsx | 41 | // Static header config (glass, title, fonts) lives in Stack.tsx |
| 37 | // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. | 42 | // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. |
| 38 | headerTintColor: iconColor, | 43 | headerTintColor: iconColor, |
| 39 | unstable_headerRightItems: () => [ | 44 | unstable_headerRightItems: () => |
| 40 | withNativeGlassHeaderItem({ | 45 | iPadSidebar |
| 41 | accessibilityLabel: "Open settings", | 46 | ? createSidebarHeaderItems({ |
| 42 | icon: { name: "ellipsis", type: "sfSymbol" } as const, | 47 | filterIcon: hasCustomListOptions |
| 43 | identifier: "home-settings", | 48 | ? "line.3.horizontal.decrease.circle.fill" |
| 44 | label: "", | 49 | : "line.3.horizontal.decrease.circle", |
| 45 | onPress: props.onOpenSettings, | 50 | filterMenu, |
| 46 | type: "button", | 51 | onOpenSettings: props.onOpenSettings, |
| 47 | }), | 52 | }) |
| 48 | ], | 53 | : [ |
| 54 | withNativeGlassHeaderItem({ | ||
| 55 | accessibilityLabel: "Open settings", | ||
| 56 | icon: { name: "ellipsis", type: "sfSymbol" } as const, | ||
| 57 | identifier: "home-settings", | ||
| 58 | label: "", | ||
| 59 | onPress: props.onOpenSettings, | ||
| 60 | type: "button", | ||
| 61 | }), | ||
| 62 | ], | ||
| 49 | // The keys below are set per-branch (not `undefined`) so a later | 63 | // The keys below are set per-branch (not `undefined`) so a later |
| 50 | // reapply cannot clobber options owned by NativeHeaderToolbar. | 64 | // reapply cannot clobber options owned by NativeHeaderToolbar. |
| 51 | ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED | 65 | ...(iPadSidebar |
| 52 | ? { | 66 | ? { |
| 53 | unstable_headerToolbarItems: () => [ | ||
| 54 | createNativeMailSearchToolbarItem({ | ||
| 55 | composeButtonId: "home-new-task", | ||
| 56 | composeSystemImageName: "square.and.pencil", | ||
| 57 | filterMenu, | ||
| 58 | filterButtonId: "home-filter", | ||
| 59 | filterSystemImageName: hasCustomListOptions | ||
| 60 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 61 | : "line.3.horizontal.decrease", | ||
| 62 | onComposePress: props.onStartNewTask, | ||
| 63 | onSearchTextChange: props.onSearchQueryChange, | ||
| 64 | placeholder: "Search", | ||
| 65 | searchTextChangeId: "home-search-text", | ||
| 66 | showsSearchDismissButton: true, | ||
| 67 | }), | ||
| 68 | ], | ||
| 69 | } | ||
| 70 | : { | ||
| 71 | // Pre-Liquid-Glass iOS: standard pull-down search in the nav | ||
| 72 | // bar; create + sort live in the plain bottom toolbar below. | ||
| 73 | headerSearchBarOptions: { | 67 | headerSearchBarOptions: { |
| 74 | ref: searchBarRef, | 68 | ref: searchBarRef, |
| 75 | autoCapitalize: "none" as const, | 69 | autoCapitalize: "none" as const, |
| 76 | hideNavigationBar: false, | 70 | hideNavigationBar: false, |
| 71 | hideWhenScrolling: false, | ||
| 72 | obscureBackground: false, | ||
| 73 | placement: "stacked" as const, | ||
| 74 | allowToolbarIntegration: false, | ||
| 77 | placeholder: "Search", | 75 | placeholder: "Search", |
| 78 | onCancelButtonPress: () => { | 76 | onCancelButtonPress: () => props.onSearchQueryChange(""), |
| 79 | props.onSearchQueryChange(""); | 77 | onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text), |
| 78 | }, | ||
| 79 | unstable_headerToolbarItems: () => [], | ||
| 80 | } | ||
| 81 | : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED | ||
| 82 | ? { | ||
| 83 | headerSearchBarOptions: { | ||
| 84 | ref: searchBarRef, | ||
| 85 | autoCapitalize: "none" as const, | ||
| 86 | onCancelButtonPress: () => props.onSearchQueryChange(""), | ||
| 80 | }, | 87 | }, |
| 81 | onChangeText: (event) => { | 88 | unstable_headerToolbarItems: () => [ |
| 82 | props.onSearchQueryChange(event.nativeEvent.text); | 89 | createNativeMailSearchToolbarItem({ |
| 90 | composeButtonId: "home-new-task", | ||
| 91 | composeSystemImageName: "square.and.pencil", | ||
| 92 | filterMenu, | ||
| 93 | filterButtonId: "home-filter", | ||
| 94 | filterSystemImageName: hasCustomListOptions | ||
| 95 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 96 | : "line.3.horizontal.decrease", | ||
| 97 | onComposePress: props.onStartNewTask, | ||
| 98 | onSearchTextChange: props.onSearchQueryChange, | ||
| 99 | placeholder: "Search", | ||
| 100 | searchTextChangeId: "home-search-text", | ||
| 101 | showsSearchDismissButton: true, | ||
| 102 | }), | ||
| 103 | ], | ||
| 104 | } | ||
| 105 | : { | ||
| 106 | // Pre-Liquid-Glass iOS: standard pull-down search in the nav | ||
| 107 | // bar; create + sort live in the plain bottom toolbar below. | ||
| 108 | headerSearchBarOptions: { | ||
| 109 | ref: searchBarRef, | ||
| 110 | autoCapitalize: "none" as const, | ||
| 111 | hideNavigationBar: false, | ||
| 112 | placeholder: "Search", | ||
| 113 | onCancelButtonPress: () => { | ||
| 114 | props.onSearchQueryChange(""); | ||
| 115 | }, | ||
| 116 | onChangeText: (event) => { | ||
| 117 | props.onSearchQueryChange(event.nativeEvent.text); | ||
| 118 | }, | ||
| 83 | }, | 119 | }, |
| 84 | }, | 120 | }), |
| 85 | }), | ||
| 86 | }} | 121 | }} |
| 87 | /> | 122 | /> |
| 88 | 123 | ||
| 89 | {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( | 124 | {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( |
| 90 | <NativeHeaderToolbar placement="bottom"> | 125 | <NativeHeaderToolbar placement="bottom"> |
| 91 | <NativeHeaderToolbar.Menu | 126 | <NativeHeaderToolbar.Menu |
| 92 | accessibilityLabel="Filter threads" | 127 | accessibilityLabel="Filter threads" |
apps/mobile/src/features/home/HomeRouteScreen.tsx+11-3| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { use } from "react"; | ||
| 2 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; | ||
| 1 | import * as Arr from "effect/Array"; | 3 | import * as Arr from "effect/Array"; |
| 2 | import * as Order from "effect/Order"; | 4 | import * as Order from "effect/Order"; |
| 3 | import { useNavigation } from "@react-navigation/native"; | 5 | import { useNavigation } from "@react-navigation/native"; |
| ... | @@ -12,6 +14,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | ... | @@ -12,6 +14,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 12 | import { useWorkspaceState } from "../../state/workspace"; | 14 | import { useWorkspaceState } from "../../state/workspace"; |
| 13 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 15 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 14 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 16 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 17 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | ||
| 15 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; | 18 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; |
| 16 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 19 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 17 | import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; | 20 | import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; |
| ... | @@ -29,6 +32,9 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle | ... | @@ -29,6 +32,9 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle |
| 29 | 32 | ||
| 30 | export function HomeRouteScreen() { | 33 | export function HomeRouteScreen() { |
| 31 | const { width: windowWidth } = useWindowDimensions(); | 34 | const { width: windowWidth } = useWindowDimensions(); |
| 35 | const nativePrimaryColumn = use(NativePrimaryColumnContext); | ||
| 36 | const columnMetrics = useNativeColumnLayoutMetrics(); | ||
| 37 | const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; | ||
| 32 | const { layout, panes } = useAdaptiveWorkspaceLayout(); | 38 | const { layout, panes } = useAdaptiveWorkspaceLayout(); |
| 33 | const projects = useProjects(); | 39 | const projects = useProjects(); |
| 34 | const threads = useNavigationThreadShells(); | 40 | const threads = useNavigationThreadShells(); |
| ... | @@ -120,7 +126,7 @@ export function HomeRouteScreen() { | ... | @@ -120,7 +126,7 @@ export function HomeRouteScreen() { |
| 120 | 126 | ||
| 121 | // In split layouts the persistent sidebar IS the thread list — Home becomes | 127 | // In split layouts the persistent sidebar IS the thread list — Home becomes |
| 122 | // an empty detail pane so selecting a thread never transitions layouts. | 128 | // an empty detail pane so selecting a thread never transitions layouts. |
| 123 | if (layout.usesSplitView) { | 129 | if (layout.usesSplitView && !nativePrimaryColumn) { |
| 124 | return ( | 130 | return ( |
| 125 | <> | 131 | <> |
| 126 | <NativeStackScreenOptions | 132 | <NativeStackScreenOptions |
| ... | @@ -169,10 +175,12 @@ export function HomeRouteScreen() { | ... | @@ -169,10 +175,12 @@ export function HomeRouteScreen() { |
| 169 | shallow-merged. The brand slot also doubles as the connection | 175 | shallow-merged. The brand slot also doubles as the connection |
| 170 | status surface while an environment reconnects. */} | 176 | status surface while an environment reconnects. */} |
| 171 | <NativeStackScreenOptions | 177 | <NativeStackScreenOptions |
| 172 | optionsVersion={windowWidth} | 178 | optionsVersion={headerWidth} |
| 173 | options={{ | 179 | options={{ |
| 174 | ...getConnectionAwareBrandHeaderOptions({ | 180 | ...getConnectionAwareBrandHeaderOptions({ |
| 175 | headerWidth: windowWidth, | 181 | headerWidth, |
| 182 | trailingItemCount: | ||
| 183 | nativePrimaryColumn && Platform.OS === "ios" && Platform.isPad ? 2 : 1, | ||
| 176 | onOpenEnvironments: () => | 184 | onOpenEnvironments: () => |
| 177 | navigation.navigate("SettingsSheet", { | 185 | navigation.navigate("SettingsSheet", { |
| 178 | screen: "SettingsContent", | 186 | screen: "SettingsContent", |
apps/mobile/src/features/home/HomeScreen.tsx+78-15| ... | @@ -13,15 +13,18 @@ import { | ... | @@ -13,15 +13,18 @@ import { |
| 13 | import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; | 13 | import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 14 | import { useAtomValue } from "@effect/atom-react"; | 14 | import { useAtomValue } from "@effect/atom-react"; |
| 15 | import { useFocusEffect } from "@react-navigation/native"; | 15 | import { useFocusEffect } from "@react-navigation/native"; |
| 16 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 16 | import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; |
| 17 | import { | 17 | import { |
| 18 | ActivityIndicator, | 18 | ActivityIndicator, |
| 19 | Platform, | 19 | Platform, |
| 20 | ScrollView, | ||
| 20 | View, | 21 | View, |
| 22 | type ScrollViewProps, | ||
| 21 | type GestureResponderEvent, | 23 | type GestureResponderEvent, |
| 22 | type NativeScrollEvent, | 24 | type NativeScrollEvent, |
| 23 | type NativeSyntheticEvent, | 25 | type NativeSyntheticEvent, |
| 24 | } from "react-native"; | 26 | } from "react-native"; |
| 27 | import { ScrollViewMarker } from "react-native-screens"; | ||
| 25 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 28 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 26 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 29 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 27 | 30 | ||
| ... | @@ -30,7 +33,10 @@ import { EmptyState } from "../../components/EmptyState"; | ... | @@ -30,7 +33,10 @@ import { EmptyState } from "../../components/EmptyState"; |
| 30 | import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; | 33 | import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; |
| 31 | import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; | 34 | import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; |
| 32 | import type { SavedRemoteConnection } from "../../lib/connection"; | 35 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 33 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 36 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 37 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; | ||
| 38 | import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; | ||
| 39 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | ||
| 34 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 40 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 35 | import { useThreadSearch } from "../../state/queries"; | 41 | import { useThreadSearch } from "../../state/queries"; |
| 36 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; | 42 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| ... | @@ -140,7 +146,6 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; | ... | @@ -140,7 +146,6 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; |
| 140 | // swipe-row-activation), so render further ahead: a fast fling then reaches | 146 | // swipe-row-activation), so render further ahead: a fast fling then reaches |
| 141 | // rows that are already built instead of rows still being rebuilt. | 147 | // rows that are already built instead of rows still being rebuilt. |
| 142 | const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; | 148 | const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; |
| 143 | const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; | ||
| 144 | /** | 149 | /** |
| 145 | * Top spacing between the list and the Android custom header. The Android | 150 | * Top spacing between the list and the Android custom header. The Android |
| 146 | * header is rendered in-flow above this screen and | 151 | * header is rendered in-flow above this screen and |
| ... | @@ -219,17 +224,61 @@ function HomeTopContentSpacer() { | ... | @@ -219,17 +224,61 @@ function HomeTopContentSpacer() { |
| 219 | return <View className="h-4" />; | 224 | return <View className="h-4" />; |
| 220 | } | 225 | } |
| 221 | 226 | ||
| 227 | function HomeScrollView(props: ComponentProps<typeof ScrollView>) { | ||
| 228 | const insets = useSafeAreaInsets(); | ||
| 229 | const primaryColumn = use(NativePrimaryColumnContext); | ||
| 230 | if (Platform.OS !== "ios") return <ScrollView {...props} />; | ||
| 231 | |||
| 232 | // v5 needs the actual content scroll view registered with its screen controller. | ||
| 233 | // The layout observer and header siblings prevent UIKit from finding it implicitly. | ||
| 234 | return ( | ||
| 235 | <ScrollViewMarker | ||
| 236 | style={{ flex: 1 }} | ||
| 237 | scrollEdgeEffects={{ | ||
| 238 | ...nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version), | ||
| 239 | // Retain the existing sidebar fade on iOS 26; iOS 27 uses the native bar material. | ||
| 240 | top: Number.parseInt(String(Platform.Version), 10) >= 27 ? "automatic" : "soft", | ||
| 241 | }} | ||
| 242 | > | ||
| 243 | <ScrollView | ||
| 244 | {...props} | ||
| 245 | // UIKit's scroll-edge material needs the scroll view's own surface color. | ||
| 246 | className={cn(props.className, primaryColumn ? "bg-drawer" : "bg-screen")} | ||
| 247 | // Use the column's measured inset so UIKit recognizes the resting top. | ||
| 248 | // Padding visually clears the bar but leaves the native scroll offset | ||
| 249 | // past its edge, activating scroll-edge protection before any scroll. | ||
| 250 | automaticallyAdjustContentInsets={false} | ||
| 251 | contentInsetAdjustmentBehavior="never" | ||
| 252 | contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }} | ||
| 253 | /> | ||
| 254 | </ScrollViewMarker> | ||
| 255 | ); | ||
| 256 | } | ||
| 257 | |||
| 258 | function renderHomeScrollView(props: ScrollViewProps) { | ||
| 259 | // FlatList clones this element with its cells and ref. Keep the marker inside | ||
| 260 | // the component so those cells remain children of the actual ScrollView. | ||
| 261 | return <HomeScrollView {...props} />; | ||
| 262 | } | ||
| 263 | |||
| 222 | /* ─── Main screen ────────────────────────────────────────────────────── */ | 264 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 223 | 265 | ||
| 224 | export function HomeScreen(props: HomeScreenProps) { | 266 | export function HomeScreen(props: HomeScreenProps) { |
| 267 | const primaryColumn = use(NativePrimaryColumnContext); | ||
| 268 | const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen"; | ||
| 269 | const containerClassName = cn( | ||
| 270 | "flex-1", | ||
| 271 | Platform.OS === "android" ? "bg-header" : contentBackground, | ||
| 272 | ); | ||
| 273 | const columnMetrics = useNativeColumnLayoutMetrics(); | ||
| 274 | const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null; | ||
| 275 | const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined; | ||
| 225 | const queuedThreadKeys = useQueuedThreadKeys(); | 276 | const queuedThreadKeys = useQueuedThreadKeys(); |
| 226 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | 277 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 227 | const insets = useSafeAreaInsets(); | 278 | const insets = useSafeAreaInsets(); |
| 228 | const { fabClearance } = useAndroidControlSizing(); | 279 | const { fabClearance } = useAndroidControlSizing(); |
| 229 | const iosBottomToolbarClearance = | 280 | // UIKit's column safe area already includes its bottom toolbar. |
| 230 | Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED | 281 | const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); |
| 231 | ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT | ||
| 232 | : 0; | ||
| 233 | const searchEnvironmentIds = useMemo( | 282 | const searchEnvironmentIds = useMemo( |
| 234 | () => | 283 | () => |
| 235 | props.selectedEnvironmentId === null | 284 | props.selectedEnvironmentId === null |
| ... | @@ -563,12 +612,13 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -563,12 +612,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 563 | inboxReturnAt: threadListInboxReturns.returnedAt, | 612 | inboxReturnAt: threadListInboxReturns.returnedAt, |
| 564 | snoozedShelfExpanded, | 613 | snoozedShelfExpanded, |
| 565 | settledShelfExpanded, | 614 | settledShelfExpanded, |
| 566 | selectedThreadKey: null, | 615 | selectedThreadKey, |
| 567 | }); | 616 | }); |
| 568 | }, [ | 617 | }, [ |
| 569 | workingShelfEnabled, | 618 | workingShelfEnabled, |
| 570 | workingShelfExpanded, | 619 | workingShelfExpanded, |
| 571 | pendingOrder, | 620 | pendingOrder, |
| 621 | selectedThreadKey, | ||
| 572 | queuedThreadKeys, | 622 | queuedThreadKeys, |
| 573 | nowMinute, | 623 | nowMinute, |
| 574 | snoozeWakeTick, | 624 | snoozeWakeTick, |
| ... | @@ -715,6 +765,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -715,6 +765,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 715 | const thread = item.item.thread; | 765 | const thread = item.item.thread; |
| 716 | return ( | 766 | return ( |
| 717 | <ThreadListV2Row | 767 | <ThreadListV2Row |
| 768 | pane={primaryColumn ? "sidebar" : "screen"} | ||
| 769 | selected={scopedThreadKey(thread.environmentId, thread.id) === selectedThreadKey} | ||
| 770 | fullSwipeWidth={fullSwipeWidth} | ||
| 718 | onNewThreadOnBranch={props.onNewThreadOnBranch} | 771 | onNewThreadOnBranch={props.onNewThreadOnBranch} |
| 719 | thread={thread} | 772 | thread={thread} |
| 720 | variant={item.item.variant} | 773 | variant={item.item.variant} |
| ... | @@ -802,6 +855,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -802,6 +855,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 802 | props.onDeletePendingTask, | 855 | props.onDeletePendingTask, |
| 803 | props.onSelectPendingTask, | 856 | props.onSelectPendingTask, |
| 804 | props.onSelectThread, | 857 | props.onSelectThread, |
| 858 | primaryColumn, | ||
| 859 | selectedThreadKey, | ||
| 860 | fullSwipeWidth, | ||
| 805 | props.onNewThreadOnBranch, | 861 | props.onNewThreadOnBranch, |
| 806 | props.savedConnectionsById, | 862 | props.savedConnectionsById, |
| 807 | resolveProviderInstance, | 863 | resolveProviderInstance, |
| ... | @@ -831,6 +887,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -831,6 +887,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 831 | listEnvironments, | 887 | listEnvironments, |
| 832 | savedConnectionsById: props.savedConnectionsById, | 888 | savedConnectionsById: props.savedConnectionsById, |
| 833 | searchQuery: props.searchQuery, | 889 | searchQuery: props.searchQuery, |
| 890 | selectedThreadKey, | ||
| 834 | threadSearchMatchByKey, | 891 | threadSearchMatchByKey, |
| 835 | // Rows read it for their reorder menu items. | 892 | // Rows read it for their reorder menu items. |
| 836 | workingShelfEnabled, | 893 | workingShelfEnabled, |
| ... | @@ -838,6 +895,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -838,6 +895,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 838 | [ | 895 | [ |
| 839 | projectByKey, | 896 | projectByKey, |
| 840 | props.searchQuery, | 897 | props.searchQuery, |
| 898 | selectedThreadKey, | ||
| 841 | props.savedConnectionsById, | 899 | props.savedConnectionsById, |
| 842 | listEnvironments, | 900 | listEnvironments, |
| 843 | threadSearchMatchByKey, | 901 | threadSearchMatchByKey, |
| ... | @@ -868,14 +926,15 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -868,14 +926,15 @@ export function HomeScreen(props: HomeScreenProps) { |
| 868 | 926 | ||
| 869 | if (!hasAnyThreads) { | 927 | if (!hasAnyThreads) { |
| 870 | return ( | 928 | return ( |
| 871 | <View className="flex-1 bg-screen android:bg-header"> | 929 | <View className={containerClassName}> |
| 872 | <View | 930 | <View |
| 873 | className={cn( | 931 | className={cn( |
| 874 | "flex-1 items-center justify-center bg-screen px-8", | 932 | "flex-1 items-center justify-center px-8", |
| 933 | contentBackground, | ||
| 875 | Platform.OS === "android" && "overflow-hidden rounded-t-[28px]", | 934 | Platform.OS === "android" && "overflow-hidden rounded-t-[28px]", |
| 876 | )} | 935 | )} |
| 877 | style={{ | 936 | style={{ |
| 878 | paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance, | 937 | paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24), |
| 879 | paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, | 938 | paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, |
| 880 | }} | 939 | }} |
| 881 | > | 940 | > |
| ... | @@ -958,12 +1017,12 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -958,12 +1017,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 958 | } | 1017 | } |
| 959 | 1018 | ||
| 960 | return ( | 1019 | return ( |
| 961 | <View className="flex-1 bg-screen android:bg-header"> | 1020 | <View className={containerClassName}> |
| 962 | <View | 1021 | <View |
| 963 | className={ | 1022 | className={ |
| 964 | Platform.OS === "android" | 1023 | Platform.OS === "android" |
| 965 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" | 1024 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" |
| 966 | : "flex-1 bg-screen" | 1025 | : cn("flex-1", contentBackground) |
| 967 | } | 1026 | } |
| 968 | > | 1027 | > |
| 969 | {/* Shared with the iPad sidebar: cells are reused across data | 1028 | {/* Shared with the iPad sidebar: cells are reused across data |
| ... | @@ -976,6 +1035,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -976,6 +1035,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 976 | onTouchStart={(event) => trackListTouches(event, true)} | 1035 | onTouchStart={(event) => trackListTouches(event, true)} |
| 977 | onTouchEnd={(event) => trackListTouches(event, false)} | 1036 | onTouchEnd={(event) => trackListTouches(event, false)} |
| 978 | onTouchCancel={(event) => trackListTouches(event, false)} | 1037 | onTouchCancel={(event) => trackListTouches(event, false)} |
| 1038 | renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} | ||
| 979 | data={threadListV2Items} | 1039 | data={threadListV2Items} |
| 980 | renderItem={renderV2Item} | 1040 | renderItem={renderV2Item} |
| 981 | keyExtractor={v2KeyExtractor} | 1041 | keyExtractor={v2KeyExtractor} |
| ... | @@ -997,16 +1057,19 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -997,16 +1057,19 @@ export function HomeScreen(props: HomeScreenProps) { |
| 997 | ListEmptyComponent={v2ListEmpty} | 1057 | ListEmptyComponent={v2ListEmpty} |
| 998 | style={{ flex: 1 }} | 1058 | style={{ flex: 1 }} |
| 999 | automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} | 1059 | automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} |
| 1000 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | 1060 | contentInsetAdjustmentBehavior="never" |
| 1061 | contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined} | ||
| 1062 | contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} | ||
| 1001 | showsVerticalScrollIndicator={false} | 1063 | showsVerticalScrollIndicator={false} |
| 1002 | keyboardDismissMode="on-drag" | 1064 | keyboardDismissMode="on-drag" |
| 1003 | keyboardShouldPersistTaps="handled" | 1065 | keyboardShouldPersistTaps="handled" |
| 1004 | {...scrollGateHandlers} | 1066 | {...scrollGateHandlers} |
| 1005 | scrollEventThrottle={16} | 1067 | scrollEventThrottle={16} |
| 1006 | contentContainerStyle={{ | 1068 | contentContainerStyle={{ |
| 1069 | paddingHorizontal: primaryColumn ? 8 : 0, | ||
| 1007 | paddingBottom: | 1070 | paddingBottom: |
| 1008 | Platform.OS === "ios" | 1071 | Platform.OS === "ios" |
| 1009 | ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance | 1072 | ? iosBottomClearance |
| 1010 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88), | 1073 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88), |
| 1011 | }} | 1074 | }} |
| 1012 | /> | 1075 | /> |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+20-1| ... | @@ -61,6 +61,10 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE | ... | @@ -61,6 +61,10 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE |
| 61 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; | 61 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 62 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; | 62 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; |
| 63 | import { WorkspaceContentWidthContext } from "./workspace-content-width"; | 63 | import { WorkspaceContentWidthContext } from "./workspace-content-width"; |
| 64 | import { | ||
| 65 | NativeWorkspaceModeContext, | ||
| 66 | NativeWorkspaceInspectorContext, | ||
| 67 | } from "../../native/v5-workspace-context"; | ||
| 64 | 68 | ||
| 65 | interface AdaptiveWorkspaceContextValue { | 69 | interface AdaptiveWorkspaceContextValue { |
| 66 | readonly layout: Layout; | 70 | readonly layout: Layout; |
| ... | @@ -234,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -234,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent( |
| 234 | }, | 238 | }, |
| 235 | ) { | 239 | ) { |
| 236 | const projectGroupingMode = props.projectGroupingMode; | 240 | const projectGroupingMode = props.projectGroupingMode; |
| 241 | const nativeWorkspace = use(NativeWorkspaceModeContext); | ||
| 237 | const { width, height } = useWindowDimensions(); | 242 | const { width, height } = useWindowDimensions(); |
| 238 | const pathname = props.pathname; | 243 | const pathname = props.pathname; |
| 239 | const navigation = useNavigation(); | 244 | const navigation = useNavigation(); |
| ... | @@ -468,9 +473,10 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -468,9 +473,10 @@ function AdaptiveWorkspaceLayoutContent( |
| 468 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, | 473 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, |
| 469 | ); | 474 | ); |
| 470 | useEffect(() => { | 475 | useEffect(() => { |
| 476 | if (nativeWorkspace) return; | ||
| 471 | const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; | 477 | const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; |
| 472 | renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); | 478 | renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); |
| 473 | }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); | 479 | }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); |
| 474 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ | 480 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ |
| 475 | opacity: Math.min(1, renderedSidebarWidth.value / 80), | 481 | opacity: Math.min(1, renderedSidebarWidth.value / 80), |
| 476 | width: renderedSidebarWidth.value, | 482 | width: renderedSidebarWidth.value, |
| ... | @@ -565,6 +571,19 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -565,6 +571,19 @@ function AdaptiveWorkspaceLayoutContent( |
| 565 | ], | 571 | ], |
| 566 | ); | 572 | ); |
| 567 | 573 | ||
| 574 | if (nativeWorkspace) { | ||
| 575 | return ( | ||
| 576 | <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}> | ||
| 577 | <AdaptiveWorkspaceContext value={contextValue}> | ||
| 578 | <NativeWorkspaceInspectorContext | ||
| 579 | value={{ render: workspaceInspector?.render, visible: inspectorColumnTargetWidth > 0 }} | ||
| 580 | > | ||
| 581 | {props.children} | ||
| 582 | </NativeWorkspaceInspectorContext> | ||
| 583 | </AdaptiveWorkspaceContext> | ||
| 584 | </HomeListOptionsProvider> | ||
| 585 | ); | ||
| 586 | } | ||
| 568 | return ( | 587 | return ( |
| 569 | <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}> | 588 | <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}> |
| 570 | <AdaptiveWorkspaceContext.Provider value={contextValue}> | 589 | <AdaptiveWorkspaceContext.Provider value={contextValue}> |
apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx created+342| ... | @@ -0,0 +1,342 @@ | ||
| 1 | import { | ||
| 2 | createNavigatorFactory, | ||
| 3 | NavigationContext, | ||
| 4 | NavigationRouteContext, | ||
| 5 | StackRouter, | ||
| 6 | useNavigationBuilder, | ||
| 7 | type NavigatorTypeBagBase, | ||
| 8 | type ParamListBase, | ||
| 9 | type StackActionHelpers, | ||
| 10 | type StackNavigationState, | ||
| 11 | type StackRouterOptions, | ||
| 12 | type StaticConfig, | ||
| 13 | type TypedNavigator, | ||
| 14 | } from "@react-navigation/native"; | ||
| 15 | import { | ||
| 16 | NativeStackView, | ||
| 17 | type NativeStackNavigationEventMap, | ||
| 18 | type NativeStackNavigationOptions, | ||
| 19 | type NativeStackNavigatorProps, | ||
| 20 | type NativeStackTypeBag, | ||
| 21 | } from "@react-navigation/native-stack"; | ||
| 22 | import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; | ||
| 23 | import { View } from "react-native"; | ||
| 24 | import { Split, type SplitHostCommands } from "react-native-screens"; | ||
| 25 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 26 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 27 | |||
| 28 | import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; | ||
| 29 | import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; | ||
| 30 | import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios"; | ||
| 31 | import { | ||
| 32 | modalEnvelopeOptions, | ||
| 33 | V5CardStackView, | ||
| 34 | V5StackView, | ||
| 35 | } from "../../native/createV5StackNavigator.ios"; | ||
| 36 | import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns"; | ||
| 37 | import { V5StackHeader } from "../../native/V5StackHeader.ios"; | ||
| 38 | import { | ||
| 39 | nativeWorkspacePopAction, | ||
| 40 | projectWorkspaceStack, | ||
| 41 | partitionStackPresentations, | ||
| 42 | } from "../../native/workspace-stack-projection"; | ||
| 43 | import { | ||
| 44 | NativePrimaryColumnContext, | ||
| 45 | NativeWorkspaceInspectorContext, | ||
| 46 | NativeWorkspaceModeContext, | ||
| 47 | } from "../../native/v5-workspace-context"; | ||
| 48 | |||
| 49 | type ViewProps = ComponentProps<typeof NativeStackView>; | ||
| 50 | type Descriptor = ViewProps["descriptors"][string]; | ||
| 51 | type Route = ViewProps["state"]["routes"][number]; | ||
| 52 | |||
| 53 | function ColumnScreen(props: { | ||
| 54 | readonly descriptor: Descriptor; | ||
| 55 | readonly primary?: boolean; | ||
| 56 | readonly selectedThreadKey?: string | null; | ||
| 57 | readonly onDismiss: (routeKey: string) => void; | ||
| 58 | readonly navigation: ViewProps["navigation"]; | ||
| 59 | }) { | ||
| 60 | const { descriptor } = props; | ||
| 61 | const primaryColumn = useMemo( | ||
| 62 | () => (props.primary ? { selectedThreadKey: props.selectedThreadKey ?? null } : null), | ||
| 63 | [props.primary, props.selectedThreadKey], | ||
| 64 | ); | ||
| 65 | const key = descriptor.route.key; | ||
| 66 | return ( | ||
| 67 | <Split.Screen | ||
| 68 | screenKey={key} | ||
| 69 | onDismiss={(event) => { | ||
| 70 | if (event.nativeEvent.isNativeDismiss) props.onDismiss(key); | ||
| 71 | }} | ||
| 72 | onWillAppear={() => | ||
| 73 | props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }) | ||
| 74 | } | ||
| 75 | onDidAppear={() => | ||
| 76 | props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }) | ||
| 77 | } | ||
| 78 | onWillDisappear={() => | ||
| 79 | props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }) | ||
| 80 | } | ||
| 81 | onDidDisappear={() => | ||
| 82 | props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } }) | ||
| 83 | } | ||
| 84 | > | ||
| 85 | <NavigationContext value={descriptor.navigation}> | ||
| 86 | <NavigationRouteContext value={descriptor.route}> | ||
| 87 | <V5StackHeader | ||
| 88 | options={descriptor.options} | ||
| 89 | canGoBack={!props.primary} | ||
| 90 | primary={props.primary} | ||
| 91 | /> | ||
| 92 | <ColumnContent primary={props.primary}> | ||
| 93 | <NativePrimaryColumnContext value={primaryColumn}> | ||
| 94 | {descriptor.render()} | ||
| 95 | </NativePrimaryColumnContext> | ||
| 96 | </ColumnContent> | ||
| 97 | </NavigationRouteContext> | ||
| 98 | </NavigationContext> | ||
| 99 | </Split.Screen> | ||
| 100 | ); | ||
| 101 | } | ||
| 102 | |||
| 103 | function WorkspaceColumns( | ||
| 104 | props: ViewProps & { readonly primary: Route; readonly detail: Route[] }, | ||
| 105 | ) { | ||
| 106 | const { layout, panes, togglePrimarySidebar, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); | ||
| 107 | const inspector = use(NativeWorkspaceInspectorContext); | ||
| 108 | const hostRef = useRef<SplitHostCommands>(null); | ||
| 109 | const activeDetailKey = props.detail.at(-1)?.key; | ||
| 110 | const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; | ||
| 111 | const selectedThreadKey = | ||
| 112 | threadParams && | ||
| 113 | "environmentId" in threadParams && | ||
| 114 | "threadId" in threadParams && | ||
| 115 | typeof threadParams.environmentId === "string" && | ||
| 116 | typeof threadParams.threadId === "string" | ||
| 117 | ? scopedThreadKey( | ||
| 118 | EnvironmentId.make(threadParams.environmentId), | ||
| 119 | ThreadId.make(threadParams.threadId), | ||
| 120 | ) | ||
| 121 | : null; | ||
| 122 | const handleNativeDismiss = useCallback( | ||
| 123 | (key: string) => { | ||
| 124 | const state = props.navigation.getState(); | ||
| 125 | const action = nativeWorkspacePopAction(state, key); | ||
| 126 | if (action) props.navigation.dispatch(action); | ||
| 127 | }, | ||
| 128 | [props.navigation], | ||
| 129 | ); | ||
| 130 | |||
| 131 | useEffect(() => { | ||
| 132 | // In compact size classes UIKit exposes one column. Selecting a thread | ||
| 133 | // changes the visible column without rebuilding either navigation stack. | ||
| 134 | if (layout.usesSplitView) return; | ||
| 135 | hostRef.current?.show(activeDetailKey ? "secondary" : "primary"); | ||
| 136 | }, [activeDetailKey, layout.usesSplitView]); | ||
| 137 | |||
| 138 | const primary = props.descriptors[props.primary.key]; | ||
| 139 | if (!primary) return null; | ||
| 140 | return ( | ||
| 141 | <Split.Host | ||
| 142 | ref={hostRef} | ||
| 143 | testID="adaptive-workspace-layout" | ||
| 144 | preferredSplitBehavior="tile" | ||
| 145 | preferredDisplayMode={ | ||
| 146 | panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly" | ||
| 147 | } | ||
| 148 | displayModeButtonVisibility="never" | ||
| 149 | presentsWithGesture | ||
| 150 | showInspector={inspector.visible} | ||
| 151 | onInspectorHide={() => { | ||
| 152 | if (inspector.visible) toggleAuxiliaryPane(); | ||
| 153 | }} | ||
| 154 | columnMetrics={{ | ||
| 155 | minimumPrimaryColumnWidth: 280, | ||
| 156 | maximumPrimaryColumnWidth: 380, | ||
| 157 | preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, | ||
| 158 | minimumSecondaryColumnWidth: 320, | ||
| 159 | }} | ||
| 160 | > | ||
| 161 | <Split.Column> | ||
| 162 | <Split.Stack> | ||
| 163 | <ColumnScreen | ||
| 164 | descriptor={primary} | ||
| 165 | primary | ||
| 166 | selectedThreadKey={selectedThreadKey} | ||
| 167 | onDismiss={handleNativeDismiss} | ||
| 168 | navigation={props.navigation} | ||
| 169 | /> | ||
| 170 | </Split.Stack> | ||
| 171 | </Split.Column> | ||
| 172 | <Split.Column> | ||
| 173 | <Split.Stack> | ||
| 174 | <Split.Screen | ||
| 175 | screenKey="workspace-empty" | ||
| 176 | activityMode={layout.usesSplitView && !activeDetailKey ? "attached" : "detached"} | ||
| 177 | > | ||
| 178 | <V5StackHeader | ||
| 179 | options={{ | ||
| 180 | headerShown: true, | ||
| 181 | title: "", | ||
| 182 | unstable_headerLeftItems: () => [ | ||
| 183 | { | ||
| 184 | type: "button", | ||
| 185 | label: "", | ||
| 186 | icon: { type: "sfSymbol", name: "sidebar.left" }, | ||
| 187 | onPress: togglePrimarySidebar, | ||
| 188 | }, | ||
| 189 | ], | ||
| 190 | }} | ||
| 191 | canGoBack={false} | ||
| 192 | /> | ||
| 193 | <ColumnContent> | ||
| 194 | <WorkspaceEmptyDetail | ||
| 195 | onStartNewTask={() => | ||
| 196 | props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }) | ||
| 197 | } | ||
| 198 | /> | ||
| 199 | </ColumnContent> | ||
| 200 | </Split.Screen> | ||
| 201 | {props.detail.map((route) => { | ||
| 202 | const descriptor = props.descriptors[route.key]; | ||
| 203 | return descriptor ? ( | ||
| 204 | <ColumnScreen | ||
| 205 | key={route.key} | ||
| 206 | descriptor={descriptor} | ||
| 207 | onDismiss={handleNativeDismiss} | ||
| 208 | navigation={props.navigation} | ||
| 209 | /> | ||
| 210 | ) : null; | ||
| 211 | })} | ||
| 212 | </Split.Stack> | ||
| 213 | </Split.Column> | ||
| 214 | <Split.Inspector> | ||
| 215 | {/* Inspector content supplies its own navigation header. */} | ||
| 216 | <Split.HeaderConfig hidden /> | ||
| 217 | <ColumnContent> | ||
| 218 | <View className="flex-1 bg-screen">{inspector.render?.()}</View> | ||
| 219 | </ColumnContent> | ||
| 220 | </Split.Inspector> | ||
| 221 | </Split.Host> | ||
| 222 | ); | ||
| 223 | } | ||
| 224 | |||
| 225 | function WorkspaceStackView(props: ViewProps) { | ||
| 226 | const projection = projectWorkspaceStack(props.state, (route) => { | ||
| 227 | const presentation = props.descriptors[route.key]?.options.presentation; | ||
| 228 | return presentation !== undefined && presentation !== "card"; | ||
| 229 | }); | ||
| 230 | // Linking normally restores Home via initialRouteName. A detail-only history | ||
| 231 | // must stay a single stack: placeholder descriptors cannot own an interactive | ||
| 232 | // sidebar because React Navigation rejects their actions and setOptions. | ||
| 233 | const primaryRoute = projection.primary; | ||
| 234 | if (!primaryRoute) return <V5StackView {...props} />; | ||
| 235 | const primaryDescriptor = props.descriptors[primaryRoute.key]!; | ||
| 236 | const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; | ||
| 237 | const baseDescriptor: Descriptor = { | ||
| 238 | ...primaryDescriptor, | ||
| 239 | route: baseRoute, | ||
| 240 | options: { headerShown: false }, | ||
| 241 | render: () => <WorkspaceColumns {...props} primary={primaryRoute} detail={projection.detail} />, | ||
| 242 | }; | ||
| 243 | const overlays = partitionStackPresentations(projection.overlays, (route) => { | ||
| 244 | const presentation = props.descriptors[route.key]?.options.presentation; | ||
| 245 | return presentation !== undefined && presentation !== "card"; | ||
| 246 | }); | ||
| 247 | const routes = [baseRoute, ...overlays.map((group) => group[0]!)]; | ||
| 248 | return ( | ||
| 249 | <NativeStackView | ||
| 250 | {...props} | ||
| 251 | state={{ ...props.state, routes, index: routes.length - 1, preloadedRoutes: [] }} | ||
| 252 | descriptors={{ | ||
| 253 | ...Object.fromEntries( | ||
| 254 | overlays.map((group) => { | ||
| 255 | const route = group[0]!; | ||
| 256 | const descriptor = props.descriptors[route.key]!; | ||
| 257 | return [ | ||
| 258 | route.key, | ||
| 259 | { | ||
| 260 | ...descriptor, | ||
| 261 | options: modalEnvelopeOptions(descriptor.options), | ||
| 262 | render: () => ( | ||
| 263 | <V5CardStackView | ||
| 264 | {...props} | ||
| 265 | state={{ | ||
| 266 | ...props.state, | ||
| 267 | routes: group, | ||
| 268 | index: group.length - 1, | ||
| 269 | preloadedRoutes: [], | ||
| 270 | }} | ||
| 271 | /> | ||
| 272 | ), | ||
| 273 | }, | ||
| 274 | ]; | ||
| 275 | }), | ||
| 276 | ), | ||
| 277 | [baseRoute.key]: baseDescriptor, | ||
| 278 | }} | ||
| 279 | /> | ||
| 280 | ); | ||
| 281 | } | ||
| 282 | |||
| 283 | function WorkspaceStackNavigator({ | ||
| 284 | id, | ||
| 285 | initialRouteName, | ||
| 286 | UNSTABLE_routeNamesChangeBehavior, | ||
| 287 | children, | ||
| 288 | layout, | ||
| 289 | screenListeners, | ||
| 290 | screenOptions, | ||
| 291 | screenLayout, | ||
| 292 | UNSTABLE_router, | ||
| 293 | ...rest | ||
| 294 | }: NativeStackNavigatorProps) { | ||
| 295 | const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< | ||
| 296 | StackNavigationState<ParamListBase>, | ||
| 297 | StackRouterOptions, | ||
| 298 | StackActionHelpers<ParamListBase>, | ||
| 299 | NativeStackNavigationOptions, | ||
| 300 | NativeStackNavigationEventMap | ||
| 301 | >(StackRouter, { | ||
| 302 | id, | ||
| 303 | initialRouteName, | ||
| 304 | UNSTABLE_routeNamesChangeBehavior, | ||
| 305 | children, | ||
| 306 | layout, | ||
| 307 | screenListeners, | ||
| 308 | screenOptions, | ||
| 309 | screenLayout, | ||
| 310 | UNSTABLE_router, | ||
| 311 | }); | ||
| 312 | const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView; | ||
| 313 | return ( | ||
| 314 | <NativeWorkspaceModeContext value={NATIVE_WORKSPACE_COLUMNS_SUPPORTED}> | ||
| 315 | <NavigationContent> | ||
| 316 | <WorkspaceView | ||
| 317 | {...rest} | ||
| 318 | state={state} | ||
| 319 | describe={describe} | ||
| 320 | descriptors={descriptors} | ||
| 321 | navigation={navigation} | ||
| 322 | /> | ||
| 323 | </NavigationContent> | ||
| 324 | </NativeWorkspaceModeContext> | ||
| 325 | ); | ||
| 326 | } | ||
| 327 | |||
| 328 | type WorkspaceTypeBag< | ||
| 329 | ParamList extends ParamListBase, | ||
| 330 | NavigatorID extends string | undefined, | ||
| 331 | > = Omit<NativeStackTypeBag<ParamList, NavigatorID>, "Navigator"> & { | ||
| 332 | Navigator: typeof WorkspaceStackNavigator; | ||
| 333 | }; | ||
| 334 | |||
| 335 | export function createWorkspaceStackNavigator< | ||
| 336 | const ParamList extends ParamListBase, | ||
| 337 | const NavigatorID extends string | undefined = string | undefined, | ||
| 338 | const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag<ParamList, NavigatorID>, | ||
| 339 | const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>, | ||
| 340 | >(config?: Config): TypedNavigator<TypeBag, Config> { | ||
| 341 | return createNavigatorFactory(WorkspaceStackNavigator)(config); | ||
| 342 | } | ||
apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | export { createNativeStackNavigator as createWorkspaceStackNavigator } from "@react-navigation/native-stack"; | ||
apps/mobile/src/features/layout/native-mail-search-toolbar.ts+4-11| ... | @@ -3,11 +3,9 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens" | ... | @@ -3,11 +3,9 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens" |
| 3 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 3 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 4 | 4 | ||
| 5 | /** | 5 | /** |
| 6 | * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass | 6 | * Group search, filtering, and composition on platforms with Liquid Glass. |
| 7 | * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side | 7 | * The v5 header adapter turns this intent into UISearchController and native |
| 8 | * silently drops the item and hides the navigation toolbar entirely. Screens | 8 | * toolbar items. Earlier iOS versions use separate search and toolbar options. |
| 9 | * that send it must fall back to standard search/toolbar primitives when this | ||
| 10 | * is false. | ||
| 11 | */ | 9 | */ |
| 12 | export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; | 10 | export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; |
| 13 | 11 | ||
| ... | @@ -20,12 +18,7 @@ type NativeMailSearchToolbarInput = Omit< | ... | @@ -20,12 +18,7 @@ type NativeMailSearchToolbarInput = Omit< |
| 20 | >; | 18 | >; |
| 21 | 19 | ||
| 22 | /** | 20 | /** |
| 23 | * Builds the patched react-native-screens Mail-style bottom search toolbar. | 21 | * Describe the thread-list search and actions for the native header adapter. |
| 24 | * | ||
| 25 | * Keeping this behind an app-level helper makes the iOS-only RNS patch an | ||
| 26 | * explicit layout primitive instead of a per-screen object literal. Android can | ||
| 27 | * keep using platform-specific header/search primitives without depending on | ||
| 28 | * this helper. | ||
| 29 | */ | 22 | */ |
| 30 | export function createNativeMailSearchToolbarItem( | 23 | export function createNativeMailSearchToolbarItem( |
| 31 | input: NativeMailSearchToolbarInput, | 24 | input: NativeMailSearchToolbarInput, |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+13-8| ... | @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 7 | ||
| 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 9 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 9 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 10 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | 10 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 11 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 11 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 12 | import { SettingsRow } from "./components/SettingsRow"; | 12 | import { SettingsRow } from "./components/SettingsRow"; |
| 13 | import { SettingsSection } from "./components/SettingsSection"; | 13 | import { SettingsSection } from "./components/SettingsSection"; |
| ... | @@ -29,14 +29,19 @@ export function SettingsRouteScreen() { | ... | @@ -29,14 +29,19 @@ export function SettingsRouteScreen() { |
| 29 | 29 | ||
| 30 | return ( | 30 | return ( |
| 31 | <> | 31 | <> |
| 32 | {Platform.OS === "ios" ? ( | ||
| 33 | <NativeStackScreenOptions options={{ headerBackVisible: !layout.usesSplitView }} /> | ||
| 34 | ) : null} | ||
| 32 | {Platform.OS === "ios" && layout.usesSplitView ? ( | 35 | {Platform.OS === "ios" && layout.usesSplitView ? ( |
| 33 | <NativeHeaderToolbar placement="left"> | 36 | <> |
| 34 | <NativeHeaderToolbar.Button | 37 | <NativeHeaderToolbar placement="left"> |
| 35 | accessibilityLabel="Go back" | 38 | <NativeHeaderToolbar.Button |
| 36 | icon="chevron.left" | 39 | accessibilityLabel="Go back" |
| 37 | onPress={() => navigation.goBack()} | 40 | icon="chevron.left" |
| 38 | /> | 41 | onPress={() => navigation.goBack()} |
| 39 | </NativeHeaderToolbar> | 42 | /> |
| 43 | </NativeHeaderToolbar> | ||
| 44 | </> | ||
| 40 | ) : null} | 45 | ) : null} |
| 41 | <SettingsEnvironmentFilterHeader closeSettings /> | 46 | <SettingsEnvironmentFilterHeader closeSettings /> |
| 42 | {Platform.OS === "android" ? ( | 47 | {Platform.OS === "android" ? ( |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+6| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; | 9 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 10 | import { SymbolView } from "../../components/AppSymbol"; | 10 | import { SymbolView } from "../../components/AppSymbol"; |
| 11 | import { ScreenHeader } from "../../components/ScreenHeader"; | 11 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 12 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | ||
| 12 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 13 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 13 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 14 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 14 | import { Alert, Platform, Pressable, View } from "react-native"; | 15 | import { Alert, Platform, Pressable, View } from "react-native"; |
| ... | @@ -254,6 +255,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ | ... | @@ -254,6 +255,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ |
| 254 | 255 | ||
| 255 | export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { | 256 | export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { |
| 256 | const insets = useSafeAreaInsets(); | 257 | const insets = useSafeAreaInsets(); |
| 258 | const nativeColumnMetrics = useNativeColumnLayoutMetrics(); | ||
| 257 | const navigation = useNavigation(); | 259 | const navigation = useNavigation(); |
| 258 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); | 260 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); |
| 259 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); | 261 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); |
| ... | @@ -1202,6 +1204,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -1202,6 +1204,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1202 | Platform.OS === "android" | 1204 | Platform.OS === "android" |
| 1203 | ? themeVariables["--color-card-alt"] | 1205 | ? themeVariables["--color-card-alt"] |
| 1204 | : terminalTheme.background, | 1206 | : terminalTheme.background, |
| 1207 | // The v5 column extends behind UIKit chrome. Its measured top | ||
| 1208 | // reservation is zero when UIKit has already inset the body or moved the bar. | ||
| 1209 | paddingTop: | ||
| 1210 | Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0, | ||
| 1205 | paddingBottom: | 1211 | paddingBottom: |
| 1206 | Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0, | 1212 | Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0, |
| 1207 | }} | 1213 | }} |
apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx+1-1| ... | @@ -29,7 +29,7 @@ import { | ... | @@ -29,7 +29,7 @@ import { |
| 29 | } from "@t3tools/client-runtime/state/thread-settled"; | 29 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 30 | import { useState, type ReactNode } from "react"; | 30 | import { useState, type ReactNode } from "react"; |
| 31 | import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; | 31 | import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; |
| 32 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; | 32 | import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; |
| 33 | import { ScrollView, useWindowDimensions, View } from "react-native"; | 33 | import { ScrollView, useWindowDimensions, View } from "react-native"; |
| 34 | import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; | 34 | import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; |
| 35 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | 35 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+3-3| ... | @@ -664,11 +664,11 @@ function ThreadRouteContent( | ... | @@ -664,11 +664,11 @@ function ThreadRouteContent( |
| 664 | () => | 664 | () => |
| 665 | inspectorMode === null ? null : ( | 665 | inspectorMode === null ? null : ( |
| 666 | <ThreadInspectorContentStack | 666 | <ThreadInspectorContentStack |
| 667 | Files={FilesInspector} | 667 | renderFiles={FilesInspector} |
| 668 | Git={GitInspector} | 668 | renderGit={GitInspector} |
| 669 | mode={inspectorMode} | 669 | mode={inspectorMode} |
| 670 | resetKeys={[routeThreadIdentity, selectedThreadCwd]} | 670 | resetKeys={[routeThreadIdentity, selectedThreadCwd]} |
| 671 | Route={props.renderInspector ? RouteInspector : undefined} | 671 | renderRoute={props.renderInspector ? RouteInspector : undefined} |
| 672 | /> | 672 | /> |
| 673 | ), | 673 | ), |
| 674 | [ | 674 | [ |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+2-4| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; | ||
| 1 | import type { | 2 | import type { |
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 3 | ModelSelection, | 4 | ModelSelection, |
| ... | @@ -15,10 +16,7 @@ import { | ... | @@ -15,10 +16,7 @@ import { |
| 15 | getProviderOptionDescriptors, | 16 | getProviderOptionDescriptors, |
| 16 | } from "@t3tools/shared/model"; | 17 | } from "@t3tools/shared/model"; |
| 17 | import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; | 18 | import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; |
| 18 | import { | 19 | import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; |
| 19 | createNativeStackNavigator, | ||
| 20 | type NativeStackNavigationProp, | ||
| 21 | } from "@react-navigation/native-stack"; | ||
| 22 | import * as Haptics from "expo-haptics"; | 20 | import * as Haptics from "expo-haptics"; |
| 23 | import { AsyncResult } from "effect/reactivity"; | 21 | import { AsyncResult } from "effect/reactivity"; |
| 24 | import { | 22 | import { |
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+2-4| ... | @@ -1,8 +1,6 @@ | ... | @@ -1,8 +1,6 @@ |
| 1 | import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; | ||
| 1 | import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; | 2 | import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; |
| 2 | import { | 3 | import { type NativeStackNavigationOptions } from "@react-navigation/native-stack"; |
| 3 | createNativeStackNavigator, | ||
| 4 | type NativeStackNavigationOptions, | ||
| 5 | } from "@react-navigation/native-stack"; | ||
| 6 | import type { ReactNode } from "react"; | 4 | import type { ReactNode } from "react"; |
| 7 | import { Platform } from "react-native"; | 5 | import { Platform } from "react-native"; |
| 8 | 6 |
apps/mobile/src/features/threads/thread-inspector-content-stack.tsx+21-18| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { useEffect, useState, type ComponentType, type ReactNode } from "react"; | 1 | import { useEffect, useState, type ReactNode } from "react"; |
| 2 | import { View } from "react-native"; | 2 | import { View } from "react-native"; |
| 3 | 3 | ||
| 4 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; | 4 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; |
| ... | @@ -43,11 +43,11 @@ function InspectorContentPane(props: { | ... | @@ -43,11 +43,11 @@ function InspectorContentPane(props: { |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | export function ThreadInspectorContentStack(props: { | 45 | export function ThreadInspectorContentStack(props: { |
| 46 | readonly Files: ComponentType; | 46 | readonly renderFiles: () => ReactNode; |
| 47 | readonly Git: ComponentType; | 47 | readonly renderGit?: () => ReactNode; |
| 48 | readonly mode: ThreadInspectorMode; | 48 | readonly mode: ThreadInspectorMode; |
| 49 | readonly resetKeys: readonly [string | null, string | null]; | 49 | readonly resetKeys: readonly [string | null, string | null]; |
| 50 | readonly Route?: ComponentType; | 50 | readonly renderRoute?: () => ReactNode; |
| 51 | }) { | 51 | }) { |
| 52 | const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>( | 52 | const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>( |
| 53 | () => new Set([props.mode]), | 53 | () => new Set([props.mode]), |
| ... | @@ -81,10 +81,6 @@ export function ThreadInspectorContentStack(props: { | ... | @@ -81,10 +81,6 @@ export function ThreadInspectorContentStack(props: { |
| 81 | return () => clearTimeout(timeout); | 81 | return () => clearTimeout(timeout); |
| 82 | }, [props.mode]); | 82 | }, [props.mode]); |
| 83 | 83 | ||
| 84 | const Files = props.Files; | ||
| 85 | const Git = props.Git; | ||
| 86 | const Route = props.Route; | ||
| 87 | |||
| 88 | return ( | 84 | return ( |
| 89 | <View className="flex-1"> | 85 | <View className="flex-1"> |
| 90 | <InspectorContentPane | 86 | <InspectorContentPane |
| ... | @@ -92,24 +88,31 @@ export function ThreadInspectorContentStack(props: { | ... | @@ -92,24 +88,31 @@ export function ThreadInspectorContentStack(props: { |
| 92 | resetKeys={props.resetKeys} | 88 | resetKeys={props.resetKeys} |
| 93 | visible={props.mode === "files"} | 89 | visible={props.mode === "files"} |
| 94 | > | 90 | > |
| 95 | <Files /> | 91 | <InspectorRenderer render={props.renderFiles} /> |
| 96 | </InspectorContentPane> | ||
| 97 | <InspectorContentPane | ||
| 98 | mounted={mountedModes.has("git") || props.mode === "git"} | ||
| 99 | resetKeys={props.resetKeys} | ||
| 100 | visible={props.mode === "git"} | ||
| 101 | > | ||
| 102 | <Git /> | ||
| 103 | </InspectorContentPane> | 92 | </InspectorContentPane> |
| 104 | {Route ? ( | 93 | {props.renderGit ? ( |
| 94 | <InspectorContentPane | ||
| 95 | mounted={mountedModes.has("git") || props.mode === "git"} | ||
| 96 | resetKeys={props.resetKeys} | ||
| 97 | visible={props.mode === "git"} | ||
| 98 | > | ||
| 99 | <InspectorRenderer render={props.renderGit} /> | ||
| 100 | </InspectorContentPane> | ||
| 101 | ) : null} | ||
| 102 | {props.renderRoute ? ( | ||
| 105 | <InspectorContentPane | 103 | <InspectorContentPane |
| 106 | mounted={mountedModes.has("route") || props.mode === "route"} | 104 | mounted={mountedModes.has("route") || props.mode === "route"} |
| 107 | resetKeys={props.resetKeys} | 105 | resetKeys={props.resetKeys} |
| 108 | visible={props.mode === "route"} | 106 | visible={props.mode === "route"} |
| 109 | > | 107 | > |
| 110 | <Route /> | 108 | <InspectorRenderer render={props.renderRoute} /> |
| 111 | </InspectorContentPane> | 109 | </InspectorContentPane> |
| 112 | ) : null} | 110 | ) : null} |
| 113 | </View> | 111 | </View> |
| 114 | ); | 112 | ); |
| 115 | } | 113 | } |
| 114 | |||
| 115 | // Render callbacks carry changing route data; they are not component types. | ||
| 116 | function InspectorRenderer(props: { readonly render: () => ReactNode }) { | ||
| 117 | return <>{props.render()}</>; | ||
| 118 | } |
apps/mobile/src/lib/reserved-regions.ts created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | /** Geometry of the body inside a native navigation column. */ | ||
| 2 | export interface NativeLayoutMetrics { | ||
| 3 | readonly width: number; | ||
| 4 | readonly height: number; | ||
| 5 | readonly horizontalSizeClass: "compact" | "regular"; | ||
| 6 | readonly safeArea: { | ||
| 7 | readonly top: number; | ||
| 8 | readonly bottom: number; | ||
| 9 | readonly left: number; | ||
| 10 | readonly right: number; | ||
| 11 | }; | ||
| 12 | } | ||
apps/mobile/src/native/NativeColumnContent.ios.tsx created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { HeaderHeightContext } from "@react-navigation/elements"; | ||
| 2 | import { useState, type ReactNode } from "react"; | ||
| 3 | import { View } from "react-native"; | ||
| 4 | import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; | ||
| 5 | import { NativeColumnLayoutMetricsContext } from "./native-layout-metrics"; | ||
| 6 | import type { NativeLayoutMetrics } from "../lib/reserved-regions"; | ||
| 7 | import { NativeLayoutObserver } from "./NativeLayoutObserver"; | ||
| 8 | |||
| 9 | export function NativeColumnContent(props: { | ||
| 10 | readonly children: ReactNode; | ||
| 11 | readonly primary?: boolean; | ||
| 12 | }) { | ||
| 13 | const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null); | ||
| 14 | return ( | ||
| 15 | <View className={props.primary ? "flex-1 bg-drawer" : "flex-1 bg-screen"}> | ||
| 16 | <SafeAreaProvider style={{ flex: 1 }}> | ||
| 17 | <SafeAreaView edges={["left", "right"]} style={{ flex: 1 }}> | ||
| 18 | {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} | ||
| 19 | <View style={{ flex: 1 }}> | ||
| 20 | <NativeLayoutObserver onChange={setMetrics} /> | ||
| 21 | <NativeColumnLayoutMetricsContext value={metrics}> | ||
| 22 | <HeaderHeightContext value={metrics?.safeArea.top ?? undefined}> | ||
| 23 | {props.children} | ||
| 24 | </HeaderHeightContext> | ||
| 25 | </NativeColumnLayoutMetricsContext> | ||
| 26 | </View> | ||
| 27 | </SafeAreaView> | ||
| 28 | </SafeAreaProvider> | ||
| 29 | </View> | ||
| 30 | ); | ||
| 31 | } | ||
apps/mobile/src/native/NativeLayoutObserver.ios.tsx created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { requireNativeView, requireOptionalNativeModule } from "expo"; | ||
| 2 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; | ||
| 3 | import type { NativeLayoutMetrics } from "../lib/reserved-regions"; | ||
| 4 | |||
| 5 | interface ObserverProps extends ViewProps { | ||
| 6 | readonly onMetricsChange?: (event: NativeSyntheticEvent<NativeLayoutMetrics>) => void; | ||
| 7 | } | ||
| 8 | |||
| 9 | // An older development client can still run the compact app while it rebuilds. | ||
| 10 | function resolveObserver() { | ||
| 11 | try { | ||
| 12 | const module = requireOptionalNativeModule<{ | ||
| 13 | readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown }; | ||
| 14 | }>("T3NativeControls"); | ||
| 15 | if (!module?.ViewPrototypes?.T3NativeControls_LayoutMetrics) return null; | ||
| 16 | return requireNativeView<ObserverProps>("T3NativeControls", "LayoutMetrics"); | ||
| 17 | } catch { | ||
| 18 | return null; | ||
| 19 | } | ||
| 20 | } | ||
| 21 | const Observer = resolveObserver(); | ||
| 22 | |||
| 23 | export function NativeLayoutObserver(props: { | ||
| 24 | readonly onChange?: (metrics: NativeLayoutMetrics) => void; | ||
| 25 | }) { | ||
| 26 | return Observer ? ( | ||
| 27 | <Observer | ||
| 28 | pointerEvents="none" | ||
| 29 | onMetricsChange={props.onChange ? (event) => props.onChange?.(event.nativeEvent) : undefined} | ||
| 30 | style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }} | ||
| 31 | /> | ||
| 32 | ) : null; | ||
| 33 | } | ||
apps/mobile/src/native/NativeLayoutObserver.tsx created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import type { NativeLayoutMetrics } from "../lib/reserved-regions"; | ||
| 2 | |||
| 3 | export function NativeLayoutObserver(_props: { | ||
| 4 | readonly onChange?: (metrics: NativeLayoutMetrics) => void; | ||
| 5 | }) { | ||
| 6 | return null; | ||
| 7 | } | ||
apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import { requireOptionalNativeModule } from "expo"; | ||
| 2 | import { Platform } from "react-native"; | ||
| 3 | const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>( | ||
| 4 | "T3NativeControls", | ||
| 5 | ); | ||
| 6 | export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = | ||
| 7 | Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true; | ||
apps/mobile/src/native/NativeWorkspaceColumns.tsx created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | import { Platform } from "react-native"; | ||
| 2 | // Android retains its existing navigator. The iOS file checks native availability. | ||
| 3 | export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad; | ||
apps/mobile/src/native/V5StackHeader.ios.tsx created+253| ... | @@ -0,0 +1,253 @@ | ||
| 1 | import type { | ||
| 2 | NativeStackHeaderItem, | ||
| 3 | NativeStackHeaderItemMenu, | ||
| 4 | NativeStackNavigationOptions, | ||
| 5 | } from "@react-navigation/native-stack"; | ||
| 6 | import { isValidElement, useRef } from "react"; | ||
| 7 | import { Platform, StyleSheet } from "react-native"; | ||
| 8 | import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; | ||
| 9 | import { | ||
| 10 | SearchBar, | ||
| 11 | Stack, | ||
| 12 | type HeaderBarButtonMailSearchToolbarItem, | ||
| 13 | type SearchBarCommands, | ||
| 14 | type StackHeaderConfigProps, | ||
| 15 | type StackHeaderMenuElementIOS, | ||
| 16 | type StackHeaderMenuIOS, | ||
| 17 | type HeaderBarButtonItemWithMenu, | ||
| 18 | type HeaderBarButtonSearchBarPlacementItem, | ||
| 19 | } from "react-native-screens"; | ||
| 20 | |||
| 21 | type HeaderItem = NonNullable<NonNullable<StackHeaderConfigProps["ios"]>["leadingItems"]>[number]; | ||
| 22 | type ToolbarItem = | ||
| 23 | | NativeStackHeaderItem | ||
| 24 | | HeaderBarButtonMailSearchToolbarItem | ||
| 25 | | HeaderBarButtonSearchBarPlacementItem; | ||
| 26 | type Options = NativeStackNavigationOptions & { | ||
| 27 | readonly unstable_navigationItemStyle?: "editor" | "navigator"; | ||
| 28 | readonly headerSubtitle?: string; | ||
| 29 | readonly unstable_headerSubtitle?: string; | ||
| 30 | readonly headerSubtitleStyle?: { color?: string; fontSize?: number; fontFamily?: string }; | ||
| 31 | readonly unstable_headerCenterItems?: () => NativeStackHeaderItem[]; | ||
| 32 | readonly unstable_headerToolbarItems?: () => ToolbarItem[]; | ||
| 33 | }; | ||
| 34 | |||
| 35 | type MenuElementInput = | ||
| 36 | | NativeStackHeaderItemMenu["menu"]["items"][number] | ||
| 37 | | HeaderBarButtonItemWithMenu["menu"]["items"][number]; | ||
| 38 | type MenuInput = { readonly title?: string; readonly items: readonly MenuElementInput[] }; | ||
| 39 | |||
| 40 | function convertIcon( | ||
| 41 | icon: | ||
| 42 | | Extract<NativeStackHeaderItem, { type: "button" }>["icon"] | ||
| 43 | | HeaderBarButtonItemWithMenu["icon"], | ||
| 44 | ) { | ||
| 45 | if (!icon) return undefined; | ||
| 46 | if (icon.type === "image") | ||
| 47 | return icon.tinted === false | ||
| 48 | ? { type: "imageSource" as const, imageSource: icon.source } | ||
| 49 | : { type: "templateSource" as const, templateSource: icon.source }; | ||
| 50 | return icon; | ||
| 51 | } | ||
| 52 | |||
| 53 | function convertMenu(menu: MenuInput, id: string): StackHeaderMenuIOS { | ||
| 54 | const children = menu.items.flatMap<StackHeaderMenuElementIOS>((item, index) => { | ||
| 55 | if ("hidden" in item && item.hidden) return []; | ||
| 56 | const childId = `${id}:${index}`; | ||
| 57 | if (item.type === "submenu") { | ||
| 58 | return [ | ||
| 59 | { | ||
| 60 | ...convertMenu({ items: item.items }, childId), | ||
| 61 | title: "label" in item ? item.label : item.title, | ||
| 62 | displayInline: | ||
| 63 | "inline" in item | ||
| 64 | ? item.inline | ||
| 65 | : "displayInline" in item | ||
| 66 | ? item.displayInline | ||
| 67 | : undefined, | ||
| 68 | }, | ||
| 69 | ]; | ||
| 70 | } | ||
| 71 | // Use actions even for checked entries: the app owns their durable state. | ||
| 72 | return [ | ||
| 73 | { | ||
| 74 | id: childId, | ||
| 75 | type: "menuItem" as const, | ||
| 76 | itemType: "action" as const, | ||
| 77 | title: "label" in item ? item.label : item.title, | ||
| 78 | icon: convertIcon(item.icon), | ||
| 79 | onPress: item.disabled ? undefined : item.onPress, | ||
| 80 | keepsMenuPresented: item.keepsMenuPresented, | ||
| 81 | disabled: item.disabled, | ||
| 82 | destructive: item.destructive, | ||
| 83 | subtitle: | ||
| 84 | ("subtitle" in item ? item.subtitle : undefined) ?? | ||
| 85 | ("description" in item ? item.description : undefined), | ||
| 86 | state: item.state, | ||
| 87 | }, | ||
| 88 | ]; | ||
| 89 | }); | ||
| 90 | return { id, type: "menu", title: menu.title, children }; | ||
| 91 | } | ||
| 92 | |||
| 93 | function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderItem[] { | ||
| 94 | return items.map((item, index): HeaderItem => { | ||
| 95 | const id = | ||
| 96 | item.type === "spacing" ? `${prefix}:${index}` : (item.identifier ?? `${prefix}:${index}`); | ||
| 97 | switch (item.type) { | ||
| 98 | case "spacing": | ||
| 99 | return { type: "spacer", id, sizing: "fixed", width: item.spacing }; | ||
| 100 | case "custom": | ||
| 101 | return { | ||
| 102 | type: "item", | ||
| 103 | id, | ||
| 104 | render: () => item.element, | ||
| 105 | hidesSharedBackground: item.hidesSharedBackground, | ||
| 106 | }; | ||
| 107 | case "menu": | ||
| 108 | return { | ||
| 109 | type: "item", | ||
| 110 | id, | ||
| 111 | identifier: item.identifier, | ||
| 112 | title: item.label || item.accessibilityLabel || undefined, | ||
| 113 | icon: convertIcon(item.icon), | ||
| 114 | menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), | ||
| 115 | hidesSharedBackground: item.hidesSharedBackground, | ||
| 116 | }; | ||
| 117 | case "button": | ||
| 118 | return { | ||
| 119 | type: "item", | ||
| 120 | id, | ||
| 121 | identifier: item.identifier, | ||
| 122 | title: item.label || item.accessibilityLabel || undefined, | ||
| 123 | icon: convertIcon(item.icon), | ||
| 124 | onPress: item.disabled ? undefined : item.onPress, | ||
| 125 | hidesSharedBackground: item.hidesSharedBackground, | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | }); | ||
| 129 | } | ||
| 130 | |||
| 131 | /** Translate the existing screen options to v5's native header API. */ | ||
| 132 | export function V5StackHeader(props: { | ||
| 133 | readonly options: Options; | ||
| 134 | readonly canGoBack: boolean; | ||
| 135 | readonly primary?: boolean; | ||
| 136 | }) { | ||
| 137 | const { options } = props; | ||
| 138 | const theme = useMobileNavigationTheme(); | ||
| 139 | const searchRef = useRef<SearchBarCommands>(null); | ||
| 140 | const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; | ||
| 141 | const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); | ||
| 142 | const trailing = convertItems( | ||
| 143 | [ | ||
| 144 | ...(options.unstable_headerCenterItems?.() ?? []), | ||
| 145 | ...(options.unstable_headerRightItems?.(itemProps) ?? []), | ||
| 146 | ], | ||
| 147 | "trailing", | ||
| 148 | ); | ||
| 149 | const toolbar = options.unstable_headerToolbarItems?.() ?? []; | ||
| 150 | const mailSearch = toolbar.find( | ||
| 151 | (item): item is HeaderBarButtonMailSearchToolbarItem => item.type === "mailSearchToolbar", | ||
| 152 | ); | ||
| 153 | const bottomSearch = Boolean(mailSearch) && Platform.OS === "ios" && !Platform.isPad; | ||
| 154 | const bottom = toolbar.flatMap<HeaderItem>((item, index) => { | ||
| 155 | if (item.type === "mailSearchToolbar") return []; | ||
| 156 | if (item.type === "searchBarPlacement") | ||
| 157 | return [{ type: "item", id: `toolbar-search:${index}`, searchBarPlacement: true }]; | ||
| 158 | return convertItems([item], `toolbar:${index}`); | ||
| 159 | }); | ||
| 160 | if (mailSearch) { | ||
| 161 | if (mailSearch.filterMenu) | ||
| 162 | bottom.push({ | ||
| 163 | type: "item", | ||
| 164 | id: mailSearch.filterButtonId ?? "filter", | ||
| 165 | icon: { | ||
| 166 | type: "sfSymbol", | ||
| 167 | name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", | ||
| 168 | }, | ||
| 169 | menu: convertMenu(mailSearch.filterMenu, "filter-menu"), | ||
| 170 | }); | ||
| 171 | if (bottomSearch) { | ||
| 172 | if (bottom.length) | ||
| 173 | bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); | ||
| 174 | bottom.push({ type: "item", id: "home-search", searchBarPlacement: true }); | ||
| 175 | if (mailSearch.onComposePress) | ||
| 176 | bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); | ||
| 177 | } | ||
| 178 | if (mailSearch.onComposePress) | ||
| 179 | bottom.push({ | ||
| 180 | type: "item", | ||
| 181 | id: mailSearch.composeButtonId ?? "compose", | ||
| 182 | icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, | ||
| 183 | onPress: mailSearch.onComposePress, | ||
| 184 | }); | ||
| 185 | } | ||
| 186 | const left = options.headerLeft?.(itemProps); | ||
| 187 | const right = options.headerRight?.(itemProps); | ||
| 188 | if (!options.unstable_headerLeftItems && isValidElement(left)) | ||
| 189 | leading.push({ type: "item", id: "custom-left", render: () => left }); | ||
| 190 | if (!options.unstable_headerRightItems && isValidElement(right)) | ||
| 191 | trailing.push({ type: "item", id: "custom-right", render: () => right }); | ||
| 192 | const title = typeof options.headerTitle === "string" ? options.headerTitle : options.title; | ||
| 193 | const titleElement = | ||
| 194 | typeof options.headerTitle === "function" | ||
| 195 | ? options.headerTitle({ children: title ?? "", tintColor: options.headerTintColor }) | ||
| 196 | : undefined; | ||
| 197 | const searchOptions = options.headerSearchBarOptions; | ||
| 198 | const titleStyle = StyleSheet.flatten(options.headerTitleStyle); | ||
| 199 | const headerStyle = StyleSheet.flatten(options.headerStyle); | ||
| 200 | const appearance = { | ||
| 201 | backgroundColor: | ||
| 202 | headerStyle?.backgroundColor ?? | ||
| 203 | (options.headerTransparent ? "transparent" : theme.colors.card), | ||
| 204 | shadowColor: options.headerShadowVisible === false ? "transparent" : undefined, | ||
| 205 | titleFontSize: titleStyle?.fontSize, | ||
| 206 | titleFontFamily: titleStyle?.fontFamily, | ||
| 207 | titleFontWeight: titleStyle?.fontWeight, | ||
| 208 | titleFontColor: titleStyle?.color, | ||
| 209 | subtitleFontSize: options.headerSubtitleStyle?.fontSize, | ||
| 210 | subtitleFontColor: options.headerSubtitleStyle?.color, | ||
| 211 | }; | ||
| 212 | return ( | ||
| 213 | <Stack.HeaderConfig | ||
| 214 | hidden={options.headerShown === false} | ||
| 215 | transparent={options.headerTransparent} | ||
| 216 | title={title} | ||
| 217 | subtitle={options.unstable_headerSubtitle ?? options.headerSubtitle} | ||
| 218 | backButtonHidden={options.headerBackVisible === false} | ||
| 219 | ios={{ | ||
| 220 | navigationItemStyle: options.unstable_navigationItemStyle, | ||
| 221 | tintColor: options.headerTintColor, | ||
| 222 | searchBar: | ||
| 223 | mailSearch || searchOptions ? ( | ||
| 224 | <SearchBar | ||
| 225 | {...searchOptions} | ||
| 226 | ref={searchOptions?.ref ?? searchRef} | ||
| 227 | hideNavigationBar={false} | ||
| 228 | hideWhenScrolling={false} | ||
| 229 | allowToolbarIntegration={searchOptions?.allowToolbarIntegration ?? true} | ||
| 230 | placement={bottomSearch ? "integrated" : (searchOptions?.placement ?? "automatic")} | ||
| 231 | placeholder={mailSearch?.placeholder ?? searchOptions?.placeholder ?? "Search"} | ||
| 232 | onChangeText={(event) => { | ||
| 233 | mailSearch?.onSearchTextChange?.(event.nativeEvent.text); | ||
| 234 | searchOptions?.onChangeText?.(event); | ||
| 235 | }} | ||
| 236 | /> | ||
| 237 | ) : undefined, | ||
| 238 | toolbarItems: bottom, | ||
| 239 | leadingItems: leading, | ||
| 240 | trailingItems: trailing, | ||
| 241 | titleItem: isValidElement(titleElement) | ||
| 242 | ? { id: "title", render: () => titleElement } | ||
| 243 | : undefined, | ||
| 244 | largeTitleEnabled: options.headerLargeTitle, | ||
| 245 | backButtonDisplayMode: options.headerBackButtonDisplayMode ?? "minimal", | ||
| 246 | backButtonMenuEnabled: options.headerBackButtonMenuEnabled ?? true, | ||
| 247 | backButtonTitle: options.headerBackTitle, | ||
| 248 | standardAppearance: appearance, | ||
| 249 | scrollEdgeAppearance: appearance, | ||
| 250 | }} | ||
| 251 | /> | ||
| 252 | ); | ||
| 253 | } | ||
apps/mobile/src/native/createV5StackNavigator.ios.tsx created+422| ... | @@ -0,0 +1,422 @@ | ||
| 1 | import { | ||
| 2 | createNavigatorFactory, | ||
| 3 | NavigationContext, | ||
| 4 | NavigationRouteContext, | ||
| 5 | StackRouter, | ||
| 6 | useNavigationBuilder, | ||
| 7 | usePreventRemoveContext, | ||
| 8 | type NavigatorTypeBagBase, | ||
| 9 | type ParamListBase, | ||
| 10 | type StackActionHelpers, | ||
| 11 | type StackNavigationState, | ||
| 12 | type StackRouterOptions, | ||
| 13 | type StaticConfig, | ||
| 14 | type TypedNavigator, | ||
| 15 | } from "@react-navigation/native"; | ||
| 16 | import { | ||
| 17 | NativeStackView, | ||
| 18 | type NativeStackNavigationEventMap, | ||
| 19 | type NativeStackNavigationOptions, | ||
| 20 | type NativeStackNavigatorProps, | ||
| 21 | type NativeStackTypeBag, | ||
| 22 | } from "@react-navigation/native-stack"; | ||
| 23 | import { useCallback, useState, type ComponentProps } from "react"; | ||
| 24 | import { StyleSheet, View } from "react-native"; | ||
| 25 | import { FormSheet, Stack } from "react-native-screens"; | ||
| 26 | import { V5StackHeader } from "./V5StackHeader.ios"; | ||
| 27 | import { NativeColumnContent } from "./NativeColumnContent.ios"; | ||
| 28 | import { | ||
| 29 | nativeWorkspacePopAction, | ||
| 30 | nativeWorkspacePopCount, | ||
| 31 | partitionStackPresentations, | ||
| 32 | reconcileStackScreens, | ||
| 33 | } from "./workspace-stack-projection"; | ||
| 34 | |||
| 35 | export type V5StackViewProps = ComponentProps<typeof NativeStackView>; | ||
| 36 | |||
| 37 | /** The presentation envelope must not mount a second search bar or header. */ | ||
| 38 | export function modalEnvelopeOptions(options: NativeStackNavigationOptions) { | ||
| 39 | return { | ||
| 40 | ...Object.fromEntries( | ||
| 41 | Object.entries(options).filter( | ||
| 42 | ([key]) => | ||
| 43 | !key.startsWith("header") && | ||
| 44 | !key.startsWith("unstable_header") && | ||
| 45 | key !== "unstable_navigationItemStyle", | ||
| 46 | ), | ||
| 47 | ), | ||
| 48 | headerShown: false, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | /** Keep outgoing screens until UIKit completes its pop, as required by v5. */ | ||
| 53 | export function V5CardStackView(props: V5StackViewProps) { | ||
| 54 | const { preventedRoutes } = usePreventRemoveContext(); | ||
| 55 | const [screens, setScreens] = useState({ | ||
| 56 | completedNativeDismissals: new Set<string>(), | ||
| 57 | routes: props.state.routes, | ||
| 58 | descriptors: props.descriptors, | ||
| 59 | observedRoutes: props.state.routes, | ||
| 60 | observedDescriptors: props.descriptors, | ||
| 61 | }); | ||
| 62 | if ( | ||
| 63 | screens.observedRoutes !== props.state.routes || | ||
| 64 | screens.observedDescriptors !== props.descriptors | ||
| 65 | ) { | ||
| 66 | const routes = reconcileStackScreens( | ||
| 67 | screens.routes, | ||
| 68 | props.state.routes, | ||
| 69 | screens.completedNativeDismissals, | ||
| 70 | ); | ||
| 71 | const active = new Set(props.state.routes.map((route) => route.key)); | ||
| 72 | const retainedKeys = new Set(routes.map((route) => route.key)); | ||
| 73 | setScreens({ | ||
| 74 | completedNativeDismissals: new Set( | ||
| 75 | [...screens.completedNativeDismissals].filter((key) => active.has(key)), | ||
| 76 | ), | ||
| 77 | routes, | ||
| 78 | descriptors: Object.fromEntries( | ||
| 79 | Object.entries({ ...screens.descriptors, ...props.descriptors }).filter(([key]) => | ||
| 80 | retainedKeys.has(key), | ||
| 81 | ), | ||
| 82 | ), | ||
| 83 | observedRoutes: props.state.routes, | ||
| 84 | observedDescriptors: props.descriptors, | ||
| 85 | }); | ||
| 86 | } | ||
| 87 | // Keep the real descriptor through dismissal. describe(route, true) creates | ||
| 88 | // a placeholder whose navigation rejects setOptions and dispatches. | ||
| 89 | const nativeDismiss = useCallback( | ||
| 90 | (key: string) => { | ||
| 91 | const state = props.navigation.getState(); | ||
| 92 | const attached = state.routes.some((route) => route.key === key); | ||
| 93 | setScreens((current) => { | ||
| 94 | const completedNativeDismissals = new Set(current.completedNativeDismissals); | ||
| 95 | if (attached) { | ||
| 96 | completedNativeDismissals.add(key); | ||
| 97 | return { ...current, completedNativeDismissals }; | ||
| 98 | } | ||
| 99 | // A delayed callback needs immediate cleanup: the router already | ||
| 100 | // removed the route, so no further router update will follow. | ||
| 101 | completedNativeDismissals.delete(key); | ||
| 102 | const descriptors = { ...current.descriptors }; | ||
| 103 | delete descriptors[key]; | ||
| 104 | return { | ||
| 105 | ...current, | ||
| 106 | completedNativeDismissals, | ||
| 107 | routes: current.routes.filter((route) => route.key !== key), | ||
| 108 | descriptors, | ||
| 109 | }; | ||
| 110 | }); | ||
| 111 | const action = nativeWorkspacePopAction(state, key); | ||
| 112 | if (action) props.navigation.dispatch(action); | ||
| 113 | }, | ||
| 114 | [props.navigation, setScreens], | ||
| 115 | ); | ||
| 116 | const removeDismissed = useCallback( | ||
| 117 | (key: string) => { | ||
| 118 | if (!props.navigation.getState().routes.some((route) => route.key === key)) { | ||
| 119 | setScreens((current) => { | ||
| 120 | const descriptors = { ...current.descriptors }; | ||
| 121 | delete descriptors[key]; | ||
| 122 | return { | ||
| 123 | ...current, | ||
| 124 | routes: current.routes.filter((route) => route.key !== key), | ||
| 125 | descriptors, | ||
| 126 | }; | ||
| 127 | }); | ||
| 128 | } | ||
| 129 | }, | ||
| 130 | [props.navigation, setScreens], | ||
| 131 | ); | ||
| 132 | return ( | ||
| 133 | <View className="flex-1 bg-screen"> | ||
| 134 | <Stack.Host> | ||
| 135 | {screens.routes.map((route, index) => { | ||
| 136 | const descriptor = props.descriptors[route.key] ?? screens.descriptors[route.key]; | ||
| 137 | if (!descriptor) return null; | ||
| 138 | const attached = props.state.routes.some((current) => current.key === route.key); | ||
| 139 | return ( | ||
| 140 | <Stack.Screen | ||
| 141 | key={route.key} | ||
| 142 | screenKey={route.key} | ||
| 143 | activityMode={attached ? "attached" : "detached"} | ||
| 144 | preventNativeDismiss={ | ||
| 145 | preventedRoutes[route.key]?.preventRemove || | ||
| 146 | descriptor.options.gestureEnabled === false | ||
| 147 | } | ||
| 148 | onDismiss={removeDismissed} | ||
| 149 | onNativeDismiss={nativeDismiss} | ||
| 150 | onNativeDismissPrevented={() => { | ||
| 151 | if (preventedRoutes[route.key]?.preventRemove) descriptor.navigation.goBack(); | ||
| 152 | }} | ||
| 153 | onWillAppear={() => | ||
| 154 | props.navigation.emit({ | ||
| 155 | type: "transitionStart", | ||
| 156 | target: route.key, | ||
| 157 | data: { closing: false }, | ||
| 158 | }) | ||
| 159 | } | ||
| 160 | onDidAppear={() => | ||
| 161 | props.navigation.emit({ | ||
| 162 | type: "transitionEnd", | ||
| 163 | target: route.key, | ||
| 164 | data: { closing: false }, | ||
| 165 | }) | ||
| 166 | } | ||
| 167 | onWillDisappear={() => | ||
| 168 | props.navigation.emit({ | ||
| 169 | type: "transitionStart", | ||
| 170 | target: route.key, | ||
| 171 | data: { closing: true }, | ||
| 172 | }) | ||
| 173 | } | ||
| 174 | onDidDisappear={() => | ||
| 175 | props.navigation.emit({ | ||
| 176 | type: "transitionEnd", | ||
| 177 | target: route.key, | ||
| 178 | data: { closing: true }, | ||
| 179 | }) | ||
| 180 | } | ||
| 181 | > | ||
| 182 | <NavigationContext value={descriptor.navigation}> | ||
| 183 | <NavigationRouteContext value={descriptor.route}> | ||
| 184 | <V5StackHeader options={descriptor.options} canGoBack={index > 0} /> | ||
| 185 | <NativeColumnContent>{descriptor.render()}</NativeColumnContent> | ||
| 186 | </NavigationRouteContext> | ||
| 187 | </NavigationContext> | ||
| 188 | </Stack.Screen> | ||
| 189 | ); | ||
| 190 | })} | ||
| 191 | </Stack.Host> | ||
| 192 | </View> | ||
| 193 | ); | ||
| 194 | } | ||
| 195 | |||
| 196 | /** v5 owns pushes and headers; the legacy renderer only presents modal groups. */ | ||
| 197 | export function V5StackView(props: V5StackViewProps) { | ||
| 198 | const groups = partitionStackPresentations(props.state.routes, (route) => { | ||
| 199 | const presentation = props.descriptors[route.key]?.options.presentation; | ||
| 200 | return ( | ||
| 201 | ["SettingsSheet", "NewTaskSheet"].includes(route.name) || | ||
| 202 | (presentation !== undefined && presentation !== "card") | ||
| 203 | ); | ||
| 204 | }); | ||
| 205 | const routes = groups.map((group) => group[0]!); | ||
| 206 | const descriptors = Object.fromEntries( | ||
| 207 | groups.map((group) => { | ||
| 208 | const first = group[0]!; | ||
| 209 | const descriptor = props.descriptors[first.key]!; | ||
| 210 | return [ | ||
| 211 | first.key, | ||
| 212 | { | ||
| 213 | ...descriptor, | ||
| 214 | options: modalEnvelopeOptions(descriptor.options), | ||
| 215 | render: () => ( | ||
| 216 | <V5CardStackView | ||
| 217 | {...props} | ||
| 218 | state={{ | ||
| 219 | ...props.state, | ||
| 220 | routes: group, | ||
| 221 | index: group.length - 1, | ||
| 222 | preloadedRoutes: [], | ||
| 223 | }} | ||
| 224 | /> | ||
| 225 | ), | ||
| 226 | }, | ||
| 227 | ]; | ||
| 228 | }), | ||
| 229 | ); | ||
| 230 | return ( | ||
| 231 | <NativeStackView | ||
| 232 | {...props} | ||
| 233 | descriptors={descriptors} | ||
| 234 | state={{ ...props.state, routes, index: routes.length - 1, preloadedRoutes: [] }} | ||
| 235 | /> | ||
| 236 | ); | ||
| 237 | } | ||
| 238 | |||
| 239 | /** Keep a direct card host mounted while v5 form sheets present above it. */ | ||
| 240 | export function V5SheetStackView(props: V5StackViewProps) { | ||
| 241 | const { preventedRoutes } = usePreventRemoveContext(); | ||
| 242 | const groups = partitionStackPresentations( | ||
| 243 | props.state.routes, | ||
| 244 | (route) => props.descriptors[route.key]?.options.presentation === "formSheet", | ||
| 245 | ); | ||
| 246 | const [retained, setRetained] = useState({ | ||
| 247 | groups: groups.slice(1), | ||
| 248 | descriptors: props.descriptors, | ||
| 249 | observedRoutes: props.state.routes, | ||
| 250 | observedDescriptors: props.descriptors, | ||
| 251 | }); | ||
| 252 | if ( | ||
| 253 | retained.observedRoutes !== props.state.routes || | ||
| 254 | retained.observedDescriptors !== props.descriptors | ||
| 255 | ) { | ||
| 256 | const active = new Set(groups.slice(1).map((group) => group[0]!.key)); | ||
| 257 | const sheets = [ | ||
| 258 | ...groups.slice(1), | ||
| 259 | ...retained.groups.filter((group) => !active.has(group[0]!.key)), | ||
| 260 | ]; | ||
| 261 | const sheetKeys = new Set(sheets.flatMap((group) => group.map((route) => route.key))); | ||
| 262 | setRetained({ | ||
| 263 | groups: sheets, | ||
| 264 | descriptors: Object.fromEntries( | ||
| 265 | Object.entries({ ...retained.descriptors, ...props.descriptors }).filter(([key]) => | ||
| 266 | sheetKeys.has(key), | ||
| 267 | ), | ||
| 268 | ), | ||
| 269 | observedRoutes: props.state.routes, | ||
| 270 | observedDescriptors: props.descriptors, | ||
| 271 | }); | ||
| 272 | } | ||
| 273 | const removeSheet = (key: string) => { | ||
| 274 | setRetained((current) => { | ||
| 275 | const sheets = current.groups.filter((group) => group[0]!.key !== key); | ||
| 276 | const sheetKeys = new Set(sheets.flatMap((group) => group.map((route) => route.key))); | ||
| 277 | return { | ||
| 278 | ...current, | ||
| 279 | groups: sheets, | ||
| 280 | descriptors: Object.fromEntries( | ||
| 281 | Object.entries(current.descriptors).filter(([routeKey]) => sheetKeys.has(routeKey)), | ||
| 282 | ), | ||
| 283 | }; | ||
| 284 | }); | ||
| 285 | }; | ||
| 286 | const base = groups[0] ?? []; | ||
| 287 | return ( | ||
| 288 | <View className="flex-1 bg-screen"> | ||
| 289 | <V5CardStackView | ||
| 290 | {...props} | ||
| 291 | state={{ ...props.state, routes: base, index: base.length - 1, preloadedRoutes: [] }} | ||
| 292 | /> | ||
| 293 | {retained.groups.map((group) => { | ||
| 294 | const first = group[0]!; | ||
| 295 | const descriptor = props.descriptors[first.key] ?? retained.descriptors[first.key]; | ||
| 296 | if (!descriptor) return null; | ||
| 297 | const options = descriptor.options; | ||
| 298 | const attached = nativeWorkspacePopCount(props.state, first.key) > 0; | ||
| 299 | return ( | ||
| 300 | <FormSheet | ||
| 301 | key={first.key} | ||
| 302 | isOpen={attached} | ||
| 303 | detents={options.sheetAllowedDetents} | ||
| 304 | initialDetentIndex={options.sheetInitialDetentIndex} | ||
| 305 | largestUndimmedDetentIndex={options.sheetLargestUndimmedDetentIndex} | ||
| 306 | prefersGrabberVisible={options.sheetGrabberVisible} | ||
| 307 | preferredCornerRadius={options.sheetCornerRadius} | ||
| 308 | prefersScrollingExpandsWhenScrolledToEdge={options.sheetExpandsWhenScrolledToEdge} | ||
| 309 | nativeContainerStyle={{ | ||
| 310 | backgroundColor: StyleSheet.flatten(options.contentStyle)?.backgroundColor, | ||
| 311 | }} | ||
| 312 | preventNativeDismiss={ | ||
| 313 | group.some((route) => preventedRoutes[route.key]?.preventRemove) || | ||
| 314 | options.gestureEnabled === false | ||
| 315 | } | ||
| 316 | onNativeDismissPrevented={() => { | ||
| 317 | const guarded = group.findLast((route) => preventedRoutes[route.key]?.preventRemove); | ||
| 318 | if (guarded) { | ||
| 319 | const state = props.navigation.getState(); | ||
| 320 | const action = nativeWorkspacePopAction(state, first.key); | ||
| 321 | if (action) props.navigation.dispatch(action); | ||
| 322 | } | ||
| 323 | }} | ||
| 324 | onDismiss={() => removeSheet(first.key)} | ||
| 325 | onNativeDismiss={() => { | ||
| 326 | const state = props.navigation.getState(); | ||
| 327 | const action = nativeWorkspacePopAction(state, first.key); | ||
| 328 | if (action) props.navigation.dispatch(action); | ||
| 329 | removeSheet(first.key); | ||
| 330 | }} | ||
| 331 | > | ||
| 332 | <V5CardStackView | ||
| 333 | {...props} | ||
| 334 | descriptors={{ ...retained.descriptors, ...props.descriptors }} | ||
| 335 | state={{ | ||
| 336 | ...props.state, | ||
| 337 | routes: group, | ||
| 338 | index: group.length - 1, | ||
| 339 | preloadedRoutes: [], | ||
| 340 | }} | ||
| 341 | /> | ||
| 342 | </FormSheet> | ||
| 343 | ); | ||
| 344 | })} | ||
| 345 | </View> | ||
| 346 | ); | ||
| 347 | } | ||
| 348 | |||
| 349 | function V5Navigator({ | ||
| 350 | nativeSheets, | ||
| 351 | id, | ||
| 352 | initialRouteName, | ||
| 353 | UNSTABLE_routeNamesChangeBehavior, | ||
| 354 | children, | ||
| 355 | layout, | ||
| 356 | screenListeners, | ||
| 357 | screenOptions, | ||
| 358 | screenLayout, | ||
| 359 | UNSTABLE_router, | ||
| 360 | ...rest | ||
| 361 | }: NativeStackNavigatorProps & { readonly nativeSheets?: boolean }) { | ||
| 362 | const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< | ||
| 363 | StackNavigationState<ParamListBase>, | ||
| 364 | StackRouterOptions, | ||
| 365 | StackActionHelpers<ParamListBase>, | ||
| 366 | NativeStackNavigationOptions, | ||
| 367 | NativeStackNavigationEventMap | ||
| 368 | >(StackRouter, { | ||
| 369 | id, | ||
| 370 | initialRouteName, | ||
| 371 | UNSTABLE_routeNamesChangeBehavior, | ||
| 372 | children, | ||
| 373 | layout, | ||
| 374 | screenListeners, | ||
| 375 | screenOptions, | ||
| 376 | screenLayout, | ||
| 377 | UNSTABLE_router, | ||
| 378 | }); | ||
| 379 | const StackView = nativeSheets ? V5SheetStackView : V5StackView; | ||
| 380 | return ( | ||
| 381 | <NavigationContent> | ||
| 382 | <StackView | ||
| 383 | {...rest} | ||
| 384 | state={state} | ||
| 385 | describe={describe} | ||
| 386 | descriptors={descriptors} | ||
| 387 | navigation={navigation} | ||
| 388 | /> | ||
| 389 | </NavigationContent> | ||
| 390 | ); | ||
| 391 | } | ||
| 392 | |||
| 393 | function V5StackNavigator(props: NativeStackNavigatorProps) { | ||
| 394 | return <V5Navigator {...props} />; | ||
| 395 | } | ||
| 396 | |||
| 397 | function V5SheetStackNavigator(props: NativeStackNavigatorProps) { | ||
| 398 | return <V5Navigator {...props} nativeSheets />; | ||
| 399 | } | ||
| 400 | |||
| 401 | type V5TypeBag<ParamList extends ParamListBase, NavigatorID extends string | undefined> = Omit< | ||
| 402 | NativeStackTypeBag<ParamList, NavigatorID>, | ||
| 403 | "Navigator" | ||
| 404 | > & { Navigator: typeof V5StackNavigator }; | ||
| 405 | export function createV5StackNavigator< | ||
| 406 | const ParamList extends ParamListBase, | ||
| 407 | const NavigatorID extends string | undefined = string | undefined, | ||
| 408 | const TypeBag extends NavigatorTypeBagBase = V5TypeBag<ParamList, NavigatorID>, | ||
| 409 | const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>, | ||
| 410 | >(config?: Config): TypedNavigator<TypeBag, Config> { | ||
| 411 | return createNavigatorFactory(V5StackNavigator)(config); | ||
| 412 | } | ||
| 413 | |||
| 414 | /** For navigators whose presentations are cards and form sheets only. */ | ||
| 415 | export function createV5SheetStackNavigator< | ||
| 416 | const ParamList extends ParamListBase, | ||
| 417 | const NavigatorID extends string | undefined = string | undefined, | ||
| 418 | const TypeBag extends NavigatorTypeBagBase = V5TypeBag<ParamList, NavigatorID>, | ||
| 419 | const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>, | ||
| 420 | >(config?: Config): TypedNavigator<TypeBag, Config> { | ||
| 421 | return createNavigatorFactory(V5SheetStackNavigator)(config); | ||
| 422 | } | ||
apps/mobile/src/native/createV5StackNavigator.tsx created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | export { | ||
| 2 | createNativeStackNavigator as createV5StackNavigator, | ||
| 3 | createNativeStackNavigator as createV5SheetStackNavigator, | ||
| 4 | } from "@react-navigation/native-stack"; | ||
apps/mobile/src/native/native-layout-metrics.tsx created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | import { createContext, use, useState, type ReactNode } from "react"; | ||
| 2 | import type { NativeLayoutMetrics } from "../lib/reserved-regions"; | ||
| 3 | import { NativeLayoutObserver } from "./NativeLayoutObserver"; | ||
| 4 | |||
| 5 | const NativeLayoutContext = createContext<NativeLayoutMetrics | null>(null); | ||
| 6 | // Column reservations come from UIKit's layout, outside the resizing navigator. | ||
| 7 | export const NativeColumnLayoutMetricsContext = createContext<NativeLayoutMetrics | null>(null); | ||
| 8 | |||
| 9 | export function NativeLayoutMetricsProvider(props: { readonly children: ReactNode }) { | ||
| 10 | const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null); | ||
| 11 | return ( | ||
| 12 | <NativeLayoutContext value={metrics}> | ||
| 13 | <NativeLayoutObserver onChange={setMetrics} /> | ||
| 14 | {props.children} | ||
| 15 | </NativeLayoutContext> | ||
| 16 | ); | ||
| 17 | } | ||
| 18 | |||
| 19 | export function useNativeLayoutMetrics() { | ||
| 20 | return use(NativeLayoutContext); | ||
| 21 | } | ||
| 22 | |||
| 23 | export function useNativeColumnLayoutMetrics() { | ||
| 24 | return use(NativeColumnLayoutMetricsContext); | ||
| 25 | } | ||
apps/mobile/src/native/v5-workspace-context.tsx created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | import { createContext, type ReactNode } from "react"; | ||
| 2 | |||
| 3 | // The router is shared with the other platforms; only iOS uses native columns. | ||
| 4 | export const NativeWorkspaceModeContext = createContext(false); | ||
| 5 | export const NativePrimaryColumnContext = createContext<{ | ||
| 6 | readonly selectedThreadKey: string | null; | ||
| 7 | } | null>(null); | ||
| 8 | export const NativeWorkspaceInspectorContext = createContext<{ | ||
| 9 | readonly render: (() => ReactNode) | undefined; | ||
| 10 | readonly visible: boolean; | ||
| 11 | }>({ render: undefined, visible: false }); | ||
apps/mobile/src/native/workspace-stack-projection.test.ts created+175| ... | @@ -0,0 +1,175 @@ | ||
| 1 | import * as NodeModule from "node:module"; | ||
| 2 | import type { ParamListBase, StackNavigationState } from "@react-navigation/native"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | nativeWorkspacePopAction, | ||
| 7 | nativeWorkspacePopCount, | ||
| 8 | projectWorkspaceStack, | ||
| 9 | partitionStackPresentations, | ||
| 10 | reconcileStackScreens, | ||
| 11 | } from "./workspace-stack-projection"; | ||
| 12 | |||
| 13 | // Load the same router as native navigation without importing React Native into Node. | ||
| 14 | const requireNavigation = NodeModule.createRequire( | ||
| 15 | NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), | ||
| 16 | ); | ||
| 17 | const { StackRouter } = requireNavigation("@react-navigation/routers") as { | ||
| 18 | StackRouter: typeof import("@react-navigation/native").StackRouter; | ||
| 19 | }; | ||
| 20 | |||
| 21 | const home = { key: "home", name: "Home" }; | ||
| 22 | const thread = { key: "thread", name: "Thread", params: { threadId: "draft-thread" } }; | ||
| 23 | const files = { key: "files", name: "ThreadFiles", params: thread.params }; | ||
| 24 | const settings = { key: "settings", name: "SettingsSheet" }; | ||
| 25 | const legal = { key: "legal", name: "SettingsLegal" }; | ||
| 26 | |||
| 27 | function history( | ||
| 28 | routes: StackNavigationState<ParamListBase>["routes"], | ||
| 29 | ): StackNavigationState<ParamListBase> { | ||
| 30 | return { | ||
| 31 | key: "router", | ||
| 32 | type: "stack", | ||
| 33 | stale: false, | ||
| 34 | index: routes.length - 1, | ||
| 35 | routeNames: ["Home", "Thread", "ThreadFiles", "SettingsSheet", "SettingsLegal"], | ||
| 36 | routes, | ||
| 37 | preloadedRoutes: [], | ||
| 38 | }; | ||
| 39 | } | ||
| 40 | |||
| 41 | describe("workspace router projection", () => { | ||
| 42 | it("keeps workspace flows beside Home and restores them after a modal closes", () => { | ||
| 43 | const draft = { key: "draft", name: "NewTaskSheet" }; | ||
| 44 | const modal = { key: "connect", name: "ConnectOnboarding" }; | ||
| 45 | const routes = [home, thread, draft, settings, legal]; | ||
| 46 | const isOverlay = (route: { name: string }) => route.name === "ConnectOnboarding"; | ||
| 47 | expect(projectWorkspaceStack(history([...routes, modal]), isOverlay)).toEqual({ | ||
| 48 | primary: home, | ||
| 49 | detail: [thread, draft, settings, legal], | ||
| 50 | overlays: [modal], | ||
| 51 | }); | ||
| 52 | expect(projectWorkspaceStack(history(routes), isOverlay)).toEqual({ | ||
| 53 | primary: home, | ||
| 54 | detail: [thread, draft, settings, legal], | ||
| 55 | overlays: [], | ||
| 56 | }); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("keeps the thread and file history in the detail column when a modal is opened", () => { | ||
| 60 | const state = history([home, thread, files, settings, legal]); | ||
| 61 | const projection = projectWorkspaceStack(state, (route) => route.name === "SettingsSheet"); | ||
| 62 | expect(projection).toEqual({ | ||
| 63 | primary: home, | ||
| 64 | detail: [thread, files], | ||
| 65 | overlays: [settings, legal], | ||
| 66 | }); | ||
| 67 | expect(projection.detail[0]).toBe(thread); | ||
| 68 | expect(state.routes).toEqual([home, thread, files, settings, legal]); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("retains a cold-linked detail route without changing the router history", () => { | ||
| 72 | const state = history([thread, files]); | ||
| 73 | expect(projectWorkspaceStack(state, () => false)).toEqual({ | ||
| 74 | primary: undefined, | ||
| 75 | detail: [thread, files], | ||
| 76 | overlays: [], | ||
| 77 | }); | ||
| 78 | expect(state.routes[0]).toBe(thread); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("restores the empty detail column after Back reaches the thread list", () => { | ||
| 82 | expect(projectWorkspaceStack(history([home]), () => false)).toEqual({ | ||
| 83 | primary: home, | ||
| 84 | detail: [], | ||
| 85 | overlays: [], | ||
| 86 | }); | ||
| 87 | }); | ||
| 88 | }); | ||
| 89 | |||
| 90 | describe("native workspace dismissal", () => { | ||
| 91 | it("dismisses a sheet and its pushed pages without removing the underlying draft", () => { | ||
| 92 | const state = history([home, thread, settings, legal]); | ||
| 93 | const action = nativeWorkspacePopAction(state, settings.key)!; | ||
| 94 | const next = StackRouter({}).getStateForAction(state, action, { | ||
| 95 | routeNames: state.routeNames, | ||
| 96 | routeParamList: {}, | ||
| 97 | routeGetIdList: {}, | ||
| 98 | }); | ||
| 99 | expect(next?.routes).toEqual([home, thread]); | ||
| 100 | expect(next?.routes[1]).toBe(thread); | ||
| 101 | expect(next?.index).toBe(1); | ||
| 102 | }); | ||
| 103 | |||
| 104 | it("does not dismiss retained sheets beyond the active index", () => { | ||
| 105 | const state = { ...history([home, thread, settings, legal]), index: 1 }; | ||
| 106 | expect(nativeWorkspacePopAction(state, settings.key)).toBeNull(); | ||
| 107 | expect(nativeWorkspacePopAction(state, legal.key)).toBeNull(); | ||
| 108 | }); | ||
| 109 | it("pops a native dismissed file while keeping the conversation and its draft mounted", () => { | ||
| 110 | expect(nativeWorkspacePopCount(history([home, thread, files]), files.key)).toBe(1); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("removes the descendants when UIKit dismisses their parent conversation", () => { | ||
| 114 | expect(nativeWorkspacePopCount(history([home, thread, files]), thread.key)).toBe(2); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("ignores delayed callbacks from JS removal and replacement", () => { | ||
| 118 | expect(nativeWorkspacePopCount(history([home, thread]), files.key)).toBe(0); | ||
| 119 | expect( | ||
| 120 | nativeWorkspacePopCount(history([home, { ...thread, key: "replacement" }]), thread.key), | ||
| 121 | ).toBe(0); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("never removes the root or a route ahead of the active index", () => { | ||
| 125 | const state = history([home, thread, files]); | ||
| 126 | expect(nativeWorkspacePopCount(state, home.key)).toBe(0); | ||
| 127 | expect(nativeWorkspacePopCount({ ...state, index: 1 }, files.key)).toBe(0); | ||
| 128 | }); | ||
| 129 | }); | ||
| 130 | |||
| 131 | describe("v5 stack handoff", () => { | ||
| 132 | it("releases a completed native pop when the router acknowledges it", () => { | ||
| 133 | const completed = new Set([files.key]); | ||
| 134 | expect(reconcileStackScreens([home, thread, files], [home, thread, files], completed)).toEqual([ | ||
| 135 | home, | ||
| 136 | thread, | ||
| 137 | files, | ||
| 138 | ]); | ||
| 139 | expect(reconcileStackScreens([home, thread, files], [home, thread], completed)).toEqual([ | ||
| 140 | home, | ||
| 141 | thread, | ||
| 142 | ]); | ||
| 143 | }); | ||
| 144 | it("still retains an unfinished JS pop beside a completed native pop", () => { | ||
| 145 | expect(reconcileStackScreens([home, thread, files], [home], new Set([files.key]))).toEqual([ | ||
| 146 | thread, | ||
| 147 | home, | ||
| 148 | ]); | ||
| 149 | }); | ||
| 150 | it("retains removed native screens through a pop followed immediately by a push", () => { | ||
| 151 | const popped = reconcileStackScreens([home, thread, files], [home, thread]); | ||
| 152 | const next = { ...files, key: "new-files" }; | ||
| 153 | const screens = reconcileStackScreens(popped, [home, thread, next]); | ||
| 154 | expect(screens.map((route) => route.key)).toEqual(["files", "home", "thread", "new-files"]); | ||
| 155 | expect(screens.filter((route) => [home, thread, next].includes(route))).toEqual([ | ||
| 156 | home, | ||
| 157 | thread, | ||
| 158 | next, | ||
| 159 | ]); | ||
| 160 | expect(screens[0]).toBe(files); | ||
| 161 | }); | ||
| 162 | it("updates params without retaining duplicate copies of the same screen", () => { | ||
| 163 | const updated = { ...thread, params: { threadId: "another-thread" } }; | ||
| 164 | expect(reconcileStackScreens([home, thread], [home, updated])).toEqual([home, updated]); | ||
| 165 | }); | ||
| 166 | it("keeps card pushes inside the modal they belong to", () => { | ||
| 167 | const secondModal = { ...settings, key: "another-settings" }; | ||
| 168 | expect( | ||
| 169 | partitionStackPresentations( | ||
| 170 | [home, thread, settings, legal, secondModal], | ||
| 171 | (route) => route.name === "SettingsSheet", | ||
| 172 | ), | ||
| 173 | ).toEqual([[home, thread], [settings, legal], [secondModal]]); | ||
| 174 | }); | ||
| 175 | }); | ||
apps/mobile/src/native/workspace-stack-projection.ts created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import type { | ||
| 2 | NavigationState, | ||
| 3 | ParamListBase, | ||
| 4 | StackNavigationState, | ||
| 5 | } from "@react-navigation/native"; | ||
| 6 | |||
| 7 | type Route = StackNavigationState<ParamListBase>["routes"][number]; | ||
| 8 | |||
| 9 | /** Project one router history into native columns and a modal presentation stack. */ | ||
| 10 | export function projectWorkspaceStack( | ||
| 11 | state: StackNavigationState<ParamListBase>, | ||
| 12 | isOverlay: (route: Route) => boolean, | ||
| 13 | ) { | ||
| 14 | const firstOverlay = state.routes.findIndex(isOverlay); | ||
| 15 | const workspaceRoutes = state.routes.slice(0, firstOverlay < 0 ? undefined : firstOverlay); | ||
| 16 | const primary = workspaceRoutes.find((route) => route.name === "Home"); | ||
| 17 | const detail = workspaceRoutes.filter((route) => route.name !== "Home"); | ||
| 18 | const overlays = firstOverlay < 0 ? [] : state.routes.slice(firstOverlay); | ||
| 19 | return { primary, detail, overlays }; | ||
| 20 | } | ||
| 21 | |||
| 22 | /** Native callbacks can arrive after a JS pop or replace; those must not pop the next screen. */ | ||
| 23 | export function nativeWorkspacePopCount( | ||
| 24 | state: Pick<NavigationState, "index" | "routes">, | ||
| 25 | dismissedKey: string, | ||
| 26 | ): number { | ||
| 27 | const index = state.routes.findIndex((route) => route.key === dismissedKey); | ||
| 28 | return index <= 0 || index > state.index ? 0 : state.index - index + 1; | ||
| 29 | } | ||
| 30 | |||
| 31 | /** POP counts backwards from its source, so dismiss a group from the active descendant. */ | ||
| 32 | export function nativeWorkspacePopAction( | ||
| 33 | state: Pick<NavigationState, "key" | "index" | "routes">, | ||
| 34 | dismissedKey: string, | ||
| 35 | ) { | ||
| 36 | const count = nativeWorkspacePopCount(state, dismissedKey); | ||
| 37 | return count | ||
| 38 | ? { | ||
| 39 | type: "POP" as const, | ||
| 40 | payload: { count }, | ||
| 41 | source: state.routes[state.index]!.key, | ||
| 42 | target: state.key, | ||
| 43 | } | ||
| 44 | : null; | ||
| 45 | } | ||
| 46 | |||
| 47 | /** Group pushes with the modal that owns their native stack. */ | ||
| 48 | export function partitionStackPresentations<T>( | ||
| 49 | routes: readonly T[], | ||
| 50 | isModal: (route: T) => boolean, | ||
| 51 | ): T[][] { | ||
| 52 | const groups: T[][] = []; | ||
| 53 | for (const route of routes) { | ||
| 54 | if (groups.length === 0 || isModal(route)) groups.push([]); | ||
| 55 | groups.at(-1)!.push(route); | ||
| 56 | } | ||
| 57 | return groups; | ||
| 58 | } | ||
| 59 | |||
| 60 | /** Preserve UIKit's outgoing screens, before newly pushed screens, until dismissal. */ | ||
| 61 | export function reconcileStackScreens<T extends { readonly key: string }>( | ||
| 62 | previous: readonly T[], | ||
| 63 | current: readonly T[], | ||
| 64 | completedNativeDismissals: ReadonlySet<string> = new Set(), | ||
| 65 | ): T[] { | ||
| 66 | const active = new Set(current.map((route) => route.key)); | ||
| 67 | return [ | ||
| 68 | ...previous.filter( | ||
| 69 | (route) => !active.has(route.key) && !completedNativeDismissals.has(route.key), | ||
| 70 | ), | ||
| 71 | ...current, | ||
| 72 | ]; | ||
| 73 | } | ||
docs/operations/mobile-screens-fork.md created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | # Building the mobile Screens dependency | ||
| 2 | |||
| 3 | The mobile v5 stack uses a committed tarball from the | ||
| 4 | [T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3-v5.0.0-t3.7). | ||
| 5 | The fork owns the integration changes; the app does not patch this package. | ||
| 6 | Each archive includes compiled JavaScript, declarations, native source and | ||
| 7 | `t3-fork.json` recording its version, source commit and upstream base. | ||
| 8 | |||
| 9 | To reproduce the current archive with Node 24 and the repository's pinned Yarn: | ||
| 10 | |||
| 11 | ```sh | ||
| 12 | git clone --branch t3-v5.0.0-t3.7 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork | ||
| 13 | yarn --cwd /tmp/t3-screens-fork pack:t3 /absolute/path/to/t3code/apps/mobile/deps | ||
| 14 | ``` | ||
| 15 | |||
| 16 | For an update, sync the fork's `main` with upstream, merge it and the required | ||
| 17 | proposal branches into `t3/main`, increment the `5.0.0-t3.N` version, pin `t3Fork.upstreamCommit` to the | ||
| 18 | integrated upstream SHA and commit. | ||
| 19 | Run `pack:t3` from that clean checkout and tag the source as `t3-v<version>`. | ||
| 20 | Replace the old archive, update `apps/mobile/package.json` and run `vp i` to | ||
| 21 | regenerate the lockfile. Verify mobile types and affected navigation tests. | ||
| 22 | Changes to native source also require rebuilding and testing the native client. | ||
patches/react-native-gesture-handler@3.2.1.patch+53| ... | @@ -149,3 +149,56 @@ index 1e1246f709feb04c01cf3221472cc1219a8fffac..f526d4fe2d2738becbe8d29c59d1105a | ... | @@ -149,3 +149,56 @@ index 1e1246f709feb04c01cf3221472cc1219a8fffac..f526d4fe2d2738becbe8d29c59d1105a |
| 149 | }>; | 149 | }>; |
| 150 | 150 | ||
| 151 | export interface SwipeableMethods { | 151 | export interface SwipeableMethods { |
| 152 | diff --git a/apple/Handlers/RNPanHandler.m b/apple/Handlers/RNPanHandler.m | ||
| 153 | --- a/apple/Handlers/RNPanHandler.m | ||
| 154 | +++ b/apple/Handlers/RNPanHandler.m | ||
| 155 | @@ -31,6 +31,7 @@ | ||
| 156 | @property (nonatomic) CGFloat failOffsetYStart; | ||
| 157 | @property (nonatomic) CGFloat failOffsetYEnd; | ||
| 158 | @property (nonatomic) CGFloat activateAfterLongPress; | ||
| 159 | +@property (nonatomic) BOOL handlingTouchStart; | ||
| 160 | |||
| 161 | #if !TARGET_OS_OSX && !TARGET_OS_TV | ||
| 162 | @property (atomic, readonly, strong) RNGHStylusData *stylusData; | ||
| 163 | @@ -126,9 +127,14 @@ | ||
| 164 | |||
| 165 | - (void)interactionsBegan:(NSSet *)touches withEvent:(UIEvent *)event | ||
| 166 | { | ||
| 167 | - if (self.state == UIGestureRecognizerStatePossible && ![self.delegate gestureRecognizerShouldBegin:self]) { | ||
| 168 | - self.state = UIGestureRecognizerStateFailed; | ||
| 169 | - return; | ||
| 170 | + if (self.state == UIGestureRecognizerStatePossible) { | ||
| 171 | + self.handlingTouchStart = touches.count > 0; | ||
| 172 | + BOOL shouldBegin = [self.delegate gestureRecognizerShouldBegin:self]; | ||
| 173 | + self.handlingTouchStart = NO; | ||
| 174 | + if (!shouldBegin) { | ||
| 175 | + self.state = UIGestureRecognizerStateFailed; | ||
| 176 | + return; | ||
| 177 | + } | ||
| 178 | } | ||
| 179 | |||
| 180 | if (touches.count == 0) { | ||
| 181 | @@ -476,6 +482,23 @@ | ||
| 182 | |||
| 183 | - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer | ||
| 184 | { | ||
| 185 | +#if !TARGET_OS_OSX && !TARGET_OS_TV | ||
| 186 | + RNBetterPanGestureRecognizer *recognizer = (RNBetterPanGestureRecognizer *)gestureRecognizer; | ||
| 187 | + if (!recognizer.handlingTouchStart && recognizer.allowedScrollTypesMask != 0 && recognizer.numberOfTouches == 0) { | ||
| 188 | + // Trackpad scroll events can begin without touchesMoved enforcing failure | ||
| 189 | + // offsets. Reject vertical scrolling before a horizontal row pan cancels | ||
| 190 | + // the enclosing UIScrollView. The manual touch-start preflight above has | ||
| 191 | + // no current translation yet, so leave that path to the touch callbacks. | ||
| 192 | + CGPoint translation = [recognizer translationInView:recognizer.view.window]; | ||
| 193 | + BOOL horizontalOnly = (!isnan(recognizer.activeOffsetXStart) || !isnan(recognizer.activeOffsetXEnd)) && | ||
| 194 | + isnan(recognizer.activeOffsetYStart) && isnan(recognizer.activeOffsetYEnd); | ||
| 195 | + if ([recognizer shouldFailUnderCustomCriteria] || | ||
| 196 | + (horizontalOnly && fabs(translation.y) > fabs(translation.x))) { | ||
| 197 | + return NO; | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | +#endif | ||
| 201 | + | ||
| 202 | RNGestureHandlerState savedState = _lastState; | ||
| 203 | BOOL shouldBegin = [super gestureRecognizerShouldBegin:gestureRecognizer]; | ||
| 204 | _lastState = savedState; |
patches/react-native-screens@4.28.0.patch deleted-2232| ... | @@ -1,2232 +0,0 @@ | ||
| 1 | diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt | ||
| 2 | index fa1b773..270c81c 100644 | ||
| 3 | --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt | ||
| 4 | +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt | ||
| 5 | @@ -124,6 +124,34 @@ class ScreenStackHeaderConfigViewManager : | ||
| 6 | config.setTitle(title) | ||
| 7 | } | ||
| 8 | |||
| 9 | + // The t3 patch adds these iOS glass-chrome props to the native spec; they have | ||
| 10 | + // no Android implementation, but the codegen-generated interface requires the | ||
| 11 | + // setters to exist. No-ops keep the Android build compiling. | ||
| 12 | + override fun setSubtitle( | ||
| 13 | + config: ScreenStackHeaderConfig, | ||
| 14 | + subtitle: String?, | ||
| 15 | + ) = Unit | ||
| 16 | + | ||
| 17 | + override fun setLargeSubtitle( | ||
| 18 | + config: ScreenStackHeaderConfig, | ||
| 19 | + largeSubtitle: String?, | ||
| 20 | + ) = Unit | ||
| 21 | + | ||
| 22 | + override fun setNavigationItemStyle( | ||
| 23 | + config: ScreenStackHeaderConfig, | ||
| 24 | + navigationItemStyle: String?, | ||
| 25 | + ) = Unit | ||
| 26 | + | ||
| 27 | + override fun setHeaderCenterBarButtonItems( | ||
| 28 | + config: ScreenStackHeaderConfig, | ||
| 29 | + headerCenterBarButtonItems: ReadableArray?, | ||
| 30 | + ) = Unit | ||
| 31 | + | ||
| 32 | + override fun setHeaderToolbarItems( | ||
| 33 | + config: ScreenStackHeaderConfig, | ||
| 34 | + headerToolbarItems: ReadableArray?, | ||
| 35 | + ) = Unit | ||
| 36 | + | ||
| 37 | override fun setTitleFontFamily( | ||
| 38 | config: ScreenStackHeaderConfig, | ||
| 39 | titleFontFamily: String?, | ||
| 40 | diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt | ||
| 41 | index 64f14f0..6cb1d69 100644 | ||
| 42 | --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt | ||
| 43 | +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt | ||
| 44 | @@ -47,6 +47,12 @@ class ScreenStackHeaderSubviewManager : | ||
| 45 | Log.w("[RNScreens]", "hidesSharedBackground prop is not available on Android") | ||
| 46 | } | ||
| 47 | |||
| 48 | + // Bar button transition identifiers only apply on iOS. | ||
| 49 | + override fun setIdentifier( | ||
| 50 | + view: ScreenStackHeaderSubview, | ||
| 51 | + identifier: String?, | ||
| 52 | + ) = Unit | ||
| 53 | + | ||
| 54 | // synchronousShadowStateUpdatesEnabled is not available on Android atm, | ||
| 55 | // however we must override their setters | ||
| 56 | override fun setSynchronousShadowStateUpdatesEnabled( | ||
| 57 | diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h | ||
| 58 | index a26d125..62eea42 100644 | ||
| 59 | --- a/ios/RNSBarButtonItem.h | ||
| 60 | +++ b/ios/RNSBarButtonItem.h | ||
| 61 | @@ -15,6 +15,10 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); | ||
| 62 | action:(RNSBarButtonItemAction)action | ||
| 63 | menuAction:(RNSBarButtonMenuItemAction)menuAction | ||
| 64 | imageLoader:(RCTImageLoader *)imageLoader; | ||
| 65 | + | ||
| 66 | ++ (UIMenu *)initUIMenuWithDict:(NSDictionary<NSString *, id> *)dict | ||
| 67 | + menuAction:(RNSBarButtonMenuItemAction)menuAction | ||
| 68 | + imageLoader:(RCTImageLoader *)imageLoader; | ||
| 69 | #endif // defined(__cplusplus) | ||
| 70 | |||
| 71 | @end | ||
| 72 | diff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mm | ||
| 73 | index 76a39d4..15bd5a9 100644 | ||
| 74 | --- a/ios/RNSBarButtonItem.mm | ||
| 75 | +++ b/ios/RNSBarButtonItem.mm | ||
| 76 | @@ -110,6 +110,58 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict | ||
| 77 | if (dict[@"accessibilityHint"]) { | ||
| 78 | self.accessibilityHint = dict[@"accessibilityHint"]; | ||
| 79 | } | ||
| 80 | +#if !TARGET_OS_TV && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 81 | + NSNumber *glassEffectNum = dict[@"glassEffect"]; | ||
| 82 | + if (@available(iOS 26.0, *)) { | ||
| 83 | + if ([glassEffectNum boolValue]) { | ||
| 84 | + UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; | ||
| 85 | + configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; | ||
| 86 | + UIBackgroundConfiguration *background = configuration.background; | ||
| 87 | + background.strokeWidth = 0.0; | ||
| 88 | + background.strokeColor = UIColor.clearColor; | ||
| 89 | + configuration.background = background; | ||
| 90 | + configuration.contentInsets = NSDirectionalEdgeInsetsZero; | ||
| 91 | + if (title != nil) { | ||
| 92 | + configuration.title = title; | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; | ||
| 96 | + button.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 97 | + button.enabled = self.enabled; | ||
| 98 | + button.accessibilityLabel = self.accessibilityLabel; | ||
| 99 | + button.accessibilityHint = self.accessibilityHint; | ||
| 100 | + | ||
| 101 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : 56.0; | ||
| 102 | + [button.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; | ||
| 103 | + [button.heightAnchor constraintEqualToConstant:50.0].active = YES; | ||
| 104 | + | ||
| 105 | + [[self class] resolveImageFromConfig:dict | ||
| 106 | + imageLoader:imageLoader | ||
| 107 | + completionBlock:^(UIImage *img) { | ||
| 108 | + UIButtonConfiguration *updatedConfiguration = button.configuration; | ||
| 109 | + updatedConfiguration.image = img; | ||
| 110 | + button.configuration = updatedConfiguration; | ||
| 111 | + }]; | ||
| 112 | + | ||
| 113 | + NSDictionary *menu = dict[@"menu"]; | ||
| 114 | + if (menu) { | ||
| 115 | + button.menu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader]; | ||
| 116 | + button.showsMenuAsPrimaryAction = YES; | ||
| 117 | + } else { | ||
| 118 | + NSString *buttonId = dict[@"buttonId"]; | ||
| 119 | + if (buttonId && action) { | ||
| 120 | + UIAction *pressAction = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull sender) { | ||
| 121 | + action(buttonId); | ||
| 122 | + }]; | ||
| 123 | + [button addAction:pressAction forControlEvents:UIControlEventTouchUpInside]; | ||
| 124 | + } | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + self.customView = button; | ||
| 128 | + return self; | ||
| 129 | + } | ||
| 130 | + } | ||
| 131 | +#endif | ||
| 132 | |||
| 133 | #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000 | ||
| 134 | if (@available(tvOS 17.0, *)) { | ||
| 135 | diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm | ||
| 136 | index c233036..0c2db42 100644 | ||
| 137 | --- a/ios/RNSScreenStack.mm | ||
| 138 | +++ b/ios/RNSScreenStack.mm | ||
| 139 | @@ -1076,6 +1076,15 @@ - (BOOL)scrollViewHasHorizontalPart:(UIScrollView *)scrollView | ||
| 140 | return scrollView.contentSize.width > scrollView.frame.size.width; | ||
| 141 | } | ||
| 142 | |||
| 143 | +// Whether a horizontally scrollable ScrollView is resting at its leading edge, so a | ||
| 144 | +// pan in the back-gesture direction has nothing left to scroll. Checked in the | ||
| 145 | +// ScrollView's own coordinate space: React Native mirrors the view for RTL layout, | ||
| 146 | +// which keeps the leading edge at the minimum content offset in both directions. | ||
| 147 | +- (BOOL)scrollViewIsAtLeadingEdge:(UIScrollView *)scrollView | ||
| 148 | +{ | ||
| 149 | + return scrollView.contentOffset.x <= -scrollView.adjustedContentInset.left + 1.0; | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | // Custom method for compatibility with iOS < 13.4 | ||
| 153 | // RNSScreenStackView is a UIGestureRecognizerDelegate for three types of gesture recognizers: | ||
| 154 | // RNSPanGestureRecognizer, RNSScreenEdgeGestureRecognizer, _UIParallaxTransitionPanGestureRecognizer | ||
| 155 | @@ -1169,9 +1178,11 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer | ||
| 156 | if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer && | ||
| 157 | [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) { | ||
| 158 | // ScrollView should take precedence when scrolling horizontally (it should be required to fail). | ||
| 159 | - // However, if it does not allow for horizontal scrolling, there should be no such restriction, | ||
| 160 | + // However, if it does not allow for horizontal scrolling, or is already resting at its leading | ||
| 161 | + // edge so a back swipe has nothing left to scroll, there should be no such restriction, | ||
| 162 | // and swiping horizontally should dismiss the screen. | ||
| 163 | - return [self scrollViewHasHorizontalPart:static_cast<UIScrollView *>(otherGestureRecognizer.view)]; | ||
| 164 | + UIScrollView *scrollView = static_cast<UIScrollView *>(otherGestureRecognizer.view); | ||
| 165 | + return [self scrollViewHasHorizontalPart:scrollView] && ![self scrollViewIsAtLeadingEdge:scrollView]; | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | @@ -1191,6 +1202,15 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer | ||
| 170 | // gestureRecognizer:shouldRequireFailureOfGestureRecognizer | ||
| 171 | isEdgeSwipeGestureRecognizer = | ||
| 172 | isEdgeSwipeGestureRecognizer && gestureRecognizer != _controller.interactiveContentPopGestureRecognizer; | ||
| 173 | + | ||
| 174 | + // The exception is a horizontal ScrollView resting at its leading edge: its pan recognizer would | ||
| 175 | + // still begin (and merely bounce) on a back swipe, so it must yield to the pop gesture the same | ||
| 176 | + // way plain content does. | ||
| 177 | + if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer && | ||
| 178 | + [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) { | ||
| 179 | + UIScrollView *scrollView = static_cast<UIScrollView *>(otherGestureRecognizer.view); | ||
| 180 | + return [self scrollViewHasHorizontalPart:scrollView] && [self scrollViewIsAtLeadingEdge:scrollView]; | ||
| 181 | + } | ||
| 182 | } | ||
| 183 | #endif // check for iOS >= 26 | ||
| 184 | |||
| 185 | diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h | ||
| 186 | index 8a41697..309ba2f 100644 | ||
| 187 | --- a/ios/RNSScreenStackHeaderConfig.h | ||
| 188 | +++ b/ios/RNSScreenStackHeaderConfig.h | ||
| 189 | @@ -23,6 +23,8 @@ | ||
| 190 | NS_ASSUME_NONNULL_BEGIN | ||
| 191 | |||
| 192 | @property (nonatomic, retain) NSString *title; | ||
| 193 | +@property (nonatomic, retain) NSString *subtitle; | ||
| 194 | +@property (nonatomic, retain) NSString *largeSubtitle; | ||
| 195 | @property (nonatomic, retain) NSString *titleFontFamily; | ||
| 196 | @property (nonatomic, retain) NSNumber *titleFontSize; | ||
| 197 | @property (nonatomic, retain) NSString *titleFontWeight; | ||
| 198 | @@ -47,9 +49,12 @@ NS_ASSUME_NONNULL_BEGIN | ||
| 199 | @property (nonatomic) BOOL backButtonInCustomView; | ||
| 200 | @property (nonatomic) UISemanticContentAttribute direction; | ||
| 201 | @property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode; | ||
| 202 | +@property (nonatomic) NSInteger navigationItemStyle; | ||
| 203 | @property (nonatomic) RNSBlurEffectStyle blurEffect; | ||
| 204 | @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerRightBarButtonItems; | ||
| 205 | @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerLeftBarButtonItems; | ||
| 206 | +@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerCenterBarButtonItems; | ||
| 207 | +@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerToolbarItems; | ||
| 208 | @property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled; | ||
| 209 | |||
| 210 | NS_ASSUME_NONNULL_END | ||
| 211 | diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm | ||
| 212 | index a54cb63..4ff30b3 100644 | ||
| 213 | --- a/ios/RNSScreenStackHeaderConfig.mm | ||
| 214 | +++ b/ios/RNSScreenStackHeaderConfig.mm | ||
| 215 | @@ -24,11 +24,46 @@ | ||
| 216 | #import "RNSSearchBar.h" | ||
| 217 | #import "UINavigationBar+RNSUtility.h" | ||
| 218 | |||
| 219 | +#import <objc/runtime.h> | ||
| 220 | + | ||
| 221 | namespace react = facebook::react; | ||
| 222 | |||
| 223 | static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; | ||
| 224 | + | ||
| 225 | +#if !TARGET_OS_TV | ||
| 226 | +// Each placement keeps its own cache so changes to one side do not move live | ||
| 227 | +// items on the other side into new groups during a navigation transition. | ||
| 228 | +static char RNSAppliedLeadingHeaderItemsKey; | ||
| 229 | +static char RNSAppliedTrailingHeaderItemsKey; | ||
| 230 | +static char RNSAppliedCenterHeaderItemsKey; | ||
| 231 | + | ||
| 232 | +// Records a placement's owner and contents, returning whether they changed. | ||
| 233 | +static BOOL RNSUpdateHeaderItemsCache(UINavigationItem *navitem, const void *key, NSArray *itemsKey) | ||
| 234 | +{ | ||
| 235 | + if ([objc_getAssociatedObject(navitem, key) isEqual:itemsKey]) { | ||
| 236 | + return NO; | ||
| 237 | + } | ||
| 238 | + objc_setAssociatedObject(navitem, key, itemsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); | ||
| 239 | + return YES; | ||
| 240 | +} | ||
| 241 | +#endif | ||
| 242 | +static char RNSAppliedToolbarConfigsKey; | ||
| 243 | static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; | ||
| 244 | |||
| 245 | +static NSInteger navigationItemStyleFromCppEquivalent( | ||
| 246 | + react::RNSScreenStackHeaderConfigNavigationItemStyle navigationItemStyle) | ||
| 247 | +{ | ||
| 248 | + switch (navigationItemStyle) { | ||
| 249 | + case react::RNSScreenStackHeaderConfigNavigationItemStyle::Browser: | ||
| 250 | + return UINavigationItemStyleBrowser; | ||
| 251 | + case react::RNSScreenStackHeaderConfigNavigationItemStyle::Editor: | ||
| 252 | + return UINavigationItemStyleEditor; | ||
| 253 | + case react::RNSScreenStackHeaderConfigNavigationItemStyle::Navigator: | ||
| 254 | + default: | ||
| 255 | + return UINavigationItemStyleNavigator; | ||
| 256 | + } | ||
| 257 | +} | ||
| 258 | + | ||
| 259 | @interface RCTImageLoader (Private) | ||
| 260 | - (id<RCTImageCache>)imageCache; | ||
| 261 | @end | ||
| 262 | @@ -46,6 +81,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string | ||
| 263 | @end | ||
| 264 | |||
| 265 | @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving> | ||
| 266 | +#if !TARGET_OS_TV | ||
| 267 | +- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items; | ||
| 268 | +#endif | ||
| 269 | @end | ||
| 270 | |||
| 271 | @implementation RNSScreenStackHeaderConfig { | ||
| 272 | @@ -80,6 +118,7 @@ - (void)initProps | ||
| 273 | self.hidden = YES; | ||
| 274 | _reactSubviews = [NSMutableArray new]; | ||
| 275 | _backTitleVisible = YES; | ||
| 276 | + _navigationItemStyle = UINavigationItemStyleNavigator; | ||
| 277 | _blurEffect = RNSBlurEffectStyleNone; | ||
| 278 | _synchronousShadowStateUpdatesEnabled = YES; | ||
| 279 | } | ||
| 280 | @@ -507,6 +546,10 @@ + (void)updateViewController:(UIViewController *)vc | ||
| 281 | |||
| 282 | if (shouldHide) { | ||
| 283 | navitem.title = config.title; | ||
| 284 | + if (@available(iOS 26.0, *)) { | ||
| 285 | + navitem.subtitle = config.subtitle; | ||
| 286 | + navitem.largeSubtitle = config.largeSubtitle; | ||
| 287 | + } | ||
| 288 | |||
| 289 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. | ||
| 290 | [navctr setNavigationBarHidden:YES animated:animated]; | ||
| 291 | @@ -523,11 +566,19 @@ + (void)updateViewController:(UIViewController *)vc | ||
| 292 | } | ||
| 293 | navitem.largeTitleDisplayMode = | ||
| 294 | config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; | ||
| 295 | + | ||
| 296 | + if (@available(iOS 16.0, *)) { | ||
| 297 | + navitem.style = (UINavigationItemStyle)config.navigationItemStyle; | ||
| 298 | + } | ||
| 299 | #endif | ||
| 300 | |||
| 301 | UINavigationBarAppearance *appearance = [self buildAppearance:vc withConfig:config]; | ||
| 302 | navitem.standardAppearance = appearance; | ||
| 303 | navitem.compactAppearance = appearance; | ||
| 304 | + if (@available(iOS 26.0, *)) { | ||
| 305 | + navitem.subtitle = config.subtitle; | ||
| 306 | + navitem.largeSubtitle = config.largeSubtitle; | ||
| 307 | + } | ||
| 308 | |||
| 309 | // appearance does not apply to the tvOS so we need to use lagacy customization | ||
| 310 | #if TARGET_OS_TV | ||
| 311 | @@ -559,8 +610,6 @@ + (void)updateViewController:(UIViewController *)vc | ||
| 312 | navitem.leftItemsSupplementBackButton = config.backButtonInCustomView; | ||
| 313 | #endif | ||
| 314 | navitem.titleView = nil; | ||
| 315 | - navitem.leftBarButtonItems = nil; | ||
| 316 | - navitem.rightBarButtonItems = nil; | ||
| 317 | |||
| 318 | #if !TARGET_OS_TV | ||
| 319 | // We want to set navitem.searchController to nil only if we are sure | ||
| 320 | @@ -568,22 +617,18 @@ + (void)updateViewController:(UIViewController *)vc | ||
| 321 | bool searchBarPresent = false; | ||
| 322 | #endif /* !TARGET_OS_TV */ | ||
| 323 | |||
| 324 | + NSMutableArray<UIBarButtonItem *> *subviewLeftItems = [NSMutableArray array]; | ||
| 325 | + NSMutableArray<UIBarButtonItem *> *subviewRightItems = [NSMutableArray array]; | ||
| 326 | for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) { | ||
| 327 | // This code should be kept in sync on Fabric with analogous switch statement in | ||
| 328 | // `- [RNSScreenStackHeaderConfig replaceNavigationBarViewsWithSnapshotOfSubview:]` method. | ||
| 329 | switch (subview.type) { | ||
| 330 | case RNSScreenStackHeaderSubviewTypeLeft: { | ||
| 331 | - NSArray<UIBarButtonItem *> *currentItems = navitem.leftBarButtonItems ?: @[]; | ||
| 332 | - NSMutableArray<UIBarButtonItem *> *mutableItems = [currentItems mutableCopy]; | ||
| 333 | - [mutableItems addObject:[subview getUIBarButtonItem]]; | ||
| 334 | - navitem.leftBarButtonItems = mutableItems; | ||
| 335 | + [subviewLeftItems addObject:[subview getUIBarButtonItem]]; | ||
| 336 | break; | ||
| 337 | } | ||
| 338 | case RNSScreenStackHeaderSubviewTypeRight: { | ||
| 339 | - NSArray<UIBarButtonItem *> *currentItems = navitem.rightBarButtonItems ?: @[]; | ||
| 340 | - NSMutableArray<UIBarButtonItem *> *mutableItems = [currentItems mutableCopy]; | ||
| 341 | - [mutableItems addObject:[subview getUIBarButtonItem]]; | ||
| 342 | - navitem.rightBarButtonItems = mutableItems; | ||
| 343 | + [subviewRightItems addObject:[subview getUIBarButtonItem]]; | ||
| 344 | break; | ||
| 345 | } | ||
| 346 | case RNSScreenStackHeaderSubviewTypeCenter: | ||
| 347 | @@ -648,10 +693,479 @@ + (void)updateViewController:(UIViewController *)vc | ||
| 348 | // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). | ||
| 349 | // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) | ||
| 350 | navitem.title = config.title; | ||
| 351 | - navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems | ||
| 352 | - withCurrentItems:navitem.leftBarButtonItems]; | ||
| 353 | - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems | ||
| 354 | - withCurrentItems:navitem.rightBarButtonItems]; | ||
| 355 | + // Retain the config in the key so its address cannot be reused while cached. | ||
| 356 | + // Items capture this config's event emitter in their press handlers, so a | ||
| 357 | + // remounted header-config view with value-equal configs must still rebuild | ||
| 358 | + // instead of dispatching presses into the old config's emitter. | ||
| 359 | + // getUIBarButtonItem is stable for the lifetime of its React header | ||
| 360 | + // subview. Include those native identities in the key so React-backed | ||
| 361 | + // items can use the same cache without reusing a group after its view was | ||
| 362 | + // replaced. | ||
| 363 | + // A bar button item can belong to only one group. Even if the item itself | ||
| 364 | + // is reused, constructing a new group removes it from the old group that | ||
| 365 | + // UIKit may still be animating. Preserve each unchanged placement's group, | ||
| 366 | + // including the empty leading group beside the system back button. | ||
| 367 | +#if !TARGET_OS_TV | ||
| 368 | + if (@available(iOS 16.0, *)) { | ||
| 369 | + NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; | ||
| 370 | + NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; | ||
| 371 | + NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; | ||
| 372 | + if (RNSUpdateHeaderItemsCache( | ||
| 373 | + navitem, &RNSAppliedLeadingHeaderItemsKey, @[ config, headerLeftConfigs, subviewLeftItems ])) { | ||
| 374 | + NSArray *items = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems | ||
| 375 | + withCurrentItems:subviewLeftItems | ||
| 376 | + navigationItem:navitem | ||
| 377 | + allowsSearchBarPlacement:NO]; | ||
| 378 | + navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:items]; | ||
| 379 | + } | ||
| 380 | + if (RNSUpdateHeaderItemsCache( | ||
| 381 | + navitem, &RNSAppliedTrailingHeaderItemsKey, @[ config, headerRightConfigs, subviewRightItems ])) { | ||
| 382 | + NSArray *items = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems | ||
| 383 | + withCurrentItems:subviewRightItems | ||
| 384 | + navigationItem:navitem | ||
| 385 | + allowsSearchBarPlacement:NO]; | ||
| 386 | + navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:items]; | ||
| 387 | + } | ||
| 388 | + if (@available(iOS 26.0, *)) { | ||
| 389 | + if (RNSUpdateHeaderItemsCache(navitem, &RNSAppliedCenterHeaderItemsKey, @[ config, headerCenterConfigs ])) { | ||
| 390 | + NSArray *items = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems | ||
| 391 | + withCurrentItems:@[] | ||
| 392 | + navigationItem:navitem | ||
| 393 | + allowsSearchBarPlacement:NO]; | ||
| 394 | + navitem.centerItemGroups = [config barButtonItemGroupsFromItems:items]; | ||
| 395 | + } | ||
| 396 | + } | ||
| 397 | + } else | ||
| 398 | +#endif | ||
| 399 | + { | ||
| 400 | + NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems | ||
| 401 | + withCurrentItems:subviewLeftItems | ||
| 402 | + navigationItem:navitem | ||
| 403 | + allowsSearchBarPlacement:NO]; | ||
| 404 | + NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems | ||
| 405 | + withCurrentItems:subviewRightItems | ||
| 406 | + navigationItem:navitem | ||
| 407 | + allowsSearchBarPlacement:NO]; | ||
| 408 | + navitem.leftBarButtonItems = leftBarButtonItems; | ||
| 409 | + navitem.rightBarButtonItems = rightBarButtonItems; | ||
| 410 | + } | ||
| 411 | + NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil; | ||
| 412 | + NSMutableArray<NSDictionary<NSString *, id> *> *nonMailSearchToolbarConfigs = [NSMutableArray array]; | ||
| 413 | + for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) { | ||
| 414 | + if (toolbarConfig[@"mailSearchToolbar"]) { | ||
| 415 | + if (mailSearchToolbarConfig == nil) { | ||
| 416 | + mailSearchToolbarConfig = toolbarConfig; | ||
| 417 | + } | ||
| 418 | + } else { | ||
| 419 | + [nonMailSearchToolbarConfigs addObject:toolbarConfig]; | ||
| 420 | + } | ||
| 421 | + } | ||
| 422 | + NSArray<NSDictionary<NSString *, id> *> *navigationToolbarConfigs = [nonMailSearchToolbarConfigs copy]; | ||
| 423 | + if (mailSearchToolbarConfig == nil) { | ||
| 424 | + for (NSDictionary<NSString *, id> *rightConfig in config.headerRightBarButtonItems) { | ||
| 425 | + if ([rightConfig[@"bottomMailSearchToolbar"] boolValue]) { | ||
| 426 | + mailSearchToolbarConfig = rightConfig; | ||
| 427 | + break; | ||
| 428 | + } | ||
| 429 | + } | ||
| 430 | + } | ||
| 431 | + | ||
| 432 | + static NSInteger const RNSMailSearchToolbarViewTag = 260628; | ||
| 433 | + UIView *chromeHostView = vc.view; | ||
| 434 | + UIView *existingMailSearchToolbar = [chromeHostView viewWithTag:RNSMailSearchToolbarViewTag]; | ||
| 435 | + if (existingMailSearchToolbar == nil) { | ||
| 436 | + existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag]; | ||
| 437 | + } | ||
| 438 | + // Reuse the live toolbar when nothing it was built from changed: rebuilding | ||
| 439 | + // replaces the fallback UISearchTextField with a fresh empty one, dropping | ||
| 440 | + // the user's in-progress search text and first responder on every unrelated | ||
| 441 | + // header update. Keyed on the config instance (the button/search blocks | ||
| 442 | + // capture its event emitter), the toolbar config values, and the host width | ||
| 443 | + // (the width constraint constant is resolved from it at build time). | ||
| 444 | + static char RNSAppliedMailSearchToolbarConfigKey; | ||
| 445 | + NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil | ||
| 446 | + ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ] | ||
| 447 | + : nil; | ||
| 448 | + BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil && | ||
| 449 | + [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey) | ||
| 450 | + isEqual:mailSearchToolbarKey]; | ||
| 451 | + if (!reuseMailSearchToolbar) { | ||
| 452 | + [existingMailSearchToolbar removeFromSuperview]; | ||
| 453 | + } | ||
| 454 | + | ||
| 455 | + if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) { | ||
| 456 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 457 | + if (@available(iOS 26.0, *)) { | ||
| 458 | + CGFloat horizontalInset = 18.0; | ||
| 459 | + NSNumber *width = mailSearchToolbarConfig[@"width"]; | ||
| 460 | + CGFloat hostWidth = chromeHostView.bounds.size.width; | ||
| 461 | + if (hostWidth <= 0.0) { | ||
| 462 | + hostWidth = vc.view.bounds.size.width; | ||
| 463 | + } | ||
| 464 | + if (hostWidth <= 0.0) { | ||
| 465 | + hostWidth = [UIScreen mainScreen].bounds.size.width; | ||
| 466 | + } | ||
| 467 | + CGFloat fallbackWidth = MIN(560.0, MAX(300.0, hostWidth - (horizontalInset * 2.0))); | ||
| 468 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : fallbackWidth; | ||
| 469 | + if (hostWidth > 0.0) { | ||
| 470 | + resolvedWidth = MIN(resolvedWidth, MAX(260.0, hostWidth - 12.0)); | ||
| 471 | + } | ||
| 472 | + | ||
| 473 | + CGFloat toolbarHeight = 48.0; | ||
| 474 | + CGFloat toolbarRadius = toolbarHeight / 2.0; | ||
| 475 | + CGFloat buttonSize = toolbarHeight; | ||
| 476 | + CGFloat sideButtonReserve = buttonSize + 8.0; | ||
| 477 | + CGFloat searchFieldHeight = 42.0; | ||
| 478 | + CGFloat toolbarBottomSpacing = 4.0; | ||
| 479 | + UIFont *searchFont = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle3]; | ||
| 480 | + NSDictionary<NSAttributedStringKey, id> *placeholderAttributes = @{ | ||
| 481 | + NSForegroundColorAttributeName : UIColor.secondaryLabelColor, | ||
| 482 | + NSFontAttributeName : searchFont, | ||
| 483 | + }; | ||
| 484 | + UIKeyboardLayoutGuide *keyboardLayoutGuide = chromeHostView.keyboardLayoutGuide; | ||
| 485 | + | ||
| 486 | + UIView *toolbarHost = [[UIView alloc] init]; | ||
| 487 | + toolbarHost.tag = RNSMailSearchToolbarViewTag; | ||
| 488 | + objc_setAssociatedObject( | ||
| 489 | + toolbarHost, | ||
| 490 | + &RNSAppliedMailSearchToolbarConfigKey, | ||
| 491 | + mailSearchToolbarKey, | ||
| 492 | + OBJC_ASSOCIATION_RETAIN_NONATOMIC); | ||
| 493 | + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 494 | + [chromeHostView addSubview:toolbarHost]; | ||
| 495 | + // The screen stays mounted beneath pushed routes, so its keyboard layout | ||
| 496 | + // guide can track a keyboard owned by another screen. Keep the toolbar at | ||
| 497 | + // rest unless its own search field is editing. | ||
| 498 | + NSLayoutConstraint *keyboardAvoidConstraint = | ||
| 499 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor | ||
| 500 | + constant:-toolbarBottomSpacing]; | ||
| 501 | + keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; | ||
| 502 | + NSLayoutConstraint *restingBottomConstraint = | ||
| 503 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor | ||
| 504 | + constant:-toolbarBottomSpacing]; | ||
| 505 | + restingBottomConstraint.priority = UILayoutPriorityDefaultHigh; | ||
| 506 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 507 | + [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], | ||
| 508 | + [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor | ||
| 509 | + constant:-toolbarBottomSpacing], | ||
| 510 | + keyboardAvoidConstraint, | ||
| 511 | + restingBottomConstraint, | ||
| 512 | + [toolbarHost.widthAnchor constraintEqualToConstant:resolvedWidth], | ||
| 513 | + [toolbarHost.heightAnchor constraintEqualToConstant:toolbarHeight], | ||
| 514 | + ]]; | ||
| 515 | + [chromeHostView bringSubviewToFront:toolbarHost]; | ||
| 516 | + | ||
| 517 | + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; | ||
| 518 | + glassEffect.interactive = YES; | ||
| 519 | + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; | ||
| 520 | + glassView.clipsToBounds = YES; | ||
| 521 | + glassView.layer.cornerRadius = toolbarRadius; | ||
| 522 | + glassView.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 523 | + [toolbarHost addSubview:glassView]; | ||
| 524 | + BOOL hasFilterButton = | ||
| 525 | + mailSearchToolbarConfig[@"filterButtonId"] != nil || mailSearchToolbarConfig[@"filterMenu"] != nil; | ||
| 526 | + BOOL hasComposeButton = | ||
| 527 | + mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil; | ||
| 528 | + CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0; | ||
| 529 | + CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0; | ||
| 530 | + NSLayoutConstraint *glassLeadingConstraint = | ||
| 531 | + [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset]; | ||
| 532 | + NSLayoutConstraint *glassTrailingConstraint = | ||
| 533 | + [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset]; | ||
| 534 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 535 | + glassLeadingConstraint, | ||
| 536 | + glassTrailingConstraint, | ||
| 537 | + [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], | ||
| 538 | + [glassView.heightAnchor constraintEqualToConstant:toolbarHeight], | ||
| 539 | + ]]; | ||
| 540 | + | ||
| 541 | + void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) { | ||
| 542 | + auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 543 | + config->_eventEmitter); | ||
| 544 | + if (eventEmitter && buttonId) { | ||
| 545 | + eventEmitter->onPressHeaderBarButtonItem( | ||
| 546 | + facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{ | ||
| 547 | + .buttonId = std::string([buttonId UTF8String])}); | ||
| 548 | + } | ||
| 549 | + }; | ||
| 550 | + | ||
| 551 | + void (^emitMenuPress)(NSString *) = ^(NSString *menuId) { | ||
| 552 | + auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 553 | + config->_eventEmitter); | ||
| 554 | + if (eventEmitter && menuId) { | ||
| 555 | + eventEmitter->onPressHeaderBarButtonMenuItem( | ||
| 556 | + facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{ | ||
| 557 | + .menuId = std::string([menuId UTF8String])}); | ||
| 558 | + } | ||
| 559 | + }; | ||
| 560 | + | ||
| 561 | + UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) = | ||
| 562 | + ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) { | ||
| 563 | + UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; | ||
| 564 | + configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; | ||
| 565 | + configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"]; | ||
| 566 | + configuration.baseForegroundColor = UIColor.labelColor; | ||
| 567 | + UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; | ||
| 568 | + if (menuConfig != nil) { | ||
| 569 | + button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig | ||
| 570 | + menuAction:emitMenuPress | ||
| 571 | + imageLoader:config->_imageLoader]; | ||
| 572 | + button.showsMenuAsPrimaryAction = YES; | ||
| 573 | + } else if (buttonId != nil) { | ||
| 574 | + UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { | ||
| 575 | + emitButtonPress(buttonId); | ||
| 576 | + }]; | ||
| 577 | + [button addAction:action forControlEvents:UIControlEventTouchUpInside]; | ||
| 578 | + } | ||
| 579 | + return button; | ||
| 580 | + }; | ||
| 581 | + | ||
| 582 | + BOOL useFallbackSearchField = [mailSearchToolbarConfig[@"useFallbackSearchField"] boolValue]; | ||
| 583 | + UISearchBar *searchBar = | ||
| 584 | + !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil; | ||
| 585 | + NSString *placeholder = mailSearchToolbarConfig[@"placeholder"]; | ||
| 586 | + UITextField *resolvedSearchTextField = nil; | ||
| 587 | + if (searchBar != nil) { | ||
| 588 | + if (placeholder != nil) { | ||
| 589 | + searchBar.placeholder = placeholder; | ||
| 590 | + } | ||
| 591 | + searchBar.hidden = NO; | ||
| 592 | + searchBar.alpha = 1.0; | ||
| 593 | + searchBar.userInteractionEnabled = YES; | ||
| 594 | + searchBar.searchBarStyle = UISearchBarStyleMinimal; | ||
| 595 | + searchBar.backgroundImage = [UIImage new]; | ||
| 596 | + searchBar.searchTextField.hidden = NO; | ||
| 597 | + searchBar.searchTextField.alpha = 1.0; | ||
| 598 | + searchBar.searchTextField.backgroundColor = UIColor.clearColor; | ||
| 599 | + searchBar.searchTextField.font = searchFont; | ||
| 600 | + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; | ||
| 601 | + searchBar.searchTextField.textColor = UIColor.labelColor; | ||
| 602 | + searchBar.searchTextField.tintColor = UIColor.labelColor; | ||
| 603 | + resolvedSearchTextField = searchBar.searchTextField; | ||
| 604 | + if (placeholder != nil) { | ||
| 605 | + searchBar.searchTextField.attributedPlaceholder = | ||
| 606 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; | ||
| 607 | + } | ||
| 608 | + searchBar.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 609 | + [glassView.contentView addSubview:searchBar]; | ||
| 610 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 611 | + [searchBar.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:8.0], | ||
| 612 | + [searchBar.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-8.0], | ||
| 613 | + [searchBar.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor], | ||
| 614 | + [searchBar.heightAnchor constraintEqualToConstant:searchFieldHeight], | ||
| 615 | + ]]; | ||
| 616 | + } else { | ||
| 617 | + UISearchTextField *searchField = [UISearchTextField new]; | ||
| 618 | + if (placeholder != nil) { | ||
| 619 | + searchField.placeholder = placeholder; | ||
| 620 | + searchField.attributedPlaceholder = | ||
| 621 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; | ||
| 622 | + } | ||
| 623 | + searchField.borderStyle = UITextBorderStyleNone; | ||
| 624 | + searchField.font = searchFont; | ||
| 625 | + searchField.adjustsFontForContentSizeCategory = YES; | ||
| 626 | + searchField.textColor = UIColor.labelColor; | ||
| 627 | + searchField.tintColor = UIColor.labelColor; | ||
| 628 | + resolvedSearchTextField = searchField; | ||
| 629 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 630 | + [glassView.contentView addSubview:searchField]; | ||
| 631 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 632 | + [searchField.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:14.0], | ||
| 633 | + [searchField.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-14.0], | ||
| 634 | + [searchField.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor], | ||
| 635 | + [searchField.heightAnchor constraintEqualToConstant:searchFieldHeight], | ||
| 636 | + ]]; | ||
| 637 | + } | ||
| 638 | + | ||
| 639 | + // A bare UISearchTextField keeps UITextField defaults (sentence | ||
| 640 | + // capitalization, autocorrect), unlike UISearchBar. Search queries are | ||
| 641 | + // identifiers, so match the autoCapitalize "none" the JS fallbacks use. | ||
| 642 | + if (resolvedSearchTextField != nil) { | ||
| 643 | + resolvedSearchTextField.autocapitalizationType = UITextAutocapitalizationTypeNone; | ||
| 644 | + resolvedSearchTextField.autocorrectionType = UITextAutocorrectionTypeNo; | ||
| 645 | + resolvedSearchTextField.spellCheckingType = UITextSpellCheckingTypeNo; | ||
| 646 | + } | ||
| 647 | + | ||
| 648 | + NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"]; | ||
| 649 | + NSString *searchTextChangeActionIdentifier = @"org.react-native-screens.mail-search-toolbar.text-change"; | ||
| 650 | + if (resolvedSearchTextField != nil) { | ||
| 651 | + [resolvedSearchTextField removeActionForIdentifier:searchTextChangeActionIdentifier | ||
| 652 | + forControlEvents:UIControlEventEditingChanged]; | ||
| 653 | + } | ||
| 654 | + if (searchTextChangeId != nil && resolvedSearchTextField != nil) { | ||
| 655 | + [resolvedSearchTextField | ||
| 656 | + addAction:[UIAction actionWithTitle:@"" | ||
| 657 | + image:nil | ||
| 658 | + identifier:searchTextChangeActionIdentifier | ||
| 659 | + handler:^(__kindof UIAction *_Nonnull action) { | ||
| 660 | + UITextField *field = (UITextField *)action.sender; | ||
| 661 | + emitButtonPress( | ||
| 662 | + [NSString stringWithFormat:@"%@:%@", searchTextChangeId, field.text ?: @""]); | ||
| 663 | + }] | ||
| 664 | + forControlEvents:UIControlEventEditingChanged]; | ||
| 665 | + } | ||
| 666 | + | ||
| 667 | + UIButton *filterButton = nil; | ||
| 668 | + if (hasFilterButton) { | ||
| 669 | + filterButton = makeGlassButton( | ||
| 670 | + mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", | ||
| 671 | + mailSearchToolbarConfig[@"filterButtonId"], | ||
| 672 | + mailSearchToolbarConfig[@"filterMenu"]); | ||
| 673 | + filterButton.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 674 | + [toolbarHost addSubview:filterButton]; | ||
| 675 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 676 | + [filterButton.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor], | ||
| 677 | + [filterButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], | ||
| 678 | + [filterButton.widthAnchor constraintEqualToConstant:buttonSize], | ||
| 679 | + [filterButton.heightAnchor constraintEqualToConstant:buttonSize], | ||
| 680 | + ]]; | ||
| 681 | + } | ||
| 682 | + | ||
| 683 | + UIButton *composeButton = nil; | ||
| 684 | + if (hasComposeButton) { | ||
| 685 | + composeButton = makeGlassButton( | ||
| 686 | + mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil", | ||
| 687 | + mailSearchToolbarConfig[@"composeButtonId"], | ||
| 688 | + mailSearchToolbarConfig[@"composeMenu"]); | ||
| 689 | + composeButton.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 690 | + [toolbarHost addSubview:composeButton]; | ||
| 691 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 692 | + [composeButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor], | ||
| 693 | + [composeButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], | ||
| 694 | + [composeButton.widthAnchor constraintEqualToConstant:buttonSize], | ||
| 695 | + [composeButton.heightAnchor constraintEqualToConstant:buttonSize], | ||
| 696 | + ]]; | ||
| 697 | + } | ||
| 698 | + | ||
| 699 | + BOOL showsSearchDismissButton = | ||
| 700 | + [mailSearchToolbarConfig[@"showsSearchDismissButton"] boolValue] && resolvedSearchTextField != nil; | ||
| 701 | + UIButton *searchDismissButton = nil; | ||
| 702 | + if (showsSearchDismissButton) { | ||
| 703 | + searchDismissButton = makeGlassButton(@"xmark", nil, nil); | ||
| 704 | + searchDismissButton.accessibilityLabel = @"Dismiss search keyboard"; | ||
| 705 | + searchDismissButton.alpha = 0.0; | ||
| 706 | + searchDismissButton.hidden = YES; | ||
| 707 | + searchDismissButton.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 708 | + [toolbarHost addSubview:searchDismissButton]; | ||
| 709 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 710 | + [searchDismissButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor], | ||
| 711 | + [searchDismissButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], | ||
| 712 | + [searchDismissButton.widthAnchor constraintEqualToConstant:buttonSize], | ||
| 713 | + [searchDismissButton.heightAnchor constraintEqualToConstant:buttonSize], | ||
| 714 | + ]]; | ||
| 715 | + __weak UITextField *weakSearchTextField = resolvedSearchTextField; | ||
| 716 | + [searchDismissButton | ||
| 717 | + addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { | ||
| 718 | + [weakSearchTextField resignFirstResponder]; | ||
| 719 | + }] | ||
| 720 | + forControlEvents:UIControlEventTouchUpInside]; | ||
| 721 | + } | ||
| 722 | + | ||
| 723 | + __weak UIButton *weakFilterButton = filterButton; | ||
| 724 | + __weak UIButton *weakComposeButton = composeButton; | ||
| 725 | + __weak UIButton *weakSearchDismissButton = searchDismissButton; | ||
| 726 | + __weak NSLayoutConstraint *weakGlassLeadingConstraint = glassLeadingConstraint; | ||
| 727 | + __weak NSLayoutConstraint *weakGlassTrailingConstraint = glassTrailingConstraint; | ||
| 728 | + __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint; | ||
| 729 | + __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint; | ||
| 730 | + __weak UIView *weakToolbarHost = toolbarHost; | ||
| 731 | + __weak UITextField *weakSearchTextField = resolvedSearchTextField; | ||
| 732 | + void (^setSearchEditingAppearance)(BOOL, BOOL) = ^(BOOL isEditing, BOOL animated) { | ||
| 733 | + weakKeyboardAvoidConstraint.priority = | ||
| 734 | + isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow; | ||
| 735 | + weakRestingBottomConstraint.priority = | ||
| 736 | + isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh; | ||
| 737 | + | ||
| 738 | + if (showsSearchDismissButton) { | ||
| 739 | + if (isEditing) { | ||
| 740 | + weakSearchDismissButton.hidden = NO; | ||
| 741 | + } else { | ||
| 742 | + weakFilterButton.hidden = NO; | ||
| 743 | + weakComposeButton.hidden = NO; | ||
| 744 | + } | ||
| 745 | + weakGlassLeadingConstraint.constant = isEditing ? 0.0 : glassLeadingInset; | ||
| 746 | + weakGlassTrailingConstraint.constant = isEditing ? -sideButtonReserve : glassTrailingInset; | ||
| 747 | + | ||
| 748 | + void (^changes)(void) = ^{ | ||
| 749 | + weakFilterButton.alpha = isEditing ? 0.0 : 1.0; | ||
| 750 | + weakComposeButton.alpha = isEditing ? 0.0 : 1.0; | ||
| 751 | + weakSearchDismissButton.alpha = isEditing ? 1.0 : 0.0; | ||
| 752 | + [weakToolbarHost layoutIfNeeded]; | ||
| 753 | + }; | ||
| 754 | + void (^completion)(BOOL) = ^(BOOL finished) { | ||
| 755 | + if (!finished || weakSearchTextField.isFirstResponder != isEditing) { | ||
| 756 | + return; | ||
| 757 | + } | ||
| 758 | + weakFilterButton.hidden = isEditing; | ||
| 759 | + weakComposeButton.hidden = isEditing; | ||
| 760 | + weakSearchDismissButton.hidden = !isEditing; | ||
| 761 | + }; | ||
| 762 | + if (animated) { | ||
| 763 | + [UIView animateWithDuration:0.2 | ||
| 764 | + delay:0.0 | ||
| 765 | + options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut | ||
| 766 | + animations:changes | ||
| 767 | + completion:completion]; | ||
| 768 | + } else { | ||
| 769 | + changes(); | ||
| 770 | + completion(YES); | ||
| 771 | + } | ||
| 772 | + } | ||
| 773 | + [weakToolbarHost setNeedsLayout]; | ||
| 774 | + }; | ||
| 775 | + | ||
| 776 | + setSearchEditingAppearance(resolvedSearchTextField.isFirstResponder, NO); | ||
| 777 | + NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin"; | ||
| 778 | + NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end"; | ||
| 779 | + [resolvedSearchTextField removeActionForIdentifier:beginActionIdentifier | ||
| 780 | + forControlEvents:UIControlEventEditingDidBegin]; | ||
| 781 | + [resolvedSearchTextField removeActionForIdentifier:endActionIdentifier | ||
| 782 | + forControlEvents:UIControlEventEditingDidEnd]; | ||
| 783 | + [resolvedSearchTextField | ||
| 784 | + addAction:[UIAction actionWithTitle:@"" | ||
| 785 | + image:nil | ||
| 786 | + identifier:beginActionIdentifier | ||
| 787 | + handler:^(__kindof UIAction *_Nonnull action) { | ||
| 788 | + setSearchEditingAppearance(YES, YES); | ||
| 789 | + }] | ||
| 790 | + forControlEvents:UIControlEventEditingDidBegin]; | ||
| 791 | + [resolvedSearchTextField | ||
| 792 | + addAction:[UIAction actionWithTitle:@"" | ||
| 793 | + image:nil | ||
| 794 | + identifier:endActionIdentifier | ||
| 795 | + handler:^(__kindof UIAction *_Nonnull action) { | ||
| 796 | + setSearchEditingAppearance(NO, YES); | ||
| 797 | + }] | ||
| 798 | + forControlEvents:UIControlEventEditingDidEnd]; | ||
| 799 | + } | ||
| 800 | +#endif | ||
| 801 | + } | ||
| 802 | + | ||
| 803 | + NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[]; | ||
| 804 | + // Same reuse rule as the header item groups above (including the config | ||
| 805 | + // identity — cached toolbar items capture this config's event emitter). | ||
| 806 | + // The top-view-controller and hidden-state checks scope the skip to | ||
| 807 | + // same-screen refreshes, so transitions between screens with different | ||
| 808 | + // toolbars still reapply. | ||
| 809 | + NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ]; | ||
| 810 | + BOOL reuseToolbarItems = navctr.topViewController == vc && | ||
| 811 | + navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) && | ||
| 812 | + [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarCacheKey]; | ||
| 813 | + if (navctr.topViewController == vc && !reuseToolbarItems) { | ||
| 814 | + NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs | ||
| 815 | + withCurrentItems:@[] | ||
| 816 | + navigationItem:navitem | ||
| 817 | + allowsSearchBarPlacement:YES]; | ||
| 818 | + if (toolbarItems.count > 0) { | ||
| 819 | + vc.toolbarItems = toolbarItems; | ||
| 820 | + [navctr setToolbarHidden:NO animated:animated]; | ||
| 821 | + } else { | ||
| 822 | + vc.toolbarItems = nil; | ||
| 823 | + [navctr setToolbarHidden:YES animated:animated]; | ||
| 824 | + } | ||
| 825 | + objc_setAssociatedObject( | ||
| 826 | + navitem, &RNSAppliedToolbarConfigsKey, toolbarCacheKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); | ||
| 827 | + } | ||
| 828 | |||
| 829 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items | ||
| 830 | // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). | ||
| 831 | @@ -784,6 +1298,8 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | ||
| 832 | |||
| 833 | - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts | ||
| 834 | withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems | ||
| 835 | + navigationItem:(UINavigationItem *)navitem | ||
| 836 | + allowsSearchBarPlacement:(BOOL)allowsSearchBarPlacement | ||
| 837 | { | ||
| 838 | if (dicts.count == 0) { | ||
| 839 | return currentItems; | ||
| 840 | @@ -792,7 +1308,200 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | ||
| 841 | [items addObjectsFromArray:currentItems]; | ||
| 842 | for (NSUInteger i = 0; i < dicts.count; i++) { | ||
| 843 | NSDictionary *dict = dicts[i]; | ||
| 844 | - if (dict[@"buttonId"] || dict[@"menu"]) { | ||
| 845 | + if (dict[@"mailSearchToolbar"]) { | ||
| 846 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 847 | + if (@available(iOS 26.0, *)) { | ||
| 848 | + NSNumber *width = dict[@"width"]; | ||
| 849 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : MAX(320.0, [UIScreen mainScreen].bounds.size.width - 56.0); | ||
| 850 | + CGFloat resolvedHeight = 58.0; | ||
| 851 | + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)]; | ||
| 852 | + container.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 853 | + [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; | ||
| 854 | + [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES; | ||
| 855 | + | ||
| 856 | + UIStackView *stackView = [[UIStackView alloc] initWithFrame:container.bounds]; | ||
| 857 | + stackView.axis = UILayoutConstraintAxisHorizontal; | ||
| 858 | + stackView.alignment = UIStackViewAlignmentCenter; | ||
| 859 | + stackView.distribution = UIStackViewDistributionFill; | ||
| 860 | + stackView.spacing = 10.0; | ||
| 861 | + stackView.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 862 | + [container addSubview:stackView]; | ||
| 863 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 864 | + [stackView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], | ||
| 865 | + [stackView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], | ||
| 866 | + [stackView.centerYAnchor constraintEqualToAnchor:container.centerYAnchor], | ||
| 867 | + [stackView.heightAnchor constraintEqualToConstant:50.0], | ||
| 868 | + ]]; | ||
| 869 | + | ||
| 870 | + void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) { | ||
| 871 | + auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 872 | + self->_eventEmitter); | ||
| 873 | + if (eventEmitter && buttonId) { | ||
| 874 | + eventEmitter->onPressHeaderBarButtonItem( | ||
| 875 | + facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{ | ||
| 876 | + .buttonId = std::string([buttonId UTF8String])}); | ||
| 877 | + } | ||
| 878 | + }; | ||
| 879 | + | ||
| 880 | + void (^emitMenuPress)(NSString *) = ^(NSString *menuId) { | ||
| 881 | + auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 882 | + self->_eventEmitter); | ||
| 883 | + if (eventEmitter && menuId) { | ||
| 884 | + eventEmitter->onPressHeaderBarButtonMenuItem( | ||
| 885 | + facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{ | ||
| 886 | + .menuId = std::string([menuId UTF8String])}); | ||
| 887 | + } | ||
| 888 | + }; | ||
| 889 | + | ||
| 890 | + UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) = | ||
| 891 | + ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) { | ||
| 892 | + UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; | ||
| 893 | + configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; | ||
| 894 | + configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"]; | ||
| 895 | + | ||
| 896 | + UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; | ||
| 897 | + button.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 898 | + [button.widthAnchor constraintEqualToConstant:58.0].active = YES; | ||
| 899 | + [button.heightAnchor constraintEqualToConstant:50.0].active = YES; | ||
| 900 | + if (menuConfig != nil) { | ||
| 901 | + button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig | ||
| 902 | + menuAction:emitMenuPress | ||
| 903 | + imageLoader:self->_imageLoader]; | ||
| 904 | + button.showsMenuAsPrimaryAction = YES; | ||
| 905 | + } else if (buttonId != nil) { | ||
| 906 | + UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { | ||
| 907 | + emitButtonPress(buttonId); | ||
| 908 | + }]; | ||
| 909 | + [button addAction:action forControlEvents:UIControlEventTouchUpInside]; | ||
| 910 | + } | ||
| 911 | + return button; | ||
| 912 | + }; | ||
| 913 | + | ||
| 914 | + BOOL hasFilterButton = dict[@"filterButtonId"] != nil || dict[@"filterMenu"] != nil; | ||
| 915 | + BOOL hasComposeButton = dict[@"composeButtonId"] != nil || dict[@"composeMenu"] != nil; | ||
| 916 | + | ||
| 917 | + if (hasFilterButton) { | ||
| 918 | + UIButton *filterButton = makeGlassButton( | ||
| 919 | + dict[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", | ||
| 920 | + dict[@"filterButtonId"], | ||
| 921 | + dict[@"filterMenu"]); | ||
| 922 | + [stackView addArrangedSubview:filterButton]; | ||
| 923 | + } | ||
| 924 | + | ||
| 925 | + UISearchBar *searchBar = navitem.searchController != nil ? navitem.searchController.searchBar : [UISearchBar new]; | ||
| 926 | + NSString *placeholder = dict[@"placeholder"]; | ||
| 927 | + if (placeholder != nil) { | ||
| 928 | + searchBar.placeholder = placeholder; | ||
| 929 | + } | ||
| 930 | + searchBar.searchBarStyle = UISearchBarStyleMinimal; | ||
| 931 | + searchBar.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 932 | + [searchBar.heightAnchor constraintEqualToConstant:50.0].active = YES; | ||
| 933 | + [stackView addArrangedSubview:searchBar]; | ||
| 934 | + | ||
| 935 | + if (hasComposeButton) { | ||
| 936 | + UIButton *composeButton = makeGlassButton( | ||
| 937 | + dict[@"composeSystemImageName"] ?: @"square.and.pencil", | ||
| 938 | + dict[@"composeButtonId"], | ||
| 939 | + dict[@"composeMenu"]); | ||
| 940 | + [stackView addArrangedSubview:composeButton]; | ||
| 941 | + } | ||
| 942 | + | ||
| 943 | + UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container]; | ||
| 944 | + [items addObject:item]; | ||
| 945 | + } | ||
| 946 | +#endif | ||
| 947 | + } else if (dict[@"searchField"]) { | ||
| 948 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 949 | + if (@available(iOS 26.0, *)) { | ||
| 950 | + NSNumber *width = dict[@"width"]; | ||
| 951 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : 280.0; | ||
| 952 | + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, 44.0)]; | ||
| 953 | + container.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 954 | + [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; | ||
| 955 | + [container.heightAnchor constraintEqualToConstant:44.0].active = YES; | ||
| 956 | + | ||
| 957 | + UISearchTextField *searchField = [UISearchTextField new]; | ||
| 958 | + NSString *placeholder = dict[@"placeholder"]; | ||
| 959 | + if (placeholder != nil) { | ||
| 960 | + searchField.placeholder = placeholder; | ||
| 961 | + } | ||
| 962 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 963 | + [container addSubview:searchField]; | ||
| 964 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 965 | + [searchField.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], | ||
| 966 | + [searchField.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], | ||
| 967 | + [searchField.centerYAnchor constraintEqualToAnchor:container.centerYAnchor], | ||
| 968 | + [searchField.heightAnchor constraintEqualToConstant:40.0], | ||
| 969 | + ]]; | ||
| 970 | + | ||
| 971 | + UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container]; | ||
| 972 | + [items addObject:item]; | ||
| 973 | + } | ||
| 974 | +#endif | ||
| 975 | + } else if (dict[@"searchBarPlacement"]) { | ||
| 976 | + if (!allowsSearchBarPlacement) { | ||
| 977 | + continue; | ||
| 978 | + } | ||
| 979 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 980 | + if (@available(iOS 26.0, *)) { | ||
| 981 | + NSNumber *width = dict[@"width"]; | ||
| 982 | + UIBarButtonItem *item = nil; | ||
| 983 | + if ([dict[@"activatesSearchController"] boolValue]) { | ||
| 984 | + UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; | ||
| 985 | + configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; | ||
| 986 | + configuration.image = [UIImage systemImageNamed:@"magnifyingglass"]; | ||
| 987 | + configuration.baseForegroundColor = UIColor.labelColor; | ||
| 988 | + UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; | ||
| 989 | + button.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 990 | + [button.widthAnchor constraintEqualToConstant:56.0].active = YES; | ||
| 991 | + [button.heightAnchor constraintEqualToConstant:56.0].active = YES; | ||
| 992 | + UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { | ||
| 993 | + if (navitem.searchController != nil) { | ||
| 994 | + [navitem.searchController setActive:YES]; | ||
| 995 | + [navitem.searchController.searchBar becomeFirstResponder]; | ||
| 996 | + } | ||
| 997 | + }]; | ||
| 998 | + [button addAction:action forControlEvents:UIControlEventTouchUpInside]; | ||
| 999 | + item = [[UIBarButtonItem alloc] initWithCustomView:button]; | ||
| 1000 | + } else if ([dict[@"customView"] boolValue] && navitem.searchController != nil) { | ||
| 1001 | + UISearchBar *searchBar = navitem.searchController.searchBar; | ||
| 1002 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : searchBar.frame.size.width; | ||
| 1003 | + CGFloat resolvedHeight = searchBar.frame.size.height > 0 ? searchBar.frame.size.height : 44.0; | ||
| 1004 | + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)]; | ||
| 1005 | + container.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 1006 | + [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; | ||
| 1007 | + [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES; | ||
| 1008 | + searchBar.frame = container.bounds; | ||
| 1009 | + searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; | ||
| 1010 | + [container addSubview:searchBar]; | ||
| 1011 | + item = [[UIBarButtonItem alloc] initWithCustomView:container]; | ||
| 1012 | + } else { | ||
| 1013 | + item = navitem.searchBarPlacementBarButtonItem; | ||
| 1014 | + if (width != nil) { | ||
| 1015 | + item.width = width.doubleValue; | ||
| 1016 | + } | ||
| 1017 | + } | ||
| 1018 | + NSNumber *index = dict[@"index"]; | ||
| 1019 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 1020 | + if (@available(iOS 26.0, *)) { | ||
| 1021 | + NSNumber *sharesBackground = dict[@"sharesBackground"]; | ||
| 1022 | + if (sharesBackground != nil) { | ||
| 1023 | + item.sharesBackground = sharesBackground.boolValue; | ||
| 1024 | + } | ||
| 1025 | + NSNumber *hidesSharedBackground = dict[@"hidesSharedBackground"]; | ||
| 1026 | + if (hidesSharedBackground != nil) { | ||
| 1027 | + item.hidesSharedBackground = hidesSharedBackground.boolValue; | ||
| 1028 | + } | ||
| 1029 | + } | ||
| 1030 | +#endif | ||
| 1031 | + if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { | ||
| 1032 | + [items insertObject:item atIndex:index.integerValue]; | ||
| 1033 | + } else { | ||
| 1034 | + [items addObject:item]; | ||
| 1035 | + } | ||
| 1036 | + } | ||
| 1037 | +#endif | ||
| 1038 | + } else if (dict[@"buttonId"] || dict[@"menu"]) { | ||
| 1039 | RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict | ||
| 1040 | action:^(NSString *buttonId) { | ||
| 1041 | auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 1042 | @@ -814,19 +1523,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | ||
| 1043 | } | ||
| 1044 | imageLoader:_imageLoader]; | ||
| 1045 | NSNumber *index = dict[@"index"]; | ||
| 1046 | - if (index.integerValue < items.count) { | ||
| 1047 | + if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { | ||
| 1048 | [items insertObject:item atIndex:index.integerValue]; | ||
| 1049 | } else { | ||
| 1050 | [items addObject:item]; | ||
| 1051 | } | ||
| 1052 | } else if (dict[@"spacing"]) { | ||
| 1053 | - UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace | ||
| 1054 | - target:nil | ||
| 1055 | - action:nil]; | ||
| 1056 | + BOOL flexible = [dict[@"flexible"] boolValue]; | ||
| 1057 | + UIBarButtonItem *item = [[UIBarButtonItem alloc] | ||
| 1058 | + initWithBarButtonSystemItem:(flexible ? UIBarButtonSystemItemFlexibleSpace : UIBarButtonSystemItemFixedSpace) | ||
| 1059 | + target:nil | ||
| 1060 | + action:nil]; | ||
| 1061 | NSNumber *spacingValue = dict[@"spacing"]; | ||
| 1062 | - item.width = [spacingValue doubleValue]; | ||
| 1063 | + if (!flexible) { | ||
| 1064 | + item.width = [spacingValue doubleValue]; | ||
| 1065 | + } | ||
| 1066 | NSNumber *index = dict[@"index"]; | ||
| 1067 | - if (index.integerValue < items.count) { | ||
| 1068 | + if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { | ||
| 1069 | [items insertObject:item atIndex:index.integerValue]; | ||
| 1070 | } else { | ||
| 1071 | [items addObject:item]; | ||
| 1072 | @@ -836,6 +1549,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | ||
| 1073 | return items; | ||
| 1074 | } | ||
| 1075 | |||
| 1076 | +#if !TARGET_OS_TV | ||
| 1077 | +- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items | ||
| 1078 | +{ | ||
| 1079 | + if (items.count == 0) { | ||
| 1080 | + return @[]; | ||
| 1081 | + } | ||
| 1082 | + | ||
| 1083 | + NSMutableArray<UIBarButtonItemGroup *> *groups = [NSMutableArray array]; | ||
| 1084 | + NSMutableArray<UIBarButtonItem *> *sharedRun = [NSMutableArray array]; | ||
| 1085 | + | ||
| 1086 | + void (^flushSharedRun)(void) = ^{ | ||
| 1087 | + if (sharedRun.count == 0) { | ||
| 1088 | + return; | ||
| 1089 | + } | ||
| 1090 | + [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil | ||
| 1091 | + items:[sharedRun copy]]]; | ||
| 1092 | + [sharedRun removeAllObjects]; | ||
| 1093 | + }; | ||
| 1094 | + | ||
| 1095 | + for (UIBarButtonItem *item in items) { | ||
| 1096 | + BOOL shouldShareBackground = NO; | ||
| 1097 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 1098 | + if (@available(iOS 26.0, *)) { | ||
| 1099 | + shouldShareBackground = item.sharesBackground && !item.hidesSharedBackground; | ||
| 1100 | + } | ||
| 1101 | +#endif | ||
| 1102 | + | ||
| 1103 | + if (shouldShareBackground) { | ||
| 1104 | + [sharedRun addObject:item]; | ||
| 1105 | + continue; | ||
| 1106 | + } | ||
| 1107 | + | ||
| 1108 | + flushSharedRun(); | ||
| 1109 | + [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil items:@[ item ]]]; | ||
| 1110 | + } | ||
| 1111 | + | ||
| 1112 | + flushSharedRun(); | ||
| 1113 | + return groups; | ||
| 1114 | +} | ||
| 1115 | +#endif | ||
| 1116 | + | ||
| 1117 | RNS_IGNORE_SUPER_CALL_BEGIN | ||
| 1118 | - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex | ||
| 1119 | { | ||
| 1120 | @@ -1025,6 +1779,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | ||
| 1121 | } | ||
| 1122 | |||
| 1123 | _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); | ||
| 1124 | + _subtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.subtitle); | ||
| 1125 | + _largeSubtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.largeSubtitle); | ||
| 1126 | if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { | ||
| 1127 | _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); | ||
| 1128 | } | ||
| 1129 | @@ -1050,6 +1806,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | ||
| 1130 | _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; | ||
| 1131 | _backButtonDisplayMode = | ||
| 1132 | [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; | ||
| 1133 | + _navigationItemStyle = navigationItemStyleFromCppEquivalent(newScreenProps.navigationItemStyle); | ||
| 1134 | |||
| 1135 | if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { | ||
| 1136 | _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; | ||
| 1137 | @@ -1096,6 +1853,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | ||
| 1138 | _headerRightBarButtonItems = array; | ||
| 1139 | } | ||
| 1140 | |||
| 1141 | + if (newScreenProps.headerCenterBarButtonItems != oldScreenProps.headerCenterBarButtonItems) { | ||
| 1142 | + const auto &vec = newScreenProps.headerCenterBarButtonItems; | ||
| 1143 | + NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()]; | ||
| 1144 | + for (const auto &item : vec) { | ||
| 1145 | + NSDictionary *dict = [RNSConvert idFromFollyDynamic:item]; | ||
| 1146 | + if ([dict isKindOfClass:[NSDictionary class]]) { | ||
| 1147 | + [array addObject:dict]; | ||
| 1148 | + } | ||
| 1149 | + } | ||
| 1150 | + _headerCenterBarButtonItems = array; | ||
| 1151 | + } | ||
| 1152 | + | ||
| 1153 | + if (newScreenProps.headerToolbarItems != oldScreenProps.headerToolbarItems) { | ||
| 1154 | + const auto &vec = newScreenProps.headerToolbarItems; | ||
| 1155 | + NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()]; | ||
| 1156 | + for (const auto &item : vec) { | ||
| 1157 | + NSDictionary *dict = [RNSConvert idFromFollyDynamic:item]; | ||
| 1158 | + if ([dict isKindOfClass:[NSDictionary class]]) { | ||
| 1159 | + [array addObject:dict]; | ||
| 1160 | + } | ||
| 1161 | + } | ||
| 1162 | + _headerToolbarItems = array; | ||
| 1163 | + } | ||
| 1164 | + | ||
| 1165 | [self updateViewControllerIfNeeded]; | ||
| 1166 | |||
| 1167 | if (needsNavigationControllerLayout) { | ||
| 1168 | diff --git a/ios/RNSScreenStackHeaderSubview.mm b/ios/RNSScreenStackHeaderSubview.mm | ||
| 1169 | index add33c4..489afa5 100644 | ||
| 1170 | --- a/ios/RNSScreenStackHeaderSubview.mm | ||
| 1171 | +++ b/ios/RNSScreenStackHeaderSubview.mm | ||
| 1172 | @@ -20,7 +20,9 @@ @implementation RNSScreenStackHeaderSubview { | ||
| 1173 | // This is a strong reference to UIBarButtonItem which creates a retain cycle. | ||
| 1174 | // The cycle is cleared via `invalidateUIBarButtonItem` method, called by `invalidate` callback. | ||
| 1175 | UIBarButtonItem *_barButtonItem; | ||
| 1176 | + NSString *_barButtonItemIdentifier; | ||
| 1177 | BOOL _hidesSharedBackground; | ||
| 1178 | + UILabel *_titleScrollEdgeEffectGuide; | ||
| 1179 | } | ||
| 1180 | |||
| 1181 | #pragma mark - Common | ||
| 1182 | @@ -102,6 +104,25 @@ - (void)updateShadowStateWithFrame:(CGRect)frame | ||
| 1183 | - (void)layoutSubviews | ||
| 1184 | { | ||
| 1185 | [super layoutSubviews]; | ||
| 1186 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 1187 | + if (@available(iOS 26.0, *)) { | ||
| 1188 | + if (_type == RNSScreenStackHeaderSubviewTypeTitle || _type == RNSScreenStackHeaderSubviewTypeCenter) { | ||
| 1189 | + // UIKit shapes the navigation bar's scroll-edge fade around native labels, | ||
| 1190 | + // but does not recognize Fabric's custom text views. An empty, non-interactive | ||
| 1191 | + // label supplies the title's geometry without drawing or becoming a bar item. | ||
| 1192 | + if (_titleScrollEdgeEffectGuide == nil) { | ||
| 1193 | + _titleScrollEdgeEffectGuide = [UILabel new]; | ||
| 1194 | + _titleScrollEdgeEffectGuide.accessibilityElementsHidden = YES; | ||
| 1195 | + // Append after Fabric's children so their mount/unmount indices stay intact. | ||
| 1196 | + [self addSubview:_titleScrollEdgeEffectGuide]; | ||
| 1197 | + } | ||
| 1198 | + _titleScrollEdgeEffectGuide.frame = self.bounds; | ||
| 1199 | + } else { | ||
| 1200 | + [_titleScrollEdgeEffectGuide removeFromSuperview]; | ||
| 1201 | + _titleScrollEdgeEffectGuide = nil; | ||
| 1202 | + } | ||
| 1203 | + } | ||
| 1204 | +#endif | ||
| 1205 | [self updateShadowStateInContextOfAncestorView:[self findNavigationBar]]; | ||
| 1206 | } | ||
| 1207 | |||
| 1208 | @@ -124,6 +145,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | ||
| 1209 | const auto &newHeaderSubviewProps = *std::static_pointer_cast<const react::RNSScreenStackHeaderSubviewProps>(props); | ||
| 1210 | |||
| 1211 | [self setType:[RNSConvert RNSScreenStackHeaderSubviewTypeFromCppEquivalent:newHeaderSubviewProps.type]]; | ||
| 1212 | + [self setBarButtonItemIdentifier:RCTNSStringFromStringNilIfEmpty(newHeaderSubviewProps.identifier)]; | ||
| 1213 | [self setHidesSharedBackground:newHeaderSubviewProps.hidesSharedBackground]; | ||
| 1214 | [self setSynchronousShadowStateUpdatesEnabled:newHeaderSubviewProps.synchronousShadowStateUpdatesEnabled]; | ||
| 1215 | [super updateProps:props oldProps:oldProps]; | ||
| 1216 | @@ -276,7 +298,14 @@ - (void)configureBarButtonItem | ||
| 1217 | #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 1218 | if (@available(iOS 26.0, *)) { | ||
| 1219 | if (_barButtonItem != nil) { | ||
| 1220 | - [_barButtonItem setHidesSharedBackground:_hidesSharedBackground]; | ||
| 1221 | + NSString *currentIdentifier = _barButtonItem.identifier; | ||
| 1222 | + if (currentIdentifier != _barButtonItemIdentifier && | ||
| 1223 | + ![currentIdentifier isEqualToString:_barButtonItemIdentifier]) { | ||
| 1224 | + _barButtonItem.identifier = _barButtonItemIdentifier; | ||
| 1225 | + } | ||
| 1226 | + if (_barButtonItem.hidesSharedBackground != _hidesSharedBackground) { | ||
| 1227 | + _barButtonItem.hidesSharedBackground = _hidesSharedBackground; | ||
| 1228 | + } | ||
| 1229 | } | ||
| 1230 | } | ||
| 1231 | #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 1232 | @@ -284,10 +313,22 @@ - (void)configureBarButtonItem | ||
| 1233 | |||
| 1234 | - (void)setHidesSharedBackground:(BOOL)hidesSharedBackground | ||
| 1235 | { | ||
| 1236 | + if (_hidesSharedBackground == hidesSharedBackground) { | ||
| 1237 | + return; | ||
| 1238 | + } | ||
| 1239 | _hidesSharedBackground = hidesSharedBackground; | ||
| 1240 | [self configureBarButtonItem]; | ||
| 1241 | } | ||
| 1242 | |||
| 1243 | +- (void)setBarButtonItemIdentifier:(nullable NSString *)identifier | ||
| 1244 | +{ | ||
| 1245 | + if (_barButtonItemIdentifier == identifier || [_barButtonItemIdentifier isEqualToString:identifier]) { | ||
| 1246 | + return; | ||
| 1247 | + } | ||
| 1248 | + _barButtonItemIdentifier = [identifier copy]; | ||
| 1249 | + [self configureBarButtonItem]; | ||
| 1250 | +} | ||
| 1251 | + | ||
| 1252 | #pragma mark - Dynamic frameworks support | ||
| 1253 | |||
| 1254 | // Needed because of this: https://github.com/facebook/react-native/pull/37274 | ||
| 1255 | diff --git a/lib/commonjs/components/ScreenStackHeaderConfig.js b/lib/commonjs/components/ScreenStackHeaderConfig.js | ||
| 1256 | index 6e68cd1..9e09485 100644 | ||
| 1257 | --- a/lib/commonjs/components/ScreenStackHeaderConfig.js | ||
| 1258 | +++ b/lib/commonjs/components/ScreenStackHeaderConfig.js | ||
| 1259 | @@ -31,17 +31,42 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ | ||
| 1260 | } = (0, _EdgeInsetApplicationContext.useEdgeInsetApplication)(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false); | ||
| 1261 | const { | ||
| 1262 | headerLeftBarButtonItems, | ||
| 1263 | - headerRightBarButtonItems | ||
| 1264 | + headerRightBarButtonItems, | ||
| 1265 | + headerCenterBarButtonItems, | ||
| 1266 | + headerToolbarItems | ||
| 1267 | } = props; | ||
| 1268 | const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerLeftBarButtonItems, 'left') : undefined; | ||
| 1269 | const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerRightBarButtonItems, 'right') : undefined; | ||
| 1270 | - const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length); | ||
| 1271 | + const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'center') : undefined; | ||
| 1272 | + const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'toolbar') : undefined; | ||
| 1273 | + const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); | ||
| 1274 | |||
| 1275 | // Handle bar button item presses | ||
| 1276 | const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => { | ||
| 1277 | - const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId); | ||
| 1278 | + const buttonId = event.nativeEvent.buttonId; | ||
| 1279 | + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; | ||
| 1280 | + const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId); | ||
| 1281 | if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) { | ||
| 1282 | pressedItem.onPress(); | ||
| 1283 | + return; | ||
| 1284 | + } | ||
| 1285 | + for (const item of allItems) { | ||
| 1286 | + if (!item || item.type !== 'mailSearchToolbar') { | ||
| 1287 | + continue; | ||
| 1288 | + } | ||
| 1289 | + if (item.filterButtonId === buttonId) { | ||
| 1290 | + item.onFilterPress?.(); | ||
| 1291 | + return; | ||
| 1292 | + } | ||
| 1293 | + if (item.composeButtonId === buttonId) { | ||
| 1294 | + item.onComposePress?.(); | ||
| 1295 | + return; | ||
| 1296 | + } | ||
| 1297 | + const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined; | ||
| 1298 | + if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) { | ||
| 1299 | + item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length)); | ||
| 1300 | + return; | ||
| 1301 | + } | ||
| 1302 | } | ||
| 1303 | } : undefined; | ||
| 1304 | |||
| 1305 | @@ -64,7 +89,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ | ||
| 1306 | }; | ||
| 1307 | |||
| 1308 | // Check each bar-button item with a menu | ||
| 1309 | - const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])]; | ||
| 1310 | + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; | ||
| 1311 | for (const item of allItems) { | ||
| 1312 | if (item && item.type === 'menu' && item.menu) { | ||
| 1313 | const action = findInMenu(item.menu, event.nativeEvent.menuId); | ||
| 1314 | @@ -72,6 +97,15 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ | ||
| 1315 | action.onPress(); | ||
| 1316 | return; | ||
| 1317 | } | ||
| 1318 | + } else if (item && item.type === 'mailSearchToolbar') { | ||
| 1319 | + const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean); | ||
| 1320 | + for (const toolbarMenu of toolbarMenus) { | ||
| 1321 | + const action = findInMenu(toolbarMenu, event.nativeEvent.menuId); | ||
| 1322 | + if (action) { | ||
| 1323 | + action.onPress(); | ||
| 1324 | + return; | ||
| 1325 | + } | ||
| 1326 | + } | ||
| 1327 | } | ||
| 1328 | } | ||
| 1329 | } : undefined; | ||
| 1330 | @@ -79,6 +113,8 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ | ||
| 1331 | userInterfaceStyle: props.experimental_userInterfaceStyle, | ||
| 1332 | headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems, | ||
| 1333 | headerRightBarButtonItems: preparedHeaderRightBarButtonItems, | ||
| 1334 | + headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems, | ||
| 1335 | + headerToolbarItems: preparedHeaderToolbarItems, | ||
| 1336 | onPressHeaderBarButtonItem: onPressHeaderBarButtonItem, | ||
| 1337 | onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem, | ||
| 1338 | ref: ref, | ||
| 1339 | diff --git a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js | ||
| 1340 | index 589b6cc..b584624 100644 | ||
| 1341 | --- a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js | ||
| 1342 | +++ b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js | ||
| 1343 | @@ -8,7 +8,7 @@ var _reactNative = require("react-native"); | ||
| 1344 | // Local nominal type so declaration emit doesn't resolve the RN alias down to | ||
| 1345 | // the non-public `types_generated/.../AssetSourceResolver#ResolvedAssetSource`. | ||
| 1346 | |||
| 1347 | -const prepareMenu = (menu, index, side, path = '') => { | ||
| 1348 | +const prepareMenu = (menu, index, placement, path = '') => { | ||
| 1349 | return { | ||
| 1350 | ...menu, | ||
| 1351 | items: menu.items.map((menuItem, menuIndex) => { | ||
| 1352 | @@ -29,7 +29,7 @@ const prepareMenu = (menu, index, side, path = '') => { | ||
| 1353 | xcassetName, | ||
| 1354 | imageSource, | ||
| 1355 | templateSource, | ||
| 1356 | - ...prepareMenu(menuItem, index, side, currentPath) | ||
| 1357 | + ...prepareMenu(menuItem, index, placement, currentPath) | ||
| 1358 | }; | ||
| 1359 | } | ||
| 1360 | return { | ||
| 1361 | @@ -38,16 +38,40 @@ const prepareMenu = (menu, index, side, path = '') => { | ||
| 1362 | xcassetName, | ||
| 1363 | imageSource, | ||
| 1364 | templateSource, | ||
| 1365 | - menuId: `${currentPath}-${index}-${side}` | ||
| 1366 | + menuId: `${currentPath}-${index}-${placement}` | ||
| 1367 | }; | ||
| 1368 | }) | ||
| 1369 | }; | ||
| 1370 | }; | ||
| 1371 | -const prepareHeaderBarButtonItems = (barButtonItems, side) => { | ||
| 1372 | - return barButtonItems?.map((item, index) => { | ||
| 1373 | +const prepareHeaderBarButtonItems = (barButtonItems, placement) => { | ||
| 1374 | + const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined; | ||
| 1375 | + return items?.map((item, index) => { | ||
| 1376 | if (item.type === 'spacing') { | ||
| 1377 | return item; | ||
| 1378 | } | ||
| 1379 | + if (item.type === 'searchBarPlacement') { | ||
| 1380 | + if (placement !== 'toolbar') { | ||
| 1381 | + return null; | ||
| 1382 | + } | ||
| 1383 | + return { | ||
| 1384 | + ...item, | ||
| 1385 | + searchBarPlacement: true | ||
| 1386 | + }; | ||
| 1387 | + } | ||
| 1388 | + if (item.type === 'searchField') { | ||
| 1389 | + return { | ||
| 1390 | + ...item, | ||
| 1391 | + searchField: true | ||
| 1392 | + }; | ||
| 1393 | + } | ||
| 1394 | + if (item.type === 'mailSearchToolbar') { | ||
| 1395 | + return { | ||
| 1396 | + ...item, | ||
| 1397 | + mailSearchToolbar: true, | ||
| 1398 | + filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined, | ||
| 1399 | + composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined | ||
| 1400 | + }; | ||
| 1401 | + } | ||
| 1402 | let imageSource, templateSource; | ||
| 1403 | if (item.icon?.type === 'imageSource') { | ||
| 1404 | imageSource = _reactNative.Image.resolveAssetSource(item.icon.imageSource); | ||
| 1405 | @@ -80,17 +104,17 @@ const prepareHeaderBarButtonItems = (barButtonItems, side) => { | ||
| 1406 | if (item.type === 'button') { | ||
| 1407 | return { | ||
| 1408 | ...processedItem, | ||
| 1409 | - buttonId: `${index}-${side}` | ||
| 1410 | + buttonId: `${index}-${placement}` | ||
| 1411 | }; | ||
| 1412 | } | ||
| 1413 | if (item.type === 'menu') { | ||
| 1414 | return { | ||
| 1415 | ...processedItem, | ||
| 1416 | - menu: prepareMenu(item.menu, index, side) | ||
| 1417 | + menu: prepareMenu(item.menu, index, placement) | ||
| 1418 | }; | ||
| 1419 | } | ||
| 1420 | return null; | ||
| 1421 | - }); | ||
| 1422 | + }).filter(item => item !== null); | ||
| 1423 | }; | ||
| 1424 | exports.prepareHeaderBarButtonItems = prepareHeaderBarButtonItems; | ||
| 1425 | //# sourceMappingURL=prepareHeaderBarButtonItems.js.map | ||
| 1426 | \ No newline at end of file | ||
| 1427 | diff --git a/lib/module/components/ScreenStackHeaderConfig.js b/lib/module/components/ScreenStackHeaderConfig.js | ||
| 1428 | index 242898c..7cebaa3 100644 | ||
| 1429 | --- a/lib/module/components/ScreenStackHeaderConfig.js | ||
| 1430 | +++ b/lib/module/components/ScreenStackHeaderConfig.js | ||
| 1431 | @@ -27,17 +27,42 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref | ||
| 1432 | } = useEdgeInsetApplication(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false); | ||
| 1433 | const { | ||
| 1434 | headerLeftBarButtonItems, | ||
| 1435 | - headerRightBarButtonItems | ||
| 1436 | + headerRightBarButtonItems, | ||
| 1437 | + headerCenterBarButtonItems, | ||
| 1438 | + headerToolbarItems | ||
| 1439 | } = props; | ||
| 1440 | const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerLeftBarButtonItems, 'left') : undefined; | ||
| 1441 | const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') : undefined; | ||
| 1442 | - const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length); | ||
| 1443 | + const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center') : undefined; | ||
| 1444 | + const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar') : undefined; | ||
| 1445 | + const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); | ||
| 1446 | |||
| 1447 | // Handle bar button item presses | ||
| 1448 | const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => { | ||
| 1449 | - const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId); | ||
| 1450 | + const buttonId = event.nativeEvent.buttonId; | ||
| 1451 | + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; | ||
| 1452 | + const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId); | ||
| 1453 | if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) { | ||
| 1454 | pressedItem.onPress(); | ||
| 1455 | + return; | ||
| 1456 | + } | ||
| 1457 | + for (const item of allItems) { | ||
| 1458 | + if (!item || item.type !== 'mailSearchToolbar') { | ||
| 1459 | + continue; | ||
| 1460 | + } | ||
| 1461 | + if (item.filterButtonId === buttonId) { | ||
| 1462 | + item.onFilterPress?.(); | ||
| 1463 | + return; | ||
| 1464 | + } | ||
| 1465 | + if (item.composeButtonId === buttonId) { | ||
| 1466 | + item.onComposePress?.(); | ||
| 1467 | + return; | ||
| 1468 | + } | ||
| 1469 | + const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined; | ||
| 1470 | + if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) { | ||
| 1471 | + item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length)); | ||
| 1472 | + return; | ||
| 1473 | + } | ||
| 1474 | } | ||
| 1475 | } : undefined; | ||
| 1476 | |||
| 1477 | @@ -60,7 +85,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref | ||
| 1478 | }; | ||
| 1479 | |||
| 1480 | // Check each bar-button item with a menu | ||
| 1481 | - const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])]; | ||
| 1482 | + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; | ||
| 1483 | for (const item of allItems) { | ||
| 1484 | if (item && item.type === 'menu' && item.menu) { | ||
| 1485 | const action = findInMenu(item.menu, event.nativeEvent.menuId); | ||
| 1486 | @@ -68,6 +93,15 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref | ||
| 1487 | action.onPress(); | ||
| 1488 | return; | ||
| 1489 | } | ||
| 1490 | + } else if (item && item.type === 'mailSearchToolbar') { | ||
| 1491 | + const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean); | ||
| 1492 | + for (const toolbarMenu of toolbarMenus) { | ||
| 1493 | + const action = findInMenu(toolbarMenu, event.nativeEvent.menuId); | ||
| 1494 | + if (action) { | ||
| 1495 | + action.onPress(); | ||
| 1496 | + return; | ||
| 1497 | + } | ||
| 1498 | + } | ||
| 1499 | } | ||
| 1500 | } | ||
| 1501 | } : undefined; | ||
| 1502 | @@ -75,6 +109,8 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref | ||
| 1503 | userInterfaceStyle: props.experimental_userInterfaceStyle, | ||
| 1504 | headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems, | ||
| 1505 | headerRightBarButtonItems: preparedHeaderRightBarButtonItems, | ||
| 1506 | + headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems, | ||
| 1507 | + headerToolbarItems: preparedHeaderToolbarItems, | ||
| 1508 | onPressHeaderBarButtonItem: onPressHeaderBarButtonItem, | ||
| 1509 | onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem, | ||
| 1510 | ref: ref, | ||
| 1511 | diff --git a/lib/module/components/helpers/prepareHeaderBarButtonItems.js b/lib/module/components/helpers/prepareHeaderBarButtonItems.js | ||
| 1512 | index 24bf271..693cfad 100644 | ||
| 1513 | --- a/lib/module/components/helpers/prepareHeaderBarButtonItems.js | ||
| 1514 | +++ b/lib/module/components/helpers/prepareHeaderBarButtonItems.js | ||
| 1515 | @@ -3,7 +3,7 @@ import { Image, processColor } from 'react-native'; | ||
| 1516 | // Local nominal type so declaration emit doesn't resolve the RN alias down to | ||
| 1517 | // the non-public `types_generated/.../AssetSourceResolver#ResolvedAssetSource`. | ||
| 1518 | |||
| 1519 | -const prepareMenu = (menu, index, side, path = '') => { | ||
| 1520 | +const prepareMenu = (menu, index, placement, path = '') => { | ||
| 1521 | return { | ||
| 1522 | ...menu, | ||
| 1523 | items: menu.items.map((menuItem, menuIndex) => { | ||
| 1524 | @@ -24,7 +24,7 @@ const prepareMenu = (menu, index, side, path = '') => { | ||
| 1525 | xcassetName, | ||
| 1526 | imageSource, | ||
| 1527 | templateSource, | ||
| 1528 | - ...prepareMenu(menuItem, index, side, currentPath) | ||
| 1529 | + ...prepareMenu(menuItem, index, placement, currentPath) | ||
| 1530 | }; | ||
| 1531 | } | ||
| 1532 | return { | ||
| 1533 | @@ -33,16 +33,40 @@ const prepareMenu = (menu, index, side, path = '') => { | ||
| 1534 | xcassetName, | ||
| 1535 | imageSource, | ||
| 1536 | templateSource, | ||
| 1537 | - menuId: `${currentPath}-${index}-${side}` | ||
| 1538 | + menuId: `${currentPath}-${index}-${placement}` | ||
| 1539 | }; | ||
| 1540 | }) | ||
| 1541 | }; | ||
| 1542 | }; | ||
| 1543 | -export const prepareHeaderBarButtonItems = (barButtonItems, side) => { | ||
| 1544 | - return barButtonItems?.map((item, index) => { | ||
| 1545 | +export const prepareHeaderBarButtonItems = (barButtonItems, placement) => { | ||
| 1546 | + const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined; | ||
| 1547 | + return items?.map((item, index) => { | ||
| 1548 | if (item.type === 'spacing') { | ||
| 1549 | return item; | ||
| 1550 | } | ||
| 1551 | + if (item.type === 'searchBarPlacement') { | ||
| 1552 | + if (placement !== 'toolbar') { | ||
| 1553 | + return null; | ||
| 1554 | + } | ||
| 1555 | + return { | ||
| 1556 | + ...item, | ||
| 1557 | + searchBarPlacement: true | ||
| 1558 | + }; | ||
| 1559 | + } | ||
| 1560 | + if (item.type === 'searchField') { | ||
| 1561 | + return { | ||
| 1562 | + ...item, | ||
| 1563 | + searchField: true | ||
| 1564 | + }; | ||
| 1565 | + } | ||
| 1566 | + if (item.type === 'mailSearchToolbar') { | ||
| 1567 | + return { | ||
| 1568 | + ...item, | ||
| 1569 | + mailSearchToolbar: true, | ||
| 1570 | + filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined, | ||
| 1571 | + composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined | ||
| 1572 | + }; | ||
| 1573 | + } | ||
| 1574 | let imageSource, templateSource; | ||
| 1575 | if (item.icon?.type === 'imageSource') { | ||
| 1576 | imageSource = Image.resolveAssetSource(item.icon.imageSource); | ||
| 1577 | @@ -75,16 +99,16 @@ export const prepareHeaderBarButtonItems = (barButtonItems, side) => { | ||
| 1578 | if (item.type === 'button') { | ||
| 1579 | return { | ||
| 1580 | ...processedItem, | ||
| 1581 | - buttonId: `${index}-${side}` | ||
| 1582 | + buttonId: `${index}-${placement}` | ||
| 1583 | }; | ||
| 1584 | } | ||
| 1585 | if (item.type === 'menu') { | ||
| 1586 | return { | ||
| 1587 | ...processedItem, | ||
| 1588 | - menu: prepareMenu(item.menu, index, side) | ||
| 1589 | + menu: prepareMenu(item.menu, index, placement) | ||
| 1590 | }; | ||
| 1591 | } | ||
| 1592 | return null; | ||
| 1593 | - }); | ||
| 1594 | + }).filter(item => item !== null); | ||
| 1595 | }; | ||
| 1596 | //# sourceMappingURL=prepareHeaderBarButtonItems.js.map | ||
| 1597 | \ No newline at end of file | ||
| 1598 | diff --git a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts | ||
| 1599 | index 4704a14..e2e2b78 100644 | ||
| 1600 | --- a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts | ||
| 1601 | +++ b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts | ||
| 1602 | @@ -2,7 +2,7 @@ import type { ImageResolvedAssetSource } from 'react-native'; | ||
| 1603 | import type { HeaderBarButtonItem } from '../../types'; | ||
| 1604 | export interface ResolvedImageAsset extends ImageResolvedAssetSource { | ||
| 1605 | } | ||
| 1606 | -export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right") => (import("../..").HeaderBarButtonItemSpacing | { | ||
| 1607 | +export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right" | "center" | "toolbar") => (import("../..").HeaderBarButtonItemSpacing | { | ||
| 1608 | buttonId: string; | ||
| 1609 | imageSource: ResolvedImageAsset | undefined; | ||
| 1610 | templateSource: ResolvedImageAsset | undefined; | ||
| 1611 | @@ -164,5 +164,5 @@ export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButt | ||
| 1612 | identifier?: string | undefined; | ||
| 1613 | accessibilityLabel?: string | undefined; | ||
| 1614 | accessibilityHint?: string | undefined; | ||
| 1615 | -} | null)[]; | ||
| 1616 | +})[]; | ||
| 1617 | //# sourceMappingURL=prepareHeaderBarButtonItems.d.ts.map | ||
| 1618 | \ No newline at end of file | ||
| 1619 | diff --git a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts | ||
| 1620 | index ec39792..434bccf 100644 | ||
| 1621 | --- a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts | ||
| 1622 | +++ b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts | ||
| 1623 | @@ -9,6 +9,7 @@ type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ | ||
| 1624 | menuId: string; | ||
| 1625 | }>; | ||
| 1626 | type BackButtonDisplayMode = 'minimal' | 'default' | 'generic'; | ||
| 1627 | +type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; | ||
| 1628 | type BlurEffect = 'none' | 'extraLight' | 'light' | 'dark' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark'; | ||
| 1629 | type UserInterfaceStyle = 'unspecified' | 'light' | 'dark'; | ||
| 1630 | export interface NativeProps extends ViewProps { | ||
| 1631 | @@ -32,18 +33,23 @@ export interface NativeProps extends ViewProps { | ||
| 1632 | largeTitleColor?: ColorValue | undefined; | ||
| 1633 | translucent?: boolean | undefined; | ||
| 1634 | title?: string | undefined; | ||
| 1635 | + subtitle?: string | undefined; | ||
| 1636 | + largeSubtitle?: string | undefined; | ||
| 1637 | titleFontFamily?: string | undefined; | ||
| 1638 | titleFontSize?: CT.Int32 | undefined; | ||
| 1639 | titleFontWeight?: string | undefined; | ||
| 1640 | titleColor?: ColorValue | undefined; | ||
| 1641 | disableBackButtonMenu?: boolean | undefined; | ||
| 1642 | backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>; | ||
| 1643 | + navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>; | ||
| 1644 | hideBackButton?: boolean | undefined; | ||
| 1645 | backButtonInCustomView?: boolean | undefined; | ||
| 1646 | blurEffect?: CT.WithDefault<BlurEffect, 'none'>; | ||
| 1647 | topInsetEnabled?: boolean | undefined; | ||
| 1648 | headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 1649 | headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 1650 | + headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 1651 | + headerToolbarItems?: CT.UnsafeMixed[] | undefined; | ||
| 1652 | onPressHeaderBarButtonItem?: CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent> | undefined; | ||
| 1653 | onPressHeaderBarButtonMenuItem?: CT.DirectEventHandler<OnPressHeaderBarButtonMenuItemEvent> | undefined; | ||
| 1654 | synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, true>; | ||
| 1655 | diff --git a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts | ||
| 1656 | index 2cb1a79..d6ca5a4 100644 | ||
| 1657 | --- a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts | ||
| 1658 | +++ b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts | ||
| 1659 | @@ -3,6 +3,7 @@ export type HeaderSubviewTypes = 'back' | 'right' | 'left' | 'title' | 'center' | ||
| 1660 | export interface NativeProps extends ViewProps { | ||
| 1661 | type?: CT.WithDefault<HeaderSubviewTypes, 'left'>; | ||
| 1662 | hidesSharedBackground?: boolean | undefined; | ||
| 1663 | + identifier?: string | undefined; | ||
| 1664 | synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, true>; | ||
| 1665 | } | ||
| 1666 | declare const _default: HostComponent<NativeProps>; | ||
| 1667 | diff --git a/lib/typescript/types.d.ts b/lib/typescript/types.d.ts | ||
| 1668 | index ff37881..b487303 100644 | ||
| 1669 | --- a/lib/typescript/types.d.ts | ||
| 1670 | +++ b/lib/typescript/types.d.ts | ||
| 1671 | @@ -19,6 +19,7 @@ export type SearchBarCommands = { | ||
| 1672 | cancelSearch: () => void; | ||
| 1673 | }; | ||
| 1674 | export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal'; | ||
| 1675 | +export type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; | ||
| 1676 | export type StackPresentationTypes = 'push' | 'modal' | 'transparentModal' | 'containedModal' | 'containedTransparentModal' | 'fullScreenModal' | 'formSheet' | 'pageSheet'; | ||
| 1677 | 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'; | ||
| 1678 | export type BlurEffectTypes = BlurEffect; | ||
| 1679 | @@ -650,6 +651,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 1680 | * @platform ios | ||
| 1681 | */ | ||
| 1682 | backButtonDisplayMode?: BackButtonDisplayMode | undefined; | ||
| 1683 | + /** | ||
| 1684 | + * Controls the UIKit `UINavigationItem.style` used by native iOS headers. | ||
| 1685 | + * | ||
| 1686 | + * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls. | ||
| 1687 | + * | ||
| 1688 | + * @platform ios | ||
| 1689 | + */ | ||
| 1690 | + navigationItemStyle?: NavigationItemStyle | undefined; | ||
| 1691 | /** | ||
| 1692 | * Array of UIBarButtomItems to the left side of the header. | ||
| 1693 | * | ||
| 1694 | @@ -662,6 +671,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 1695 | * @platform ios | ||
| 1696 | */ | ||
| 1697 | headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined; | ||
| 1698 | + /** | ||
| 1699 | + * Array of UIBarButtonItems to the centered item group in modern iOS headers. | ||
| 1700 | + * | ||
| 1701 | + * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets | ||
| 1702 | + * document/editor-style chrome keep primary actions visually centered while | ||
| 1703 | + * leaving trailing search separate. | ||
| 1704 | + * | ||
| 1705 | + * @platform ios | ||
| 1706 | + */ | ||
| 1707 | + headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined; | ||
| 1708 | + /** | ||
| 1709 | + * Array of UIBarButtonItems to display in the native toolbar attached to | ||
| 1710 | + * the current screen's navigation controller. | ||
| 1711 | + * | ||
| 1712 | + * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit | ||
| 1713 | + * render integrated Mail-style search chrome using the attached SearchBar. | ||
| 1714 | + * | ||
| 1715 | + * @platform ios | ||
| 1716 | + */ | ||
| 1717 | + headerToolbarItems?: HeaderBarButtonItem[] | undefined; | ||
| 1718 | /** | ||
| 1719 | * 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. | ||
| 1720 | */ | ||
| 1721 | @@ -723,6 +752,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 1722 | * String that can be displayed in the header as a fallback for `headerTitle`. | ||
| 1723 | */ | ||
| 1724 | title?: string | undefined; | ||
| 1725 | + /** | ||
| 1726 | + * String displayed below the compact navigation title on iOS 26+. | ||
| 1727 | + * | ||
| 1728 | + * @platform ios | ||
| 1729 | + */ | ||
| 1730 | + subtitle?: string | undefined; | ||
| 1731 | + /** | ||
| 1732 | + * String displayed below the large navigation title on iOS 26+. | ||
| 1733 | + * | ||
| 1734 | + * Falls back to `subtitle` when omitted. | ||
| 1735 | + * | ||
| 1736 | + * @platform ios | ||
| 1737 | + */ | ||
| 1738 | + largeSubtitle?: string | undefined; | ||
| 1739 | /** | ||
| 1740 | * Allows for setting text color of the title. | ||
| 1741 | */ | ||
| 1742 | @@ -1018,6 +1061,13 @@ export interface SearchBarProps { | ||
| 1743 | shouldShowHintSearchIcon?: boolean | undefined; | ||
| 1744 | } | ||
| 1745 | export interface ScreenStackHeaderSubviewProps { | ||
| 1746 | + /** | ||
| 1747 | + * An identifier used to match this item across navigation bar transitions. | ||
| 1748 | + * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later. | ||
| 1749 | + * | ||
| 1750 | + * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier | ||
| 1751 | + */ | ||
| 1752 | + identifier?: string | undefined; | ||
| 1753 | /** | ||
| 1754 | * A boolean value indicating whether the background this item may share with other items in the bar should be hidden. | ||
| 1755 | * Only applicable to type="right" and type="left" subviews. | ||
| 1756 | @@ -1056,6 +1106,11 @@ interface SharedHeaderBarButtonItem { | ||
| 1757 | * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property | ||
| 1758 | */ | ||
| 1759 | variant?: 'plain' | 'done' | 'prominent' | undefined; | ||
| 1760 | + /** | ||
| 1761 | + * Render the item with UIButtonConfiguration.glassButtonConfiguration. | ||
| 1762 | + * Only available from iOS 26.0 and later. | ||
| 1763 | + */ | ||
| 1764 | + glassEffect?: boolean | undefined; | ||
| 1765 | /** | ||
| 1766 | * The tint color to apply to the item. | ||
| 1767 | * | ||
| 1768 | @@ -1201,8 +1256,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { | ||
| 1769 | export interface HeaderBarButtonItemSpacing { | ||
| 1770 | type: 'spacing'; | ||
| 1771 | spacing: number; | ||
| 1772 | + flexible?: boolean | undefined; | ||
| 1773 | +} | ||
| 1774 | +export interface HeaderBarButtonSearchBarPlacementItem { | ||
| 1775 | + type: 'searchBarPlacement'; | ||
| 1776 | + activatesSearchController?: boolean | undefined; | ||
| 1777 | + customView?: boolean | undefined; | ||
| 1778 | + index?: number | undefined; | ||
| 1779 | + width?: number | undefined; | ||
| 1780 | +} | ||
| 1781 | +export interface HeaderBarButtonSearchFieldItem { | ||
| 1782 | + type: 'searchField'; | ||
| 1783 | + placeholder?: string | undefined; | ||
| 1784 | + width?: number | undefined; | ||
| 1785 | +} | ||
| 1786 | +export interface HeaderBarButtonMailSearchToolbarItem { | ||
| 1787 | + type: 'mailSearchToolbar'; | ||
| 1788 | + composeButtonId?: string | undefined; | ||
| 1789 | + composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; | ||
| 1790 | + composeSystemImageName?: string | undefined; | ||
| 1791 | + filterButtonId?: string | undefined; | ||
| 1792 | + filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; | ||
| 1793 | + filterSystemImageName?: string | undefined; | ||
| 1794 | + onComposePress?: (() => void) | undefined; | ||
| 1795 | + onFilterPress?: (() => void) | undefined; | ||
| 1796 | + onSearchTextChange?: ((text: string) => void) | undefined; | ||
| 1797 | + placeholder?: string | undefined; | ||
| 1798 | + searchTextChangeId?: string | undefined; | ||
| 1799 | + showsSearchDismissButton?: boolean | undefined; | ||
| 1800 | + useFallbackSearchField?: boolean | undefined; | ||
| 1801 | + width?: number | undefined; | ||
| 1802 | } | ||
| 1803 | -export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonItemSpacing; | ||
| 1804 | +export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonMailSearchToolbarItem | HeaderBarButtonSearchFieldItem | HeaderBarButtonSearchBarPlacementItem | HeaderBarButtonItemSpacing; | ||
| 1805 | /** | ||
| 1806 | * Custom Screen Transition | ||
| 1807 | */ | ||
| 1808 | diff --git a/src/components/ScreenStackHeaderConfig.tsx b/src/components/ScreenStackHeaderConfig.tsx | ||
| 1809 | index 509d6cd..761e49b 100644 | ||
| 1810 | --- a/src/components/ScreenStackHeaderConfig.tsx | ||
| 1811 | +++ b/src/components/ScreenStackHeaderConfig.tsx | ||
| 1812 | @@ -54,7 +54,12 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1813 | props.disableBottomInsetApplication ?? false, | ||
| 1814 | ); | ||
| 1815 | |||
| 1816 | - const { headerLeftBarButtonItems, headerRightBarButtonItems } = props; | ||
| 1817 | + const { | ||
| 1818 | + headerLeftBarButtonItems, | ||
| 1819 | + headerRightBarButtonItems, | ||
| 1820 | + headerCenterBarButtonItems, | ||
| 1821 | + headerToolbarItems, | ||
| 1822 | + } = props; | ||
| 1823 | |||
| 1824 | const preparedHeaderLeftBarButtonItems = | ||
| 1825 | headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform | ||
| 1826 | @@ -64,22 +69,36 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1827 | headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform | ||
| 1828 | ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') | ||
| 1829 | : undefined; | ||
| 1830 | + const preparedHeaderCenterBarButtonItems = | ||
| 1831 | + headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform | ||
| 1832 | + ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center') | ||
| 1833 | + : undefined; | ||
| 1834 | + const preparedHeaderToolbarItems = | ||
| 1835 | + headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform | ||
| 1836 | + ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar') | ||
| 1837 | + : undefined; | ||
| 1838 | const hasHeaderBarButtonItems = | ||
| 1839 | isHeaderBarButtonsAvailableForCurrentPlatform && | ||
| 1840 | (preparedHeaderLeftBarButtonItems?.length || | ||
| 1841 | - preparedHeaderRightBarButtonItems?.length); | ||
| 1842 | + preparedHeaderRightBarButtonItems?.length || | ||
| 1843 | + preparedHeaderCenterBarButtonItems?.length || | ||
| 1844 | + preparedHeaderToolbarItems?.length); | ||
| 1845 | |||
| 1846 | // Handle bar button item presses | ||
| 1847 | const onPressHeaderBarButtonItem = hasHeaderBarButtonItems | ||
| 1848 | ? (event: NativeSyntheticEvent<{ buttonId: string }>) => { | ||
| 1849 | - const pressedItem = [ | ||
| 1850 | + const buttonId = event.nativeEvent.buttonId; | ||
| 1851 | + const allItems = [ | ||
| 1852 | ...(preparedHeaderLeftBarButtonItems ?? []), | ||
| 1853 | ...(preparedHeaderRightBarButtonItems ?? []), | ||
| 1854 | - ].find( | ||
| 1855 | + ...(preparedHeaderCenterBarButtonItems ?? []), | ||
| 1856 | + ...(preparedHeaderToolbarItems ?? []), | ||
| 1857 | + ]; | ||
| 1858 | + const pressedItem = allItems.find( | ||
| 1859 | item => | ||
| 1860 | item && | ||
| 1861 | 'buttonId' in item && | ||
| 1862 | - item.buttonId === event.nativeEvent.buttonId, | ||
| 1863 | + item.buttonId === buttonId, | ||
| 1864 | ); | ||
| 1865 | if ( | ||
| 1866 | pressedItem && | ||
| 1867 | @@ -87,6 +106,32 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1868 | pressedItem.onPress | ||
| 1869 | ) { | ||
| 1870 | pressedItem.onPress(); | ||
| 1871 | + return; | ||
| 1872 | + } | ||
| 1873 | + for (const item of allItems) { | ||
| 1874 | + if (!item || item.type !== 'mailSearchToolbar') { | ||
| 1875 | + continue; | ||
| 1876 | + } | ||
| 1877 | + if (item.filterButtonId === buttonId) { | ||
| 1878 | + item.onFilterPress?.(); | ||
| 1879 | + return; | ||
| 1880 | + } | ||
| 1881 | + if (item.composeButtonId === buttonId) { | ||
| 1882 | + item.onComposePress?.(); | ||
| 1883 | + return; | ||
| 1884 | + } | ||
| 1885 | + const searchTextChangePrefix = item.searchTextChangeId | ||
| 1886 | + ? `${item.searchTextChangeId}:` | ||
| 1887 | + : undefined; | ||
| 1888 | + if ( | ||
| 1889 | + searchTextChangePrefix && | ||
| 1890 | + buttonId.startsWith(searchTextChangePrefix) | ||
| 1891 | + ) { | ||
| 1892 | + item.onSearchTextChange?.( | ||
| 1893 | + buttonId.slice(searchTextChangePrefix.length), | ||
| 1894 | + ); | ||
| 1895 | + return; | ||
| 1896 | + } | ||
| 1897 | } | ||
| 1898 | } | ||
| 1899 | : undefined; | ||
| 1900 | @@ -117,6 +162,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1901 | const allItems = [ | ||
| 1902 | ...(preparedHeaderLeftBarButtonItems ?? []), | ||
| 1903 | ...(preparedHeaderRightBarButtonItems ?? []), | ||
| 1904 | + ...(preparedHeaderCenterBarButtonItems ?? []), | ||
| 1905 | + ...(preparedHeaderToolbarItems ?? []), | ||
| 1906 | ]; | ||
| 1907 | for (const item of allItems) { | ||
| 1908 | if (item && item.type === 'menu' && item.menu) { | ||
| 1909 | @@ -125,6 +172,17 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1910 | action.onPress(); | ||
| 1911 | return; | ||
| 1912 | } | ||
| 1913 | + } else if (item && item.type === 'mailSearchToolbar') { | ||
| 1914 | + const toolbarMenus = [item.filterMenu, item.composeMenu].filter( | ||
| 1915 | + Boolean, | ||
| 1916 | + ); | ||
| 1917 | + for (const toolbarMenu of toolbarMenus) { | ||
| 1918 | + const action = findInMenu(toolbarMenu, event.nativeEvent.menuId); | ||
| 1919 | + if (action) { | ||
| 1920 | + action.onPress(); | ||
| 1921 | + return; | ||
| 1922 | + } | ||
| 1923 | + } | ||
| 1924 | } | ||
| 1925 | } | ||
| 1926 | } | ||
| 1927 | @@ -136,6 +194,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1928 | userInterfaceStyle={props.experimental_userInterfaceStyle} | ||
| 1929 | headerLeftBarButtonItems={preparedHeaderLeftBarButtonItems} | ||
| 1930 | headerRightBarButtonItems={preparedHeaderRightBarButtonItems} | ||
| 1931 | + headerCenterBarButtonItems={preparedHeaderCenterBarButtonItems} | ||
| 1932 | + headerToolbarItems={preparedHeaderToolbarItems} | ||
| 1933 | onPressHeaderBarButtonItem={onPressHeaderBarButtonItem} | ||
| 1934 | onPressHeaderBarButtonMenuItem={onPressHeaderBarButtonMenuItem} | ||
| 1935 | ref={ref} | ||
| 1936 | diff --git a/src/components/helpers/prepareHeaderBarButtonItems.ts b/src/components/helpers/prepareHeaderBarButtonItems.ts | ||
| 1937 | index 19ab7bc..9fd5ceb 100644 | ||
| 1938 | --- a/src/components/helpers/prepareHeaderBarButtonItems.ts | ||
| 1939 | +++ b/src/components/helpers/prepareHeaderBarButtonItems.ts | ||
| 1940 | @@ -12,7 +12,7 @@ export interface ResolvedImageAsset extends ImageResolvedAssetSource {} | ||
| 1941 | const prepareMenu = ( | ||
| 1942 | menu: HeaderBarButtonItemWithMenu['menu'], | ||
| 1943 | index: number, | ||
| 1944 | - side: 'left' | 'right', | ||
| 1945 | + placement: 'left' | 'right' | 'center' | 'toolbar', | ||
| 1946 | path: string = '', | ||
| 1947 | ): HeaderBarButtonItemWithMenu['menu'] => { | ||
| 1948 | return { | ||
| 1949 | @@ -39,7 +39,7 @@ const prepareMenu = ( | ||
| 1950 | xcassetName, | ||
| 1951 | imageSource, | ||
| 1952 | templateSource, | ||
| 1953 | - ...prepareMenu(menuItem, index, side, currentPath), | ||
| 1954 | + ...prepareMenu(menuItem, index, placement, currentPath), | ||
| 1955 | }; | ||
| 1956 | } | ||
| 1957 | return { | ||
| 1958 | @@ -48,20 +48,53 @@ const prepareMenu = ( | ||
| 1959 | xcassetName, | ||
| 1960 | imageSource, | ||
| 1961 | templateSource, | ||
| 1962 | - menuId: `${currentPath}-${index}-${side}`, | ||
| 1963 | + menuId: `${currentPath}-${index}-${placement}`, | ||
| 1964 | }; | ||
| 1965 | }), | ||
| 1966 | }; | ||
| 1967 | }; | ||
| 1968 | |||
| 1969 | export const prepareHeaderBarButtonItems = ( | ||
| 1970 | - barButtonItems: HeaderBarButtonItem[], | ||
| 1971 | - side: 'left' | 'right', | ||
| 1972 | + barButtonItems: | ||
| 1973 | + | HeaderBarButtonItem[] | ||
| 1974 | + | HeaderBarButtonItem | ||
| 1975 | + | null | ||
| 1976 | + | undefined, | ||
| 1977 | + placement: 'left' | 'right' | 'center' | 'toolbar', | ||
| 1978 | ) => { | ||
| 1979 | - return barButtonItems?.map((item, index) => { | ||
| 1980 | + const items = Array.isArray(barButtonItems) | ||
| 1981 | + ? barButtonItems | ||
| 1982 | + : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems | ||
| 1983 | + ? [barButtonItems] | ||
| 1984 | + : undefined; | ||
| 1985 | + | ||
| 1986 | + return items?.map((item, index) => { | ||
| 1987 | if (item.type === 'spacing') { | ||
| 1988 | return item; | ||
| 1989 | } | ||
| 1990 | + if (item.type === 'searchBarPlacement') { | ||
| 1991 | + if (placement !== 'toolbar') { | ||
| 1992 | + return null; | ||
| 1993 | + } | ||
| 1994 | + return { | ||
| 1995 | + ...item, | ||
| 1996 | + searchBarPlacement: true, | ||
| 1997 | + }; | ||
| 1998 | + } | ||
| 1999 | + if (item.type === 'searchField') { | ||
| 2000 | + return { | ||
| 2001 | + ...item, | ||
| 2002 | + searchField: true, | ||
| 2003 | + }; | ||
| 2004 | + } | ||
| 2005 | + if (item.type === 'mailSearchToolbar') { | ||
| 2006 | + return { | ||
| 2007 | + ...item, | ||
| 2008 | + mailSearchToolbar: true, | ||
| 2009 | + filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined, | ||
| 2010 | + composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined, | ||
| 2011 | + }; | ||
| 2012 | + } | ||
| 2013 | let imageSource: ResolvedImageAsset | undefined, | ||
| 2014 | templateSource: ResolvedImageAsset | undefined; | ||
| 2015 | if (item.icon?.type === 'imageSource') { | ||
| 2016 | @@ -97,15 +130,15 @@ export const prepareHeaderBarButtonItems = ( | ||
| 2017 | if (item.type === 'button') { | ||
| 2018 | return { | ||
| 2019 | ...processedItem, | ||
| 2020 | - buttonId: `${index}-${side}`, | ||
| 2021 | + buttonId: `${index}-${placement}`, | ||
| 2022 | }; | ||
| 2023 | } | ||
| 2024 | if (item.type === 'menu') { | ||
| 2025 | return { | ||
| 2026 | ...processedItem, | ||
| 2027 | - menu: prepareMenu(item.menu, index, side), | ||
| 2028 | + menu: prepareMenu(item.menu, index, placement), | ||
| 2029 | }; | ||
| 2030 | } | ||
| 2031 | return null; | ||
| 2032 | - }); | ||
| 2033 | + }).filter(item => item !== null); | ||
| 2034 | }; | ||
| 2035 | diff --git a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts | ||
| 2036 | index da28c51..1136622 100644 | ||
| 2037 | --- a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts | ||
| 2038 | +++ b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts | ||
| 2039 | @@ -19,6 +19,7 @@ type OnPressHeaderBarButtonItemEvent = Readonly<{ buttonId: string }>; | ||
| 2040 | type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ menuId: string }>; | ||
| 2041 | |||
| 2042 | type BackButtonDisplayMode = 'minimal' | 'default' | 'generic'; | ||
| 2043 | +type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; | ||
| 2044 | |||
| 2045 | type BlurEffect = | ||
| 2046 | | 'none' | ||
| 2047 | @@ -66,12 +67,15 @@ export interface NativeProps extends ViewProps { | ||
| 2048 | largeTitleColor?: ColorValue | undefined; | ||
| 2049 | translucent?: boolean | undefined; | ||
| 2050 | title?: string | undefined; | ||
| 2051 | + subtitle?: string | undefined; | ||
| 2052 | + largeSubtitle?: string | undefined; | ||
| 2053 | titleFontFamily?: string | undefined; | ||
| 2054 | titleFontSize?: CT.Int32 | undefined; | ||
| 2055 | titleFontWeight?: string | undefined; | ||
| 2056 | titleColor?: ColorValue | undefined; | ||
| 2057 | disableBackButtonMenu?: boolean | undefined; | ||
| 2058 | backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>; | ||
| 2059 | + navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>; | ||
| 2060 | hideBackButton?: boolean | undefined; | ||
| 2061 | backButtonInCustomView?: boolean | undefined; | ||
| 2062 | blurEffect?: CT.WithDefault<BlurEffect, 'none'>; | ||
| 2063 | @@ -79,6 +83,8 @@ export interface NativeProps extends ViewProps { | ||
| 2064 | topInsetEnabled?: boolean | undefined; | ||
| 2065 | headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 2066 | headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 2067 | + headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 2068 | + headerToolbarItems?: CT.UnsafeMixed[] | undefined; | ||
| 2069 | onPressHeaderBarButtonItem?: | ||
| 2070 | | CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent> | ||
| 2071 | | undefined; | ||
| 2072 | diff --git a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts | ||
| 2073 | index 9a6af57..03aa2b2 100644 | ||
| 2074 | --- a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts | ||
| 2075 | +++ b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts | ||
| 2076 | @@ -18,6 +18,7 @@ export type HeaderSubviewTypes = | ||
| 2077 | export interface NativeProps extends ViewProps { | ||
| 2078 | type?: CT.WithDefault<HeaderSubviewTypes, 'left'>; | ||
| 2079 | hidesSharedBackground?: boolean | undefined; | ||
| 2080 | + identifier?: string | undefined; | ||
| 2081 | synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, true>; | ||
| 2082 | } | ||
| 2083 | |||
| 2084 | diff --git a/src/types.tsx b/src/types.tsx | ||
| 2085 | index 8594b73..20c6e88 100644 | ||
| 2086 | --- a/src/types.tsx | ||
| 2087 | +++ b/src/types.tsx | ||
| 2088 | @@ -35,6 +35,7 @@ export type SearchBarCommands = { | ||
| 2089 | }; | ||
| 2090 | |||
| 2091 | export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal'; | ||
| 2092 | +export type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; | ||
| 2093 | export type StackPresentationTypes = | ||
| 2094 | | 'push' | ||
| 2095 | | 'modal' | ||
| 2096 | @@ -754,6 +755,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 2097 | * @platform ios | ||
| 2098 | */ | ||
| 2099 | backButtonDisplayMode?: BackButtonDisplayMode | undefined; | ||
| 2100 | + /** | ||
| 2101 | + * Controls the UIKit `UINavigationItem.style` used by native iOS headers. | ||
| 2102 | + * | ||
| 2103 | + * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls. | ||
| 2104 | + * | ||
| 2105 | + * @platform ios | ||
| 2106 | + */ | ||
| 2107 | + navigationItemStyle?: NavigationItemStyle | undefined; | ||
| 2108 | /** | ||
| 2109 | * Array of UIBarButtomItems to the left side of the header. | ||
| 2110 | * | ||
| 2111 | @@ -766,6 +775,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 2112 | * @platform ios | ||
| 2113 | */ | ||
| 2114 | headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined; | ||
| 2115 | + /** | ||
| 2116 | + * Array of UIBarButtonItems to the centered item group in modern iOS headers. | ||
| 2117 | + * | ||
| 2118 | + * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets | ||
| 2119 | + * document/editor-style chrome keep primary actions visually centered while | ||
| 2120 | + * leaving trailing search separate. | ||
| 2121 | + * | ||
| 2122 | + * @platform ios | ||
| 2123 | + */ | ||
| 2124 | + headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined; | ||
| 2125 | + /** | ||
| 2126 | + * Array of UIBarButtonItems to display in the native toolbar attached to | ||
| 2127 | + * the current screen's navigation controller. | ||
| 2128 | + * | ||
| 2129 | + * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit | ||
| 2130 | + * render integrated Mail-style search chrome using the attached SearchBar. | ||
| 2131 | + * | ||
| 2132 | + * @platform ios | ||
| 2133 | + */ | ||
| 2134 | + headerToolbarItems?: HeaderBarButtonItem[] | undefined; | ||
| 2135 | /** | ||
| 2136 | * 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. | ||
| 2137 | */ | ||
| 2138 | @@ -827,6 +856,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 2139 | * String that can be displayed in the header as a fallback for `headerTitle`. | ||
| 2140 | */ | ||
| 2141 | title?: string | undefined; | ||
| 2142 | + /** | ||
| 2143 | + * String displayed below the compact navigation title on iOS 26+. | ||
| 2144 | + * | ||
| 2145 | + * @platform ios | ||
| 2146 | + */ | ||
| 2147 | + subtitle?: string | undefined; | ||
| 2148 | + /** | ||
| 2149 | + * String displayed below the large navigation title on iOS 26+. | ||
| 2150 | + * | ||
| 2151 | + * Falls back to `subtitle` when omitted. | ||
| 2152 | + * | ||
| 2153 | + * @platform ios | ||
| 2154 | + */ | ||
| 2155 | + largeSubtitle?: string | undefined; | ||
| 2156 | /** | ||
| 2157 | * Allows for setting text color of the title. | ||
| 2158 | */ | ||
| 2159 | @@ -1139,6 +1182,13 @@ export interface SearchBarProps { | ||
| 2160 | } | ||
| 2161 | |||
| 2162 | export interface ScreenStackHeaderSubviewProps { | ||
| 2163 | + /** | ||
| 2164 | + * An identifier used to match this item across navigation bar transitions. | ||
| 2165 | + * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later. | ||
| 2166 | + * | ||
| 2167 | + * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier | ||
| 2168 | + */ | ||
| 2169 | + identifier?: string | undefined; | ||
| 2170 | /** | ||
| 2171 | * A boolean value indicating whether the background this item may share with other items in the bar should be hidden. | ||
| 2172 | * Only applicable to type="right" and type="left" subviews. | ||
| 2173 | @@ -1180,6 +1230,11 @@ interface SharedHeaderBarButtonItem { | ||
| 2174 | * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property | ||
| 2175 | */ | ||
| 2176 | variant?: 'plain' | 'done' | 'prominent' | undefined; | ||
| 2177 | + /** | ||
| 2178 | + * Render the item with UIButtonConfiguration.glassButtonConfiguration. | ||
| 2179 | + * Only available from iOS 26.0 and later. | ||
| 2180 | + */ | ||
| 2181 | + glassEffect?: boolean | undefined; | ||
| 2182 | /** | ||
| 2183 | * The tint color to apply to the item. | ||
| 2184 | * | ||
| 2185 | @@ -1335,11 +1390,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { | ||
| 2186 | export interface HeaderBarButtonItemSpacing { | ||
| 2187 | type: 'spacing'; | ||
| 2188 | spacing: number; | ||
| 2189 | + flexible?: boolean | undefined; | ||
| 2190 | +} | ||
| 2191 | + | ||
| 2192 | +export interface HeaderBarButtonSearchBarPlacementItem { | ||
| 2193 | + type: 'searchBarPlacement'; | ||
| 2194 | + activatesSearchController?: boolean | undefined; | ||
| 2195 | + customView?: boolean | undefined; | ||
| 2196 | + index?: number | undefined; | ||
| 2197 | + width?: number | undefined; | ||
| 2198 | +} | ||
| 2199 | + | ||
| 2200 | +export interface HeaderBarButtonSearchFieldItem { | ||
| 2201 | + type: 'searchField'; | ||
| 2202 | + placeholder?: string | undefined; | ||
| 2203 | + width?: number | undefined; | ||
| 2204 | +} | ||
| 2205 | + | ||
| 2206 | +export interface HeaderBarButtonMailSearchToolbarItem { | ||
| 2207 | + type: 'mailSearchToolbar'; | ||
| 2208 | + composeButtonId?: string | undefined; | ||
| 2209 | + composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; | ||
| 2210 | + composeSystemImageName?: string | undefined; | ||
| 2211 | + filterButtonId?: string | undefined; | ||
| 2212 | + filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; | ||
| 2213 | + filterSystemImageName?: string | undefined; | ||
| 2214 | + onComposePress?: (() => void) | undefined; | ||
| 2215 | + onFilterPress?: (() => void) | undefined; | ||
| 2216 | + onSearchTextChange?: ((text: string) => void) | undefined; | ||
| 2217 | + placeholder?: string | undefined; | ||
| 2218 | + searchTextChangeId?: string | undefined; | ||
| 2219 | + showsSearchDismissButton?: boolean | undefined; | ||
| 2220 | + useFallbackSearchField?: boolean | undefined; | ||
| 2221 | + width?: number | undefined; | ||
| 2222 | } | ||
| 2223 | |||
| 2224 | export type HeaderBarButtonItem = | ||
| 2225 | | HeaderBarButtonItemWithAction | ||
| 2226 | | HeaderBarButtonItemWithMenu | ||
| 2227 | + | HeaderBarButtonMailSearchToolbarItem | ||
| 2228 | + | HeaderBarButtonSearchFieldItem | ||
| 2229 | + | HeaderBarButtonSearchBarPlacementItem | ||
| 2230 | | HeaderBarButtonItemSpacing; | ||
| 2231 | |||
| 2232 | /** | ||
pnpm-lock.yaml+12-12| ... | @@ -116,11 +116,10 @@ patchedDependencies: | ... | @@ -116,11 +116,10 @@ patchedDependencies: |
| 116 | expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 | 116 | expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 |
| 117 | expo-widgets@58.0.11: d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed | 117 | expo-widgets@58.0.11: d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed |
| 118 | node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 | 118 | node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 |
| 119 | react-native-gesture-handler@3.2.1: d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9 | 119 | react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad |
| 120 | react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d | 120 | react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d |
| 121 | react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c | 121 | react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c |
| 122 | react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 | 122 | react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 |
| 123 | react-native-screens@4.28.0: cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483 | ||
| 124 | 123 | ||
| 125 | importers: | 124 | importers: |
| 126 | 125 | ||
| ... | @@ -292,7 +291,7 @@ importers: | ... | @@ -292,7 +291,7 @@ importers: |
| 292 | 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) | 291 | 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) |
| 293 | '@react-navigation/native-stack': | 292 | '@react-navigation/native-stack': |
| 294 | specifier: 7.17.6 | 293 | specifier: 7.17.6 |
| 295 | version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(cdeee8948497d4b80ffe015e2a003650) | 294 | version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(141a742cd91f55378cde709a48a3af06) |
| 296 | '@shikijs/core': | 295 | '@shikijs/core': |
| 297 | specifier: 4.2.0 | 296 | specifier: 4.2.0 |
| 298 | version: 4.2.0 | 297 | version: 4.2.0 |
| ... | @@ -454,7 +453,7 @@ importers: | ... | @@ -454,7 +453,7 @@ importers: |
| 454 | 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) | 453 | 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) |
| 455 | react-native-gesture-handler: | 454 | react-native-gesture-handler: |
| 456 | specifier: ~3.2.1 | 455 | specifier: ~3.2.1 |
| 457 | 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) | 456 | 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) |
| 458 | react-native-image-viewing: | 457 | react-native-image-viewing: |
| 459 | specifier: ^0.2.2 | 458 | specifier: ^0.2.2 |
| 460 | 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) | 459 | 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) |
| ... | @@ -474,8 +473,8 @@ importers: | ... | @@ -474,8 +473,8 @@ importers: |
| 474 | specifier: ~5.9.1 | 473 | specifier: ~5.9.1 |
| 475 | 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) | 474 | 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) |
| 476 | react-native-screens: | 475 | react-native-screens: |
| 477 | specifier: ~4.28.0 | 476 | specifier: file:deps/react-native-screens-5.0.0-t3.7.tgz |
| 478 | 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) | 477 | version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) |
| 479 | react-native-shiki-engine: | 478 | react-native-shiki-engine: |
| 480 | specifier: ^0.3.12 | 479 | specifier: ^0.3.12 |
| 481 | 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) | 480 | 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) |
| ... | @@ -10071,8 +10070,9 @@ packages: | ... | @@ -10071,8 +10070,9 @@ packages: |
| 10071 | react: '*' | 10070 | react: '*' |
| 10072 | react-native: '*' | 10071 | react-native: '*' |
| 10073 | 10072 | ||
| 10074 | react-native-screens@4.28.0: | 10073 | react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz: |
| 10075 | resolution: {integrity: sha512-0/79Z8RCibuaAHti+DJ2Dq0m6UFWh4+p1bH58K3cjiQ7IDWs5kU9tCAj4lXcJMHHonbF2mIE2BeaPSEvOpIiFg==} | 10074 | resolution: {integrity: sha512-Di1+YTCuhWpk9helKBxkvXAVAQTCSRo24tpFBGXdbTAT4vPm2vGPbtPe+lnIXcePjNHbTEaI1ha5n9PqQKl5lg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz} |
| 10075 | version: 5.0.0-t3.7 | ||
| 10076 | peerDependencies: | 10076 | peerDependencies: |
| 10077 | react: '*' | 10077 | react: '*' |
| 10078 | react-native: '*' | 10078 | react-native: '*' |
| ... | @@ -15198,7 +15198,7 @@ snapshots: | ... | @@ -15198,7 +15198,7 @@ snapshots: |
| 15198 | optionalDependencies: | 15198 | optionalDependencies: |
| 15199 | '@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) | 15199 | '@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) |
| 15200 | 15200 | ||
| 15201 | '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(cdeee8948497d4b80ffe015e2a003650)': | 15201 | '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(141a742cd91f55378cde709a48a3af06)': |
| 15202 | dependencies: | 15202 | dependencies: |
| 15203 | '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) | 15203 | '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) |
| 15204 | '@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) | 15204 | '@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) |
| ... | @@ -15206,7 +15206,7 @@ snapshots: | ... | @@ -15206,7 +15206,7 @@ snapshots: |
| 15206 | react: 19.3.0 | 15206 | react: 19.3.0 |
| 15207 | 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) | 15207 | 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) |
| 15208 | 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) | 15208 | 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) |
| 15209 | 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) | 15209 | react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) |
| 15210 | sf-symbols-typescript: 2.2.0 | 15210 | sf-symbols-typescript: 2.2.0 |
| 15211 | warn-once: 0.1.1 | 15211 | warn-once: 0.1.1 |
| 15212 | transitivePeerDependencies: | 15212 | transitivePeerDependencies: |
| ... | @@ -21123,7 +21123,7 @@ snapshots: | ... | @@ -21123,7 +21123,7 @@ snapshots: |
| 21123 | transitivePeerDependencies: | 21123 | transitivePeerDependencies: |
| 21124 | - supports-color | 21124 | - supports-color |
| 21125 | 21125 | ||
| 21126 | 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): | 21126 | 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): |
| 21127 | dependencies: | 21127 | dependencies: |
| 21128 | '@types/react-test-renderer': 19.1.0 | 21128 | '@types/react-test-renderer': 19.1.0 |
| 21129 | invariant: 2.2.4 | 21129 | invariant: 2.2.4 |
| ... | @@ -21173,7 +21173,7 @@ snapshots: | ... | @@ -21173,7 +21173,7 @@ snapshots: |
| 21173 | react: 19.3.0 | 21173 | react: 19.3.0 |
| 21174 | 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) | 21174 | 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) |
| 21175 | 21175 | ||
| 21176 | 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): | 21176 | react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): |
| 21177 | dependencies: | 21177 | dependencies: |
| 21178 | react: 19.3.0 | 21178 | react: 19.3.0 |
| 21179 | react-freeze: 1.0.4(react@19.3.0) | 21179 | react-freeze: 1.0.4(react@19.3.0) |
pnpm-workspace.yaml-1| ... | @@ -284,7 +284,6 @@ patchedDependencies: | ... | @@ -284,7 +284,6 @@ patchedDependencies: |
| 284 | react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch | 284 | react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch |
| 285 | react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch | 285 | react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch |
| 286 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch | 286 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch |
| 287 | react-native-screens@4.28.0: patches/react-native-screens@4.28.0.patch | ||
| 288 | 287 | ||
| 289 | peerDependencyRules: | 288 | peerDependencyRules: |
| 290 | allowAny: | 289 | allowAny: |
scripts/release-smoke.ts+7| ... | @@ -41,6 +41,13 @@ function copyWorkspaceManifestFixture(targetRoot: string): void { | ... | @@ -41,6 +41,13 @@ function copyWorkspaceManifestFixture(targetRoot: string): void { |
| 41 | NodeFS.cpSync(sourcePath, destinationPath); | 41 | NodeFS.cpSync(sourcePath, destinationPath); |
| 42 | } | 42 | } |
| 43 | 43 | ||
| 44 | const mobileDependencies = NodePath.resolve(repoRoot, "apps/mobile/deps"); | ||
| 45 | if (NodeFS.existsSync(mobileDependencies)) { | ||
| 46 | NodeFS.cpSync(mobileDependencies, NodePath.resolve(targetRoot, "apps/mobile/deps"), { | ||
| 47 | recursive: true, | ||
| 48 | }); | ||
| 49 | } | ||
| 50 | |||
| 44 | const patchesDirectory = NodePath.resolve(repoRoot, "patches"); | 51 | const patchesDirectory = NodePath.resolve(repoRoot, "patches"); |
| 45 | if (NodeFS.existsSync(patchesDirectory)) { | 52 | if (NodeFS.existsSync(patchesDirectory)) { |
| 46 | NodeFS.cpSync(patchesDirectory, NodePath.resolve(targetRoot, "patches"), { recursive: true }); | 53 | NodeFS.cpSync(patchesDirectory, NodePath.resolve(targetRoot, "patches"), { recursive: true }); |