1import { useAtomValue } from "@effect/atom-react";
2import {
3 type ConnectionRoute,
4 type ConnectionRouteKind,
5 connectionRouteAddress,
6 connectionRouteId,
7 connectionRouteKind,
8 connectionRouteLabel,
9 connectionRoutes,
10 isLearned,
11} from "@t3tools/client-runtime/connection";
12import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
13import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
14import { AsyncResult } from "effect/reactivity";
15import * as Option from "effect/Option";
16import { useState } from "react";
17import { Alert, Platform, Pressable, View } from "react-native";
18import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
19
20import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
21import { AppText as Text } from "../../components/AppText";
22import { StatusPill } from "../../components/StatusPill";
23import { environmentCatalog } from "../../connection/catalog";
24import { cn } from "../../lib/cn";
25import { environmentSession } from "../../state/session";
26import { useAtomCommand } from "../../state/use-atom-command";
27import { connectionTone } from "../connection/connectionTone";
28import { SettingsActionRow } from "./components/SettingsActionRow";
29import { SettingsDragHandle } from "./components/SettingsDragHandle";
30import { SettingsSection } from "./components/SettingsSection";
31
32const ICON_SIZE = Platform.OS === "android" ? 24 : 22;
33const REMOVE_SIZE = 20;
34
35const ROUTE_ICONS: Record<ConnectionRouteKind, AppSymbolName> = {
36 relay: "cloud",
37 loopback: "desktopcomputer",
38 lan: "wifi",
39 tailnet: "point.3.connected.trianglepath.dotted",
40 vpn: { ios: "lock.shield", android: "lock" },
41 public: "globe",
42 ssh: "terminal",
43};
44
45/**
46 * The ways this device reaches an environment, preferred first. The first
47 * route that answers is used, and the connection moves back up the list when
48 * a better route is reachable again. Edit shows drag handles and remove
49 * buttons; Add route pairs this machine over another address.
50 */
51export function EnvironmentRoutesSection({
52 environmentId,
53 connected,
54 onAddRoute,
55}: {
56 readonly environmentId: EnvironmentId;
57 readonly connected: boolean;
58 readonly onAddRoute: () => void;
59}) {
60 const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId);
61 const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId));
62 const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId));
63 const session = Option.getOrNull(AsyncResult.value(sessionResult));
64 const [permissionsOpen, setPermissionsOpen] = useState(false);
65 const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder");
66 const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal");
67 const [editing, setEditing] = useState(false);
68 const saved = entry === undefined ? [] : connectionRoutes(entry);
69 const savedIds = saved.map((route) => connectionRouteId(route.target));
70 // A dropped order shows until the catalog matches it.
71 const [pending, setPending] = useState<ReadonlyArray<string> | null>(null);
72 const order =
73 pending !== null &&
74 pending.length === savedIds.length &&
75 pending.some((id, index) => id !== savedIds[index])
76 ? pending
77 : savedIds;
78 const [drag, setDrag] = useState<{ readonly id: string; readonly translation: number } | null>(
79 null,
80 );
81 // Rows size to their content, so a drag measures how far it has moved past each one.
82 const [heights, setHeights] = useState<ReadonlyMap<string, number>>(() => new Map());
83 // Each drop remounts the rows so the new order and the cleared drag offsets
84 // land in one frame. Kept rows would show their old offsets in their new
85 // slots until the animated style catches up, and the card flashes empty.
86 const [drops, setDrops] = useState(0);
87 if (entry === undefined) return null;
88
89 const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route]));
90 const routes = order.flatMap((id) => byId.get(id) ?? []);
91 const activeRouteId =
92 connected && Option.isSome(prepared) ? connectionRouteId(prepared.value.target) : null;
93
94 const commit = (next: ReadonlyArray<string>) => {
95 setPending(next);
96 void reorder({ environmentId, routeIds: next }).then((result) => {
97 if (result._tag === "Failure") setPending(null);
98 });
99 };
100 const move = (from: number, to: number) => {
101 if (to < 0 || to >= order.length || from === to) return;
102 const next = [...order];
103 const [moved] = next.splice(from, 1);
104 next.splice(to, 0, moved!);
105 commit(next);
106 };
107 // A lifted row takes a neighbour's slot once it has moved past half of that row.
108 const dropIndex = (from: number, translation: number) => {
109 const step = translation > 0 ? 1 : -1;
110 let to = from;
111 let remaining = Math.abs(translation);
112 while (to + step >= 0 && to + step < order.length) {
113 const next = heights.get(order[to + step]!) ?? 0;
114 if (next === 0 || remaining < next / 2) break;
115 remaining -= next;
116 to += step;
117 }
118 return to;
119 };
120 const confirmRemove = (route: ConnectionRoute) =>
121 Alert.alert(
122 `Remove ${connectionRouteLabel(route)}?`,
123 connectionRouteAddress(route) ?? undefined,
124 [
125 { text: "Cancel", style: "cancel" },
126 {
127 text: "Remove",
128 style: "destructive",
129 onPress: () =>
130 void removeRoute({ environmentId, routeId: connectionRouteId(route.target) }),
131 },
132 ],
133 );
134
135 const dragFrom = drag === null ? -1 : order.indexOf(drag.id);
136 const dragTo = drag === null ? -1 : dropIndex(dragFrom, drag.translation);
137
138 return (
139 <SettingsSection
140 title="Routes"
141 trailing={
142 routes.length > 1 ? (
143 <Pressable
144 accessibilityRole="button"
145 onPress={() => setEditing((value) => !value)}
146 className="px-2 py-1 active:opacity-70"
147 >
148 <Text className="text-sm font-t3-medium text-foreground android:text-primary-text">
149 {editing ? "Done" : "Edit"}
150 </Text>
151 </Pressable>
152 ) : undefined
153 }
154 >
155 <SettingsActionRow
156 icon="checkmark.circle"
157 label="Your permissions"
158 onPress={() => setPermissionsOpen((open) => !open)}
159 />
160 {permissionsOpen ? (
161 <View className="gap-2 px-4 py-3">
162 {activeRouteId !== null &&
163 sessionResult._tag !== "Failure" &&
164 !sessionResult.waiting &&
165 session?.authenticated ? (
166 <>
167 <Text className="text-sm text-foreground-muted">
168 Applies to the route marked In use. Other routes may have different permissions and
169 have not been checked.
170 </Text>
171 {AUTH_SCOPE_OPTIONS.map(({ scope, title }) => (
172 <Text key={scope} className="text-sm text-foreground">
173 {title}: {sessionGrantsScope(session, scope) ? "Allowed" : "Not granted"}
174 </Text>
175 ))}
176 </>
177 ) : (
178 <Text className="text-sm text-foreground-muted">
179 Permissions not checked. Connect to this environment to view this session’s
180 permissions.
181 </Text>
182 )}
183 </View>
184 ) : null}
185 {routes.map((route, index) => {
186 const id = connectionRouteId(route.target);
187 // Rows between the lifted row and its drop slot shift to make room.
188 const lifted = drag === null ? 0 : (heights.get(drag.id) ?? 0);
189 const shift =
190 dragFrom === -1 || index === dragFrom
191 ? 0
192 : dragFrom < dragTo && index > dragFrom && index <= dragTo
193 ? -lifted
194 : dragFrom > dragTo && index < dragFrom && index >= dragTo
195 ? lifted
196 : 0;
197 return (
198 <RouteRow
199 key={`${id}:${drops}`}
200 route={route}
201 position={index + 1}
202 count={routes.length}
203 inUse={id === activeRouteId}
204 editing={editing}
205 offset={index === dragFrom ? (drag?.translation ?? 0) : shift}
206 lifted={index === dragFrom}
207 onHeight={(height) =>
208 setHeights((current) =>
209 current.get(id) === height ? current : new Map(current).set(id, height),
210 )
211 }
212 onDragStart={() => setDrag({ id, translation: 0 })}
213 onDragMove={(translation) => setDrag({ id, translation })}
214 onDragEnd={(translation, cancelled) => {
215 setDrag(null);
216 setDrops((count) => count + 1);
217 if (!cancelled) move(index, dropIndex(index, translation));
218 }}
219 onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)}
220 // The last route goes with the machine, which is "Remove" on the row.
221 // A learned route would be learned again, so it is only reordered.
222 onRemove={
223 routes.length > 1 && !isLearned(route) ? () => confirmRemove(route) : undefined
224 }
225 />
226 );
227 })}
228 <SettingsActionRow icon="plus" label="Add route" onPress={onAddRoute} />
229 </SettingsSection>
230 );
231}
232
233function RouteRow(props: {
234 readonly route: ConnectionRoute;
235 readonly position: number;
236 readonly count: number;
237 readonly inUse: boolean;
238 readonly editing: boolean;
239 readonly offset: number;
240 readonly lifted: boolean;
241 readonly onHeight: (height: number) => void;
242 readonly onDragStart: () => void;
243 readonly onDragMove: (translation: number) => void;
244 readonly onDragEnd: (translation: number, cancelled: boolean) => void;
245 readonly onStep: (direction: "up" | "down") => void;
246 readonly onRemove: (() => void) | undefined;
247}) {
248 const { route, lifted, offset } = props;
249 const label = connectionRouteLabel(route);
250 const address = connectionRouteAddress(route);
251 const style = useAnimatedStyle(() => ({
252 transform: [
253 {
254 translateY: lifted
255 ? offset
256 : withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }),
257 },
258 ],
259 zIndex: lifted ? 1 : 0,
260 }));
261 return (
262 <Reanimated.View
263 style={style}
264 onLayout={(event) => props.onHeight(event.nativeEvent.layout.height)}
265 className={cn("flex-row items-center gap-4 pl-4", lifted && "bg-grouped-card shadow-md")}
266 >
267 {props.editing && props.onRemove ? (
268 <Pressable
269 accessibilityRole="button"
270 accessibilityLabel={`Remove ${label} route`}
271 hitSlop={8}
272 onPress={props.onRemove}
273 className="active:opacity-70"
274 >
275 <SymbolView
276 name="xmark.circle.fill"
277 size={REMOVE_SIZE}
278 tintColorClassName="accent-danger-foreground"
279 type="monochrome"
280 />
281 </Pressable>
282 ) : props.editing ? (
283 <View style={{ width: REMOVE_SIZE }} />
284 ) : null}
285 <View
286 accessible
287 accessibilityLabel={[
288 label,
289 address,
290 isLearned(route) ? "Found automatically" : null,
291 props.inUse ? "In use" : null,
292 `Route ${props.position} of ${props.count}`,
293 ]
294 .filter((part) => part !== null)
295 .join(", ")}
296 // Matches the padding of the settings rows around it.
297 className={cn(
298 "min-w-0 flex-1 flex-row items-center gap-4 py-4 android:py-3",
299 address !== null ? "android:min-h-18" : "android:min-h-14",
300 !props.editing && "pr-4",
301 )}
302 >
303 <SymbolView
304 name={ROUTE_ICONS[connectionRouteKind(route)]}
305 size={ICON_SIZE}
306 tintColorClassName="accent-icon"
307 type="monochrome"
308 weight="regular"
309 />
310 <View className="min-w-0 flex-1 gap-0.5 android:gap-1">
311 <View className="flex-row items-center gap-2">
312 <Text numberOfLines={1} className="shrink text-lg text-foreground android:text-base">
313 {label}
314 </Text>
315 {props.inUse ? (
316 <StatusPill {...connectionTone("connected")} label="In use" size="compact" />
317 ) : null}
318 </View>
319 {address !== null ? (
320 <Text numberOfLines={1} className="text-sm text-foreground-muted">
321 {isLearned(route) ? `${address} · found automatically` : address}
322 </Text>
323 ) : null}
324 </View>
325 </View>
326 {props.editing ? (
327 <SettingsDragHandle
328 title={label}
329 canMoveUp={props.position > 1}
330 canMoveDown={props.position < props.count}
331 onStart={props.onDragStart}
332 onMove={props.onDragMove}
333 onEnd={props.onDragEnd}
334 onStep={props.onStep}
335 />
336 ) : null}
337 </Reanimated.View>
338 );
339}