| 1 | import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; |
| 2 | import { useEffect, useRef, useState, type ReactNode } from "react"; |
| 3 | import { Animated, Platform, Pressable, View } from "react-native"; |
| 4 | |
| 5 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 6 | import { SymbolView } from "../../components/AppSymbol"; |
| 7 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; |
| 9 | import { |
| 10 | brandTitleOffset, |
| 11 | CompactBrandTitle, |
| 12 | getCompactBrandHeaderOptions, |
| 13 | } from "../../components/CompactBrandTitle"; |
| 14 | import { useWorkspaceState } from "../../state/workspace"; |
| 15 | import { |
| 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 | */ |
| 24 | const STATUS_SHOW_DELAY_MS = 800; |
| 25 | const 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 | */ |
| 32 | function 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 | */ |
| 56 | function 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 | */ |
| 95 | export 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 | */ |
| 167 | export 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 | } |