| 1 | import { UiSettingsSyncCache } from "@t3tools/client-runtime/state/ui-settings-sync"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Ref from "effect/Ref"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as Semaphore from "effect/Semaphore"; |
| 9 | import { |
| 10 | EnvironmentId, |
| 11 | PullRequestInvolvement, |
| 12 | PullRequestListFilters, |
| 13 | PullRequestListState, |
| 14 | type ProviderInstanceId, |
| 15 | type PullRequestMergeMethod, |
| 16 | type SidebarProjectGroupingMode, |
| 17 | } from "@t3tools/contracts"; |
| 18 | import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; |
| 19 | import type { FollowUpBehavior } from "../lib/followUpBehavior"; |
| 20 | import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority"; |
| 21 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; |
| 22 | import * as MobileDatabase from "./mobile-database"; |
| 23 | import * as MobileSecureStorage from "./mobile-secure-storage"; |
| 24 | import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; |
| 25 | |
| 26 | const PREFERENCES_KEY = "t3code.preferences"; |
| 27 | const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; |
| 28 | |
| 29 | /** The pull request list's filters and sort for one environment scope. */ |
| 30 | export 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 | }); |
| 38 | export type PullRequestListControls = typeof PullRequestListControls.Type; |
| 39 | |
| 40 | const ThreadListFilters = Schema.Struct({ |
| 41 | environmentId: Schema.NullOr(EnvironmentId), |
| 42 | projectKey: Schema.NullOr(Schema.String), |
| 43 | }); |
| 44 | export type ThreadListFilters = typeof ThreadListFilters.Type; |
| 45 | |
| 46 | /** Controls are keyed by environment id, with "" for all environments. */ |
| 47 | const PullRequestListPreferences = Schema.Struct({ |
| 48 | environmentId: Schema.NullOr(Schema.String), |
| 49 | controls: Schema.Record(Schema.String, PullRequestListControls), |
| 50 | }); |
| 51 | export type PullRequestListPreferences = typeof PullRequestListPreferences.Type; |
| 52 | |
| 53 | const isUiSettingsSyncCache = Schema.is(UiSettingsSyncCache); |
| 54 | const isThreadListFilters = Schema.is(ThreadListFilters); |
| 55 | const isPullRequestListPreferences = Schema.is(PullRequestListPreferences); |
| 56 | |
| 57 | export 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 | |
| 104 | export 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 | |
| 113 | export 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 | |
| 122 | interface PreferencesFallback { |
| 123 | readonly payload: string; |
| 124 | readonly updatedAt: number; |
| 125 | readonly preferences: Preferences; |
| 126 | } |
| 127 | |
| 128 | export 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 | |
| 141 | function 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 | |
| 303 | export 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 | |
| 505 | export const layer = Layer.effect(MobilePreferencesStore, make()); |