about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx51
-rw-r--r--src/App.web.tsx51
-rw-r--r--src/state/models/root-store.ts4
-rw-r--r--src/state/models/ui/shell.ts30
-rw-r--r--src/state/persisted/broadcast/index.ts6
-rw-r--r--src/state/persisted/broadcast/index.web.ts1
-rw-r--r--src/state/persisted/index.ts91
-rw-r--r--src/state/persisted/legacy.ts137
-rw-r--r--src/state/persisted/schema.ts68
-rw-r--r--src/state/persisted/store.ts18
-rw-r--r--src/state/shell/color-mode.tsx56
-rw-r--r--src/state/shell/index.tsx6
-rw-r--r--src/view/screens/Settings.tsx20
13 files changed, 465 insertions, 74 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index a99dbc951..f5d35cf74 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<RootStoreModel | undefined>(
     undefined,
   )
@@ -45,23 +48,39 @@ const App = observer(function AppImpl() {
     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
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 6bbc2065d..adad9ddb6 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<RootStoreModel | undefined>(
     undefined,
   )
@@ -35,23 +38,39 @@ const App = observer(function AppImpl() {
   }
 
   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
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index cf7307ca3..1943f6dbc 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 d690b9331..d39131629 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 000000000..e0e7f724b
--- /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 000000000..33b3548ad
--- /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 000000000..67fac6b65
--- /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<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,
+        },
+      )
+    }
+  }
+}
diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts
new file mode 100644
index 000000000..6d0a2bccc
--- /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 000000000..1c5d317cc
--- /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<typeof schema>
+
+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 000000000..2b03bec20
--- /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<Schema | undefined> {
+  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 000000000..74379da37
--- /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<StateContext>('system')
+const setContext = React.createContext<SetContext>(
+  (_: 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 (
+    <stateContext.Provider value={state}>
+      <setContext.Provider value={setStateWrapped}>
+        {children}
+      </setContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+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 ac2f24b4a..1e01a4e7d 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 (
     <DrawerOpenProvider>
       <DrawerSwipableProvider>
-        <MinimalModeProvider>{children}</MinimalModeProvider>
+        <MinimalModeProvider>
+          <ColorModeProvider>{children}</ColorModeProvider>
+        </MinimalModeProvider>
       </DrawerSwipableProvider>
     </DrawerOpenProvider>
   )
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 3f957f3ff..ca4ef2a40 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<CommonNavigatorParams, 'Settings'>
 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(
           <View>
             <View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
               <SelectableBtn
-                selected={store.shell.colorMode === 'system'}
+                selected={colorMode === 'system'}
                 label="System"
                 left
-                onSelect={() => store.shell.setColorMode('system')}
+                onSelect={() => setColorMode('system')}
                 accessibilityHint="Set color theme to system setting"
               />
               <SelectableBtn
-                selected={store.shell.colorMode === 'light'}
+                selected={colorMode === 'light'}
                 label="Light"
-                onSelect={() => store.shell.setColorMode('light')}
+                onSelect={() => setColorMode('light')}
                 accessibilityHint="Set color theme to light"
               />
               <SelectableBtn
-                selected={store.shell.colorMode === 'dark'}
+                selected={colorMode === 'dark'}
                 label="Dark"
                 right
-                onSelect={() => store.shell.setColorMode('dark')}
+                onSelect={() => setColorMode('dark')}
                 accessibilityHint="Set color theme to dark"
               />
             </View>