477 lines · 18.8 KB · TypeScript
RawDownload
import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";import type { MenuAction } from "@react-native-menu/menu";import Constants from "expo-constants";import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";import { useCallback, useMemo, useRef } from "react";import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";import type { SearchBarCommands } from "react-native-screens";import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPillMenu } from "../../components/ControlPill";import { SymbolView } from "../../components/AppSymbol";import { T3Wordmark } from "../../components/T3Wordmark";import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";import { resolveMobileStageLabel } from "../../lib/mobileBranding";import { useThemeColor } from "../../lib/useThemeColor";import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";import {  createNativeMailSearchToolbarItem,  NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,} from "../layout/native-mail-search-toolbar";import type { HomeProjectSortOrder } from "./homeThreadList";import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";import {  buildHomeListFilterMenu,  type HomeListFilterMenuEnvironment,  type HomeListFilterMenuProject,} from "./home-list-filter-menu";import {  hasCustomHomeListOptions,  PROJECT_SORT_OPTIONS,  THREAD_SORT_OPTIONS,} from "./home-list-options"; export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; export function HomeHeader(props: {  readonly environments: ReadonlyArray<HomeHeaderEnvironment>;  readonly projects: ReadonlyArray<HomeListFilterMenuProject>;  readonly searchQuery: string;  readonly selectedEnvironmentId: EnvironmentId | null;  readonly selectedProjectKey: string | null;  readonly projectSortOrder: HomeProjectSortOrder;  readonly threadSortOrder: SidebarThreadSortOrder;  readonly onSearchQueryChange: (query: string) => void;  readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;  readonly onProjectChange: (projectKey: string | null) => void;  readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;  readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;  readonly onOpenEnvironments: () => void;  readonly onOpenSettings: () => void;  readonly onStartNewTask: () => void;}) {  if (Platform.OS === "android") {    return <AndroidHomeHeader {...props} />;  }   return <IosHomeHeader {...props} />;} type HomeHeaderProps = Parameters<typeof HomeHeader>[0]; function checkedMenuState(checked: boolean) {  return checked ? ("on" as const) : undefined;} function AndroidHomeHeader(props: HomeHeaderProps) {  const insets = useSafeAreaInsets();  const iconColor = useThemeColor("--color-icon");  const mutedColor = useThemeColor("--color-foreground-muted");  const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);  // Thread List v2 lays the list out in fixed creation order, so the  // sort/group filter controls would be silently ignored — hide them and  // key the "customized" icon state off the environment filter alone.  const threadListV2Enabled = useThreadListV2Enabled();  const hasCustomListOptions = threadListV2Enabled    ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null    : hasCustomHomeListOptions(props);  const menuActions = useMemo<MenuAction[]>(    () => [      {        id: "environment",        title: "Environment",        subactions: [          {            id: "environment:all",            title: "All environments",            state: checkedMenuState(props.selectedEnvironmentId === null),          },          ...props.environments.map((environment) => ({            id: `environment:${environment.environmentId}`,            title: environment.label,            state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId),          })),        ],      },      ...(props.projects.length === 0        ? []        : ([            {              id: "project",              title: "Project",              subactions: [                {                  id: "project:all",                  title: "All projects",                  state: checkedMenuState(props.selectedProjectKey === null),                },                ...props.projects.map((project) => ({                  id: `project:${project.key}`,                  title: project.label,                  state: checkedMenuState(props.selectedProjectKey === project.key),                })),              ],            },          ] satisfies MenuAction[])),      ...(threadListV2Enabled        ? []        : ([            {              id: "project-sort",              title: "Sort projects",              subactions: PROJECT_SORT_OPTIONS.map((option) => ({                id: `project-sort:${option.value}`,                title: option.label,                state: checkedMenuState(props.projectSortOrder === option.value),              })),            },            {              id: "thread-sort",              title: "Sort threads",              subactions: THREAD_SORT_OPTIONS.map((option) => ({                id: `thread-sort:${option.value}`,                title: option.label,                state: checkedMenuState(props.threadSortOrder === option.value),              })),            },          ] satisfies MenuAction[])),    ],    [      props.environments,      props.projectSortOrder,      props.projects,      props.selectedEnvironmentId,      props.selectedProjectKey,      props.threadSortOrder,      threadListV2Enabled,    ],  );  const handleMenuAction = useCallback(    (event: { nativeEvent: { event: string } }) => {      const id = event.nativeEvent.event;      if (id === "environment:all") {        props.onEnvironmentChange(null);        return;      }       if (id.startsWith("environment:")) {        const environmentId = id.slice("environment:".length);        const environment = props.environments.find(          (candidate) => candidate.environmentId === environmentId,        );        if (environment) {          props.onEnvironmentChange(environment.environmentId);        }        return;      }       if (id === "project:all") {        props.onProjectChange(null);        return;      }       if (id.startsWith("project:")) {        const projectKey = id.slice("project:".length);        if (props.projects.some((project) => project.key === projectKey)) {          props.onProjectChange(projectKey);        }        return;      }       const projectSort = PROJECT_SORT_OPTIONS.find(        (option) => id === `project-sort:${option.value}`,      );      if (projectSort) {        props.onProjectSortOrderChange(projectSort.value);        return;      }       const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`);      if (threadSort) {        props.onThreadSortOrderChange(threadSort.value);        return;      }    },    [props],  );   return (    <>      <NativeStackScreenOptions options={{ headerShown: false }} />      <View        className="border-b border-header-border bg-header pb-3"        style={{          paddingHorizontal: HOME_HORIZONTAL_INSET,          paddingTop: Math.max(insets.top, 12),        }}      >        <View className="w-full max-w-[720px] self-center gap-3">          <View className="flex-row items-center gap-2.5">            {/* Brand slot doubles as the connection status surface: while an                environment reconnects, the lockup fades to a status label in                place (no layout shift in the list below). */}            <WorkspaceConnectionTitle              grow              onPress={props.onOpenEnvironments}              brand={                <View className="flex-row items-center gap-2">                  {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}                  <T3Wordmark color={iconColor} height={15} />                  <RNText className="-ml-0.5 text-[21px] font-t3-medium tracking-[-0.5px] text-foreground-muted">                    Code                  </RNText>                  <View className="rounded-full bg-subtle px-2 py-0.75">                    <RNText className="text-[11px] font-t3-bold tracking-[1.1px] text-foreground-muted uppercase">                      {stageLabel}                    </RNText>                  </View>                </View>              }            />             <ControlPillMenu              actions={menuActions}              isAnchoredToRight              onPressAction={handleMenuAction}            >              <Pressable                accessibilityLabel="Filter and sort threads"                accessibilityRole="button"                className="size-11 items-center justify-center rounded-full bg-subtle"              >                <SymbolView                  name={                    hasCustomListOptions                      ? "line.3.horizontal.decrease.circle.fill"                      : "line.3.horizontal.decrease.circle"                  }                  size={16}                  tintColor={iconColor}                  type="monochrome"                />              </Pressable>            </ControlPillMenu>            {/* Built identically to the filter button so the two circles                match exactly (ControlPill sizes via Tailwind classes and                resolves to a different box). */}            <Pressable              accessibilityLabel="Open settings"              accessibilityRole="button"              onPress={props.onOpenSettings}              className="size-11 items-center justify-center rounded-full bg-subtle"            >              <SymbolView name="gearshape" size={18} tintColor={iconColor} type="monochrome" />            </Pressable>          </View>           <View className="min-h-12 flex-row items-center gap-2.5 rounded-2xl border border-input-border bg-input px-3.5">            <SymbolView name="magnifyingglass" size={17} tintColor={mutedColor} type="monochrome" />            <TextInput              accessibilityLabel="Search threads"              autoCapitalize="none"              onChangeText={props.onSearchQueryChange}              placeholder="Search threads"              placeholderTextColorClassName="accent-placeholder"              className="flex-1 py-2.5 text-base font-sans text-foreground"              value={props.searchQuery}            />            {props.searchQuery.length > 0 ? (              <Pressable                accessibilityLabel="Clear search"                hitSlop={10}                onPress={() => props.onSearchQueryChange("")}              >                <SymbolView                  name="xmark.circle.fill"                  size={17}                  tintColor={mutedColor}                  type="monochrome"                />              </Pressable>            ) : null}          </View>        </View>      </View>    </>  );} function IosHomeHeader(props: HomeHeaderProps) {  const searchBarRef = useRef<SearchBarCommands>(null);  const iconColor = useThemeColor("--color-icon");  // Thread List v2 lays the list out in fixed creation order, so the  // sort/group filter controls would be silently ignored — hide them and  // key the "customized" icon state off the environment filter alone.  const threadListV2Enabled = useThreadListV2Enabled();  const hasCustomListOptions = threadListV2Enabled    ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null    : hasCustomHomeListOptions(props);  const focusSearch = useCallback(() => {    searchBarRef.current?.focus();    return searchBarRef.current !== null;  }, []);  useHardwareKeyboardCommand("focusSearch", focusSearch);  const filterMenu = buildHomeListFilterMenu({    ...props,    listOrganization: !threadListV2Enabled,  });   return (    <>      <NativeStackScreenOptions        optionsVersion={filterMenu.items}        options={{          // Static header config (glass, title, fonts) lives in Stack.tsx          // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.          headerTintColor: iconColor,          unstable_headerRightItems:            Platform.OS === "ios"              ? () => [                  withNativeGlassHeaderItem({                    accessibilityLabel: "Open settings",                    icon: { name: "ellipsis", type: "sfSymbol" } as const,                    identifier: "home-settings",                    label: "",                    onPress: props.onOpenSettings,                    type: "button",                  }),                ]              : undefined,          // The keys below are set per-branch (not `undefined`) so a later          // reapply cannot clobber options owned by NativeHeaderToolbar.          ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED            ? {                unstable_headerToolbarItems: () => [                  createNativeMailSearchToolbarItem({                    composeButtonId: "home-new-task",                    composeSystemImageName: "square.and.pencil",                    filterMenu,                    filterButtonId: "home-filter",                    filterSystemImageName: hasCustomListOptions                      ? "line.3.horizontal.decrease.circle.fill"                      : "line.3.horizontal.decrease",                    onComposePress: props.onStartNewTask,                    onSearchTextChange: props.onSearchQueryChange,                    placeholder: "Search",                    searchTextChangeId: "home-search-text",                    showsSearchDismissButton: true,                  }),                ],              }            : {                // Pre-Liquid-Glass iOS: standard pull-down search in the nav                // bar; create + sort live in the plain bottom toolbar below.                headerSearchBarOptions: {                  ref: searchBarRef,                  autoCapitalize: "none" as const,                  hideNavigationBar: false,                  placeholder: "Search",                  onCancelButtonPress: () => {                    props.onSearchQueryChange("");                  },                  onChangeText: (event) => {                    props.onSearchQueryChange(event.nativeEvent.text);                  },                },              }),        }}      />       {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (        <NativeHeaderToolbar placement="bottom">          <NativeHeaderToolbar.Menu            accessibilityLabel="Filter and sort threads"            icon={              hasCustomListOptions                ? "line.3.horizontal.decrease.circle.fill"                : "line.3.horizontal.decrease.circle"            }            title="Thread list options"            separateBackground          >            <NativeHeaderToolbar.Menu title="Environment">              <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>              <NativeHeaderToolbar.MenuAction                isOn={props.selectedEnvironmentId === null}                onPress={() => props.onEnvironmentChange(null)}                subtitle="Show threads from every environment"              >                <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>             {props.projects.length > 0 ? (              <NativeHeaderToolbar.Menu title="Project">                <NativeHeaderToolbar.Label>Project</NativeHeaderToolbar.Label>                <NativeHeaderToolbar.MenuAction                  isOn={props.selectedProjectKey === null}                  onPress={() => props.onProjectChange(null)}                  subtitle="Show threads from every project"                >                  <NativeHeaderToolbar.Label>All projects</NativeHeaderToolbar.Label>                </NativeHeaderToolbar.MenuAction>                {props.projects.map((project) => (                  <NativeHeaderToolbar.MenuAction                    key={project.key}                    isOn={props.selectedProjectKey === project.key}                    onPress={() => props.onProjectChange(project.key)}                  >                    <NativeHeaderToolbar.Label>{project.label}</NativeHeaderToolbar.Label>                  </NativeHeaderToolbar.MenuAction>                ))}              </NativeHeaderToolbar.Menu>            ) : null}             {threadListV2Enabled ? null : (              <NativeHeaderToolbar.Menu title="Sort projects">                <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>                {PROJECT_SORT_OPTIONS.map((option) => (                  <NativeHeaderToolbar.MenuAction                    key={option.value}                    isOn={props.projectSortOrder === option.value}                    onPress={() => props.onProjectSortOrderChange(option.value)}                  >                    <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>                  </NativeHeaderToolbar.MenuAction>                ))}              </NativeHeaderToolbar.Menu>            )}             {threadListV2Enabled ? null : (              <NativeHeaderToolbar.Menu title="Sort threads">                <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>                {THREAD_SORT_OPTIONS.map((option) => (                  <NativeHeaderToolbar.MenuAction                    key={option.value}                    isOn={props.threadSortOrder === option.value}                    onPress={() => props.onThreadSortOrderChange(option.value)}                  >                    <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>                  </NativeHeaderToolbar.MenuAction>                ))}              </NativeHeaderToolbar.Menu>            )}          </NativeHeaderToolbar.Menu>          <NativeHeaderToolbar.Spacer flexible />          <NativeHeaderToolbar.Button            accessibilityLabel="New task"            icon="square.and.pencil"            onPress={props.onStartNewTask}            separateBackground          />        </NativeHeaderToolbar>      )}    </>  );}