Open History to see the last commit
1import { findErrorTraceId } from "@t3tools/client-runtime/errors";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import type { MenuAction } from "@react-native-menu/menu";
7import type { EnvironmentId } from "@t3tools/contracts";
8import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
9import { type ReactNode, useRef, useState } from "react";
10import { Pressable, ScrollView, Text, View } from "react-native";
11import { RefreshControl } from "../../components/ui/RefreshControl";
12
13import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
14import { SymbolView } from "../../components/ui/AppSymbol";
15import * as Dialog from "../../components/ui/Dialog";
16import { Menu } from "../../components/ui/Menu";
17import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
18import { relayEnvironmentDiscovery } from "../../state/relay";
19import { useAtomCommand } from "../../state/use-atom-command";
20import {
21 deregisterManagedRelayEnvironmentCommand,
22 useManagedRelayEnvironments,
23} from "./managedRelayState";
24
25const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
26
27function linkedAtLabel(value: string): string {
28 const linkedAt = new Date(value);
29 return Number.isNaN(linkedAt.getTime())
30 ? "Link date unavailable"
31 : `Linked ${linkedAtFormatter.format(linkedAt)}`;
32}
33
34function endpointLabel(environment: RelayClientEnvironmentRecord): string {
35 return environment.endpoint.providerKind === "cloudflare_tunnel"
36 ? "Managed tunnel"
37 : "Activity publishing only";
38}
39
40function confirmDeregister(environment: RelayClientEnvironmentRecord, onConfirm: () => void) {
41 const title = "Deregister server?";
42 const message = `“${environment.label}” will be removed from this account. T3 Connect access will be revoked, any managed tunnel will be removed, and a host space will become available. Local connections on your devices are not changed.`;
43 Dialog.confirm({ title, message, confirmText: "Deregister", destructive: true, onConfirm });
44}
45
46export function T3ConnectProfilePage() {
47 const environmentsState = useManagedRelayEnvironments();
48 const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, {
49 reportFailure: false,
50 });
51 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refreshAfterMutation, {
52 reportFailure: false,
53 });
54 const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] =
55 useState<EnvironmentId | null>(null);
56 const mutationPendingRef = useRef(false);
57 // Hide cached rows by linkedAt until refresh; re-linking changes linkedAt and restores visibility.
58 const [removedEnvironments, setRemovedEnvironments] = useState<{
59 readonly accountId: string | null;
60 readonly linkedAtById: ReadonlyMap<EnvironmentId, string>;
61 }>({ accountId: null, linkedAtById: new Map() });
62
63 const handleDeregister = async (environment: RelayClientEnvironmentRecord) => {
64 const accountId = environmentsState.accountId;
65 if (!accountId || mutationPendingRef.current) return;
66
67 mutationPendingRef.current = true;
68 setDeregisteringEnvironmentId(environment.environmentId);
69 const result = await deregisterEnvironment({
70 accountId,
71 environmentId: environment.environmentId,
72 });
73 mutationPendingRef.current = false;
74 setDeregisteringEnvironmentId(null);
75
76 if (result._tag === "Success") {
77 setRemovedEnvironments((current) => {
78 const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []);
79 linkedAtById.set(environment.environmentId, environment.linkedAt);
80 return { accountId, linkedAtById };
81 });
82 environmentsState.refresh();
83 void refreshRelayEnvironments();
84 return;
85 }
86 if (isAtomCommandInterrupted(result)) return;
87
88 const cause = squashAtomCommandFailure(result);
89 const message = cause instanceof Error ? cause.message : "Could not deregister the server.";
90 const traceId = findErrorTraceId(cause);
91 console.error("[t3-connect] Could not deregister environment", {
92 environmentId: environment.environmentId,
93 message,
94 traceId,
95 cause,
96 });
97 Dialog.alert(
98 "Could not deregister server",
99 traceId ? `${message}\n\nTrace ID: ${traceId}` : message,
100 traceId
101 ? [
102 {
103 text: "Copy trace ID",
104 onPress: () => copyTextWithHaptic(traceId, { target: "connection-trace-id" }),
105 },
106 { text: "OK", style: "cancel" },
107 ]
108 : undefined,
109 );
110 };
111
112 const removedEnvironmentLinkedAt =
113 removedEnvironments.accountId === environmentsState.accountId
114 ? removedEnvironments.linkedAtById
115 : new Map<EnvironmentId, string>();
116 const environments = (environmentsState.data ?? []).filter(
117 (environment) =>
118 removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt,
119 );
120 const isInitialLoad =
121 !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error);
122 const errorTraceId = environmentsState.errorTraceId;
123
124 return (
125 <ScrollView
126 className="flex-1 bg-clerk-page"
127 contentContainerClassName="pb-8"
128 contentInsetAdjustmentBehavior="automatic"
129 refreshControl={
130 <RefreshControl
131 refreshing={environmentsState.isPending && environmentsState.data !== null}
132 onRefresh={environmentsState.refresh}
133 />
134 }
135 >
136 <ClerkSectionHeader>Registered servers</ClerkSectionHeader>
137
138 {environmentsState.error ? (
139 <>
140 <ClerkRow
141 title="Could not load T3 Connect environments"
142 subtitle={environmentsState.error}
143 />
144 {errorTraceId ? (
145 <ClerkButtonRow
146 label="Copy trace ID"
147 onPress={() => {
148 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });
149 }}
150 />
151 ) : null}
152 </>
153 ) : isInitialLoad ? (
154 <View className="flex-row items-center gap-3 px-6 py-4">
155 <AppActivityIndicator colorClassName={"accent-clerk-foreground-muted"} size="small" />
156 <Text className="text-base text-clerk-foreground-muted">Loading environments</Text>
157 </View>
158 ) : environments.length > 0 ? (
159 environments.map((environment) => (
160 <ClerkRow
161 key={environment.environmentId}
162 title={environment.label}
163 subtitle={`${linkedAtLabel(environment.linkedAt)} · ${endpointLabel(environment)}`}
164 accessory={
165 deregisteringEnvironmentId === environment.environmentId ? (
166 <AppActivityIndicator
167 colorClassName={"accent-clerk-foreground-muted"}
168 size="small"
169 />
170 ) : (
171 <Menu
172 actions={ENVIRONMENT_MENU_ACTIONS}
173 isAnchoredToRight
174 onPressAction={() =>
175 confirmDeregister(environment, () => void handleDeregister(environment))
176 }
177 >
178 <Pressable
179 accessibilityLabel={`Actions for ${environment.label}`}
180 accessibilityRole="button"
181 disabled={deregisteringEnvironmentId !== null}
182 className="size-[30px] items-center justify-center active:opacity-60 disabled:opacity-50"
183 >
184 <View className="rotate-90">
185 <SymbolView
186 name="ellipsis"
187 size={18}
188 tintColorClassName={"accent-clerk-foreground-muted"}
189 type="monochrome"
190 />
191 </View>
192 </Pressable>
193 </Menu>
194 )
195 }
196 />
197 ))
198 ) : (
199 <ClerkRow
200 title="No servers registered"
201 subtitle="Link a server from its local Settings to reach it through T3 Connect."
202 />
203 )}
204
205 <Text className="px-6 pt-6 text-xs leading-normal text-clerk-foreground-muted">
206 Connections on this device are managed in Settings.
207 </Text>
208 </ScrollView>
209 );
210}
211
212const ENVIRONMENT_MENU_ACTIONS = [
213 { id: "deregister", title: "Deregister", image: "trash", attributes: { destructive: true } },
214] satisfies MenuAction[];
215
216function ClerkSectionHeader(props: { readonly children: string }) {
217 return (
218 <Text className="min-h-4 border-b border-clerk-border px-6 pt-8 pb-4 text-xs font-medium tracking-[0.3px] text-clerk-foreground-muted uppercase">
219 {props.children}
220 </Text>
221 );
222}
223
224function ClerkRow(props: {
225 readonly title: string;
226 readonly subtitle: string;
227 readonly accessory?: ReactNode;
228}) {
229 return (
230 <View
231 collapsable={false}
232 className="flex-row items-center gap-3 border-b border-clerk-border px-6 py-4"
233 >
234 <View className="min-w-0 flex-1 gap-0.5">
235 <Text className="min-h-[22px] text-base text-clerk-foreground" numberOfLines={1}>
236 {props.title}
237 </Text>
238 <Text className="min-h-5 text-sm text-clerk-foreground-muted" numberOfLines={2}>
239 {props.subtitle}
240 </Text>
241 </View>
242 {props.accessory}
243 </View>
244 );
245}
246
247function ClerkButtonRow(props: { readonly label: string; readonly onPress: () => void }) {
248 return (
249 <Pressable
250 accessibilityRole="button"
251 onPress={props.onPress}
252 className="border-b border-clerk-border px-6 py-4 active:opacity-60"
253 >
254 <Text className="text-base font-semibold text-clerk-foreground">{props.label}</Text>
255 </Pressable>
256 );
257}