1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/reactivity";
3import { useState } from "react";
4import { EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts";
5import { Button } from "../ui/button";
6import { Select, SelectTrigger, SelectPopup, SelectItem, SelectValue } from "../ui/select";
7import { useEnvironments } from "../../state/environments";
8import { useAtomCommand } from "../../state/use-atom-command";
9import {
10 disableUiSettingsSync,
11 enableUiSettingsSync,
12 uiSettingsSyncAtoms,
13 useUiSettingsSync,
14} from "../../state/uiSettingsSync";
15import { SettingsRow, SettingsSection } from "./settingsLayout";
16import { searchableSetting } from "./settingsSearch";
17
18export function UiSettingsSyncSection() {
19 const sync = useUiSettingsSync();
20 const { environments } = useEnvironments();
21 const [selection, setSelection] = useState("");
22 const [saved, setSaved] = useState<{
23 environmentId: EnvironmentId;
24 profile: UiSettingsProfile;
25 } | null>(null);
26 const [busy, setBusy] = useState(false);
27 const [error, setError] = useState<string | null>(null);
28 const read = useAtomCommand(uiSettingsSyncAtoms.read, { reportFailure: false });
29 const selected = selection ? EnvironmentId.make(selection) : null;
30 const permission = useAtomValue(uiSettingsSyncAtoms.patch.permissionAtom(selected));
31 const eligible = environments.filter(
32 (environment) =>
33 environment.connection.phase === "connected" &&
34 environment.serverConfig?.environment.capabilities.uiSettingsSync === true,
35 );
36 const active = environments.find(
37 (environment) => environment.environmentId === sync.environmentId,
38 );
39
40 async function inspect() {
41 if (!selected) return;
42 setBusy(true);
43 setError(null);
44 const result = await read({ environmentId: selected, input: {} });
45 if (AsyncResult.isSuccess(result)) setSaved({ environmentId: selected, profile: result.value });
46 else setError("Unable to read the profile. Reconnect to this environment and try again.");
47 setBusy(false);
48 }
49
50 async function enable(mode: "adopt" | "publish") {
51 if (!saved) return;
52 setBusy(true);
53 setError(null);
54 try {
55 await enableUiSettingsSync(saved.environmentId, saved.profile, mode);
56 setSaved(null);
57 setSelection("");
58 } catch {
59 setError("Unable to enable sync. Check local storage access and try again.");
60 } finally {
61 setBusy(false);
62 }
63 }
64
65 return (
66 <SettingsSection id="ui-settings-sync-section" title="UI settings sync">
67 <SettingsRow
68 {...searchableSetting("ui-settings-sync")}
69 description="Sync appearance, fonts, and chat preferences through one private environment. Enable it on each device."
70 control={
71 sync.environmentId !== null || sync.status === "error" ? (
72 <Button
73 size="sm"
74 variant="outline"
75 disabled={busy}
76 onClick={() => {
77 void disableUiSettingsSync().catch(() =>
78 setError("Unable to disable sync. Check local storage access and try again."),
79 );
80 }}
81 >
82 Disable sync
83 </Button>
84 ) : undefined
85 }
86 >
87 <div className="space-y-3">
88 <p role="status" className="text-sm text-muted-foreground">
89 {sync.environmentId === null
90 ? "Off"
91 : sync.status === "synced"
92 ? `Following ${active?.label ?? "your saved environment"}`
93 : sync.status === "syncing"
94 ? "Syncing…"
95 : sync.status === "error"
96 ? "Sync paused. Reconnect to the profile environment to try again."
97 : "Offline. Changes stay on this device until the profile environment reconnects."}
98 </p>
99 {sync.conflicts.length > 0 ? (
100 <p role="status" className="text-sm text-muted-foreground">
101 Newer saved preferences replaced conflicting edits. Change a preference again to use
102 your choice.
103 </p>
104 ) : null}
105 <div className="flex flex-wrap items-center gap-2">
106 <Select
107 value={selection}
108 disabled={busy}
109 onValueChange={(value) => {
110 setSelection(value ?? "");
111 setSaved(null);
112 setError(null);
113 }}
114 >
115 <SelectTrigger size="sm" aria-label="Profile environment">
116 <SelectValue>
117 {eligible.find((entry) => entry.environmentId === selection)?.label ??
118 "Choose private environment"}
119 </SelectValue>
120 </SelectTrigger>
121 <SelectPopup>
122 {eligible.map((environment) => (
123 <SelectItem key={environment.environmentId} value={environment.environmentId}>
124 {environment.label}
125 </SelectItem>
126 ))}
127 </SelectPopup>
128 </Select>
129 <Button
130 size="sm"
131 variant="outline"
132 disabled={!selected || !permission || busy}
133 onClick={() => void inspect()}
134 >
135 Read profile
136 </Button>
137 </div>
138 {eligible.length === 0 ? (
139 <p className="text-sm text-muted-foreground">
140 Connect to an updated private environment to set up sync.
141 </p>
142 ) : null}
143 {selected && !permission ? (
144 <p className="text-sm text-muted-foreground">
145 Settings access is required. Update this connection’s permissions in Connections.
146 </p>
147 ) : null}
148 {saved ? (
149 <div className="space-y-2">
150 <p className="text-sm text-muted-foreground">
151 {saved.profile.revision === 0
152 ? "No profile saved. Publish this device’s current preferences to start."
153 : "Use the saved profile, or publish this device’s current preferences. Other participating devices will follow it."}
154 </p>
155 <div className="flex flex-wrap gap-2">
156 {saved.profile.revision > 0 ? (
157 <Button size="sm" disabled={busy} onClick={() => void enable("adopt")}>
158 Use saved
159 </Button>
160 ) : null}
161 <Button
162 size="sm"
163 variant={saved.profile.revision > 0 ? "outline" : "default"}
164 disabled={busy || !permission}
165 onClick={() => void enable("publish")}
166 >
167 Publish current
168 </Button>
169 <Button size="sm" variant="ghost" disabled={busy} onClick={() => setSaved(null)}>
170 Cancel
171 </Button>
172 </div>
173 </div>
174 ) : null}
175 {error ? (
176 <p role="alert" className="text-sm text-destructive">
177 {error}
178 </p>
179 ) : null}
180 </div>
181 </SettingsRow>
182 </SettingsSection>
183 );
184}