// PIZZAX --- A lightweight store import { onUnmounted, Ref, ref, watch } from "vue"; import { $i } from "./account"; import { api } from "./os"; import { stream } from "./stream"; type StateDef = Record< string, { where: "account" | "device" | "deviceAccount"; default: any; } >; type ArrayElement = A extends readonly (infer T)[] ? T : never; const connection = $i && stream.useChannel("main"); export class Storage { public readonly key: string; public readonly keyForLocalStorage: string; public readonly def: T; // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 public readonly state: { [K in keyof T]: T[K]["default"]; }; public readonly reactiveState: { [K in keyof T]: Ref; }; constructor(key: string, def: T) { this.key = key; this.keyForLocalStorage = `pizzax::${key}`; this.def = def; // TODO: indexedDBにする const deviceState = JSON.parse( localStorage.getItem(this.keyForLocalStorage) || "{}", ); const deviceAccountState = $i ? JSON.parse( localStorage.getItem( `${this.keyForLocalStorage}::${$i.id}`, ) || "{}", ) : {}; const registryCache = $i ? JSON.parse( localStorage.getItem( `${this.keyForLocalStorage}::cache::${$i.id}`, ) || "{}", ) : {}; const state = {}; const reactiveState = {}; for (const [k, v] of Object.entries(def)) { if ( v.where === "device" && Object.prototype.hasOwnProperty.call(deviceState, k) ) { state[k] = deviceState[k]; } else if ( v.where === "account" && $i && Object.prototype.hasOwnProperty.call(registryCache, k) ) { state[k] = registryCache[k]; } else if ( v.where === "deviceAccount" && Object.prototype.hasOwnProperty.call(deviceAccountState, k) ) { state[k] = deviceAccountState[k]; } else { state[k] = v.default; if (_DEV_) console.log("Use default value", k, v.default); } } for (const [k, v] of Object.entries(state)) { reactiveState[k] = ref(v); } this.state = state as any; this.reactiveState = reactiveState as any; if ($i) { // なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう) window.setTimeout(() => { api("i/registry/get-all", { scope: ["client", this.key] }).then( (kvs) => { const cache = {}; for (const [k, v] of Object.entries(def)) { if (v.where === "account") { if ( Object.prototype.hasOwnProperty.call(kvs, k) ) { state[k] = kvs[k]; reactiveState[k].value = kvs[k]; cache[k] = kvs[k]; } else { state[k] = v.default; reactiveState[k].value = v.default; } } } localStorage.setItem( `${this.keyForLocalStorage}::cache::${$i.id}`, JSON.stringify(cache), ); }, ); }, 1); // streamingのuser storage updateイベントを監視して更新 connection?.on( "registryUpdated", ({ scope, key, value, }: { scope: string[]; key: keyof T; value: T[typeof key]["default"]; }) => { if ( scope.length !== 2 || scope[0] !== "client" || scope[1] !== this.key || this.state[key] === value ) return; this.state[key] = value; this.reactiveState[key].value = value; const cache = JSON.parse( localStorage.getItem( `${this.keyForLocalStorage}::cache::${$i.id}`, ) || "{}", ); if (cache[key] !== value) { cache[key] = value; localStorage.setItem( `${this.keyForLocalStorage}::cache::${$i.id}`, JSON.stringify(cache), ); } }, ); } } public set(key: K, value: T[K]["default"]): void { if (_DEV_) console.log("set", key, value); this.state[key] = value; this.reactiveState[key].value = value; switch (this.def[key].where) { case "device": { const deviceState = JSON.parse( localStorage.getItem(this.keyForLocalStorage) || "{}", ); deviceState[key] = value; localStorage.setItem( this.keyForLocalStorage, JSON.stringify(deviceState), ); break; } case "deviceAccount": { if ($i == null) break; const deviceAccountState = JSON.parse( localStorage.getItem( `${this.keyForLocalStorage}::${$i.id}`, ) || "{}", ); deviceAccountState[key] = value; localStorage.setItem( `${this.keyForLocalStorage}::${$i.id}`, JSON.stringify(deviceAccountState), ); break; } case "account": { if ($i == null) break; const cache = JSON.parse( localStorage.getItem( `${this.keyForLocalStorage}::cache::${$i.id}`, ) || "{}", ); cache[key] = value; localStorage.setItem( `${this.keyForLocalStorage}::cache::${$i.id}`, JSON.stringify(cache), ); api("i/registry/set", { scope: ["client", this.key], key: key, value: value, }); break; } } } public push( key: K, value: ArrayElement, ): void { const currentState = this.state[key]; this.set(key, [...currentState, value]); } public reset(key: keyof T) { this.set(key, this.def[key].default); } /** * 特定のキーの、簡易的なgetter/setterを作ります * 主にvue場で設定コントロールのmodelとして使う用 */ public makeGetterSetter( key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K], ) { const valueRef = ref(this.state[key]); const stop = watch(this.reactiveState[key], (val) => { valueRef.value = val; }); // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする onUnmounted(() => { stop(); }); // TODO: VueのcustomRef使うと良い感じになるかも return { get: () => { if (getter) { return getter(valueRef.value); } else { return valueRef.value; } }, set: (value: unknown) => { const val = setter ? setter(value) : value; this.set(key, val); valueRef.value = val; }, }; } }