From 2dc2d2e4fee549deac1709a13a9528745510321f Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:04:01 +0900 Subject: refactor --- packages/frontend/src/lib/pizzax.ts | 304 ++++++++++++++++++++++++++++ packages/frontend/src/pizzax.ts | 304 ---------------------------- packages/frontend/src/store.ts | 4 +- packages/frontend/src/ui/deck/deck-store.ts | 4 +- 4 files changed, 308 insertions(+), 308 deletions(-) create mode 100644 packages/frontend/src/lib/pizzax.ts delete mode 100644 packages/frontend/src/pizzax.ts diff --git a/packages/frontend/src/lib/pizzax.ts b/packages/frontend/src/lib/pizzax.ts new file mode 100644 index 0000000000..a232ced75e --- /dev/null +++ b/packages/frontend/src/lib/pizzax.ts @@ -0,0 +1,304 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// PIZZAX --- A lightweight store + +import { onUnmounted, ref, watch } from 'vue'; +import { BroadcastChannel } from 'broadcast-channel'; +import type { Ref } from 'vue'; +import { $i } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { get, set } from '@/utility/idb-proxy.js'; +import { store } from '@/store.js'; +import { useStream } from '@/stream.js'; +import { deepClone } from '@/utility/clone.js'; +import { deepMerge } from '@/utility/merge.js'; + +type StateDef = Record; + +type State = { [K in keyof T]: T[K]['default']; }; +type ReactiveState = { [K in keyof T]: Ref; }; + +type ArrayElement = A extends readonly (infer T)[] ? T : never; + +type PizzaxChannelMessage = { + where: 'device' | 'deviceAccount'; + key: keyof T; + value: T[keyof T]['default']; + userId?: string; +}; + +export class Pizzax { + public readonly ready: Promise; + public readonly loaded: Promise; + + public readonly key: string; + public readonly deviceStateKeyName: `pizzax::${this['key']}`; + public readonly deviceAccountStateKeyName: `pizzax::${this['key']}::${string}` | ''; + public readonly registryCacheKeyName: `pizzax::${this['key']}::cache::${string}` | ''; + + public readonly def: T; + + // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 + /** + * static / state の略 (static が予約語のため) + */ + public readonly s: State; + + /** + * reactive の略 + */ + public readonly r: ReactiveState; + + private pizzaxChannel: BroadcastChannel>; + + // 簡易的にキューイングして占有ロックとする + private currentIdbJob: Promise = Promise.resolve(); + private addIdbSetJob(job: () => Promise) { + const promise = this.currentIdbJob.then(job, err => { + console.error('Pizzax failed to save data to idb!', err); + return job(); + }); + this.currentIdbJob = promise; + return promise; + } + + constructor(key: string, def: T) { + this.key = key; + this.deviceStateKeyName = `pizzax::${key}`; + this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : ''; + this.registryCacheKeyName = $i ? `pizzax::${key}::cache::${$i.id}` : ''; + this.def = def; + + this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); + + this.s = {} as State; + this.r = {} as ReactiveState; + + for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { + this.s[k] = v.default; + this.r[k] = ref(v.default); + } + + this.ready = this.init(); + this.loaded = this.ready.then(() => this.load()); + } + + private isPureObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + private mergeState(value: X, def: X): X { + if (this.isPureObject(value) && this.isPureObject(def)) { + const merged = deepMerge(value, def); + + if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); + + return merged as X; + } + return value; + } + + private async init(): Promise { + await this.migrate(); + + const deviceState: State = await get(this.deviceStateKeyName) || {}; + const deviceAccountState = $i ? await get(this.deviceAccountStateKeyName) || {} : {}; + const registryCache = $i ? await get(this.registryCacheKeyName) || {} : {}; + + for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { + if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { + this.r[k].value = this.s[k] = this.mergeState(deviceState[k], v.default); + } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { + this.r[k].value = this.s[k] = this.mergeState(registryCache[k], v.default); + } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { + this.r[k].value = this.s[k] = this.mergeState(deviceAccountState[k], v.default); + } else { + this.r[k].value = this.s[k] = v.default; + } + } + + this.pizzaxChannel.addEventListener('message', ({ where, key, value, userId }) => { + // アカウント変更すればunisonReloadが効くため、このreturnが発火することは + // まずないと思うけど一応弾いておく + if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; + this.r[key].value = this.s[key] = value; + }); + + if ($i) { + const connection = useStream().useChannel('main'); + + // streamingのuser storage updateイベントを監視して更新 + connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { + if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return; + + this.r[key].value = this.s[key] = value; + + this.addIdbSetJob(async () => { + const cache = await get(this.registryCacheKeyName); + if (cache[key] !== value) { + cache[key] = value; + await set(this.registryCacheKeyName, cache); + } + }); + }); + } + } + + private load(): Promise { + return new Promise((resolve, reject) => { + if ($i) { + // api関数と循環参照なので一応setTimeoutしておく + window.setTimeout(async () => { + await store.ready; + + misskeyApi('i/registry/get-all', { scope: ['client', this.key] }) + .then(kvs => { + const cache: Partial = {}; + for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { + if (v.where === 'account') { + if (Object.prototype.hasOwnProperty.call(kvs, k)) { + this.r[k].value = this.s[k] = (kvs as Partial)[k]; + cache[k] = (kvs as Partial)[k]; + } else { + this.r[k].value = this.s[k] = v.default; + } + } + } + + return set(this.registryCacheKeyName, cache); + }) + .then(() => resolve()); + }, 1); + } else { + resolve(); + } + }); + } + + public set(key: K, value: T[K]['default']): Promise { + // IndexedDBやBroadcastChannelで扱うために単純なオブジェクトにする + // (JSON.parse(JSON.stringify(value))の代わり) + const rawValue = deepClone(value); + + this.r[key].value = this.s[key] = rawValue; + + return this.addIdbSetJob(async () => { + switch (this.def[key].where) { + case 'device': { + this.pizzaxChannel.postMessage({ + where: 'device', + key, + value: rawValue, + }); + const deviceState = await get(this.deviceStateKeyName) || {}; + deviceState[key] = rawValue; + await set(this.deviceStateKeyName, deviceState); + break; + } + case 'deviceAccount': { + if ($i == null) break; + this.pizzaxChannel.postMessage({ + where: 'deviceAccount', + key, + value: rawValue, + userId: $i.id, + }); + const deviceAccountState = await get(this.deviceAccountStateKeyName) || {}; + deviceAccountState[key] = rawValue; + await set(this.deviceAccountStateKeyName, deviceAccountState); + break; + } + case 'account': { + if ($i == null) break; + const cache = await get(this.registryCacheKeyName) || {}; + cache[key] = rawValue; + await set(this.registryCacheKeyName, cache); + await misskeyApi('i/registry/set', { + scope: ['client', this.key], + key: key.toString(), + value: rawValue, + }); + break; + } + } + }); + } + + public push(key: K, value: ArrayElement): void { + const currentState = this.s[key]; + this.set(key, [...currentState, value]); + } + + public reset(key: keyof T) { + this.set(key, this.def[key].default); + return this.def[key].default; + } + + /** + * 特定のキーの、簡易的なgetter/setterを作ります + * 主にvue上で設定コントロールのmodelとして使う用 + */ + // TODO: 廃止 + public makeGetterSetter( + key: K, + getter?: (v: T[K]['default']) => R, + setter?: (v: R) => T[K]['default'], + ): { + get: () => R; + set: (value: R) => void; + } { + const valueRef = ref(this.s[key]); + + const stop = watch(this.r[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) => { + const val = setter ? setter(value) : value; + this.set(key, val); + valueRef.value = val; + }, + }; + } + + // localStorage => indexedDBのマイグレーション + private async migrate() { + const deviceState = localStorage.getItem(this.deviceStateKeyName); + if (deviceState) { + await set(this.deviceStateKeyName, JSON.parse(deviceState)); + localStorage.removeItem(this.deviceStateKeyName); + } + + const deviceAccountState = $i && localStorage.getItem(this.deviceAccountStateKeyName); + if ($i && deviceAccountState) { + await set(this.deviceAccountStateKeyName, JSON.parse(deviceAccountState)); + localStorage.removeItem(this.deviceAccountStateKeyName); + } + + const registryCache = $i && localStorage.getItem(this.registryCacheKeyName); + if ($i && registryCache) { + await set(this.registryCacheKeyName, JSON.parse(registryCache)); + localStorage.removeItem(this.registryCacheKeyName); + } + } +} diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts deleted file mode 100644 index 3ebf2ab4e4..0000000000 --- a/packages/frontend/src/pizzax.ts +++ /dev/null @@ -1,304 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// PIZZAX --- A lightweight store - -import { onUnmounted, ref, watch } from 'vue'; -import { BroadcastChannel } from 'broadcast-channel'; -import type { Ref } from 'vue'; -import { $i } from '@/i.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; -import { get, set } from '@/utility/idb-proxy.js'; -import { store } from '@/store.js'; -import { useStream } from '@/stream.js'; -import { deepClone } from '@/utility/clone.js'; -import { deepMerge } from '@/utility/merge.js'; - -type StateDef = Record; - -type State = { [K in keyof T]: T[K]['default']; }; -type ReactiveState = { [K in keyof T]: Ref; }; - -type ArrayElement = A extends readonly (infer T)[] ? T : never; - -type PizzaxChannelMessage = { - where: 'device' | 'deviceAccount'; - key: keyof T; - value: T[keyof T]['default']; - userId?: string; -}; - -export class Storage { - public readonly ready: Promise; - public readonly loaded: Promise; - - public readonly key: string; - public readonly deviceStateKeyName: `pizzax::${this['key']}`; - public readonly deviceAccountStateKeyName: `pizzax::${this['key']}::${string}` | ''; - public readonly registryCacheKeyName: `pizzax::${this['key']}::cache::${string}` | ''; - - public readonly def: T; - - // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 - /** - * static / state の略 (static が予約語のため) - */ - public readonly s: State; - - /** - * reactive の略 - */ - public readonly r: ReactiveState; - - private pizzaxChannel: BroadcastChannel>; - - // 簡易的にキューイングして占有ロックとする - private currentIdbJob: Promise = Promise.resolve(); - private addIdbSetJob(job: () => Promise) { - const promise = this.currentIdbJob.then(job, err => { - console.error('Pizzax failed to save data to idb!', err); - return job(); - }); - this.currentIdbJob = promise; - return promise; - } - - constructor(key: string, def: T) { - this.key = key; - this.deviceStateKeyName = `pizzax::${key}`; - this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : ''; - this.registryCacheKeyName = $i ? `pizzax::${key}::cache::${$i.id}` : ''; - this.def = def; - - this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); - - this.s = {} as State; - this.r = {} as ReactiveState; - - for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { - this.s[k] = v.default; - this.r[k] = ref(v.default); - } - - this.ready = this.init(); - this.loaded = this.ready.then(() => this.load()); - } - - private isPureObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); - } - - private mergeState(value: X, def: X): X { - if (this.isPureObject(value) && this.isPureObject(def)) { - const merged = deepMerge(value, def); - - if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); - - return merged as X; - } - return value; - } - - private async init(): Promise { - await this.migrate(); - - const deviceState: State = await get(this.deviceStateKeyName) || {}; - const deviceAccountState = $i ? await get(this.deviceAccountStateKeyName) || {} : {}; - const registryCache = $i ? await get(this.registryCacheKeyName) || {} : {}; - - for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { - if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { - this.r[k].value = this.s[k] = this.mergeState(deviceState[k], v.default); - } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { - this.r[k].value = this.s[k] = this.mergeState(registryCache[k], v.default); - } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { - this.r[k].value = this.s[k] = this.mergeState(deviceAccountState[k], v.default); - } else { - this.r[k].value = this.s[k] = v.default; - } - } - - this.pizzaxChannel.addEventListener('message', ({ where, key, value, userId }) => { - // アカウント変更すればunisonReloadが効くため、このreturnが発火することは - // まずないと思うけど一応弾いておく - if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; - this.r[key].value = this.s[key] = value; - }); - - if ($i) { - const connection = useStream().useChannel('main'); - - // streamingのuser storage updateイベントを監視して更新 - connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { - if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return; - - this.r[key].value = this.s[key] = value; - - this.addIdbSetJob(async () => { - const cache = await get(this.registryCacheKeyName); - if (cache[key] !== value) { - cache[key] = value; - await set(this.registryCacheKeyName, cache); - } - }); - }); - } - } - - private load(): Promise { - return new Promise((resolve, reject) => { - if ($i) { - // api関数と循環参照なので一応setTimeoutしておく - window.setTimeout(async () => { - await store.ready; - - misskeyApi('i/registry/get-all', { scope: ['client', this.key] }) - .then(kvs => { - const cache: Partial = {}; - for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { - if (v.where === 'account') { - if (Object.prototype.hasOwnProperty.call(kvs, k)) { - this.r[k].value = this.s[k] = (kvs as Partial)[k]; - cache[k] = (kvs as Partial)[k]; - } else { - this.r[k].value = this.s[k] = v.default; - } - } - } - - return set(this.registryCacheKeyName, cache); - }) - .then(() => resolve()); - }, 1); - } else { - resolve(); - } - }); - } - - public set(key: K, value: T[K]['default']): Promise { - // IndexedDBやBroadcastChannelで扱うために単純なオブジェクトにする - // (JSON.parse(JSON.stringify(value))の代わり) - const rawValue = deepClone(value); - - this.r[key].value = this.s[key] = rawValue; - - return this.addIdbSetJob(async () => { - switch (this.def[key].where) { - case 'device': { - this.pizzaxChannel.postMessage({ - where: 'device', - key, - value: rawValue, - }); - const deviceState = await get(this.deviceStateKeyName) || {}; - deviceState[key] = rawValue; - await set(this.deviceStateKeyName, deviceState); - break; - } - case 'deviceAccount': { - if ($i == null) break; - this.pizzaxChannel.postMessage({ - where: 'deviceAccount', - key, - value: rawValue, - userId: $i.id, - }); - const deviceAccountState = await get(this.deviceAccountStateKeyName) || {}; - deviceAccountState[key] = rawValue; - await set(this.deviceAccountStateKeyName, deviceAccountState); - break; - } - case 'account': { - if ($i == null) break; - const cache = await get(this.registryCacheKeyName) || {}; - cache[key] = rawValue; - await set(this.registryCacheKeyName, cache); - await misskeyApi('i/registry/set', { - scope: ['client', this.key], - key: key.toString(), - value: rawValue, - }); - break; - } - } - }); - } - - public push(key: K, value: ArrayElement): void { - const currentState = this.s[key]; - this.set(key, [...currentState, value]); - } - - public reset(key: keyof T) { - this.set(key, this.def[key].default); - return this.def[key].default; - } - - /** - * 特定のキーの、簡易的なgetter/setterを作ります - * 主にvue上で設定コントロールのmodelとして使う用 - */ - // TODO: 廃止 - public makeGetterSetter( - key: K, - getter?: (v: T[K]['default']) => R, - setter?: (v: R) => T[K]['default'], - ): { - get: () => R; - set: (value: R) => void; - } { - const valueRef = ref(this.s[key]); - - const stop = watch(this.r[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) => { - const val = setter ? setter(value) : value; - this.set(key, val); - valueRef.value = val; - }, - }; - } - - // localStorage => indexedDBのマイグレーション - private async migrate() { - const deviceState = localStorage.getItem(this.deviceStateKeyName); - if (deviceState) { - await set(this.deviceStateKeyName, JSON.parse(deviceState)); - localStorage.removeItem(this.deviceStateKeyName); - } - - const deviceAccountState = $i && localStorage.getItem(this.deviceAccountStateKeyName); - if ($i && deviceAccountState) { - await set(this.deviceAccountStateKeyName, JSON.parse(deviceAccountState)); - localStorage.removeItem(this.deviceAccountStateKeyName); - } - - const registryCache = $i && localStorage.getItem(this.registryCacheKeyName); - if ($i && registryCache) { - await set(this.registryCacheKeyName, JSON.parse(registryCache)); - localStorage.removeItem(this.registryCacheKeyName); - } - } -} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index de99b233d6..fc1d463674 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -11,13 +11,13 @@ import { hemisphere } from '@@/js/intl-const.js'; import type { DeviceKind } from '@/utility/device-kind.js'; import type { Plugin } from '@/plugin.js'; import { miLocalStorage } from '@/local-storage.js'; -import { Storage } from '@/pizzax.js'; +import { Pizzax } from '@/lib/pizzax.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; /** * 「状態」を管理するストア(not「設定」) */ -export const store = markRaw(new Storage('base', { +export const store = markRaw(new Pizzax('base', { accountSetupWizard: { where: 'account', default: 0, diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index bdca513a7a..c58b5d7aad 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -5,10 +5,10 @@ import { markRaw } from 'vue'; import type { Column } from '@/deck.js'; -import { Storage } from '@/pizzax.js'; +import { Pizzax } from '@/lib/pizzax.js'; // TODO: 消す(移行済みのため) -export const deckStore = markRaw(new Storage('deck', { +export const deckStore = markRaw(new Pizzax('deck', { profile: { where: 'deviceAccount', default: 'default', -- cgit v1.2.3-freya