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