1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { use, useCallback, useEffect, useRef } from "react";
3import { useNavigation, type ParamListBase } from "@react-navigation/native";
4import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
5import { Platform } from "react-native";
6import type { SearchBarCommands } from "react-native-screens";
7import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
8import { useUniwindTheme } from "../../lib/useUniwindTheme";
9import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
10import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
11import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
12import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
13import { buildHomeListFilterMenu } from "./home-list-filter-menu";
14import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items";
15import type { HomeHeaderProps } from "./HomeHeader.types";
16import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
17
18export type { HomeHeaderEnvironment } from "./HomeHeader.types";
19
20export 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}