Add persistent state provider (#1830)
* Add persistent state provider * Catch write error * Handle read errors, update error msgs * Fix lint * Don't provide initial state to loader * Remove colorMode from shell state * Idea: hook into persisted context from other files * Migrate settings to new hook * Rework persisted state to split individual contexts * Tweak persisted schema and validation --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
parent
bfe196bac5
commit
96d8faf4b0
13 changed files with 467 additions and 76 deletions
6
src/state/persisted/broadcast/index.ts
Normal file
6
src/state/persisted/broadcast/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default class BroadcastChannel {
|
||||
constructor(public name: string) {}
|
||||
postMessage(_data: any) {}
|
||||
close() {}
|
||||
onmessage: (event: MessageEvent) => void = () => {}
|
||||
}
|
1
src/state/persisted/broadcast/index.web.ts
Normal file
1
src/state/persisted/broadcast/index.web.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export default BroadcastChannel
|
91
src/state/persisted/index.ts
Normal file
91
src/state/persisted/index.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import EventEmitter from 'eventemitter3'
|
||||
import {logger} from '#/logger'
|
||||
import {defaults, Schema} from '#/state/persisted/schema'
|
||||
import {migrate} from '#/state/persisted/legacy'
|
||||
import * as store from '#/state/persisted/store'
|
||||
import BroadcastChannel from '#/state/persisted/broadcast'
|
||||
|
||||
export type {Schema} from '#/state/persisted/schema'
|
||||
export {defaults as schema} from '#/state/persisted/schema'
|
||||
|
||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||
const UPDATE_EVENT = 'BSKY_UPDATE'
|
||||
|
||||
let _state: Schema = defaults
|
||||
const _emitter = new EventEmitter()
|
||||
|
||||
/**
|
||||
* Initializes and returns persisted data state, so that it can be passed to
|
||||
* the Provider.
|
||||
*/
|
||||
export async function init() {
|
||||
logger.debug('persisted state: initializing')
|
||||
|
||||
broadcast.onmessage = onBroadcastMessage
|
||||
|
||||
try {
|
||||
await migrate() // migrate old store
|
||||
const stored = await store.read() // check for new store
|
||||
if (!stored) await store.write(defaults) // opt: init new store
|
||||
_state = stored || defaults // return new store
|
||||
} catch (e) {
|
||||
logger.error('persisted state: failed to load root state from storage', {
|
||||
error: e,
|
||||
})
|
||||
// AsyncStorage failured, but we can still continue in memory
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||
return _state[key]
|
||||
}
|
||||
|
||||
export async function write<K extends keyof Schema>(
|
||||
key: K,
|
||||
value: Schema[K],
|
||||
): Promise<void> {
|
||||
try {
|
||||
_state[key] = value
|
||||
await store.write(_state)
|
||||
// must happen on next tick, otherwise the tab will read stale storage data
|
||||
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
|
||||
logger.debug(`persisted state: wrote root state to storage`)
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed writing root state to storage`, {
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function onUpdate(cb: () => void): () => void {
|
||||
_emitter.addListener('update', cb)
|
||||
return () => _emitter.removeListener('update', cb)
|
||||
}
|
||||
|
||||
async function onBroadcastMessage({data}: MessageEvent) {
|
||||
// validate event
|
||||
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
|
||||
try {
|
||||
// read next state, possibly updated by another tab
|
||||
const next = await store.read()
|
||||
|
||||
if (next) {
|
||||
logger.debug(`persisted state: handling update from broadcast channel`)
|
||||
_state = next
|
||||
_emitter.emit('update')
|
||||
} else {
|
||||
logger.error(
|
||||
`persisted state: handled update update from broadcast channel, but found no data`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`persisted state: failed handling update from broadcast channel`,
|
||||
{
|
||||
error: e,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
137
src/state/persisted/legacy.ts
Normal file
137
src/state/persisted/legacy.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {defaults, Schema} from '#/state/persisted/schema'
|
||||
import {write, read} from '#/state/persisted/store'
|
||||
|
||||
/**
|
||||
* The shape of the serialized data from our legacy Mobx store.
|
||||
*/
|
||||
type LegacySchema = {
|
||||
shell: {
|
||||
colorMode: 'system' | 'light' | 'dark'
|
||||
}
|
||||
session: {
|
||||
data: {
|
||||
service: string
|
||||
did: `did:plc:${string}`
|
||||
}
|
||||
accounts: {
|
||||
service: string
|
||||
did: `did:plc:${string}`
|
||||
refreshJwt: string
|
||||
accessJwt: string
|
||||
handle: string
|
||||
email: string
|
||||
displayName: string
|
||||
aviUrl: string
|
||||
emailConfirmed: boolean
|
||||
}[]
|
||||
}
|
||||
me: {
|
||||
did: `did:plc:${string}`
|
||||
handle: string
|
||||
displayName: string
|
||||
description: string
|
||||
avatar: string
|
||||
}
|
||||
onboarding: {
|
||||
step: string
|
||||
}
|
||||
preferences: {
|
||||
primaryLanguage: string
|
||||
contentLanguages: string[]
|
||||
postLanguage: string
|
||||
postLanguageHistory: string[]
|
||||
contentLabels: {
|
||||
nsfw: string
|
||||
nudity: string
|
||||
suggestive: string
|
||||
gore: string
|
||||
hate: string
|
||||
spam: string
|
||||
impersonation: string
|
||||
}
|
||||
savedFeeds: string[]
|
||||
pinnedFeeds: string[]
|
||||
requireAltTextEnabled: boolean
|
||||
}
|
||||
invitedUsers: {
|
||||
seenDids: string[]
|
||||
copiedInvites: string[]
|
||||
}
|
||||
mutedThreads: {uris: string[]}
|
||||
reminders: {lastEmailConfirm: string}
|
||||
}
|
||||
|
||||
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
|
||||
|
||||
export function transform(legacy: LegacySchema): Schema {
|
||||
return {
|
||||
colorMode: legacy.shell?.colorMode || defaults.colorMode,
|
||||
session: {
|
||||
accounts: legacy.session.accounts || defaults.session.accounts,
|
||||
currentAccount:
|
||||
legacy.session.accounts.find(a => a.did === legacy.session.data.did) ||
|
||||
defaults.session.currentAccount,
|
||||
},
|
||||
reminders: {
|
||||
lastEmailConfirmReminder:
|
||||
legacy.reminders.lastEmailConfirm ||
|
||||
defaults.reminders.lastEmailConfirmReminder,
|
||||
},
|
||||
languagePrefs: {
|
||||
primaryLanguage:
|
||||
legacy.preferences.primaryLanguage ||
|
||||
defaults.languagePrefs.primaryLanguage,
|
||||
contentLanguages:
|
||||
legacy.preferences.contentLanguages ||
|
||||
defaults.languagePrefs.contentLanguages,
|
||||
postLanguage:
|
||||
legacy.preferences.postLanguage || defaults.languagePrefs.postLanguage,
|
||||
postLanguageHistory:
|
||||
legacy.preferences.postLanguageHistory ||
|
||||
defaults.languagePrefs.postLanguageHistory,
|
||||
},
|
||||
requireAltTextEnabled:
|
||||
legacy.preferences.requireAltTextEnabled ||
|
||||
defaults.requireAltTextEnabled,
|
||||
mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads,
|
||||
invitedUsers: {
|
||||
seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids,
|
||||
copiedInvites:
|
||||
legacy.invitedUsers.copiedInvites ||
|
||||
defaults.invitedUsers.copiedInvites,
|
||||
},
|
||||
onboarding: {
|
||||
step: legacy.onboarding.step || defaults.onboarding.step,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates legacy persisted state to new store if new store doesn't exist in
|
||||
* local storage AND old storage exists.
|
||||
*/
|
||||
export async function migrate() {
|
||||
logger.debug('persisted state: migrate')
|
||||
|
||||
try {
|
||||
const rawLegacyData = await AsyncStorage.getItem(
|
||||
DEPRECATED_ROOT_STATE_STORAGE_KEY,
|
||||
)
|
||||
const alreadyMigrated = Boolean(await read())
|
||||
|
||||
if (!alreadyMigrated && rawLegacyData) {
|
||||
logger.debug('persisted state: migrating legacy storage')
|
||||
const legacyData = JSON.parse(rawLegacyData)
|
||||
const newData = transform(legacyData)
|
||||
await write(newData)
|
||||
logger.debug('persisted state: migrated legacy storage')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('persisted state: error migrating legacy storage', {
|
||||
error: String(e),
|
||||
})
|
||||
}
|
||||
}
|
68
src/state/persisted/schema.ts
Normal file
68
src/state/persisted/schema.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import {z} from 'zod'
|
||||
import {deviceLocales} from '#/platform/detection'
|
||||
|
||||
// only data needed for rendering account page
|
||||
const accountSchema = z.object({
|
||||
service: z.string(),
|
||||
did: z.string(),
|
||||
refreshJwt: z.string().optional(),
|
||||
accessJwt: z.string().optional(),
|
||||
handle: z.string(),
|
||||
displayName: z.string(),
|
||||
aviUrl: z.string(),
|
||||
})
|
||||
|
||||
export const schema = z.object({
|
||||
colorMode: z.enum(['system', 'light', 'dark']),
|
||||
session: z.object({
|
||||
accounts: z.array(accountSchema),
|
||||
currentAccount: accountSchema.optional(),
|
||||
}),
|
||||
reminders: z.object({
|
||||
lastEmailConfirmReminder: z.string().optional(),
|
||||
}),
|
||||
languagePrefs: z.object({
|
||||
primaryLanguage: z.string(), // should move to server
|
||||
contentLanguages: z.array(z.string()), // should move to server
|
||||
postLanguage: z.string(), // should move to server
|
||||
postLanguageHistory: z.array(z.string()),
|
||||
}),
|
||||
requireAltTextEnabled: z.boolean(), // should move to server
|
||||
mutedThreads: z.array(z.string()), // should move to server
|
||||
invitedUsers: z.object({
|
||||
seenDids: z.array(z.string()),
|
||||
copiedInvites: z.array(z.string()),
|
||||
}),
|
||||
onboarding: z.object({
|
||||
step: z.string(),
|
||||
}),
|
||||
})
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
export const defaults: Schema = {
|
||||
colorMode: 'system',
|
||||
session: {
|
||||
accounts: [],
|
||||
currentAccount: undefined,
|
||||
},
|
||||
reminders: {
|
||||
lastEmailConfirmReminder: undefined,
|
||||
},
|
||||
languagePrefs: {
|
||||
primaryLanguage: deviceLocales[0] || 'en',
|
||||
contentLanguages: deviceLocales || [],
|
||||
postLanguage: deviceLocales[0] || 'en',
|
||||
postLanguageHistory: (deviceLocales || [])
|
||||
.concat(['en', 'ja', 'pt', 'de'])
|
||||
.slice(0, 6),
|
||||
},
|
||||
requireAltTextEnabled: false,
|
||||
mutedThreads: [],
|
||||
invitedUsers: {
|
||||
seenDids: [],
|
||||
copiedInvites: [],
|
||||
},
|
||||
onboarding: {
|
||||
step: 'Home',
|
||||
},
|
||||
}
|
18
src/state/persisted/store.ts
Normal file
18
src/state/persisted/store.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {Schema, schema} from '#/state/persisted/schema'
|
||||
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
export async function write(value: Schema) {
|
||||
schema.parse(value)
|
||||
await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value))
|
||||
}
|
||||
|
||||
export async function read(): Promise<Schema | undefined> {
|
||||
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||
const objData = rawData ? JSON.parse(rawData) : undefined
|
||||
if (schema.safeParse(objData).success) {
|
||||
return objData
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue