1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import { LegendList } from "@legendapp/list/react-native";
6import {
7 AuthOrchestrationOperateScope,
8 type EnvironmentId,
9 type EnvironmentMachineKind,
10 resolveEnvironmentMachineKind,
11} from "@t3tools/contracts";
12import { useNavigation } from "@react-navigation/native";
13import { AppActivityIndicator } from "../../components/AppActivityIndicator";
14import { ScreenHeader } from "../../components/ScreenHeader";
15import { SymbolView } from "../../components/AppSymbol";
16import { useCallback, useMemo, useRef, type ComponentProps } from "react";
17import { Platform, Pressable, RefreshControl, useWindowDimensions, View } from "react-native";
18import { GestureDetector, useNativeGesture } from "react-native-gesture-handler";
19import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
20
21import { AppText as Text } from "../../components/AppText";
22import { EmptyState } from "../../components/EmptyState";
23import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
24import { ProjectFavicon } from "../../components/ProjectFavicon";
25import { relativeTime } from "../../lib/time";
26import { useUniwindTheme } from "../../lib/useUniwindTheme";
27import { useServerConfigs } from "../../state/entities";
28import { useEnvironmentScope } from "../../state/session";
29import { ThreadSwipeable } from "../home/thread-swipe-actions";
30import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
31import { SettingsScreenContent } from "../settings/components/SettingsScreen";
32
33export interface ArchivedThreadsHeaderEnvironment {
34 readonly environmentId: EnvironmentId;
35 readonly label: string;
36}
37
38function ArchivedThreadsHeader(props: {
39 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
40 readonly searchQuery: string;
41 readonly selectedEnvironmentId: EnvironmentId | null;
42 readonly sortOrder: ArchivedThreadSortOrder;
43 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
44 readonly onRefresh: () => void;
45 readonly onSearchQueryChange: (query: string) => void;
46 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
47}) {
48 const navigation = useNavigation();
49 const { width } = useWindowDimensions();
50 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";
51 return (
52 <ScreenHeader
53 title="Archived threads"
54 sidebar={false}
55 onBack={() => navigation.goBack()}
56 search={{
57 value: props.searchQuery,
58 onChangeText: props.onSearchQueryChange,
59 placeholder: "Search archived threads",
60 compactPlaceholder: "Search",
61 mode: "inline",
62 compactToolbar: width < 700,
63 }}
64 menus={[
65 {
66 title: "Archived thread options",
67 icon: hasCustomFilter
68 ? "line.3.horizontal.decrease.circle.fill"
69 : "line.3.horizontal.decrease.circle",
70 items: [
71 {
72 id: "environment",
73 title: "Environment",
74 items: [
75 {
76 id: "environment:all",
77 title: "All environments",
78 selected: props.selectedEnvironmentId === null,
79 onPress: () => props.onEnvironmentChange(null),
80 },
81 ...props.environments.map((environment) => ({
82 id: `environment:${environment.environmentId}`,
83 title: environment.label,
84 selected: props.selectedEnvironmentId === environment.environmentId,
85 onPress: () => props.onEnvironmentChange(environment.environmentId),
86 })),
87 ],
88 },
89 {
90 id: "sort",
91 title: "Sort by archived date",
92 items: [
93 {
94 id: "sort:newest",
95 title: "Newest first",
96 selected: props.sortOrder === "newest",
97 onPress: () => props.onSortOrderChange("newest"),
98 },
99 {
100 id: "sort:oldest",
101 title: "Oldest first",
102 selected: props.sortOrder === "oldest",
103 onPress: () => props.onSortOrderChange("oldest"),
104 },
105 ],
106 },
107 ...(Platform.OS === "android"
108 ? [
109 {
110 id: "refresh",
111 title: "Refresh archived threads",
112 onPress: props.onRefresh,
113 },
114 ]
115 : []),
116 ],
117 },
118 ]}
119 />
120 );
121}
122
123type ArchivedThreadListItem =
124 | {
125 readonly kind: "project";
126 readonly key: string;
127 readonly environmentLabel: string | null;
128 readonly environmentMachine: EnvironmentMachineKind;
129 readonly project: EnvironmentProject;
130 }
131 | {
132 readonly kind: "thread";
133 readonly key: string;
134 readonly environmentLabel: string | null;
135 readonly isFirst: boolean;
136 readonly isLast: boolean;
137 readonly thread: EnvironmentThreadShell;
138 };
139
140function ProjectGroupLabel(props: {
141 readonly environmentLabel: string | null;
142 readonly environmentMachine: EnvironmentMachineKind;
143 readonly project: EnvironmentProject;
144}) {
145 return (
146 <View className="flex-row items-center gap-2.5 px-1 pb-2">
147 <ProjectFavicon
148 environmentId={props.project.environmentId}
149 faviconPath={props.project.faviconPath}
150 projectIcon={props.project.projectIcon}
151 projectTitle={props.project.title}
152 size={18}
153 workspaceRoot={props.project.workspaceRoot}
154 />
155 <Text
156 className="flex-1 text-xs font-t3-medium tracking-[0.5px] uppercase text-foreground-muted"
157 numberOfLines={1}
158 >
159 {props.project.title}
160 </Text>
161 {props.environmentLabel ? (
162 <View className="max-w-[42%] flex-row items-center gap-1">
163 <EnvironmentMachineSymbol
164 kind={props.environmentMachine}
165 size={10}
166 tintColorClassName="accent-foreground-tertiary"
167 />
168 <Text className="shrink text-2xs text-foreground-tertiary" numberOfLines={1}>
169 {props.environmentLabel}
170 </Text>
171 </View>
172 ) : null}
173 </View>
174 );
175}
176
177function ArchivedThreadRow(props: {
178 readonly environmentLabel: string | null;
179 readonly isFirst: boolean;
180 readonly isLast: boolean;
181 readonly onDelete: () => void;
182 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
183 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
184 readonly simultaneousSwipeGesture?: ComponentProps<typeof ThreadSwipeable>["simultaneousWith"];
185 readonly onUnarchive: () => void;
186 readonly thread: EnvironmentThreadShell;
187}) {
188 const { width: windowWidth } = useWindowDimensions();
189 const cardColor = useUniwindTheme()["--color-grouped-card"];
190 const canOperateThread = useEnvironmentScope(
191 props.thread.environmentId,
192 AuthOrchestrationOperateScope,
193 );
194 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
195 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
196 Boolean(part),
197 );
198 const rowContent = (
199 <View
200 className={`flex-row items-center gap-3 bg-grouped-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`}
201 >
202 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
203 <SymbolView
204 name="archivebox.fill"
205 size={15}
206 tintColorClassName="accent-icon-subtle"
207 type="monochrome"
208 />
209 </View>
210
211 <View className="min-w-0 flex-1 gap-1">
212 <View className="flex-row items-center gap-2">
213 <Text
214 className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground"
215 numberOfLines={1}
216 >
217 {props.thread.title}
218 </Text>
219 <Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary">
220 {timestamp}
221 </Text>
222 </View>
223 {subtitle.length > 0 ? (
224 <View className="flex-row items-center gap-1.5">
225 <SymbolView
226 name="arrow.triangle.branch"
227 size={10}
228 tintColorClassName="accent-icon-subtle"
229 type="monochrome"
230 />
231 <Text
232 className="min-w-0 flex-1 font-mono text-2xs text-foreground-tertiary"
233 numberOfLines={1}
234 >
235 {subtitle.join(" · ")}
236 </Text>
237 </View>
238 ) : null}
239 </View>
240 </View>
241 );
242 // Keep the group's rounded corners on both interactive and read-only rows.
243 const containerStyle = {
244 borderTopLeftRadius: props.isFirst ? 20 : 0,
245 borderTopRightRadius: props.isFirst ? 20 : 0,
246 borderBottomLeftRadius: props.isLast ? 20 : 0,
247 borderBottomRightRadius: props.isLast ? 20 : 0,
248 overflow: "hidden" as const,
249 };
250 if (!canOperateThread) return <View style={containerStyle}>{rowContent}</View>;
251
252 return (
253 <ThreadSwipeable
254 resetKey={`${props.thread.environmentId}:${props.thread.id}`}
255 threadKey={`${props.thread.environmentId}:${props.thread.id}`}
256 backgroundColor={cardColor}
257 containerStyle={containerStyle}
258 fullSwipeWidth={windowWidth - 32}
259 onDelete={props.onDelete}
260 onSwipeableClose={props.onSwipeableClose}
261 onSwipeableWillOpen={props.onSwipeableWillOpen}
262 primaryAction={{
263 accessibilityLabel: `Unarchive ${props.thread.title}`,
264 icon: "arrow.uturn.backward",
265 label: "Unarchive",
266 onPress: props.onUnarchive,
267 }}
268 simultaneousWith={props.simultaneousSwipeGesture}
269 threadTitle={props.thread.title}
270 >
271 {() => rowContent}
272 </ThreadSwipeable>
273 );
274}
275
276function ArchiveError(props: { readonly message: string; readonly onRetry: () => void }) {
277 return (
278 <View className="rounded-[20px] border border-danger-border bg-danger p-4">
279 <Text className="text-base font-t3-bold text-danger-foreground">
280 Could not load every archive
281 </Text>
282 <Text className="mt-1 text-sm text-foreground-muted">{props.message}</Text>
283 <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}>
284 <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text>
285 </Pressable>
286 </View>
287 );
288}
289
290export function ArchivedThreadsScreen(props: {
291 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
292 readonly error: string | null;
293 readonly groups: ReadonlyArray<ArchivedThreadGroup>;
294 readonly isLoading: boolean;
295 readonly searchQuery: string;
296 readonly selectedEnvironmentId: EnvironmentId | null;
297 readonly sortOrder: ArchivedThreadSortOrder;
298 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
299 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
300 readonly onRefresh: () => void;
301 readonly onSearchQueryChange: (query: string) => void;
302 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
303 readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;
304}) {
305 const { onDeleteThread, onUnarchiveThread } = props;
306 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
307 const archiveScrollGesture = useNativeGesture();
308 const environmentLabelsById = useMemo(
309 () =>
310 new Map(
311 props.environments.map((environment) => [environment.environmentId, environment.label]),
312 ),
313 [props.environments],
314 );
315 const serverConfigs = useServerConfigs();
316 const listItems = useMemo<ReadonlyArray<ArchivedThreadListItem>>(() => {
317 const items: ArchivedThreadListItem[] = [];
318 for (const group of props.groups) {
319 const environmentLabel = environmentLabelsById.get(group.project.environmentId) ?? null;
320 items.push({
321 kind: "project",
322 key: `${group.key}:project`,
323 environmentLabel,
324 environmentMachine: resolveEnvironmentMachineKind(
325 serverConfigs.get(group.project.environmentId) ?? null,
326 ),
327 project: group.project,
328 });
329
330 group.threads.forEach((thread, index) => {
331 items.push({
332 kind: "thread",
333 key: `${thread.environmentId}:${thread.id}`,
334 environmentLabel,
335 isFirst: index === 0,
336 isLast: index === group.threads.length - 1,
337 thread,
338 });
339 });
340 }
341 return items;
342 }, [environmentLabelsById, props.groups, serverConfigs]);
343 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
344 if (openSwipeableRef.current && openSwipeableRef.current !== methods) {
345 openSwipeableRef.current.close();
346 }
347 openSwipeableRef.current = methods;
348 }, []);
349 const handleSwipeableClose = useCallback((methods: SwipeableMethods) => {
350 if (openSwipeableRef.current === methods) {
351 openSwipeableRef.current = null;
352 }
353 }, []);
354 const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null;
355 const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null;
356 const renderListItem = useCallback(
357 ({ item }: { item: ArchivedThreadListItem }) => {
358 if (item.kind === "project") {
359 return (
360 <View className="pt-4">
361 <ProjectGroupLabel
362 environmentLabel={item.environmentLabel}
363 environmentMachine={item.environmentMachine}
364 project={item.project}
365 />
366 </View>
367 );
368 }
369
370 return (
371 <ArchivedThreadRow
372 environmentLabel={item.environmentLabel}
373 isFirst={item.isFirst}
374 isLast={item.isLast}
375 onDelete={() => onDeleteThread(item.thread)}
376 onSwipeableClose={handleSwipeableClose}
377 onSwipeableWillOpen={handleSwipeableWillOpen}
378 onUnarchive={() => onUnarchiveThread(item.thread)}
379 simultaneousSwipeGesture={archiveScrollGesture}
380 thread={item.thread}
381 />
382 );
383 },
384 [
385 archiveScrollGesture,
386 handleSwipeableClose,
387 handleSwipeableWillOpen,
388 onDeleteThread,
389 onUnarchiveThread,
390 ],
391 );
392 const listEmptyComponent = useMemo(() => {
393 if (isInitialLoad) {
394 return (
395 <View className="items-center py-16">
396 <AppActivityIndicator colorClassName="accent-icon" />
397 <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text>
398 </View>
399 );
400 }
401
402 return (
403 <EmptyState
404 detail={
405 isFiltered
406 ? "Try another search or environment."
407 : "Threads you archive will appear here."
408 }
409 title={isFiltered ? "No matching threads" : "No archived threads"}
410 />
411 );
412 }, [isFiltered, isInitialLoad]);
413
414 return (
415 // Keep the list inside this native container. Form-sheet resizing otherwise
416 // treats the flattened background as a header and shrinks the list to zero.
417 <View collapsable={false} className="flex-1 bg-sheet">
418 <ArchivedThreadsHeader
419 environments={props.environments}
420 searchQuery={props.searchQuery}
421 onEnvironmentChange={props.onEnvironmentChange}
422 onRefresh={props.onRefresh}
423 onSearchQueryChange={props.onSearchQueryChange}
424 onSortOrderChange={props.onSortOrderChange}
425 selectedEnvironmentId={props.selectedEnvironmentId}
426 sortOrder={props.sortOrder}
427 />
428
429 <SettingsScreenContent>
430 <GestureDetector gesture={archiveScrollGesture}>
431 <LegendList
432 className="flex-1"
433 contentContainerStyle={{
434 paddingBottom: 32,
435 paddingHorizontal: 16,
436 paddingTop: 4,
437 }}
438 contentInsetAdjustmentBehavior="automatic"
439 data={listItems}
440 estimatedItemSize={62}
441 getItemType={(item) => item.kind}
442 keyboardDismissMode="on-drag"
443 keyboardShouldPersistTaps="handled"
444 keyExtractor={(item) => item.key}
445 ListEmptyComponent={listEmptyComponent}
446 ListHeaderComponent={
447 props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null
448 }
449 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
450 refreshControl={
451 <RefreshControl
452 onRefresh={props.onRefresh}
453 refreshing={props.isLoading && !isInitialLoad}
454 tintColorClassName={String("accent-icon")}
455 />
456 }
457 renderItem={renderListItem}
458 showsVerticalScrollIndicator={false}
459 />
460 </GestureDetector>
461 </SettingsScreenContent>
462 </View>
463 );
464}