Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at feat/custom-appview 168 lines 4.7 kB view raw
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}