my fork of the bluesky client
at main 151 lines 4.2 kB view raw
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}