| 1 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 2 | import { ConnectionTraceId } from "./ConnectionTraceId"; |
| 3 | import { useAuth } from "@clerk/expo"; |
| 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 5 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 6 | import { |
| 7 | type EnvironmentId, |
| 8 | type EnvironmentMachineKind, |
| 9 | type ExecutionEnvironmentDescriptor, |
| 10 | resolveEnvironmentMachineKind, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import { useAtomValue } from "@effect/atom-react"; |
| 13 | import { useCallback, useState } from "react"; |
| 14 | import { Pressable, type TextLayoutEvent, View } from "react-native"; |
| 15 | |
| 16 | import { AppText as Text } from "../../components/AppText"; |
| 17 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 18 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 19 | import { cn } from "../../lib/cn"; |
| 20 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 21 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 22 | import { serverEnvironment } from "../../state/server"; |
| 23 | import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; |
| 24 | import { useCloudAuthAvailable } from "../cloud/cloudAvailability"; |
| 25 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| 26 | import { environmentStatusText } from "./environmentStatusText"; |
| 27 | import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; |
| 28 | |
| 29 | interface CloudEnvironmentRowsProps { |
| 30 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; |
| 31 | readonly onOpenEnvironment?: (environmentId: EnvironmentId) => void; |
| 32 | readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; |
| 33 | /** Long-press on a saved row. The callback owns the confirm. */ |
| 34 | readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void; |
| 35 | readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>; |
| 36 | readonly showcaseSignedIn?: boolean; |
| 37 | /** |
| 38 | * Hide the "T3 Connect" section title when the host provides its own header. |
| 39 | */ |
| 40 | readonly showHeader?: boolean; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * "T3 Connect" section: every environment published to the signed-in account, |
| 45 | * with connect switches, availability status, and loading/error |
| 46 | * states. Shared between the Settings environments screen and the T3 Connect |
| 47 | * onboarding sheet. |
| 48 | * |
| 49 | * Already-connected relay environments render even without cloud config or a |
| 50 | * signed-in account — they are registered on this device and must stay |
| 51 | * reachable and removable. Only discovery (the available list, refresh, and |
| 52 | * its errors) requires a signed-in session. |
| 53 | */ |
| 54 | export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { |
| 55 | const cloudAvailable = useCloudAuthAvailable(); |
| 56 | // Showcase captures run without a Clerk publishable key, so `ClerkProvider` |
| 57 | // is never mounted and any `useAuth` call throws — the fixture states whether |
| 58 | // the rows are signed in instead of asking Clerk. |
| 59 | if (props.showcaseSignedIn !== undefined) { |
| 60 | return props.showcaseSignedIn ? <CloudEnvironmentRowsContent {...props} /> : null; |
| 61 | } |
| 62 | if (!cloudAvailable) { |
| 63 | return <ConnectedOnlyCloudEnvironmentRows {...props} />; |
| 64 | } |
| 65 | return <SignedInCloudEnvironmentRows {...props} />; |
| 66 | } |
| 67 | |
| 68 | function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { |
| 69 | const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 70 | if (!isSignedIn) return <ConnectedOnlyCloudEnvironmentRows {...props} />; |
| 71 | return <CloudEnvironmentRowsContent {...props} />; |
| 72 | } |
| 73 | |
| 74 | function ConnectedOnlyCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { |
| 75 | if (props.connectedCloudEnvironments.length === 0) return null; |
| 76 | return <CloudEnvironmentRowsContent {...props} discoveryAvailable={false} />; |
| 77 | } |
| 78 | |
| 79 | function CloudEnvironmentRowsContent( |
| 80 | props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean }, |
| 81 | ) { |
| 82 | const controller = useConnectionController(); |
| 83 | const discoveryAvailable = props.discoveryAvailable ?? true; |
| 84 | const availableCloudEnvironments = discoveryAvailable |
| 85 | ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments) |
| 86 | : []; |
| 87 | const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null); |
| 88 | const hasCloudRows = |
| 89 | props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; |
| 90 | |
| 91 | const handleConnectCloudEnvironment = useCallback( |
| 92 | (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), |
| 93 | [controller], |
| 94 | ); |
| 95 | |
| 96 | // The relay's health probe carries each server's descriptor, so a machine |
| 97 | // can wear its detected glyph before this device ever connects to it. |
| 98 | const discoveredDescriptors = new Map( |
| 99 | controller.relayEnvironments.flatMap((entry) => |
| 100 | entry.status?.descriptor === undefined |
| 101 | ? [] |
| 102 | : [[entry.environment.environmentId, entry.status.descriptor] as const], |
| 103 | ), |
| 104 | ); |
| 105 | |
| 106 | const handleToggleCloudError = useCallback((environmentId: string) => { |
| 107 | setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); |
| 108 | }, []); |
| 109 | |
| 110 | const showHeader = props.showHeader ?? true; |
| 111 | |
| 112 | return ( |
| 113 | <View collapsable={false} className={cn("gap-3", showHeader && "mt-5")}> |
| 114 | {showHeader ? ( |
| 115 | <View className="px-1"> |
| 116 | <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Connect</Text> |
| 117 | </View> |
| 118 | ) : null} |
| 119 | |
| 120 | {hasCloudRows ? ( |
| 121 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card"> |
| 122 | {props.connectedCloudEnvironments.map((environment) => ( |
| 123 | <ConnectedCloudEnvironmentRow |
| 124 | key={environment.environmentId} |
| 125 | environment={environment} |
| 126 | descriptor={discoveredDescriptors.get(environment.environmentId)} |
| 127 | onSetEnabled={(enabled) => |
| 128 | props.onSetEnvironmentEnabled(environment.environmentId, enabled) |
| 129 | } |
| 130 | onRemove={() => props.onRemoveEnvironment(environment.environmentId)} |
| 131 | onOpen={ |
| 132 | props.onOpenEnvironment |
| 133 | ? () => props.onOpenEnvironment?.(environment.environmentId) |
| 134 | : undefined |
| 135 | } |
| 136 | errorExpanded={expandedErrorId === environment.environmentId} |
| 137 | onToggleError={() => handleToggleCloudError(environment.environmentId)} |
| 138 | /> |
| 139 | ))} |
| 140 | {availableCloudEnvironments.map((environment) => ( |
| 141 | <CloudEnvironmentRow |
| 142 | key={environment.environment.environmentId} |
| 143 | environment={environment} |
| 144 | showChevron={props.onOpenEnvironment !== undefined} |
| 145 | onConnect={() => handleConnectCloudEnvironment(environment)} |
| 146 | errorExpanded={expandedErrorId === environment.environment.environmentId} |
| 147 | onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} |
| 148 | /> |
| 149 | ))} |
| 150 | </View> |
| 151 | ) : controller.relayDiscovery.isRefreshing ? ( |
| 152 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-grouped-card p-6"> |
| 153 | <AppActivityIndicator colorClassName={"accent-icon"} /> |
| 154 | <Text className="text-center text-sm leading-normal text-foreground-muted"> |
| 155 | Loading linked cloud environments. |
| 156 | </Text> |
| 157 | </View> |
| 158 | ) : controller.relayDiscovery.error ? null : ( |
| 159 | <View collapsable={false} className="rounded-[24px] bg-grouped-card p-5"> |
| 160 | <Text className="text-sm leading-normal text-foreground-muted"> |
| 161 | No additional linked cloud environments. |
| 162 | </Text> |
| 163 | </View> |
| 164 | )} |
| 165 | |
| 166 | {/* Rendered alongside any connected rows — a failed discovery must not |
| 167 | hide behind an otherwise-healthy list. */} |
| 168 | {discoveryAvailable && |
| 169 | controller.relayDiscovery.error && |
| 170 | !controller.relayDiscovery.isRefreshing ? ( |
| 171 | <View collapsable={false} className="gap-3 rounded-[24px] bg-grouped-card p-5"> |
| 172 | <Text className="text-base font-t3-bold text-foreground"> |
| 173 | Could not load T3 Connect environments |
| 174 | </Text> |
| 175 | <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text> |
| 176 | {controller.relayDiscovery.errorTraceId ? ( |
| 177 | <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} /> |
| 178 | ) : null} |
| 179 | <Pressable |
| 180 | accessibilityRole="button" |
| 181 | onPress={() => { |
| 182 | void controller.refreshRelayEnvironments(); |
| 183 | }} |
| 184 | className="self-start rounded-full bg-subtle px-3.5 py-2 active:opacity-70" |
| 185 | > |
| 186 | <Text className="text-xs font-t3-bold text-foreground">Try again</Text> |
| 187 | </Pressable> |
| 188 | </View> |
| 189 | ) : null} |
| 190 | </View> |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | /** |
| 195 | * A saved T3 Connect environment. The switch turns it on or off; off keeps the |
| 196 | * registration and cache but drops the connection and hides its errors. |
| 197 | * Long-press removes it from this device. |
| 198 | */ |
| 199 | function ConnectedCloudEnvironmentRow(props: { |
| 200 | readonly environment: ConnectedEnvironmentSummary; |
| 201 | /** Discovery's view of the server, for the glyph before the first connection. */ |
| 202 | readonly descriptor: ExecutionEnvironmentDescriptor | undefined; |
| 203 | readonly errorExpanded: boolean; |
| 204 | readonly onSetEnabled: (enabled: boolean) => void; |
| 205 | readonly onRemove: () => void; |
| 206 | readonly onOpen?: (() => void) | undefined; |
| 207 | readonly onToggleError: () => void; |
| 208 | }) { |
| 209 | const serverConfig = useAtomValue( |
| 210 | serverEnvironment.configValueAtom(props.environment.environmentId), |
| 211 | ); |
| 212 | const unsupported = props.environment.connectionState === "unsupported"; |
| 213 | const enabled = props.environment.isEnabled && !unsupported; |
| 214 | // Discovery empties its map on every refresh; hold the last descriptor seen |
| 215 | // so the glyph does not blink back to the generic one each time. |
| 216 | const [lastDescriptor, setLastDescriptor] = useState(props.descriptor); |
| 217 | if (props.descriptor !== undefined && props.descriptor !== lastDescriptor) { |
| 218 | setLastDescriptor(props.descriptor); |
| 219 | } |
| 220 | return ( |
| 221 | <Pressable |
| 222 | accessibilityHint="Long press to remove from this device" |
| 223 | accessibilityRole={props.onOpen ? "button" : undefined} |
| 224 | accessibilityLabel={props.onOpen ? `Manage ${props.environment.environmentLabel}` : undefined} |
| 225 | onPress={props.onOpen} |
| 226 | onLongPress={props.onRemove} |
| 227 | > |
| 228 | <CloudEnvironmentRowShell |
| 229 | opensDetails={props.onOpen !== undefined} |
| 230 | connectionError={enabled || unsupported ? props.environment.connectionError : null} |
| 231 | connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null} |
| 232 | connectionState={enabled || unsupported ? props.environment.connectionState : "available"} |
| 233 | errorExpanded={props.errorExpanded} |
| 234 | label={props.environment.environmentLabel} |
| 235 | machine={resolveEnvironmentMachineKind( |
| 236 | serverConfig ?? (lastDescriptor === undefined ? null : { environment: lastDescriptor }), |
| 237 | )} |
| 238 | onValueChange={props.onSetEnabled} |
| 239 | onToggleError={props.onToggleError} |
| 240 | disabled={unsupported} |
| 241 | {...(enabled || unsupported ? {} : { statusText: "Off" })} |
| 242 | value={enabled} |
| 243 | /> |
| 244 | </Pressable> |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | function CloudEnvironmentRow(props: { |
| 249 | readonly environment: RelayEnvironmentView; |
| 250 | readonly showChevron: boolean; |
| 251 | readonly errorExpanded: boolean; |
| 252 | readonly onConnect: () => void; |
| 253 | readonly onToggleError: () => void; |
| 254 | }) { |
| 255 | const presentation = availableCloudEnvironmentPresentation({ |
| 256 | isStatusPending: props.environment.availability === "checking", |
| 257 | status: props.environment.status, |
| 258 | statusError: props.environment.error, |
| 259 | statusErrorTraceId: props.environment.traceId, |
| 260 | }); |
| 261 | |
| 262 | return ( |
| 263 | <CloudEnvironmentRowShell |
| 264 | showChevron={props.showChevron} |
| 265 | connectionError={presentation.connectionError} |
| 266 | connectionErrorTraceId={presentation.connectionErrorTraceId} |
| 267 | connectionState={presentation.connectionState} |
| 268 | errorExpanded={props.errorExpanded} |
| 269 | label={props.environment.environment.label} |
| 270 | machine={resolveEnvironmentMachineKind( |
| 271 | props.environment.status?.descriptor === undefined |
| 272 | ? null |
| 273 | : { environment: props.environment.status.descriptor }, |
| 274 | )} |
| 275 | onValueChange={(enabled) => { |
| 276 | if (enabled) { |
| 277 | props.onConnect(); |
| 278 | } |
| 279 | }} |
| 280 | onToggleError={props.onToggleError} |
| 281 | disabled={presentation.connectionState === "unsupported"} |
| 282 | statusText={presentation.statusText} |
| 283 | value={false} |
| 284 | /> |
| 285 | ); |
| 286 | } |
| 287 | |
| 288 | function CloudEnvironmentRowShell(props: { |
| 289 | readonly showChevron?: boolean; |
| 290 | readonly opensDetails?: boolean; |
| 291 | readonly connectionError: string | null; |
| 292 | readonly connectionErrorTraceId: string | null; |
| 293 | readonly connectionState: EnvironmentConnectionPhase; |
| 294 | readonly disabled?: boolean; |
| 295 | readonly errorExpanded: boolean; |
| 296 | readonly label: string; |
| 297 | readonly machine: EnvironmentMachineKind; |
| 298 | readonly onToggleError: () => void; |
| 299 | readonly onValueChange: (enabled: boolean) => void; |
| 300 | readonly statusText?: string; |
| 301 | readonly value: boolean; |
| 302 | }) { |
| 303 | const isRetrying = |
| 304 | props.connectionState === "connecting" || props.connectionState === "reconnecting"; |
| 305 | const shouldPulse = isRetrying; |
| 306 | const statusText = |
| 307 | props.statusText ?? |
| 308 | environmentStatusText({ |
| 309 | phase: props.connectionState, |
| 310 | error: props.connectionError, |
| 311 | traceId: props.connectionErrorTraceId, |
| 312 | }); |
| 313 | // Unsupported is a compatibility note, not a failure, so it stays muted. |
| 314 | const statusClassName = |
| 315 | props.connectionError && props.connectionState !== "unsupported" |
| 316 | ? "text-danger-foreground" |
| 317 | : "text-foreground-muted"; |
| 318 | const [errorMeasurement, setErrorMeasurement] = useState<{ |
| 319 | readonly text: string; |
| 320 | readonly lineCount: number; |
| 321 | } | null>(null); |
| 322 | const errorTraceId = props.connectionErrorTraceId; |
| 323 | const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; |
| 324 | const errorLineCount = |
| 325 | errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; |
| 326 | const errorCanExpand = props.connectionError !== null && errorLineCount > 1; |
| 327 | const isErrorExpanded = errorCanExpand && props.errorExpanded; |
| 328 | const StatusContainer = errorCanExpand ? Pressable : View; |
| 329 | const onMeasuredErrorTextLayout = useCallback( |
| 330 | (event: TextLayoutEvent) => { |
| 331 | if (!props.connectionError) { |
| 332 | return; |
| 333 | } |
| 334 | const nextLineCount = event.nativeEvent.lines.length; |
| 335 | setErrorMeasurement((currentMeasurement) => |
| 336 | currentMeasurement?.text === measuredErrorText && |
| 337 | currentMeasurement.lineCount === nextLineCount |
| 338 | ? currentMeasurement |
| 339 | : { text: measuredErrorText, lineCount: nextLineCount }, |
| 340 | ); |
| 341 | }, |
| 342 | [measuredErrorText, props.connectionError], |
| 343 | ); |
| 344 | return ( |
| 345 | <View collapsable={false} className="flex-row items-center gap-3 bg-grouped-card px-4 py-3.5"> |
| 346 | <View className="min-w-0 flex-1 gap-0.5"> |
| 347 | <View className="min-w-0 flex-row items-center gap-2"> |
| 348 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> |
| 349 | <EnvironmentMachineSymbol |
| 350 | kind={props.machine} |
| 351 | size={14} |
| 352 | tintColorClassName="accent-foreground-muted" |
| 353 | /> |
| 354 | <Text |
| 355 | className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground" |
| 356 | numberOfLines={1} |
| 357 | > |
| 358 | {props.label} |
| 359 | </Text> |
| 360 | </View> |
| 361 | {props.connectionError ? ( |
| 362 | <Text |
| 363 | aria-hidden |
| 364 | onTextLayout={onMeasuredErrorTextLayout} |
| 365 | className={cn("absolute inset-x-0 -z-[1] text-xs opacity-0", statusClassName)} |
| 366 | > |
| 367 | {measuredErrorText} |
| 368 | </Text> |
| 369 | ) : null} |
| 370 | <StatusContainer |
| 371 | {...(errorCanExpand |
| 372 | ? { accessibilityRole: "button" as const, onPress: props.onToggleError } |
| 373 | : {})} |
| 374 | className="min-w-0 flex-row items-start gap-1" |
| 375 | > |
| 376 | <Text |
| 377 | className={cn("min-w-0 flex-1 text-xs", statusClassName)} |
| 378 | numberOfLines={isErrorExpanded ? undefined : 1} |
| 379 | > |
| 380 | {statusText} |
| 381 | {errorTraceId ? ( |
| 382 | <ConnectionTraceId |
| 383 | traceId={errorTraceId} |
| 384 | tone={ |
| 385 | props.connectionError && props.connectionState !== "unsupported" |
| 386 | ? "danger" |
| 387 | : "muted" |
| 388 | } |
| 389 | activation="longPress" |
| 390 | /> |
| 391 | ) : null} |
| 392 | </Text> |
| 393 | {errorCanExpand ? ( |
| 394 | <SymbolView |
| 395 | name="chevron.down" |
| 396 | size={10} |
| 397 | tintColorClassName={"accent-chevron"} |
| 398 | type="monochrome" |
| 399 | style={{ |
| 400 | marginTop: 3, |
| 401 | transform: [{ rotate: isErrorExpanded ? "180deg" : "0deg" }], |
| 402 | }} |
| 403 | /> |
| 404 | ) : null} |
| 405 | </StatusContainer> |
| 406 | </View> |
| 407 | <ThemedSwitch |
| 408 | style={{ alignSelf: "center" }} |
| 409 | disabled={props.disabled} |
| 410 | onValueChange={props.onValueChange} |
| 411 | value={props.value} |
| 412 | /> |
| 413 | {props.opensDetails || props.showChevron ? ( |
| 414 | <View style={{ opacity: props.opensDetails ? 1 : 0.4 }}> |
| 415 | <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-subtle" /> |
| 416 | </View> |
| 417 | ) : null} |
| 418 | </View> |
| 419 | ); |
| 420 | } |
| 421 | |
| 422 | function CopyTraceIdButton(props: { readonly traceId: string }) { |
| 423 | return ( |
| 424 | <Pressable |
| 425 | accessibilityRole="button" |
| 426 | onPress={() => { |
| 427 | copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); |
| 428 | }} |
| 429 | className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" |
| 430 | > |
| 431 | <SymbolView |
| 432 | name="doc.on.doc" |
| 433 | size={12} |
| 434 | tintColorClassName={"accent-icon"} |
| 435 | type="monochrome" |
| 436 | /> |
| 437 | <Text className="text-xs font-t3-bold text-foreground">Copy trace ID</Text> |
| 438 | </Pressable> |
| 439 | ); |
| 440 | } |