1import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";
2import {
3 UiSettingsSync,
4 createUiSettingsSyncAtoms,
5 type UiSettingsSyncCache,
6} from "@t3tools/client-runtime/state/ui-settings-sync";
7import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
8import type { EnvironmentId, UiSettingsProfile } from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import { AsyncResult } from "effect/reactivity";
11import {
12 createContext,
13 createElement,
14 use,
15 useEffect,
16 useCallback,
17 useRef,
18 useSyncExternalStore,
19 type PropsWithChildren,
20} from "react";
21import { portableMobileUiSettings, mobileUiSettingsPatch } from "../lib/uiSettingsSync";
22import { connectionAtomRuntime } from "../connection/runtime";
23import {
24 mobilePreferencesAtom,
25 updateMobilePreferencesAtom,
26 subscribeMobilePreferenceEdits,
27} from "./preferences";
28import { useEnvironments } from "./environments";
29import { resolveMobileProjectGroupingSettings } from "./project-grouping";
30
31export const uiSettingsSyncAtoms = createUiSettingsSyncAtoms(connectionAtomRuntime);
32const listeners = new Set<() => void>();
33let state: {
34 controller: UiSettingsSync | null;
35 environmentId: EnvironmentId | null;
36 status: "disabled" | UiSettingsSync["snapshot"]["status"];
37 conflicts: UiSettingsSync["snapshot"]["conflicts"];
38} = {
39 controller: null,
40 environmentId: null,
41 status: "disabled",
42 conflicts: [],
43};
44function notify() {
45 for (const listener of listeners) listener();
46}
47export function useUiSettingsSync() {
48 return useSyncExternalStore(
49 (listener) => {
50 listeners.add(listener);
51 return () => {
52 listeners.delete(listener);
53 };
54 },
55 () => state,
56 );
57}
58
59function usePersonalUiSettingsSync() {
60 const registry = use(RegistryContext);
61 const hydrated = useRef(false);
62 const preferences = useAtomValue(mobilePreferencesAtom);
63 const save = useAtomSet(updateMobilePreferencesAtom, { mode: "promise" });
64 const sync = useUiSettingsSync();
65 const { environments } = useEnvironments();
66 const host = environments.find((entry) => entry.environmentId === sync.environmentId);
67 const activeController = sync.controller;
68 const connected =
69 host?.connection.phase === "connected" &&
70 host.serverConfig?.environment.capabilities.uiSettingsSync === true;
71
72 const install = useCallback(
73 (cache: UiSettingsSyncCache) => {
74 const next = new UiSettingsSync(cache, {
75 save: async (value) => {
76 await save({ patch: { uiSettingsSync: value }, source: "sync" });
77 },
78 apply: async (values) => {
79 await save({ patch: mobileUiSettingsPatch(values), source: "sync" });
80 },
81 patch: async (input) => {
82 const result = await runAtomCommand(
83 registry,
84 uiSettingsSyncAtoms.patch,
85 { environmentId: cache.environmentId, input },
86 { reportFailure: false },
87 );
88 if (AsyncResult.isFailure(result)) throw Cause.squash(result.cause);
89 return result.value;
90 },
91 });
92 state = { ...state, controller: next };
93 const update = () => {
94 if (state.controller !== next) return;
95 state = { controller: next, environmentId: cache.environmentId, ...next.snapshot };
96 notify();
97 };
98 next.subscribe(update);
99 update();
100 return next;
101 },
102 [registry, save],
103 );
104
105 useEffect(() => {
106 if (hydrated.current || !AsyncResult.isSuccess(preferences) || preferences.waiting) return;
107 hydrated.current = true;
108 if (!state.controller && preferences.value.uiSettingsSync) {
109 void install(preferences.value.uiSettingsSync).restore();
110 }
111 }, [install, preferences]);
112
113 useEffect(
114 () =>
115 subscribeMobilePreferenceEdits((patch) =>
116 state.controller?.edit(portableMobileUiSettings(patch)),
117 ),
118 [],
119 );
120 useEffect(() => {
121 const current = activeController;
122 if (!current || !sync.environmentId) return;
123 if (!connected) {
124 current.disconnect();
125 return;
126 }
127 let disposed = false;
128 let unsubscribe = () => {};
129 const target = { environmentId: sync.environmentId, input: {} };
130 void runAtomCommand(registry, uiSettingsSyncAtoms.read, target, { reportFailure: false }).then(
131 async (result) => {
132 if (disposed) return;
133 if (AsyncResult.isFailure(result)) {
134 current.disconnect();
135 return;
136 }
137 await current.receive(result.value);
138 if (disposed) return;
139 unsubscribe = registry.subscribe(
140 uiSettingsSyncAtoms.changes(target),
141 (update) => {
142 if (AsyncResult.isSuccess(update)) void current.receive(update.value);
143 else if (AsyncResult.isFailure(update)) current.disconnect();
144 },
145 { immediate: true },
146 );
147 },
148 );
149 return () => {
150 disposed = true;
151 unsubscribe();
152 current.disconnect();
153 };
154 }, [activeController, connected, registry, sync.environmentId]);
155
156 return {
157 enable: async (
158 environmentId: EnvironmentId,
159 profile: UiSettingsProfile,
160 mode: "adopt" | "publish",
161 ) => {
162 if (!AsyncResult.isSuccess(preferences) || preferences.waiting)
163 throw new Error("Preferences are still loading.");
164 const cache: UiSettingsSyncCache = {
165 environmentId,
166 profile,
167 pending: { patch: {}, baseRevisions: {} },
168 };
169 await state.controller?.stop();
170 await save({ patch: { uiSettingsSync: cache }, source: "sync" });
171 const next = install(cache);
172 if (mode === "publish")
173 next.edit(
174 portableMobileUiSettings({
175 ...preferences.value,
176 followUpBehavior: preferences.value.followUpBehavior ?? "queue",
177 workingShelfEnabled: preferences.value.workingShelfEnabled ?? false,
178 projectGroupingMode: resolveMobileProjectGroupingSettings(preferences.value)
179 .sidebarProjectGroupingMode,
180 }),
181 );
182 await next.receive(profile);
183 },
184 disable: async () => {
185 const previous = state.controller;
186 state = { ...state, controller: null };
187 await previous?.stop();
188 await save({ patch: { uiSettingsSync: null }, source: "sync" });
189 state = { controller: null, environmentId: null, status: "disabled", conflicts: [] };
190 notify();
191 },
192 };
193}
194
195const UiSettingsSyncContext = createContext<ReturnType<typeof usePersonalUiSettingsSync> | null>(
196 null,
197);
198export function UiSettingsSyncProvider({ children }: PropsWithChildren) {
199 const actions = usePersonalUiSettingsSync();
200 return createElement(UiSettingsSyncContext.Provider, { value: actions }, children);
201}
202export function useUiSettingsSyncActions() {
203 const actions = use(UiSettingsSyncContext);
204 if (!actions) throw new Error("UI settings sync is unavailable.");
205 return actions;
206}