| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { use, useCallback, useEffect, useRef } from "react"; |
| 3 | import { useNavigation, type ParamListBase } from "@react-navigation/native"; |
| 4 | import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; |
| 5 | import { Platform } from "react-native"; |
| 6 | import type { SearchBarCommands } from "react-native-screens"; |
| 7 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; |
| 8 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 9 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 10 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 11 | import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; |
| 12 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; |
| 13 | import { buildHomeListFilterMenu } from "./home-list-filter-menu"; |
| 14 | import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; |
| 15 | import type { HomeHeaderProps } from "./HomeHeader.types"; |
| 16 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 17 | |
| 18 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| 19 | |
| 20 | export function HomeHeader(props: HomeHeaderProps) { |
| 21 | const navigation = useNavigation<NativeStackNavigationProp<ParamListBase>>(); |
| 22 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
| 23 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); |
| 24 | const primaryColumn = use(NativePrimaryColumnContext); |
| 25 | const sidebarHeader = |
| 26 | Platform.OS === "ios" && |
| 27 | primaryColumn !== null && |
| 28 | (Platform.isPad || !usesNativeMailSearchToolbar); |
| 29 | const showsCompactCompose = Platform.OS === "ios" && Platform.isPad && !layout.usesSplitView; |
| 30 | const searchBarRef = useRef<SearchBarCommands>(null); |
| 31 | const focusAfterReveal = useRef(false); |
| 32 | useEffect( |
| 33 | () => |
| 34 | navigation.addListener("transitionEnd", (event) => { |
| 35 | if (focusAfterReveal.current && !event.data.closing) { |
| 36 | focusAfterReveal.current = false; |
| 37 | searchBarRef.current?.focus(); |
| 38 | } |
| 39 | }), |
| 40 | [navigation], |
| 41 | ); |
| 42 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 43 | // The list uses a fixed creation order and ignores sort/group options, so |
| 44 | // the filter menu only carries the filters and the "customized" icon state |
| 45 | // keys off those alone. |
| 46 | const hasCustomListOptions = |
| 47 | props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; |
| 48 | const focusSearch = useCallback(() => { |
| 49 | if (primaryColumn && !panes.primarySidebarVisible) { |
| 50 | focusAfterReveal.current = true; |
| 51 | togglePrimarySidebar(); |
| 52 | return true; |
| 53 | } |
| 54 | searchBarRef.current?.focus(); |
| 55 | return searchBarRef.current !== null; |
| 56 | }, [primaryColumn, panes.primarySidebarVisible, togglePrimarySidebar]); |
| 57 | useHardwareKeyboardCommand("focusSearch", focusSearch); |
| 58 | const filterMenu = buildHomeListFilterMenu(props); |
| 59 | |
| 60 | if (props.usesWorkspaceToolbar) { |
| 61 | return null; |
| 62 | } |
| 63 | |
| 64 | return ( |
| 65 | <> |
| 66 | <NativeStackScreenOptions |
| 67 | optionsVersion={[filterMenu.items, showsCompactCompose]} |
| 68 | options={{ |
| 69 | // Static header config (glass, title, fonts) lives in Stack.tsx |
| 70 | // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. |
| 71 | headerTintColor: iconColor, |
| 72 | unstable_headerRightItems: () => |
| 73 | sidebarHeader |
| 74 | ? [ |
| 75 | ...createSidebarHeaderItems({ |
| 76 | filterIcon: hasCustomListOptions |
| 77 | ? "line.3.horizontal.decrease.circle.fill" |
| 78 | : "line.3.horizontal.decrease.circle", |
| 79 | filterMenu, |
| 80 | onOpenSettings: props.onOpenSettings, |
| 81 | }), |
| 82 | ...(showsCompactCompose |
| 83 | ? [ |
| 84 | withNativeGlassHeaderItem({ |
| 85 | accessibilityLabel: "New task", |
| 86 | axisBehavior: "horizontalOnly", |
| 87 | icon: { name: "square.and.pencil", type: "sfSymbol" } as const, |
| 88 | identifier: "home-new-task", |
| 89 | label: "", |
| 90 | onPress: props.onStartNewTask, |
| 91 | type: "button", |
| 92 | }), |
| 93 | ] |
| 94 | : []), |
| 95 | ] |
| 96 | : [ |
| 97 | withNativeGlassHeaderItem({ |
| 98 | accessibilityLabel: "Open settings", |
| 99 | icon: { name: "ellipsis", type: "sfSymbol" } as const, |
| 100 | identifier: "home-settings", |
| 101 | label: "", |
| 102 | onPress: props.onOpenSettings, |
| 103 | type: "button", |
| 104 | }), |
| 105 | ], |
| 106 | // The keys below are set per-branch (not `undefined`) so a later |
| 107 | // reapply cannot clobber options owned by NativeHeaderToolbar. |
| 108 | ...(sidebarHeader |
| 109 | ? { |
| 110 | headerSearchBarOptions: { |
| 111 | ref: searchBarRef, |
| 112 | autoCapitalize: "none" as const, |
| 113 | hideNavigationBar: false, |
| 114 | hideWhenScrolling: false, |
| 115 | obscureBackground: false, |
| 116 | placement: "stacked" as const, |
| 117 | allowToolbarIntegration: false, |
| 118 | placeholder: "Search", |
| 119 | onCancelButtonPress: () => props.onSearchQueryChange(""), |
| 120 | onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text), |
| 121 | }, |
| 122 | unstable_headerToolbarItems: () => [], |
| 123 | } |
| 124 | : usesNativeMailSearchToolbar |
| 125 | ? { |
| 126 | headerSearchBarOptions: { |
| 127 | ref: searchBarRef, |
| 128 | autoCapitalize: "none" as const, |
| 129 | onCancelButtonPress: () => props.onSearchQueryChange(""), |
| 130 | }, |
| 131 | unstable_headerToolbarItems: () => [ |
| 132 | createNativeMailSearchToolbarItem({ |
| 133 | composeButtonId: "home-new-task", |
| 134 | composeSystemImageName: "square.and.pencil", |
| 135 | filterMenu, |
| 136 | filterButtonId: "home-filter", |
| 137 | filterSystemImageName: hasCustomListOptions |
| 138 | ? "line.3.horizontal.decrease.circle.fill" |
| 139 | : "line.3.horizontal.decrease", |
| 140 | onComposePress: props.onStartNewTask, |
| 141 | onSearchTextChange: props.onSearchQueryChange, |
| 142 | placeholder: "Search", |
| 143 | searchTextChangeId: "home-search-text", |
| 144 | showsSearchDismissButton: true, |
| 145 | }), |
| 146 | ], |
| 147 | } |
| 148 | : { |
| 149 | // Pre-Liquid-Glass iOS: standard pull-down search in the nav |
| 150 | // bar; create + sort live in the plain bottom toolbar below. |
| 151 | headerSearchBarOptions: { |
| 152 | ref: searchBarRef, |
| 153 | autoCapitalize: "none" as const, |
| 154 | hideNavigationBar: false, |
| 155 | placeholder: "Search", |
| 156 | onCancelButtonPress: () => { |
| 157 | props.onSearchQueryChange(""); |
| 158 | }, |
| 159 | onChangeText: (event) => { |
| 160 | props.onSearchQueryChange(event.nativeEvent.text); |
| 161 | }, |
| 162 | }, |
| 163 | }), |
| 164 | }} |
| 165 | /> |
| 166 | |
| 167 | {sidebarHeader || usesNativeMailSearchToolbar ? null : ( |
| 168 | <NativeHeaderToolbar placement="bottom"> |
| 169 | <NativeHeaderToolbar.Menu |
| 170 | accessibilityLabel="Filter threads" |
| 171 | icon={ |
| 172 | hasCustomListOptions |
| 173 | ? "line.3.horizontal.decrease.circle.fill" |
| 174 | : "line.3.horizontal.decrease.circle" |
| 175 | } |
| 176 | title="Thread list options" |
| 177 | separateBackground |
| 178 | > |
| 179 | <NativeHeaderToolbar.Menu title="Environment"> |
| 180 | <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label> |
| 181 | <NativeHeaderToolbar.MenuAction |
| 182 | isOn={props.selectedEnvironmentId === null} |
| 183 | onPress={() => props.onEnvironmentChange(null)} |
| 184 | subtitle="Show threads from every environment" |
| 185 | > |
| 186 | <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label> |
| 187 | </NativeHeaderToolbar.MenuAction> |
| 188 | {props.environments.map((environment) => ( |
| 189 | <NativeHeaderToolbar.MenuAction |
| 190 | key={environment.environmentId} |
| 191 | isOn={props.selectedEnvironmentId === environment.environmentId} |
| 192 | onPress={() => props.onEnvironmentChange(environment.environmentId)} |
| 193 | > |
| 194 | <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label> |
| 195 | </NativeHeaderToolbar.MenuAction> |
| 196 | ))} |
| 197 | </NativeHeaderToolbar.Menu> |
| 198 | |
| 199 | {props.projects.length > 0 ? ( |
| 200 | <NativeHeaderToolbar.Menu title="Project"> |
| 201 | <NativeHeaderToolbar.Label>Project</NativeHeaderToolbar.Label> |
| 202 | <NativeHeaderToolbar.MenuAction |
| 203 | isOn={props.selectedProjectKey === null} |
| 204 | onPress={() => props.onProjectChange(null)} |
| 205 | subtitle="Show threads from every project" |
| 206 | > |
| 207 | <NativeHeaderToolbar.Label>All projects</NativeHeaderToolbar.Label> |
| 208 | </NativeHeaderToolbar.MenuAction> |
| 209 | {props.projects.map((project) => ( |
| 210 | <NativeHeaderToolbar.MenuAction |
| 211 | key={project.key} |
| 212 | isOn={props.selectedProjectKey === project.key} |
| 213 | onPress={() => props.onProjectChange(project.key)} |
| 214 | > |
| 215 | <NativeHeaderToolbar.Label>{project.label}</NativeHeaderToolbar.Label> |
| 216 | </NativeHeaderToolbar.MenuAction> |
| 217 | ))} |
| 218 | </NativeHeaderToolbar.Menu> |
| 219 | ) : null} |
| 220 | </NativeHeaderToolbar.Menu> |
| 221 | <NativeHeaderToolbar.Spacer flexible /> |
| 222 | <NativeHeaderToolbar.Button |
| 223 | accessibilityLabel="New task" |
| 224 | icon="square.and.pencil" |
| 225 | onPress={props.onStartNewTask} |
| 226 | separateBackground |
| 227 | /> |
| 228 | </NativeHeaderToolbar> |
| 229 | )} |
| 230 | </> |
| 231 | ); |
| 232 | } |