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

refactor(mobile): share archived thread header configuration (#12399)


1 files changed, 80 insertions(+), 313 deletions(-)

apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+80-313
...@@ -8,15 +8,12 @@ import {...@@ -8,15 +8,12 @@ import {
8 type EnvironmentMachineKind,8 type EnvironmentMachineKind,
9 resolveEnvironmentMachineKind,9 resolveEnvironmentMachineKind,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import type { MenuAction } from "@react-native-menu/menu";
12import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
13import { SymbolView } from "../../components/AppSymbol";
14import { useNavigation } from "@react-navigation/native";11import { useNavigation } from "@react-navigation/native";
12import { ScreenHeader } from "../../components/ScreenHeader";
13import { SymbolView } from "../../components/AppSymbol";
15import { useCallback, useMemo, useRef, type ComponentProps } from "react";14import { useCallback, useMemo, useRef, type ComponentProps } from "react";
16import {15import {
17 TextInput,
18 ActivityIndicator,16 ActivityIndicator,
19 Platform,
20 Pressable,17 Pressable,
21 RefreshControl,18 RefreshControl,
22 useWindowDimensions,19 useWindowDimensions,
...@@ -26,19 +23,13 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";...@@ -26,19 +23,13 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
26import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";23import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2724
28import { AppText as Text } from "../../components/AppText";25import { AppText as Text } from "../../components/AppText";
29import { ControlPillMenu } from "../../components/ControlPill";
30import { EmptyState } from "../../components/EmptyState";26import { EmptyState } from "../../components/EmptyState";
31import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";27import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
32import { ProjectFavicon } from "../../components/ProjectFavicon";28import { ProjectFavicon } from "../../components/ProjectFavicon";
33import { useSafeAreaInsets } from "react-native-safe-area-context";
34import { relativeTime } from "../../lib/time";29import { relativeTime } from "../../lib/time";
35import { useUniwindTheme } from "../../lib/useUniwindTheme";30import { useUniwindTheme } from "../../lib/useUniwindTheme";
36import { useServerConfigs } from "../../state/entities";31import { useServerConfigs } from "../../state/entities";
37import { ThreadSwipeable } from "../home/thread-swipe-actions";32import { ThreadSwipeable } from "../home/thread-swipe-actions";
38import {
39 createNativeMailSearchToolbarItem,
40 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
41} from "../layout/native-mail-search-toolbar";
42import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";33import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
43import { SettingsScreenContent } from "../settings/components/SettingsScreen";34import { SettingsScreenContent } from "../settings/components/SettingsScreen";
4435
...@@ -47,23 +38,6 @@ export interface ArchivedThreadsHeaderEnvironment {...@@ -47,23 +38,6 @@ export interface ArchivedThreadsHeaderEnvironment {
47 readonly label: string;38 readonly label: string;
48}39}
4940
50type ArchivedThreadListItem =
51 | {
52 readonly kind: "project";
53 readonly key: string;
54 readonly environmentLabel: string | null;
55 readonly environmentMachine: EnvironmentMachineKind;
56 readonly project: EnvironmentProject;
57 }
58 | {
59 readonly kind: "thread";
60 readonly key: string;
61 readonly environmentLabel: string | null;
62 readonly isFirst: boolean;
63 readonly isLast: boolean;
64 readonly thread: EnvironmentThreadShell;
65 };
66
67function ArchivedThreadsHeader(props: {41function ArchivedThreadsHeader(props: {
68 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;42 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
69 readonly searchQuery: string;43 readonly searchQuery: string;
...@@ -74,299 +48,92 @@ function ArchivedThreadsHeader(props: {...@@ -74,299 +48,92 @@ function ArchivedThreadsHeader(props: {
74 readonly onSearchQueryChange: (query: string) => void;48 readonly onSearchQueryChange: (query: string) => void;
75 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;49 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
76}) {50}) {
77 const { width } = useWindowDimensions();
78 const navigation = useNavigation();51 const navigation = useNavigation();
79 const insets = useSafeAreaInsets();52 const { width } = useWindowDimensions();
80 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";53 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";
81 const usesNativeChrome = Platform.OS === "ios";
82 const usesCompactMailToolbar =
83 Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
84 const androidFilterActions = useMemo<MenuAction[]>(
85 () => [
86 {
87 id: "environment",
88 title: "Environment",
89 subactions: [
90 {
91 id: "environment:all",
92 title: "All environments",
93 state: props.selectedEnvironmentId === null ? ("on" as const) : undefined,
94 },
95 ...props.environments.map((environment) => ({
96 id: `environment:${environment.environmentId}`,
97 title: environment.label,
98 state:
99 props.selectedEnvironmentId === environment.environmentId
100 ? ("on" as const)
101 : undefined,
102 })),
103 ],
104 },
105 {
106 id: "sort",
107 title: "Sort by archived date",
108 subactions: [
109 {
110 id: "sort:newest",
111 title: "Newest first",
112 state: props.sortOrder === "newest" ? ("on" as const) : undefined,
113 },
114 {
115 id: "sort:oldest",
116 title: "Oldest first",
117 state: props.sortOrder === "oldest" ? ("on" as const) : undefined,
118 },
119 ],
120 },
121 ],
122 [props.environments, props.selectedEnvironmentId, props.sortOrder],
123 );
124 const handleAndroidFilterAction = useCallback(
125 (event: { nativeEvent: { event: string } }) => {
126 const action = event.nativeEvent.event;
127 if (action === "environment:all") {
128 props.onEnvironmentChange(null);
129 } else if (action.startsWith("environment:")) {
130 props.onEnvironmentChange(action.slice("environment:".length) as EnvironmentId);
131 } else if (action === "sort:newest") {
132 props.onSortOrderChange("newest");
133 } else if (action === "sort:oldest") {
134 props.onSortOrderChange("oldest");
135 }
136 },
137 [props.onEnvironmentChange, props.onSortOrderChange],
138 );
139
140 if (Platform.OS === "android") {
141 // Single header row matching the app's Android chrome (AndroidScreenHeader
142 // palette): back chevron, inline search, filter menu.
143 return (
144 <>
145 <NativeStackScreenOptions options={{ headerShown: false }} />
146 <View
147 className="border-b border-header-border bg-header px-3 pb-2.5"
148 style={{
149 paddingTop: Math.max(insets.top, 12),
150 borderBottomWidth: 0,
151 }}
152 >
153 <View className="min-h-12 flex-row items-center gap-2">
154 <Pressable
155 accessibilityLabel="Navigate up"
156 accessibilityRole="button"
157 hitSlop={8}
158 onPress={() => navigation.goBack()}
159 className="size-11 items-center justify-center"
160 >
161 <SymbolView
162 name="chevron.left"
163 size={24}
164 tintColorClassName="accent-foreground"
165 type="monochrome"
166 />
167 </Pressable>
168 <View className="min-h-11 flex-1 flex-row items-center gap-2.5 rounded-2xl bg-input px-3.5">
169 <SymbolView
170 name="magnifyingglass"
171 size={17}
172 tintColorClassName="accent-icon"
173 type="monochrome"
174 />
175 <TextInput
176 accessibilityLabel="Search archived threads"
177 autoCapitalize="none"
178 onChangeText={props.onSearchQueryChange}
179 value={props.searchQuery}
180 placeholder="Search archived threads"
181 placeholderTextColorClassName="accent-placeholder"
182 className="flex-1 py-2 text-base font-sans text-foreground"
183 />
184 </View>
185 <ControlPillMenu
186 actions={androidFilterActions}
187 isAnchoredToRight
188 onPressAction={handleAndroidFilterAction}
189 >
190 <Pressable
191 accessibilityLabel="Filter and sort archived threads"
192 accessibilityRole="button"
193 className="size-11 items-center justify-center rounded-full bg-subtle"
194 >
195 <SymbolView
196 name={
197 hasCustomFilter
198 ? "line.3.horizontal.decrease.circle.fill"
199 : "line.3.horizontal.decrease.circle"
200 }
201 size={16}
202 tintColorClassName="accent-icon"
203 type="monochrome"
204 />
205 </Pressable>
206 </ControlPillMenu>
207 </View>
208 </View>
209 </>
210 );
211 }
212 const archiveFilterMenu = {
213 title: "Archived thread options",
214 items: [
215 {
216 type: "submenu" as const,
217 title: "Environment",
218 items: [
219 {
220 type: "action" as const,
221 title: "All environments",
222 state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const),
223 onPress: () => props.onEnvironmentChange(null),
224 },
225 ...props.environments.map((environment) => ({
226 type: "action" as const,
227 title: environment.label,
228 state:
229 props.selectedEnvironmentId === environment.environmentId
230 ? ("on" as const)
231 : ("off" as const),
232 onPress: () => props.onEnvironmentChange(environment.environmentId),
233 })),
234 ],
235 },
236 {
237 type: "submenu" as const,
238 title: "Sort by archived date",
239 items: [
240 {
241 type: "action" as const,
242 title: "Newest first",
243 state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const),
244 onPress: () => props.onSortOrderChange("newest"),
245 },
246 {
247 type: "action" as const,
248 title: "Oldest first",
249 state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const),
250 onPress: () => props.onSortOrderChange("oldest"),
251 },
252 ],
253 },
254 ],
255 };
256
257 return (54 return (
258 <>55 <ScreenHeader
259 {/* Static header config (glass preset + title) lives in Stack.tsx; only56 title="Archived threads"
260 dynamic toolbar/search wiring is set here. */}57 sidebar={false}
261 <NativeStackScreenOptions58 onBack={() => navigation.goBack()}
262 options={{59 search={{
263 unstable_headerToolbarItems: usesCompactMailToolbar60 value: props.searchQuery,
264 ? () => [61 onChangeText: props.onSearchQueryChange,
265 createNativeMailSearchToolbarItem({62 placeholder: "Search archived threads",
266 composeButtonId: "archived-refresh",63 compactPlaceholder: "Search",
267 composeSystemImageName: "arrow.clockwise",64 mode: "inline",
268 filterMenu: archiveFilterMenu,65 compactToolbar: width < 700,
269 filterButtonId: "archived-filter",66 onRefresh: props.onRefresh,
270 filterSystemImageName: hasCustomFilter67 refreshInToolbar: true,
271 ? "line.3.horizontal.decrease.circle.fill"68 refreshAccessibilityLabel: "Refresh archived threads",
272 : "line.3.horizontal.decrease",69 }}
273 onComposePress: props.onRefresh,70 menus={[
274 onSearchTextChange: props.onSearchQueryChange,71 {
275 placeholder: "Search",72 title: "Archived thread options",
276 searchTextChangeId: "archived-search-text",73 icon: hasCustomFilter
277 }),74 ? "line.3.horizontal.decrease.circle.fill"
278 ]75 : "line.3.horizontal.decrease.circle",
279 : undefined,76 items: [
280 headerSearchBarOptions: usesCompactMailToolbar77 {
281 ? undefined78 id: "environment",
282 : {79 title: "Environment",
283 ...(usesNativeChrome80 items: [
284 ? {81 {
285 allowToolbarIntegration: true,82 id: "environment:all",
286 // "integratedButton" is an iOS 26 search-bar placement;83 title: "All environments",
287 // pre-glass iOS keeps the default pull-down placement.84 selected: props.selectedEnvironmentId === null,
288 ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED85 onPress: () => props.onEnvironmentChange(null),
289 ? { placement: "integratedButton" as const }
290 : null),
291 }
292 : {
293 placement: "stacked" as const,
294 }),
295 autoCapitalize: "none",
296 hideNavigationBar: false,
297 obscureBackground: false,
298 placeholder: "Search archived threads",
299 onChangeText: (event) => {
300 props.onSearchQueryChange(event.nativeEvent.text);
301 },86 },
302 onCancelButtonPress: () => {87 ...props.environments.map((environment) => ({
303 props.onSearchQueryChange("");88 id: `environment:${environment.environmentId}`,
89 title: environment.label,
90 selected: props.selectedEnvironmentId === environment.environmentId,
91 onPress: () => props.onEnvironmentChange(environment.environmentId),
92 })),
93 ],
94 },
95 {
96 id: "sort",
97 title: "Sort by archived date",
98 items: [
99 {
100 id: "sort:newest",
101 title: "Newest first",
102 selected: props.sortOrder === "newest",
103 onPress: () => props.onSortOrderChange("newest"),
304 },104 },
305 },105 {
306 }}106 id: "sort:oldest",
307 />107 title: "Oldest first",
308108 selected: props.sortOrder === "oldest",
309 {usesCompactMailToolbar ? null : (109 onPress: () => props.onSortOrderChange("oldest"),
310 <NativeHeaderToolbar placement="right">110 },
311 {usesNativeChrome ? (111 ],
312 <NativeHeaderToolbar.Button112 },
313 accessibilityLabel="Refresh archived threads"113 ],
314 icon="arrow.clockwise"114 },
315 onPress={props.onRefresh}115 ]}
316 separateBackground116 />
317 />
318 ) : null}
319 <NativeHeaderToolbar.Menu
320 accessibilityLabel="Filter and sort archived threads"
321 icon={
322 hasCustomFilter
323 ? "line.3.horizontal.decrease.circle.fill"
324 : "line.3.horizontal.decrease.circle"
325 }
326 separateBackground
327 title="Archived thread options"
328 >
329 <NativeHeaderToolbar.Menu title="Environment">
330 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
331 <NativeHeaderToolbar.MenuAction
332 isOn={props.selectedEnvironmentId === null}
333 onPress={() => props.onEnvironmentChange(null)}
334 >
335 <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>
336 </NativeHeaderToolbar.MenuAction>
337 {props.environments.map((environment) => (
338 <NativeHeaderToolbar.MenuAction
339 key={environment.environmentId}
340 isOn={props.selectedEnvironmentId === environment.environmentId}
341 onPress={() => props.onEnvironmentChange(environment.environmentId)}
342 >
343 <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label>
344 </NativeHeaderToolbar.MenuAction>
345 ))}
346 </NativeHeaderToolbar.Menu>
347
348 <NativeHeaderToolbar.Menu title="Sort by archived date">
349 <NativeHeaderToolbar.Label>Sort by archived date</NativeHeaderToolbar.Label>
350 <NativeHeaderToolbar.MenuAction
351 isOn={props.sortOrder === "newest"}
352 onPress={() => props.onSortOrderChange("newest")}
353 >
354 <NativeHeaderToolbar.Label>Newest first</NativeHeaderToolbar.Label>
355 </NativeHeaderToolbar.MenuAction>
356 <NativeHeaderToolbar.MenuAction
357 isOn={props.sortOrder === "oldest"}
358 onPress={() => props.onSortOrderChange("oldest")}
359 >
360 <NativeHeaderToolbar.Label>Oldest first</NativeHeaderToolbar.Label>
361 </NativeHeaderToolbar.MenuAction>
362 </NativeHeaderToolbar.Menu>
363 </NativeHeaderToolbar.Menu>
364 </NativeHeaderToolbar>
365 )}
366 </>
367 );117 );
368}118}
369119
120type ArchivedThreadListItem =
121 | {
122 readonly kind: "project";
123 readonly key: string;
124 readonly environmentLabel: string | null;
125 readonly environmentMachine: EnvironmentMachineKind;
126 readonly project: EnvironmentProject;
127 }
128 | {
129 readonly kind: "thread";
130 readonly key: string;
131 readonly environmentLabel: string | null;
132 readonly isFirst: boolean;
133 readonly isLast: boolean;
134 readonly thread: EnvironmentThreadShell;
135 };
136
370function ProjectGroupLabel(props: {137function ProjectGroupLabel(props: {
371 readonly environmentLabel: string | null;138 readonly environmentLabel: string | null;
372 readonly environmentMachine: EnvironmentMachineKind;139 readonly environmentMachine: EnvironmentMachineKind;