909 lines
27 KiB
TypeScript
909 lines
27 KiB
TypeScript
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
import {
|
|
AppState,
|
|
useColorScheme,
|
|
} from 'react-native'
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import { useAuth } from './auth-context'
|
|
import { supabase } from './supabase'
|
|
import {
|
|
getMobileThemePalette,
|
|
isMobileThemeMode,
|
|
resolveEffectiveTheme,
|
|
type EffectiveMobileTheme,
|
|
type MobileThemeMode,
|
|
type MobileThemePalette,
|
|
} from '../theme/mobile-theme'
|
|
|
|
export const CURRENT_ONBOARDING_VERSION = 1
|
|
|
|
const CACHE_SCHEMA_VERSION = 1
|
|
const INSTALLATION_CACHE_KEY = '@d3ro/mobile/preferences/installation-v1'
|
|
const USER_CACHE_PREFIX = '@d3ro/mobile/preferences/user-v1/'
|
|
const USER_SETTINGS_COLUMNS = [
|
|
'user_id',
|
|
'theme_mode',
|
|
'locale',
|
|
'haptic_enabled',
|
|
'auto_polish_enabled',
|
|
'preferred_stt_model',
|
|
'preferred_llm_model',
|
|
'onboarding_version',
|
|
'tutorial_completed_at',
|
|
'revision',
|
|
'updated_at',
|
|
].join(',')
|
|
|
|
export type SupportedMobileLocale = 'ko' | 'en'
|
|
export type PreferencesSyncStatus =
|
|
| 'loading'
|
|
| 'local'
|
|
| 'saving'
|
|
| 'synced'
|
|
| 'offline'
|
|
| 'error'
|
|
|
|
export type PreferencesErrorCode =
|
|
| 'CACHE_READ_FAILED'
|
|
| 'CACHE_WRITE_FAILED'
|
|
| 'SYNC_FAILED'
|
|
| 'SYNC_CONFLICT'
|
|
|
|
export interface MobilePreferences {
|
|
themeMode: MobileThemeMode
|
|
locale: SupportedMobileLocale
|
|
hapticEnabled: boolean
|
|
autoPolishEnabled: boolean
|
|
preferredSttModel: string | null
|
|
preferredLlmModel: string | null
|
|
onboardingVersion: number
|
|
tutorialCompletedAt: string | null
|
|
revision: number
|
|
updatedAt: string | null
|
|
}
|
|
|
|
export type MobilePreferencesPatch = Partial<Pick<
|
|
MobilePreferences,
|
|
| 'themeMode'
|
|
| 'locale'
|
|
| 'hapticEnabled'
|
|
| 'autoPolishEnabled'
|
|
| 'preferredSttModel'
|
|
| 'preferredLlmModel'
|
|
| 'onboardingVersion'
|
|
| 'tutorialCompletedAt'
|
|
>>
|
|
|
|
export interface PreferenceMutationResult {
|
|
localSaved: boolean
|
|
serverSynced: boolean
|
|
}
|
|
|
|
interface PreferencesContextValue {
|
|
preferences: MobilePreferences
|
|
loading: boolean
|
|
syncStatus: PreferencesSyncStatus
|
|
errorCode: PreferencesErrorCode | null
|
|
lastSyncedAt: string | null
|
|
effectiveTheme: EffectiveMobileTheme
|
|
palette: MobileThemePalette
|
|
needsOnboarding: boolean
|
|
updatePreferences: (patch: MobilePreferencesPatch) => Promise<PreferenceMutationResult>
|
|
completeOnboarding: (
|
|
outcome: 'completed' | 'skipped',
|
|
) => Promise<PreferenceMutationResult>
|
|
retrySync: () => Promise<void>
|
|
}
|
|
|
|
interface CacheEnvelope {
|
|
schemaVersion: number
|
|
preferences: MobilePreferences
|
|
pendingPatch: MobilePreferencesPatch
|
|
lastSyncedAt: string | null
|
|
}
|
|
|
|
interface SettingsRow {
|
|
user_id: string
|
|
theme_mode: unknown
|
|
locale: unknown
|
|
haptic_enabled: unknown
|
|
auto_polish_enabled: unknown
|
|
preferred_stt_model: unknown
|
|
preferred_llm_model: unknown
|
|
onboarding_version: unknown
|
|
tutorial_completed_at: unknown
|
|
revision: unknown
|
|
updated_at: unknown
|
|
}
|
|
|
|
interface ProviderSnapshot {
|
|
ownerKey: string
|
|
preferences: MobilePreferences
|
|
loading: boolean
|
|
syncStatus: PreferencesSyncStatus
|
|
errorCode: PreferencesErrorCode | null
|
|
lastSyncedAt: string | null
|
|
}
|
|
|
|
const DEFAULT_PREFERENCES: MobilePreferences = Object.freeze({
|
|
themeMode: 'system',
|
|
locale: 'ko',
|
|
hapticEnabled: true,
|
|
autoPolishEnabled: true,
|
|
preferredSttModel: null,
|
|
preferredLlmModel: null,
|
|
onboardingVersion: 0,
|
|
tutorialCompletedAt: null,
|
|
revision: 1,
|
|
updatedAt: null,
|
|
})
|
|
|
|
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
|
|
|
|
export async function clearAllUserPreferenceCaches(): Promise<void> {
|
|
const keys = await AsyncStorage.getAllKeys()
|
|
const userKeys = keys.filter((key) => key.startsWith(USER_CACHE_PREFIX))
|
|
if (userKeys.length > 0) await AsyncStorage.multiRemove(userKeys)
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function normalizeLocale(value: unknown): SupportedMobileLocale {
|
|
return value === 'en' ? 'en' : 'ko'
|
|
}
|
|
|
|
function normalizeNullableString(value: unknown): string | null {
|
|
return typeof value === 'string' && value.trim().length > 0
|
|
? value.trim()
|
|
: null
|
|
}
|
|
|
|
function normalizeNonNegativeInteger(value: unknown, fallback: number): number {
|
|
const numeric = typeof value === 'number' ? value : Number(value)
|
|
return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : fallback
|
|
}
|
|
|
|
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
|
const numeric = typeof value === 'number' ? value : Number(value)
|
|
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback
|
|
}
|
|
|
|
function normalizeIsoDate(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null
|
|
const timestamp = Date.parse(value)
|
|
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null
|
|
}
|
|
|
|
export function normalizePreferences(
|
|
value: unknown,
|
|
fallback: MobilePreferences = DEFAULT_PREFERENCES,
|
|
): MobilePreferences {
|
|
if (!isRecord(value)) return { ...fallback }
|
|
|
|
return {
|
|
themeMode: isMobileThemeMode(value.themeMode) ? value.themeMode : fallback.themeMode,
|
|
locale: normalizeLocale(value.locale ?? fallback.locale),
|
|
hapticEnabled: typeof value.hapticEnabled === 'boolean'
|
|
? value.hapticEnabled
|
|
: fallback.hapticEnabled,
|
|
autoPolishEnabled: typeof value.autoPolishEnabled === 'boolean'
|
|
? value.autoPolishEnabled
|
|
: fallback.autoPolishEnabled,
|
|
preferredSttModel: value.preferredSttModel === undefined
|
|
? fallback.preferredSttModel
|
|
: normalizeNullableString(value.preferredSttModel),
|
|
preferredLlmModel: value.preferredLlmModel === undefined
|
|
? fallback.preferredLlmModel
|
|
: normalizeNullableString(value.preferredLlmModel),
|
|
onboardingVersion: normalizeNonNegativeInteger(
|
|
value.onboardingVersion,
|
|
fallback.onboardingVersion,
|
|
),
|
|
tutorialCompletedAt: value.tutorialCompletedAt === undefined
|
|
? fallback.tutorialCompletedAt
|
|
: normalizeIsoDate(value.tutorialCompletedAt),
|
|
revision: normalizePositiveInteger(value.revision, fallback.revision),
|
|
updatedAt: value.updatedAt === undefined
|
|
? fallback.updatedAt
|
|
: normalizeIsoDate(value.updatedAt),
|
|
}
|
|
}
|
|
|
|
export function normalizePreferencesPatch(value: unknown): MobilePreferencesPatch {
|
|
if (!isRecord(value)) return {}
|
|
const patch: MobilePreferencesPatch = {}
|
|
|
|
if (isMobileThemeMode(value.themeMode)) patch.themeMode = value.themeMode
|
|
if (value.locale === 'ko' || value.locale === 'en') patch.locale = value.locale
|
|
if (typeof value.hapticEnabled === 'boolean') patch.hapticEnabled = value.hapticEnabled
|
|
if (typeof value.autoPolishEnabled === 'boolean') {
|
|
patch.autoPolishEnabled = value.autoPolishEnabled
|
|
}
|
|
if (value.preferredSttModel === null || typeof value.preferredSttModel === 'string') {
|
|
patch.preferredSttModel = normalizeNullableString(value.preferredSttModel)
|
|
}
|
|
if (value.preferredLlmModel === null || typeof value.preferredLlmModel === 'string') {
|
|
patch.preferredLlmModel = normalizeNullableString(value.preferredLlmModel)
|
|
}
|
|
if (value.onboardingVersion !== undefined) {
|
|
patch.onboardingVersion = normalizeNonNegativeInteger(value.onboardingVersion, 0)
|
|
}
|
|
if (value.tutorialCompletedAt === null || typeof value.tutorialCompletedAt === 'string') {
|
|
patch.tutorialCompletedAt = normalizeIsoDate(value.tutorialCompletedAt)
|
|
}
|
|
|
|
return patch
|
|
}
|
|
|
|
export function applyPreferencesPatch(
|
|
preferences: MobilePreferences,
|
|
patch: MobilePreferencesPatch,
|
|
): MobilePreferences {
|
|
return normalizePreferences({ ...preferences, ...normalizePreferencesPatch(patch) }, preferences)
|
|
}
|
|
|
|
function parseSettingsRow(value: unknown): MobilePreferences | null {
|
|
if (!isRecord(value) || typeof value.user_id !== 'string') return null
|
|
const row = value as unknown as SettingsRow
|
|
|
|
return normalizePreferences({
|
|
themeMode: row.theme_mode,
|
|
locale: row.locale,
|
|
hapticEnabled: row.haptic_enabled,
|
|
autoPolishEnabled: row.auto_polish_enabled,
|
|
preferredSttModel: row.preferred_stt_model,
|
|
preferredLlmModel: row.preferred_llm_model,
|
|
onboardingVersion: row.onboarding_version,
|
|
tutorialCompletedAt: row.tutorial_completed_at,
|
|
revision: row.revision,
|
|
updatedAt: row.updated_at,
|
|
})
|
|
}
|
|
|
|
function parseCacheEnvelope(raw: string | null): CacheEnvelope | null {
|
|
if (raw === null) return null
|
|
const parsed: unknown = JSON.parse(raw)
|
|
if (!isRecord(parsed) || parsed.schemaVersion !== CACHE_SCHEMA_VERSION) {
|
|
throw new Error('Unsupported mobile preferences cache schema')
|
|
}
|
|
|
|
return {
|
|
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
preferences: normalizePreferences(parsed.preferences),
|
|
pendingPatch: normalizePreferencesPatch(parsed.pendingPatch),
|
|
lastSyncedAt: normalizeIsoDate(parsed.lastSyncedAt),
|
|
}
|
|
}
|
|
|
|
function createEnvelope(
|
|
preferences: MobilePreferences,
|
|
pendingPatch: MobilePreferencesPatch,
|
|
lastSyncedAt: string | null,
|
|
): CacheEnvelope {
|
|
return {
|
|
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
preferences,
|
|
pendingPatch: normalizePreferencesPatch(pendingPatch),
|
|
lastSyncedAt,
|
|
}
|
|
}
|
|
|
|
function toInstallationPreferences(
|
|
preferences: MobilePreferences,
|
|
): MobilePreferences {
|
|
return {
|
|
...DEFAULT_PREFERENCES,
|
|
themeMode: preferences.themeMode,
|
|
locale: preferences.locale,
|
|
hapticEnabled: preferences.hapticEnabled,
|
|
onboardingVersion: preferences.onboardingVersion,
|
|
tutorialCompletedAt: preferences.tutorialCompletedAt,
|
|
}
|
|
}
|
|
|
|
function getUserCacheKey(userId: string): string {
|
|
return `${USER_CACHE_PREFIX}${userId}`
|
|
}
|
|
|
|
function serializeEnvelope(envelope: CacheEnvelope): string {
|
|
return JSON.stringify(envelope)
|
|
}
|
|
|
|
async function writeCaches(
|
|
userId: string | null,
|
|
preferences: MobilePreferences,
|
|
userPendingPatch: MobilePreferencesPatch,
|
|
lastSyncedAt: string | null,
|
|
installationPendingPatch: MobilePreferencesPatch = {},
|
|
): Promise<void> {
|
|
const installationEnvelope = createEnvelope(
|
|
toInstallationPreferences(preferences),
|
|
installationPendingPatch,
|
|
lastSyncedAt,
|
|
)
|
|
|
|
if (userId === null) {
|
|
await AsyncStorage.setItem(
|
|
INSTALLATION_CACHE_KEY,
|
|
serializeEnvelope(installationEnvelope),
|
|
)
|
|
return
|
|
}
|
|
|
|
const userEnvelope = createEnvelope(
|
|
preferences,
|
|
userPendingPatch,
|
|
lastSyncedAt,
|
|
)
|
|
await AsyncStorage.multiSet([
|
|
[INSTALLATION_CACHE_KEY, serializeEnvelope(installationEnvelope)],
|
|
[getUserCacheKey(userId), serializeEnvelope(userEnvelope)],
|
|
])
|
|
}
|
|
|
|
function toRowMutation(
|
|
userId: string,
|
|
preferences: MobilePreferences,
|
|
): Record<string, unknown> {
|
|
return {
|
|
user_id: userId,
|
|
theme_mode: preferences.themeMode,
|
|
locale: preferences.locale,
|
|
haptic_enabled: preferences.hapticEnabled,
|
|
auto_polish_enabled: preferences.autoPolishEnabled,
|
|
preferred_stt_model: preferences.preferredSttModel,
|
|
preferred_llm_model: preferences.preferredLlmModel,
|
|
onboarding_version: preferences.onboardingVersion,
|
|
tutorial_completed_at: preferences.tutorialCompletedAt,
|
|
revision: preferences.revision,
|
|
}
|
|
}
|
|
|
|
async function fetchServerPreferences(userId: string): Promise<MobilePreferences | null> {
|
|
const { data, error } = await supabase
|
|
.from('user_settings')
|
|
.select(USER_SETTINGS_COLUMNS)
|
|
.eq('user_id', userId)
|
|
.maybeSingle()
|
|
|
|
if (error) throw error
|
|
return parseSettingsRow(data)
|
|
}
|
|
|
|
async function writeServerPreferences(
|
|
userId: string,
|
|
fallback: MobilePreferences,
|
|
patch: MobilePreferencesPatch,
|
|
): Promise<MobilePreferences> {
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const remote = await fetchServerPreferences(userId)
|
|
|
|
if (remote === null) {
|
|
const desired = applyPreferencesPatch(fallback, patch)
|
|
const insertValue: MobilePreferences = {
|
|
...desired,
|
|
revision: Math.max(1, desired.revision),
|
|
}
|
|
const { data, error } = await supabase
|
|
.from('user_settings')
|
|
.insert(toRowMutation(userId, insertValue))
|
|
.select(USER_SETTINGS_COLUMNS)
|
|
.single()
|
|
|
|
if (error?.code === '23505') continue
|
|
if (error) throw error
|
|
const inserted = parseSettingsRow(data)
|
|
if (inserted === null) throw new Error('Invalid user settings insert response')
|
|
return inserted
|
|
}
|
|
|
|
const desired: MobilePreferences = {
|
|
...applyPreferencesPatch(remote, patch),
|
|
revision: remote.revision + 1,
|
|
}
|
|
const { data, error } = await supabase
|
|
.from('user_settings')
|
|
.update(toRowMutation(userId, desired))
|
|
.eq('user_id', userId)
|
|
.eq('revision', remote.revision)
|
|
.select(USER_SETTINGS_COLUMNS)
|
|
.maybeSingle()
|
|
|
|
if (error) throw error
|
|
const updated = parseSettingsRow(data)
|
|
if (updated !== null) return updated
|
|
}
|
|
|
|
const conflict = new Error('User settings changed repeatedly on another device')
|
|
conflict.name = 'PreferencesSyncConflictError'
|
|
throw conflict
|
|
}
|
|
|
|
function isPatchEmpty(patch: MobilePreferencesPatch): boolean {
|
|
return Object.keys(patch).length === 0
|
|
}
|
|
|
|
function removeCommittedPatch(
|
|
current: MobilePreferencesPatch,
|
|
committed: MobilePreferencesPatch,
|
|
): MobilePreferencesPatch {
|
|
const remaining: MobilePreferencesPatch = { ...current }
|
|
for (const key of Object.keys(committed) as Array<keyof MobilePreferencesPatch>) {
|
|
if (remaining[key] === committed[key]) {
|
|
delete remaining[key]
|
|
}
|
|
}
|
|
return remaining
|
|
}
|
|
|
|
function errorCodeForSync(error: unknown): PreferencesErrorCode {
|
|
return error instanceof Error && error.name === 'PreferencesSyncConflictError'
|
|
? 'SYNC_CONFLICT'
|
|
: 'SYNC_FAILED'
|
|
}
|
|
|
|
export function MobilePreferencesProvider({
|
|
children,
|
|
}: {
|
|
children: ReactNode
|
|
}): React.ReactElement {
|
|
const { user } = useAuth()
|
|
const systemScheme = useColorScheme()
|
|
const userId = user?.id ?? null
|
|
const ownerKey = userId === null ? 'installation' : `user:${userId}`
|
|
|
|
const [snapshot, setSnapshot] = useState<ProviderSnapshot>({
|
|
ownerKey: 'uninitialized',
|
|
preferences: { ...DEFAULT_PREFERENCES },
|
|
loading: true,
|
|
syncStatus: 'loading',
|
|
errorCode: null,
|
|
lastSyncedAt: null,
|
|
})
|
|
|
|
const ownerKeyRef = useRef(ownerKey)
|
|
const userIdRef = useRef<string | null>(userId)
|
|
const preferencesRef = useRef<MobilePreferences>({ ...DEFAULT_PREFERENCES })
|
|
const pendingPatchRef = useRef<MobilePreferencesPatch>({})
|
|
const lastSyncedAtRef = useRef<string | null>(null)
|
|
const loadGenerationRef = useRef(0)
|
|
const serverQueueRef = useRef<Promise<void>>(Promise.resolve())
|
|
const localQueueRef = useRef<Promise<void>>(Promise.resolve())
|
|
|
|
ownerKeyRef.current = ownerKey
|
|
userIdRef.current = userId
|
|
|
|
const flushPendingPatch = useCallback(async (targetUserId: string): Promise<boolean> => {
|
|
if (userIdRef.current !== targetUserId) return false
|
|
const committedPatch = { ...pendingPatchRef.current }
|
|
if (isPatchEmpty(committedPatch)) return true
|
|
|
|
setSnapshot((current) => current.ownerKey === `user:${targetUserId}`
|
|
? { ...current, syncStatus: 'saving', errorCode: null }
|
|
: current)
|
|
|
|
try {
|
|
const saved = await writeServerPreferences(
|
|
targetUserId,
|
|
preferencesRef.current,
|
|
committedPatch,
|
|
)
|
|
if (userIdRef.current !== targetUserId) return false
|
|
|
|
const remainingPatch = removeCommittedPatch(
|
|
pendingPatchRef.current,
|
|
committedPatch,
|
|
)
|
|
const nextPreferences = applyPreferencesPatch(saved, remainingPatch)
|
|
const syncedAt = new Date().toISOString()
|
|
preferencesRef.current = nextPreferences
|
|
pendingPatchRef.current = remainingPatch
|
|
lastSyncedAtRef.current = syncedAt
|
|
|
|
try {
|
|
await writeCaches(
|
|
targetUserId,
|
|
nextPreferences,
|
|
remainingPatch,
|
|
syncedAt,
|
|
)
|
|
setSnapshot({
|
|
ownerKey: `user:${targetUserId}`,
|
|
preferences: nextPreferences,
|
|
loading: false,
|
|
syncStatus: isPatchEmpty(remainingPatch) ? 'synced' : 'saving',
|
|
errorCode: null,
|
|
lastSyncedAt: syncedAt,
|
|
})
|
|
} catch {
|
|
setSnapshot({
|
|
ownerKey: `user:${targetUserId}`,
|
|
preferences: nextPreferences,
|
|
loading: false,
|
|
syncStatus: 'error',
|
|
errorCode: 'CACHE_WRITE_FAILED',
|
|
lastSyncedAt: syncedAt,
|
|
})
|
|
}
|
|
return isPatchEmpty(remainingPatch)
|
|
} catch (error) {
|
|
if (userIdRef.current !== targetUserId) return false
|
|
setSnapshot((current) => current.ownerKey === `user:${targetUserId}`
|
|
? {
|
|
...current,
|
|
loading: false,
|
|
syncStatus: 'offline',
|
|
errorCode: errorCodeForSync(error),
|
|
}
|
|
: current)
|
|
return false
|
|
}
|
|
}, [])
|
|
|
|
const queueServerFlush = useCallback((targetUserId: string): Promise<boolean> => {
|
|
const run = serverQueueRef.current.then(
|
|
() => flushPendingPatch(targetUserId),
|
|
() => flushPendingPatch(targetUserId),
|
|
)
|
|
serverQueueRef.current = run.then(() => undefined, () => undefined)
|
|
return run
|
|
}, [flushPendingPatch])
|
|
|
|
const loadPreferences = useCallback(async (
|
|
targetOwnerKey: string,
|
|
targetUserId: string | null,
|
|
): Promise<void> => {
|
|
const generation = ++loadGenerationRef.current
|
|
setSnapshot((current) => ({
|
|
...current,
|
|
ownerKey: targetOwnerKey,
|
|
loading: true,
|
|
syncStatus: 'loading',
|
|
errorCode: null,
|
|
}))
|
|
|
|
let installationEnvelope: CacheEnvelope | null = null
|
|
let userEnvelope: CacheEnvelope | null = null
|
|
let cacheReadFailed = false
|
|
let cacheWriteFailed = false
|
|
|
|
try {
|
|
const keys = targetUserId === null
|
|
? [INSTALLATION_CACHE_KEY]
|
|
: [INSTALLATION_CACHE_KEY, getUserCacheKey(targetUserId)]
|
|
const entries = await AsyncStorage.multiGet(keys)
|
|
installationEnvelope = parseCacheEnvelope(entries[0]?.[1] ?? null)
|
|
if (targetUserId !== null) {
|
|
userEnvelope = parseCacheEnvelope(entries[1]?.[1] ?? null)
|
|
}
|
|
} catch {
|
|
cacheReadFailed = true
|
|
}
|
|
|
|
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
|
|
return
|
|
}
|
|
|
|
if (targetUserId === null) {
|
|
const localPreferences = installationEnvelope?.preferences ?? { ...DEFAULT_PREFERENCES }
|
|
const localPending = installationEnvelope?.pendingPatch ?? {}
|
|
preferencesRef.current = localPreferences
|
|
pendingPatchRef.current = localPending
|
|
lastSyncedAtRef.current = installationEnvelope?.lastSyncedAt ?? null
|
|
setSnapshot({
|
|
ownerKey: targetOwnerKey,
|
|
preferences: localPreferences,
|
|
loading: false,
|
|
syncStatus: cacheReadFailed ? 'error' : 'local',
|
|
errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null,
|
|
lastSyncedAt: installationEnvelope?.lastSyncedAt ?? null,
|
|
})
|
|
return
|
|
}
|
|
|
|
const cachedPreferences = userEnvelope?.preferences
|
|
?? installationEnvelope?.preferences
|
|
?? { ...DEFAULT_PREFERENCES }
|
|
const stagedPatch: MobilePreferencesPatch = {
|
|
...(userEnvelope?.pendingPatch ?? {}),
|
|
...(installationEnvelope?.pendingPatch ?? {}),
|
|
}
|
|
const cachedWithPending = applyPreferencesPatch(cachedPreferences, stagedPatch)
|
|
preferencesRef.current = cachedWithPending
|
|
pendingPatchRef.current = stagedPatch
|
|
lastSyncedAtRef.current = userEnvelope?.lastSyncedAt
|
|
?? installationEnvelope?.lastSyncedAt
|
|
?? null
|
|
|
|
setSnapshot({
|
|
ownerKey: targetOwnerKey,
|
|
preferences: cachedWithPending,
|
|
loading: false,
|
|
syncStatus: 'loading',
|
|
errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null,
|
|
lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null,
|
|
})
|
|
|
|
try {
|
|
await writeCaches(
|
|
targetUserId,
|
|
cachedWithPending,
|
|
stagedPatch,
|
|
userEnvelope?.lastSyncedAt ?? null,
|
|
)
|
|
} catch {
|
|
cacheWriteFailed = true
|
|
}
|
|
|
|
let resolved: MobilePreferences
|
|
try {
|
|
const remote = await fetchServerPreferences(targetUserId)
|
|
if (remote === null || !isPatchEmpty(stagedPatch)) {
|
|
resolved = await writeServerPreferences(
|
|
targetUserId,
|
|
remote ?? cachedWithPending,
|
|
stagedPatch,
|
|
)
|
|
} else {
|
|
resolved = remote
|
|
}
|
|
|
|
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
|
|
return
|
|
}
|
|
} catch (error) {
|
|
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
|
|
return
|
|
}
|
|
setSnapshot({
|
|
ownerKey: targetOwnerKey,
|
|
preferences: cachedWithPending,
|
|
loading: false,
|
|
syncStatus: 'offline',
|
|
errorCode: cacheReadFailed
|
|
? 'CACHE_READ_FAILED'
|
|
: cacheWriteFailed
|
|
? 'CACHE_WRITE_FAILED'
|
|
: errorCodeForSync(error),
|
|
lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
|
|
return
|
|
}
|
|
|
|
const syncedAt = new Date().toISOString()
|
|
try {
|
|
await writeCaches(targetUserId, resolved, {}, syncedAt)
|
|
} catch {
|
|
cacheWriteFailed = true
|
|
}
|
|
|
|
preferencesRef.current = resolved
|
|
pendingPatchRef.current = {}
|
|
lastSyncedAtRef.current = syncedAt
|
|
setSnapshot({
|
|
ownerKey: targetOwnerKey,
|
|
preferences: resolved,
|
|
loading: false,
|
|
syncStatus: cacheReadFailed || cacheWriteFailed ? 'error' : 'synced',
|
|
errorCode: cacheReadFailed
|
|
? 'CACHE_READ_FAILED'
|
|
: cacheWriteFailed
|
|
? 'CACHE_WRITE_FAILED'
|
|
: null,
|
|
lastSyncedAt: syncedAt,
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void loadPreferences(ownerKey, userId)
|
|
}, [loadPreferences, ownerKey, userId])
|
|
|
|
useEffect(() => {
|
|
if (userId === null) return undefined
|
|
|
|
const channel = supabase
|
|
.channel(`mobile-user-settings-${userId}`)
|
|
.on(
|
|
'postgres_changes',
|
|
{
|
|
event: 'UPDATE',
|
|
schema: 'public',
|
|
table: 'user_settings',
|
|
filter: `user_id=eq.${userId}`,
|
|
},
|
|
(payload) => {
|
|
if (userIdRef.current !== userId || !isPatchEmpty(pendingPatchRef.current)) return
|
|
const remote = parseSettingsRow(payload.new)
|
|
if (remote === null) return
|
|
|
|
const syncedAt = new Date().toISOString()
|
|
preferencesRef.current = remote
|
|
lastSyncedAtRef.current = syncedAt
|
|
setSnapshot({
|
|
ownerKey: `user:${userId}`,
|
|
preferences: remote,
|
|
loading: false,
|
|
syncStatus: 'synced',
|
|
errorCode: null,
|
|
lastSyncedAt: syncedAt,
|
|
})
|
|
void writeCaches(userId, remote, {}, syncedAt).catch(() => {
|
|
if (userIdRef.current !== userId) return
|
|
setSnapshot((current) => ({
|
|
...current,
|
|
syncStatus: 'error',
|
|
errorCode: 'CACHE_WRITE_FAILED',
|
|
}))
|
|
})
|
|
},
|
|
)
|
|
.subscribe()
|
|
|
|
return () => {
|
|
void supabase.removeChannel(channel)
|
|
}
|
|
}, [userId])
|
|
|
|
const commitPatch = useCallback(async (
|
|
requestedPatch: MobilePreferencesPatch,
|
|
): Promise<PreferenceMutationResult> => {
|
|
const targetOwnerKey = ownerKeyRef.current
|
|
const targetUserId = userIdRef.current
|
|
const patch = normalizePreferencesPatch(requestedPatch)
|
|
if (isPatchEmpty(patch)) {
|
|
return {
|
|
localSaved: true,
|
|
serverSynced: targetUserId === null || isPatchEmpty(pendingPatchRef.current),
|
|
}
|
|
}
|
|
|
|
const nextPreferences = applyPreferencesPatch(preferencesRef.current, patch)
|
|
const nextPendingPatch = {
|
|
...pendingPatchRef.current,
|
|
...patch,
|
|
}
|
|
|
|
try {
|
|
await writeCaches(
|
|
targetUserId,
|
|
nextPreferences,
|
|
nextPendingPatch,
|
|
lastSyncedAtRef.current,
|
|
targetUserId === null ? nextPendingPatch : {},
|
|
)
|
|
} catch {
|
|
if (ownerKeyRef.current === targetOwnerKey) {
|
|
setSnapshot((current) => ({
|
|
...current,
|
|
syncStatus: 'error',
|
|
errorCode: 'CACHE_WRITE_FAILED',
|
|
}))
|
|
}
|
|
return { localSaved: false, serverSynced: false }
|
|
}
|
|
|
|
if (ownerKeyRef.current !== targetOwnerKey) {
|
|
return { localSaved: true, serverSynced: false }
|
|
}
|
|
|
|
preferencesRef.current = nextPreferences
|
|
pendingPatchRef.current = nextPendingPatch
|
|
setSnapshot((current) => ({
|
|
...current,
|
|
ownerKey: targetOwnerKey,
|
|
preferences: nextPreferences,
|
|
loading: false,
|
|
syncStatus: targetUserId === null ? 'local' : 'saving',
|
|
errorCode: null,
|
|
}))
|
|
|
|
if (targetUserId === null) {
|
|
return { localSaved: true, serverSynced: false }
|
|
}
|
|
|
|
const serverSynced = await queueServerFlush(targetUserId)
|
|
return { localSaved: true, serverSynced }
|
|
}, [queueServerFlush])
|
|
|
|
const updatePreferences = useCallback((
|
|
patch: MobilePreferencesPatch,
|
|
): Promise<PreferenceMutationResult> => {
|
|
const run = localQueueRef.current.then(
|
|
() => commitPatch(patch),
|
|
() => commitPatch(patch),
|
|
)
|
|
localQueueRef.current = run.then(() => undefined, () => undefined)
|
|
return run
|
|
}, [commitPatch])
|
|
|
|
const completeOnboarding = useCallback((
|
|
outcome: 'completed' | 'skipped',
|
|
): Promise<PreferenceMutationResult> => updatePreferences({
|
|
onboardingVersion: CURRENT_ONBOARDING_VERSION,
|
|
tutorialCompletedAt: outcome === 'completed' ? new Date().toISOString() : null,
|
|
}), [updatePreferences])
|
|
|
|
const retrySync = useCallback(async (): Promise<void> => {
|
|
const targetOwnerKey = ownerKeyRef.current
|
|
const targetUserId = userIdRef.current
|
|
if (targetUserId !== null && !isPatchEmpty(pendingPatchRef.current)) {
|
|
await queueServerFlush(targetUserId)
|
|
return
|
|
}
|
|
await loadPreferences(targetOwnerKey, targetUserId)
|
|
}, [loadPreferences, queueServerFlush])
|
|
|
|
useEffect(() => {
|
|
const subscription = AppState.addEventListener('change', (state) => {
|
|
if (state !== 'active' || userIdRef.current === null) return
|
|
if (isPatchEmpty(pendingPatchRef.current) && snapshot.syncStatus !== 'offline') return
|
|
void retrySync()
|
|
})
|
|
return () => subscription.remove()
|
|
}, [retrySync, snapshot.syncStatus])
|
|
|
|
const effectiveTheme = resolveEffectiveTheme(
|
|
snapshot.preferences.themeMode,
|
|
systemScheme,
|
|
)
|
|
const palette = useMemo(
|
|
() => getMobileThemePalette(effectiveTheme),
|
|
[effectiveTheme],
|
|
)
|
|
const loading = snapshot.loading || snapshot.ownerKey !== ownerKey
|
|
|
|
const contextValue = useMemo<PreferencesContextValue>(() => ({
|
|
preferences: snapshot.preferences,
|
|
loading,
|
|
syncStatus: snapshot.syncStatus,
|
|
errorCode: snapshot.errorCode,
|
|
lastSyncedAt: snapshot.lastSyncedAt,
|
|
effectiveTheme,
|
|
palette,
|
|
needsOnboarding:
|
|
snapshot.preferences.onboardingVersion < CURRENT_ONBOARDING_VERSION,
|
|
updatePreferences,
|
|
completeOnboarding,
|
|
retrySync,
|
|
}), [
|
|
completeOnboarding,
|
|
effectiveTheme,
|
|
loading,
|
|
palette,
|
|
retrySync,
|
|
snapshot.errorCode,
|
|
snapshot.lastSyncedAt,
|
|
snapshot.preferences,
|
|
snapshot.syncStatus,
|
|
updatePreferences,
|
|
])
|
|
|
|
return (
|
|
<PreferencesContext.Provider value={contextValue}>
|
|
{children}
|
|
</PreferencesContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useMobilePreferences(): PreferencesContextValue {
|
|
const context = useContext(PreferencesContext)
|
|
if (context === null) {
|
|
throw new Error('useMobilePreferences must be used inside MobilePreferencesProvider')
|
|
}
|
|
return context
|
|
}
|