1import { AppActivityIndicator } from "../../components/AppActivityIndicator";
2import { ConnectionTraceId } from "./ConnectionTraceId";
3import { useAuth } from "@clerk/expo";
4import { SymbolView } from "../../components/AppSymbol";
5import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
6import {
7 type EnvironmentId,
8 type EnvironmentMachineKind,
9 type ExecutionEnvironmentDescriptor,
10 resolveEnvironmentMachineKind,
11} from "@t3tools/contracts";
12import { useAtomValue } from "@effect/atom-react";
13import { useCallback, useState } from "react";
14import { Pressable, type TextLayoutEvent, View } from "react-native";
15
16import { AppText as Text } from "../../components/AppText";
17import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
18import { ThemedSwitch } from "../../components/ThemedSwitch";
19import { cn } from "../../lib/cn";
20import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
21import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
22import { serverEnvironment } from "../../state/server";
23import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
24import { useCloudAuthAvailable } from "../cloud/cloudAvailability";
25import { ConnectionStatusDot } from "./ConnectionStatusDot";
26import { environmentStatusText } from "./environmentStatusText";
27import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";
28
29interface 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 */
54export 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
68function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
69 const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
70 if (!isSignedIn) return <ConnectedOnlyCloudEnvironmentRows {...props} />;
71 return <CloudEnvironmentRowsContent {...props} />;
72}
73
74function ConnectedOnlyCloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
75 if (props.connectedCloudEnvironments.length === 0) return null;
76 return <CloudEnvironmentRowsContent {...props} discoveryAvailable={false} />;
77}
78
79function 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 */
199function 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
248function 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
288function 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
422function 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}