1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import type { EnvironmentId } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";
5import { AsyncResult, Atom } from "effect/reactivity";
6import { useCallback } from "react";
7import { Alert } from "react-native";
8
9import { useConnectionController } from "../features/connection/useConnectionController";
10import { environmentPresentations } from "./presentation";
11import { useWorkspaceConnectionState, useWorkspaceEnvironments } from "./workspace";
12import type { SavedRemoteConnection } from "../lib/connection";
13import { appAtomRegistry } from "./atom-registry";
14import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types";
15import { environmentSession } from "./session";
16import { environmentCatalog } from "../connection/catalog";
17import { createRemoteEnvironmentProjectionAtoms } from "./remote-environment-projections";
18import { serverEnvironment } from "./server";
19
20const connectionPairingUrlAtom = Atom.make("").pipe(
21 Atom.keepAlive,
22 Atom.withLabel("mobile:connection-pairing-url"),
23);
24
25const pendingConnectionErrorAtom = Atom.make<string | null>(null).pipe(
26 Atom.keepAlive,
27 Atom.withLabel("mobile:pending-connection-error"),
28);
29
30export function setPendingConnectionError(message: string | null): void {
31 appAtomRegistry.set(pendingConnectionErrorAtom, message);
32}
33
34const remoteEnvironmentProjections = createRemoteEnvironmentProjectionAtoms({
35 presentationAtom: environmentPresentations.presentationAtom,
36 preparedConnectionAtom: environmentSession.preparedConnectionValueAtom,
37 serverConfigAtom: serverEnvironment.configValueAtom,
38});
39
40const EMPTY_SAVED_CONNECTION_ATOM = Atom.make<SavedRemoteConnection | null>(null).pipe(
41 Atom.withLabel("mobile:saved-connection:empty"),
42);
43
44const EMPTY_RUNTIME_STATE_ATOM = Atom.make<EnvironmentRuntimeState | null>(null).pipe(
45 Atom.withLabel("mobile:environment-runtime-state:empty"),
46);
47
48const savedConnectionsByIdAtom = Atom.make((get) => {
49 const catalog = get(environmentCatalog.catalogValueAtom);
50 return Object.fromEntries(
51 [...catalog.entries.keys()].flatMap((environmentId) => {
52 const connection = get(remoteEnvironmentProjections.savedConnectionAtom(environmentId));
53 return connection === null ? [] : [[environmentId, connection]];
54 }),
55 ) as Record<EnvironmentId, SavedRemoteConnection>;
56}).pipe(Atom.withLabel("mobile:saved-connections-by-id"));
57
58export function useSavedRemoteConnections() {
59 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
60 const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom);
61
62 return {
63 isLoadingSavedConnection: !catalog.isReady,
64 savedConnectionsById,
65 };
66}
67
68export function useSavedRemoteConnection(
69 environmentId: EnvironmentId | null,
70): SavedRemoteConnection | null {
71 return useAtomValue(
72 environmentId === null
73 ? EMPTY_SAVED_CONNECTION_ATOM
74 : remoteEnvironmentProjections.savedConnectionAtom(environmentId),
75 );
76}
77
78export function useRemoteEnvironmentRuntime(
79 environmentId: EnvironmentId | null,
80): EnvironmentRuntimeState | null {
81 return useAtomValue(
82 environmentId === null
83 ? EMPTY_RUNTIME_STATE_ATOM
84 : remoteEnvironmentProjections.runtimeStateAtom(environmentId),
85 );
86}
87
88export function useRemoteConnectionStatus() {
89 const state = useWorkspaceConnectionState();
90 const connectedEnvironments: ReadonlyArray<ConnectedEnvironmentSummary> =
91 useWorkspaceEnvironments();
92 const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
93
94 return {
95 connectedEnvironments,
96 connectionState: state.connectionState,
97 connectionError: pendingConnectionError ?? state.connectionError,
98 };
99}
100
101export function useRemoteConnections() {
102 const controller = useConnectionController();
103 const navigation = useNavigation();
104 const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom);
105 const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
106 const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus();
107
108 const onChangeConnectionPairingUrl = useCallback((pairingUrl: string) => {
109 appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl);
110 }, []);
111
112 const onConnectPress = useCallback(
113 async (pairingUrl?: string, expectedEnvironmentId?: EnvironmentId) => {
114 const nextPairingUrl = pairingUrl ?? connectionPairingUrl;
115 setPendingConnectionError(null);
116 const result = await controller.connectPairingUrl(nextPairingUrl, expectedEnvironmentId);
117 if (AsyncResult.isFailure(result)) {
118 const error = Cause.squash(result.cause);
119 const message =
120 error instanceof Error ? error.message : "Failed to pair with the environment.";
121 if (
122 error !== null &&
123 typeof error === "object" &&
124 "reason" in error &&
125 error.reason === "unsupported"
126 ) {
127 Alert.alert("Client not supported", message);
128 } else {
129 setPendingConnectionError(message);
130 }
131 } else {
132 appAtomRegistry.set(connectionPairingUrlAtom, "");
133 }
134 return result;
135 },
136 [connectionPairingUrl, controller],
137 );
138
139 const onReconnectEnvironment = useCallback(
140 (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId),
141 [controller],
142 );
143 const onSetEnvironmentEnabled = useCallback(
144 (environmentId: EnvironmentId, enabled: boolean) =>
145 controller.setEnvironmentEnabled(environmentId, enabled),
146 [controller],
147 );
148 const onUpdateEnvironment = useCallback(
149 (
150 environmentId: EnvironmentId,
151 updates: { readonly label: string; readonly displayUrl: string },
152 ) => controller.updateEnvironment(environmentId, updates),
153 [controller],
154 );
155
156 const onRemoveEnvironmentPress = useCallback(
157 (environmentId: EnvironmentId) => {
158 const environment = connectedEnvironments.find(
159 (candidate) => candidate.environmentId === environmentId,
160 );
161 if (!environment) {
162 return;
163 }
164 const remove = {
165 text: "Remove",
166 style: "destructive",
167 onPress: () => {
168 void controller.removeEnvironment(environmentId);
169 },
170 } as const;
171 // Removing a T3 Connect environment here leaves its account registration
172 // and host space, so point to where it can be deregistered.
173 if (environment.isRelayManaged) {
174 Alert.alert(
175 "Remove from this device?",
176 `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`,
177 [
178 { text: "Cancel", style: "cancel" },
179 {
180 text: "Open T3 Account",
181 onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }),
182 },
183 remove,
184 ],
185 );
186 return;
187 }
188 Alert.alert(
189 "Remove from this device?",
190 `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`,
191 [{ text: "Cancel", style: "cancel" }, remove],
192 );
193 },
194 [connectedEnvironments, controller, navigation],
195 );
196
197 return {
198 connectionPairingUrl,
199 connectionState,
200 connectionError,
201 pairingConnectionError: pendingConnectionError,
202 connectedEnvironments,
203 connectedEnvironmentCount: connectedEnvironments.length,
204 onChangeConnectionPairingUrl,
205 onConnectPress,
206 onReconnectEnvironment,
207 onSetEnvironmentEnabled,
208 onUpdateEnvironment,
209 onRemoveEnvironmentPress,
210 };
211}