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