about summary refs log tree commit diff
path: root/src/state/persisted/index.web.ts
blob: f28b19771588b9f42a4356e4ae82bbe93f44d2d7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
import EventEmitter from 'eventemitter3'

import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
import {
  defaults,
  Schema,
  tryParse,
  tryStringify,
} from '#/state/persisted/schema'
import {PersistedApi} from './types'
import {normalizeData} from './util'

export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'

const BSKY_STORAGE = 'BSKY_STORAGE'

const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
const UPDATE_EVENT = 'BSKY_UPDATE'

let _state: Schema = defaults
const _emitter = new EventEmitter()

export async function init() {
  broadcast.onmessage = onBroadcastMessage
  window.onstorage = onStorage
  const stored = readFromStorage()
  if (stored) {
    _state = stored
  }
}
init satisfies PersistedApi['init']

export function get<K extends keyof Schema>(key: K): Schema[K] {
  return _state[key]
}
get satisfies PersistedApi['get']

export async function write<K extends keyof Schema>(
  key: K,
  value: Schema[K],
): Promise<void> {
  const next = readFromStorage()
  if (next) {
    // The storage could have been updated by a different tab before this tab is notified.
    // Make sure this write is applied on top of the latest data in the storage as long as it's valid.
    _state = next
    // Don't fire the update listeners yet to avoid a loop.
    // If there was a change, we'll receive the broadcast event soon enough which will do that.
  }
  try {
    if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
      // Fast path for updates that are guaranteed to be noops.
      // This is good mostly because it avoids useless broadcasts to other tabs.
      return
    }
  } catch (e) {
    // Ignore and go through the normal path.
  }
  _state = normalizeData({
    ..._state,
    [key]: value,
  })
  writeToStorage(_state)
  broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
  broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
}
write satisfies PersistedApi['write']

export function onUpdate<K extends keyof Schema>(
  key: K,
  cb: (v: Schema[K]) => void,
): () => void {
  const listener = () => cb(get(key))
  _emitter.addListener('update', listener) // Backcompat while upgrading
  _emitter.addListener('update:' + key, listener)
  return () => {
    _emitter.removeListener('update', listener) // Backcompat while upgrading
    _emitter.removeListener('update:' + key, listener)
  }
}
onUpdate satisfies PersistedApi['onUpdate']

export async function clearStorage() {
  try {
    localStorage.removeItem(BSKY_STORAGE)
  } catch (e: any) {
    // Expected on the web in private mode.
  }
}
clearStorage satisfies PersistedApi['clearStorage']

function onStorage() {
  const next = readFromStorage()
  if (next === _state) {
    return
  }
  if (next) {
    _state = next
    _emitter.emit('update')
  }
}

async function onBroadcastMessage({data}: MessageEvent) {
  if (
    typeof data === 'object' &&
    (data.event === UPDATE_EVENT || // Backcompat while upgrading
      data.event?.type === UPDATE_EVENT)
  ) {
    // read next state, possibly updated by another tab
    const next = readFromStorage()
    if (next === _state) {
      return
    }
    if (next) {
      _state = next
      if (typeof data.event.key === 'string') {
        _emitter.emit('update:' + data.event.key)
      } else {
        _emitter.emit('update') // Backcompat while upgrading
      }
    } else {
      logger.error(
        `persisted state: handled update update from broadcast channel, but found no data`,
      )
    }
  }
}

function writeToStorage(value: Schema) {
  const rawData = tryStringify(value)
  if (rawData) {
    try {
      localStorage.setItem(BSKY_STORAGE, rawData)
    } catch (e) {
      // Expected on the web in private mode.
    }
  }
}

let lastRawData: string | undefined
let lastResult: Schema | undefined
function readFromStorage(): Schema | undefined {
  let rawData: string | null = null
  try {
    rawData = localStorage.getItem(BSKY_STORAGE)
  } catch (e) {
    // Expected on the web in private mode.
  }
  if (rawData) {
    if (rawData === lastRawData) {
      return lastResult
    } else {
      const result = tryParse(rawData)
      if (result) {
        lastRawData = rawData
        lastResult = normalizeData(result)
        return lastResult
      }
    }
  }
}