Julius MarmingeT3 Code Mobile [WIP] (#2013)b3e8c035 months agoHistory
261 lines · 9.2 KB · TypeScript
RawDownload
import { SymbolView } from "expo-symbols";import { useCallback, useEffect, useMemo, useState } from "react";import { 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 { groupProjectsByRepository } from "../../lib/repositoryGroups";import { scopedThreadKey } from "../../lib/scopedEntities";import { relativeTime } from "../../lib/time";import { threadStatusTone } from "./threadPresentation";import {  EnvironmentScopedProjectShell,  EnvironmentScopedThreadShell,} from "@t3tools/client-runtime"; const threadActivityOrder = Order.mapInput(  Order.Struct({    activityAt: Order.flip(Order.Number),    title: Order.String,  }),  (thread: EnvironmentScopedThreadShell) => ({    activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),    title: thread.title,  }),); export function ThreadNavigationDrawer(props: {  readonly visible: boolean;  readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>;  readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>;  readonly selectedThreadKey: string | null;  readonly onClose: () => void;  readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => 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");   const repositoryGroups = useMemo(    () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),    [props.projects, props.threads],  );  const groupedThreads = useMemo(    () =>      repositoryGroups.map((group) => {        const threads: EnvironmentScopedThreadShell[] = [];        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],  );   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-[26px] 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>             <ScrollView              showsVerticalScrollIndicator={false}              contentInset={{ bottom: Math.max(insets.bottom, 18) + 12 }}              contentContainerStyle={{                gap: 20,                paddingHorizontal: 14,              }}            >              {groupedThreads.map((group) => (                <View key={group.key} className="gap-3">                  <Text                    className="px-1 text-[15px] 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-[14px] 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: 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-[16px] font-t3-bold" numberOfLines={1}>                                  {thread.title}                                </Text>                                <Text                                  className="text-[13px] font-medium text-foreground-muted"                                  numberOfLines={1}                                >                                  {relativeTime(thread.updatedAt ?? thread.createdAt)}                                </Text>                              </View>                              <StatusPill {...threadStatusTone(thread)} />                            </View>                          </Pressable>                        );                      })                    )}                  </View>                </View>              ))}            </ScrollView>          </Animated.View>        </GestureDetector>      </View>    </Modal>  );}