1import { useEffect } from "react";
2import { View } from "react-native";
3import Animated, {
4 cancelAnimation,
5 Easing,
6 useAnimatedStyle,
7 useSharedValue,
8 withRepeat,
9 withTiming,
10} from "react-native-reanimated";
11
12import type { RemoteClientConnectionState } from "../../lib/connection";
13import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
14import { themeColorWithAlpha, type MobileThemeVariables } from "../../lib/mobileTheme";
15
16export type ConnectionStatusDotState = RemoteClientConnectionState;
17
18function statusDotTone(
19 state: ConnectionStatusDotState,
20 theme: MobileThemeVariables,
21 dark: boolean,
22): {
23 readonly dotColor: string;
24 readonly haloColor: string;
25} {
26 switch (state) {
27 // Unsupported is not a failure: the machine is fine, this build just
28 // cannot talk to it, so it wears the same neutral dot as "available".
29 case "available":
30 case "unsupported":
31 return {
32 dotColor: theme["--color-icon-muted"],
33 haloColor: themeColorWithAlpha(theme["--color-icon-muted"], 0.42),
34 };
35 case "connected":
36 return {
37 dotColor: dark ? "#34d399" : "#059669",
38 haloColor: themeColorWithAlpha(dark ? "#34d399" : "#059669", 0.48),
39 };
40 case "connecting":
41 case "reconnecting":
42 return {
43 dotColor: theme["--color-warning-foreground"],
44 haloColor: themeColorWithAlpha(theme["--color-warning-foreground"], 0.5),
45 };
46 case "offline":
47 case "error":
48 return {
49 dotColor: theme["--color-danger-foreground"],
50 haloColor: themeColorWithAlpha(theme["--color-danger-foreground"], 0.48),
51 };
52 }
53}
54
55function usePulseAnimation(pulse: boolean) {
56 const pulseProgress = useSharedValue(0);
57
58 useEffect(() => {
59 if (pulse) {
60 pulseProgress.value = withRepeat(
61 withTiming(1, {
62 duration: 1100,
63 easing: Easing.out(Easing.cubic),
64 }),
65 -1,
66 false,
67 );
68 return;
69 }
70
71 cancelAnimation(pulseProgress);
72 pulseProgress.value = withTiming(0, {
73 duration: 180,
74 easing: Easing.out(Easing.quad),
75 });
76 }, [pulse, pulseProgress]);
77
78 return pulseProgress;
79}
80
81export function ConnectionStatusDot(props: {
82 readonly state: ConnectionStatusDotState;
83 readonly pulse: boolean;
84 readonly size?: number;
85}) {
86 const pulseProgress = usePulseAnimation(props.pulse);
87 const { themeAppearance, themeVariables } = useAppearancePreferences();
88 const tone = statusDotTone(props.state, themeVariables, themeAppearance === "dark");
89 const dotSize = props.size ?? 10;
90 const haloSize = dotSize + 4;
91 const containerSize = haloSize + 4;
92
93 const haloStyle = useAnimatedStyle(() => ({
94 opacity: props.pulse ? 0.14 + (1 - pulseProgress.value) * 0.3 : 0,
95 transform: [{ scale: 0.78 + pulseProgress.value * 1.16 }],
96 }));
97
98 return (
99 <View
100 style={{
101 width: containerSize,
102 height: containerSize,
103 alignItems: "center",
104 justifyContent: "center",
105 }}
106 >
107 <Animated.View
108 style={[
109 haloStyle,
110 {
111 position: "absolute",
112 width: haloSize,
113 height: haloSize,
114 borderRadius: haloSize / 2,
115 backgroundColor: tone.haloColor,
116 },
117 ]}
118 />
119 <View
120 style={{
121 width: dotSize,
122 height: dotSize,
123 borderRadius: dotSize / 2,
124 backgroundColor: tone.dotColor,
125 }}
126 />
127 </View>
128 );
129}