authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 12:55:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 12:55:12-07:00
log7e7cd32463b528838cb10ef6b89ef6ceac1f7a4c
tree731d4729e98225172ffdcfffc6ef52dd9bd4ae7c
parent53830d413474b2a5749c040d4382c96002894b88
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): split home headers by platform (#12381)


4 files changed, 203 insertions(+), 203 deletions(-)

apps/mobile/src/features/home/HomeHeader.android.tsx created+161
......@@ -0,0 +1,161 @@
1import type { MenuAction } from "@react-native-menu/menu";
2import { useCallback, useMemo } from "react";
3import { NativeStackScreenOptions } from "../../native/StackHeader";
4import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
5import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";
6import {
7 hasCustomHomeListOptions,
8 PROJECT_SORT_OPTIONS,
9 THREAD_SORT_OPTIONS,
10} from "./home-list-options";
11import type { HomeHeaderProps } from "./HomeHeader.types";
12
13export type { HomeHeaderEnvironment } from "./HomeHeader.types";
14
15function checkedMenuState(checked: boolean) {
16 return checked ? ("on" as const) : undefined;
17}
18
19export function HomeHeader(props: HomeHeaderProps) {
20 // Thread List v2 lays the list out in fixed creation order, so the
21 // sort/group filter controls would be silently ignored — hide them and
22 // key the "customized" icon state off the environment filter alone.
23 const threadListV2Enabled = useThreadListV2Enabled();
24 const hasCustomListOptions = threadListV2Enabled
25 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
26 : hasCustomHomeListOptions(props);
27 const menuActions = useMemo<MenuAction[]>(
28 () => [
29 {
30 id: "environment",
31 title: "Environment",
32 subactions: [
33 {
34 id: "environment:all",
35 title: "All environments",
36 state: checkedMenuState(props.selectedEnvironmentId === null),
37 },
38 ...props.environments.map((environment) => ({
39 id: `environment:${environment.environmentId}`,
40 title: environment.label,
41 state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId),
42 })),
43 ],
44 },
45 ...(props.projects.length === 0
46 ? []
47 : ([
48 {
49 id: "project",
50 title: "Project",
51 subactions: [
52 {
53 id: "project:all",
54 title: "All projects",
55 state: checkedMenuState(props.selectedProjectKey === null),
56 },
57 ...props.projects.map((project) => ({
58 id: `project:${project.key}`,
59 title: project.label,
60 state: checkedMenuState(props.selectedProjectKey === project.key),
61 })),
62 ],
63 },
64 ] satisfies MenuAction[])),
65 ...(threadListV2Enabled
66 ? []
67 : ([
68 {
69 id: "project-sort",
70 title: "Sort projects",
71 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
72 id: `project-sort:${option.value}`,
73 title: option.label,
74 state: checkedMenuState(props.projectSortOrder === option.value),
75 })),
76 },
77 {
78 id: "thread-sort",
79 title: "Sort threads",
80 subactions: THREAD_SORT_OPTIONS.map((option) => ({
81 id: `thread-sort:${option.value}`,
82 title: option.label,
83 state: checkedMenuState(props.threadSortOrder === option.value),
84 })),
85 },
86 ] satisfies MenuAction[])),
87 ],
88 [
89 props.environments,
90 props.projectSortOrder,
91 props.projects,
92 props.selectedEnvironmentId,
93 props.selectedProjectKey,
94 props.threadSortOrder,
95 threadListV2Enabled,
96 ],
97 );
98 const handleMenuAction = useCallback(
99 (event: { nativeEvent: { event: string } }) => {
100 const id = event.nativeEvent.event;
101 if (id === "environment:all") {
102 props.onEnvironmentChange(null);
103 return;
104 }
105
106 if (id.startsWith("environment:")) {
107 const environmentId = id.slice("environment:".length);
108 const environment = props.environments.find(
109 (candidate) => candidate.environmentId === environmentId,
110 );
111 if (environment) {
112 props.onEnvironmentChange(environment.environmentId);
113 }
114 return;
115 }
116
117 if (id === "project:all") {
118 props.onProjectChange(null);
119 return;
120 }
121
122 if (id.startsWith("project:")) {
123 const projectKey = id.slice("project:".length);
124 if (props.projects.some((project) => project.key === projectKey)) {
125 props.onProjectChange(projectKey);
126 }
127 return;
128 }
129
130 const projectSort = PROJECT_SORT_OPTIONS.find(
131 (option) => id === `project-sort:${option.value}`,
132 );
133 if (projectSort) {
134 props.onProjectSortOrderChange(projectSort.value);
135 return;
136 }
137
138 const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`);
139 if (threadSort) {
140 props.onThreadSortOrderChange(threadSort.value);
141 return;
142 }
143 },
144 [props],
145 );
146
147 return (
148 <>
149 <NativeStackScreenOptions options={{ headerShown: false }} />
150 <MaterialThreadListToolbar
151 searchQuery={props.searchQuery}
152 onSearchQueryChange={props.onSearchQueryChange}
153 filterActions={menuActions}
154 filterCustomized={hasCustomListOptions}
155 onFilterAction={handleMenuAction}
156 onOpenSettings={props.onOpenSettings}
157 onOpenEnvironments={props.onOpenEnvironments}
158 />
159 </>
160 );
161}
apps/mobile/src/features/home/HomeHeader.tsx+15-202
......@@ -1,11 +1,6 @@
1import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
2import type { MenuAction } from "@react-native-menu/menu";
3
41import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
5import { useCallback, useMemo, useRef } from "react";
6import { Platform } from "react-native";
2import { useCallback, useRef } from "react";
73import type { SearchBarCommands } from "react-native-screens";
8
94import { useUniwindTheme } from "../../lib/useUniwindTheme";
105import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
116import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
......@@ -14,196 +9,17 @@ import {
149 createNativeMailSearchToolbarItem,
1510 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
1611} from "../layout/native-mail-search-toolbar";
17import type { HomeProjectSortOrder } from "./homeThreadList";
18import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";
19import {
20 buildHomeListFilterMenu,
21 type HomeListFilterMenuEnvironment,
22 type HomeListFilterMenuProject,
23} from "./home-list-filter-menu";
12import { buildHomeListFilterMenu } from "./home-list-filter-menu";
2413import {
2514 hasCustomHomeListOptions,
2615 PROJECT_SORT_OPTIONS,
2716 THREAD_SORT_OPTIONS,
2817} from "./home-list-options";
18import type { HomeHeaderProps } from "./HomeHeader.types";
2919
30export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
31
32export function HomeHeader(props: {
33 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
34 readonly projects: ReadonlyArray<HomeListFilterMenuProject>;
35 readonly searchQuery: string;
36 readonly selectedEnvironmentId: EnvironmentId | null;
37 readonly selectedProjectKey: string | null;
38 readonly projectSortOrder: HomeProjectSortOrder;
39 readonly threadSortOrder: SidebarThreadSortOrder;
40 readonly onSearchQueryChange: (query: string) => void;
41 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
42 readonly onProjectChange: (projectKey: string | null) => void;
43 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
44 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
45 readonly onOpenEnvironments: () => void;
46 readonly onOpenSettings: () => void;
47 readonly onStartNewTask: () => void;
48}) {
49 if (Platform.OS === "android") {
50 return <AndroidHomeHeader {...props} />;
51 }
52
53 return <IosHomeHeader {...props} />;
54}
55
56type HomeHeaderProps = Parameters<typeof HomeHeader>[0];
57
58function checkedMenuState(checked: boolean) {
59 return checked ? ("on" as const) : undefined;
60}
20export type { HomeHeaderEnvironment } from "./HomeHeader.types";
6121
62function AndroidHomeHeader(props: HomeHeaderProps) {
63 // Thread List v2 lays the list out in fixed creation order, so the
64 // sort/group filter controls would be silently ignored — hide them and
65 // key the "customized" icon state off the environment filter alone.
66 const threadListV2Enabled = useThreadListV2Enabled();
67 const hasCustomListOptions = threadListV2Enabled
68 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
69 : hasCustomHomeListOptions(props);
70 const menuActions = useMemo<MenuAction[]>(
71 () => [
72 {
73 id: "environment",
74 title: "Environment",
75 subactions: [
76 {
77 id: "environment:all",
78 title: "All environments",
79 state: checkedMenuState(props.selectedEnvironmentId === null),
80 },
81 ...props.environments.map((environment) => ({
82 id: `environment:${environment.environmentId}`,
83 title: environment.label,
84 state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId),
85 })),
86 ],
87 },
88 ...(props.projects.length === 0
89 ? []
90 : ([
91 {
92 id: "project",
93 title: "Project",
94 subactions: [
95 {
96 id: "project:all",
97 title: "All projects",
98 state: checkedMenuState(props.selectedProjectKey === null),
99 },
100 ...props.projects.map((project) => ({
101 id: `project:${project.key}`,
102 title: project.label,
103 state: checkedMenuState(props.selectedProjectKey === project.key),
104 })),
105 ],
106 },
107 ] satisfies MenuAction[])),
108 ...(threadListV2Enabled
109 ? []
110 : ([
111 {
112 id: "project-sort",
113 title: "Sort projects",
114 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
115 id: `project-sort:${option.value}`,
116 title: option.label,
117 state: checkedMenuState(props.projectSortOrder === option.value),
118 })),
119 },
120 {
121 id: "thread-sort",
122 title: "Sort threads",
123 subactions: THREAD_SORT_OPTIONS.map((option) => ({
124 id: `thread-sort:${option.value}`,
125 title: option.label,
126 state: checkedMenuState(props.threadSortOrder === option.value),
127 })),
128 },
129 ] satisfies MenuAction[])),
130 ],
131 [
132 props.environments,
133 props.projectSortOrder,
134 props.projects,
135 props.selectedEnvironmentId,
136 props.selectedProjectKey,
137 props.threadSortOrder,
138 threadListV2Enabled,
139 ],
140 );
141 const handleMenuAction = useCallback(
142 (event: { nativeEvent: { event: string } }) => {
143 const id = event.nativeEvent.event;
144 if (id === "environment:all") {
145 props.onEnvironmentChange(null);
146 return;
147 }
148
149 if (id.startsWith("environment:")) {
150 const environmentId = id.slice("environment:".length);
151 const environment = props.environments.find(
152 (candidate) => candidate.environmentId === environmentId,
153 );
154 if (environment) {
155 props.onEnvironmentChange(environment.environmentId);
156 }
157 return;
158 }
159
160 if (id === "project:all") {
161 props.onProjectChange(null);
162 return;
163 }
164
165 if (id.startsWith("project:")) {
166 const projectKey = id.slice("project:".length);
167 if (props.projects.some((project) => project.key === projectKey)) {
168 props.onProjectChange(projectKey);
169 }
170 return;
171 }
172
173 const projectSort = PROJECT_SORT_OPTIONS.find(
174 (option) => id === `project-sort:${option.value}`,
175 );
176 if (projectSort) {
177 props.onProjectSortOrderChange(projectSort.value);
178 return;
179 }
180
181 const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`);
182 if (threadSort) {
183 props.onThreadSortOrderChange(threadSort.value);
184 return;
185 }
186 },
187 [props],
188 );
189
190 return (
191 <>
192 <NativeStackScreenOptions options={{ headerShown: false }} />
193 <MaterialThreadListToolbar
194 searchQuery={props.searchQuery}
195 onSearchQueryChange={props.onSearchQueryChange}
196 filterActions={menuActions}
197 filterCustomized={hasCustomListOptions}
198 onFilterAction={handleMenuAction}
199 onOpenSettings={props.onOpenSettings}
200 onOpenEnvironments={props.onOpenEnvironments}
201 />
202 </>
203 );
204}
205
206function IosHomeHeader(props: HomeHeaderProps) {
22export function HomeHeader(props: HomeHeaderProps) {
20723 const searchBarRef = useRef<SearchBarCommands>(null);
20824 const iconColor = useUniwindTheme()["--color-icon"];
20925 // Thread List v2 lays the list out in fixed creation order, so the
......@@ -231,19 +47,16 @@ function IosHomeHeader(props: HomeHeaderProps) {
23147 // Static header config (glass, title, fonts) lives in Stack.tsx
23248 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
23349 headerTintColor: iconColor,
234 unstable_headerRightItems:
235 Platform.OS === "ios"
236 ? () => [
237 withNativeGlassHeaderItem({
238 accessibilityLabel: "Open settings",
239 icon: { name: "ellipsis", type: "sfSymbol" } as const,
240 identifier: "home-settings",
241 label: "",
242 onPress: props.onOpenSettings,
243 type: "button",
244 }),
245 ]
246 : undefined,
50 unstable_headerRightItems: () => [
51 withNativeGlassHeaderItem({
52 accessibilityLabel: "Open settings",
53 icon: { name: "ellipsis", type: "sfSymbol" } as const,
54 identifier: "home-settings",
55 label: "",
56 onPress: props.onOpenSettings,
57 type: "button",
58 }),
59 ],
24760 // The keys below are set per-branch (not `undefined`) so a later
24861 // reapply cannot clobber options owned by NativeHeaderToolbar.
24962 ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
apps/mobile/src/features/home/HomeHeader.types.ts created+26
......@@ -0,0 +1,26 @@
1import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
2import type { HomeProjectSortOrder } from "./homeThreadList";
3import type {
4 HomeListFilterMenuEnvironment,
5 HomeListFilterMenuProject,
6} from "./home-list-filter-menu";
7
8export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
9
10export interface HomeHeaderProps {
11 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
12 readonly projects: ReadonlyArray<HomeListFilterMenuProject>;
13 readonly searchQuery: string;
14 readonly selectedEnvironmentId: EnvironmentId | null;
15 readonly selectedProjectKey: string | null;
16 readonly projectSortOrder: HomeProjectSortOrder;
17 readonly threadSortOrder: SidebarThreadSortOrder;
18 readonly onSearchQueryChange: (query: string) => void;
19 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
20 readonly onProjectChange: (projectKey: string | null) => void;
21 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
22 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
23 readonly onOpenEnvironments: () => void;
24 readonly onOpenSettings: () => void;
25 readonly onStartNewTask: () => void;
26}
apps/mobile/src/features/home/HomeScreen.tsx+1-1
......@@ -141,7 +141,7 @@ const ESTIMATED_THREAD_ROW_HEIGHT = 72;
141141const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;
142142/**
143143 * Top spacing between the list and the Android custom header. The Android
144 * header (AndroidHomeHeader) is rendered in-flow above this screen and
144 * header is rendered in-flow above this screen and
145145 * already consumes the top safe-area inset, so the list only needs breathing
146146 * room here.
147147 */