1import { useAtomValue } from "@effect/atom-react";
2import {
3 AVAILABLE_CONNECTION_STATE,
4 type ConnectionDiagnosis,
5 connectionDiagnosisText,
6 connectionRoutes,
7 hasRelayRoute,
8 presentConnectionDiagnosis,
9 type EnvironmentPresentation,
10} from "@t3tools/client-runtime/connection";
11import type { EnvironmentId } from "@t3tools/contracts";
12import Constants from "expo-constants";
13import { AsyncResult } from "effect/reactivity";
14import { Linking, View } from "react-native";
15
16import { AppText as Text } from "../../components/ui/AppText";
17import { Button } from "../../components/ui/Button";
18import { environmentCatalog } from "../../connection/catalog";
19import { cn } from "../../lib/cn";
20import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
21
22const HOP_MARK = { ok: "✓", failed: "✕", unchecked: "–" } as const;
23const HOP_NOTE = { ok: "", failed: " · Failed", unchecked: " · Not checked" } as const;
24
25/** Same model as the web Settings row; null while connected, off, or not failed. */
26export function useConnectionDiagnosis(
27 environmentId: EnvironmentId,
28 presentation: EnvironmentPresentation | null,
29) {
30 const state = AsyncResult.getOrElse(
31 useAtomValue(environmentCatalog.stateAtom(environmentId)),
32 () => AVAILABLE_CONNECTION_STATE,
33 );
34 if (presentation === null || !presentation.entry.enabled) return { diagnosis: null, state };
35 const phase = presentation.connection.phase;
36 if (phase === "connected" || phase === "unsupported") return { diagnosis: null, state };
37 const relayOnly =
38 connectionRoutes(presentation.entry).length === 1 && hasRelayRoute(presentation.entry);
39 const diagnosis = presentConnectionDiagnosis({
40 hostLabel: presentation.entry.target.label,
41 state,
42 via: relayOnly ? "relay" : "direct",
43 clientVersion: Constants.expoConfig?.version,
44 serverVersion: presentation.serverConfig?.environment.serverVersion,
45 });
46 return { diagnosis, state };
47}
48
49export function ConnectionDiagnosisDetails(props: {
50 readonly hostLabel: string;
51 readonly diagnosis: ConnectionDiagnosis;
52}) {
53 const { diagnosis } = props;
54 const { moreInfoUrl } = diagnosis;
55 return (
56 <View className="gap-3">
57 <View className="gap-0.5">
58 {diagnosis.hops.map((hop) => (
59 <Text
60 key={hop.hop}
61 className={cn(
62 "text-sm",
63 hop.result === "failed" ? "text-danger-foreground" : "text-foreground-muted",
64 )}
65 >
66 {`${HOP_MARK[hop.result]} ${hop.label}${HOP_NOTE[hop.result]}`}
67 </Text>
68 ))}
69 </View>
70 <View className="gap-0.5">
71 <Text className="text-sm font-t3-bold text-foreground">What to try</Text>
72 {diagnosis.steps.map((step, index) => (
73 <Text key={step} className="text-sm text-foreground-muted">
74 {`${index + 1}. ${step}`}
75 </Text>
76 ))}
77 </View>
78 <Text selectable className="font-mono text-xs text-foreground-muted">
79 {diagnosis.facts.map(([label, value]) => `${label}: ${value}`).join("\n")}
80 </Text>
81 <Button
82 label="Copy diagnostics"
83 icon="doc.on.doc"
84 onPress={() => {
85 copyTextWithHaptic(connectionDiagnosisText(props.hostLabel, diagnosis), {
86 target: "connection-diagnostics",
87 });
88 }}
89 />
90 {moreInfoUrl ? (
91 <Button
92 label="More info"
93 icon="safari"
94 tone="secondary"
95 onPress={() => void Linking.openURL(moreInfoUrl).catch(() => undefined)}
96 />
97 ) : null}
98 </View>
99 );
100}