Skip to content
Source
Sign inSign up

CloudEnvironmentRows.tsx

442 lines17.3 KB

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