diff --git a/package.json b/package.json index efb0942df4..ac2e1527e7 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@tiptap/pm": "^2.0.0-beta.220", "@tiptap/react": "^2.0.0-beta.220", "@tiptap/suggestion": "^2.0.0-beta.220", + "@types/lodash.get": "^4.4.8", "@types/node": "^18.16.2", "@zxing/text-encoding": "^0.9.0", "array.prototype.findlast": "^1.2.3", @@ -108,6 +109,7 @@ "lande": "^1.0.10", "lodash.chunk": "^4.2.0", "lodash.debounce": "^4.0.8", + "lodash.get": "^4.4.2", "lodash.isequal": "^4.5.0", "lodash.omit": "^4.5.0", "lodash.once": "^4.1.1", diff --git a/src/storage/README.md b/src/storage/README.md deleted file mode 100644 index 58d2a5d5a9..0000000000 --- a/src/storage/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# `#/storage` - -Strongly typed storage interface over `AsyncStorage`. - -## Usage - -Import the correctly scoped store from `#/storage`. Each instance of `Storage` -(the base class, not to be used directly), has the following interface: - -- `set([...scope, key], value)` -- `get([...scope, key])` -- `remove([...scope, key])` -- `removeMany([...scope], [...keys])` - -For example, using our `storage` global store looks like this, since it's scoped to the -device and not an account: - -```typescript -import * as storage from '#/storage'; - -storage.device.set(['colorScheme'], 'light'); -storage.device.get(['colorScheme']); -storage.device.remove(['colorScheme']); -storage.device.removeMany([], ['colorScheme']); -``` - -### Storing Objects - -Because of the way `Storage` serializes data for storage, you don't need to -pre-serialize objects or any other type of data. - -```typescript -storage.device.set(['foo'], true) -storage.device.get(['foo']) // => boolean - -storage.device.set(['bar'], 1) -storage.device.get(['bar']) // => number - -storage.device.set(['baz'], { yes: true }) -storage.device.get(['baz']) // => object { yes: true } -``` - -## TypeScript - -Stores are strongly typed, and when setting a given value, it will need to -conform to the schemas defined in `#/storage/schemas`. When getting a value, it -will be returned to you as the type defined in its schema. - -## Scoped Stores - -Some stores are (will be) scoped to account. In this case, storage instances are -created with type-guards, like this: - -```typescript -type Account = { - initialView: string -}; - -type DID = `did:plc:${string}`; - -const account = new Storage< - [DID], - Account ->({ - initialView: 'following', -}); -``` - -```typescript -import * as storage from '#/storage' - -storage.account.set(['did:plc:123abc', 'initialView'], 'following') -storage.account.get(['did:plc:123abc', 'initialView']) -``` - -Here, if `['did:plc:123abc']` is not supplied along with the storage key of -`initialView`, type checking will fail, and the value will return undefined at -runtime. - -## Extensibility - -For storage instances that require scopes like `account`, it may be useful in -the future to define wrappers around `Storage` that can cache references to the -currently active account. That way, we don't have to pass in the `DID` every -time. diff --git a/src/storage/__tests__/storage.test.ts b/src/storage/__tests__/storage.test.ts index 41cb40821f..ec4692ffe9 100644 --- a/src/storage/__tests__/storage.test.ts +++ b/src/storage/__tests__/storage.test.ts @@ -1,62 +1,39 @@ -import {beforeEach, expect, test} from '@jest/globals' +import {jest, beforeAll, afterEach, expect, test} from '@jest/globals' +// has a built-in mock +import AsyncStorage from '@react-native-async-storage/async-storage' -import {Storage} from '#/storage' +import * as storage from '#/storage' -type Schema = { - boo: boolean - str: string | null - num: number - obj: Record -} - -const store = new Storage<[], Schema>() - -beforeEach(() => { - store.removeMany([], ['boo', 'str', 'num', 'obj']) +beforeAll(async () => { + await storage.init() + jest.mocked(AsyncStorage.getItem).mockClear() }) -test(`stores and retrieves data`, async () => { - await store.set(['boo'], true) - await store.set(['str'], 'string') - await store.set(['num'], 1) - expect(await store.get(['boo'])).toEqual(true) - expect(await store.get(['str'])).toEqual('string') - expect(await store.get(['num'])).toEqual(1) +afterEach(() => { + jest.mocked(AsyncStorage.getItem).mockClear() }) -test(`removes data`, async () => { - await store.set(['boo'], true) - expect(await store.get(['boo'])).toEqual(true) - await store.remove(['boo']) - expect(await store.get(['boo'])).toEqual(undefined) +test(`gets and sets data synchronously`, async () => { + storage.set('shell.colorMode', 'light') + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + storage.STORAGE_ROOT_KEY, + JSON.stringify({shell: {colorMode: 'light'}}), + ) + expect(storage.get('shell.colorMode')).toBe('light') + expect(AsyncStorage.getItem).not.toHaveBeenCalled() + + storage.set('shell.colorMode', 'dark') + expect(storage.get('shell.colorMode')).toBe('dark') }) -test(`removes multiple keys at once`, async () => { - await store.set(['boo'], true) - await store.set(['str'], 'string') - await store.set(['num'], 1) - await store.removeMany([], ['boo', 'str', 'num']) - expect(await store.get(['boo'])).toEqual(undefined) - expect(await store.get(['str'])).toEqual(undefined) - expect(await store.get(['num'])).toEqual(undefined) -}) +test(`set rolls back on error`, async () => { + storage.set('shell.colorMode', 'light') -test(`concatenates keys`, async () => { - await store.remove(['str']) - await store.set(['str'], 'concat') - // @ts-ignore accessing these properties for testing purposes only - expect(await store.store.getItem(`str`)).toBeTruthy() -}) + jest + .mocked(AsyncStorage.setItem) + .mockImplementationOnce(() => Promise.reject(new Error('test error'))) -test(`can store falsy values`, async () => { - await store.set(['str'], null) - await store.set(['num'], 0) - expect(await store.get(['str'])).toEqual(null) - expect(await store.get(['num'])).toEqual(0) -}) + await storage.set('shell.colorMode', 'system') -test(`can store objects`, async () => { - const obj = {foo: true} - await store.set(['obj'], obj) - expect(await store.get(['obj'])).toEqual(obj) + expect(storage.get('shell.colorMode')).toBe('light') }) diff --git a/src/storage/index.ts b/src/storage/index.ts index bea4a80275..3ddcb6d7b8 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,72 +1,48 @@ import AsyncStorage from '@react-native-async-storage/async-storage' +import getObj from 'lodash.get' +import setObj from 'lodash.set' -import {Device} from '#/storage/schemas' +import {logger} from '#/logger' +import {Schema, PropertyMap} from '#/storage/schema' + +export const STORAGE_ROOT_KEY = 'root' /** - * Generic storage class. DO NOT use this directly. Instead, use the exported - * storage instances below. + * In memory cache of local storage data. Never export or reference this + * directly. */ -export class Storage { - protected sep = ':' - protected store: typeof AsyncStorage +let data: Schema | undefined - constructor() { - this.store = AsyncStorage - } - - /** - * Store a value in storage based on scopes and/or keys - * - * `set([key], value)` - * `set([scope, key], value)` - */ - async set( - scopes: [...Scopes, Key], - data: Schema[Key], - ): Promise { - // stored as `{ data: }` structure to ease stringification - await this.store.setItem(scopes.join(this.sep), JSON.stringify({data})) - } - - /** - * Get a value from storage based on scopes and/or keys - * - * `get([key])` - * `get([scope, key])` - */ - async get( - scopes: [...Scopes, Key], - ): Promise { - const res = await this.store.getItem(scopes.join(this.sep)) - if (!res) return undefined - // parsed from storage structure `{ data: }` - return JSON.parse(res).data - } - - /** - * Remove a value from storage based on scopes and/or keys - * - * `remove([key])` - * `remove([scope, key])` - */ - async remove(scopes: [...Scopes, Key]) { - await this.store.removeItem(scopes.join(this.sep)) - } - - /** - * Remove many values from the same storage scope by keys - * - * `removeMany([], [key])` - * `removeMany([scope], [key])` - */ - async removeMany(scopes: [...Scopes], keys: Key[]) { - await Promise.all(keys.map(key => this.remove([...scopes, key]))) - } +export async function init() { + const raw = await AsyncStorage.getItem(STORAGE_ROOT_KEY) + logger.debug(`storage initialized`) + data = (raw ? JSON.parse(raw) : {}) as Schema } -/** - * Data that's specific to the device - * - * `device.set(['colorScheme'], 'light')` - */ -export const device = new Storage<[], Device>() +export function get(keypath: T): PropertyMap[T] { + logger.debug(`storage get(${keypath})`) + if (!data) { + throw new Error(`Data is not initialized. Did you forget to call init()`) + } + return getObj(data, keypath) +} + +export async function set( + keypath: T, + value: PropertyMap[T], +) { + logger.debug(`storage set(${keypath}, value)`) + if (!data) { + throw new Error(`Data is not initialized. Did you forget to call init()`) + } + + const prev = getObj(data, keypath) + setObj(data, keypath, value) + + try { + await AsyncStorage.setItem(STORAGE_ROOT_KEY, JSON.stringify(data)) + } catch (err) { + logger.error(`storage set(${keypath}, value) failed`) + setObj(data, keypath, prev) + } +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts new file mode 100644 index 0000000000..1db20dc047 --- /dev/null +++ b/src/storage/schema.ts @@ -0,0 +1,70 @@ +import {LabelPreference} from '@atproto/api' + +/** + * The shape of the object we store in local storage + */ +export type Schema = { + 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: LabelPreference + nudity: LabelPreference + suggestive: LabelPreference + gore: LabelPreference + hate: LabelPreference + spam: LabelPreference + impersonation: LabelPreference + } + savedFeeds: string[] + pinnedFeeds: string[] + requireAltTextEnabled: boolean + } + invitedUsers: { + seenDids: string[] + copiedInvites: string[] + } + mutedThreads: {uris: string[]} + reminders: {lastEmailConfirm: string} +} + +/** + * A map of all the properties that can be stored in the storage, and their + * values. This mapping must be updated in order to get/set values by their + * keypaths. + */ +export type PropertyMap = { + 'shell.colorMode': Schema['shell']['colorMode'] +} diff --git a/src/storage/schemas.ts b/src/storage/schemas.ts deleted file mode 100644 index 2a7e9b26b7..0000000000 --- a/src/storage/schemas.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Data that's specific to the device - */ -export type Device = { - colorScheme: 'light' | 'dark' | 'system' -} diff --git a/yarn.lock b/yarn.lock index cd4f71a7a4..1e4e44123b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5425,6 +5425,13 @@ dependencies: "@types/lodash" "*" +"@types/lodash.get@^4.4.8": + version "4.4.8" + resolved "https://registry.yarnpkg.com/@types/lodash.get/-/lodash.get-4.4.8.tgz#205004a76a7b821999563342e63ffb1940a9498a" + integrity sha512-XK+co6sBkJxh1vaVP8al6cAA17dX//RNCknGG8JhpHFJfxq/GXKAYB9NKheG22pu2xpWpxfFd65W08EhH2IFlg== + dependencies: + "@types/lodash" "*" + "@types/lodash.isequal@^4.5.6": version "4.5.6" resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz#ff42a1b8e20caa59a97e446a77dc57db923bc02b" @@ -12803,6 +12810,11 @@ lodash.defaults@^4.2.0: resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"