diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz new file mode 100644 index 000000000000..572dfa37cede Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.2.tgz differ diff --git a/apps/mobile/fingerprint.config.cjs b/apps/mobile/fingerprint.config.cjs new file mode 100644 index 000000000000..f1ac7e2c9d59 --- /dev/null +++ b/apps/mobile/fingerprint.config.cjs @@ -0,0 +1,3 @@ +// SDK 58 fingerprints native packages by version by default. Our pinned +// Screens PoC needs native source changes to invalidate the development client. +module.exports = { nativeModuleSourceType: "files" }; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 8fa5abfe2a8e..e15c60794e1e 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -287,6 +287,7 @@ @implementation T3MarkdownText { UILongPressGestureRecognizer *_longPressGestureRecognizer; UITapGestureRecognizer *_pressGestureRecognizer; NSArray *_contextAccessibilityElements; + BOOL _textLayoutNeedsUpdate; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -378,16 +379,18 @@ - (void)prepareForRecycle _textView.frame = CGRectZero; _textView.attributedText = nil; _contextAccessibilityElements = nil; + _textLayoutNeedsUpdate = YES; } - (void)layoutSubviews { [super layoutSubviews]; - // _textView's frame is assigned inside drawRect, which only fires when - // state changes. Trigger a redraw whenever the host frame moves out from - // under it (rotation, parent relayout) so the text view resizes and - // onTextLayout re-fires with the new line wrapping. + // Resize during UIKit's layout pass. Doing this in drawRect can leave the + // text view's drawing surface clipped to its previous width after a pane + // expands, even though TextKit has already rewrapped the lines. if (!CGRectEqualToRect(_textView.frame, _view.frame)) { + _textView.frame = _view.frame; + _textLayoutNeedsUpdate = YES; [self setNeedsDisplay]; } } @@ -449,7 +452,7 @@ - (void)drawRect:(CGRect)rect // entirely when nothing actually changed so a JS-side state update made in // response to onSelectionChange doesn't deselect what the user is selecting. const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; - const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); + const BOOL frameChanged = _textLayoutNeedsUpdate; if (!textChanged && !frameChanged) { return; } @@ -475,9 +478,7 @@ - (void)drawRect:(CGRect)rect } _suppressSelectionChange = NO; } - if (frameChanged) { - _textView.frame = _view.frame; - } + _textLayoutNeedsUpdate = NO; // Text attachments have no native link element. Expose their existing runs // at the measured glyph bounds, without inserting views into text layout. diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift new file mode 100644 index 000000000000..43070a614582 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -0,0 +1,48 @@ +import ExpoModulesCore +import UIKit + +/// Reports the body geometry of a native navigation column. +final class T3LayoutMetricsView: ExpoView { + let onMetricsChange = EventDispatcher() + private var lastMetrics: NSDictionary? + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + isUserInteractionEnabled = false + registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in + view.publishMetrics() + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + lastMetrics = nil + publishMetrics() + } + + override func layoutSubviews() { + super.layoutSubviews() + publishMetrics() + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + publishMetrics() + } + + private func publishMetrics() { + guard window != nil, bounds.width > 0, bounds.height > 0 else { return } + let metrics: [String: Any] = [ + "width": bounds.width, "height": bounds.height, + "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", + "safeArea": [ + "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, + "left": safeAreaInsets.left, "right": safeAreaInsets.right, + ], + ] + let next = metrics as NSDictionary + guard lastMetrics?.isEqual(next) != true else { return } + lastMetrics = next + onMetricsChange(metrics) + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index a5813c8a0e8b..e1a7f4d71650 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -11,6 +11,14 @@ public final class T3NativeControlsModule: Module { private var filePresentation: T3NativeFilePresentation? public func definition() -> ModuleDefinition { + Constants { + ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil] + } + View(T3LayoutMetricsView.self) { + ViewName("LayoutMetrics") + Events("onMetricsChange") + } + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ae52098e59dd..6b350d16e3f8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -119,7 +119,7 @@ "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.7.0", "react-native-safe-area-context": "~5.9.1", - "react-native-screens": "~4.28.0", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.2.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.5", "react-native-webview": "14.0.1", @@ -140,7 +140,8 @@ "expo": { "install": { "exclude": [ - "react-native-keyboard-controller" + "react-native-keyboard-controller", + "react-native-screens" ] }, "autolinking": { diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 417473ba20e8..d448aa824df1 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,3 +1,5 @@ +import { createV5StackNavigator as createNativeStackNavigator } from "./native/createV5StackNavigator"; +import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; import { createPathConfigForStaticNavigation, getPathFromState, @@ -6,7 +8,6 @@ import { useNavigation, } from "@react-navigation/native"; import { - createNativeStackNavigator, createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; @@ -602,7 +603,7 @@ function NotFoundScreen() { ); } -const RootStackConfig = createNativeStackNavigator({ +const RootStackConfig = createWorkspaceStackNavigator({ initialRouteName: "Home", layout: RootStackLayout, screenOptions: { diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index f64370c3cead..5120e7c1868d 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -1,3 +1,4 @@ +import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns"; import Constants from "expo-constants"; import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { Platform, View } from "react-native"; @@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing"; */ export function brandTitleOffset(): number { if (Platform.OS !== "ios") return 0; - return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; + return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0; } /** diff --git a/apps/mobile/src/components/ScreenHeader.tsx b/apps/mobile/src/components/ScreenHeader.tsx index 6024ceec08d9..4a3877571966 100644 --- a/apps/mobile/src/components/ScreenHeader.tsx +++ b/apps/mobile/src/components/ScreenHeader.tsx @@ -53,6 +53,10 @@ export function ScreenHeader(props: ScreenHeaderProps) { ]} options={{ headerShown: true, + headerBackVisible: + !layout.usesSplitView || + !props.backInSplitView || + !(props.backInSplitView.onPress || props.onBack), title: props.title, unstable_headerSubtitle: props.subtitle || undefined, ...(props.matchSearchSurface diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 8d2a67000050..3123d785aced 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -4,7 +4,7 @@ import { deviceToolUpdatePolicy, } from "@t3tools/client-runtime/state/device"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index df18347f336c..8680f3b92be7 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -258,7 +258,12 @@ export function FileTreeBrowser(props: { updateCellsBatchingPeriod={16} windowSize={5} contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} - refreshControl={} + refreshControl={ + 0} + onRefresh={props.onRefresh} + /> + } renderItem={renderItem} ListHeaderComponent={ <> diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e1cc7448eb59..ac58bcc8c787 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,6 +1,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useRef } from "react"; +import { use, useCallback, useRef } from "react"; +import { Platform } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; +import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -9,11 +11,14 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; +import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; export function HomeHeader(props: HomeHeaderProps) { + const primaryColumn = use(NativePrimaryColumnContext); + const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null; const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; // The list uses a fixed creation order and ignores sort/group options, so @@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) { // Static header config (glass, title, fonts) lives in Stack.tsx // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. headerTintColor: iconColor, - unstable_headerRightItems: () => [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ], + unstable_headerRightItems: () => + iPadSidebar + ? createSidebarHeaderItems({ + filterIcon: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle", + filterMenu, + onOpenSettings: props.onOpenSettings, + }) + : [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open settings", + icon: { name: "ellipsis", type: "sfSymbol" } as const, + identifier: "home-settings", + label: "", + onPress: props.onOpenSettings, + type: "button", + }), + ], // The keys below are set per-branch (not `undefined`) so a later // reapply cannot clobber options owned by NativeHeaderToolbar. - ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ...(iPadSidebar ? { - unstable_headerToolbarItems: () => [ - createNativeMailSearchToolbarItem({ - composeButtonId: "home-new-task", - composeSystemImageName: "square.and.pencil", - filterMenu, - filterButtonId: "home-filter", - filterSystemImageName: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onStartNewTask, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "home-search-text", - showsSearchDismissButton: true, - }), - ], - } - : { - // Pre-Liquid-Glass iOS: standard pull-down search in the nav - // bar; create + sort live in the plain bottom toolbar below. headerSearchBarOptions: { ref: searchBarRef, autoCapitalize: "none" as const, hideNavigationBar: false, + hideWhenScrolling: false, + obscureBackground: false, + placement: "stacked" as const, + allowToolbarIntegration: false, placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); + onCancelButtonPress: () => props.onSearchQueryChange(""), + onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text), + }, + unstable_headerToolbarItems: () => [], + } + : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? { + headerSearchBarOptions: { + ref: searchBarRef, + autoCapitalize: "none" as const, + onCancelButtonPress: () => props.onSearchQueryChange(""), }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); + unstable_headerToolbarItems: () => [ + createNativeMailSearchToolbarItem({ + composeButtonId: "home-new-task", + composeSystemImageName: "square.and.pencil", + filterMenu, + filterButtonId: "home-filter", + filterSystemImageName: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onComposePress: props.onStartNewTask, + onSearchTextChange: props.onSearchQueryChange, + placeholder: "Search", + searchTextChangeId: "home-search-text", + showsSearchDismissButton: true, + }), + ], + } + : { + // Pre-Liquid-Glass iOS: standard pull-down search in the nav + // bar; create + sort live in the plain bottom toolbar below. + headerSearchBarOptions: { + ref: searchBarRef, + autoCapitalize: "none" as const, + hideNavigationBar: false, + placeholder: "Search", + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + onChangeText: (event) => { + props.onSearchQueryChange(event.nativeEvent.text); + }, }, - }, - }), + }), }} /> - {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( + {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( navigation.navigate("SettingsSheet", { screen: "SettingsContent", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index c54f1c12bf58..2f4a5c0d4f29 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -17,15 +17,18 @@ import { } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import { ActivityIndicator, Platform, + ScrollView, View, + type ScrollViewProps, type GestureResponderEvent, type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; +import { ScrollViewMarker } from "react-native-screens"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -34,7 +37,10 @@ import { EmptyState } from "../../components/EmptyState"; import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey } from "../../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useThreadSearch } from "../../state/queries"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; @@ -221,9 +227,51 @@ function HomeTopContentSpacer() { return ; } +function HomeScrollView(props: ComponentProps) { + const insets = useSafeAreaInsets(); + if (Platform.OS !== "ios") return ; + + // v5 needs the actual content scroll view registered with its screen controller. + // The layout observer and header siblings prevent UIKit from finding it implicitly. + return ( + = 27 ? "automatic" : "soft", + }} + > + + + ); +} + +function renderHomeScrollView(props: ScrollViewProps) { + // FlatList clones this element with its cells and ref. Keep the marker inside + // the component so those cells remain children of the actual ScrollView. + return ; +} + /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { + const primaryColumn = use(NativePrimaryColumnContext); + const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen"; + const containerClassName = cn( + "flex-1", + Platform.OS === "android" ? "bg-header" : contentBackground, + ); + const columnMetrics = useNativeColumnLayoutMetrics(); + const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null; + const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined; const queuedThreadKeys = useQueuedThreadKeys(); const openSwipeableRef = useRef(null); const insets = useSafeAreaInsets(); @@ -623,10 +671,11 @@ export function HomeScreen(props: HomeScreenProps) { now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, - selectedThreadKey: null, + selectedThreadKey, }); }, [ pendingOrder, + selectedThreadKey, queuedThreadKeys, nowMinute, snoozeWakeTick, @@ -759,6 +808,9 @@ export function HomeScreen(props: HomeScreenProps) { const thread = item.item.thread; return ( + + {/* Shared with the iPad sidebar: cells are reused across data @@ -1012,6 +1070,7 @@ export function HomeScreen(props: HomeScreenProps) { onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)} onTouchCancel={(event) => trackListTouches(event, false)} + renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} @@ -1040,6 +1099,7 @@ export function HomeScreen(props: HomeScreenProps) { {...scrollGateHandlers} scrollEventThrottle={16} contentContainerStyle={{ + paddingHorizontal: primaryColumn ? 8 : 0, paddingBottom: Platform.OS === "ios" ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 60df38b30357..8a5af2026ec1 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -61,6 +61,10 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; +import { + NativeWorkspaceModeContext, + NativeWorkspaceInspectorContext, +} from "../../native/v5-workspace-context"; interface AdaptiveWorkspaceContextValue { readonly layout: Layout; @@ -234,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; + const nativeWorkspace = use(NativeWorkspaceModeContext); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -468,9 +473,10 @@ function AdaptiveWorkspaceLayoutContent( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, ); useEffect(() => { + if (nativeWorkspace) return; const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); - }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); + }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, @@ -565,6 +571,19 @@ function AdaptiveWorkspaceLayoutContent( ], ); + if (nativeWorkspace) { + return ( + + + 0 }} + > + {props.children} + + + + ); + } return ( diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx new file mode 100644 index 000000000000..1b8084430605 --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -0,0 +1,344 @@ +import { + createNavigatorFactory, + NavigationContext, + NavigationRouteContext, + StackActions, + StackRouter, + useNavigationBuilder, + type NavigatorTypeBagBase, + type ParamListBase, + type StackActionHelpers, + type StackNavigationState, + type StackRouterOptions, + type StaticConfig, + type TypedNavigator, +} from "@react-navigation/native"; +import { + NativeStackView, + type NativeStackNavigationEventMap, + type NativeStackNavigationOptions, + type NativeStackNavigatorProps, + type NativeStackTypeBag, +} from "@react-navigation/native-stack"; +import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; +import { View } from "react-native"; +import { Split, type SplitHostCommands } from "react-native-screens"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopedThreadKey } from "../../lib/scopedEntities"; + +import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; +import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios"; +import { + modalEnvelopeOptions, + V5CardStackView, + V5StackView, +} from "../../native/createV5StackNavigator.ios"; +import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns"; +import { V5StackHeader } from "../../native/V5StackHeader.ios"; +import { + nativeWorkspacePopCount, + projectWorkspaceStack, + partitionStackPresentations, +} from "../../native/workspace-stack-projection"; +import { + NativePrimaryColumnContext, + NativeWorkspaceInspectorContext, + NativeWorkspaceModeContext, +} from "../../native/v5-workspace-context"; + +type ViewProps = ComponentProps; +type Descriptor = ViewProps["descriptors"][string]; +type Route = ViewProps["state"]["routes"][number]; + +function ColumnScreen(props: { + readonly descriptor: Descriptor; + readonly primary?: boolean; + readonly selectedThreadKey?: string | null; + readonly onDismiss: (routeKey: string) => void; + readonly navigation: ViewProps["navigation"]; +}) { + const { descriptor } = props; + const primaryColumn = useMemo( + () => (props.primary ? { selectedThreadKey: props.selectedThreadKey ?? null } : null), + [props.primary, props.selectedThreadKey], + ); + const key = descriptor.route.key; + return ( + { + if (event.nativeEvent.isNativeDismiss) props.onDismiss(key); + }} + onWillAppear={() => + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }) + } + onDidAppear={() => + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }) + } + onWillDisappear={() => + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }) + } + onDidDisappear={() => + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } }) + } + > + + + + + + {descriptor.render()} + + + + + + ); +} + +function WorkspaceColumns( + props: ViewProps & { readonly primary: Route; readonly detail: Route[] }, +) { + const { layout, panes, togglePrimarySidebar, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const inspector = use(NativeWorkspaceInspectorContext); + const hostRef = useRef(null); + const activeDetailKey = props.detail.at(-1)?.key; + const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; + const selectedThreadKey = + threadParams && + "environmentId" in threadParams && + "threadId" in threadParams && + typeof threadParams.environmentId === "string" && + typeof threadParams.threadId === "string" + ? scopedThreadKey( + EnvironmentId.make(threadParams.environmentId), + ThreadId.make(threadParams.threadId), + ) + : null; + const handleNativeDismiss = useCallback( + (key: string) => { + const state = props.navigation.getState(); + const count = nativeWorkspacePopCount(state, key); + if (count) + props.navigation.dispatch({ ...StackActions.pop(count), source: key, target: state.key }); + }, + [props.navigation], + ); + + useEffect(() => { + // In compact size classes UIKit exposes one column. Selecting a thread + // changes the visible column without rebuilding either navigation stack. + if (layout.usesSplitView) return; + hostRef.current?.show(activeDetailKey ? "secondary" : "primary"); + }, [activeDetailKey, layout.usesSplitView]); + + const primary = props.descriptors[props.primary.key]; + if (!primary) return null; + return ( + { + if (inspector.visible) toggleAuxiliaryPane(); + }} + columnMetrics={{ + minimumPrimaryColumnWidth: 280, + maximumPrimaryColumnWidth: 380, + preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, + minimumSecondaryColumnWidth: 320, + }} + > + + + + + + + + + [ + { + type: "button", + label: "", + icon: { type: "sfSymbol", name: "sidebar.left" }, + onPress: togglePrimarySidebar, + }, + ], + }} + canGoBack={false} + /> + + + props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + } + /> + + + {props.detail.map((route) => { + const descriptor = props.descriptors[route.key]; + return descriptor ? ( + + ) : null; + })} + + + + {/* Inspector content supplies its own navigation header. */} + + + ); +} + +function WorkspaceStackView(props: ViewProps) { + const projection = projectWorkspaceStack(props.state, (route) => { + const presentation = props.descriptors[route.key]?.options.presentation; + return presentation !== undefined && presentation !== "card"; + }); + // Linking normally restores Home via initialRouteName. A detail-only history + // must stay a single stack: placeholder descriptors cannot own an interactive + // sidebar because React Navigation rejects their actions and setOptions. + const primaryRoute = projection.primary; + if (!primaryRoute) return ; + const primaryDescriptor = props.descriptors[primaryRoute.key]!; + const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; + const baseDescriptor: Descriptor = { + ...primaryDescriptor, + route: baseRoute, + options: { headerShown: false }, + render: () => , + }; + const overlays = partitionStackPresentations(projection.overlays, (route) => { + const presentation = props.descriptors[route.key]?.options.presentation; + return presentation !== undefined && presentation !== "card"; + }); + const routes = [baseRoute, ...overlays.map((group) => group[0]!)]; + return ( + { + const route = group[0]!; + const descriptor = props.descriptors[route.key]!; + return [ + route.key, + { + ...descriptor, + options: modalEnvelopeOptions(descriptor.options), + render: () => ( + + ), + }, + ]; + }), + ), + [baseRoute.key]: baseDescriptor, + }} + /> + ); +} + +function WorkspaceStackNavigator({ + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + ...rest +}: NativeStackNavigatorProps) { + const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< + StackNavigationState, + StackRouterOptions, + StackActionHelpers, + NativeStackNavigationOptions, + NativeStackNavigationEventMap + >(StackRouter, { + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + }); + const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView; + return ( + + + + + + ); +} + +type WorkspaceTypeBag< + ParamList extends ParamListBase, + NavigatorID extends string | undefined, +> = Omit, "Navigator"> & { + Navigator: typeof WorkspaceStackNavigator; +}; + +export function createWorkspaceStackNavigator< + const ParamList extends ParamListBase, + const NavigatorID extends string | undefined = string | undefined, + const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag, + const Config extends StaticConfig = StaticConfig, +>(config?: Config): TypedNavigator { + return createNavigatorFactory(WorkspaceStackNavigator)(config); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx new file mode 100644 index 000000000000..fb22815dd2c1 --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx @@ -0,0 +1 @@ +export { createNativeStackNavigator as createWorkspaceStackNavigator } from "@react-navigation/native-stack"; diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts index 34d5570e6109..44ef2a992e8b 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -3,11 +3,9 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens" import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; /** - * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass - * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side - * silently drops the item and hides the navigation toolbar entirely. Screens - * that send it must fall back to standard search/toolbar primitives when this - * is false. + * Group search, filtering, and composition on platforms with Liquid Glass. + * The v5 header adapter turns this intent into UISearchController and native + * toolbar items. Earlier iOS versions use separate search and toolbar options. */ export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; @@ -20,12 +18,7 @@ type NativeMailSearchToolbarInput = Omit< >; /** - * Builds the patched react-native-screens Mail-style bottom search toolbar. - * - * Keeping this behind an app-level helper makes the iOS-only RNS patch an - * explicit layout primitive instead of a per-screen object literal. Android can - * keep using platform-specific header/search primitives without depending on - * this helper. + * Describe the thread-list search and actions for the native header adapter. */ export function createNativeMailSearchToolbarItem( input: NativeMailSearchToolbarInput, diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx index cf123a736559..5df9282d13f9 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -29,7 +29,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { useState, type ReactNode } from "react"; import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; -import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { ScrollView, useWindowDimensions, View } from "react-native"; import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; import { NativeHeaderToolbar } from "../../native/StackHeader"; diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index aedf415d02de..8445eafccd3a 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,3 +1,4 @@ +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import type { EnvironmentId, ModelSelection, @@ -15,10 +16,7 @@ import { getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; -import { - createNativeStackNavigator, - type NativeStackNavigationProp, -} from "@react-navigation/native-stack"; +import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; import { AsyncResult } from "effect/unstable/reactivity"; import { diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index 1198b06cb6a4..6f434ad656b7 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -1,8 +1,6 @@ +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; -import { - createNativeStackNavigator, - type NativeStackNavigationOptions, -} from "@react-navigation/native-stack"; +import { type NativeStackNavigationOptions } from "@react-navigation/native-stack"; import type { ReactNode } from "react"; import { Platform } from "react-native"; diff --git a/apps/mobile/src/lib/reserved-regions.ts b/apps/mobile/src/lib/reserved-regions.ts new file mode 100644 index 000000000000..318896239536 --- /dev/null +++ b/apps/mobile/src/lib/reserved-regions.ts @@ -0,0 +1,12 @@ +/** Geometry of the body inside a native navigation column. */ +export interface NativeLayoutMetrics { + readonly width: number; + readonly height: number; + readonly horizontalSizeClass: "compact" | "regular"; + readonly safeArea: { + readonly top: number; + readonly bottom: number; + readonly left: number; + readonly right: number; + }; +} diff --git a/apps/mobile/src/native/NativeColumnContent.ios.tsx b/apps/mobile/src/native/NativeColumnContent.ios.tsx new file mode 100644 index 000000000000..93b24ec72085 --- /dev/null +++ b/apps/mobile/src/native/NativeColumnContent.ios.tsx @@ -0,0 +1,31 @@ +import { HeaderHeightContext } from "@react-navigation/elements"; +import { useState, type ReactNode } from "react"; +import { View } from "react-native"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +import { NativeColumnLayoutMetricsContext } from "./native-layout-metrics"; +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; +import { NativeLayoutObserver } from "./NativeLayoutObserver"; + +export function NativeColumnContent(props: { + readonly children: ReactNode; + readonly primary?: boolean; +}) { + const [metrics, setMetrics] = useState(null); + return ( + + + + {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} + + + + + {props.children} + + + + + + + ); +} diff --git a/apps/mobile/src/native/NativeLayoutObserver.ios.tsx b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx new file mode 100644 index 000000000000..8d284d9edeec --- /dev/null +++ b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx @@ -0,0 +1,33 @@ +import { requireNativeView, requireOptionalNativeModule } from "expo"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; + +interface ObserverProps extends ViewProps { + readonly onMetricsChange?: (event: NativeSyntheticEvent) => void; +} + +// An older development client can still run the compact app while it rebuilds. +function resolveObserver() { + try { + const module = requireOptionalNativeModule<{ + readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown }; + }>("T3NativeControls"); + if (!module?.ViewPrototypes?.T3NativeControls_LayoutMetrics) return null; + return requireNativeView("T3NativeControls", "LayoutMetrics"); + } catch { + return null; + } +} +const Observer = resolveObserver(); + +export function NativeLayoutObserver(props: { + readonly onChange?: (metrics: NativeLayoutMetrics) => void; +}) { + return Observer ? ( + props.onChange?.(event.nativeEvent) : undefined} + style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }} + /> + ) : null; +} diff --git a/apps/mobile/src/native/NativeLayoutObserver.tsx b/apps/mobile/src/native/NativeLayoutObserver.tsx new file mode 100644 index 000000000000..e2b24a55a213 --- /dev/null +++ b/apps/mobile/src/native/NativeLayoutObserver.tsx @@ -0,0 +1,7 @@ +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; + +export function NativeLayoutObserver(_props: { + readonly onChange?: (metrics: NativeLayoutMetrics) => void; +}) { + return null; +} diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx new file mode 100644 index 000000000000..c85529b1dbf2 --- /dev/null +++ b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx @@ -0,0 +1,7 @@ +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; +const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>( + "T3NativeControls", +); +export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = + Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true; diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.tsx new file mode 100644 index 000000000000..4bb7aa16ac9b --- /dev/null +++ b/apps/mobile/src/native/NativeWorkspaceColumns.tsx @@ -0,0 +1,3 @@ +import { Platform } from "react-native"; +// Android retains its existing navigator. The iOS file checks native availability. +export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad; diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx new file mode 100644 index 000000000000..75c7ead44f71 --- /dev/null +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -0,0 +1,250 @@ +import type { + NativeStackHeaderItem, + NativeStackHeaderItemMenu, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { isValidElement, useRef } from "react"; +import { Platform, StyleSheet } from "react-native"; +import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; +import { + SearchBar, + Stack, + type HeaderBarButtonMailSearchToolbarItem, + type SearchBarCommands, + type StackHeaderConfigProps, + type StackHeaderMenuElementIOS, + type StackHeaderMenuIOS, + type HeaderBarButtonItemWithMenu, + type HeaderBarButtonSearchBarPlacementItem, +} from "react-native-screens"; + +type HeaderItem = NonNullable["leadingItems"]>[number]; +type ToolbarItem = + | NativeStackHeaderItem + | HeaderBarButtonMailSearchToolbarItem + | HeaderBarButtonSearchBarPlacementItem; +type Options = NativeStackNavigationOptions & { + readonly unstable_navigationItemStyle?: "editor" | "navigator"; + readonly headerSubtitle?: string; + readonly unstable_headerSubtitle?: string; + readonly headerSubtitleStyle?: { color?: string; fontSize?: number; fontFamily?: string }; + readonly unstable_headerCenterItems?: () => NativeStackHeaderItem[]; + readonly unstable_headerToolbarItems?: () => ToolbarItem[]; +}; + +type MenuElementInput = + | NativeStackHeaderItemMenu["menu"]["items"][number] + | HeaderBarButtonItemWithMenu["menu"]["items"][number]; +type MenuInput = { readonly title?: string; readonly items: readonly MenuElementInput[] }; + +function convertIcon( + icon: + | Extract["icon"] + | HeaderBarButtonItemWithMenu["icon"], +) { + if (!icon) return undefined; + if (icon.type === "image") + return icon.tinted === false + ? { type: "imageSource" as const, imageSource: icon.source } + : { type: "templateSource" as const, templateSource: icon.source }; + return icon; +} + +function convertMenu(menu: MenuInput, id: string): StackHeaderMenuIOS { + const children = menu.items.flatMap((item, index) => { + if ("hidden" in item && item.hidden) return []; + const childId = `${id}:${index}`; + if (item.type === "submenu") { + return [ + { + ...convertMenu({ items: item.items }, childId), + title: "label" in item ? item.label : item.title, + displayInline: + "inline" in item + ? item.inline + : "displayInline" in item + ? item.displayInline + : undefined, + }, + ]; + } + // Use actions even for checked entries: the app owns their durable state. + return [ + { + id: childId, + type: "menuItem" as const, + itemType: "action" as const, + title: "label" in item ? item.label : item.title, + icon: convertIcon(item.icon), + onPress: item.disabled ? undefined : item.onPress, + keepsMenuPresented: item.keepsMenuPresented, + disabled: item.disabled, + destructive: item.destructive, + subtitle: "subtitle" in item ? item.subtitle : undefined, + state: item.state, + }, + ]; + }); + return { id, type: "menu", title: menu.title, children }; +} + +function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderItem[] { + return items.map((item, index): HeaderItem => { + const id = + item.type === "spacing" ? `${prefix}:${index}` : (item.identifier ?? `${prefix}:${index}`); + switch (item.type) { + case "spacing": + return { type: "spacer", id, sizing: "fixed", width: item.spacing }; + case "custom": + return { + type: "item", + id, + render: () => item.element, + hidesSharedBackground: item.hidesSharedBackground, + }; + case "menu": + return { + type: "item", + id, + identifier: item.identifier, + title: item.label || undefined, + icon: convertIcon(item.icon), + menu: convertMenu(item.menu, `${id}:menu`), + hidesSharedBackground: item.hidesSharedBackground, + }; + case "button": + return { + type: "item", + id, + identifier: item.identifier, + title: item.label || undefined, + icon: convertIcon(item.icon), + onPress: item.disabled ? undefined : item.onPress, + hidesSharedBackground: item.hidesSharedBackground, + }; + } + }); +} + +/** Translate the existing screen options to v5's native header API. */ +export function V5StackHeader(props: { + readonly options: Options; + readonly canGoBack: boolean; + readonly primary?: boolean; +}) { + const { options } = props; + const theme = useMobileNavigationTheme(); + const searchRef = useRef(null); + const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; + const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); + const trailing = convertItems( + [ + ...(options.unstable_headerCenterItems?.() ?? []), + ...(options.unstable_headerRightItems?.(itemProps) ?? []), + ], + "trailing", + ); + const toolbar = options.unstable_headerToolbarItems?.() ?? []; + const mailSearch = toolbar.find( + (item): item is HeaderBarButtonMailSearchToolbarItem => item.type === "mailSearchToolbar", + ); + const bottomSearch = Boolean(mailSearch) && Platform.OS === "ios" && !Platform.isPad; + const bottom = toolbar.flatMap((item, index) => { + if (item.type === "mailSearchToolbar") return []; + if (item.type === "searchBarPlacement") + return [{ type: "item", id: `toolbar-search:${index}`, searchBarPlacement: true }]; + return convertItems([item], `toolbar:${index}`); + }); + if (mailSearch) { + if (mailSearch.filterMenu) + bottom.push({ + type: "item", + id: mailSearch.filterButtonId ?? "filter", + icon: { + type: "sfSymbol", + name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", + }, + menu: convertMenu(mailSearch.filterMenu, "filter-menu"), + }); + if (bottomSearch) { + if (bottom.length) + bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); + bottom.push({ type: "item", id: "home-search", searchBarPlacement: true }); + if (mailSearch.onComposePress) + bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); + } + if (mailSearch.onComposePress) + bottom.push({ + type: "item", + id: mailSearch.composeButtonId ?? "compose", + icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, + onPress: mailSearch.onComposePress, + }); + } + const left = options.headerLeft?.(itemProps); + const right = options.headerRight?.(itemProps); + if (!options.unstable_headerLeftItems && isValidElement(left)) + leading.push({ type: "item", id: "custom-left", render: () => left }); + if (!options.unstable_headerRightItems && isValidElement(right)) + trailing.push({ type: "item", id: "custom-right", render: () => right }); + const title = typeof options.headerTitle === "string" ? options.headerTitle : options.title; + const titleElement = + typeof options.headerTitle === "function" + ? options.headerTitle({ children: title ?? "", tintColor: options.headerTintColor }) + : undefined; + const searchOptions = options.headerSearchBarOptions; + const titleStyle = StyleSheet.flatten(options.headerTitleStyle); + const headerStyle = StyleSheet.flatten(options.headerStyle); + const appearance = { + backgroundColor: + headerStyle?.backgroundColor ?? + (options.headerTransparent ? "transparent" : theme.colors.card), + shadowColor: options.headerShadowVisible === false ? "transparent" : undefined, + titleFontSize: titleStyle?.fontSize, + titleFontFamily: titleStyle?.fontFamily, + titleFontWeight: titleStyle?.fontWeight, + titleFontColor: titleStyle?.color, + subtitleFontSize: options.headerSubtitleStyle?.fontSize, + subtitleFontColor: options.headerSubtitleStyle?.color, + }; + return ( +