| 1 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 2 | import { useMemo } from "react"; |
| 3 | import { Platform } from "react-native"; |
| 4 | import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; |
| 5 | import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; |
| 6 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 7 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 8 | import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 9 | import { |
| 10 | ThreadGitControls, |
| 11 | useThreadGitCenterHeaderItems, |
| 12 | useThreadGitRightHeaderItems, |
| 13 | } from "./ThreadGitControls"; |
| 14 | |
| 15 | type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>; |
| 16 | |
| 17 | export function useThreadHeaderOptions(props: { |
| 18 | readonly title: string; |
| 19 | readonly subtitle: string; |
| 20 | readonly headerColor: string; |
| 21 | readonly usesNativeHeaderGlass: boolean; |
| 22 | readonly gitControls: Parameters<typeof ThreadGitControls>[0]; |
| 23 | readonly onReturnToThread?: () => void; |
| 24 | }) { |
| 25 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); |
| 26 | const usesDuoHeader = usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad; |
| 27 | const navigation = useNavigation(); |
| 28 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
| 29 | const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls); |
| 30 | const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls); |
| 31 | const splitLeftHeaderItems = useMemo<NativeHeaderItems>( |
| 32 | () => [ |
| 33 | ...(!usesDuoHeader |
| 34 | ? [ |
| 35 | { |
| 36 | // Match Mail's split-view detail toolbar: the first detail action sits |
| 37 | // inside the content pane, not flush against the sidebar divider. |
| 38 | spacing: 18, |
| 39 | type: "spacing" as const, |
| 40 | }, |
| 41 | ] |
| 42 | : []), |
| 43 | ...(props.onReturnToThread |
| 44 | ? [ |
| 45 | withNativeGlassHeaderItem({ |
| 46 | accessibilityLabel: "Return to chat", |
| 47 | icon: { name: "chevron.left", type: "sfSymbol" as const }, |
| 48 | identifier: "thread-left-return", |
| 49 | onPress: props.onReturnToThread, |
| 50 | type: "button" as const, |
| 51 | }), |
| 52 | ] |
| 53 | : []), |
| 54 | withNativeGlassHeaderItem({ |
| 55 | axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, |
| 56 | accessibilityLabel: panes.primarySidebarVisible |
| 57 | ? "Maximize content" |
| 58 | : "Show thread sidebar", |
| 59 | icon: { |
| 60 | name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", |
| 61 | type: "sfSymbol" as const, |
| 62 | }, |
| 63 | identifier: "thread-left-sidebar", |
| 64 | label: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar", |
| 65 | onPress: togglePrimarySidebar, |
| 66 | type: "button" as const, |
| 67 | }), |
| 68 | ...(!usesDuoHeader |
| 69 | ? [ |
| 70 | withNativeGlassHeaderItem({ |
| 71 | accessibilityLabel: "New task", |
| 72 | icon: { name: "square.and.pencil", type: "sfSymbol" as const }, |
| 73 | identifier: "thread-left-new-task", |
| 74 | label: "New task", |
| 75 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), |
| 76 | type: "button" as const, |
| 77 | }), |
| 78 | ] |
| 79 | : []), |
| 80 | ], |
| 81 | [ |
| 82 | panes.primarySidebarVisible, |
| 83 | props.onReturnToThread, |
| 84 | navigation, |
| 85 | togglePrimarySidebar, |
| 86 | usesDuoHeader, |
| 87 | usesNativeWorkspaceColumns, |
| 88 | ], |
| 89 | ); |
| 90 | // Deep links / cold starts land with Thread as the ONLY route, where the |
| 91 | // native back button does not render. Provide an explicit Home escape for |
| 92 | // that case; when history exists the native back button is used instead. |
| 93 | const canGoBack = navigation.canGoBack(); |
| 94 | const compactHomeHeaderItems = useMemo<NativeHeaderItems>( |
| 95 | () => [ |
| 96 | withNativeGlassHeaderItem({ |
| 97 | accessibilityLabel: "Go to threads list", |
| 98 | icon: { name: "list.bullet", type: "sfSymbol" as const }, |
| 99 | identifier: "thread-left-home", |
| 100 | onPress: () => navigation.dispatch(StackActions.replace("Home")), |
| 101 | type: "button" as const, |
| 102 | }), |
| 103 | ], |
| 104 | [navigation], |
| 105 | ); |
| 106 | |
| 107 | const duoRightHeaderItems = useMemo<NativeHeaderItems>( |
| 108 | () => [ |
| 109 | ...threadCenterHeaderItems, |
| 110 | { type: "spacing", spacing: 8 }, |
| 111 | ...(layout.usesSplitView && |
| 112 | Platform.OS === "ios" && |
| 113 | !Platform.isPad && |
| 114 | !panes.primarySidebarVisible |
| 115 | ? [ |
| 116 | withNativeGlassHeaderItem({ |
| 117 | type: "button" as const, |
| 118 | axisBehavior: "verticalPreferred", |
| 119 | identifier: "thread-right-search", |
| 120 | label: "Search threads", |
| 121 | accessibilityLabel: "Search threads", |
| 122 | icon: { name: "magnifyingglass", type: "sfSymbol" as const }, |
| 123 | onPress: () => { |
| 124 | dispatchHardwareKeyboardCommand("focusSearch"); |
| 125 | }, |
| 126 | }), |
| 127 | ] |
| 128 | : []), |
| 129 | withNativeGlassHeaderItem({ |
| 130 | type: "button" as const, |
| 131 | axisBehavior: "verticalPreferred", |
| 132 | identifier: "thread-right-new-task", |
| 133 | label: "New task", |
| 134 | accessibilityLabel: "New task", |
| 135 | icon: { name: "square.and.pencil", type: "sfSymbol" as const }, |
| 136 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), |
| 137 | }), |
| 138 | ], |
| 139 | [navigation, threadCenterHeaderItems, layout.usesSplitView, panes.primarySidebarVisible], |
| 140 | ); |
| 141 | |
| 142 | const options: AppNativeStackNavigationOptions = { |
| 143 | headerShown: true, |
| 144 | headerTitle: props.title, |
| 145 | headerTitleStyle: props.usesNativeHeaderGlass |
| 146 | ? { |
| 147 | fontSize: 17, |
| 148 | fontWeight: "800", |
| 149 | } |
| 150 | : undefined, |
| 151 | title: props.title, |
| 152 | headerBackVisible: !layout.usesSplitView, |
| 153 | // Compact uses the NATIVE back button when a previous route exists; |
| 154 | // deep links / cold starts get an explicit Home button instead. |
| 155 | // Split view always uses its custom left items. |
| 156 | unstable_headerLeftItems: layout.usesSplitView |
| 157 | ? () => splitLeftHeaderItems |
| 158 | : canGoBack |
| 159 | ? undefined |
| 160 | : () => compactHomeHeaderItems, |
| 161 | // Search lives in the persistent sidebar, so the split header keeps |
| 162 | // the git controls on the RIGHT (no center items — center space is |
| 163 | // reserved for future breadcrumbs/status). |
| 164 | unstable_headerRightItems: () => |
| 165 | usesDuoHeader |
| 166 | ? duoRightHeaderItems |
| 167 | : layout.usesSplitView |
| 168 | ? threadCenterHeaderItems |
| 169 | : compactRightHeaderItems, |
| 170 | unstable_headerToolbarItems: () => [], |
| 171 | unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, |
| 172 | contentStyle: undefined, |
| 173 | }; |
| 174 | const { environmentId, threadId, gitStatus } = props.gitControls; |
| 175 | return { |
| 176 | options, |
| 177 | // Header item factories are stabilized, so the native header only re-reads them when |
| 178 | // this version changes. Keying on the items keeps the Git menu status live; the menu |
| 179 | // callbacks also read state the items do not display (a "Push" item runs `push` or |
| 180 | // `commit_push` depending on the default ref), so that state is keyed too. |
| 181 | optionsVersion: [ |
| 182 | splitLeftHeaderItems, |
| 183 | threadCenterHeaderItems, |
| 184 | compactRightHeaderItems, |
| 185 | duoRightHeaderItems, |
| 186 | usesDuoHeader, |
| 187 | environmentId, |
| 188 | threadId, |
| 189 | gitStatus?.isDefaultRef, |
| 190 | gitStatus?.refName, |
| 191 | gitStatus?.pr?.url, |
| 192 | ], |
| 193 | sidebar: false, |
| 194 | fallback: |
| 195 | !layout.usesSplitView && !props.usesNativeHeaderGlass ? ( |
| 196 | <ThreadGitControls {...props.gitControls} showActionControls /> |
| 197 | ) : null, |
| 198 | }; |
| 199 | } |