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