authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 08:55:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 08:55:33-07:00
log68802a48f9a8f7f7db5ca4d605bdc818be62a804
tree30e9e2781794ecfbc1b6c3685ade00295723fc75
parente22c8804346d4bd45a733803eda826e04e3bbd74
signature Signed by PGP key B5690EEEBB952194

fix(mobile): make the routes list match the other settings rows (#15958)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 88 insertions(+), 34 deletions(-)

apps/mobile/src/components/AppSymbol.tsx+2
......@@ -107,6 +107,7 @@ import IconTypography from "@tabler/icons-react-native/IconTypography";
107107import IconUpload from "@tabler/icons-react-native/IconUpload";
108108import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";
109109import IconUsers from "@tabler/icons-react-native/IconUsers";
110import IconWifi from "@tabler/icons-react-native/IconWifi";
110111import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";
111112import IconWorld from "@tabler/icons-react-native/IconWorld";
112113import IconX from "@tabler/icons-react-native/IconX";
......@@ -224,6 +225,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
224225 "textformat.size.smaller": IconTextDecrease,
225226 "tray.and.arrow.up": IconUpload,
226227 trash: IconTrash,
228 wifi: IconWifi,
227229 "wifi.slash": IconWifiOff,
228230 xmark: IconX,
229231 "xmark.circle.fill": IconCircleXFilled,
apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx+86-34
......@@ -1,8 +1,10 @@
11import { useAtomValue } from "@effect/atom-react";
22import {
33 type ConnectionRoute,
4 type ConnectionRouteKind,
45 connectionRouteAddress,
56 connectionRouteId,
7 connectionRouteKind,
68 connectionRouteLabel,
79 connectionRoutes,
810 isLearned,
......@@ -10,18 +12,32 @@ import {
1012import type { EnvironmentId } from "@t3tools/contracts";
1113import * as Option from "effect/Option";
1214import { useEffect, useMemo, useRef, useState } from "react";
13import { Alert, Pressable, View } from "react-native";
15import { Alert, Platform, Pressable, View } from "react-native";
1416import { Gesture, GestureDetector } from "react-native-gesture-handler";
1517import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
1618
17import { SymbolView } from "../../components/AppSymbol";
19import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
1820import { AppText as Text } from "../../components/AppText";
21import { StatusPill } from "../../components/StatusPill";
1922import { environmentCatalog } from "../../connection/catalog";
23import { cn } from "../../lib/cn";
2024import { environmentSession } from "../../state/session";
2125import { useAtomCommand } from "../../state/use-atom-command";
26import { connectionTone } from "../connection/connectionTone";
27import { SettingsActionRow } from "./components/SettingsActionRow";
2228import { SettingsSection } from "./components/SettingsSection";
2329
24const ROW_HEIGHT = 64;
30const ICON_SIZE = Platform.OS === "android" ? 24 : 22;
31const REMOVE_SIZE = 20;
32
33const ROUTE_ICONS: Record<ConnectionRouteKind, AppSymbolName> = {
34 relay: "cloud",
35 loopback: "desktopcomputer",
36 lan: "wifi",
37 tailnet: "point.3.connected.trianglepath.dotted",
38 public: "globe",
39 ssh: "terminal",
40};
2541
2642/**
2743 * The ways this device reaches an environment, preferred first. The first
......@@ -56,6 +72,12 @@ export function EnvironmentRoutesSection({
5672 const [drag, setDrag] = useState<{ readonly id: string; readonly translation: number } | null>(
5773 null,
5874 );
75 // Rows size to their content, so a drag measures how far it has moved past each one.
76 const [heights, setHeights] = useState<ReadonlyMap<string, number>>(() => new Map());
77 // Each drop remounts the rows so the new order and the cleared drag offsets
78 // land in one frame. Kept rows would show their old offsets in their new
79 // slots until the animated style catches up, and the card flashes empty.
80 const [drops, setDrops] = useState(0);
5981 if (entry === undefined) return null;
6082
6183 const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route]));
......@@ -76,8 +98,19 @@ export function EnvironmentRoutesSection({
7698 next.splice(to, 0, moved!);
7799 commit(next);
78100 };
79 const dropIndex = (from: number, translation: number) =>
80 Math.max(0, Math.min(order.length - 1, from + Math.round(translation / ROW_HEIGHT)));
101 // A lifted row takes a neighbour's slot once it has moved past half of that row.
102 const dropIndex = (from: number, translation: number) => {
103 const step = translation > 0 ? 1 : -1;
104 let to = from;
105 let remaining = Math.abs(translation);
106 while (to + step >= 0 && to + step < order.length) {
107 const next = heights.get(order[to + step]!) ?? 0;
108 if (next === 0 || remaining < next / 2) break;
109 remaining -= next;
110 to += step;
111 }
112 return to;
113 };
81114 const confirmRemove = (route: ConnectionRoute) =>
82115 Alert.alert(
83116 `Remove ${connectionRouteLabel(route)}?`,
......@@ -106,7 +139,7 @@ export function EnvironmentRoutesSection({
106139 onPress={() => setEditing((value) => !value)}
107140 className="px-2 py-1 active:opacity-70"
108141 >
109 <Text className="text-sm font-t3-medium text-primary-text">
142 <Text className="text-sm font-t3-medium text-foreground android:text-primary-text">
110143 {editing ? "Done" : "Edit"}
111144 </Text>
112145 </Pressable>
......@@ -116,17 +149,18 @@ export function EnvironmentRoutesSection({
116149 {routes.map((route, index) => {
117150 const id = connectionRouteId(route.target);
118151 // Rows between the lifted row and its drop slot shift to make room.
152 const lifted = drag === null ? 0 : (heights.get(drag.id) ?? 0);
119153 const shift =
120154 dragFrom === -1 || index === dragFrom
121155 ? 0
122156 : dragFrom < dragTo && index > dragFrom && index <= dragTo
123 ? -ROW_HEIGHT
157 ? -lifted
124158 : dragFrom > dragTo && index < dragFrom && index >= dragTo
125 ? ROW_HEIGHT
159 ? lifted
126160 : 0;
127161 return (
128162 <RouteRow
129 key={id}
163 key={`${id}:${drops}`}
130164 route={route}
131165 position={index + 1}
132166 count={routes.length}
......@@ -134,10 +168,16 @@ export function EnvironmentRoutesSection({
134168 editing={editing}
135169 offset={index === dragFrom ? (drag?.translation ?? 0) : shift}
136170 lifted={index === dragFrom}
171 onHeight={(height) =>
172 setHeights((current) =>
173 current.get(id) === height ? current : new Map(current).set(id, height),
174 )
175 }
137176 onDragStart={() => setDrag({ id, translation: 0 })}
138177 onDragMove={(translation) => setDrag({ id, translation })}
139178 onDragEnd={(translation, cancelled) => {
140179 setDrag(null);
180 setDrops((count) => count + 1);
141181 if (!cancelled) move(index, dropIndex(index, translation));
142182 }}
143183 onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)}
......@@ -149,14 +189,7 @@ export function EnvironmentRoutesSection({
149189 />
150190 );
151191 })}
152 <Pressable
153 accessibilityRole="button"
154 onPress={onAddRoute}
155 className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-70"
156 >
157 <SymbolView name="plus" size={16} tintColorClassName="accent-icon" type="monochrome" />
158 <Text className="text-base text-primary-text">Add route</Text>
159 </Pressable>
192 <SettingsActionRow icon="plus" label="Add route" onPress={onAddRoute} />
160193 </SettingsSection>
161194 );
162195}
......@@ -169,6 +202,7 @@ function RouteRow(props: {
169202 readonly editing: boolean;
170203 readonly offset: number;
171204 readonly lifted: boolean;
205 readonly onHeight: (height: number) => void;
172206 readonly onDragStart: () => void;
173207 readonly onDragMove: (translation: number) => void;
174208 readonly onDragEnd: (translation: number, cancelled: boolean) => void;
......@@ -190,25 +224,27 @@ function RouteRow(props: {
190224 }));
191225 return (
192226 <Reanimated.View
193 style={[{ height: ROW_HEIGHT }, style]}
194 className={
195 lifted ? "flex-row items-center bg-grouped-card shadow-md" : "flex-row items-center"
196 }
227 style={style}
228 onLayout={(event) => props.onHeight(event.nativeEvent.layout.height)}
229 className={cn("flex-row items-center gap-4 pl-4", lifted && "bg-grouped-card shadow-md")}
197230 >
198231 {props.editing && props.onRemove ? (
199232 <Pressable
200233 accessibilityRole="button"
201234 accessibilityLabel={`Remove ${label} route`}
235 hitSlop={8}
202236 onPress={props.onRemove}
203 className="h-full items-center justify-center pl-4 active:opacity-70"
237 className="active:opacity-70"
204238 >
205239 <SymbolView
206240 name="xmark.circle.fill"
207 size={20}
241 size={REMOVE_SIZE}
208242 tintColorClassName="accent-danger-foreground"
209243 type="monochrome"
210244 />
211245 </Pressable>
246 ) : props.editing ? (
247 <View style={{ width: REMOVE_SIZE }} />
212248 ) : null}
213249 <View
214250 accessible
......@@ -221,19 +257,35 @@ function RouteRow(props: {
221257 ]
222258 .filter((part) => part !== null)
223259 .join(", ")}
224 className="min-w-0 flex-1 gap-0.5 px-4"
260 // Matches the padding of the settings rows around it.
261 className={cn(
262 "min-w-0 flex-1 flex-row items-center gap-4 py-4 android:py-3",
263 address !== null ? "android:min-h-18" : "android:min-h-14",
264 !props.editing && "pr-4",
265 )}
225266 >
226 <View className="flex-row items-center gap-2">
227 <Text className="text-base text-foreground">{label}</Text>
228 {props.inUse ? (
229 <Text className="text-xs font-t3-medium text-success-foreground">In use</Text>
267 <SymbolView
268 name={ROUTE_ICONS[connectionRouteKind(route)]}
269 size={ICON_SIZE}
270 tintColorClassName="accent-icon"
271 type="monochrome"
272 weight="regular"
273 />
274 <View className="min-w-0 flex-1 gap-0.5 android:gap-1">
275 <View className="flex-row items-center gap-2">
276 <Text numberOfLines={1} className="shrink text-lg text-foreground android:text-base">
277 {label}
278 </Text>
279 {props.inUse ? (
280 <StatusPill {...connectionTone("connected")} label="In use" size="compact" />
281 ) : null}
282 </View>
283 {address !== null ? (
284 <Text numberOfLines={1} className="text-sm text-foreground-muted">
285 {isLearned(route) ? `${address} · found automatically` : address}
286 </Text>
230287 ) : null}
231288 </View>
232 {address !== null ? (
233 <Text numberOfLines={1} className="text-sm text-foreground-muted">
234 {isLearned(route) ? `${address} · found automatically` : address}
235 </Text>
236 ) : null}
237289 </View>
238290 {props.editing ? (
239291 <DragHandle
......@@ -297,7 +349,7 @@ function DragHandle(props: {
297349 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
298350 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
299351 }}
300 style={{ width: 48, height: ROW_HEIGHT, alignItems: "center", justifyContent: "center" }}
352 style={{ width: 48, alignSelf: "stretch", alignItems: "center", justifyContent: "center" }}
301353 >
302354 <SymbolView
303355 name="line.3.horizontal"