about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-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
10 files changed, 382 insertions, 35 deletions
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>
   )