Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add persistent state provider #1830

Merged
merged 11 commits into from
Nov 7, 2023
51 changes: 35 additions & 16 deletions src/App.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {QueryClientProvider} from '@tanstack/react-query'

import 'view/icons'

import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
Expand All @@ -23,7 +25,8 @@ import {Provider as ShellStateProvider} from 'state/shell'

SplashScreen.preventAutoHideAsync()

const App = observer(function AppImpl() {
const InnerApp = observer(function AppImpl() {
const colorMode = useColorMode()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
Expand All @@ -44,24 +47,40 @@ const App = observer(function AppImpl() {
if (!rootStore) {
return null
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
)
})

function App() {
const [isReady, setReady] = useState(false)

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

if (!isReady) {
return null
}

return (
<ShellStateProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
<InnerApp />
</ShellStateProvider>
)
})
}

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

import 'view/icons'

import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import * as analytics from 'lib/analytics/analytics'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from 'view/shell/index'
Expand All @@ -16,7 +18,8 @@ import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query'
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>(
undefined,
)
Expand All @@ -34,24 +37,40 @@ const App = observer(function AppImpl() {
return null
}

return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<ToastContainer />
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
)
})

function App() {
const [isReady, setReady] = useState(false)

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

if (!isReady) {
return null
}

return (
<ShellStateProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<ToastContainer />
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
<InnerApp />
</ShellStateProvider>
)
})
}

export default App
4 changes: 0 additions & 4 deletions src/state/models/root-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export class RootStoreModel {
session: this.session.serialize(),
me: this.me.serialize(),
onboarding: this.onboarding.serialize(),
shell: this.shell.serialize(),
preferences: this.preferences.serialize(),
invitedUsers: this.invitedUsers.serialize(),
mutedThreads: this.mutedThreads.serialize(),
Expand All @@ -99,9 +98,6 @@ export class RootStoreModel {
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
if (hasProp(v, 'shell')) {
this.shell.hydrate(v.shell)
}
if (hasProp(v, 'preferences')) {
this.preferences.hydrate(v.preferences)
}
Expand Down
30 changes: 0 additions & 30 deletions src/state/models/ui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {makeAutoObservable, runInAction} from 'mobx'
import {ProfileModel} from '../content/profile'
import {isObj, hasProp} from 'lib/type-guards'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '../media/image'
import {ListModel} from '../content/list'
import {GalleryModel} from '../media/gallery'
import {StyleProp, ViewStyle} from 'react-native'
import {isWeb} from 'platform/detection'

export type ColorMode = 'system' | 'light' | 'dark'

Expand Down Expand Up @@ -265,7 +263,6 @@ export interface ComposerOpts {
}

export class ShellUiModel {
colorMode: ColorMode = 'system'
isModalActive = false
activeModals: Modal[] = []
isLightboxActive = false
Expand All @@ -276,40 +273,13 @@ export class ShellUiModel {

constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
serialize: false,
rootStore: false,
hydrate: false,
})

this.setupClock()
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
* (used by the android hardware back btn)
Expand Down
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
91 changes: 91 additions & 0 deletions src/state/persisted/index.ts
Original file line number Diff line number Diff line change
@@ -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,
},
)
}
}
}
Loading