1import { UiSettingsSyncCache } from "@t3tools/client-runtime/state/ui-settings-sync";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7import * as Schema from "effect/Schema";
8import * as Semaphore from "effect/Semaphore";
9import {
10 EnvironmentId,
11 PullRequestInvolvement,
12 PullRequestListFilters,
13 PullRequestListState,
14 type ProviderInstanceId,
15 type PullRequestMergeMethod,
16 type SidebarProjectGroupingMode,
17} from "@t3tools/contracts";
18import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
19import type { FollowUpBehavior } from "../lib/followUpBehavior";
20import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority";
21import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
22import * as MobileDatabase from "./mobile-database";
23import * as MobileSecureStorage from "./mobile-secure-storage";
24import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage";
25
26const PREFERENCES_KEY = "t3code.preferences";
27const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";
28
29/** The pull request list's filters and sort for one environment scope. */
30export const PullRequestListControls = Schema.Struct({
31 state: PullRequestListState,
32 involvement: PullRequestInvolvement,
33 filters: PullRequestListFilters,
34 projectId: Schema.optional(Schema.String),
35 projectEnvironmentId: Schema.optional(Schema.String),
36 sort: Schema.Literals(["updated", "created", "oldest"]),
37});
38export type PullRequestListControls = typeof PullRequestListControls.Type;
39
40const ThreadListFilters = Schema.Struct({
41 environmentId: Schema.NullOr(EnvironmentId),
42 projectKey: Schema.NullOr(Schema.String),
43});
44export type ThreadListFilters = typeof ThreadListFilters.Type;
45
46/** Controls are keyed by environment id, with "" for all environments. */
47const PullRequestListPreferences = Schema.Struct({
48 environmentId: Schema.NullOr(Schema.String),
49 controls: Schema.Record(Schema.String, PullRequestListControls),
50});
51export type PullRequestListPreferences = typeof PullRequestListPreferences.Type;
52
53const isUiSettingsSyncCache = Schema.is(UiSettingsSyncCache);
54const isThreadListFilters = Schema.is(ThreadListFilters);
55const isPullRequestListPreferences = Schema.is(PullRequestListPreferences);
56
57export interface Preferences {
58 readonly connectionService?: "t3-connect" | "self-hosted";
59 readonly uiSettingsSync?: UiSettingsSyncCache | null;
60 readonly pullRequestHideTestFiles?: boolean;
61 readonly pullRequestMergeMethod?: PullRequestMergeMethod;
62 readonly liveActivitiesEnabled?: boolean;
63 readonly themeId?: MobileThemeId;
64 readonly lightThemeId?: MobileThemeId;
65 readonly darkThemeId?: MobileThemeId;
66 readonly themeMode?: MobileThemeMode;
67 readonly baseFontSize?: number;
68 readonly terminalFontSize?: number | null;
69 readonly markdownFontSize?: number;
70 readonly codeFontSize?: number | null;
71 readonly codeWordBreak?: boolean;
72 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
73 readonly collapsedProjectGroups?: readonly string[];
74 /** What the Return key does in the composer on a hardware keyboard. iOS only. */
75 readonly composerEnterBehavior?: ComposerEnterBehavior;
76 /**
77 * Device-local mirror of the web `followUpBehavior` client setting: whether a
78 * message sent during a running turn queues behind it or steers it.
79 */
80 readonly followUpBehavior?: FollowUpBehavior;
81 /** Microphone kinds and remembered devices, most preferred first. iOS only. */
82 readonly microphones?: ReadonlyArray<MicrophoneEntry>;
83 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
84 readonly projectGroupingEnabled?: boolean;
85 readonly projectGroupingMode?: SidebarProjectGroupingMode;
86 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
87 readonly planModeEnabled?: boolean;
88 /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */
89 readonly workingShelfEnabled?: boolean;
90 /** @deprecated Retained to migrate favorites to the connected environment. */
91 readonly modelFavorites?: ReadonlyArray<{
92 readonly provider: ProviderInstanceId;
93 readonly model: string;
94 }>;
95 /** Fresh keys reset both shelves to collapsed when users update. */
96 readonly threadListSettledShelfExpanded?: boolean;
97 readonly threadListSnoozedShelfExpanded?: boolean;
98 readonly threadListWorkingShelfExpanded?: boolean;
99 /** Home list filters, restored on the next launch. */
100 readonly threadListFilters?: ThreadListFilters;
101 readonly pullRequestListFilters?: PullRequestListPreferences;
102}
103
104export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()(
105 "MobilePreferencesLoadError",
106 { cause: Schema.Defect() },
107) {
108 override get message(): string {
109 return "Failed to load mobile preferences.";
110 }
111}
112
113export class MobilePreferencesSaveError extends Schema.TaggedError<MobilePreferencesSaveError>()(
114 "MobilePreferencesSaveError",
115 { cause: Schema.Defect() },
116) {
117 override get message(): string {
118 return "Failed to save mobile preferences.";
119 }
120}
121
122interface PreferencesFallback {
123 readonly payload: string;
124 readonly updatedAt: number;
125 readonly preferences: Preferences;
126}
127
128export class MobilePreferencesStore extends Context.Service<
129 MobilePreferencesStore,
130 {
131 readonly load: Effect.Effect<Preferences, MobilePreferencesLoadError>;
132 readonly savePatch: (
133 patch: Partial<Preferences>,
134 ) => Effect.Effect<Preferences, MobilePreferencesSaveError>;
135 readonly update: (
136 transform: (current: Preferences) => Partial<Preferences>,
137 ) => Effect.Effect<Preferences, MobilePreferencesSaveError>;
138 }
139>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}
140
141function sanitizePreferences(parsed: Preferences): Preferences {
142 const preferences: {
143 connectionService?: "t3-connect" | "self-hosted";
144 uiSettingsSync?: UiSettingsSyncCache | null;
145 pullRequestHideTestFiles?: boolean;
146 pullRequestMergeMethod?: PullRequestMergeMethod;
147 liveActivitiesEnabled?: boolean;
148 themeId?: MobileThemeId;
149 lightThemeId?: MobileThemeId;
150 darkThemeId?: MobileThemeId;
151 themeMode?: MobileThemeMode;
152 baseFontSize?: number;
153 terminalFontSize?: number | null;
154 markdownFontSize?: number;
155 codeFontSize?: number | null;
156 codeWordBreak?: boolean;
157 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
158 collapsedProjectGroups?: readonly string[];
159 composerEnterBehavior?: ComposerEnterBehavior;
160 followUpBehavior?: FollowUpBehavior;
161 microphones?: ReadonlyArray<MicrophoneEntry>;
162 projectGroupingEnabled?: boolean;
163 projectGroupingMode?: SidebarProjectGroupingMode;
164 planModeEnabled?: boolean;
165 workingShelfEnabled?: boolean;
166 modelFavorites?: Preferences["modelFavorites"];
167 threadListSettledShelfExpanded?: boolean;
168 threadListSnoozedShelfExpanded?: boolean;
169 threadListWorkingShelfExpanded?: boolean;
170 threadListFilters?: ThreadListFilters;
171 pullRequestListFilters?: PullRequestListPreferences;
172 } = {};
173
174 if (parsed.connectionService === "t3-connect" || parsed.connectionService === "self-hosted") {
175 preferences.connectionService = parsed.connectionService;
176 }
177
178 if (typeof parsed.pullRequestHideTestFiles === "boolean") {
179 preferences.pullRequestHideTestFiles = parsed.pullRequestHideTestFiles;
180 }
181
182 if (
183 parsed.pullRequestMergeMethod === "squash" ||
184 parsed.pullRequestMergeMethod === "merge" ||
185 parsed.pullRequestMergeMethod === "rebase"
186 ) {
187 preferences.pullRequestMergeMethod = parsed.pullRequestMergeMethod;
188 }
189
190 if (typeof parsed.liveActivitiesEnabled === "boolean") {
191 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
192 }
193 if (
194 typeof parsed.themeId === "string" &&
195 (MOBILE_THEME_IDS as readonly string[]).includes(parsed.themeId)
196 ) {
197 preferences.themeId = parsed.themeId as MobileThemeId;
198 }
199 if (
200 typeof parsed.lightThemeId === "string" &&
201 (MOBILE_THEME_IDS as readonly string[]).includes(parsed.lightThemeId)
202 ) {
203 preferences.lightThemeId = parsed.lightThemeId as MobileThemeId;
204 }
205 if (
206 typeof parsed.darkThemeId === "string" &&
207 (MOBILE_THEME_IDS as readonly string[]).includes(parsed.darkThemeId)
208 ) {
209 preferences.darkThemeId = parsed.darkThemeId as MobileThemeId;
210 }
211 if (
212 parsed.themeMode === "system" ||
213 parsed.themeMode === "light" ||
214 parsed.themeMode === "dark"
215 ) {
216 preferences.themeMode = parsed.themeMode;
217 }
218 if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
219 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
220 preferences.terminalFontSize = parsed.terminalFontSize;
221 }
222 if (typeof parsed.markdownFontSize === "number") {
223 preferences.markdownFontSize = parsed.markdownFontSize;
224 }
225 if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) {
226 preferences.codeFontSize = parsed.codeFontSize;
227 }
228 if (typeof parsed.codeWordBreak === "boolean") preferences.codeWordBreak = parsed.codeWordBreak;
229 if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) {
230 preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter(
231 (account): account is string => typeof account === "string",
232 );
233 }
234 if (Array.isArray(parsed.collapsedProjectGroups)) {
235 preferences.collapsedProjectGroups = parsed.collapsedProjectGroups.filter(
236 (key): key is string => typeof key === "string",
237 );
238 }
239 if (parsed.composerEnterBehavior === "send" || parsed.composerEnterBehavior === "newline") {
240 preferences.composerEnterBehavior = parsed.composerEnterBehavior;
241 }
242 if (parsed.followUpBehavior === "queue" || parsed.followUpBehavior === "steer") {
243 preferences.followUpBehavior = parsed.followUpBehavior;
244 }
245 if (Array.isArray(parsed.microphones)) {
246 preferences.microphones = parsed.microphones.flatMap((entry): MicrophoneEntry[] => {
247 if (typeof entry !== "object" || entry === null || !isMicrophoneKind(entry.kind)) return [];
248 if (!("uid" in entry)) return [{ kind: entry.kind }];
249 return typeof entry.uid === "string" && typeof entry.name === "string"
250 ? [{ kind: entry.kind, uid: entry.uid, name: entry.name }]
251 : [];
252 });
253 }
254 if (typeof parsed.projectGroupingEnabled === "boolean") {
255 preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
256 }
257 if (
258 parsed.projectGroupingMode === "repository" ||
259 parsed.projectGroupingMode === "repository_path" ||
260 parsed.projectGroupingMode === "separate"
261 ) {
262 preferences.projectGroupingMode = parsed.projectGroupingMode;
263 }
264 if (typeof parsed.planModeEnabled === "boolean") {
265 preferences.planModeEnabled = parsed.planModeEnabled;
266 }
267 if (typeof parsed.workingShelfEnabled === "boolean") {
268 preferences.workingShelfEnabled = parsed.workingShelfEnabled;
269 }
270 if (Array.isArray(parsed.modelFavorites)) {
271 preferences.modelFavorites = parsed.modelFavorites.filter(
272 (favorite) =>
273 typeof favorite === "object" &&
274 favorite !== null &&
275 typeof favorite.provider === "string" &&
276 favorite.provider.length > 0 &&
277 typeof favorite.model === "string" &&
278 favorite.model.trim().length > 0,
279 );
280 }
281 if (typeof parsed.threadListSettledShelfExpanded === "boolean") {
282 preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded;
283 }
284 if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") {
285 preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded;
286 }
287 if (typeof parsed.threadListWorkingShelfExpanded === "boolean") {
288 preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded;
289 }
290 if (isThreadListFilters(parsed.threadListFilters)) {
291 preferences.threadListFilters = parsed.threadListFilters;
292 }
293 if (isPullRequestListPreferences(parsed.pullRequestListFilters)) {
294 preferences.pullRequestListFilters = parsed.pullRequestListFilters;
295 }
296 if (parsed.uiSettingsSync === null || isUiSettingsSyncCache(parsed.uiSettingsSync)) {
297 preferences.uiSettingsSync = parsed.uiSettingsSync;
298 }
299
300 return preferences;
301}
302
303export const make = Effect.fn("MobilePreferencesStore.make")(function* () {
304 const database = yield* MobileDatabase.MobileDatabase;
305 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
306 const lock = yield* Semaphore.make(1);
307 const lastUpdatedAt = yield* Ref.make(0);
308
309 const parsePayload = (raw: string | null): Preferences | null => {
310 if (raw === null || !raw.trim()) return null;
311 let parsed: unknown;
312 try {
313 parsed = JSON.parse(raw);
314 } catch (cause) {
315 console.warn(
316 "[mobile-storage] ignored invalid JSON",
317 new MobileStorageDecodeError({ key: PREFERENCES_KEY, cause }),
318 );
319 return null;
320 }
321 return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
322 ? (parsed as Preferences)
323 : null;
324 };
325
326 const parseFallback = (raw: string | null): PreferencesFallback | null => {
327 if (raw === null || !raw.trim()) return null;
328 let parsed: unknown;
329 try {
330 parsed = JSON.parse(raw);
331 } catch (cause) {
332 console.warn(
333 "[mobile-storage] ignored invalid JSON",
334 new MobileStorageDecodeError({ key: PREFERENCES_FALLBACK_KEY, cause }),
335 );
336 return null;
337 }
338 if (
339 typeof parsed !== "object" ||
340 parsed === null ||
341 !("payload" in parsed) ||
342 typeof parsed.payload !== "string" ||
343 !("updatedAt" in parsed) ||
344 typeof parsed.updatedAt !== "number"
345 ) {
346 return null;
347 }
348 const preferences = parsePayload(parsed.payload);
349 return preferences === null
350 ? null
351 : { payload: parsed.payload, updatedAt: parsed.updatedAt, preferences };
352 };
353
354 const encode = Effect.fn("MobilePreferencesStore.encode")(function* (
355 key: string,
356 value: unknown,
357 ) {
358 return yield* Effect.try({
359 try: () => JSON.stringify(value),
360 catch: (cause) => new MobileStorageEncodeError({ key, cause }),
361 });
362 });
363
364 const nextUpdatedAt = Ref.modify(lastUpdatedAt, (last) => {
365 const next = Math.max(Date.now(), last + 1);
366 return [next, next] as const;
367 });
368
369 const saveJson = Effect.fn("MobilePreferencesStore.saveJson")(function* (
370 payload: string,
371 updatedAt?: number,
372 ) {
373 const timestamp = updatedAt ?? (yield* nextUpdatedAt);
374 yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, timestamp));
375 const databaseResult = yield* Effect.result(database.savePreferencesJson(payload, timestamp));
376 if (databaseResult._tag === "Failure") {
377 yield* Effect.logWarning("Database unavailable; saving preferences to secure storage.").pipe(
378 Effect.annotateLogs({ cause: databaseResult.failure }),
379 );
380 const fallback = yield* encode(PREFERENCES_FALLBACK_KEY, { payload, updatedAt: timestamp });
381 yield* secureStorage.setItem(PREFERENCES_FALLBACK_KEY, fallback);
382 return;
383 }
384 yield* secureStorage
385 .removeItem(PREFERENCES_FALLBACK_KEY)
386 .pipe(
387 Effect.catch((error) =>
388 Effect.logWarning("Could not remove the mobile preferences fallback.").pipe(
389 Effect.annotateLogs({ error }),
390 ),
391 ),
392 );
393 });
394
395 const loadUnlocked = Effect.gen(function* () {
396 const databaseResult = yield* Effect.result(database.loadPreferencesJson);
397 const databaseAvailable = databaseResult._tag === "Success";
398 const storedJson = databaseAvailable
399 ? databaseResult.success
400 : Option.none<MobileDatabase.StoredPreferencesJson>();
401 if (databaseResult._tag === "Failure") {
402 yield* Effect.logWarning("Database unavailable; loading fallback preferences.").pipe(
403 Effect.annotateLogs({ cause: databaseResult.failure }),
404 );
405 }
406
407 const fallbackResult = yield* Effect.result(secureStorage.getItem(PREFERENCES_FALLBACK_KEY));
408 let fallbackJson: string | null = null;
409 if (fallbackResult._tag === "Success") {
410 fallbackJson = fallbackResult.success;
411 } else if (Option.isNone(storedJson)) {
412 return yield* fallbackResult.failure;
413 } else {
414 yield* Effect.logWarning("Could not inspect the mobile preferences fallback.").pipe(
415 Effect.annotateLogs({ error: fallbackResult.failure }),
416 );
417 }
418
419 const fallback = parseFallback(fallbackJson);
420 const storedPreferences = Option.isSome(storedJson)
421 ? parsePayload(storedJson.value.payload)
422 : null;
423 const fallbackIsNewer =
424 fallback !== null &&
425 (storedPreferences === null ||
426 (Option.isSome(storedJson) && fallback.updatedAt > storedJson.value.updatedAt));
427
428 let parsed: Preferences | null = null;
429 if (fallbackIsNewer) {
430 parsed = fallback.preferences;
431 yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, fallback.updatedAt));
432 if (databaseAvailable) yield* saveJson(fallback.payload, fallback.updatedAt);
433 } else if (storedPreferences !== null && Option.isSome(storedJson)) {
434 parsed = storedPreferences;
435 yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, storedJson.value.updatedAt));
436 if (fallbackJson !== null) {
437 yield* secureStorage
438 .removeItem(PREFERENCES_FALLBACK_KEY)
439 .pipe(
440 Effect.catch((error) =>
441 Effect.logWarning("Could not remove a stale mobile preferences fallback.").pipe(
442 Effect.annotateLogs({ error }),
443 ),
444 ),
445 );
446 }
447 }
448
449 if (parsed === null) {
450 const legacyJson = yield* secureStorage.getItem(PREFERENCES_KEY);
451 const legacyPreferences = parsePayload(legacyJson);
452 parsed = legacyPreferences;
453 if (legacyJson !== null && legacyPreferences !== null && databaseAvailable) {
454 yield* saveJson(legacyJson);
455 yield* secureStorage
456 .removeItem(PREFERENCES_KEY)
457 .pipe(
458 Effect.catch((error) =>
459 Effect.logWarning("Could not remove migrated mobile preferences.").pipe(
460 Effect.annotateLogs({ error }),
461 ),
462 ),
463 );
464 }
465 }
466
467 return parsed === null ? {} : sanitizePreferences(parsed);
468 });
469
470 const load = lock
471 .withPermits(1)(loadUnlocked)
472 .pipe(Effect.mapError((cause) => new MobilePreferencesLoadError({ cause })));
473
474 const update = Effect.fn("MobilePreferencesStore.update")((transform) =>
475 lock
476 .withPermits(1)(
477 Effect.gen(function* () {
478 const current = yield* loadUnlocked;
479 const patch = yield* Effect.try({
480 try: () => transform(current),
481 catch: (cause) => new MobilePreferencesSaveError({ cause }),
482 });
483 const next: Preferences = { ...current, ...patch };
484 const payload = yield* encode(PREFERENCES_KEY, next);
485 yield* saveJson(payload);
486 return next;
487 }),
488 )
489 .pipe(
490 Effect.mapError((cause) =>
491 cause instanceof MobilePreferencesSaveError
492 ? cause
493 : new MobilePreferencesSaveError({ cause }),
494 ),
495 ),
496 );
497
498 return MobilePreferencesStore.of({
499 load,
500 update,
501 savePatch: (patch) => update(() => patch),
502 });
503});
504
505export const layer = Layer.effect(MobilePreferencesStore, make());