194 lines · 6.5 KB · TypeScript
RawDownload
import type {  NativeStackHeaderItem,  NativeStackNavigationOptions,} from "@react-navigation/native-stack";import { useEffect, useRef, useState, type ReactNode } from "react";import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; import { SymbolView } from "../../components/AppSymbol";import { AppText as Text } from "../../components/AppText";import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle";import { useThemeColor } from "../../lib/useThemeColor";import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";import { useWorkspaceState } from "../../state/workspace";import {  workspaceConnectionStatusPresentation,  type WorkspaceConnectionStatusPresentation,} from "./workspace-connection-status"; /** * Delay before a connection interruption surfaces in the title slot. Sub-second * blips (the common reconnect case) resolve without any UI at all. */const STATUS_SHOW_DELAY_MS = 800;const FADE_IN_MS = 250; /** * Connection status presentation, debounced for display: null until the * workspace has been in a non-connected state for STATUS_SHOW_DELAY_MS, * then live-updating until the workspace reconnects (null again immediately). */function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null {  const { state } = useWorkspaceState();  const presentation = workspaceConnectionStatusPresentation(state);  const hasStatus = presentation !== null;  const [visible, setVisible] = useState(false);   useEffect(() => {    if (!hasStatus) {      setVisible(false);      return;    }    const timer = setTimeout(() => setVisible(true), STATUS_SHOW_DELAY_MS);    return () => clearTimeout(timer);  }, [hasStatus]);   return visible ? presentation : null;} /** * One-shot entrance fade for the status label. Deliberately JS-driven: this can * mount inside a native header item (RNSScreenStackHeaderSubview), where * native-driver animated nodes blank the re-hosted view entirely. The JS driver * updates opacity through the ordinary style path, which those subviews handle. */function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean }) {  const opacity = useRef(new Animated.Value(0)).current;   useEffect(() => {    const animation = Animated.timing(opacity, {      duration: FADE_IN_MS,      toValue: 1,      useNativeDriver: false,    });    animation.start();    return () => animation.stop();  }, [opacity]);   return (    <Animated.View      style={[        { alignItems: "center", flexDirection: "row", opacity },        props.grow ? { flex: 1, minWidth: 0 } : null,      ]}    >      {props.children}    </Animated.View>  );} /** * Renders the brand/title slot of a thread-list surface, swapping the brand * for the workspace connection status while an environment is unavailable. * * Both states occupy the same slot, so connection changes never shift the * layout below. While connected the brand renders untouched — no wrapper — * keeping the native header item on the exact element tree that predates the * status swap. Replaces the old WorkspaceConnectionStatus pill, which inserted * a row above the thread list. */export function WorkspaceConnectionTitle(props: {  /** Content shown while connected (brand lockup or a screen title). */  readonly brand: ReactNode;  /** Opens environment settings. Status is not pressable when omitted. */  readonly onPress?: () => void;  /** Fill the available row width (in-flow headers) instead of hugging content (native title slots). */  readonly grow?: boolean;  readonly size?: "navbar" | "pageTitle";  /** Horizontal correction so the status aligns with the brand in native title slots. */  readonly statusOffset?: number;}) {  const iconColor = String(useThemeColor("--color-icon-muted"));  const status = useDelayedConnectionStatus();  const size = props.size ?? "navbar";   if (status === null) {    return props.grow ? (      <View style={{ alignItems: "center", flex: 1, flexDirection: "row", minWidth: 0 }}>        {props.brand}      </View>    ) : (      <>{props.brand}</>    );  }   return (    <StatusFadeIn grow={props.grow}>      <Pressable        accessibilityHint="Opens environment settings"        accessibilityLabel={status.label}        accessibilityRole="button"        disabled={props.onPress === undefined}        hitSlop={8}        onPress={props.onPress}        className="flex-row items-center gap-2"        style={{ marginLeft: props.statusOffset ?? 0 }}      >        {status.showsProgress ? (          <ActivityIndicator color={iconColor} size="small" />        ) : (          <SymbolView            name="wifi.slash"            size={size === "pageTitle" ? 17 : 15}            tintColor={iconColor}            type="monochrome"          />        )}        <Text          className={            size === "pageTitle"              ? "text-[20px] font-t3-bold text-foreground-muted"              : "text-[16px] font-t3-bold text-foreground-muted"          }          numberOfLines={1}          style={{ flexShrink: 1 }}        >          {status.label}        </Text>      </Pressable>    </StatusFadeIn>  );} /** * getCompactBrandHeaderOptions with the brand slot upgraded to the * connection-status swap. Screens with an environment-settings callback apply * this over the static brand options at mount. */export function getConnectionAwareBrandHeaderOptions(opts: {  readonly onOpenEnvironments: () => void;  readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"];}): NativeStackNavigationOptions {  if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) {    return {      headerTitle: "Threads",      headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" },      title: "Threads",      unstable_headerLeftItems: (): NativeStackHeaderItem[] => [        {          element: (            <WorkspaceConnectionTitle              brand={<CompactBrandTitle nativeLeadingItem />}              onPress={opts.onOpenEnvironments}              statusOffset={brandTitleOffset(true)}            />          ),          hidesSharedBackground: true,          type: "custom",        },      ],    };  }   return {    headerTitle: () => (      <WorkspaceConnectionTitle        brand={<CompactBrandTitle />}        onPress={opts.onOpenEnvironments}        statusOffset={brandTitleOffset(false)}      />    ),    headerTitleStyle: opts.fallbackTitleStyle,    title: "Threads",  };}