652 lines · 23.7 KB · TypeScript
RawDownload
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 { useThemeColor } from "../../lib/useThemeColor";import { ThreadSwipeable } from "../home/thread-swipe-actions";import { createNativeMailSearchToolbarItem } 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<ArchivedThreadsHeaderEnvironment>;  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 searchIconColor = useThemeColor("--color-icon");  const searchTextColor = useThemeColor("--color-foreground");  const usesNativeChrome = Platform.OS === "ios";  const usesCompactMailToolbar = Platform.OS === "ios" && width < 700;  const androidFilterActions = useMemo<MenuAction[]>(    () => [      {        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 (      <>        <NativeStackScreenOptions options={{ headerShown: false }} />        <View          className="border-b border-header-border bg-header px-3 pb-2.5"          style={{            paddingTop: Math.max(insets.top, 12),          }}        >          <View className="min-h-12 flex-row items-center gap-2">            <Pressable              accessibilityLabel="Navigate up"              accessibilityRole="button"              hitSlop={8}              onPress={() => navigation.goBack()}              className="size-11 items-center justify-center"            >              <SymbolView                name="chevron.left"                size={24}                tintColor={searchTextColor}                type="monochrome"              />            </Pressable>            <View className="min-h-11 flex-1 flex-row items-center gap-2.5 rounded-2xl bg-input px-3.5">              <SymbolView                name="magnifyingglass"                size={17}                tintColor={searchIconColor}                type="monochrome"              />              <TextInput                accessibilityLabel="Search archived threads"                autoCapitalize="none"                onChangeText={props.onSearchQueryChange}                value={props.searchQuery}                placeholder="Search archived threads"                placeholderTextColorClassName="accent-placeholder"                className="flex-1 py-2 text-base font-sans text-foreground"              />            </View>            <ControlPillMenu              actions={androidFilterActions}              isAnchoredToRight              onPressAction={handleAndroidFilterAction}            >              <Pressable                accessibilityLabel="Filter and sort archived threads"                accessibilityRole="button"                className="size-11 items-center justify-center rounded-full bg-subtle"              >                <SymbolView                  name={                    hasCustomFilter                      ? "line.3.horizontal.decrease.circle.fill"                      : "line.3.horizontal.decrease.circle"                  }                  size={16}                  tintColor={searchIconColor}                  type="monochrome"                />              </Pressable>            </ControlPillMenu>          </View>        </View>      </>    );  }  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. */}      <NativeStackScreenOptions        options={{          unstable_headerToolbarItems: usesCompactMailToolbar            ? () => [                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,                      placement: "integratedButton" as const,                    }                  : {                      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 : (        <NativeHeaderToolbar placement="right">          {usesNativeChrome ? (            <NativeHeaderToolbar.Button              accessibilityLabel="Refresh archived threads"              icon="arrow.clockwise"              onPress={props.onRefresh}              separateBackground            />          ) : null}          <NativeHeaderToolbar.Menu            accessibilityLabel="Filter and sort archived threads"            icon={              hasCustomFilter                ? "line.3.horizontal.decrease.circle.fill"                : "line.3.horizontal.decrease.circle"            }            separateBackground            title="Archived thread options"          >            <NativeHeaderToolbar.Menu title="Environment">              <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>              <NativeHeaderToolbar.MenuAction                isOn={props.selectedEnvironmentId === null}                onPress={() => props.onEnvironmentChange(null)}              >                <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>              </NativeHeaderToolbar.MenuAction>              {props.environments.map((environment) => (                <NativeHeaderToolbar.MenuAction                  key={environment.environmentId}                  isOn={props.selectedEnvironmentId === environment.environmentId}                  onPress={() => props.onEnvironmentChange(environment.environmentId)}                >                  <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label>                </NativeHeaderToolbar.MenuAction>              ))}            </NativeHeaderToolbar.Menu>             <NativeHeaderToolbar.Menu title="Sort by archived date">              <NativeHeaderToolbar.Label>Sort by archived date</NativeHeaderToolbar.Label>              <NativeHeaderToolbar.MenuAction                isOn={props.sortOrder === "newest"}                onPress={() => props.onSortOrderChange("newest")}              >                <NativeHeaderToolbar.Label>Newest first</NativeHeaderToolbar.Label>              </NativeHeaderToolbar.MenuAction>              <NativeHeaderToolbar.MenuAction                isOn={props.sortOrder === "oldest"}                onPress={() => props.onSortOrderChange("oldest")}              >                <NativeHeaderToolbar.Label>Oldest first</NativeHeaderToolbar.Label>              </NativeHeaderToolbar.MenuAction>            </NativeHeaderToolbar.Menu>          </NativeHeaderToolbar.Menu>        </NativeHeaderToolbar>      )}    </>  );} function ProjectGroupLabel(props: {  readonly environmentLabel: string | null;  readonly project: EnvironmentProject;}) {  return (    <View className="flex-row items-center gap-2.5 px-1 pb-2">      <ProjectFavicon        environmentId={props.project.environmentId}        projectTitle={props.project.title}        size={18}        workspaceRoot={props.project.workspaceRoot}      />      <Text        className="flex-1 text-xs font-t3-medium tracking-[0.5px] uppercase text-foreground-muted"        numberOfLines={1}      >        {props.project.title}      </Text>      {props.environmentLabel ? (        <Text className="max-w-[42%] text-2xs text-foreground-tertiary" numberOfLines={1}>          {props.environmentLabel}        </Text>      ) : null}    </View>  );} 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 = useThemeColor("--color-card");  const iconColor = useThemeColor("--color-icon-subtle");  const separatorColor = useThemeColor("--color-separator");  const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);  const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>    Boolean(part),  );  return (    <ThreadSwipeable      backgroundColor={cardColor}      // Round + clip the swipeable container so the group's corners stay      // rounded while rows swipe; the row itself stays square inside.      containerStyle={{        borderTopLeftRadius: props.isFirst ? 20 : 0,        borderTopRightRadius: props.isFirst ? 20 : 0,        borderBottomLeftRadius: props.isLast ? 20 : 0,        borderBottomRightRadius: props.isLast ? 20 : 0,        overflow: "hidden",      }}      fullSwipeWidth={windowWidth - 32}      onDelete={props.onDelete}      onSwipeableClose={props.onSwipeableClose}      onSwipeableWillOpen={props.onSwipeableWillOpen}      primaryAction={{        accessibilityLabel: `Unarchive ${props.thread.title}`,        icon: "arrow.uturn.backward",        label: "Unarchive",        onPress: props.onUnarchive,      }}      simultaneousWithExternalGesture={props.simultaneousSwipeGesture}      threadTitle={props.thread.title}    >      {() => (        <View          className="flex-row items-center gap-3 bg-card px-4 py-3"          style={{            borderBottomColor: separatorColor,            borderBottomWidth: props.isLast ? 0 : 1,          }}        >          <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">            <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />          </View>           <View className="min-w-0 flex-1 gap-1">            <View className="flex-row items-center gap-2">              <Text                className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground"                numberOfLines={1}              >                {props.thread.title}              </Text>              <Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary">                {timestamp}              </Text>            </View>            {subtitle.length > 0 ? (              <View className="flex-row items-center gap-1.5">                <SymbolView                  name="arrow.triangle.branch"                  size={10}                  tintColor={iconColor}                  type="monochrome"                />                <Text                  className="min-w-0 flex-1 font-mono text-2xs text-foreground-tertiary"                  numberOfLines={1}                >                  {subtitle.join(" · ")}                </Text>              </View>            ) : null}          </View>        </View>      )}    </ThreadSwipeable>  );} function ArchiveError(props: { readonly message: string; readonly onRetry: () => void }) {  return (    <View className="rounded-[20px] border border-danger-border bg-danger p-4">      <Text className="text-base font-t3-bold text-danger-foreground">        Could not load every archive      </Text>      <Text className="mt-1 text-sm text-foreground-muted">{props.message}</Text>      <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}>        <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text>      </Pressable>    </View>  );} export function ArchivedThreadsScreen(props: {  readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;  readonly error: string | null;  readonly groups: ReadonlyArray<ArchivedThreadGroup>;  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;

Lines 1–500 of 652.