From 2a11929f00dafcd2526e682859dbd09174713b89 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 11:21:09 -0600 Subject: [PATCH 01/10] 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" /> From bcfb52c158289dca8bf408b1949ded0475e71bba Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 11:34:58 -0600 Subject: [PATCH 02/10] Catch write error --- src/state/persisted/index.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/state/persisted/index.tsx b/src/state/persisted/index.tsx index e34b12d036..f6235c0856 100644 --- a/src/state/persisted/index.tsx +++ b/src/state/persisted/index.tsx @@ -78,10 +78,16 @@ export function Provider({ * 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`) + try { + 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`) + } catch (e) { + logger.error(`persisted state: failed to write root state to storage`, { + error: e, + }) + } }, []) /* From 2ef5db994b1828d0152dfe0c0cac6c4ba36400ac Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 11:38:25 -0600 Subject: [PATCH 03/10] Handle read errors, update error msgs --- src/state/persisted/index.tsx | 31 ++++++++++++++++++++----------- src/state/persisted/legacy.ts | 6 +++--- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/state/persisted/index.tsx b/src/state/persisted/index.tsx index f6235c0856..867b643500 100644 --- a/src/state/persisted/index.tsx +++ b/src/state/persisted/index.tsx @@ -52,17 +52,26 @@ export function Provider({ 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 { + 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`, + ) + setState(next) + } else { + logger.error( + `persisted state: handled update update from broadcast channel, but found no data`, + ) + } + } catch (e) { logger.error( - `persisted state: received update but no data found in storage`, + `persisted state: failed handling update from broadcast channel`, + { + error: e, + }, ) } } @@ -84,7 +93,7 @@ export function Provider({ setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) logger.debug(`persisted state: wrote root state to storage`) } catch (e) { - logger.error(`persisted state: failed to write root state to storage`, { + logger.error(`persisted state: failed writing root state to storage`, { error: e, }) } diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index 68cebd1ef6..55e599a0a2 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -110,13 +110,13 @@ export async function migrate() { const alreadyMigrated = Boolean(await read()) if (!alreadyMigrated && rawLegacyData) { - logger.debug('persisted state: migrating legacy store') + 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 store') + logger.debug('persisted state: migrated legacy storage') } } catch (e) { - logger.error('persisted state: error migrating legacy store', {error: e}) + logger.error('persisted state: error migrating legacy storage', {error: e}) } } From 2b1986c29557992f97f9016b3a300ce12df7242d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 11:51:37 -0600 Subject: [PATCH 04/10] Fix lint --- src/App.native.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index 3c0896d354..2c6aeaed22 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -70,7 +70,9 @@ const InnerApp = observer(function AppImpl() { }) function App() { - const [persistedState, setPersistedState] = useState(initialPersistedState) + const [persistedState, setPersistedState] = useState( + initialPersistedState, + ) React.useEffect(() => { initPersistedState().then(setPersistedState) From c5e2a63bbfd3fd6ff5ffcdc3f2d9d25e47bcd2f3 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 13:41:50 -0600 Subject: [PATCH 05/10] Don't provide initial state to loader --- src/App.native.tsx | 5 +---- src/App.web.tsx | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index 2c6aeaed22..b4748930ea 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -12,7 +12,6 @@ import 'view/icons' import { Schema, - schema as initialPersistedState, Provider as PersistedStateProvider, init as initPersistedState, usePersisted, @@ -70,9 +69,7 @@ const InnerApp = observer(function AppImpl() { }) function App() { - const [persistedState, setPersistedState] = useState( - initialPersistedState, - ) + const [persistedState, setPersistedState] = useState() React.useEffect(() => { initPersistedState().then(setPersistedState) diff --git a/src/App.web.tsx b/src/App.web.tsx index ebc1707d1a..2e0814c745 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -10,7 +10,6 @@ import 'view/icons' import { Schema, - schema as initialPersistedState, Provider as PersistedStateProvider, init as initPersistedState, usePersisted, @@ -60,9 +59,7 @@ const InnerApp = observer(function AppImpl() { }) function App() { - const [persistedState, setPersistedState] = useState( - initialPersistedState, - ) + const [persistedState, setPersistedState] = useState() React.useEffect(() => { initPersistedState().then(setPersistedState) From 081ce0cbbf0f515b33ca119f96a1f1df65500195 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 13:58:08 -0600 Subject: [PATCH 06/10] Remove colorMode from shell state --- src/state/models/root-store.ts | 4 ---- src/state/models/ui/shell.ts | 30 ------------------------------ src/state/persisted/index.tsx | 8 ++++++++ 3 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index cf7307ca31..1943f6dbc1 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -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(), @@ -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) } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index d690b9331a..d39131629e 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -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' @@ -265,7 +263,6 @@ export interface ComposerOpts { } export class ShellUiModel { - colorMode: ColorMode = 'system' isModalActive = false activeModals: Modal[] = [] isLightboxActive = false @@ -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) diff --git a/src/state/persisted/index.tsx b/src/state/persisted/index.tsx index 867b643500..81cbfdb31d 100644 --- a/src/state/persisted/index.tsx +++ b/src/state/persisted/index.tsx @@ -5,6 +5,7 @@ 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' +import {isWeb} from '#/platform/detection' export type {Schema} from '#/state/persisted/schema' export {schema} from '#/state/persisted/schema' @@ -110,6 +111,13 @@ export function Provider({ _write(next) return next }) + + 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}`) + } }, [_write, setState], ) From d9d7b7851a7f9132bcdfe84532cc5c77f11f1f6f Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 14:09:31 -0600 Subject: [PATCH 07/10] Idea: hook into persisted context from other files --- src/state/colorMode/index.ts | 32 +++++++++++++++++++++++++ src/state/persisted/index.tsx | 45 +++++++++++++---------------------- 2 files changed, 48 insertions(+), 29 deletions(-) create mode 100644 src/state/colorMode/index.ts diff --git a/src/state/colorMode/index.ts b/src/state/colorMode/index.ts new file mode 100644 index 0000000000..26c3bc0129 --- /dev/null +++ b/src/state/colorMode/index.ts @@ -0,0 +1,32 @@ +import React from 'react' + +import {isWeb} from '#/platform/detection' +import { + Schema, + PersistedContext, + PersistedSetStateContext, +} from '#/state/persisted' + +export function useColorMode() { + const {colorMode} = React.useContext(PersistedContext) + const {setState} = React.useContext(PersistedSetStateContext) + + const setColorMode = React.useCallback( + (colorMode: Schema['colorMode']) => { + setState(s => ({ + ...s, + colorMode, + })) + + 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}`) + } + }, + [setState], + ) + + return {colorMode, setColorMode} +} diff --git a/src/state/persisted/index.tsx b/src/state/persisted/index.tsx index 81cbfdb31d..baf1c59f70 100644 --- a/src/state/persisted/index.tsx +++ b/src/state/persisted/index.tsx @@ -5,7 +5,6 @@ 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' -import {isWeb} from '#/platform/detection' export type {Schema} from '#/state/persisted/schema' export {schema} from '#/state/persisted/schema' @@ -34,13 +33,11 @@ export async function init() { } } -const context = React.createContext< - Schema & { - setColorMode: (colorMode: Schema['colorMode']) => void - } ->({ - ...schema, - setColorMode: () => {}, +export const PersistedContext = React.createContext(schema) +export const PersistedSetStateContext = React.createContext<{ + setState: (fn: (prevState: Schema) => Schema) => void +}>({ + setState: () => {}, }) export function Provider({ @@ -100,36 +97,26 @@ export function Provider({ } }, []) - /* - * Other state methods go below - */ - - const setColorMode = React.useCallback( - (colorMode: Schema['colorMode']) => { + const _setState = React.useCallback( + (fn: (prevState: Schema) => Schema) => { setState(s => { - const next = {...s, colorMode} + const next = fn(s) _write(next) return next }) - - 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}`) - } }, [_write, setState], ) - const ctx = { - ...state, - setColorMode, - } - - return {children} + return ( + + + {children} + + + ) } export function usePersisted() { - return React.useContext(context) + return React.useContext(PersistedContext) } From 8b0a05c207f400253f82b44b1f6d1a6863d61cc3 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 14:16:44 -0600 Subject: [PATCH 08/10] Migrate settings to new hook --- src/view/screens/Settings.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 1970b7d4c3..620b6a345a 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -46,7 +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' +import {useColorMode} from '#/state/colorMode' import {useSetMinimalShellMode} from '#/state/shell' // TEMPORARY (APP-700) @@ -58,7 +58,7 @@ import {STATUS_PAGE_URL} from 'lib/constants' type Props = NativeStackScreenProps export const SettingsScreen = withAuthRequired( observer(function Settings({}: Props) { - const persisted = usePersisted() + const {colorMode, setColorMode} = useColorMode() const pal = usePalette('default') const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() @@ -381,23 +381,23 @@ export const SettingsScreen = withAuthRequired( persisted.setColorMode('system')} + onSelect={() => setColorMode('system')} accessibilityHint="Set color theme to system setting" /> persisted.setColorMode('light')} + onSelect={() => setColorMode('light')} accessibilityHint="Set color theme to light" /> persisted.setColorMode('dark')} + onSelect={() => setColorMode('dark')} accessibilityHint="Set color theme to dark" /> From b6adb001fc5740a72120418e807e528c865c2cb4 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 7 Nov 2023 13:44:29 -0800 Subject: [PATCH 09/10] Rework persisted state to split individual contexts --- src/App.native.tsx | 26 +++---- src/App.web.tsx | 26 +++---- src/state/colorMode/index.ts | 32 --------- src/state/persisted/index.ts | 91 ++++++++++++++++++++++++ src/state/persisted/index.tsx | 122 --------------------------------- src/state/persisted/legacy.ts | 30 ++++---- src/state/persisted/schema.ts | 60 ++++++++-------- src/state/persisted/store.ts | 11 +-- src/state/shell/color-mode.tsx | 56 +++++++++++++++ src/state/shell/index.tsx | 6 +- src/view/screens/Settings.tsx | 10 ++- 11 files changed, 233 insertions(+), 237 deletions(-) delete mode 100644 src/state/colorMode/index.ts create mode 100644 src/state/persisted/index.ts delete mode 100644 src/state/persisted/index.tsx create mode 100644 src/state/shell/color-mode.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index 653649bb29..f5d35cf741 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -10,12 +10,8 @@ import {QueryClientProvider} from '@tanstack/react-query' import 'view/icons' -import { - Schema, - Provider as PersistedStateProvider, - init as initPersistedState, - usePersisted, -} from '#/state/persisted' +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' @@ -30,7 +26,7 @@ import {Provider as ShellStateProvider} from 'state/shell' SplashScreen.preventAutoHideAsync() const InnerApp = observer(function AppImpl() { - const persisted = usePersisted() + const colorMode = useColorMode() const [rootStore, setRootStore] = useState( undefined, ) @@ -53,7 +49,7 @@ const InnerApp = observer(function AppImpl() { } return ( - + @@ -70,22 +66,20 @@ const InnerApp = observer(function AppImpl() { }) function App() { - const [persistedState, setPersistedState] = useState() + const [isReady, setReady] = useState(false) React.useEffect(() => { - initPersistedState().then(setPersistedState) + initPersistedState().then(() => setReady(true)) }, []) - if (!persistedState) { + if (!isReady) { return null } return ( - - - - - + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index 32ee8ce471..adad9ddb6a 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -8,12 +8,8 @@ import {RootSiblingParent} from 'react-native-root-siblings' import 'view/icons' -import { - Schema, - Provider as PersistedStateProvider, - init as initPersistedState, - usePersisted, -} from '#/state/persisted' +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' @@ -23,7 +19,7 @@ import {queryClient} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' const InnerApp = observer(function AppImpl() { - const persisted = usePersisted() + const colorMode = useColorMode() const [rootStore, setRootStore] = useState( undefined, ) @@ -43,7 +39,7 @@ const InnerApp = observer(function AppImpl() { return ( - + @@ -60,22 +56,20 @@ const InnerApp = observer(function AppImpl() { }) function App() { - const [persistedState, setPersistedState] = useState() + const [isReady, setReady] = useState(false) React.useEffect(() => { - initPersistedState().then(setPersistedState) + initPersistedState().then(() => setReady(true)) }, []) - if (!persistedState) { + if (!isReady) { return null } return ( - - - - - + + + ) } diff --git a/src/state/colorMode/index.ts b/src/state/colorMode/index.ts deleted file mode 100644 index 26c3bc0129..0000000000 --- a/src/state/colorMode/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' - -import {isWeb} from '#/platform/detection' -import { - Schema, - PersistedContext, - PersistedSetStateContext, -} from '#/state/persisted' - -export function useColorMode() { - const {colorMode} = React.useContext(PersistedContext) - const {setState} = React.useContext(PersistedSetStateContext) - - const setColorMode = React.useCallback( - (colorMode: Schema['colorMode']) => { - setState(s => ({ - ...s, - colorMode, - })) - - 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}`) - } - }, - [setState], - ) - - return {colorMode, setColorMode} -} diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts new file mode 100644 index 0000000000..67fac6b65b --- /dev/null +++ b/src/state/persisted/index.ts @@ -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(key: K): Schema[K] { + return _state[key] +} + +export async function write( + key: K, + value: Schema[K], +): Promise { + 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, + }, + ) + } + } +} diff --git a/src/state/persisted/index.tsx b/src/state/persisted/index.tsx deleted file mode 100644 index baf1c59f70..0000000000 --- a/src/state/persisted/index.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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 - } -} - -export const PersistedContext = React.createContext(schema) -export const PersistedSetStateContext = React.createContext<{ - setState: (fn: (prevState: Schema) => Schema) => void -}>({ - setState: () => {}, -}) - -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) { - 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`, - ) - setState(next) - } 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, - }, - ) - } - } - } - - 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) => { - try { - 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`) - } catch (e) { - logger.error(`persisted state: failed writing root state to storage`, { - error: e, - }) - } - }, []) - - const _setState = React.useCallback( - (fn: (prevState: Schema) => Schema) => { - setState(s => { - const next = fn(s) - _write(next) - return next - }) - }, - [_write, setState], - ) - - return ( - - - {children} - - - ) -} - -export function usePersisted() { - return React.useContext(PersistedContext) -} diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index 55e599a0a2..d85cc3b09f 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -1,7 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import {logger} from '#/logger' -import {schema, Schema} from '#/state/persisted/schema' +import {defaults, Schema} from '#/state/persisted/schema' import {write, read} from '#/state/persisted/store' /** @@ -68,30 +68,32 @@ 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, + colorMode: legacy.shell.colorMode || defaults.colorMode, + accounts: legacy.session.accounts || defaults.accounts, currentAccount: legacy.session.accounts.find(a => a.did === legacy.session.data.did) || - schema.currentAccount, + defaults.currentAccount, lastEmailConfirmReminder: - legacy.reminders.lastEmailConfirm || schema.lastEmailConfirmReminder, + legacy.reminders.lastEmailConfirm || defaults.lastEmailConfirmReminder, primaryLanguage: - legacy.preferences.primaryLanguage || schema.primaryLanguage, + legacy.preferences.primaryLanguage || defaults.primaryLanguage, contentLanguages: - legacy.preferences.contentLanguages || schema.contentLanguages, - postLanguage: legacy.preferences.postLanguage || schema.postLanguage, + legacy.preferences.contentLanguages || defaults.contentLanguages, + postLanguage: legacy.preferences.postLanguage || defaults.postLanguage, postLanguageHistory: - legacy.preferences.postLanguageHistory || schema.postLanguageHistory, + legacy.preferences.postLanguageHistory || defaults.postLanguageHistory, requireAltTextEnabled: - legacy.preferences.requireAltTextEnabled || schema.requireAltTextEnabled, - mutedThreads: legacy.mutedThreads.uris || schema.mutedThreads, + legacy.preferences.requireAltTextEnabled || + defaults.requireAltTextEnabled, + mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads, invitedUsers: { - seenDids: legacy.invitedUsers.seenDids || schema.invitedUsers.seenDids, + seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids, copiedInvites: - legacy.invitedUsers.copiedInvites || schema.invitedUsers.copiedInvites, + legacy.invitedUsers.copiedInvites || + defaults.invitedUsers.copiedInvites, }, onboarding: { - step: legacy.onboarding.step || schema.onboarding.step, + step: legacy.onboarding.step || defaults.onboarding.step, }, } } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index bc50a96164..366304e6f0 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -1,42 +1,44 @@ +import {z} from 'zod' 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 -} +const accountSchema = z.object({ + service: z.string(), + did: z.string(), + refreshJwt: z.string(), + accessJwt: z.string(), + handle: z.string(), + displayName: z.string(), + aviUrl: z.string(), +}) -export type Schema = { - colorMode: 'system' | 'light' | 'dark' - accounts: Account[] - currentAccount: Account | undefined - lastEmailConfirmReminder: string | undefined +export const schema = z.object({ + colorMode: z.enum(['system', 'light', 'dark']), + accounts: z.array(accountSchema), + currentAccount: accountSchema.optional(), + lastEmailConfirmReminder: z.string().optional(), // 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 + 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 // should move to server? - invitedUsers: { - seenDids: string[] - copiedInvites: string[] - } + invitedUsers: z.object({ + seenDids: z.array(z.string()), + copiedInvites: z.array(z.string()), + }), - onboarding: { - step: string - } -} + onboarding: z.object({ + step: z.string(), + }), +}) +export type Schema = z.infer -export const schema: Schema = { +export const defaults: Schema = { colorMode: 'system', accounts: [], currentAccount: undefined, diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts index 9e35222939..b67b880032 100644 --- a/src/state/persisted/store.ts +++ b/src/state/persisted/store.ts @@ -1,14 +1,17 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import {Schema} from '#/state/persisted/schema' +import {Schema, 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 write(value: Schema) { + await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) } export async function read(): Promise { const rawData = await AsyncStorage.getItem(BSKY_STORAGE) - return rawData ? JSON.parse(rawData) : undefined + const objData = rawData ? JSON.parse(rawData) : undefined + if (schema.safeParse(objData).success) { + return objData + } } diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx new file mode 100644 index 0000000000..74379da371 --- /dev/null +++ b/src/state/shell/color-mode.tsx @@ -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('system') +const setContext = React.createContext( + (_: 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 ( + + + {children} + + + ) +} + +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}`) + } +} diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index ac2f24b4a9..1e01a4e7d5 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -2,6 +2,7 @@ import React from 'react' import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as MinimalModeProvider} from './minimal-mode' +import {Provider as ColorModeProvider} from './color-mode' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export { @@ -9,12 +10,15 @@ export { useSetDrawerSwipeDisabled, } from './drawer-swipe-disabled' export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' +export {useColorMode, useSetColorMode} from './color-mode' export function Provider({children}: React.PropsWithChildren<{}>) { return ( - {children} + + {children} + ) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 620b6a345a..ca4ef2a40d 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -46,8 +46,11 @@ 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 {useColorMode} from '#/state/colorMode' -import {useSetMinimalShellMode} from '#/state/shell' +import { + useSetMinimalShellMode, + useColorMode, + useSetColorMode, +} from '#/state/shell' // TEMPORARY (APP-700) // remove after backend testing finishes @@ -58,7 +61,8 @@ import {STATUS_PAGE_URL} from 'lib/constants' type Props = NativeStackScreenProps export const SettingsScreen = withAuthRequired( observer(function Settings({}: Props) { - const {colorMode, setColorMode} = useColorMode() + const colorMode = useColorMode() + const setColorMode = useSetColorMode() const pal = usePalette('default') const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() From 9e96703b7f91915f7823569fa146a48452b86bd9 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 7 Nov 2023 14:05:03 -0800 Subject: [PATCH 10/10] Tweak persisted schema and validation --- src/state/persisted/legacy.ts | 43 ++++++++++++++++++---------- src/state/persisted/schema.ts | 53 ++++++++++++++++++++--------------- src/state/persisted/store.ts | 1 + 3 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index d85cc3b09f..6d0a2bccc1 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -68,20 +68,31 @@ const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root' export function transform(legacy: LegacySchema): Schema { return { - colorMode: legacy.shell.colorMode || defaults.colorMode, - accounts: legacy.session.accounts || defaults.accounts, - currentAccount: - legacy.session.accounts.find(a => a.did === legacy.session.data.did) || - defaults.currentAccount, - lastEmailConfirmReminder: - legacy.reminders.lastEmailConfirm || defaults.lastEmailConfirmReminder, - primaryLanguage: - legacy.preferences.primaryLanguage || defaults.primaryLanguage, - contentLanguages: - legacy.preferences.contentLanguages || defaults.contentLanguages, - postLanguage: legacy.preferences.postLanguage || defaults.postLanguage, - postLanguageHistory: - legacy.preferences.postLanguageHistory || defaults.postLanguageHistory, + 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, @@ -119,6 +130,8 @@ export async function migrate() { logger.debug('persisted state: migrated legacy storage') } } catch (e) { - logger.error('persisted state: error migrating legacy storage', {error: e}) + logger.error('persisted state: error migrating legacy storage', { + error: String(e), + }) } } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 366304e6f0..1c5d317cce 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -5,8 +5,8 @@ import {deviceLocales} from '#/platform/detection' const accountSchema = z.object({ service: z.string(), did: z.string(), - refreshJwt: z.string(), - accessJwt: z.string(), + refreshJwt: z.string().optional(), + accessJwt: z.string().optional(), handle: z.string(), displayName: z.string(), aviUrl: z.string(), @@ -14,24 +14,25 @@ const accountSchema = z.object({ export const schema = z.object({ colorMode: z.enum(['system', 'light', 'dark']), - accounts: z.array(accountSchema), - currentAccount: accountSchema.optional(), - lastEmailConfirmReminder: z.string().optional(), - - // preferences - 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()), + 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 - - // should move to server? invitedUsers: z.object({ seenDids: z.array(z.string()), copiedInvites: z.array(z.string()), }), - onboarding: z.object({ step: z.string(), }), @@ -40,15 +41,21 @@ export type Schema = z.infer export const defaults: 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), + 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: { diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts index b67b880032..2b03bec20c 100644 --- a/src/state/persisted/store.ts +++ b/src/state/persisted/store.ts @@ -5,6 +5,7 @@ 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)) }