436 lines · 15.7 KB · TypeScript
RawDownload
import type {  EnvironmentProject,  EnvironmentThreadShell,} from "@t3tools/client-runtime/state/shell";import type { EnvironmentId } from "@t3tools/contracts";import type { MenuAction } from "@react-native-menu/menu";import * as Haptics from "expo-haptics";import { Stack } from "expo-router";import { SymbolView } from "expo-symbols";import { useCallback, useRef } from "react";import {  ActivityIndicator,  Pressable,  RefreshControl,  ScrollView,  useWindowDimensions,  View,} from "react-native";import ReanimatedSwipeable, {  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 { relativeTime } from "../../lib/time";import { useThemeColor } from "../../lib/useThemeColor";import {  THREAD_SWIPE_ACTIONS_WIDTH,  THREAD_SWIPE_SPRING,  ThreadSwipeActions,} from "../home/thread-swipe-actions";import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; export interface ArchivedThreadsHeaderEnvironment {  readonly environmentId: EnvironmentId;  readonly label: string;} const THREAD_ACTIONS: MenuAction[] = [  {    id: "unarchive",    title: "Unarchive",    image: "arrow.uturn.backward",  },  {    id: "delete",    title: "Delete",    image: "trash",    attributes: { destructive: true },  },]; function ArchivedThreadsHeader(props: {  readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;  readonly selectedEnvironmentId: EnvironmentId | null;  readonly sortOrder: ArchivedThreadSortOrder;  readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;  readonly onSearchQueryChange: (query: string) => void;  readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;}) {  const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";   return (    <>      <Stack.Screen        options={{          title: "Archived Threads",          headerSearchBarOptions: {            autoCapitalize: "none",            hideNavigationBar: false,            obscureBackground: false,            placeholder: "Search archived threads",            placement: "stacked",            onChangeText: (event) => {              props.onSearchQueryChange(event.nativeEvent.text);            },            onCancelButtonPress: () => {              props.onSearchQueryChange("");            },          },        }}      />       <Stack.Toolbar placement="right">        <Stack.Toolbar.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"        >          <Stack.Toolbar.Menu title="Environment">            <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>            <Stack.Toolbar.MenuAction              isOn={props.selectedEnvironmentId === null}              onPress={() => props.onEnvironmentChange(null)}            >              <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>            </Stack.Toolbar.MenuAction>            {props.environments.map((environment) => (              <Stack.Toolbar.MenuAction                key={environment.environmentId}                isOn={props.selectedEnvironmentId === environment.environmentId}                onPress={() => props.onEnvironmentChange(environment.environmentId)}              >                <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>              </Stack.Toolbar.MenuAction>            ))}          </Stack.Toolbar.Menu>           <Stack.Toolbar.Menu title="Sort by archived date">            <Stack.Toolbar.Label>Sort by archived date</Stack.Toolbar.Label>            <Stack.Toolbar.MenuAction              isOn={props.sortOrder === "newest"}              onPress={() => props.onSortOrderChange("newest")}            >              <Stack.Toolbar.Label>Newest first</Stack.Toolbar.Label>            </Stack.Toolbar.MenuAction>            <Stack.Toolbar.MenuAction              isOn={props.sortOrder === "oldest"}              onPress={() => props.onSortOrderChange("oldest")}            >              <Stack.Toolbar.Label>Oldest first</Stack.Toolbar.Label>            </Stack.Toolbar.MenuAction>          </Stack.Toolbar.Menu>        </Stack.Toolbar.Menu>      </Stack.Toolbar>    </>  );} 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 uppercase text-foreground-muted"        numberOfLines={1}        style={{ letterSpacing: 0.5 }}      >        {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 isLast: boolean;  readonly onDelete: () => void;  readonly onSwipeableClose: (methods: SwipeableMethods) => void;  readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;  readonly onUnarchive: () => void;  readonly thread: EnvironmentThreadShell;}) {  const swipeableRef = useRef<SwipeableMethods | null>(null);  const fullSwipeArmedRef = useRef(false);  const { width: windowWidth } = useWindowDimensions();  const cardColor = useThemeColor("--color-card");  const iconColor = useThemeColor("--color-icon-subtle");  const separatorColor = useThemeColor("--color-separator");  const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58);  const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);  const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>    Boolean(part),  );  const handleFullSwipeArmedChange = useCallback((armed: boolean) => {    if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {      void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);    }    fullSwipeArmedRef.current = armed;  }, []);  const handleMenuAction = useCallback(    (event: { nativeEvent: { event: string } }) => {      if (event.nativeEvent.event === "unarchive") {        props.onUnarchive();      } else if (event.nativeEvent.event === "delete") {        props.onDelete();      }    },    [props.onDelete, props.onUnarchive],  );   return (    <ReanimatedSwipeable      ref={swipeableRef}      animationOptions={THREAD_SWIPE_SPRING}      childrenContainerStyle={{ backgroundColor: cardColor }}      containerStyle={{ backgroundColor: cardColor }}      dragOffsetFromRightEdge={8}      enableTrackpadTwoFingerGesture      friction={1}      onSwipeableClose={() => {        fullSwipeArmedRef.current = false;        if (swipeableRef.current) {          props.onSwipeableClose(swipeableRef.current);        }      }}      onSwipeableOpenStartDrag={() => {        if (swipeableRef.current) {          props.onSwipeableWillOpen(swipeableRef.current);        }      }}      onSwipeableWillOpen={() => {        const methods = swipeableRef.current;        if (!methods) return;         props.onSwipeableWillOpen(methods);        if (fullSwipeArmedRef.current) {          fullSwipeArmedRef.current = false;          methods.close();          props.onDelete();        }      }}      overshootFriction={1}      overshootRight      renderRightActions={(_progress, translation, methods) => (        <ThreadSwipeActions          backgroundColor={cardColor}          fullSwipeThreshold={fullSwipeThreshold}          onDelete={props.onDelete}          onFullSwipeArmedChange={handleFullSwipeArmedChange}          primaryAction={{            accessibilityLabel: `Unarchive ${props.thread.title}`,            icon: "arrow.uturn.backward",            label: "Unarchive",            onPress: props.onUnarchive,          }}          swipeableMethods={methods}          threadTitle={props.thread.title}          translation={translation}        />      )}      rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}    >      <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-[20px] text-foreground"              numberOfLines={1}            >              {props.thread.title}            </Text>            <Text              className="text-xs text-foreground-tertiary"              style={{ fontVariant: ["tabular-nums"] }}            >              {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 text-2xs text-foreground-tertiary"                numberOfLines={1}                style={{ fontFamily: "monospace" }}              >                {subtitle.join(" · ")}              </Text>            </View>          ) : null}        </View>         <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>          <Pressable            accessibilityLabel={`Actions for ${props.thread.title}`}            accessibilityRole="button"            className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"            hitSlop={6}          >            <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />          </Pressable>        </ControlPillMenu>      </View>    </ReanimatedSwipeable>  );} 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 leading-[18px] 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;  readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;  readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;}) {  const openSwipeableRef = useRef<SwipeableMethods | null>(null);  const refreshTint = useThemeColor("--color-icon");  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;   return (    <View className="flex-1 bg-sheet">      <ArchivedThreadsHeader        environments={props.environments}        onEnvironmentChange={props.onEnvironmentChange}        onSearchQueryChange={props.onSearchQueryChange}        onSortOrderChange={props.onSortOrderChange}        selectedEnvironmentId={props.selectedEnvironmentId}        sortOrder={props.sortOrder}      />       <ScrollView        className="flex-1"        contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 16, paddingTop: 8 }}        contentInsetAdjustmentBehavior="automatic"        keyboardDismissMode="on-drag"        keyboardShouldPersistTaps="handled"        onScrollBeginDrag={() => openSwipeableRef.current?.close()}        refreshControl={          <RefreshControl            onRefresh={props.onRefresh}            refreshing={props.isLoading && !isInitialLoad}            tintColor={String(refreshTint)}          />        }        showsVerticalScrollIndicator={false}      >        {props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null}         {isInitialLoad ? (          <View className="items-center py-16">            <ActivityIndicator color={refreshTint} />            <Text className="mt-3 text-sm text-foreground-muted">Loading archive…</Text>          </View>        ) : props.groups.length === 0 ? (          <EmptyState            detail={              isFiltered                ? "Try another search or environment."                : "Threads you archive will appear here."            }            title={isFiltered ? "No matching threads" : "No archived threads"}          />        ) : (          props.groups.map((group) => {            const environmentLabel =              props.environments.find(                (environment) => environment.environmentId === group.project.environmentId,              )?.label ?? null;             return (              <View key={group.key} collapsable={false}>                <ProjectGroupLabel environmentLabel={environmentLabel} project={group.project} />                <View                  className="overflow-hidden rounded-[20px] bg-card"                  style={{ borderCurve: "continuous" }}                >                  {group.threads.map((thread, index) => (                    <ArchivedThreadRow                      key={`${thread.environmentId}:${thread.id}`}                      environmentLabel={environmentLabel}                      isLast={index === group.threads.length - 1}                      onDelete={() => props.onDeleteThread(thread)}                      onSwipeableClose={handleSwipeableClose}                      onSwipeableWillOpen={handleSwipeableWillOpen}                      onUnarchive={() => props.onUnarchiveThread(thread)}                      thread={thread}                    />                  ))}                </View>              </View>            );          })        )}      </ScrollView>    </View>  );}