Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import EventEmitter from 'eventemitter3'
2
3import BroadcastChannel from '#/lib/broadcast'
4import {logger} from '#/logger'
5import {
6 defaults,
7 type Schema,
8 tryParse,
9 tryStringify,
10} from '#/state/persisted/schema'
11import {type PersistedApi} from './types'
12import {normalizeData} from './util'
13
14export type {PersistedAccount, Schema} from '#/state/persisted/schema'
15export {defaults} from '#/state/persisted/schema'
16
17const BSKY_STORAGE = 'BSKY_STORAGE'
18
19const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
20const UPDATE_EVENT = 'BSKY_UPDATE'
21
22let _state: Schema = defaults
23const _emitter = new EventEmitter()
24
25// async, to match native implementation
26// eslint-disable-next-line @typescript-eslint/require-await
27export async function init() {
28 broadcast.onmessage = onBroadcastMessage
29 window.onstorage = onStorage
30 const stored = readFromStorage()
31 if (stored) {
32 _state = stored
33 }
34}
35init satisfies PersistedApi['init']
36
37export function get<K extends keyof Schema>(key: K): Schema[K] {
38 return _state[key]
39}
40get satisfies PersistedApi['get']
41
42// eslint-disable-next-line @typescript-eslint/require-await
43export async function write<K extends keyof Schema>(
44 key: K,
45 value: Schema[K],
46): Promise<void> {
47 const next = readFromStorage()
48 if (next) {
49 // The storage could have been updated by a different tab before this tab is notified.
50 // Make sure this write is applied on top of the latest data in the storage as long as it's valid.
51 _state = next
52 // Don't fire the update listeners yet to avoid a loop.
53 // If there was a change, we'll receive the broadcast event soon enough which will do that.
54 }
55 try {
56 if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
57 // Fast path for updates that are guaranteed to be noops.
58 // This is good mostly because it avoids useless broadcasts to other tabs.
59 return
60 }
61 } catch (e) {
62 // Ignore and go through the normal path.
63 }
64 _state = normalizeData({
65 ..._state,
66 [key]: value,
67 })
68 writeToStorage(_state)
69 broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
70 broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
71}
72write satisfies PersistedApi['write']
73
74export function onUpdate<K extends keyof Schema>(
75 key: K,
76 cb: (v: Schema[K]) => void,
77): () => void {
78 const listener = () => cb(get(key))
79 _emitter.addListener('update', listener) // Backcompat while upgrading
80 _emitter.addListener('update:' + key, listener)
81 return () => {
82 _emitter.removeListener('update', listener) // Backcompat while upgrading
83 _emitter.removeListener('update:' + key, listener)
84 }
85}
86onUpdate satisfies PersistedApi['onUpdate']
87
88// eslint-disable-next-line @typescript-eslint/require-await
89export async function clearStorage() {
90 try {
91 localStorage.removeItem(BSKY_STORAGE)
92 } catch (e: any) {
93 // Expected on the web in private mode.
94 }
95}
96clearStorage satisfies PersistedApi['clearStorage']
97
98function onStorage() {
99 const next = readFromStorage()
100 if (next === _state) {
101 return
102 }
103 if (next) {
104 _state = next
105 _emitter.emit('update')
106 }
107}
108
109// eslint-disable-next-line @typescript-eslint/require-await
110async function onBroadcastMessage({data}: MessageEvent) {
111 if (
112 typeof data === 'object' &&
113 (data.event === UPDATE_EVENT || // Backcompat while upgrading
114 data.event?.type === UPDATE_EVENT)
115 ) {
116 // read next state, possibly updated by another tab
117 const next = readFromStorage()
118 if (next === _state) {
119 return
120 }
121 if (next) {
122 _state = next
123 if (typeof data.event.key === 'string') {
124 _emitter.emit('update:' + data.event.key)
125 } else {
126 _emitter.emit('update') // Backcompat while upgrading
127 }
128 } else {
129 logger.error(
130 `persisted state: handled update update from broadcast channel, but found no data`,
131 )
132 }
133 }
134}
135
136function writeToStorage(value: Schema) {
137 const rawData = tryStringify(value)
138 if (rawData) {
139 try {
140 localStorage.setItem(BSKY_STORAGE, rawData)
141 } catch (e) {
142 // Expected on the web in private mode.
143 }
144 }
145}
146
147let lastRawData: string | undefined
148let lastResult: Schema | undefined
149function readFromStorage(): Schema | undefined {
150 let rawData: string | null = null
151 try {
152 rawData = localStorage.getItem(BSKY_STORAGE)
153 } catch (e) {
154 // Expected on the web in private mode.
155 }
156 if (rawData) {
157 if (rawData === lastRawData) {
158 return lastResult
159 } else {
160 const result = tryParse(rawData)
161 if (result) {
162 lastRawData = rawData
163 lastResult = normalizeData(result)
164 return lastResult
165 }
166 }
167 }
168}