282 lines · 9.1 KB · TypeScript
RawDownload
import { SymbolView } from "expo-symbols";import { useCallback, useEffect, useMemo, useState } from "react";import {  type ColorValue,  Modal,  Pressable,  ScrollView,  useWindowDimensions,  View,} from "react-native";import * as Arr from "effect/Array";import * as Order from "effect/Order";import { Gesture, GestureDetector } from "react-native-gesture-handler";import { useSafeAreaInsets } from "react-native-safe-area-context";import Animated, {  runOnJS,  useAnimatedStyle,  useSharedValue,  withTiming,} from "react-native-reanimated";import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText";import { StatusPill } from "../../components/StatusPill";import { useProjects, useThreadShells } from "../../state/entities";import { groupProjectsByRepository } from "../../lib/repositoryGroups";import { scopedThreadKey } from "../../lib/scopedEntities";import { relativeTime } from "../../lib/time";import { threadStatusTone } from "./threadPresentation";import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; const threadActivityOrder = Order.mapInput(  Order.Struct({    activityAt: Order.flip(Order.Number),    title: Order.String,  }),  (thread: EnvironmentThreadShell) => ({    activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),    title: thread.title,  }),); export function ThreadNavigationDrawer(props: {  readonly visible: boolean;  readonly selectedThreadKey: string | null;  readonly onClose: () => void;  readonly onSelectThread: (thread: EnvironmentThreadShell) => void;  readonly onStartNewTask: () => void;}) {  const insets = useSafeAreaInsets();  const { width } = useWindowDimensions();  const drawerWidth = Math.min(width * 0.84, 360);  const [mounted, setMounted] = useState(props.visible);  const translateX = useSharedValue(-drawerWidth);  const overlayOpacity = useSharedValue(0);   const backdropColor = useThemeColor("--color-backdrop");  const drawerBg = useThemeColor("--color-drawer");  const drawerShadow = useThemeColor("--color-drawer-shadow");  const primaryForeground = useThemeColor("--color-primary-foreground");  const borderSubtleColor = useThemeColor("--color-border-subtle");   useEffect(() => {    if (props.visible) {      setMounted(true);      translateX.value = withTiming(0, { duration: 240 });      overlayOpacity.value = withTiming(1, { duration: 220 });      return;    }     overlayOpacity.value = withTiming(0, { duration: 180 });    translateX.value = withTiming(-drawerWidth, { duration: 220 }, (finished) => {      if (finished) {        runOnJS(setMounted)(false);      }    });  }, [drawerWidth, overlayOpacity, props.visible, translateX]);   const closeDrawer = useCallback(() => {    props.onClose();  }, [props]);   const panGesture = useMemo(    () =>      Gesture.Pan()        .activeOffsetX([-12, 12])        .failOffsetY([-24, 24])        .onUpdate((event) => {          translateX.value = Math.min(0, event.translationX);        })        .onEnd((event) => {          const shouldClose = event.translationX < -drawerWidth * 0.2 || event.velocityX < -500;          if (shouldClose) {            runOnJS(closeDrawer)();            return;          }           translateX.value = withTiming(0, { duration: 180 });        }),    [closeDrawer, drawerWidth, translateX],  );   const drawerStyle = useAnimatedStyle(() => ({    transform: [{ translateX: translateX.value }],  }));   const backdropStyle = useAnimatedStyle(() => ({    opacity: overlayOpacity.value,  }));   if (!mounted) {    return null;  }   return (    <Modal transparent visible={mounted} onRequestClose={props.onClose} statusBarTranslucent>      <View style={{ flex: 1 }}>        <Animated.View          pointerEvents="none"          style={[            {              position: "absolute",              left: 0,              right: 0,              top: 0,              bottom: 0,              backgroundColor: backdropColor,            },            backdropStyle,          ]}        />        <Pressable          style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }}          onPress={props.onClose}        />         <GestureDetector gesture={panGesture}>          <Animated.View            style={[              {                position: "absolute",                left: 0,                top: 0,                bottom: 0,                width: drawerWidth,                backgroundColor: drawerBg,                paddingTop: insets.top + 10,                paddingBottom: Math.max(insets.bottom, 18),                boxShadow: `20px 0 36px ${String(drawerShadow)}`,              },              drawerStyle,            ]}          >            <View className="flex-row items-center justify-between px-4 pb-5">              <Text className="text-2xl font-t3-bold">Threads</Text>              <Pressable                onPress={() => {                  props.onClose();                  props.onStartNewTask();                }}                className="h-11 w-11 items-center justify-center rounded-full bg-primary"              >                <SymbolView                  name="square.and.pencil"                  size={17}                  tintColor={primaryForeground}                  type="monochrome"                />              </Pressable>            </View>             <ThreadNavigationDrawerContent              bottomInset={Math.max(insets.bottom, 18)}              borderSubtleColor={borderSubtleColor}              selectedThreadKey={props.selectedThreadKey}              onClose={props.onClose}              onSelectThread={props.onSelectThread}            />          </Animated.View>        </GestureDetector>      </View>    </Modal>  );} function ThreadNavigationDrawerContent(props: {  readonly bottomInset: number;  readonly borderSubtleColor: ColorValue;  readonly selectedThreadKey: string | null;  readonly onClose: () => void;  readonly onSelectThread: (thread: EnvironmentThreadShell) => void;}) {  const projects = useProjects();  const threads = useThreadShells();  const repositoryGroups = useMemo(    () => groupProjectsByRepository({ projects, threads }),    [projects, threads],  );  const groupedThreads = useMemo(    () =>      repositoryGroups.map((group) => {        const threads: EnvironmentThreadShell[] = [];        for (const projectGroup of group.projects) {          threads.push(...projectGroup.threads);        }        return {          key: group.key,          title: group.projects[0]?.project.title ?? group.title,          threads: Arr.sort(threads, threadActivityOrder),        };      }),    [repositoryGroups],  );   return (    <ScrollView      showsVerticalScrollIndicator={false}      contentInset={{ bottom: props.bottomInset + 12 }}      contentContainerStyle={{        gap: 20,        paddingHorizontal: 14,      }}    >      {groupedThreads.map((group) => (        <View key={group.key} className="gap-3">          <Text            className="px-1 text-base font-t3-bold text-foreground-muted"            style={{ letterSpacing: -0.2 }}          >            {group.title}          </Text>           <View className="overflow-hidden rounded-[22px] bg-card">            {group.threads.length === 0 ? (              <View className="px-4 py-4">                <Text className="text-sm font-medium text-foreground-tertiary">No threads yet</Text>              </View>            ) : (              group.threads.map((thread, index) => {                const threadKey = scopedThreadKey(thread.environmentId, thread.id);                const selected = props.selectedThreadKey === threadKey;                 return (                  <Pressable                    key={threadKey}                    onPress={() => {                      props.onSelectThread(thread);                      props.onClose();                    }}                    style={{                      paddingHorizontal: 16,                      paddingVertical: 15,                      borderTopWidth: index === 0 ? 0 : 1,                      borderTopColor: props.borderSubtleColor,                      backgroundColor: selected ? undefined : "transparent",                    }}                    className={selected ? "bg-subtle" : undefined}                  >                    <View className="flex-row items-start justify-between gap-3">                      <View className="flex-1 gap-1">                        <Text className="text-base font-t3-bold" numberOfLines={1}>                          {thread.title}                        </Text>                        <Text                          className="text-sm font-medium text-foreground-muted"                          numberOfLines={1}                        >                          {relativeTime(thread.updatedAt ?? thread.createdAt)}                        </Text>                      </View>                      <StatusPill {...threadStatusTone(thread)} />                    </View>                  </Pressable>                );              })            )}          </View>        </View>      ))}    </ScrollView>  );}