From 2a11929f00dafcd2526e682859dbd09174713b89 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 11:21:09 -0600 Subject: [PATCH] Add persistent state provider --- src/App.native.tsx | 30 ++++- src/App.web.tsx | 32 +++++- src/state/persisted/broadcast/index.ts | 6 + src/state/persisted/broadcast/index.web.ts | 1 + src/state/persisted/index.tsx | 112 +++++++++++++++++++ src/state/persisted/legacy.ts | 122 +++++++++++++++++++++ src/state/persisted/schema.ts | 59 ++++++++++ src/state/persisted/store.ts | 14 +++ src/view/screens/Settings.tsx | 14 ++- 9 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 src/state/persisted/broadcast/index.ts create mode 100644 src/state/persisted/broadcast/index.web.ts create mode 100644 src/state/persisted/index.tsx create mode 100644 src/state/persisted/legacy.ts create mode 100644 src/state/persisted/schema.ts create mode 100644 src/state/persisted/store.ts diff --git a/src/App.native.tsx b/src/App.native.tsx index 3250ea563b..3c0896d354 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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' @@ -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( undefined, ) @@ -45,7 +53,7 @@ const App = observer(function AppImpl() { } return ( - + @@ -61,4 +69,22 @@ const App = observer(function AppImpl() { ) }) +function App() { + const [persistedState, setPersistedState] = useState(initialPersistedState) + + React.useEffect(() => { + initPersistedState().then(setPersistedState) + }, []) + + if (!persistedState) { + return null + } + + return ( + + + + ) +} + export default App diff --git a/src/App.web.tsx b/src/App.web.tsx index 3b67af0dcd..ebc1707d1a 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -8,6 +8,13 @@ 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' @@ -15,7 +22,8 @@ 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( undefined, ) @@ -35,7 +43,7 @@ const App = observer(function AppImpl() { return ( - + @@ -51,4 +59,24 @@ const App = observer(function AppImpl() { ) }) +function App() { + const [persistedState, setPersistedState] = useState( + initialPersistedState, + ) + + React.useEffect(() => { + initPersistedState().then(setPersistedState) + }, []) + + if (!persistedState) { + return null + } + + return ( + + + + ) +} + export default App diff --git a/src/state/persisted/broadcast/index.ts b/src/state/persisted/broadcast/index.ts new file mode 100644 index 0000000000..e0e7f724b7 --- /dev/null +++ b/src/state/persisted/broadcast/index.ts @@ -0,0 +1,6 @@ +export default class BroadcastChannel { + constructor(public name: string) {} + postMessage(_data: any) {} + close() {} + onmessage: (event: MessageEvent) => void = () => {} +} diff --git a/src/state/persisted/broadcast/index.web.ts b/src/state/persisted/broadcast/index.web.ts new file mode 100644 index 0000000000..33b3548ad3 --- /dev/null +++ b/src/state/persisted/broadcast/index.web.ts @@ -0,0 +1 @@ +export default BroadcastChannel diff --git a/src/state/persisted/index.tsx b/src/state/persisted/index.tsx new file mode 100644 index 0000000000..e34b12d036 --- /dev/null +++ b/src/state/persisted/index.tsx @@ -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(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 {children} +} + +export function usePersisted() { + return React.useContext(context) +} diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts new file mode 100644 index 0000000000..68cebd1ef6 --- /dev/null +++ b/src/state/persisted/legacy.ts @@ -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}) + } +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts new file mode 100644 index 0000000000..bc50a96164 --- /dev/null +++ b/src/state/persisted/schema.ts @@ -0,0 +1,59 @@ +import {deviceLocales} from '#/platform/detection' + +// only data needed for rendering account page +type Account = { + service: string + did: `did:plc:${string}` + refreshJwt: string + accessJwt: string + handle: string + displayName: string + aviUrl: string +} + +export type Schema = { + colorMode: 'system' | 'light' | 'dark' + accounts: Account[] + currentAccount: Account | undefined + lastEmailConfirmReminder: string | undefined + + // preferences + primaryLanguage: string // should move to server + contentLanguages: string[] // should move to server + postLanguage: string // should move to server + postLanguageHistory: string[] // should move to server + requireAltTextEnabled: boolean // should move to server + mutedThreads: string[] // should move to server + + // should move to server? + invitedUsers: { + seenDids: string[] + copiedInvites: string[] + } + + onboarding: { + step: string + } +} + +export const schema: Schema = { + colorMode: 'system', + accounts: [], + currentAccount: undefined, + lastEmailConfirmReminder: undefined, + 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', + }, +} diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts new file mode 100644 index 0000000000..9e35222939 --- /dev/null +++ b/src/state/persisted/store.ts @@ -0,0 +1,14 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import {Schema} from '#/state/persisted/schema' + +const BSKY_STORAGE = 'BSKY_STORAGE' + +export async function write(data: Schema) { + await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(data)) +} + +export async function read(): Promise { + const rawData = await AsyncStorage.getItem(BSKY_STORAGE) + return rawData ? JSON.parse(rawData) : undefined +} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 3f498ba85a..fb40c076df 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -46,6 +46,7 @@ import Clipboard from '@react-native-clipboard/clipboard' import {makeProfileLink} from 'lib/routes/links' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' import {logger} from '#/logger' +import {usePersisted} from '#/state/persisted' // TEMPORARY (APP-700) // remove after backend testing finishes @@ -56,6 +57,7 @@ import {STATUS_PAGE_URL} from 'lib/constants' type Props = NativeStackScreenProps export const SettingsScreen = withAuthRequired( observer(function Settings({}: Props) { + const persisted = usePersisted() const pal = usePalette('default') const store = useStores() const navigation = useNavigation() @@ -377,23 +379,23 @@ export const SettingsScreen = withAuthRequired( store.shell.setColorMode('system')} + onSelect={() => persisted.setColorMode('system')} accessibilityHint="Set color theme to system setting" /> store.shell.setColorMode('light')} + onSelect={() => persisted.setColorMode('light')} accessibilityHint="Set color theme to light" /> store.shell.setColorMode('dark')} + onSelect={() => persisted.setColorMode('dark')} accessibilityHint="Set color theme to dark" />