| 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 | 14 | } |
| 15 | 15 | |
| 16 | 16 | module.exports = { |
| 17 | // Hash the pinned Screens fork's native source, rather than only its version. | |
| 18 | nativeModuleSourceType: "files", | |
| 17 | 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 | 287 | UILongPressGestureRecognizer *_longPressGestureRecognizer; |
| 288 | 288 | UITapGestureRecognizer *_pressGestureRecognizer; |
| 289 | 289 | NSArray *_contextAccessibilityElements; |
| 290 | BOOL _textLayoutNeedsUpdate; | |
| 290 | 291 | } |
| 291 | 292 | |
| 292 | 293 | + (ComponentDescriptorProvider)componentDescriptorProvider |
| ... | ... | @@ -384,16 +385,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 384 | 385 | _textView.frame = CGRectZero; |
| 385 | 386 | _textView.attributedText = nil; |
| 386 | 387 | _contextAccessibilityElements = nil; |
| 388 | _textLayoutNeedsUpdate = YES; | |
| 387 | 389 | } |
| 388 | 390 | |
| 389 | 391 | - (void)layoutSubviews |
| 390 | 392 | { |
| 391 | 393 | [super layoutSubviews]; |
| 392 | // _textView's frame is assigned inside drawRect, which only fires when | |
| 393 | // state changes. Trigger a redraw whenever the host frame moves out from | |
| 394 | // under it (rotation, parent relayout) so the text view resizes and | |
| 395 | // onTextLayout re-fires with the new line wrapping. | |
| 394 | // Resize during UIKit's layout pass. Doing this in drawRect can leave the | |
| 395 | // text view's drawing surface clipped to its previous width after a pane | |
| 396 | // expands, even though TextKit has already rewrapped the lines. | |
| 396 | 397 | if (!CGRectEqualToRect(_textView.frame, _view.frame)) { |
| 398 | _textView.frame = _view.frame; | |
| 399 | _textLayoutNeedsUpdate = YES; | |
| 397 | 400 | [self setNeedsDisplay]; |
| 398 | 401 | } |
| 399 | 402 | } |
| ... | ... | @@ -455,7 +458,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 455 | 458 | // entirely when nothing actually changed so a JS-side state update made in |
| 456 | 459 | // response to onSelectionChange doesn't deselect what the user is selecting. |
| 457 | 460 | const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; |
| 458 | const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); | |
| 461 | const BOOL frameChanged = _textLayoutNeedsUpdate; | |
| 459 | 462 | if (!textChanged && !frameChanged) { |
| 460 | 463 | return; |
| 461 | 464 | } |
| ... | ... | @@ -482,9 +485,9 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 482 | 485 | _suppressSelectionChange = NO; |
| 483 | 486 | } |
| 484 | 487 | if (frameChanged) { |
| 485 | _textView.frame = _view.frame; | |
| 486 | 488 | _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); |
| 487 | 489 | } |
| 490 | _textLayoutNeedsUpdate = NO; | |
| 488 | 491 | |
| 489 | 492 | // Text attachments have no native link element. Expose their existing runs |
| 490 | 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 | 11 | private var filePresentation: T3NativeFilePresentation? |
| 12 | 12 | |
| 13 | 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 | 25 | AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in |
| 15 | 26 | try self.presentVideo( |
| 16 | 27 | url: url, |
apps/mobile/package.json+3-2| ... | ... | @@ -120,7 +120,7 @@ |
| 120 | 120 | "react-native-nitro-modules": "0.35.9", |
| 121 | 121 | "react-native-reanimated": "4.7.0", |
| 122 | 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 | 124 | "react-native-shiki-engine": "^0.3.12", |
| 125 | 125 | "react-native-svg": "15.15.5", |
| 126 | 126 | "react-native-webview": "14.0.1", |
| ... | ... | @@ -142,7 +142,8 @@ |
| 142 | 142 | "install": { |
| 143 | 143 | "exclude": [ |
| 144 | 144 | "react-native-keyboard-controller", |
| 145 | "typescript" | |
| 145 | "typescript", | |
| 146 | "react-native-screens" | |
| 146 | 147 | ] |
| 147 | 148 | }, |
| 148 | 149 | "autolinking": { |
apps/mobile/src/Stack.tsx+8-4| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | |
| 2 | createV5StackNavigator as createNativeStackNavigator, | |
| 3 | createV5SheetStackNavigator, | |
| 4 | } from "./native/createV5StackNavigator"; | |
| 5 | import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; | |
| 1 | 6 | import { |
| 2 | 7 | createPathConfigForStaticNavigation, |
| 3 | 8 | getPathFromState, |
| ... | ... | @@ -6,7 +11,6 @@ import { |
| 6 | 11 | useNavigation, |
| 7 | 12 | } from "@react-navigation/native"; |
| 8 | 13 | import { |
| 9 | createNativeStackNavigator, | |
| 10 | 14 | createNativeStackScreen, |
| 11 | 15 | type NativeStackNavigationOptions, |
| 12 | 16 | } from "@react-navigation/native-stack"; |
| ... | ... | @@ -189,7 +193,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { |
| 189 | 193 | presentation: "fullScreenModal", |
| 190 | 194 | }; |
| 191 | 195 | |
| 192 | const SettingsContentStack = createNativeStackNavigator({ | |
| 196 | const SettingsContentStack = createV5SheetStackNavigator({ | |
| 193 | 197 | initialRouteName: "Settings", |
| 194 | 198 | screenOptions: { |
| 195 | 199 | ...GLASS_HEADER_OPTIONS, |
| ... | ... | @@ -399,7 +403,7 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 399 | 403 | // The outer stack never owns visible chrome. Settings routes render inside a |
| 400 | 404 | // nested stack whose native header remains mounted, while Clerk owns auth chrome. |
| 401 | 405 | // Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. |
| 402 | const SettingsSheetStack = createNativeStackNavigator({ | |
| 406 | const SettingsSheetStack = createV5SheetStackNavigator({ | |
| 403 | 407 | initialRouteName: "SettingsContent", |
| 404 | 408 | screenOptions: { |
| 405 | 409 | headerShown: false, |
| ... | ... | @@ -670,7 +674,7 @@ function NotFoundScreen() { |
| 670 | 674 | ); |
| 671 | 675 | } |
| 672 | 676 | |
| 673 | const RootStackConfig = createNativeStackNavigator({ | |
| 677 | const RootStackConfig = createWorkspaceStackNavigator({ | |
| 674 | 678 | initialRouteName: "Home", |
| 675 | 679 | layout: RootStackLayout, |
| 676 | 680 | screenOptions: { |
apps/mobile/src/components/CompactBrandTitle.tsx+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns"; | |
| 1 | 2 | import Constants from "expo-constants"; |
| 2 | 3 | import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; |
| 3 | 4 | import { Platform, View } from "react-native"; |
| ... | ... | @@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing"; |
| 14 | 15 | */ |
| 15 | 16 | export function brandTitleOffset(): number { |
| 16 | 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 | 53 | ]} |
| 54 | 54 | options={{ |
| 55 | 55 | headerShown: true, |
| 56 | headerBackVisible: | |
| 57 | !layout.usesSplitView || | |
| 58 | !props.backInSplitView || | |
| 59 | !(props.backInSplitView.onPress || props.onBack), | |
| 56 | 60 | title: props.title, |
| 57 | 61 | unstable_headerSubtitle: props.subtitle || undefined, |
| 58 | 62 | ...(props.matchSearchSurface |
apps/mobile/src/features/files/FileTreeBrowser.tsx+47-34| ... | ... | @@ -1,14 +1,14 @@ |
| 1 | import { LegendList } from "@legendapp/list/react-native"; | |
| 1 | 2 | import type { ProjectEntry } from "@t3tools/contracts"; |
| 2 | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | 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 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 7 | |
| 7 | 8 | import { AppText as Text } from "../../components/AppText"; |
| 8 | 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 9 | 10 | import { cn } from "../../lib/cn"; |
| 10 | 11 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 11 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | |
| 12 | 12 | import { |
| 13 | 13 | buildFileTree, |
| 14 | 14 | flattenFileTree, |
| ... | ... | @@ -17,8 +17,6 @@ import { |
| 17 | 17 | } from "./fileTree"; |
| 18 | 18 | |
| 19 | 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 | 20 | const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; |
| 23 | 21 | |
| 24 | 22 | function cachedFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> { |
| ... | ... | @@ -45,7 +43,8 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 45 | 43 | readonly selected: boolean; |
| 46 | 44 | readonly expanded: boolean; |
| 47 | 45 | readonly loaded: boolean; |
| 48 | readonly onPressDirectory: (path: string) => void; | |
| 46 | readonly loading: boolean; | |
| 47 | readonly onPressDirectory: (path: string, expand: boolean) => void; | |
| 49 | 48 | readonly onPreviewFile?: (path: string) => void; |
| 50 | 49 | readonly onPressFile: (path: string) => void; |
| 51 | 50 | }) { |
| ... | ... | @@ -62,7 +61,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 62 | 61 | }} |
| 63 | 62 | onPress={() => { |
| 64 | 63 | if (node.kind === "directory") { |
| 65 | props.onPressDirectory(node.path); | |
| 64 | props.onPressDirectory(node.path, !props.expanded); | |
| 66 | 65 | return; |
| 67 | 66 | } |
| 68 | 67 | props.onPressFile(node.path); |
| ... | ... | @@ -97,7 +96,9 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 97 | 96 | > |
| 98 | 97 | {node.name} |
| 99 | 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 | 102 | <Text className="text-2xs font-t3-medium text-foreground-tertiary"> |
| 102 | 103 | {node.children.length} |
| 103 | 104 | </Text> |
| ... | ... | @@ -110,10 +111,12 @@ export function FileTreeBrowser(props: { |
| 110 | 111 | readonly entries: ReadonlyArray<ProjectEntry>; |
| 111 | 112 | readonly error: string | null; |
| 112 | 113 | readonly isPending: boolean; |
| 114 | readonly isRefreshing: boolean; | |
| 113 | 115 | readonly searchQuery: string; |
| 114 | 116 | readonly searchTruncated: boolean; |
| 115 | 117 | readonly selectedPath: string | null; |
| 116 | 118 | readonly loadedDirectories: ReadonlySet<string>; |
| 119 | readonly loadingDirectories: ReadonlySet<string>; | |
| 117 | 120 | readonly onLoadDirectory: (path: string) => void; |
| 118 | 121 | readonly onPreviewFile?: (path: string) => void; |
| 119 | 122 | readonly onRefresh: () => void; |
| ... | ... | @@ -127,12 +130,13 @@ export function FileTreeBrowser(props: { |
| 127 | 130 | const insets = useSafeAreaInsets(); |
| 128 | 131 | // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the |
| 129 | 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 | 134 | const { |
| 132 | 135 | onLoadDirectory, |
| 133 | 136 | onPreviewFile, |
| 134 | 137 | onSelectFile, |
| 135 | 138 | loadedDirectories, |
| 139 | loadingDirectories, | |
| 136 | 140 | selectedPath: controlledSelectedPath, |
| 137 | 141 | } = props; |
| 138 | 142 | const controlledSelectedPathRef = useRef(controlledSelectedPath); |
| ... | ... | @@ -184,17 +188,21 @@ export function FileTreeBrowser(props: { |
| 184 | 188 | [], |
| 185 | 189 | ); |
| 186 | 190 | |
| 187 | const toggleDirectory = useCallback((path: string) => { | |
| 188 | setExpandedPaths((current) => { | |
| 189 | const next = new Set(current); | |
| 190 | if (next.has(path)) { | |
| 191 | next.delete(path); | |
| 192 | } else { | |
| 193 | next.add(path); | |
| 194 | } | |
| 195 | return next; | |
| 196 | }); | |
| 197 | }, []); | |
| 191 | const toggleDirectory = useCallback( | |
| 192 | (path: string, expand: boolean) => { | |
| 193 | if (expand) onLoadDirectory(path); | |
| 194 | setExpandedPaths((current) => { | |
| 195 | const next = new Set(current); | |
| 196 | if (next.has(path)) { | |
| 197 | next.delete(path); | |
| 198 | } else { | |
| 199 | next.add(path); | |
| 200 | } | |
| 201 | return next; | |
| 202 | }); | |
| 203 | }, | |
| 204 | [onLoadDirectory], | |
| 205 | ); | |
| 198 | 206 | const handleSelectFile = useCallback( |
| 199 | 207 | (path: string) => { |
| 200 | 208 | if (pendingSelectionTimeoutRef.current !== null) { |
| ... | ... | @@ -219,6 +227,7 @@ export function FileTreeBrowser(props: { |
| 219 | 227 | selected={item.node.kind === "file" && item.node.path === selectedPath} |
| 220 | 228 | expanded={expandedPaths.has(item.node.path)} |
| 221 | 229 | loaded={loadedDirectories.has(item.node.path)} |
| 230 | loading={loadingDirectories.has(item.node.path)} | |
| 222 | 231 | onPressDirectory={toggleDirectory} |
| 223 | 232 | onPreviewFile={onPreviewFile} |
| 224 | 233 | onPressFile={handleSelectFile} |
| ... | ... | @@ -229,36 +238,40 @@ export function FileTreeBrowser(props: { |
| 229 | 238 | handleSelectFile, |
| 230 | 239 | onPreviewFile, |
| 231 | 240 | loadedDirectories, |
| 241 | loadingDirectories, | |
| 232 | 242 | selectedPath, |
| 233 | 243 | toggleDirectory, |
| 234 | 244 | ], |
| 235 | 245 | ); |
| 236 | 246 | |
| 237 | // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and | |
| 238 | // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"` | |
| 239 | // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge | |
| 240 | // blur to a scroll view in the screen's primary position; a scroll view buried in | |
| 241 | // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. | |
| 247 | const extraData = useMemo( | |
| 248 | () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), | |
| 249 | [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], | |
| 250 | ); | |
| 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 | 254 | return ( |
| 243 | <FlatList | |
| 255 | <LegendList | |
| 256 | contentInsetStartAdjustment={headerInset} | |
| 244 | 257 | alwaysBounceVertical |
| 245 | 258 | className="flex-1" |
| 246 | 259 | data={visibleNodes} |
| 247 | 260 | keyExtractor={(item) => item.node.path} |
| 248 | contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} | |
| 261 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | |
| 249 | 262 | scrollIndicatorInsets={ |
| 250 | NATIVE_LIQUID_GLASS_SUPPORTED | |
| 251 | ? { top: headerInset, left: 0, right: 0, bottom: 0 } | |
| 252 | : undefined | |
| 263 | Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined | |
| 253 | 264 | } |
| 254 | 265 | keyboardDismissMode="on-drag" |
| 255 | 266 | keyboardShouldPersistTaps="handled" |
| 256 | initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} | |
| 257 | maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} | |
| 258 | updateCellsBatchingPeriod={16} | |
| 259 | windowSize={5} | |
| 267 | estimatedItemSize={42} | |
| 268 | recycleItems | |
| 269 | maintainVisibleContentPosition | |
| 270 | extraData={extraData} | |
| 260 | 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 | 275 | renderItem={renderItem} |
| 263 | 276 | ListHeaderComponent={ |
| 264 | 277 | <> |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+11-2| ... | ... | @@ -43,6 +43,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe |
| 43 | 43 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; |
| 44 | 44 | import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; |
| 45 | 45 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 46 | import { ThreadInspectorContentStack } from "../threads/thread-inspector-content-stack"; | |
| 46 | 47 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 47 | 48 | import { useFileTreeEntries } from "./useFileTreeEntries"; |
| 48 | 49 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| ... | ... | @@ -561,9 +562,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 561 | 562 | key={JSON.stringify([environmentId, cwd])} |
| 562 | 563 | entries={entriesQuery.entries} |
| 563 | 564 | loadedDirectories={entriesQuery.loadedDirectories} |
| 565 | loadingDirectories={entriesQuery.loadingDirectories} | |
| 564 | 566 | onLoadDirectory={entriesQuery.loadDirectory} |
| 565 | 567 | error={entriesQuery.error} |
| 566 | 568 | isPending={entriesQuery.isPending} |
| 569 | isRefreshing={entriesQuery.isRefreshing} | |
| 567 | 570 | searchQuery={searchQuery} |
| 568 | 571 | searchTruncated={entriesQuery.searchTruncated} |
| 569 | 572 | selectedPath={null} |
| ... | ... | @@ -751,8 +754,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 751 | 754 | // Hand the file navigator to the workspace so it renders beside the |
| 752 | 755 | // navigator, outside this screen's native header. |
| 753 | 756 | const renderWorkspaceInspector = useCallback( |
| 754 | () => renderInspector(inspectorHeaderInset), | |
| 755 | [inspectorHeaderInset, renderInspector], | |
| 757 | () => ( | |
| 758 | <ThreadInspectorContentStack | |
| 759 | mode="files" | |
| 760 | resetKeys={[threadId, cwd]} | |
| 761 | renderFiles={() => renderInspector(inspectorHeaderInset)} | |
| 762 | /> | |
| 763 | ), | |
| 764 | [cwd, inspectorHeaderInset, renderInspector, threadId], | |
| 756 | 765 | ); |
| 757 | 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 | 77 | }; |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); | |
| 81 | ||
| 80 | 82 | function compareNodes( |
| 81 | 83 | left: Pick<FileTreeNode, "kind" | "name">, |
| 82 | 84 | right: Pick<FileTreeNode, "kind" | "name">, |
| ... | ... | @@ -84,7 +86,7 @@ function compareNodes( |
| 84 | 86 | if (left.kind !== right.kind) { |
| 85 | 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 | 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 | 78 | sharesBackground: false, |
| 79 | 79 | tintColor: foregroundColor, |
| 80 | 80 | type: "button" as const, |
| 81 | width: 44, | |
| 82 | 81 | }, |
| 83 | 82 | ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"], |
| 84 | 83 | [foregroundColor, toggleAuxiliaryPane], |
| ... | ... | @@ -89,6 +88,7 @@ export function ThreadFileNavigatorPane(props: { |
| 89 | 88 | key={JSON.stringify([props.environmentId, props.cwd])} |
| 90 | 89 | entries={entriesQuery.entries} |
| 91 | 90 | loadedDirectories={entriesQuery.loadedDirectories} |
| 91 | loadingDirectories={entriesQuery.loadingDirectories} | |
| 92 | 92 | onLoadDirectory={entriesQuery.loadDirectory} |
| 93 | 93 | error={ |
| 94 | 94 | canReadFiles |
| ... | ... | @@ -98,6 +98,7 @@ export function ThreadFileNavigatorPane(props: { |
| 98 | 98 | : (fileAccess.error ?? "This connection cannot read host files.") |
| 99 | 99 | } |
| 100 | 100 | isPending={fileAccess.isPending || entriesQuery.isPending} |
| 101 | isRefreshing={entriesQuery.isRefreshing} | |
| 101 | 102 | searchQuery={searchQuery} |
| 102 | 103 | searchTruncated={entriesQuery.searchTruncated} |
| 103 | 104 | selectedPath={props.selectedPath} |
apps/mobile/src/features/files/useFileTreeEntries.ts+38-5| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 2 | 2 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 3 | 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 | 6 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 7 | 7 | import { projectEnvironment } from "../../state/projects"; |
| ... | ... | @@ -30,7 +30,8 @@ export function useFileTreeEntries(input: { |
| 30 | 30 | }) |
| 31 | 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 | 35 | const refreshVersion = useRef(0); |
| 35 | 36 | const directories = useMemo( |
| 36 | 37 | () => ({ |
| ... | ... | @@ -43,6 +44,9 @@ export function useFileTreeEntries(input: { |
| 43 | 44 | }), |
| 44 | 45 | [cwd, environmentId], |
| 45 | 46 | ); |
| 47 | const [refreshingDirectories, setRefreshingDirectories] = useState<typeof directories | null>( | |
| 48 | null, | |
| 49 | ); | |
| 46 | 50 | useEffect( |
| 47 | 51 | () => () => { |
| 48 | 52 | refreshVersion.current++; |
| ... | ... | @@ -83,6 +87,7 @@ export function useFileTreeEntries(input: { |
| 83 | 87 | entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, |
| 84 | 88 | ), |
| 85 | 89 | ); |
| 90 | entriesChanged(); | |
| 86 | 91 | } else { |
| 87 | 92 | const error = Cause.squash(result.cause); |
| 88 | 93 | directories.errors.set( |
| ... | ... | @@ -113,8 +118,8 @@ export function useFileTreeEntries(input: { |
| 113 | 118 | } |
| 114 | 119 | }; |
| 115 | 120 | visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); |
| 116 | return { revision, entries: [...merged.values()], reachableDirectories }; | |
| 117 | }, [directories, revision, rootData, searchData, searching]); | |
| 121 | return { revision: entriesRevision, entries: [...merged.values()], reachableDirectories }; | |
| 122 | }, [directories, entriesRevision, rootData, searchData, searching]); | |
| 118 | 123 | |
| 119 | 124 | const refresh = useCallback(() => { |
| 120 | 125 | refreshRoot(); |
| ... | ... | @@ -126,6 +131,7 @@ export function useFileTreeEntries(input: { |
| 126 | 131 | directories.pending.clear(); |
| 127 | 132 | directories.errors.clear(); |
| 128 | 133 | const version = ++refreshVersion.current; |
| 134 | setRefreshingDirectories(directories); | |
| 129 | 135 | const remaining = paths.values(); |
| 130 | 136 | const worker = async () => { |
| 131 | 137 | while (version === refreshVersion.current) { |
| ... | ... | @@ -134,9 +140,34 @@ export function useFileTreeEntries(input: { |
| 134 | 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 | 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 | 171 | directories, |
| 141 | 172 | loadDirectory, |
| 142 | 173 | refreshRoot, |
| ... | ... | @@ -147,6 +178,7 @@ export function useFileTreeEntries(input: { |
| 147 | 178 | |
| 148 | 179 | return { |
| 149 | 180 | entries: snapshot.entries, |
| 181 | isRefreshing: refreshingDirectories === directories, | |
| 150 | 182 | error: |
| 151 | 183 | root.error ?? |
| 152 | 184 | (searching ? search.error : null) ?? |
| ... | ... | @@ -158,6 +190,7 @@ export function useFileTreeEntries(input: { |
| 158 | 190 | (searching && (query !== debouncedQuery || search.isPending)), |
| 159 | 191 | searchTruncated: searching && (search.data?.truncated ?? false), |
| 160 | 192 | loadedDirectories: new Set(directories.entries.keys()), |
| 193 | loadingDirectories: new Set(directories.pending.keys()), | |
| 161 | 194 | loadDirectory, |
| 162 | 195 | refresh, |
| 163 | 196 | }; |
apps/mobile/src/features/home/HomeHeader.tsx+74-39| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 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 | 4 | import type { SearchBarCommands } from "react-native-screens"; |
| 5 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; | |
| 4 | 6 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 5 | 7 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 6 | 8 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| ... | ... | @@ -9,11 +11,14 @@ import { |
| 9 | 11 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 10 | 12 | } from "../layout/native-mail-search-toolbar"; |
| 11 | 13 | import { buildHomeListFilterMenu } from "./home-list-filter-menu"; |
| 14 | import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; | |
| 12 | 15 | import type { HomeHeaderProps } from "./HomeHeader.types"; |
| 13 | 16 | |
| 14 | 17 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| 15 | 18 | |
| 16 | 19 | export function HomeHeader(props: HomeHeaderProps) { |
| 20 | const primaryColumn = use(NativePrimaryColumnContext); | |
| 21 | const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null; | |
| 17 | 22 | const searchBarRef = useRef<SearchBarCommands>(null); |
| 18 | 23 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 19 | 24 | // The list uses a fixed creation order and ignores sort/group options, so |
| ... | ... | @@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 36 | 41 | // Static header config (glass, title, fonts) lives in Stack.tsx |
| 37 | 42 | // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. |
| 38 | 43 | headerTintColor: iconColor, |
| 39 | unstable_headerRightItems: () => [ | |
| 40 | withNativeGlassHeaderItem({ | |
| 41 | accessibilityLabel: "Open settings", | |
| 42 | icon: { name: "ellipsis", type: "sfSymbol" } as const, | |
| 43 | identifier: "home-settings", | |
| 44 | label: "", | |
| 45 | onPress: props.onOpenSettings, | |
| 46 | type: "button", | |
| 47 | }), | |
| 48 | ], | |
| 44 | unstable_headerRightItems: () => | |
| 45 | iPadSidebar | |
| 46 | ? createSidebarHeaderItems({ | |
| 47 | filterIcon: hasCustomListOptions | |
| 48 | ? "line.3.horizontal.decrease.circle.fill" | |
| 49 | : "line.3.horizontal.decrease.circle", | |
| 50 | filterMenu, | |
| 51 | onOpenSettings: props.onOpenSettings, | |
| 52 | }) | |
| 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 | 63 | // The keys below are set per-branch (not `undefined`) so a later |
| 50 | 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 | 67 | headerSearchBarOptions: { |
| 74 | 68 | ref: searchBarRef, |
| 75 | 69 | autoCapitalize: "none" as const, |
| 76 | 70 | hideNavigationBar: false, |
| 71 | hideWhenScrolling: false, | |
| 72 | obscureBackground: false, | |
| 73 | placement: "stacked" as const, | |
| 74 | allowToolbarIntegration: false, | |
| 77 | 75 | placeholder: "Search", |
| 78 | onCancelButtonPress: () => { | |
| 79 | props.onSearchQueryChange(""); | |
| 76 | onCancelButtonPress: () => 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) => { | |
| 82 | props.onSearchQueryChange(event.nativeEvent.text); | |
| 88 | unstable_headerToolbarItems: () => [ | |
| 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 | }, | |
| 85 | }), | |
| 120 | }), | |
| 86 | 121 | }} |
| 87 | 122 | /> |
| 88 | 123 | |
| 89 | {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( | |
| 124 | {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( | |
| 90 | 125 | <NativeHeaderToolbar placement="bottom"> |
| 91 | 126 | <NativeHeaderToolbar.Menu |
| 92 | 127 | accessibilityLabel="Filter threads" |
apps/mobile/src/features/home/HomeRouteScreen.tsx+11-3| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { use } from "react"; | |
| 2 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; | |
| 1 | 3 | import * as Arr from "effect/Array"; |
| 2 | 4 | import * as Order from "effect/Order"; |
| 3 | 5 | import { useNavigation } from "@react-navigation/native"; |
| ... | ... | @@ -12,6 +14,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 12 | 14 | import { useWorkspaceState } from "../../state/workspace"; |
| 13 | 15 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 14 | 16 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 17 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | |
| 15 | 18 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; |
| 16 | 19 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 17 | 20 | import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; |
| ... | ... | @@ -29,6 +32,9 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle |
| 29 | 32 | |
| 30 | 33 | export function HomeRouteScreen() { |
| 31 | 34 | const { width: windowWidth } = useWindowDimensions(); |
| 35 | const nativePrimaryColumn = use(NativePrimaryColumnContext); | |
| 36 | const columnMetrics = useNativeColumnLayoutMetrics(); | |
| 37 | const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; | |
| 32 | 38 | const { layout, panes } = useAdaptiveWorkspaceLayout(); |
| 33 | 39 | const projects = useProjects(); |
| 34 | 40 | const threads = useNavigationThreadShells(); |
| ... | ... | @@ -120,7 +126,7 @@ export function HomeRouteScreen() { |
| 120 | 126 | |
| 121 | 127 | // In split layouts the persistent sidebar IS the thread list — Home becomes |
| 122 | 128 | // an empty detail pane so selecting a thread never transitions layouts. |
| 123 | if (layout.usesSplitView) { | |
| 129 | if (layout.usesSplitView && !nativePrimaryColumn) { | |
| 124 | 130 | return ( |
| 125 | 131 | <> |
| 126 | 132 | <NativeStackScreenOptions |
| ... | ... | @@ -169,10 +175,12 @@ export function HomeRouteScreen() { |
| 169 | 175 | shallow-merged. The brand slot also doubles as the connection |
| 170 | 176 | status surface while an environment reconnects. */} |
| 171 | 177 | <NativeStackScreenOptions |
| 172 | optionsVersion={windowWidth} | |
| 178 | optionsVersion={headerWidth} | |
| 173 | 179 | options={{ |
| 174 | 180 | ...getConnectionAwareBrandHeaderOptions({ |
| 175 | headerWidth: windowWidth, | |
| 181 | headerWidth, | |
| 182 | trailingItemCount: | |
| 183 | nativePrimaryColumn && Platform.OS === "ios" && Platform.isPad ? 2 : 1, | |
| 176 | 184 | onOpenEnvironments: () => |
| 177 | 185 | navigation.navigate("SettingsSheet", { |
| 178 | 186 | screen: "SettingsContent", |
apps/mobile/src/features/home/HomeScreen.tsx+78-15| ... | ... | @@ -13,15 +13,18 @@ import { |
| 13 | 13 | import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 14 | 14 | import { useAtomValue } from "@effect/atom-react"; |
| 15 | 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 | 17 | import { |
| 18 | 18 | ActivityIndicator, |
| 19 | 19 | Platform, |
| 20 | ScrollView, | |
| 20 | 21 | View, |
| 22 | type ScrollViewProps, | |
| 21 | 23 | type GestureResponderEvent, |
| 22 | 24 | type NativeScrollEvent, |
| 23 | 25 | type NativeSyntheticEvent, |
| 24 | 26 | } from "react-native"; |
| 27 | import { ScrollViewMarker } from "react-native-screens"; | |
| 25 | 28 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 26 | 29 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 27 | 30 | |
| ... | ... | @@ -30,7 +33,10 @@ import { EmptyState } from "../../components/EmptyState"; |
| 30 | 33 | import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; |
| 31 | 34 | import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; |
| 32 | 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 | 40 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 35 | 41 | import { useThreadSearch } from "../../state/queries"; |
| 36 | 42 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| ... | ... | @@ -140,7 +146,6 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; |
| 140 | 146 | // swipe-row-activation), so render further ahead: a fast fling then reaches |
| 141 | 147 | // rows that are already built instead of rows still being rebuilt. |
| 142 | 148 | const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; |
| 143 | const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; | |
| 144 | 149 | /** |
| 145 | 150 | * Top spacing between the list and the Android custom header. The Android |
| 146 | 151 | * header is rendered in-flow above this screen and |
| ... | ... | @@ -219,17 +224,61 @@ function HomeTopContentSpacer() { |
| 219 | 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 | 264 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 223 | 265 | |
| 224 | 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 | 276 | const queuedThreadKeys = useQueuedThreadKeys(); |
| 226 | 277 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 227 | 278 | const insets = useSafeAreaInsets(); |
| 228 | 279 | const { fabClearance } = useAndroidControlSizing(); |
| 229 | const iosBottomToolbarClearance = | |
| 230 | Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED | |
| 231 | ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT | |
| 232 | : 0; | |
| 280 | // UIKit's column safe area already includes its bottom toolbar. | |
| 281 | const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); | |
| 233 | 282 | const searchEnvironmentIds = useMemo( |
| 234 | 283 | () => |
| 235 | 284 | props.selectedEnvironmentId === null |
| ... | ... | @@ -563,12 +612,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 563 | 612 | inboxReturnAt: threadListInboxReturns.returnedAt, |
| 564 | 613 | snoozedShelfExpanded, |
| 565 | 614 | settledShelfExpanded, |
| 566 | selectedThreadKey: null, | |
| 615 | selectedThreadKey, | |
| 567 | 616 | }); |
| 568 | 617 | }, [ |
| 569 | 618 | workingShelfEnabled, |
| 570 | 619 | workingShelfExpanded, |
| 571 | 620 | pendingOrder, |
| 621 | selectedThreadKey, | |
| 572 | 622 | queuedThreadKeys, |
| 573 | 623 | nowMinute, |
| 574 | 624 | snoozeWakeTick, |
| ... | ... | @@ -715,6 +765,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 715 | 765 | const thread = item.item.thread; |
| 716 | 766 | return ( |
| 717 | 767 | <ThreadListV2Row |
| 768 | pane={primaryColumn ? "sidebar" : "screen"} | |
| 769 | selected={scopedThreadKey(thread.environmentId, thread.id) === selectedThreadKey} | |
| 770 | fullSwipeWidth={fullSwipeWidth} | |
| 718 | 771 | onNewThreadOnBranch={props.onNewThreadOnBranch} |
| 719 | 772 | thread={thread} |
| 720 | 773 | variant={item.item.variant} |
| ... | ... | @@ -802,6 +855,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 802 | 855 | props.onDeletePendingTask, |
| 803 | 856 | props.onSelectPendingTask, |
| 804 | 857 | props.onSelectThread, |
| 858 | primaryColumn, | |
| 859 | selectedThreadKey, | |
| 860 | fullSwipeWidth, | |
| 805 | 861 | props.onNewThreadOnBranch, |
| 806 | 862 | props.savedConnectionsById, |
| 807 | 863 | resolveProviderInstance, |
| ... | ... | @@ -831,6 +887,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 831 | 887 | listEnvironments, |
| 832 | 888 | savedConnectionsById: props.savedConnectionsById, |
| 833 | 889 | searchQuery: props.searchQuery, |
| 890 | selectedThreadKey, | |
| 834 | 891 | threadSearchMatchByKey, |
| 835 | 892 | // Rows read it for their reorder menu items. |
| 836 | 893 | workingShelfEnabled, |
| ... | ... | @@ -838,6 +895,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 838 | 895 | [ |
| 839 | 896 | projectByKey, |
| 840 | 897 | props.searchQuery, |
| 898 | selectedThreadKey, | |
| 841 | 899 | props.savedConnectionsById, |
| 842 | 900 | listEnvironments, |
| 843 | 901 | threadSearchMatchByKey, |
| ... | ... | @@ -868,14 +926,15 @@ export function HomeScreen(props: HomeScreenProps) { |
| 868 | 926 | |
| 869 | 927 | if (!hasAnyThreads) { |
| 870 | 928 | return ( |
| 871 | <View className="flex-1 bg-screen android:bg-header"> | |
| 929 | <View className={containerClassName}> | |
| 872 | 930 | <View |
| 873 | 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 | 934 | Platform.OS === "android" && "overflow-hidden rounded-t-[28px]", |
| 876 | 935 | )} |
| 877 | 936 | style={{ |
| 878 | paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance, | |
| 937 | paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24), | |
| 879 | 938 | paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, |
| 880 | 939 | }} |
| 881 | 940 | > |
| ... | ... | @@ -958,12 +1017,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 958 | 1017 | } |
| 959 | 1018 | |
| 960 | 1019 | return ( |
| 961 | <View className="flex-1 bg-screen android:bg-header"> | |
| 1020 | <View className={containerClassName}> | |
| 962 | 1021 | <View |
| 963 | 1022 | className={ |
| 964 | 1023 | Platform.OS === "android" |
| 965 | 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 | 1028 | {/* Shared with the iPad sidebar: cells are reused across data |
| ... | ... | @@ -976,6 +1035,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 976 | 1035 | onTouchStart={(event) => trackListTouches(event, true)} |
| 977 | 1036 | onTouchEnd={(event) => trackListTouches(event, false)} |
| 978 | 1037 | onTouchCancel={(event) => trackListTouches(event, false)} |
| 1038 | renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} | |
| 979 | 1039 | data={threadListV2Items} |
| 980 | 1040 | renderItem={renderV2Item} |
| 981 | 1041 | keyExtractor={v2KeyExtractor} |
| ... | ... | @@ -997,16 +1057,19 @@ export function HomeScreen(props: HomeScreenProps) { |
| 997 | 1057 | ListEmptyComponent={v2ListEmpty} |
| 998 | 1058 | style={{ flex: 1 }} |
| 999 | 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 | 1063 | showsVerticalScrollIndicator={false} |
| 1002 | 1064 | keyboardDismissMode="on-drag" |
| 1003 | 1065 | keyboardShouldPersistTaps="handled" |
| 1004 | 1066 | {...scrollGateHandlers} |
| 1005 | 1067 | scrollEventThrottle={16} |
| 1006 | 1068 | contentContainerStyle={{ |
| 1069 | paddingHorizontal: primaryColumn ? 8 : 0, | |
| 1007 | 1070 | paddingBottom: |
| 1008 | 1071 | Platform.OS === "ios" |
| 1009 | ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance | |
| 1072 | ? iosBottomClearance | |
| 1010 | 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 | 61 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 62 | 62 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; |
| 63 | 63 | import { WorkspaceContentWidthContext } from "./workspace-content-width"; |
| 64 | import { | |
| 65 | NativeWorkspaceModeContext, | |
| 66 | NativeWorkspaceInspectorContext, | |
| 67 | } from "../../native/v5-workspace-context"; | |
| 64 | 68 | |
| 65 | 69 | interface AdaptiveWorkspaceContextValue { |
| 66 | 70 | readonly layout: Layout; |
| ... | ... | @@ -234,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent( |
| 234 | 238 | }, |
| 235 | 239 | ) { |
| 236 | 240 | const projectGroupingMode = props.projectGroupingMode; |
| 241 | const nativeWorkspace = use(NativeWorkspaceModeContext); | |
| 237 | 242 | const { width, height } = useWindowDimensions(); |
| 238 | 243 | const pathname = props.pathname; |
| 239 | 244 | const navigation = useNavigation(); |
| ... | ... | @@ -468,9 +473,10 @@ function AdaptiveWorkspaceLayoutContent( |
| 468 | 473 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, |
| 469 | 474 | ); |
| 470 | 475 | useEffect(() => { |
| 476 | if (nativeWorkspace) return; | |
| 471 | 477 | const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; |
| 472 | 478 | renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); |
| 473 | }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); | |
| 479 | }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); | |
| 474 | 480 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ |
| 475 | 481 | opacity: Math.min(1, renderedSidebarWidth.value / 80), |
| 476 | 482 | width: renderedSidebarWidth.value, |
| ... | ... | @@ -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 | 587 | return ( |
| 569 | 588 | <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}> |
| 570 | 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 | 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 | |
| 7 | * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side | |
| 8 | * silently drops the item and hides the navigation toolbar entirely. Screens | |
| 9 | * that send it must fall back to standard search/toolbar primitives when this | |
| 10 | * is false. | |
| 6 | * Group search, filtering, and composition on platforms with Liquid Glass. | |
| 7 | * The v5 header adapter turns this intent into UISearchController and native | |
| 8 | * toolbar items. Earlier iOS versions use separate search and toolbar options. | |
| 11 | 9 | */ |
| 12 | 10 | export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; |
| 13 | 11 | |
| ... | ... | @@ -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. | |
| 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. | |
| 21 | * Describe the thread-list search and actions for the native header adapter. | |
| 29 | 22 | */ |
| 30 | 23 | export function createNativeMailSearchToolbarItem( |
| 31 | 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 | |
| 8 | 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 9 | 9 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 10 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 10 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 11 | 11 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 12 | 12 | import { SettingsRow } from "./components/SettingsRow"; |
| 13 | 13 | import { SettingsSection } from "./components/SettingsSection"; |
| ... | ... | @@ -29,14 +29,19 @@ export function SettingsRouteScreen() { |
| 29 | 29 | |
| 30 | 30 | return ( |
| 31 | 31 | <> |
| 32 | {Platform.OS === "ios" ? ( | |
| 33 | <NativeStackScreenOptions options={{ headerBackVisible: !layout.usesSplitView }} /> | |
| 34 | ) : null} | |
| 32 | 35 | {Platform.OS === "ios" && layout.usesSplitView ? ( |
| 33 | <NativeHeaderToolbar placement="left"> | |
| 34 | <NativeHeaderToolbar.Button | |
| 35 | accessibilityLabel="Go back" | |
| 36 | icon="chevron.left" | |
| 37 | onPress={() => navigation.goBack()} | |
| 38 | /> | |
| 39 | </NativeHeaderToolbar> | |
| 36 | <> | |
| 37 | <NativeHeaderToolbar placement="left"> | |
| 38 | <NativeHeaderToolbar.Button | |
| 39 | accessibilityLabel="Go back" | |
| 40 | icon="chevron.left" | |
| 41 | onPress={() => navigation.goBack()} | |
| 42 | /> | |
| 43 | </NativeHeaderToolbar> | |
| 44 | </> | |
| 40 | 45 | ) : null} |
| 41 | 46 | <SettingsEnvironmentFilterHeader closeSettings /> |
| 42 | 47 | {Platform.OS === "android" ? ( |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+6| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 10 | 10 | import { SymbolView } from "../../components/AppSymbol"; |
| 11 | 11 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 12 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | |
| 12 | 13 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 13 | 14 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 14 | 15 | import { Alert, Platform, Pressable, View } from "react-native"; |
| ... | ... | @@ -254,6 +255,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ |
| 254 | 255 | |
| 255 | 256 | export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { |
| 256 | 257 | const insets = useSafeAreaInsets(); |
| 258 | const nativeColumnMetrics = useNativeColumnLayoutMetrics(); | |
| 257 | 259 | const navigation = useNavigation(); |
| 258 | 260 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); |
| 259 | 261 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); |
| ... | ... | @@ -1202,6 +1204,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1202 | 1204 | Platform.OS === "android" |
| 1203 | 1205 | ? themeVariables["--color-card-alt"] |
| 1204 | 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 | 1211 | paddingBottom: |
| 1206 | 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 | 29 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 30 | 30 | import { useState, type ReactNode } from "react"; |
| 31 | 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 | 33 | import { ScrollView, useWindowDimensions, View } from "react-native"; |
| 34 | 34 | import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; |
| 35 | 35 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+3-3| ... | ... | @@ -664,11 +664,11 @@ function ThreadRouteContent( |
| 664 | 664 | () => |
| 665 | 665 | inspectorMode === null ? null : ( |
| 666 | 666 | <ThreadInspectorContentStack |
| 667 | Files={FilesInspector} | |
| 668 | Git={GitInspector} | |
| 667 | renderFiles={FilesInspector} | |
| 668 | renderGit={GitInspector} | |
| 669 | 669 | mode={inspectorMode} |
| 670 | 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 | import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; | |
| 1 | 2 | import type { |
| 2 | 3 | EnvironmentId, |
| 3 | 4 | ModelSelection, |
| ... | ... | @@ -15,10 +16,7 @@ import { |
| 15 | 16 | getProviderOptionDescriptors, |
| 16 | 17 | } from "@t3tools/shared/model"; |
| 17 | 18 | import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; |
| 18 | import { | |
| 19 | createNativeStackNavigator, | |
| 20 | type NativeStackNavigationProp, | |
| 21 | } from "@react-navigation/native-stack"; | |
| 19 | import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; | |
| 22 | 20 | import * as Haptics from "expo-haptics"; |
| 23 | 21 | import { AsyncResult } from "effect/reactivity"; |
| 24 | 22 | import { |
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+2-4| ... | ... | @@ -1,8 +1,6 @@ |
| 1 | import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; | |
| 1 | 2 | import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; |
| 2 | import { | |
| 3 | createNativeStackNavigator, | |
| 4 | type NativeStackNavigationOptions, | |
| 5 | } from "@react-navigation/native-stack"; | |
| 3 | import { type NativeStackNavigationOptions } from "@react-navigation/native-stack"; | |
| 6 | 4 | import type { ReactNode } from "react"; |
| 7 | 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 | import { useEffect, useState, type ComponentType, type ReactNode } from "react"; | |
| 1 | import { useEffect, useState, type ReactNode } from "react"; | |
| 2 | 2 | import { View } from "react-native"; |
| 3 | 3 | |
| 4 | 4 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; |
| ... | ... | @@ -43,11 +43,11 @@ function InspectorContentPane(props: { |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | 45 | export function ThreadInspectorContentStack(props: { |
| 46 | readonly Files: ComponentType; | |
| 47 | readonly Git: ComponentType; | |
| 46 | readonly renderFiles: () => ReactNode; | |
| 47 | readonly renderGit?: () => ReactNode; | |
| 48 | 48 | readonly mode: ThreadInspectorMode; |
| 49 | 49 | readonly resetKeys: readonly [string | null, string | null]; |
| 50 | readonly Route?: ComponentType; | |
| 50 | readonly renderRoute?: () => ReactNode; | |
| 51 | 51 | }) { |
| 52 | 52 | const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>( |
| 53 | 53 | () => new Set([props.mode]), |
| ... | ... | @@ -81,10 +81,6 @@ export function ThreadInspectorContentStack(props: { |
| 81 | 81 | return () => clearTimeout(timeout); |
| 82 | 82 | }, [props.mode]); |
| 83 | 83 | |
| 84 | const Files = props.Files; | |
| 85 | const Git = props.Git; | |
| 86 | const Route = props.Route; | |
| 87 | ||
| 88 | 84 | return ( |
| 89 | 85 | <View className="flex-1"> |
| 90 | 86 | <InspectorContentPane |
| ... | ... | @@ -92,24 +88,31 @@ export function ThreadInspectorContentStack(props: { |
| 92 | 88 | resetKeys={props.resetKeys} |
| 93 | 89 | visible={props.mode === "files"} |
| 94 | 90 | > |
| 95 | <Files /> | |
| 96 | </InspectorContentPane> | |
| 97 | <InspectorContentPane | |
| 98 | mounted={mountedModes.has("git") || props.mode === "git"} | |
| 99 | resetKeys={props.resetKeys} | |
| 100 | visible={props.mode === "git"} | |
| 101 | > | |
| 102 | <Git /> | |
| 91 | <InspectorRenderer render={props.renderFiles} /> | |
| 103 | 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 | 103 | <InspectorContentPane |
| 106 | 104 | mounted={mountedModes.has("route") || props.mode === "route"} |
| 107 | 105 | resetKeys={props.resetKeys} |
| 108 | 106 | visible={props.mode === "route"} |
| 109 | 107 | > |
| 110 | <Route /> | |
| 108 | <InspectorRenderer render={props.renderRoute} /> | |
| 111 | 109 | </InspectorContentPane> |
| 112 | 110 | ) : null} |
| 113 | 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 | 149 | }>; |
| 150 | 150 | |
| 151 | 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 | 116 | expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 |
| 117 | 117 | expo-widgets@58.0.11: d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed |
| 118 | 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 | 120 | react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d |
| 121 | 121 | react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c |
| 122 | 122 | react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 |
| 123 | react-native-screens@4.28.0: cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483 | |
| 124 | 123 | |
| 125 | 124 | importers: |
| 126 | 125 | |
| ... | ... | @@ -292,7 +291,7 @@ importers: |
| 292 | 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 | 292 | '@react-navigation/native-stack': |
| 294 | 293 | specifier: 7.17.6 |
| 295 | version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(cdeee8948497d4b80ffe015e2a003650) | |
| 294 | version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(141a742cd91f55378cde709a48a3af06) | |
| 296 | 295 | '@shikijs/core': |
| 297 | 296 | specifier: 4.2.0 |
| 298 | 297 | version: 4.2.0 |
| ... | ... | @@ -454,7 +453,7 @@ importers: |
| 454 | 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 | 454 | react-native-gesture-handler: |
| 456 | 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 | 457 | react-native-image-viewing: |
| 459 | 458 | specifier: ^0.2.2 |
| 460 | 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 | 473 | specifier: ~5.9.1 |
| 475 | 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 | 475 | react-native-screens: |
| 477 | specifier: ~4.28.0 | |
| 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) | |
| 476 | specifier: file:deps/react-native-screens-5.0.0-t3.7.tgz | |
| 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 | 478 | react-native-shiki-engine: |
| 480 | 479 | specifier: ^0.3.12 |
| 481 | 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 | 10070 | react: '*' |
| 10072 | 10071 | react-native: '*' |
| 10073 | 10072 | |
| 10074 | react-native-screens@4.28.0: | |
| 10075 | resolution: {integrity: sha512-0/79Z8RCibuaAHti+DJ2Dq0m6UFWh4+p1bH58K3cjiQ7IDWs5kU9tCAj4lXcJMHHonbF2mIE2BeaPSEvOpIiFg==} | |
| 10073 | react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz: | |
| 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 | 10076 | peerDependencies: |
| 10077 | 10077 | react: '*' |
| 10078 | 10078 | react-native: '*' |
| ... | ... | @@ -15198,7 +15198,7 @@ snapshots: |
| 15198 | 15198 | optionalDependencies: |
| 15199 | 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 | 15202 | dependencies: |
| 15203 | 15203 | '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) |
| 15204 | 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 | 15206 | react: 19.3.0 |
| 15207 | 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 | 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 | 15210 | sf-symbols-typescript: 2.2.0 |
| 15211 | 15211 | warn-once: 0.1.1 |
| 15212 | 15212 | transitivePeerDependencies: |
| ... | ... | @@ -21123,7 +21123,7 @@ snapshots: |
| 21123 | 21123 | transitivePeerDependencies: |
| 21124 | 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 | 21127 | dependencies: |
| 21128 | 21128 | '@types/react-test-renderer': 19.1.0 |
| 21129 | 21129 | invariant: 2.2.4 |
| ... | ... | @@ -21173,7 +21173,7 @@ snapshots: |
| 21173 | 21173 | react: 19.3.0 |
| 21174 | 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 | 21177 | dependencies: |
| 21178 | 21178 | react: 19.3.0 |
| 21179 | 21179 | react-freeze: 1.0.4(react@19.3.0) |
pnpm-workspace.yaml-1| ... | ... | @@ -284,7 +284,6 @@ patchedDependencies: |
| 284 | 284 | react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch |
| 285 | 285 | react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch |
| 286 | 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 | 288 | peerDependencyRules: |
| 290 | 289 | allowAny: |
scripts/release-smoke.ts+7| ... | ... | @@ -41,6 +41,13 @@ function copyWorkspaceManifestFixture(targetRoot: string): void { |
| 41 | 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 | 51 | const patchesDirectory = NodePath.resolve(repoRoot, "patches"); |
| 45 | 52 | if (NodeFS.existsSync(patchesDirectory)) { |
| 46 | 53 | NodeFS.cpSync(patchesDirectory, NodePath.resolve(targetRoot, "patches"), { recursive: true }); |