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>
zio/stable
Eric Bailey 2023-11-07 16:06:17 -06:00 committed by GitHub
parent bfe196bac5
commit 96d8faf4b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 467 additions and 76 deletions

View File

@ -10,6 +10,8 @@ import {QueryClientProvider} from '@tanstack/react-query'
import 'view/icons' import 'view/icons'
import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import {ThemeProvider} from 'lib/ThemeContext' import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {RootStoreModel, setupState, RootStoreProvider} from './state' import {RootStoreModel, setupState, RootStoreProvider} from './state'
@ -23,7 +25,8 @@ import {Provider as ShellStateProvider} from 'state/shell'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
const App = observer(function AppImpl() { const InnerApp = observer(function AppImpl() {
const colorMode = useColorMode()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined, undefined,
) )
@ -45,23 +48,39 @@ const App = observer(function AppImpl() {
return null return null
} }
return ( return (
<ShellStateProvider> <QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}> <ThemeProvider theme={colorMode}>
<ThemeProvider theme={rootStore.shell.colorMode}> <RootSiblingParent>
<RootSiblingParent> <analytics.Provider>
<analytics.Provider> <RootStoreProvider value={rootStore}>
<RootStoreProvider value={rootStore}> <GestureHandlerRootView style={s.h100pct}>
<GestureHandlerRootView style={s.h100pct}> <TestCtrls />
<TestCtrls /> <Shell />
<Shell /> </GestureHandlerRootView>
</GestureHandlerRootView> </RootStoreProvider>
</RootStoreProvider> </analytics.Provider>
</analytics.Provider> </RootSiblingParent>
</RootSiblingParent> </ThemeProvider>
</ThemeProvider> </QueryClientProvider>
</QueryClientProvider>
</ShellStateProvider>
) )
}) })
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])
if (!isReady) {
return null
}
return (
<ShellStateProvider>
<InnerApp />
</ShellStateProvider>
)
}
export default App export default App

View File

@ -8,6 +8,8 @@ import {RootSiblingParent} from 'react-native-root-siblings'
import 'view/icons' import 'view/icons'
import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import * as analytics from 'lib/analytics/analytics' import * as analytics from 'lib/analytics/analytics'
import {RootStoreModel, setupState, RootStoreProvider} from './state' import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from 'view/shell/index' import {Shell} from 'view/shell/index'
@ -16,7 +18,8 @@ import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query' import {queryClient} from 'lib/react-query'
import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ShellStateProvider} from 'state/shell'
const App = observer(function AppImpl() { const InnerApp = observer(function AppImpl() {
const colorMode = useColorMode()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined, undefined,
) )
@ -35,23 +38,39 @@ const App = observer(function AppImpl() {
} }
return ( return (
<ShellStateProvider> <QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}> <ThemeProvider theme={colorMode}>
<ThemeProvider theme={rootStore.shell.colorMode}> <RootSiblingParent>
<RootSiblingParent> <analytics.Provider>
<analytics.Provider> <RootStoreProvider value={rootStore}>
<RootStoreProvider value={rootStore}> <SafeAreaProvider>
<SafeAreaProvider> <Shell />
<Shell /> </SafeAreaProvider>
</SafeAreaProvider> <ToastContainer />
<ToastContainer /> </RootStoreProvider>
</RootStoreProvider> </analytics.Provider>
</analytics.Provider> </RootSiblingParent>
</RootSiblingParent> </ThemeProvider>
</ThemeProvider> </QueryClientProvider>
</QueryClientProvider>
</ShellStateProvider>
) )
}) })
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])
if (!isReady) {
return null
}
return (
<ShellStateProvider>
<InnerApp />
</ShellStateProvider>
)
}
export default App export default App

View File

