import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { ActivityIndicator, Animated, Pressable, View } from "react-native"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { brandTitleOffset, CompactBrandTitle, getCompactBrandHeaderOptions, } from "../../components/CompactBrandTitle"; 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; readonly maxWidth?: number; }) { 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; /** Space available beside the native header actions. */ readonly maxWidth?: number; }) { 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 headerWidth: number; readonly trailingItemCount?: number; readonly onOpenEnvironments: () => void; readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; }): NativeStackNavigationOptions { // Leave room for bar margins, title spacing and the 44-point native actions. // Long status labels must not push Settings into UIKit's overflow menu. const maxWidth = Math.max(0, opts.headerWidth - 64 - 44 * (opts.trailingItemCount ?? 1)); return { ...getCompactBrandHeaderOptions(opts.fallbackTitleStyle), headerTitle: () => ( } maxWidth={maxWidth} onPress={opts.onOpenEnvironments} statusOffset={brandTitleOffset()} /> ), }; }