import { isArrayBufferView } from "util/types"; export type ObservableMap = Map & { onAnyUpdate: (callback: () => void) => () => void; onUpdate: (key: string, callback: () => void) => () => void; }; //FIXME: allow different type for different properties export function memoryMap( backend: Map = new Map(), ): ObservableMap { const obs = new EventTarget(); const theMemoryMap: ObservableMap = { 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 { const obs = new EventTarget(); const theLocalStorageMap: ObservableMap = { 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) { 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, ): ObservableMap { getAllContent().then(content => { Object.entries(content ?? {}).forEach(([k, v]) => { backend.set(k, v as string); }); }) backend.onAnyUpdate(async () => { const result: Record = {}; 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; }