| 1 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; |
| 2 | import { |
| 3 | isAtomCommandInterrupted, |
| 4 | squashAtomCommandFailure, |
| 5 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | import type { MenuAction } from "@react-native-menu/menu"; |
| 7 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 8 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; |
| 9 | import { type ReactNode, useRef, useState } from "react"; |
| 10 | import { Alert, Pressable, RefreshControl, ScrollView, Text, View } from "react-native"; |
| 11 | |
| 12 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 13 | import { SymbolView } from "../../components/AppSymbol"; |
| 14 | import { showConfirmDialog } from "../../components/ConfirmDialogHost"; |
| 15 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 16 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 17 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 18 | import { |
| 19 | deregisterManagedRelayEnvironmentCommand, |
| 20 | useManagedRelayEnvironments, |
| 21 | } from "./managedRelayState"; |
| 22 | |
| 23 | const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); |
| 24 | |
| 25 | function 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 | |
| 32 | function endpointLabel(environment: RelayClientEnvironmentRecord): string { |
| 33 | return environment.endpoint.providerKind === "cloudflare_tunnel" |
| 34 | ? "Managed tunnel" |
| 35 | : "Activity publishing only"; |
| 36 | } |
| 37 | |
| 38 | function 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 | */ |
| 57 | export 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 | |
| 220 | const 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 | |
| 228 | function 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 | |
| 236 | function 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 | |
| 259 | function 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 | } |