diff --git a/src/App.native.tsx b/src/App.native.tsx index a99dbc9515..f5d35cf741 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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' @@ -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( undefined, ) @@ -44,24 +47,40 @@ const App = observer(function AppImpl() { if (!rootStore) { return null } + return ( + + + + + + + + + + + + + + + ) +}) + +function App() { + const [isReady, setReady] = useState(false) + + React.useEffect(() => { + initPersistedState().then(() => setReady(true)) + }, []) + + if (!isReady) { + return null + } + return ( - - - - - - - - - - - - - - + ) -}) +} export default App diff --git a/src/App.web.tsx b/src/App.web.tsx index 6bbc2065db..adad9ddb6a 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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' @@ -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( undefined, ) @@ -34,24 +37,40 @@ const App = observer(function AppImpl() { return null } + return ( + + + + + + + + + + + + + + + ) +}) + +function App() { + const [isReady, setReady] = useState(false) + + React.useEffect(() => { + initPersistedState().then(() => setReady(true)) + }, []) + + if (!isReady) { + return null + } + return ( - - - - - - - - - - - - - - + ) -}) +} export default App 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/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.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/legacy.ts b/src/state/persisted/legacy.ts new file mode 100644 index 0000000000..6d0a2bccc1 --- /dev/null +++ b/src/state/persisted/legacy.ts @@ -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), + }) + } +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts new file mode 100644 index 0000000000..1c5d317cce --- /dev/null +++ b/src/state/persisted/schema.ts @@ -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 + +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', + }, +} diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts new file mode 100644 index 0000000000..2b03bec20c --- /dev/null +++ b/src/state/persisted/store.ts @@ -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 { + const rawData = await AsyncStorage.getItem(BSKY_STORAGE) + 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 3f957f3ffb..ca4ef2a40d 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -46,7 +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 {useSetMinimalShellMode} from '#/state/shell' +import { + useSetMinimalShellMode, + useColorMode, + useSetColorMode, +} from '#/state/shell' // TEMPORARY (APP-700) // remove after backend testing finishes @@ -57,6 +61,8 @@ import {STATUS_PAGE_URL} from 'lib/constants' type Props = NativeStackScreenProps export const SettingsScreen = withAuthRequired( observer(function Settings({}: Props) { + const colorMode = useColorMode() + const setColorMode = useSetColorMode() const pal = usePalette('default') const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() @@ -379,23 +385,23 @@ export const SettingsScreen = withAuthRequired( store.shell.setColorMode('system')} + onSelect={() => setColorMode('system')} accessibilityHint="Set color theme to system setting" /> store.shell.setColorMode('light')} + onSelect={() => setColorMode('light')} accessibilityHint="Set color theme to light" /> store.shell.setColorMode('dark')} + onSelect={() => setColorMode('dark')} accessibilityHint="Set color theme to dark" />