feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
909
apps/mobile-rn/src/lib/preferences-context.tsx
Normal file
909
apps/mobile-rn/src/lib/preferences-context.tsx
Normal file
|
|
@ -0,0 +1,909 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue