diff options
Diffstat (limited to 'packages/web-util/src/utils/observable.ts')
-rw-r--r-- | packages/web-util/src/utils/observable.ts | 283 |
1 files changed, 283 insertions, 0 deletions
diff --git a/packages/web-util/src/utils/observable.ts b/packages/web-util/src/utils/observable.ts new file mode 100644 index 000000000..16a33ae72 --- /dev/null +++ b/packages/web-util/src/utils/observable.ts @@ -0,0 +1,283 @@ +import { isArrayBufferView } from "util/types"; + +export type ObservableMap<K, V> = Map<K, V> & { + onAnyUpdate: (callback: () => void) => () => void; + onUpdate: (key: string, callback: () => void) => () => void; +}; + +//FIXME: allow different type for different properties +export function memoryMap<T>( + backend: Map<string, T> = new Map<string, T>(), +): ObservableMap<string, T> { + const obs = new EventTarget(); + const theMemoryMap: ObservableMap<string, T> = { + onAnyUpdate: (handler) => { + obs.addEventListener(`update`, handler); + obs.addEventListener(`clear`, handler); + return () => { + obs.removeEventListener(`update`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + onUpdate: (key, handler) => { + obs.addEventListener(`update-${key}`, handler); + obs.addEventListener(`clear`, handler); + return () => { + obs.removeEventListener(`update-${key}`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + delete: (key: string) => { + const result = backend.delete(key); + //@ts-ignore + theMemoryMap.size = backend.length; + obs.dispatchEvent(new Event(`update-${key}`)); + obs.dispatchEvent(new Event(`update`)); + return result; + }, + set: (key: string, value: T) => { + backend.set(key, value); + //@ts-ignore + theMemoryMap.size = backend.length; + obs.dispatchEvent(new Event(`update-${key}`)); + obs.dispatchEvent(new Event(`update`)); + return theMemoryMap; + }, + clear: () => { + backend.clear(); + obs.dispatchEvent(new Event(`clear`)); + }, + entries: backend.entries.bind(backend), + forEach: backend.forEach.bind(backend), + get: backend.get.bind(backend), + has: backend.has.bind(backend), + keys: backend.keys.bind(backend), + size: backend.size, + values: backend.values.bind(backend), + [Symbol.iterator]: backend[Symbol.iterator], + [Symbol.toStringTag]: "theMemoryMap", + }; + return theMemoryMap; +} + +//FIXME: change this implementation to match the +// browser storage. instead of creating a sync implementation +// of observable map it should reuse the memoryMap and +// sync the state with local storage +export function localStorageMap(): ObservableMap<string, string> { + const obs = new EventTarget(); + const theLocalStorageMap: ObservableMap<string, string> = { + onAnyUpdate: (handler) => { + obs.addEventListener(`update`, handler); + obs.addEventListener(`clear`, handler); + window.addEventListener("storage", handler); + return () => { + window.removeEventListener("storage", handler); + obs.removeEventListener(`update`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + onUpdate: (key, handler) => { + obs.addEventListener(`update-${key}`, handler); + obs.addEventListener(`clear`, handler); + function handleStorageEvent(ev: StorageEvent) { + if (ev.key === null || ev.key === key) { + handler(); + } + } + window.addEventListener("storage", handleStorageEvent); + return () => { + window.removeEventListener("storage", handleStorageEvent); + obs.removeEventListener(`update-${key}`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + delete: (key: string) => { + const exists = localStorage.getItem(key) !== null; + localStorage.removeItem(key); + //@ts-ignore + theLocalStorageMap.size = localStorage.length; + obs.dispatchEvent(new Event(`update-${key}`)); + obs.dispatchEvent(new Event(`update`)); + return exists; + }, + set: (key: string, v: string) => { + localStorage.setItem(key, v); + //@ts-ignore + theLocalStorageMap.size = localStorage.length; + obs.dispatchEvent(new Event(`update-${key}`)); + obs.dispatchEvent(new Event(`update`)); + return theLocalStorageMap; + }, + clear: () => { + localStorage.clear(); + obs.dispatchEvent(new Event(`clear`)); + }, + entries: (): IterableIterator<[string, string]> => { + let index = 0; + const total = localStorage.length; + return { + next() { + if (index === total) return { done: true, value: undefined }; + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + const item = localStorage.getItem(key); + if (item === null) { + //the key exist, this should not happen + throw Error("value cant be null"); + } + index = index + 1; + return { done: false, value: [key, item] }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }, + forEach: (cb) => { + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + const item = localStorage.getItem(key); + if (item === null) { + //the key exist, this should not happen + throw Error("value cant be null"); + } + cb(key, item, theLocalStorageMap); + } + }, + get: (key: string) => { + const item = localStorage.getItem(key); + if (item === null) return undefined; + return item; + }, + has: (key: string) => { + return localStorage.getItem(key) === null; + }, + keys: () => { + let index = 0; + const total = localStorage.length; + return { + next() { + if (index === total) return { done: true, value: undefined }; + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + index = index + 1; + return { done: false, value: key }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }, + size: localStorage.length, + values: () => { + let index = 0; + const total = localStorage.length; + return { + next() { + if (index === total) return { done: true, value: undefined }; + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + const item = localStorage.getItem(key); + if (item === null) { + //the key exist, this should not happen + throw Error("value cant be null"); + } + index = index + 1; + return { done: false, value: item }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }, + [Symbol.iterator]: function (): IterableIterator<[string, string]> { + return theLocalStorageMap.entries(); + }, + [Symbol.toStringTag]: "theLocalStorageMap", + }; + return theLocalStorageMap; +} + +const isFirefox = + typeof (window as any) !== "undefined" && + typeof (window as any)["InstallTrigger"] !== "undefined"; + +async function getAllContent() { + //Firefox and Chrome has different storage api + if (isFirefox) { + // @ts-ignore + return browser.storage.local.get(); + } else { + return chrome.storage.local.get(); + } +} + +async function updateContent(obj: Record<string, any>) { + if (isFirefox) { + // @ts-ignore + return browser.storage.local.set(obj); + } else { + return chrome.storage.local.set(obj); + } +} +type Changes = { [key: string]: { oldValue?: any; newValue?: any } }; +function onBrowserStorageUpdate(cb: (changes: Changes) => void): void { + if (isFirefox) { + // @ts-ignore + browser.storage.local.onChanged.addListener(cb); + } else { + chrome.storage.local.onChanged.addListener(cb); + } +} + +export function browserStorageMap( + backend: ObservableMap<string, string>, +): ObservableMap<string, string> { + getAllContent().then(content => { + Object.entries(content ?? {}).forEach(([k, v]) => { + backend.set(k, v as string); + }); + }) + + backend.onAnyUpdate(async () => { + const result: Record<string, string> = {}; + for (const [key, value] of backend.entries()) { + result[key] = value; + } + await updateContent(result); + }); + + onBrowserStorageUpdate((changes) => { + //another chrome instance made the change + const changedItems = Object.keys(changes); + if (changedItems.length === 0) { + backend.clear(); + } else { + for (const key of changedItems) { + if (!changes[key].newValue) { + backend.delete(key); + } else { + if (changes[key].newValue !== changes[key].oldValue) { + backend.set(key, changes[key].newValue); + } + } + } + } + }); + + return backend; +} |