Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {useCallback, useEffect, useState} from 'react'
2import {MMKV} from '@bsky.app/react-native-mmkv'
3
4import {type Account, type Device} from '#/storage/schema'
5
6export * from '#/storage/schema'
7
8/**
9 * Generic storage class. DO NOT use this directly. Instead, use the exported
10 * storage instances below.
11 */
12export class Storage<Scopes extends unknown[], Schema> {
13 protected sep = ':'
14 protected store: MMKV
15
16 constructor({id}: {id: string}) {
17 this.store = new MMKV({id})
18 }
19
20 /**
21 * Store a value in storage based on scopes and/or keys
22 *
23 * `set([key], value)`
24 * `set([scope, key], value)`
25 */
26 set<Key extends keyof Schema>(
27 scopes: [...Scopes, Key],
28 data: Schema[Key],
29 ): void {
30 // stored as `{ data: <value> }` structure to ease stringification
31 this.store.set(scopes.join(this.sep), JSON.stringify({data}))
32 }
33
34 /**
35 * Get a value from storage based on scopes and/or keys
36 *
37 * `get([key])`
38 * `get([scope, key])`
39 */
40 get<Key extends keyof Schema>(
41 scopes: [...Scopes, Key],
42 ): Schema[Key] | undefined {
43 const res = this.store.getString(scopes.join(this.sep))
44 if (!res) return undefined
45 // parsed from storage structure `{ data: <value> }`
46 return JSON.parse(res).data
47 }
48
49 /**
50 * Remove a value from storage based on scopes and/or keys
51 *
52 * `remove([key])`
53 * `remove([scope, key])`
54 */
55 remove<Key extends keyof Schema>(scopes: [...Scopes, Key]) {
56 this.store.delete(scopes.join(this.sep))
57 }
58
59 /**
60 * Remove many values from the same storage scope by keys
61 *
62 * `removeMany([], [key])`
63 * `removeMany([scope], [key])`
64 */
65 removeMany<Key extends keyof Schema>(scopes: [...Scopes], keys: Key[]) {
66 keys.forEach(key => this.remove([...scopes, key]))
67 }
68
69 /**
70 * For debugging purposes
71 */
72 removeAll() {
73 this.store.clearAll()
74 }
75
76 /**
77 * Fires a callback when the storage associated with a given key changes
78 *
79 * @returns Listener - call `remove()` to stop listening
80 */
81 addOnValueChangedListener<Key extends keyof Schema>(
82 scopes: [...Scopes, Key],
83 callback: () => void,
84 ) {
85 return this.store.addOnValueChangedListener(key => {
86 if (key === scopes.join(this.sep)) {
87 callback()
88 }
89 })
90 }
91}
92
93type StorageSchema<T extends Storage<any, any>> =
94 T extends Storage<any, infer U> ? U : never
95type StorageScopes<T extends Storage<any, any>> =
96 T extends Storage<infer S, any> ? S : never
97
98/**
99 * Hook to use a storage instance. Acts like a useState hook, but persists the
100 * value in storage.
101 */
102export function useStorage<
103 Store extends Storage<any, any>,
104 Key extends keyof StorageSchema<Store>,
105>(
106 storage: Store,
107 scopes: [...StorageScopes<Store>, Key],
108): [
109 StorageSchema<Store>[Key] | undefined,
110 (data: StorageSchema<Store>[Key]) => void,
111] {
112 type Schema = StorageSchema<Store>
113 const [value, setValue] = useState<Schema[Key] | undefined>(() =>
114 storage.get(scopes),
115 )
116
117 useEffect(() => {
118 const sub = storage.addOnValueChangedListener(scopes, () => {
119 setValue(storage.get(scopes))
120 })
121 return () => sub.remove()
122 }, [storage, scopes])
123
124 const setter = useCallback(
125 (data: Schema[Key]) => {
126 setValue(data)
127 storage.set(scopes, data)
128 },
129 [storage, scopes],
130 )
131
132 return [value, setter] as const
133}
134
135/**
136 * Device data that's specific to the device and does not vary based on account
137 *
138 * `device.set([key], true)`
139 */
140export const device = new Storage<[], Device>({id: 'bsky_device'})
141
142/**
143 * Account data that's specific to the account on this device
144 */
145export const account = new Storage<[string], Account>({id: 'bsky_account'})
146
147if (__DEV__ && typeof window !== 'undefined') {
148 // @ts-expect-error - dev global
149 window.bsky_storage = {
150 device,
151 account,
152 }
153}