diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz new file mode 100644 index 0000000000000000000000000000000000000000..860521672c8a4cca87f2981a6415fc91a5a8e779 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz differ diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js index e6d4d2d218d6c0457deccf7da8a929cfdef03c6b..8f55f236996d4c5915b2923f00a9e3984ea5ce70 100644 --- a/apps/mobile/fingerprint.config.js +++ b/apps/mobile/fingerprint.config.js @@ -14,5 +14,7 @@ if (!majorVersion) { } module.exports = { + // Hash the pinned Screens fork's native source, rather than only its version. + nativeModuleSourceType: "files", extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }], }; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index c0db129ee8c8603020e67e3fbd978cfc3b564421..3115a5a34a0420a12dcc4c82b26738bf4a3d4e1f 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -287,6 +287,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) UILongPressGestureRecognizer *_longPressGestureRecognizer; UITapGestureRecognizer *_pressGestureRecognizer; NSArray *_contextAccessibilityElements; + BOOL _textLayoutNeedsUpdate; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -384,16 +385,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) _textView.frame = CGRectZero; _textView.attributedText = nil; _contextAccessibilityElements = nil; + _textLayoutNeedsUpdate = YES; } - (void)layoutSubviews { [super layoutSubviews]; - // _textView's frame is assigned inside drawRect, which only fires when - // state changes. Trigger a redraw whenever the host frame moves out from - // under it (rotation, parent relayout) so the text view resizes and - // onTextLayout re-fires with the new line wrapping. + // Resize during UIKit's layout pass. Doing this in drawRect can leave the + // text view's drawing surface clipped to its previous width after a pane + // expands, even though TextKit has already rewrapped the lines. if (!CGRectEqualToRect(_textView.frame, _view.frame)) { + _textView.frame = _view.frame; + _textLayoutNeedsUpdate = YES; [self setNeedsDisplay]; } } @@ -455,7 +458,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) // entirely when nothing actually changed so a JS-side state update made in // response to onSelectionChange doesn't deselect what the user is selecting. const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; - const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); + const BOOL frameChanged = _textLayoutNeedsUpdate; if (!textChanged && !frameChanged) { return; } @@ -482,9 +485,9 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) _suppressSelectionChange = NO; } if (frameChanged) { - _textView.frame = _view.frame; _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); } + _textLayoutNeedsUpdate = NO; // Text attachments have no native link element. Expose their existing runs // at the measured glyph bounds, without inserting views into text layout. diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift new file mode 100644 index 0000000000000000000000000000000000000000..43070a614582c21d1996719a5d6ad1d7d132111e --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -0,0 +1,48 @@ +import ExpoModulesCore +import UIKit + +/// Reports the body geometry of a native navigation column. +final class T3LayoutMetricsView: ExpoView { + let onMetricsChange = EventDispatcher() + private var lastMetrics: NSDictionary? + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + isUserInteractionEnabled = false + registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in + view.publishMetrics() + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + lastMetrics = nil + publishMetrics() + } + + override func layoutSubviews() { + super.layoutSubviews() + publishMetrics() + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + publishMetrics() + } + + private func publishMetrics() { + guard window != nil, bounds.width > 0, bounds.height > 0 else { return } + let metrics: [String: Any] = [ + "width": bounds.width, "height": bounds.height, + "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", + "safeArea": [ + "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, + "left": safeAreaInsets.left, "right": safeAreaInsets.right, + ], + ] + let next = metrics as NSDictionary + guard lastMetrics?.isEqual(next) != true else { return } + lastMetrics = next + onMetricsChange(metrics) + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index a5813c8a0e8b651745fdfe26241fb42091c5f2e5..3674e232ee5b62e99e39892ab502cac450d6ced0 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -11,6 +11,17 @@ public final class T3NativeControlsModule: Module { private var filePresentation: T3NativeFilePresentation? public func definition() -> ModuleDefinition { + Constants { + if #available(iOS 26.0, *) { + return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil] + } + return ["supportsWorkspaceColumns": false] + } + View(T3LayoutMetricsView.self) { + ViewName("LayoutMetrics") + Events("onMetricsChange") + } + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 53b5476d3a2da94acd0b98383b2a3ec53858ae90..ca295e389fe20e81d0e49792bbeaa44eb3da528d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -120,7 +120,7 @@ "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.7.0", "react-native-safe-area-context": "~5.9.1", - "react-native-screens": "~4.28.0", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.5", "react-native-webview": "14.0.1", @@ -142,7 +142,8 @@ "install": { "exclude": [ "react-native-keyboard-controller", - "typescript" + "typescript", + "react-native-screens" ] }, "autolinking": { diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 14fbc8b82f51e0f421408e4c55cad9f1dd171c33..b215ba3380700db97bf266d2c972c9ab9f203eea 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,3 +1,8 @@ +import { + createV5StackNavigator as createNativeStackNavigator, + createV5SheetStackNavigator, +} from "./native/createV5StackNavigator"; +import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; import { createPathConfigForStaticNavigation, getPathFromState, @@ -6,7 +11,6 @@ import { useNavigation, } from "@react-navigation/native"; import { - createNativeStackNavigator, createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; @@ -189,7 +193,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { presentation: "fullScreenModal", }; -const SettingsContentStack = createNativeStackNavigator({ +const SettingsContentStack = createV5SheetStackNavigator({ initialRouteName: "Settings", screenOptions: { ...GLASS_HEADER_OPTIONS, @@ -399,7 +403,7 @@ const SettingsContentStack = createNativeStackNavigator({ // The outer stack never owns visible chrome. Settings routes render inside a // nested stack whose native header remains mounted, while Clerk owns auth chrome. // Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. -const SettingsSheetStack = createNativeStackNavigator({ +const SettingsSheetStack = createV5SheetStackNavigator({ initialRouteName: "SettingsContent", screenOptions: { headerShown: false, @@ -670,7 +674,7 @@ function NotFoundScreen() { ); } -const RootStackConfig = createNativeStackNavigator({ +const RootStackConfig = createWorkspaceStackNavigator({ initialRouteName: "Home", layout: RootStackLayout, screenOptions: { diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index f64370c3cead26cc6257ea4358ca6024db2751d9..5120e7c1868d0aa1716fcd6f67b4f85766ec5a8f 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -1,3 +1,4 @@ +import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns"; import Constants from "expo-constants"; import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { Platform, View } from "react-native"; @@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing"; */ export function brandTitleOffset(): number { if (Platform.OS !== "ios") return 0; - return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; + return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0; } /** diff --git a/apps/mobile/src/components/ScreenHeader.tsx b/apps/mobile/src/components/ScreenHeader.tsx index 6024ceec08d93fc8129a76d27f69e11266dd63e0..4a3877571966e6024f9c3183ecec99b31d0887c3 100644 --- a/apps/mobile/src/components/ScreenHeader.tsx +++ b/apps/mobile/src/components/ScreenHeader.tsx @@ -53,6 +53,10 @@ export function ScreenHeader(props: ScreenHeaderProps) { ]} options={{ headerShown: true, + headerBackVisible: + !layout.usesSplitView || + !props.backInSplitView || + !(props.backInSplitView.onPress || props.onBack), title: props.title, unstable_headerSubtitle: props.subtitle || undefined, ...(props.matchSearchSurface diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index df18347f336cd0d033acfebe68f077a511ca6525..ed42f5345abe7ef7eea3f6f360415624dda47d52 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -1,14 +1,14 @@ +import { LegendList } from "@legendapp/list/react-native"; import type { ProjectEntry } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; +import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, flattenFileTree, @@ -17,8 +17,6 @@ import { } from "./fileTree"; const fileTreeCache = new WeakMap, ReadonlyArray>(); -const FILE_TREE_INITIAL_RENDER_COUNT = 20; -const FILE_TREE_RENDER_BATCH_SIZE = 12; const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; function cachedFileTree(entries: ReadonlyArray): ReadonlyArray { @@ -45,7 +43,8 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly selected: boolean; readonly expanded: boolean; readonly loaded: boolean; - readonly onPressDirectory: (path: string) => void; + readonly loading: boolean; + readonly onPressDirectory: (path: string, expand: boolean) => void; readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; }) { @@ -62,7 +61,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { }} onPress={() => { if (node.kind === "directory") { - props.onPressDirectory(node.path); + props.onPressDirectory(node.path, !props.expanded); return; } props.onPressFile(node.path); @@ -97,7 +96,9 @@ const FileTreeRow = memo(function FileTreeRow(props: { > {node.name} - {node.kind === "directory" && props.loaded ? ( + {node.kind === "directory" && props.expanded && props.loading ? ( + + ) : node.kind === "directory" && props.loaded ? ( {node.children.length} @@ -110,10 +111,12 @@ export function FileTreeBrowser(props: { readonly entries: ReadonlyArray; readonly error: string | null; readonly isPending: boolean; + readonly isRefreshing: boolean; readonly searchQuery: string; readonly searchTruncated: boolean; readonly selectedPath: string | null; readonly loadedDirectories: ReadonlySet; + readonly loadingDirectories: ReadonlySet; readonly onLoadDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; @@ -127,12 +130,13 @@ export function FileTreeBrowser(props: { const insets = useSafeAreaInsets(); // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; + const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0; const { onLoadDirectory, onPreviewFile, onSelectFile, loadedDirectories, + loadingDirectories, selectedPath: controlledSelectedPath, } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); @@ -184,17 +188,21 @@ export function FileTreeBrowser(props: { [], ); - const toggleDirectory = useCallback((path: string) => { - setExpandedPaths((current) => { - const next = new Set(current); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }, []); + const toggleDirectory = useCallback( + (path: string, expand: boolean) => { + if (expand) onLoadDirectory(path); + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, + [onLoadDirectory], + ); const handleSelectFile = useCallback( (path: string) => { if (pendingSelectionTimeoutRef.current !== null) { @@ -219,6 +227,7 @@ export function FileTreeBrowser(props: { selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} loaded={loadedDirectories.has(item.node.path)} + loading={loadingDirectories.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} onPressFile={handleSelectFile} @@ -229,36 +238,40 @@ export function FileTreeBrowser(props: { handleSelectFile, onPreviewFile, loadedDirectories, + loadingDirectories, selectedPath, toggleDirectory, ], ); - // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and - // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"` - // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge - // blur to a scroll view in the screen's primary position; a scroll view buried in - // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. + const extraData = useMemo( + () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), + [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], + ); + + // UIKit owns the header inset on every supported iOS version. Keep the + // list as direct screen content so automatic inset adjustment can find it. return ( - item.node.path} - contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} scrollIndicatorInsets={ - NATIVE_LIQUID_GLASS_SUPPORTED - ? { top: headerInset, left: 0, right: 0, bottom: 0 } - : undefined + Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined } keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" - initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} - maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} - updateCellsBatchingPeriod={16} - windowSize={5} + estimatedItemSize={42} + recycleItems + maintainVisibleContentPosition + extraData={extraData} contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} - refreshControl={} + refreshControl={ + + } renderItem={renderItem} ListHeaderComponent={ <> diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index e0421e2a587c0ce819419ca2204ef58b33a61e9e..6c8cac046bb20aa9f18e634f8672d320ef9f81eb 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -43,6 +43,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { ThreadInspectorContentStack } from "../threads/thread-inspector-content-stack"; import { FileTreeBrowser } from "./FileTreeBrowser"; import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; @@ -561,9 +562,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { key={JSON.stringify([environmentId, cwd])} entries={entriesQuery.entries} loadedDirectories={entriesQuery.loadedDirectories} + loadingDirectories={entriesQuery.loadingDirectories} onLoadDirectory={entriesQuery.loadDirectory} error={entriesQuery.error} isPending={entriesQuery.isPending} + isRefreshing={entriesQuery.isRefreshing} searchQuery={searchQuery} searchTruncated={entriesQuery.searchTruncated} selectedPath={null} @@ -751,8 +754,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { // Hand the file navigator to the workspace so it renders beside the // navigator, outside this screen's native header. const renderWorkspaceInspector = useCallback( - () => renderInspector(inspectorHeaderInset), - [inspectorHeaderInset, renderInspector], + () => ( + renderInspector(inspectorHeaderInset)} + /> + ), + [cwd, inspectorHeaderInset, renderInspector, threadId], ); useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index a8bde49488fc225557f231389f5a91ae86c56e47..9b945d1a5f5bc4b0d52eecfa07d19bdef270a580 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -77,6 +77,8 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode { }; } +const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + function compareNodes( left: Pick, right: Pick, @@ -84,7 +86,7 @@ function compareNodes( if (left.kind !== right.kind) { return left.kind === "directory" ? -1 : 1; } - return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }); + return fileNameCollator.compare(left.name, right.name); } export function buildFileTree(entries: ReadonlyArray): ReadonlyArray { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index d35ed1a98bdb88e9c6b374d670b4a8f7b7b52555..998c23b1e3001a1358b9df0061df1b83d7e5e040 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -78,7 +78,6 @@ export function ThreadFileNavigatorPane(props: { sharesBackground: false, tintColor: foregroundColor, type: "button" as const, - width: 44, }, ] as ComponentProps["headerRightBarButtonItems"], [foregroundColor, toggleAuxiliaryPane], @@ -89,6 +88,7 @@ export function ThreadFileNavigatorPane(props: { key={JSON.stringify([props.environmentId, props.cwd])} entries={entriesQuery.entries} loadedDirectories={entriesQuery.loadedDirectories} + loadingDirectories={entriesQuery.loadingDirectories} onLoadDirectory={entriesQuery.loadDirectory} error={ canReadFiles @@ -98,6 +98,7 @@ export function ThreadFileNavigatorPane(props: { : (fileAccess.error ?? "This connection cannot read host files.") } isPending={fileAccess.isPending || entriesQuery.isPending} + isRefreshing={entriesQuery.isRefreshing} searchQuery={searchQuery} searchTruncated={entriesQuery.searchTruncated} selectedPath={props.selectedPath} diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index 604fb3bd5231ea85b4feff02a6d1582c207973ec..1553736dcdbd55f92f1b05ba5a0944b99fe7c122 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -1,7 +1,7 @@ import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; @@ -30,7 +30,8 @@ export function useFileTreeEntries(input: { }) : null, ); - const [revision, render] = useReducer((value: number) => value + 1, 0); + const [, render] = useReducer((value: number) => value + 1, 0); + const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0); const refreshVersion = useRef(0); const directories = useMemo( () => ({ @@ -43,6 +44,9 @@ export function useFileTreeEntries(input: { }), [cwd, environmentId], ); + const [refreshingDirectories, setRefreshingDirectories] = useState( + null, + ); useEffect( () => () => { refreshVersion.current++; @@ -83,6 +87,7 @@ export function useFileTreeEntries(input: { entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, ), ); + entriesChanged(); } else { const error = Cause.squash(result.cause); directories.errors.set( @@ -113,8 +118,8 @@ export function useFileTreeEntries(input: { } }; visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); - return { revision, entries: [...merged.values()], reachableDirectories }; - }, [directories, revision, rootData, searchData, searching]); + return { revision: entriesRevision, entries: [...merged.values()], reachableDirectories }; + }, [directories, entriesRevision, rootData, searchData, searching]); const refresh = useCallback(() => { refreshRoot(); @@ -126,6 +131,7 @@ export function useFileTreeEntries(input: { directories.pending.clear(); directories.errors.clear(); const version = ++refreshVersion.current; + setRefreshingDirectories(directories); const remaining = paths.values(); const worker = async () => { while (version === refreshVersion.current) { @@ -134,9 +140,34 @@ export function useFileTreeEntries(input: { await loadDirectory(next.value, true); } }; - for (let index = 0; index < Math.min(4, paths.size); index++) void worker(); + const queries = + cwd !== null && environmentId !== null + ? [ + projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }), + ...(searching + ? [ + projectEnvironment.searchEntries({ + environmentId, + input: { cwd, query: debouncedQuery, limit: 200 }, + }), + ] + : []), + ] + : []; + const work = [ + ...queries.map((atom) => + executeAtomQuery(appAtomRegistry, atom, { reportFailure: false, reportDefect: false }), + ), + ...Array.from({ length: Math.min(4, paths.size) }, () => worker()), + ]; render(); + return Promise.all(work).finally(() => { + if (version === refreshVersion.current) setRefreshingDirectories(null); + }); }, [ + cwd, + debouncedQuery, + environmentId, directories, loadDirectory, refreshRoot, @@ -147,6 +178,7 @@ export function useFileTreeEntries(input: { return { entries: snapshot.entries, + isRefreshing: refreshingDirectories === directories, error: root.error ?? (searching ? search.error : null) ?? @@ -158,6 +190,7 @@ export function useFileTreeEntries(input: { (searching && (query !== debouncedQuery || search.isPending)), searchTruncated: searching && (search.data?.truncated ?? false), loadedDirectories: new Set(directories.entries.keys()), + loadingDirectories: new Set(directories.pending.keys()), loadDirectory, refresh, }; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e1cc7448eb591018aec6f40fc38d43febf749ecb..ac58bcc8c7871c4ede78977fab4f5fa6e2953a49 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,6 +1,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useRef } from "react"; +import { use, useCallback, useRef } from "react"; +import { Platform } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; +import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -9,11 +11,14 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; +import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; export function HomeHeader(props: HomeHeaderProps) { + const primaryColumn = use(NativePrimaryColumnContext); + const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null; const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; // The list uses a fixed creation order and ignores sort/group options, so @@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) { // Static header config (glass, title, fonts) lives in Stack.tsx // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. headerTintColor: iconColor, - unstable_headerRightItems: () => [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ], + unstable_headerRightItems: () => + iPadSidebar + ? createSidebarHeaderItems({ + filterIcon: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle", + filterMenu, + onOpenSettings: props.onOpenSettings, + }) + : [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open settings", + icon: { name: "ellipsis", type: "sfSymbol" } as const, + identifier: "home-settings", + label: "", + onPress: props.onOpenSettings, + type: "button", + }), + ], // The keys below are set per-branch (not `undefined`) so a later // reapply cannot clobber options owned by NativeHeaderToolbar. - ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ...(iPadSidebar ? { - unstable_headerToolbarItems: () => [ - createNativeMailSearchToolbarItem({ - composeButtonId: "home-new-task", - composeSystemImageName: "square.and.pencil", - filterMenu, - filterButtonId: "home-filter", - filterSystemImageName: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onStartNewTask, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "home-search-text", - showsSearchDismissButton: true, - }), - ], - } - : { - // Pre-Liquid-Glass iOS: standard pull-down search in the nav - // bar; create + sort live in the plain bottom toolbar below. headerSearchBarOptions: { ref: searchBarRef, autoCapitalize: "none" as const, hideNavigationBar: false, + hideWhenScrolling: false, + obscureBackground: false, + placement: "stacked" as const, + allowToolbarIntegration: false, placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); - }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); - }, + onCancelButtonPress: () => props.onSearchQueryChange(""), + onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text), }, - }), + unstable_headerToolbarItems: () => [], + } + : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? { + headerSearchBarOptions: { + ref: searchBarRef, + autoCapitalize: "none" as const, + onCancelButtonPress: () => props.onSearchQueryChange(""), + }, + unstable_headerToolbarItems: () => [ + createNativeMailSearchToolbarItem({ + composeButtonId: "home-new-task", + composeSystemImageName: "square.and.pencil", + filterMenu, + filterButtonId: "home-filter", + filterSystemImageName: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onComposePress: props.onStartNewTask, + onSearchTextChange: props.onSearchQueryChange, + placeholder: "Search", + searchTextChangeId: "home-search-text", + showsSearchDismissButton: true, + }), + ], + } + : { + // Pre-Liquid-Glass iOS: standard pull-down search in the nav + // bar; create + sort live in the plain bottom toolbar below. + headerSearchBarOptions: { + ref: searchBarRef, + autoCapitalize: "none" as const, + hideNavigationBar: false, + placeholder: "Search", + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + onChangeText: (event) => { + props.onSearchQueryChange(event.nativeEvent.text); + }, + }, + }), }} /> - {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( + {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( navigation.navigate("SettingsSheet", { screen: "SettingsContent", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6538697da2975e14592cd371661538af5a73d07b..48f074a3438f6f4a27a389ac0a9bebfba5887547 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -13,15 +13,18 @@ import { import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import { ActivityIndicator, Platform, + ScrollView, View, + type ScrollViewProps, type GestureResponderEvent, type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; +import { ScrollViewMarker } from "react-native-screens"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -30,7 +33,10 @@ import { EmptyState } from "../../components/EmptyState"; import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey } from "../../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useThreadSearch } from "../../state/queries"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; @@ -140,7 +146,6 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; // swipe-row-activation), so render further ahead: a fast fling then reaches // rows that are already built instead of rows still being rebuilt. const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; -const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android * header is rendered in-flow above this screen and @@ -219,17 +224,61 @@ function HomeTopContentSpacer() { return ; } +function HomeScrollView(props: ComponentProps) { + const insets = useSafeAreaInsets(); + const primaryColumn = use(NativePrimaryColumnContext); + if (Platform.OS !== "ios") return ; + + // v5 needs the actual content scroll view registered with its screen controller. + // The layout observer and header siblings prevent UIKit from finding it implicitly. + return ( + = 27 ? "automatic" : "soft", + }} + > + + + ); +} + +function renderHomeScrollView(props: ScrollViewProps) { + // FlatList clones this element with its cells and ref. Keep the marker inside + // the component so those cells remain children of the actual ScrollView. + return ; +} + /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { + const primaryColumn = use(NativePrimaryColumnContext); + const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen"; + const containerClassName = cn( + "flex-1", + Platform.OS === "android" ? "bg-header" : contentBackground, + ); + const columnMetrics = useNativeColumnLayoutMetrics(); + const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null; + const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined; const queuedThreadKeys = useQueuedThreadKeys(); const openSwipeableRef = useRef(null); const insets = useSafeAreaInsets(); const { fabClearance } = useAndroidControlSizing(); - const iosBottomToolbarClearance = - Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED - ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT - : 0; + // UIKit's column safe area already includes its bottom toolbar. + const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); const searchEnvironmentIds = useMemo( () => props.selectedEnvironmentId === null @@ -563,12 +612,13 @@ export function HomeScreen(props: HomeScreenProps) { inboxReturnAt: threadListInboxReturns.returnedAt, snoozedShelfExpanded, settledShelfExpanded, - selectedThreadKey: null, + selectedThreadKey, }); }, [ workingShelfEnabled, workingShelfExpanded, pendingOrder, + selectedThreadKey, queuedThreadKeys, nowMinute, snoozeWakeTick, @@ -715,6 +765,9 @@ export function HomeScreen(props: HomeScreenProps) { const thread = item.item.thread; return ( + @@ -958,12 +1017,12 @@ export function HomeScreen(props: HomeScreenProps) { } return ( - + {/* Shared with the iPad sidebar: cells are reused across data @@ -976,6 +1035,7 @@ export function HomeScreen(props: HomeScreenProps) { onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)} onTouchCancel={(event) => trackListTouches(event, false)} + renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} @@ -997,16 +1057,19 @@ export function HomeScreen(props: HomeScreenProps) { ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + contentInsetAdjustmentBehavior="never" + contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined} + contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" {...scrollGateHandlers} scrollEventThrottle={16} contentContainerStyle={{ + paddingHorizontal: primaryColumn ? 8 : 0, paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance + ? iosBottomClearance : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88), }} /> diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index caaed841ce01db01c417820e7ca8288a9f34e20a..801d5e85da80ef1dc73588a8a43a3be655f7deed 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -61,6 +61,10 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; +import { + NativeWorkspaceModeContext, + NativeWorkspaceInspectorContext, +} from "../../native/v5-workspace-context"; interface AdaptiveWorkspaceContextValue { readonly layout: Layout; @@ -234,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; + const nativeWorkspace = use(NativeWorkspaceModeContext); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -468,9 +473,10 @@ function AdaptiveWorkspaceLayoutContent( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, ); useEffect(() => { + if (nativeWorkspace) return; const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); - }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); + }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, @@ -565,6 +571,19 @@ function AdaptiveWorkspaceLayoutContent( ], ); + if (nativeWorkspace) { + return ( + + + 0 }} + > + {props.children} + + + + ); + } return ( diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c8104f9f4b1ab1c8dad46a14464e1d09dfd5d3c1 --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -0,0 +1,342 @@ +import { + createNavigatorFactory, + NavigationContext, + NavigationRouteContext, + StackRouter, + useNavigationBuilder, + type NavigatorTypeBagBase, + type ParamListBase, + type StackActionHelpers, + type StackNavigationState, + type StackRouterOptions, + type StaticConfig, + type TypedNavigator, +} from "@react-navigation/native"; +import { + NativeStackView, + type NativeStackNavigationEventMap, + type NativeStackNavigationOptions, + type NativeStackNavigatorProps, + type NativeStackTypeBag, +} from "@react-navigation/native-stack"; +import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; +import { View } from "react-native"; +import { Split, type SplitHostCommands } from "react-native-screens"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopedThreadKey } from "../../lib/scopedEntities"; + +import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; +import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios"; +import { + modalEnvelopeOptions, + V5CardStackView, + V5StackView, +} from "../../native/createV5StackNavigator.ios"; +import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns"; +import { V5StackHeader } from "../../native/V5StackHeader.ios"; +import { + nativeWorkspacePopAction, + projectWorkspaceStack, + partitionStackPresentations, +} from "../../native/workspace-stack-projection"; +import { + NativePrimaryColumnContext, + NativeWorkspaceInspectorContext, + NativeWorkspaceModeContext, +} from "../../native/v5-workspace-context"; + +type ViewProps = ComponentProps; +type Descriptor = ViewProps["descriptors"][string]; +type Route = ViewProps["state"]["routes"][number]; + +function ColumnScreen(props: { + readonly descriptor: Descriptor; + readonly primary?: boolean; + readonly selectedThreadKey?: string | null; + readonly onDismiss: (routeKey: string) => void; + readonly navigation: ViewProps["navigation"]; +}) { + const { descriptor } = props; + const primaryColumn = useMemo( + () => (props.primary ? { selectedThreadKey: props.selectedThreadKey ?? null } : null), + [props.primary, props.selectedThreadKey], + ); + const key = descriptor.route.key; + return ( + { + if (event.nativeEvent.isNativeDismiss) props.onDismiss(key); + }} + onWillAppear={() => + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }) + } + onDidAppear={() => + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }) + } + onWillDisappear={() => + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }) + } + onDidDisappear={() => + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } }) + } + > + + + + + + {descriptor.render()} + + + + + + ); +} + +function WorkspaceColumns( + props: ViewProps & { readonly primary: Route; readonly detail: Route[] }, +) { + const { layout, panes, togglePrimarySidebar, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const inspector = use(NativeWorkspaceInspectorContext); + const hostRef = useRef(null); + const activeDetailKey = props.detail.at(-1)?.key; + const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; + const selectedThreadKey = + threadParams && + "environmentId" in threadParams && + "threadId" in threadParams && + typeof threadParams.environmentId === "string" && + typeof threadParams.threadId === "string" + ? scopedThreadKey( + EnvironmentId.make(threadParams.environmentId), + ThreadId.make(threadParams.threadId), + ) + : null; + const handleNativeDismiss = useCallback( + (key: string) => { + const state = props.navigation.getState(); + const action = nativeWorkspacePopAction(state, key); + if (action) props.navigation.dispatch(action); + }, + [props.navigation], + ); + + useEffect(() => { + // In compact size classes UIKit exposes one column. Selecting a thread + // changes the visible column without rebuilding either navigation stack. + if (layout.usesSplitView) return; + hostRef.current?.show(activeDetailKey ? "secondary" : "primary"); + }, [activeDetailKey, layout.usesSplitView]); + + const primary = props.descriptors[props.primary.key]; + if (!primary) return null; + return ( + { + if (inspector.visible) toggleAuxiliaryPane(); + }} + columnMetrics={{ + minimumPrimaryColumnWidth: 280, + maximumPrimaryColumnWidth: 380, + preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, + minimumSecondaryColumnWidth: 320, + }} + > + + + + + + + + + [ + { + type: "button", + label: "", + icon: { type: "sfSymbol", name: "sidebar.left" }, + onPress: togglePrimarySidebar, + }, + ], + }} + canGoBack={false} + /> + + + props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + } + /> + + + {props.detail.map((route) => { + const descriptor = props.descriptors[route.key]; + return descriptor ? ( + + ) : null; + })} + + + + {/* Inspector content supplies its own navigation header. */} + + + ); +} + +function WorkspaceStackView(props: ViewProps) { + const projection = projectWorkspaceStack(props.state, (route) => { + const presentation = props.descriptors[route.key]?.options.presentation; + return presentation !== undefined && presentation !== "card"; + }); + // Linking normally restores Home via initialRouteName. A detail-only history + // must stay a single stack: placeholder descriptors cannot own an interactive + // sidebar because React Navigation rejects their actions and setOptions. + const primaryRoute = projection.primary; + if (!primaryRoute) return ; + const primaryDescriptor = props.descriptors[primaryRoute.key]!; + const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; + const baseDescriptor: Descriptor = { + ...primaryDescriptor, + route: baseRoute, + options: { headerShown: false }, + render: () => , + }; + const overlays = partitionStackPresentations(projection.overlays, (route) => { + const presentation = props.descriptors[route.key]?.options.presentation; + return presentation !== undefined && presentation !== "card"; + }); + const routes = [baseRoute, ...overlays.map((group) => group[0]!)]; + return ( + { + const route = group[0]!; + const descriptor = props.descriptors[route.key]!; + return [ + route.key, + { + ...descriptor, + options: modalEnvelopeOptions(descriptor.options), + render: () => ( + + ), + }, + ]; + }), + ), + [baseRoute.key]: baseDescriptor, + }} + /> + ); +} + +function WorkspaceStackNavigator({ + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + ...rest +}: NativeStackNavigatorProps) { + const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< + StackNavigationState, + StackRouterOptions, + StackActionHelpers, + NativeStackNavigationOptions, + NativeStackNavigationEventMap + >(StackRouter, { + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + }); + const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView; + return ( + + + + + + ); +} + +type WorkspaceTypeBag< + ParamList extends ParamListBase, + NavigatorID extends string | undefined, +> = Omit, "Navigator"> & { + Navigator: typeof WorkspaceStackNavigator; +}; + +export function createWorkspaceStackNavigator< + const ParamList extends ParamListBase, + const NavigatorID extends string | undefined = string | undefined, + const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag, + const Config extends StaticConfig = StaticConfig, +>(config?: Config): TypedNavigator { + return createNavigatorFactory(WorkspaceStackNavigator)(config); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fb22815dd2c1c98e60f5373d2b814e1d983ac74b --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx @@ -0,0 +1 @@ +export { createNativeStackNavigator as createWorkspaceStackNavigator } from "@react-navigation/native-stack"; diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts index 34d5570e61094746fb9fe3847280f619eee9c27d..44ef2a992e8b98055dca57bc066e027a27887085 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -3,11 +3,9 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens" import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; /** - * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass - * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side - * silently drops the item and hides the navigation toolbar entirely. Screens - * that send it must fall back to standard search/toolbar primitives when this - * is false. + * Group search, filtering, and composition on platforms with Liquid Glass. + * The v5 header adapter turns this intent into UISearchController and native + * toolbar items. Earlier iOS versions use separate search and toolbar options. */ export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; @@ -20,12 +18,7 @@ type NativeMailSearchToolbarInput = Omit< >; /** - * Builds the patched react-native-screens Mail-style bottom search toolbar. - * - * Keeping this behind an app-level helper makes the iOS-only RNS patch an - * explicit layout primitive instead of a per-screen object literal. Android can - * keep using platform-specific header/search primitives without depending on - * this helper. + * Describe the thread-list search and actions for the native header adapter. */ export function createNativeMailSearchToolbarItem( input: NativeMailSearchToolbarInput, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index f0dae4ebb0e1a3b9c3a7e89ca39617fccc73a3f0..1f474a9d56b8561bd89973b9de6af0358108a321 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -29,14 +29,19 @@ export function SettingsRouteScreen() { return ( <> + {Platform.OS === "ios" ? ( + + ) : null} {Platform.OS === "ios" && layout.usesSplitView ? ( - - navigation.goBack()} - /> - + <> + + navigation.goBack()} + /> + + ) : null} {Platform.OS === "android" ? ( diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 767a1fa1dc7f17e85c94acde1162379d883f236e..70579053bd9398692b05d52cb4b3e3ae698ff2f5 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -9,6 +9,7 @@ import { import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { SymbolView } from "../../components/AppSymbol"; import { ScreenHeader } from "../../components/ScreenHeader"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Pressable, View } from "react-native"; @@ -254,6 +255,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { const insets = useSafeAreaInsets(); + const nativeColumnMetrics = useNativeColumnLayoutMetrics(); const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); @@ -1202,6 +1204,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) Platform.OS === "android" ? themeVariables["--color-card-alt"] : terminalTheme.background, + // The v5 column extends behind UIKit chrome. Its measured top + // reservation is zero when UIKit has already inset the body or moved the bar. + paddingTop: + Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0, paddingBottom: Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0, }} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx index cf123a736559f400581895cd23f349a5cb7f93b9..5df9282d13f9032a209e56f94e2eb7f640f81d88 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -29,7 +29,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { useState, type ReactNode } from "react"; import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; -import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { ScrollView, useWindowDimensions, View } from "react-native"; import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; import { NativeHeaderToolbar } from "../../native/StackHeader"; diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 9f1e0a9411e94ca37fce28552b22ab592c036be5..5ff9397a179d98436493414c6f8403d0079daf67 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -664,11 +664,11 @@ function ThreadRouteContent( () => inspectorMode === null ? null : ( ), [ diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index ac03553eb7dc751bfd86571a50b924d3071a4f15..1ff108198c13440db118ed7fcbd593cf1b4bd8da 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,3 +1,4 @@ +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import type { EnvironmentId, ModelSelection, @@ -15,10 +16,7 @@ import { getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; -import { - createNativeStackNavigator, - type NativeStackNavigationProp, -} from "@react-navigation/native-stack"; +import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; import { AsyncResult } from "effect/reactivity"; import { diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index 1198b06cb6a441185d9e57fc6f22b10d01f6ade2..6f434ad656b71a47e52dc42f4d77dda161e13ffa 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -1,8 +1,6 @@ +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; -import { - createNativeStackNavigator, - type NativeStackNavigationOptions, -} from "@react-navigation/native-stack"; +import { type NativeStackNavigationOptions } from "@react-navigation/native-stack"; import type { ReactNode } from "react"; import { Platform } from "react-native"; diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 5a1601b0e5ae9f77c06af5662787d988ed2be5f4..b36218cd29aa8d426b28f3296f5af2f60bcd37dd 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { View } from "react-native"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; @@ -43,11 +43,11 @@ function InspectorContentPane(props: { } export function ThreadInspectorContentStack(props: { - readonly Files: ComponentType; - readonly Git: ComponentType; + readonly renderFiles: () => ReactNode; + readonly renderGit?: () => ReactNode; readonly mode: ThreadInspectorMode; readonly resetKeys: readonly [string | null, string | null]; - readonly Route?: ComponentType; + readonly renderRoute?: () => ReactNode; }) { const [mountedModes, setMountedModes] = useState>( () => new Set([props.mode]), @@ -81,10 +81,6 @@ export function ThreadInspectorContentStack(props: { return () => clearTimeout(timeout); }, [props.mode]); - const Files = props.Files; - const Git = props.Git; - const Route = props.Route; - return ( - + - - - - {Route ? ( + {props.renderGit ? ( + + + + ) : null} + {props.renderRoute ? ( - + ) : null} ); } + +// Render callbacks carry changing route data; they are not component types. +function InspectorRenderer(props: { readonly render: () => ReactNode }) { + return <>{props.render()}; +} diff --git a/apps/mobile/src/lib/reserved-regions.ts b/apps/mobile/src/lib/reserved-regions.ts new file mode 100644 index 0000000000000000000000000000000000000000..318896239536518069758af400d1be52a286d97e --- /dev/null +++ b/apps/mobile/src/lib/reserved-regions.ts @@ -0,0 +1,12 @@ +/** Geometry of the body inside a native navigation column. */ +export interface NativeLayoutMetrics { + readonly width: number; + readonly height: number; + readonly horizontalSizeClass: "compact" | "regular"; + readonly safeArea: { + readonly top: number; + readonly bottom: number; + readonly left: number; + readonly right: number; + }; +} diff --git a/apps/mobile/src/native/NativeColumnContent.ios.tsx b/apps/mobile/src/native/NativeColumnContent.ios.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77b03098b36c74d0c12458fee1b78631d973657f --- /dev/null +++ b/apps/mobile/src/native/NativeColumnContent.ios.tsx @@ -0,0 +1,31 @@ +import { HeaderHeightContext } from "@react-navigation/elements"; +import { useState, type ReactNode } from "react"; +import { View } from "react-native"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +import { NativeColumnLayoutMetricsContext } from "./native-layout-metrics"; +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; +import { NativeLayoutObserver } from "./NativeLayoutObserver"; + +export function NativeColumnContent(props: { + readonly children: ReactNode; + readonly primary?: boolean; +}) { + const [metrics, setMetrics] = useState(null); + return ( + + + + {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} + + + + + {props.children} + + + + + + + ); +} diff --git a/apps/mobile/src/native/NativeLayoutObserver.ios.tsx b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d284d9edeec5ab0ac23967cd527635af1a42194 --- /dev/null +++ b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx @@ -0,0 +1,33 @@ +import { requireNativeView, requireOptionalNativeModule } from "expo"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; + +interface ObserverProps extends ViewProps { + readonly onMetricsChange?: (event: NativeSyntheticEvent) => void; +} + +// An older development client can still run the compact app while it rebuilds. +function resolveObserver() { + try { + const module = requireOptionalNativeModule<{ + readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown }; + }>("T3NativeControls"); + if (!module?.ViewPrototypes?.T3NativeControls_LayoutMetrics) return null; + return requireNativeView("T3NativeControls", "LayoutMetrics"); + } catch { + return null; + } +} +const Observer = resolveObserver(); + +export function NativeLayoutObserver(props: { + readonly onChange?: (metrics: NativeLayoutMetrics) => void; +}) { + return Observer ? ( + props.onChange?.(event.nativeEvent) : undefined} + style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }} + /> + ) : null; +} diff --git a/apps/mobile/src/native/NativeLayoutObserver.tsx b/apps/mobile/src/native/NativeLayoutObserver.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2b24a55a2137917c1ae80d90baf38b1bf174bf5 --- /dev/null +++ b/apps/mobile/src/native/NativeLayoutObserver.tsx @@ -0,0 +1,7 @@ +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; + +export function NativeLayoutObserver(_props: { + readonly onChange?: (metrics: NativeLayoutMetrics) => void; +}) { + return null; +} diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c85529b1dbf26d89c4b23bc451e5d1c57ef4af00 --- /dev/null +++ b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx @@ -0,0 +1,7 @@ +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; +const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>( + "T3NativeControls", +); +export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = + Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true; diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4bb7aa16ac9bab79cbda7fdb010e87258ca2871c --- /dev/null +++ b/apps/mobile/src/native/NativeWorkspaceColumns.tsx @@ -0,0 +1,3 @@ +import { Platform } from "react-native"; +// Android retains its existing navigator. The iOS file checks native availability. +export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad; diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2c843dab7cdc62e3e119362f353360298ee7a7c6 --- /dev/null +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -0,0 +1,253 @@ +import type { + NativeStackHeaderItem, + NativeStackHeaderItemMenu, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { isValidElement, useRef } from "react"; +import { Platform, StyleSheet } from "react-native"; +import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; +import { + SearchBar, + Stack, + type HeaderBarButtonMailSearchToolbarItem, + type SearchBarCommands, + type StackHeaderConfigProps, + type StackHeaderMenuElementIOS, + type StackHeaderMenuIOS, + type HeaderBarButtonItemWithMenu, + type HeaderBarButtonSearchBarPlacementItem, +} from "react-native-screens"; + +type HeaderItem = NonNullable["leadingItems"]>[number]; +type ToolbarItem = + | NativeStackHeaderItem + | HeaderBarButtonMailSearchToolbarItem + | HeaderBarButtonSearchBarPlacementItem; +type Options = NativeStackNavigationOptions & { + readonly unstable_navigationItemStyle?: "editor" | "navigator"; + readonly headerSubtitle?: string; + readonly unstable_headerSubtitle?: string; + readonly headerSubtitleStyle?: { color?: string; fontSize?: number; fontFamily?: string }; + readonly unstable_headerCenterItems?: () => NativeStackHeaderItem[]; + readonly unstable_headerToolbarItems?: () => ToolbarItem[]; +}; + +type MenuElementInput = + | NativeStackHeaderItemMenu["menu"]["items"][number] + | HeaderBarButtonItemWithMenu["menu"]["items"][number]; +type MenuInput = { readonly title?: string; readonly items: readonly MenuElementInput[] }; + +function convertIcon( + icon: + | Extract["icon"] + | HeaderBarButtonItemWithMenu["icon"], +) { + if (!icon) return undefined; + if (icon.type === "image") + return icon.tinted === false + ? { type: "imageSource" as const, imageSource: icon.source } + : { type: "templateSource" as const, templateSource: icon.source }; + return icon; +} + +function convertMenu(menu: MenuInput, id: string): StackHeaderMenuIOS { + const children = menu.items.flatMap((item, index) => { + if ("hidden" in item && item.hidden) return []; + const childId = `${id}:${index}`; + if (item.type === "submenu") { + return [ + { + ...convertMenu({ items: item.items }, childId), + title: "label" in item ? item.label : item.title, + displayInline: + "inline" in item + ? item.inline + : "displayInline" in item + ? item.displayInline + : undefined, + }, + ]; + } + // Use actions even for checked entries: the app owns their durable state. + return [ + { + id: childId, + type: "menuItem" as const, + itemType: "action" as const, + title: "label" in item ? item.label : item.title, + icon: convertIcon(item.icon), + onPress: item.disabled ? undefined : item.onPress, + keepsMenuPresented: item.keepsMenuPresented, + disabled: item.disabled, + destructive: item.destructive, + subtitle: + ("subtitle" in item ? item.subtitle : undefined) ?? + ("description" in item ? item.description : undefined), + state: item.state, + }, + ]; + }); + return { id, type: "menu", title: menu.title, children }; +} + +function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderItem[] { + return items.map((item, index): HeaderItem => { + const id = + item.type === "spacing" ? `${prefix}:${index}` : (item.identifier ?? `${prefix}:${index}`); + switch (item.type) { + case "spacing": + return { type: "spacer", id, sizing: "fixed", width: item.spacing }; + case "custom": + return { + type: "item", + id, + render: () => item.element, + hidesSharedBackground: item.hidesSharedBackground, + }; + case "menu": + return { + type: "item", + id, + identifier: item.identifier, + title: item.label || item.accessibilityLabel || undefined, + icon: convertIcon(item.icon), + menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), + hidesSharedBackground: item.hidesSharedBackground, + }; + case "button": + return { + type: "item", + id, + identifier: item.identifier, + title: item.label || item.accessibilityLabel || undefined, + icon: convertIcon(item.icon), + onPress: item.disabled ? undefined : item.onPress, + hidesSharedBackground: item.hidesSharedBackground, + }; + } + }); +} + +/** Translate the existing screen options to v5's native header API. */ +export function V5StackHeader(props: { + readonly options: Options; + readonly canGoBack: boolean; + readonly primary?: boolean; +}) { + const { options } = props; + const theme = useMobileNavigationTheme(); + const searchRef = useRef(null); + const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; + const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); + const trailing = convertItems( + [ + ...(options.unstable_headerCenterItems?.() ?? []), + ...(options.unstable_headerRightItems?.(itemProps) ?? []), + ], + "trailing", + ); + const toolbar = options.unstable_headerToolbarItems?.() ?? []; + const mailSearch = toolbar.find( + (item): item is HeaderBarButtonMailSearchToolbarItem => item.type === "mailSearchToolbar", + ); + const bottomSearch = Boolean(mailSearch) && Platform.OS === "ios" && !Platform.isPad; + const bottom = toolbar.flatMap((item, index) => { + if (item.type === "mailSearchToolbar") return []; + if (item.type === "searchBarPlacement") + return [{ type: "item", id: `toolbar-search:${index}`, searchBarPlacement: true }]; + return convertItems([item], `toolbar:${index}`); + }); + if (mailSearch) { + if (mailSearch.filterMenu) + bottom.push({ + type: "item", + id: mailSearch.filterButtonId ?? "filter", + icon: { + type: "sfSymbol", + name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", + }, + menu: convertMenu(mailSearch.filterMenu, "filter-menu"), + }); + if (bottomSearch) { + if (bottom.length) + bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); + bottom.push({ type: "item", id: "home-search", searchBarPlacement: true }); + if (mailSearch.onComposePress) + bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); + } + if (mailSearch.onComposePress) + bottom.push({ + type: "item", + id: mailSearch.composeButtonId ?? "compose", + icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, + onPress: mailSearch.onComposePress, + }); + } + const left = options.headerLeft?.(itemProps); + const right = options.headerRight?.(itemProps); + if (!options.unstable_headerLeftItems && isValidElement(left)) + leading.push({ type: "item", id: "custom-left", render: () => left }); + if (!options.unstable_headerRightItems && isValidElement(right)) + trailing.push({ type: "item", id: "custom-right", render: () => right }); + const title = typeof options.headerTitle === "string" ? options.headerTitle : options.title; + const titleElement = + typeof options.headerTitle === "function" + ? options.headerTitle({ children: title ?? "", tintColor: options.headerTintColor }) + : undefined; + const searchOptions = options.headerSearchBarOptions; + const titleStyle = StyleSheet.flatten(options.headerTitleStyle); + const headerStyle = StyleSheet.flatten(options.headerStyle); + const appearance = { + backgroundColor: + headerStyle?.backgroundColor ?? + (options.headerTransparent ? "transparent" : theme.colors.card), + shadowColor: options.headerShadowVisible === false ? "transparent" : undefined, + titleFontSize: titleStyle?.fontSize, + titleFontFamily: titleStyle?.fontFamily, + titleFontWeight: titleStyle?.fontWeight, + titleFontColor: titleStyle?.color, + subtitleFontSize: options.headerSubtitleStyle?.fontSize, + subtitleFontColor: options.headerSubtitleStyle?.color, + }; + return ( +