Skip to content
Source
Sign inSign up

ArchivedThreadsScreen.tsx

451 lines16.2 KB

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