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", };}
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",
};
}