@ -74,7 +74,6 @@ export class RootStoreModel {
session: this.session.serialize(), session: this.session.serialize(),
me: this.me.serialize(), me: this.me.serialize(),
onboarding: this.onboarding.serialize(), onboarding: this.onboarding.serialize(),
shell: this.shell.serialize(),
preferences: this.preferences.serialize(), preferences: this.preferences.serialize(),
invitedUsers: this.invitedUsers.serialize(), invitedUsers: this.invitedUsers.serialize(),
mutedThreads: this.mutedThreads.serialize(), mutedThreads: this.mutedThreads.serialize(),
@ -99,9 +98,6 @@ export class RootStoreModel {
if (hasProp(v, 'session')) { if (hasProp(v, 'session')) {
this.session.hydrate(v.session) this.session.hydrate(v.session)
} }
if (hasProp(v, 'shell')) {
this.shell.hydrate(v.shell)
}
if (hasProp(v, 'preferences')) { if (hasProp(v, 'preferences')) {
this.preferences.hydrate(v.preferences) this.preferences.hydrate(v.preferences)
} }

View File

@ -2,13 +2,11 @@ import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {ProfileModel} from '../content/profile' import {ProfileModel} from '../content/profile'
import {isObj, hasProp} from 'lib/type-guards'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '../media/image' import {ImageModel} from '../media/image'
import {ListModel} from '../content/list' import {ListModel} from '../content/list'
import {GalleryModel} from '../media/gallery' import {GalleryModel} from '../media/gallery'
import {StyleProp, ViewStyle} from 'react-native' import {StyleProp, ViewStyle} from 'react-native'
import {isWeb} from 'platform/detection'
export type ColorMode = 'system' | 'light' | 'dark' export type ColorMode = 'system' | 'light' | 'dark'
@ -265,7 +263,6 @@ export interface ComposerOpts {
} }
export class ShellUiModel { export class ShellUiModel {
colorMode: ColorMode = 'system'
isModalActive = false isModalActive = false
activeModals: Modal[] = [] activeModals: Modal[] = []
isLightboxActive = false isLightboxActive = false
@ -276,40 +273,13 @@ export class ShellUiModel {
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, { makeAutoObservable(this, {
serialize: false,
rootStore: false, rootStore: false,
hydrate: false,
}) })
this.setupClock() this.setupClock()
this.setupLoginModals() this.setupLoginModals()
} }
serialize(): unknown {
return {
colorMode: this.colorMode,
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'colorMode') && isColorMode(v.colorMode)) {
this.setColorMode(v.colorMode)
}
}
}
setColorMode(mode: ColorMode) {
this.colorMode = mode
if (isWeb && typeof window !== 'undefined') {
const html = window.document.documentElement
// remove any other color mode classes
html.className = html.className.replace(/colorMode--\w+/g, '')
html.classList.add(`colorMode--${mode}`)
}
}
/** /**
* returns true if something was closed * returns true if something was closed
* (used by the android hardware back btn) * (used by the android hardware back btn)

View File

@ -0,0 +1,6 @@
export default class BroadcastChannel {
constructor(public name: string) {}
postMessage(_data: any) {}
close() {}
onmessage: (event: MessageEvent) => void = () => {}
}

View File

@ -0,0 +1 @@
export default BroadcastChannel

View 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,
},
)
}
}
}

View 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),
})
}
}

View 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',
},
}

View 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
}
}

View File

@ -0,0 +1,56 @@
import React from 'react'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['colorMode']
type SetContext = (v: persisted.Schema['colorMode']) => void
const stateContext = React.createContext<StateContext>('system')
const setContext = React.createContext<SetContext>(
(_: persisted.Schema['colorMode']) => {},
)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('colorMode'))
const setStateWrapped = React.useCallback(
(colorMode: persisted.Schema['colorMode']) => {
setState(colorMode)
persisted.write('colorMode', colorMode)
updateDocument(colorMode)
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('colorMode'))
updateDocument(persisted.get('colorMode'))
})
}, [setStateWrapped])
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}
export function useColorMode() {
return React.useContext(stateContext)
}
export function useSetColorMode() {
return React.useContext(setContext)
}
function updateDocument(colorMode: string) {
if (isWeb && typeof window !== 'undefined') {
const html = window.document.documentElement
// remove any other color mode classes
html.className = html.className.replace(/colorMode--\w+/g, '')
html.classList.add(`colorMode--${colorMode}`)
}
}

View File

@ -2,6 +2,7 @@ import React from 'react'
import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerOpenProvider} from './drawer-open'
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
import {Provider as MinimalModeProvider} from './minimal-mode' import {Provider as MinimalModeProvider} from './minimal-mode'
import {Provider as ColorModeProvider} from './color-mode'
export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open'
export { export {
@ -9,12 +10,15 @@ export {
useSetDrawerSwipeDisabled, useSetDrawerSwipeDisabled,
} from './drawer-swipe-disabled' } from './drawer-swipe-disabled'
export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode'
export {useColorMode, useSetColorMode} from './color-mode'
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
return ( return (
<DrawerOpenProvider> <DrawerOpenProvider>
<DrawerSwipableProvider> <DrawerSwipableProvider>
<MinimalModeProvider>{children}</MinimalModeProvider> <MinimalModeProvider>
<ColorModeProvider>{children}</ColorModeProvider>
</MinimalModeProvider>
</DrawerSwipableProvider> </DrawerSwipableProvider>
</DrawerOpenProvider> </DrawerOpenProvider>
) )

View File

@ -46,7 +46,11 @@ import Clipboard from '@react-native-clipboard/clipboard'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell' import {
useSetMinimalShellMode,
useColorMode,
useSetColorMode,
} from '#/state/shell'
// TEMPORARY (APP-700) // TEMPORARY (APP-700)
// remove after backend testing finishes // remove after backend testing finishes
@ -57,6 +61,8 @@ import {STATUS_PAGE_URL} from 'lib/constants'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export const SettingsScreen = withAuthRequired( export const SettingsScreen = withAuthRequired(
observer(function Settings({}: Props) { observer(function Settings({}: Props) {
const colorMode = useColorMode()
const setColorMode = useSetColorMode()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
@ -379,23 +385,23 @@ export const SettingsScreen = withAuthRequired(
<View> <View>
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}> <View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
<SelectableBtn <SelectableBtn
selected={store.shell.colorMode === 'system'} selected={colorMode === 'system'}
label="System" label="System"
left left
onSelect={() => store.shell.setColorMode('system')} onSelect={() => setColorMode('system')}
accessibilityHint="Set color theme to system setting" accessibilityHint="Set color theme to system setting"
/> />
<SelectableBtn <SelectableBtn
selected={store.shell.colorMode === 'light'} selected={colorMode === 'light'}
label="Light" label="Light"
onSelect={() => store.shell.setColorMode('light')} onSelect={() => setColorMode('light')}
accessibilityHint="Set color theme to light" accessibilityHint="Set color theme to light"
/> />
<SelectableBtn <SelectableBtn
selected={store.shell.colorMode === 'dark'} selected={colorMode === 'dark'}
label="Dark" label="Dark"
right right
onSelect={() => store.shell.setColorMode('dark')} onSelect={() => setColorMode('dark')}
accessibilityHint="Set color theme to dark" accessibilityHint="Set color theme to dark"
/> />
</View> </View>