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