1import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
2import { useEffect, useRef, useState, type ReactNode } from "react";
3import { Animated, Platform, Pressable, View } from "react-native";
4
5import { AppActivityIndicator } from "../../components/AppActivityIndicator";
6import { SymbolView } from "../../components/AppSymbol";
7import { AppText as Text } from "../../components/AppText";
8import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
9import {
10 brandTitleOffset,
11 CompactBrandTitle,
12 getCompactBrandHeaderOptions,
13} from "../../components/CompactBrandTitle";
14import { useWorkspaceState } from "../../state/workspace";
15import {
16 workspaceConnectionStatusPresentation,
17 type WorkspaceConnectionStatusPresentation,
18} from "./workspace-connection-status";
19
20/**
21 * Delay before a connection interruption surfaces in the title slot. Sub-second
22 * blips (the common reconnect case) resolve without any UI at all.
23 */
24const STATUS_SHOW_DELAY_MS = 800;
25const FADE_IN_MS = 250;
26
27/**
28 * Connection status presentation, debounced for display: null until the
29 * workspace has been in a non-connected state for STATUS_SHOW_DELAY_MS,
30 * then live-updating until the workspace reconnects (null again immediately).
31 */
32function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null {
33 const { state } = useWorkspaceState();
34 const presentation = workspaceConnectionStatusPresentation(state);
35 const hasStatus = presentation !== null;
36 const [visible, setVisible] = useState(false);
37
38 useEffect(() => {
39 if (!hasStatus) {
40 setVisible(false);
41 return;
42 }
43 const timer = setTimeout(() => setVisible(true), STATUS_SHOW_DELAY_MS);
44 return () => clearTimeout(timer);
45 }, [hasStatus]);
46
47 return visible ? presentation : null;
48}
49
50/**
51 * One-shot entrance fade for the status label. Deliberately JS-driven: this can
52 * mount inside a native header item (RNSScreenStackHeaderSubview), where
53 * native-driver animated nodes blank the re-hosted view entirely. The JS driver
54 * updates opacity through the ordinary style path, which those subviews handle.
55 */
56function StatusFadeIn(props: {
57 readonly children: ReactNode;
58 readonly grow?: boolean;
59 readonly maxWidth?: number;
60}) {
61 const opacity = useRef(new Animated.Value(0)).current;
62
63 useEffect(() => {
64 const animation = Animated.timing(opacity, {
65 duration: FADE_IN_MS,
66 toValue: 1,
67 useNativeDriver: false,
68 });
69 animation.start();
70 return () => animation.stop();
71 }, [opacity]);
72
73 return (
74 <Animated.View
75 style={[
76 { alignItems: "center", flexDirection: "row", maxWidth: props.maxWidth, opacity },
77 props.grow ? { flex: 1, minWidth: 0 } : null,
78 ]}
79 >
80 {props.children}
81 </Animated.View>
82 );
83}
84
85/**
86 * Renders the brand/title slot of a thread-list surface, swapping the brand
87 * for the workspace connection status while an environment is unavailable.
88 *
89 * Both states occupy the same slot, so connection changes never shift the
90 * layout below. While connected the brand renders untouched — no wrapper —
91 * keeping the native header item on the exact element tree that predates the
92 * status swap. Replaces the old WorkspaceConnectionStatus pill, which inserted
93 * a row above the thread list.
94 */
95export function WorkspaceConnectionTitle(props: {
96 /** Content shown while connected (brand lockup or a screen title). */
97 readonly brand: ReactNode;
98 /** Opens environment settings. Status is not pressable when omitted. */
99 readonly onPress?: () => void;
100 /** Fill the available row width (in-flow headers) instead of hugging content (native title slots). */
101 readonly grow?: boolean;
102 readonly size?: "navbar" | "pageTitle";
103 /** Horizontal correction so the status aligns with the brand in native title slots. */
104 readonly statusOffset?: number;
105 /** Space available beside the native header actions. */
106 readonly maxWidth?: number;
107}) {
108 const status = useDelayedConnectionStatus();
109 const size = props.size ?? "navbar";
110 const { scale } = useAndroidControlSizing();
111
112 if (status === null) {
113 return props.grow ? (
114 <View style={{ alignItems: "center", flex: 1, flexDirection: "row", minWidth: 0 }}>
115 {props.brand}
116 </View>
117 ) : (
118 <>{props.brand}</>
119 );
120 }
121
122 return (
123 <StatusFadeIn grow={props.grow} maxWidth={props.maxWidth}>
124 <Pressable
125 accessibilityHint="Opens environment settings"
126 accessibilityLabel={status.label}
127 accessibilityRole="button"
128 disabled={props.onPress === undefined}
129 hitSlop={8}
130 onPress={props.onPress}
131 className="flex-row items-center gap-2"
132 style={[
133 { flexShrink: 1, marginLeft: props.statusOffset ?? 0 },
134 Platform.OS === "android" && { gap: 7 * scale },
135 ]}
136 >
137 {status.showsProgress ? (
138 <AppActivityIndicator
139 colorClassName={"accent-icon-muted"}
140 size={Platform.OS === "android" ? Math.round(20 * scale) : "small"}
141 />
142 ) : (
143 <SymbolView
144 name="wifi.slash"
145 size={Math.round((size === "pageTitle" ? 17 : 15) * scale)}
146 tintColorClassName={"accent-icon-muted"}
147 type="monochrome"
148 />
149 )}
150 <Text
151 className="font-t3-bold text-foreground-muted"
152 numberOfLines={1}
153 style={{ flexShrink: 1, fontSize: (size === "pageTitle" ? 20 : 16) * scale }}
154 >
155 {status.label}
156 </Text>
157 </Pressable>
158 </StatusFadeIn>
159 );
160}
161
162/**
163 * getCompactBrandHeaderOptions with the brand slot upgraded to the
164 * connection-status swap. Screens with an environment-settings callback apply
165 * this over the static brand options at mount.
166 */
167export function getConnectionAwareBrandHeaderOptions(opts: {
168 readonly headerWidth: number;
169 readonly trailingItemCount?: number;
170 readonly onOpenEnvironments: () => void;
171 readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"];
172}): NativeStackNavigationOptions {
173 // Leave room for bar margins, title spacing and the 44-point native actions.
174 // Long status labels must not push Settings into UIKit's overflow menu.
175 const maxWidth = Math.max(0, opts.headerWidth - 64 - 44 * (opts.trailingItemCount ?? 1));
176
177 return {
178 ...getCompactBrandHeaderOptions(opts.fallbackTitleStyle),
179 headerTitle: () => (
180 <WorkspaceConnectionTitle
181 brand={<CompactBrandTitle />}
182 maxWidth={maxWidth}
183 onPress={opts.onOpenEnvironments}
184 statusOffset={brandTitleOffset()}
185 />
186 ),
187 };
188}