import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { LegendList } from "@legendapp/list/react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; import { useNavigation } from "@react-navigation/native"; import { useCallback, useMemo, useRef, type ComponentProps } from "react"; import { TextInput, ActivityIndicator, Platform, Pressable, RefreshControl, useWindowDimensions, View, } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; export interface ArchivedThreadsHeaderEnvironment { readonly environmentId: EnvironmentId; readonly label: string; } type ArchivedThreadListItem = | { readonly kind: "project"; readonly key: string; readonly environmentLabel: string | null; readonly project: EnvironmentProject; } | { readonly kind: "thread"; readonly key: string; readonly environmentLabel: string | null; readonly isFirst: boolean; readonly isLast: boolean; readonly thread: EnvironmentThreadShell; }; function ArchivedThreadsHeader(props: { readonly environments: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly sortOrder: ArchivedThreadSortOrder; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onRefresh: () => void; readonly onSearchQueryChange: (query: string) => void; readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; }) { const { width } = useWindowDimensions(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; const usesNativeChrome = Platform.OS === "ios"; const usesCompactMailToolbar = Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; const androidFilterActions = useMemo( () => [ { id: "environment", title: "Environment", subactions: [ { id: "environment:all", title: "All environments", state: props.selectedEnvironmentId === null ? ("on" as const) : undefined, }, ...props.environments.map((environment) => ({ id: `environment:${environment.environmentId}`, title: environment.label, state: props.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined, })), ], }, { id: "sort", title: "Sort by archived date", subactions: [ { id: "sort:newest", title: "Newest first", state: props.sortOrder === "newest" ? ("on" as const) : undefined, }, { id: "sort:oldest", title: "Oldest first", state: props.sortOrder === "oldest" ? ("on" as const) : undefined, }, ], }, ], [props.environments, props.selectedEnvironmentId, props.sortOrder], ); const handleAndroidFilterAction = useCallback( (event: { nativeEvent: { event: string } }) => { const action = event.nativeEvent.event; if (action === "environment:all") { props.onEnvironmentChange(null); } else if (action.startsWith("environment:")) { props.onEnvironmentChange(action.slice("environment:".length) as EnvironmentId); } else if (action === "sort:newest") { props.onSortOrderChange("newest"); } else if (action === "sort:oldest") { props.onSortOrderChange("oldest"); } }, [props.onEnvironmentChange, props.onSortOrderChange], ); if (Platform.OS === "android") { // Single header row matching the app's Android chrome (AndroidScreenHeader // palette): back chevron, inline search, filter menu. return ( <> navigation.goBack()} className="size-11 items-center justify-center" > ); } const archiveFilterMenu = { title: "Archived thread options", items: [ { type: "submenu" as const, title: "Environment", items: [ { type: "action" as const, title: "All environments", state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const), onPress: () => props.onEnvironmentChange(null), }, ...props.environments.map((environment) => ({ type: "action" as const, title: environment.label, state: props.selectedEnvironmentId === environment.environmentId ? ("on" as const) : ("off" as const), onPress: () => props.onEnvironmentChange(environment.environmentId), })), ], }, { type: "submenu" as const, title: "Sort by archived date", items: [ { type: "action" as const, title: "Newest first", state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const), onPress: () => props.onSortOrderChange("newest"), }, { type: "action" as const, title: "Oldest first", state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const), onPress: () => props.onSortOrderChange("oldest"), }, ], }, ], }; return ( <> {/* Static header config (glass preset + title) lives in Stack.tsx; only dynamic toolbar/search wiring is set here. */} [ createNativeMailSearchToolbarItem({ composeButtonId: "archived-refresh", composeSystemImageName: "arrow.clockwise", filterMenu: archiveFilterMenu, filterButtonId: "archived-filter", filterSystemImageName: hasCustomFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease", onComposePress: props.onRefresh, onSearchTextChange: props.onSearchQueryChange, placeholder: "Search", searchTextChangeId: "archived-search-text", }), ] : undefined, headerSearchBarOptions: usesCompactMailToolbar ? undefined : { ...(usesNativeChrome ? { allowToolbarIntegration: true, // "integratedButton" is an iOS 26 search-bar placement; // pre-glass iOS keeps the default pull-down placement. ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? { placement: "integratedButton" as const } : null), } : { placement: "stacked" as const, }), autoCapitalize: "none", hideNavigationBar: false, obscureBackground: false, placeholder: "Search archived threads", onChangeText: (event) => { props.onSearchQueryChange(event.nativeEvent.text); }, onCancelButtonPress: () => { props.onSearchQueryChange(""); }, }, }} /> {usesCompactMailToolbar ? null : ( {usesNativeChrome ? ( ) : null} Environment props.onEnvironmentChange(null)} > All environments {props.environments.map((environment) => ( props.onEnvironmentChange(environment.environmentId)} > {environment.label} ))} Sort by archived date props.onSortOrderChange("newest")} > Newest first props.onSortOrderChange("oldest")} > Oldest first )} ); } function ProjectGroupLabel(props: { readonly environmentLabel: string | null; readonly project: EnvironmentProject; }) { return ( {props.project.title} {props.environmentLabel ? ( {props.environmentLabel} ) : null} ); } function ArchivedThreadRow(props: { readonly environmentLabel: string | null; readonly isFirst: boolean; readonly isLast: boolean; readonly onDelete: () => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly simultaneousSwipeGesture?: ComponentProps< typeof ThreadSwipeable >["simultaneousWithExternalGesture"]; readonly onUnarchive: () => void; readonly thread: EnvironmentThreadShell; }) { const { width: windowWidth } = useWindowDimensions(); const cardColor = useUniwindTheme()["--color-card"]; const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); return ( {() => ( {props.thread.title} {timestamp} {subtitle.length > 0 ? ( {subtitle.join(" ยท ")} ) : null} )} ); } function ArchiveError(props: { readonly message: string; readonly onRetry: () => void }) { return ( Could not load every archive {props.message} Try again ); } export function ArchivedThreadsScreen(props: { readonly environments: ReadonlyArray; readonly error: string | null; readonly groups: ReadonlyArray; readonly isLoading: boolean; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly sortOrder: ArchivedThreadSortOrder; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onRefresh: () => void; readonly onSearchQueryChange: (query: string) => void; readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; }) { const { onDeleteThread, onUnarchiveThread } = props; const openSwipeableRef = useRef(null); const archiveScrollGesture = useMemo(() => Gesture.Native(), []); const environmentLabelsById = useMemo( () => new Map( props.environments.map((environment) => [environment.environmentId, environment.label]), ), [props.environments], ); const listItems = useMemo>(() => { const items: ArchivedThreadListItem[] = []; for (const group of props.groups) { const environmentLabel = environmentLabelsById.get(group.project.environmentId) ?? null; items.push({ kind: "project", key: `${group.key}:project`, environmentLabel, project: group.project, }); group.threads.forEach((thread, index) => { items.push({ kind: "thread", key: `${thread.environmentId}:${thread.id}`, environmentLabel, isFirst: index === 0, isLast: index === group.threads.length - 1, thread, }); }); } return items; }, [environmentLabelsById, props.groups]); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current && openSwipeableRef.current !== methods) { openSwipeableRef.current.close(); } openSwipeableRef.current = methods; }, []); const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current === methods) { openSwipeableRef.current = null; } }, []); const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null; const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null; const renderListItem = useCallback( ({ item }: { item: ArchivedThreadListItem }) => { if (item.kind === "project") { return ( ); } return ( onDeleteThread(item.thread)} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} onUnarchive={() => onUnarchiveThread(item.thread)} simultaneousSwipeGesture={archiveScrollGesture} thread={item.thread} /> ); }, [ archiveScrollGesture, handleSwipeableClose, handleSwipeableWillOpen, onDeleteThread, onUnarchiveThread, ], ); const listEmptyComponent = useMemo(() => { if (isInitialLoad) { return ( Loading archive... ); } return ( ); }, [isFiltered, isInitialLoad]); return ( item.kind} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" keyExtractor={(item) => item.key} ListEmptyComponent={listEmptyComponent} ListHeaderComponent={ props.error ? : null } onScrollBeginDrag={() => openSwipeableRef.current?.close()} refreshControl={ } renderItem={renderListItem} showsVerticalScrollIndicator={false} /> ); }