Skip to content
Source
Sign inSign up

use-remote-environment-registry.ts

230 lines8.6 KB

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