import { useAuth } from "@clerk/expo"; import { SymbolView } from "../../components/AppSymbol"; import { connectionStatusText, type EnvironmentConnectionPhase, } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; import { ActivityIndicator, Pressable, type NativeSyntheticEvent, type TextLayoutEventData, View, } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useThemeColor } from "../../lib/useThemeColor"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; interface CloudEnvironmentRowsProps { readonly connectedCloudEnvironments: ReadonlyArray; readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; readonly showcaseAvailableEnvironments?: ReadonlyArray; readonly showcaseSignedIn?: boolean; /** * Hide the "T3 Connect" section title + refresh button for hosts that * provide their own chrome (the onboarding sheet's native header and * pull-to-refresh). */ readonly showHeader?: boolean; } /** * "T3 Connect" section: every environment published to the signed-in account, * with connect switches, availability status, refresh, and loading/error * states. Shared between the Settings environments screen and the T3 Connect * onboarding sheet. * * Already-connected relay environments render even without cloud config or a * signed-in account — they are registered on this device and must stay * reachable and removable. Only discovery (the available list, refresh, and * its errors) requires a signed-in session. */ export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { // Showcase captures run without a Clerk publishable key, so `ClerkProvider` // is never mounted and any `useAuth` call throws — the fixture states whether // the rows are signed in instead of asking Clerk. if (props.showcaseSignedIn !== undefined) { return props.showcaseSignedIn ? : null; } // No cloud config means no `ClerkProvider` either, so `useAuth` would throw. if (!hasCloudPublicConfig()) { return ; } return ; } function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); if (!isSignedIn) return ; return ; } function ConnectedOnlyCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { if (props.connectedCloudEnvironments.length === 0) return null; return ; } function CloudEnvironmentRowsContent( props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean }, ) { const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); const discoveryAvailable = props.discoveryAvailable ?? true; const availableCloudEnvironments = discoveryAvailable ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments) : []; const [expandedErrorId, setExpandedErrorId] = useState(null); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; const handleConnectCloudEnvironment = useCallback( (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), [controller], ); const handleDisconnectCloudEnvironment = useCallback( (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), [controller], ); const handleToggleCloudError = useCallback((environmentId: string) => { setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); }, []); const showHeader = props.showHeader ?? true; return ( {showHeader ? ( T3 Connect {discoveryAvailable ? ( { void controller.refreshRelayEnvironments(); }} className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" > {controller.relayDiscovery.isRefreshing ? ( ) : ( )} ) : null} ) : null} {hasCloudRows ? ( {props.connectedCloudEnvironments.map((environment, index) => ( props.onReconnectEnvironment(environment.environmentId)} onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} errorExpanded={expandedErrorId === environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environmentId)} /> ))} {availableCloudEnvironments.map((environment, index) => ( 0 || index !== 0} onConnect={() => handleConnectCloudEnvironment(environment)} errorExpanded={expandedErrorId === environment.environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} /> ))} ) : controller.relayDiscovery.isRefreshing ? ( Loading linked cloud environments. ) : controller.relayDiscovery.error ? null : ( No additional linked cloud environments. )} {/* Rendered alongside any connected rows — a failed discovery must not hide behind an otherwise-healthy list. */} {discoveryAvailable && controller.relayDiscovery.error && !controller.relayDiscovery.isRefreshing ? ( Could not load T3 Connect environments {controller.relayDiscovery.error} {controller.relayDiscovery.errorTraceId ? ( ) : null} { void controller.refreshRelayEnvironments(); }} className="self-start rounded-full bg-subtle px-3.5 py-2 active:opacity-70" > Try again ) : null} ); } function ConnectedCloudEnvironmentRow(props: { readonly environment: ConnectedEnvironmentSummary; readonly borderTop: boolean; readonly errorExpanded: boolean; readonly onConnect: () => void; readonly onDisconnect: () => void; readonly onToggleError: () => void; }) { return ( { if (enabled) { props.onConnect(); return; } props.onDisconnect(); }} onToggleError={props.onToggleError} value={props.environment.connectionState !== "available"} /> ); } function CloudEnvironmentRow(props: { readonly environment: RelayEnvironmentView; readonly borderTop: boolean; readonly errorExpanded: boolean; readonly onConnect: () => void; readonly onToggleError: () => void; }) { const presentation = availableCloudEnvironmentPresentation({ isStatusPending: props.environment.availability === "checking", status: props.environment.status, statusError: props.environment.error, statusErrorTraceId: props.environment.traceId, }); return ( { if (enabled) { props.onConnect(); } }} onToggleError={props.onToggleError} statusText={presentation.statusText} value={false} /> ); } function CloudEnvironmentRowShell(props: { readonly borderTop: boolean; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly disabled?: boolean; readonly errorExpanded: boolean; readonly label: string; readonly onToggleError: () => void; readonly onValueChange: (enabled: boolean) => void; readonly statusText?: string; readonly value: boolean; }) { const chevron = useThemeColor("--color-chevron"); const isRetrying = props.connectionState === "connecting" || props.connectionState === "reconnecting"; const shouldPulse = isRetrying; const statusText = props.statusText ?? connectionStatusText({ phase: props.connectionState, error: props.connectionError, traceId: props.connectionErrorTraceId, }); const statusClassName = props.connectionError ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted"; const [errorMeasurement, setErrorMeasurement] = useState<{ readonly text: string; readonly lineCount: number; } | null>(null); const errorTraceId = props.connectionErrorTraceId; const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; const errorLineCount = errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; const errorCanExpand = props.connectionError !== null && errorLineCount > 1; const isErrorExpanded = errorCanExpand && props.errorExpanded; const StatusContainer = errorCanExpand ? Pressable : View; const onMeasuredErrorTextLayout = useCallback( (event: NativeSyntheticEvent) => { if (!props.connectionError) { return; } const nextLineCount = event.nativeEvent.lines.length; setErrorMeasurement((currentMeasurement) => currentMeasurement?.text === measuredErrorText && currentMeasurement.lineCount === nextLineCount ? currentMeasurement : { text: measuredErrorText, lineCount: nextLineCount }, ); }, [measuredErrorText, props.connectionError], ); return ( {props.label} {props.connectionError ? ( {measuredErrorText} ) : null} {statusText} {errorTraceId ? ( <> {" Trace ID: "} { event.stopPropagation(); copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); }} onPress={(event) => { event.stopPropagation(); }} > {errorTraceId} ) : null} {errorCanExpand ? ( ) : null} ); } function CopyTraceIdButton(props: { readonly traceId: string }) { const iconColor = useThemeColor("--color-icon"); return ( { copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); }} className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" > Copy trace ID ); }