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()}
/>
),
};
}