Open History to see the last commit
1import { createContext, use, useEffect, useState, type ReactNode } from "react";
2import { Animated, Platform, Pressable, View } from "react-native";
3
4import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
5import { SymbolView } from "../../components/ui/AppSymbol";
6import { AppText as Text } from "../../components/ui/AppText";
7import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
8import { useDeadlinePassed } from "../../lib/clock";
9import { useWorkspaceState } from "../../state/workspace";
10import {
11 workspaceConnectionStatusPresentation,
12 type WorkspaceConnectionStatusPresentation,
13} from "./workspace-connection-status";
14
15const STATUS_SHOW_DELAY_MS = 800;
16const FADE_IN_MS = 250;
17
18type ConnectionStatus = WorkspaceConnectionStatusPresentation & {
19 /** Only while the status first appears. A header that shows it later shows it at once. */
20 readonly fadeIn: boolean;
21};
22
23const ConnectionStatusContext = createContext<ConnectionStatus | null>(null);
24
25/**
26 * The delay hides brief reconnects, so it is the workspace's, not each header's: every header
27 * shows the status from the same moment. Headers read it from context instead of subscribing
28 * themselves, because a hidden Android tab drops its subscriptions and showed a stale status for
29 * a moment when it came back, while context still reaches it.
30 */
31export function WorkspaceConnectionStatusProvider(props: { readonly children: ReactNode }) {
32 const { state } = useWorkspaceState();
33 const presentation = workspaceConnectionStatusPresentation(state);
34 const hasStatus = presentation !== null;
35 // When the current status run began; a gap in the status restarts the delay.
36 const [statusSince, setStatusSince] = useState(() => (hasStatus ? Date.now() : null));
37 if (hasStatus !== (statusSince !== null)) {
38 // oxlint-disable-next-line react/purity -- Reads the clock only in the render-phase adjustment that runs when the status appears.
39 setStatusSince(hasStatus ? Date.now() : null);
40 }
41 const since = hasStatus ? statusSince : null;
42 const fadeStarted = useDeadlinePassed(since === null ? null : since + STATUS_SHOW_DELAY_MS);
43 const fadeEnded = useDeadlinePassed(
44 since === null ? null : since + STATUS_SHOW_DELAY_MS + FADE_IN_MS,
45 );
46
47 const label = presentation?.label;
48 const showsProgress = presentation?.showsProgress ?? false;
49 const status =
50 label === undefined || since === null || !fadeStarted
51 ? null
52 : { label, showsProgress, fadeIn: !fadeEnded };
53 return <ConnectionStatusContext value={status}>{props.children}</ConnectionStatusContext>;
54}
55
56/** Native-driver animation blanks views re-hosted in RNSScreenStackHeaderSubview; use the JS style path. */
57function StatusFadeIn(props: {
58 readonly children: ReactNode;
59 readonly animate: boolean;
60 readonly grow?: boolean;
61 readonly maxWidth?: number;
62}) {
63 const { animate } = props;
64 const [opacity] = useState(() => new Animated.Value(0));
65 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- runs the Animated opacity fade-in and stops it on cleanup
66 useEffect(() => {
67 if (!animate) return;
68 const animation = Animated.timing(opacity, {
69 duration: FADE_IN_MS,
70 toValue: 1,
71 useNativeDriver: false,
72 });
73 animation.start();
74 return () => animation.stop();
75 }, [animate, opacity]);
76
77 return (
78 <Animated.View
79 style={[
80 {
81 alignItems: "center",
82 flexDirection: "row",
83 maxWidth: props.maxWidth,
84 // A hidden tab never runs the fade, so its value would still be 0 when the tab returns.
85 opacity: animate ? opacity : 1,
86 },
87 props.grow ? { flex: 1, minWidth: 0 } : null,
88 ]}
89 >
90 {props.children}
91 </Animated.View>
92 );
93}
94
95export function WorkspaceConnectionTitle(props: {
96 readonly brand: ReactNode;
97 readonly onPress?: () => void;
98 readonly grow?: boolean;
99 readonly size?: "navbar" | "pageTitle";
100 readonly statusOffset?: number;
101 readonly maxWidth?: number;
102}) {
103 const status = use(ConnectionStatusContext);
104 const size = props.size ?? "navbar";
105 const { scale } = useAndroidControlSizing();
106
107 if (status === null) {
108 return props.grow ? (
109 <View style={{ alignItems: "center", flex: 1, flexDirection: "row", minWidth: 0 }}>
110 {props.brand}
111 </View>
112 ) : (
113 <>{props.brand}</>
114 );
115 }
116
117 return (
118 <StatusFadeIn animate={status.fadeIn} grow={props.grow} maxWidth={props.maxWidth}>
119 <Pressable
120 accessibilityHint="Opens environment settings"
121 accessibilityLabel={status.label}
122 accessibilityRole="button"
123 disabled={props.onPress === undefined}
124 hitSlop={8}
125 onPress={props.onPress}
126 className="flex-row items-center gap-2"
127 style={[
128 { flexShrink: 1, marginLeft: props.statusOffset ?? 0 },
129 Platform.OS === "android" && { gap: 7 * scale },
130 ]}
131 >
132 {status.showsProgress ? (
133 <AppActivityIndicator
134 colorClassName={"accent-icon-muted"}
135 size={Platform.OS === "android" ? Math.round(20 * scale) : "small"}
136 />
137 ) : (
138 <SymbolView
139 name="wifi.slash"
140 size={Math.round((size === "pageTitle" ? 17 : 15) * scale)}
141 tintColorClassName={"accent-icon-muted"}
142 type="monochrome"
143 />
144 )}
145 <Text
146 className="font-t3-bold text-foreground-muted"
147 numberOfLines={1}
148 style={{ flexShrink: 1, fontSize: (size === "pageTitle" ? 20 : 16) * scale }}
149 >
150 {status.label}
151 </Text>
152 </Pressable>
153 </StatusFadeIn>
154 );
155}