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 ( {props.children} ); } /** * 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 ? ( {props.brand} ) : ( <>{props.brand} ); } return ( {status.showsProgress ? ( ) : ( )} {status.label} ); } /** * 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: ( } onPress={opts.onOpenEnvironments} statusOffset={brandTitleOffset(true)} /> ), hidesSharedBackground: true, type: "custom", }, ], }; } return { headerTitle: () => ( } onPress={opts.onOpenEnvironments} statusOffset={brandTitleOffset(false)} /> ), headerTitleStyle: opts.fallbackTitleStyle, title: "Threads", }; }