Skip to content

Commit

Permalink
Add persistent state provider
Browse files Browse the repository at this point in the history
  • Loading branch information
estrattonbailey committed Nov 7, 2023
1 parent 7b2a7db commit 2a11929
Show file tree
Hide file tree
Showing 9 changed files with 380 additions and 10 deletions.
30 changes: 28 additions & 2 deletions src/App.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import {QueryClientProvider} from '@tanstack/react-query'

import 'view/icons'

import {
Schema,
schema as initialPersistedState,
Provider as PersistedStateProvider,
init as initPersistedState,
usePersisted,
} from '#/state/persisted'
import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
Expand All @@ -22,7 +29,8 @@ import {TestCtrls} from 'view/com/testing/TestCtrls'

SplashScreen.preventAutoHideAsync()

const App = observer(function AppImpl() {
const InnerApp = observer(function AppImpl() {
const persisted = usePersisted()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
Expand All @@ -45,7 +53,7 @@ const App = observer(function AppImpl() {
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<ThemeProvider theme={persisted.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
Expand All @@ -61,4 +69,22 @@ const App = observer(function AppImpl() {
)
})

function App() {
const [persistedState, setPersistedState] = useState<Schema>(initialPersistedState)

React.useEffect(() => {
initPersistedState().then(setPersistedState)
}, [])

if (!persistedState) {
return null
}

return (
<PersistedStateProvider data={persistedState}>
<InnerApp />
</PersistedStateProvider>
)
}

export default App
32 changes: 30 additions & 2 deletions src/App.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,22 @@ import {RootSiblingParent} from 'react-native-root-siblings'

import 'view/icons'

import {
Schema,
schema as initialPersistedState,
Provider as PersistedStateProvider,
init as initPersistedState,
usePersisted,
} from '#/state/persisted'
import * as analytics from 'lib/analytics/analytics'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from 'view/shell/index'
import {ToastContainer} from 'view/com/util/Toast.web'
import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query'

const App = observer(function AppImpl() {
const InnerApp = observer(function AppImpl() {
const persisted = usePersisted()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
Expand All @@ -35,7 +43,7 @@ const App = observer(function AppImpl() {

return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<ThemeProvider theme={persisted.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
Expand All @@ -51,4 +59,24 @@ const App = observer(function AppImpl() {
)
})

function App() {
const [persistedState, setPersistedState] = useState<Schema>(
initialPersistedState,
)

React.useEffect(() => {
initPersistedState().then(setPersistedState)
}, [])

if (!persistedState) {
return null
}

return (
<PersistedStateProvider data={persistedState}>
<InnerApp />
</PersistedStateProvider>
)
}

export default App
6 changes: 6 additions & 0 deletions src/state/persisted/broadcast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default class BroadcastChannel {
constructor(public name: string) {}
postMessage(_data: any) {}
close() {}
onmessage: (event: MessageEvent) => void = () => {}
}
1 change: 1 addition & 0 deletions src/state/persisted/broadcast/index.web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default BroadcastChannel
112 changes: 112 additions & 0 deletions src/state/persisted/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React from 'react'

import {logger} from '#/logger'
import {schema, 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 {schema} from '#/state/persisted/schema'

const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
const UPDATE_EVENT = 'BSKY_UPDATE'

/**
* Initializes and returns persisted data state, so that it can be passed to
* the Provider.
*/
export async function init() {
logger.debug('persisted state: initializing')

try {
await migrate() // migrate old store
const stored = await store.read() // check for new store
if (!stored) await store.write(schema) // opt: init new store
return stored || schema // 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 schema
}
}

const context = React.createContext<
Schema & {
setColorMode: (colorMode: Schema['colorMode']) => void
}
>({
...schema,
setColorMode: () => {},
})

export function Provider({
data,
children,
}: React.PropsWithChildren<{data: Schema}>) {
const [state, setState] = React.useState<Schema>(data)

React.useEffect(() => {
broadcast.onmessage = async ({data}) => {
// validate event
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
// read next state, possibly updated by another tab
const next = await store.read()

if (next) {
logger.debug(
`persisted state: handling update from broadcast channel`,
)
setState(next)
} else {
logger.error(
`persisted state: received update but no data found in storage`,
)
}
}
}

return () => {
broadcast.close()
}
}, [setState])

/**
* Commit a complete state object to storage, and broadcast to other tabs
* that an update has occurred.
*/
const _write = React.useCallback(async (next: Schema) => {
await store.write(next)
// 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`)
}, [])

/*
* Other state methods go below
*/

const setColorMode = React.useCallback(
(colorMode: Schema['colorMode']) => {
setState(s => {
const next = {...s, colorMode}
_write(next)
return next
})
},
[_write, setState],
)

const ctx = {
...state,
setColorMode,
}

return <context.Provider value={ctx}>{children}</context.Provider>
}

export function usePersisted() {
return React.useContext(context)
}
122 changes: 122 additions & 0 deletions src/state/persisted/legacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import AsyncStorage from '@react-native-async-storage/async-storage'

import {logger} from '#/logger'
import {schema, 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 || schema.colorMode,
accounts: legacy.session.accounts || schema.accounts,
currentAccount:
legacy.session.accounts.find(a => a.did === legacy.session.data.did) ||
schema.currentAccount,
lastEmailConfirmReminder:
legacy.reminders.lastEmailConfirm || schema.lastEmailConfirmReminder,
primaryLanguage:
legacy.preferences.primaryLanguage || schema.primaryLanguage,
contentLanguages:
legacy.preferences.contentLanguages || schema.contentLanguages,
postLanguage: legacy.preferences.postLanguage || schema.postLanguage,
postLanguageHistory:
legacy.preferences.postLanguageHistory || schema.postLanguageHistory,
requireAltTextEnabled:
legacy.preferences.requireAltTextEnabled || schema.requireAltTextEnabled,
mutedThreads: legacy.mutedThreads.uris || schema.mutedThreads,
invitedUsers: {
seenDids: legacy.invitedUsers.seenDids || schema.invitedUsers.seenDids,
copiedInvites:
legacy.invitedUsers.copiedInvites || schema.invitedUsers.copiedInvites,
},
onboarding: {
step: legacy.onboarding.step || schema.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 store')
const legacyData = JSON.parse(rawLegacyData)
const newData = transform(legacyData)
await write(newData)
logger.debug('persisted state: migrated legacy store')
}
} catch (e) {
logger.error('persisted state: error migrating legacy store', {error: e})
}
}
Loading

0 comments on commit 2a11929

Please sign in to comment.