summaryrefslogtreecommitdiff
path: root/packages/web-util/src/utils/observable.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web-util/src/utils/observable.ts')
-rw-r--r--packages/web-util/src/utils/observable.ts283
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;
+}