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";...@@ -107,6 +107,7 @@ import IconTypography from "@tabler/icons-react-native/IconTypography";
107import IconUpload from "@tabler/icons-react-native/IconUpload";107import IconUpload from "@tabler/icons-react-native/IconUpload";
108import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";108import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";
109import IconUsers from "@tabler/icons-react-native/IconUsers";109import IconUsers from "@tabler/icons-react-native/IconUsers";
110import IconWifi from "@tabler/icons-react-native/IconWifi";
110import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";111import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";
111import IconWorld from "@tabler/icons-react-native/IconWorld";112import IconWorld from "@tabler/icons-react-native/IconWorld";
112import IconX from "@tabler/icons-react-native/IconX";113import IconX from "@tabler/icons-react-native/IconX";
...@@ -224,6 +225,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -224,6 +225,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
224 "textformat.size.smaller": IconTextDecrease,225 "textformat.size.smaller": IconTextDecrease,
225 "tray.and.arrow.up": IconUpload,226 "tray.and.arrow.up": IconUpload,
226 trash: IconTrash,227 trash: IconTrash,
228 wifi: IconWifi,
227 "wifi.slash": IconWifiOff,229 "wifi.slash": IconWifiOff,
228 xmark: IconX,230 xmark: IconX,
229 "xmark.circle.fill": IconCircleXFilled,231 "xmark.circle.fill": IconCircleXFilled,
apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx+86-34
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 type ConnectionRoute,3 type ConnectionRoute,
4 type ConnectionRouteKind,
4 connectionRouteAddress,5 connectionRouteAddress,
5 connectionRouteId,6 connectionRouteId,
7 connectionRouteKind,
6 connectionRouteLabel,8 connectionRouteLabel,
7 connectionRoutes,9 connectionRoutes,
8 isLearned,10 isLearned,
...@@ -10,18 +12,32 @@ import {...@@ -10,18 +12,32 @@ import {
10import type { EnvironmentId } from "@t3tools/contracts";12import type { EnvironmentId } from "@t3tools/contracts";
11import * as Option from "effect/Option";13import * as Option from "effect/Option";
12import { useEffect, useMemo, useRef, useState } from "react";14import { useEffect, useMemo, useRef, useState } from "react";
13import { Alert, Pressable, View } from "react-native";15import { Alert, Platform, Pressable, View } from "react-native";
14import { Gesture, GestureDetector } from "react-native-gesture-handler";16import { Gesture, GestureDetector } from "react-native-gesture-handler";
15import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";17import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
1618
17import { SymbolView } from "../../components/AppSymbol";19import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
18import { AppText as Text } from "../../components/AppText";20import { AppText as Text } from "../../components/AppText";
21import { StatusPill } from "../../components/StatusPill";
19import { environmentCatalog } from "../../connection/catalog";22import { environmentCatalog } from "../../connection/catalog";
23import { cn } from "../../lib/cn";
20import { environmentSession } from "../../state/session";24import { environmentSession } from "../../state/session";
21import { useAtomCommand } from "../../state/use-atom-command";25import { useAtomCommand } from "../../state/use-atom-command";
26import { connectionTone } from "../connection/connectionTone";
27import { SettingsActionRow } from "./components/SettingsActionRow";
22import { SettingsSection } from "./components/SettingsSection";28import { 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
26/**42/**
27 * The ways this device reaches an environment, preferred first. The first43 * The ways this device reaches an environment, preferred first. The first
...@@ -56,6 +72,12 @@ export function EnvironmentRoutesSection({...@@ -56,6 +72,12 @@ export function EnvironmentRoutesSection({
56 const [drag, setDrag] = useState<{ readonly id: string; readonly translation: number } | null>(72 const [drag, setDrag] = useState<{ readonly id: string; readonly translation: number } | null>(
57 null,73 null,
58 );74 );
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);
59 if (entry === undefined) return null;81 if (entry === undefined) return null;
6082
61 const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route]));83 const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route]));
...@@ -76,8 +98,19 @@ export function EnvironmentRoutesSection({...@@ -76,8 +98,19 @@ export function EnvironmentRoutesSection({
76 next.splice(to, 0, moved!);98 next.splice(to, 0, moved!);
77 commit(next);99 commit(next);
78 };100 };
79 const dropIndex = (from: number, translation: number) =>101 // A lifted row takes a neighbour's slot once it has moved past half of that row.
80 Math.max(0, Math.min(order.length - 1, from + Math.round(translation / ROW_HEIGHT)));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 };
81 const confirmRemove = (route: ConnectionRoute) =>114 const confirmRemove = (route: ConnectionRoute) =>
82 Alert.alert(115 Alert.alert(
83 `Remove ${connectionRouteLabel(route)}?`,116 `Remove ${connectionRouteLabel(route)}?`,
...@@ -106,7 +139,7 @@ export function EnvironmentRoutesSection({...@@ -106,7 +139,7 @@ export function EnvironmentRoutesSection({
106 onPress={() => setEditing((value) => !value)}139 onPress={() => setEditing((value) => !value)}
107 className="px-2 py-1 active:opacity-70"140 className="px-2 py-1 active:opacity-70"
108 >141 >
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">
110 {editing ? "Done" : "Edit"}143 {editing ? "Done" : "Edit"}
111 </Text>144 </Text>
112 </Pressable>145 </Pressable>
...@@ -116,17 +149,18 @@ export function EnvironmentRoutesSection({...@@ -116,17 +149,18 @@ export function EnvironmentRoutesSection({
116 {routes.map((route, index) => {149 {routes.map((route, index) => {
117 const id = connectionRouteId(route.target);150 const id = connectionRouteId(route.target);
118 // Rows between the lifted row and its drop slot shift to make room.151 // Rows between the lifted row and its drop slot shift to make room.
152 const lifted = drag === null ? 0 : (heights.get(drag.id) ?? 0);
119 const shift =153 const shift =
120 dragFrom === -1 || index === dragFrom154 dragFrom === -1 || index === dragFrom
121 ? 0155 ? 0
122 : dragFrom < dragTo && index > dragFrom && index <= dragTo156 : dragFrom < dragTo && index > dragFrom && index <= dragTo
123 ? -ROW_HEIGHT157 ? -lifted
124 : dragFrom > dragTo && index < dragFrom && index >= dragTo158 : dragFrom > dragTo && index < dragFrom && index >= dragTo
125 ? ROW_HEIGHT159 ? lifted
126 : 0;160 : 0;
127 return (161 return (
128 <RouteRow162 <RouteRow
129 key={id}163 key={`${id}:${drops}`}
130 route={route}164 route={route}
131 position={index + 1}165 position={index + 1}
132 count={routes.length}166 count={routes.length}
...@@ -134,10 +168,16 @@ export function EnvironmentRoutesSection({...@@ -134,10 +168,16 @@ export function EnvironmentRoutesSection({
134 editing={editing}168 editing={editing}
135 offset={index === dragFrom ? (drag?.translation ?? 0) : shift}169 offset={index === dragFrom ? (drag?.translation ?? 0) : shift}
136 lifted={index === dragFrom}170 lifted={index === dragFrom}
171 onHeight={(height) =>
172 setHeights((current) =>
173 current.get(id) === height ? current : new Map(current).set(id, height),
174 )
175 }
137 onDragStart={() => setDrag({ id, translation: 0 })}176 onDragStart={() => setDrag({ id, translation: 0 })}
138 onDragMove={(translation) => setDrag({ id, translation })}177 onDragMove={(translation) => setDrag({ id, translation })}
139 onDragEnd={(translation, cancelled) => {178 onDragEnd={(translation, cancelled) => {
140 setDrag(null);179 setDrag(null);
180 setDrops((count) => count + 1);
141 if (!cancelled) move(index, dropIndex(index, translation));181 if (!cancelled) move(index, dropIndex(index, translation));
142 }}182 }}
143 onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)}183 onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)}
...@@ -149,14 +189,7 @@ export function EnvironmentRoutesSection({...@@ -149,14 +189,7 @@ export function EnvironmentRoutesSection({
149 />189 />
150 );190 );
151 })}191 })}
152 <Pressable192 <SettingsActionRow icon="plus" label="Add route" onPress={onAddRoute} />
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>
160 </SettingsSection>193 </SettingsSection>
161 );194 );
162}195}
...@@ -169,6 +202,7 @@ function RouteRow(props: {...@@ -169,6 +202,7 @@ function RouteRow(props: {
169 readonly editing: boolean;202 readonly editing: boolean;
170 readonly offset: number;203 readonly offset: number;
171 readonly lifted: boolean;204 readonly lifted: boolean;
205 readonly onHeight: (height: number) => void;
172 readonly onDragStart: () => void;206 readonly onDragStart: () => void;
173 readonly onDragMove: (translation: number) => void;207 readonly onDragMove: (translation: number) => void;
174 readonly onDragEnd: (translation: number, cancelled: boolean) => void;208 readonly onDragEnd: (translation: number, cancelled: boolean) => void;
...@@ -190,25 +224,27 @@ function RouteRow(props: {...@@ -190,25 +224,27 @@ function RouteRow(props: {
190 }));224 }));
191 return (225 return (
192 <Reanimated.View226 <Reanimated.View
193 style={[{ height: ROW_HEIGHT }, style]}227 style={style}
194 className={228 onLayout={(event) => props.onHeight(event.nativeEvent.layout.height)}
195 lifted ? "flex-row items-center bg-grouped-card shadow-md" : "flex-row items-center"229 className={cn("flex-row items-center gap-4 pl-4", lifted && "bg-grouped-card shadow-md")}
196 }
197 >230 >
198 {props.editing && props.onRemove ? (231 {props.editing && props.onRemove ? (
199 <Pressable232 <Pressable
200 accessibilityRole="button"233 accessibilityRole="button"
201 accessibilityLabel={`Remove ${label} route`}234 accessibilityLabel={`Remove ${label} route`}
235 hitSlop={8}
202 onPress={props.onRemove}236 onPress={props.onRemove}
203 className="h-full items-center justify-center pl-4 active:opacity-70"237 className="active:opacity-70"
204 >238 >
205 <SymbolView239 <SymbolView
206 name="xmark.circle.fill"240 name="xmark.circle.fill"
207 size={20}241 size={REMOVE_SIZE}
208 tintColorClassName="accent-danger-foreground"242 tintColorClassName="accent-danger-foreground"
209 type="monochrome"243 type="monochrome"
210 />244 />
211 </Pressable>245 </Pressable>
246 ) : props.editing ? (
247 <View style={{ width: REMOVE_SIZE }} />
212 ) : null}248 ) : null}
213 <View249 <View
214 accessible250 accessible
...@@ -221,19 +257,35 @@ function RouteRow(props: {...@@ -221,19 +257,35 @@ function RouteRow(props: {
221 ]257 ]
222 .filter((part) => part !== null)258 .filter((part) => part !== null)
223 .join(", ")}259 .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 )}
225 >266 >
226 <View className="flex-row items-center gap-2">267 <SymbolView
227 <Text className="text-base text-foreground">{label}</Text>268 name={ROUTE_ICONS[connectionRouteKind(route)]}
228 {props.inUse ? (269 size={ICON_SIZE}
229 <Text className="text-xs font-t3-medium text-success-foreground">In use</Text>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>
230 ) : null}287 ) : null}
231 </View>288 </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}
237 </View>289 </View>
238 {props.editing ? (290 {props.editing ? (
239 <DragHandle291 <DragHandle
...@@ -297,7 +349,7 @@ function DragHandle(props: {...@@ -297,7 +349,7 @@ function DragHandle(props: {
297 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");349 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
298 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");350 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
299 }}351 }}
300 style={{ width: 48, height: ROW_HEIGHT, alignItems: "center", justifyContent: "center" }}352 style={{ width: 48, alignSelf: "stretch", alignItems: "center", justifyContent: "center" }}
301 >353 >
302 <SymbolView354 <SymbolView
303 name="line.3.horizontal"355 name="line.3.horizontal"