diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts index 871e454ff0..be89644cb6 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/pizzax.ts @@ -3,252 +3,68 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// PIZZAX --- A lightweight store +import { computed, onUnmounted, ref, watch } from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import type { Ref, WritableComputedRef } from 'vue'; -import { onUnmounted, ref, watch } from 'vue'; -import { BroadcastChannel } from 'broadcast-channel'; -import type { Ref } from 'vue'; -import { $i } from '@/account.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'; +// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない -type StateDef = Record; +//type DottedToNested> = { +// [K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K]; +//}; -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; +type PizzaxEvent> = { + updated: (ctx: { + key: K; + value: Data[K]; + }) => void; }; -export class Storage { - public readonly ready: Promise; - public readonly loaded: Promise; +export class Pizzax> extends EventEmitter> { + /** + * static の略 (static が予約語のため) + */ + public s = {} as { + [K in keyof Data]: Data[K]; + }; - 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}` | ''; + /** + * reactive の略 + */ + public r = {} as { + [K in keyof Data]: Ref; + }; - public readonly def: T; + constructor(data: { [K in keyof Data]: Data[K] }) { + super(); - // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 - public readonly state: State; - public readonly reactiveState: 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.state = {} as State; - this.reactiveState = {} as ReactiveState; - - for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { - this.state[k] = v.default; - this.reactiveState[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.reactiveState[k].value = this.state[k] = this.mergeState(deviceState[k], v.default); - } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState(registryCache[k], v.default); - } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState(deviceAccountState[k], v.default); - } else { - this.reactiveState[k].value = this.state[k] = v.default; - } - } - - this.pizzaxChannel.addEventListener('message', ({ where, key, value, userId }) => { - // アカウント変更すればunisonReloadが効くため、このreturnが発火することは - // まずないと思うけど一応弾いておく - if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; - this.reactiveState[key].value = this.state[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.state[key] === value) return; - - this.reactiveState[key].value = this.state[key] = value; - - this.addIdbSetJob(async () => { - const cache = await get(this.registryCacheKeyName); - if (cache[key] !== value) { - cache[key] = value; - await set(this.registryCacheKeyName, cache); - } - }); - }); + for (const key in data) { + this.s[key] = data[key]; + this.r[key] = ref(this.s[key]); } } - 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.reactiveState[k].value = this.state[k] = (kvs as Partial)[k]; - cache[k] = (kvs as Partial)[k]; - } else { - this.reactiveState[k].value = this.state[k] = v.default; - } - } - } - - return set(this.registryCacheKeyName, cache); - }) - .then(() => resolve()); - }, 1); - } else { - resolve(); - } - }); + public set(key: K, value: Data[K]) { + this.r[key].value = this.s[key] = value; + this.emit('updated', { key, value }); } - public set(key: K, value: T[K]['default']): Promise { - // IndexedDBやBroadcastChannelで扱うために単純なオブジェクトにする - // (JSON.parse(JSON.stringify(value))の代わり) - const rawValue = deepClone(value); - - this.reactiveState[key].value = this.state[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.state[key]; - this.set(key, [...currentState, value]); - } - - public reset(key: keyof T) { - this.set(key, this.def[key].default); - return this.def[key].default; + public rewrite(key: K, value: Data[K]) { + this.r[key].value = this.s[key] = value; } /** - * 特定のキーの、簡易的なgetter/setterを作ります + * 特定のキーの、簡易的なcomputed refを作ります * 主にvue上で設定コントロールのmodelとして使う用 */ - // TODO: 廃止 - public makeGetterSetter( + public model( key: K, - getter?: (v: T[K]['default']) => R, - setter?: (v: R) => T[K]['default'], - ): { - get: () => R; - set: (value: R) => void; - } { - const valueRef = ref(this.state[key]); + getter?: (v: Data[K]) => V, + setter?: (v: V) => Data[K], + ): WritableComputedRef { + const valueRef = ref(this.s[key]); - const stop = watch(this.reactiveState[key], val => { + const stop = watch(this.r[key], val => { valueRef.value = val; }); @@ -258,7 +74,7 @@ export class Storage { }); // TODO: VueのcustomRef使うと良い感じになるかも - return { + return computed({ get: () => { if (getter) { return getter(valueRef.value); @@ -271,27 +87,6 @@ export class Storage { 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/preferences/profile.ts b/packages/frontend/src/preferences/profile.ts index 1d03639d37..146373039c 100644 --- a/packages/frontend/src/preferences/profile.ts +++ b/packages/frontend/src/preferences/profile.ts @@ -8,11 +8,11 @@ import { v4 as uuid } from 'uuid'; import { host, version } from '@@/js/config.js'; import { EventEmitter } from 'eventemitter3'; import { PREF_DEF } from './def.js'; -import { Store } from './store.js'; import type { MenuItem } from '@/types/menu.js'; import { $i } from '@/account.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; +import { Pizzax } from '@/pizzax.js'; // NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない @@ -48,7 +48,7 @@ export class ProfileManager extends EventEmitter<{ }) => void; }> { public profile: PreferencesProfile; - public store: Store<{ + public store: Pizzax<{ [K in keyof PREF]: ValueOf; }>; @@ -58,7 +58,7 @@ export class ProfileManager extends EventEmitter<{ const states = this.genStates(); - this.store = new Store(states); + this.store = new Pizzax(states); this.store.addListener('updated', ({ key, value }) => { console.log('prefer:set', key, value); diff --git a/packages/frontend/src/preferences/store.ts b/packages/frontend/src/preferences/store.ts deleted file mode 100644 index 1029346d81..0000000000 --- a/packages/frontend/src/preferences/store.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { computed, onUnmounted, ref, watch } from 'vue'; -import { EventEmitter } from 'eventemitter3'; -import type { Ref, WritableComputedRef } from 'vue'; - -// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない - -//type DottedToNested> = { -// [K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K]; -//}; - -type StoreEvent> = { - updated: (ctx: { - key: K; - value: Data[K]; - }) => void; -}; - -export class Store> extends EventEmitter> { - /** - * static の略 (static が予約語のため) - */ - public s = {} as { - [K in keyof Data]: Data[K]; - }; - - /** - * reactive の略 - */ - public r = {} as { - [K in keyof Data]: Ref; - }; - - constructor(data: { [K in keyof Data]: Data[K] }) { - super(); - - for (const key in data) { - this.s[key] = data[key]; - this.r[key] = ref(this.s[key]); - } - } - - public set(key: K, value: Data[K]) { - this.r[key].value = this.s[key] = value; - this.emit('updated', { key, value }); - } - - public rewrite(key: K, value: Data[K]) { - this.r[key].value = this.s[key] = value; - } - - /** - * 特定のキーの、簡易的なcomputed refを作ります - * 主にvue上で設定コントロールのmodelとして使う用 - */ - public model( - key: K, - getter?: (v: Data[K]) => V, - setter?: (v: V) => Data[K], - ): WritableComputedRef { - 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 computed({ - 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; - }, - }); - } -} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 8f718ebcdf..edbd7da608 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -8,17 +8,171 @@ import * as Misskey from 'misskey-js'; import lightTheme from '@@/themes/l-light.json5'; import darkTheme from '@@/themes/d-green-lime.json5'; import { hemisphere } from '@@/js/intl-const.js'; +import { BroadcastChannel } from 'broadcast-channel'; import type { DeviceKind } from '@/utility/device-kind.js'; import type { Plugin } from '@/plugin.js'; import type { Column } from '@/deck.js'; import { miLocalStorage } from '@/local-storage.js'; -import { Storage } from '@/pizzax.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; +import { Pizzax } from '@/pizzax.js'; +import { $i } from '@/account.js'; +import * as idb from '@/utility/idb-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useStream } from '@/stream.js'; +import { deepMerge } from '@/utility/merge.js'; -/** - * 「状態」を管理するストア(not「設定」) - */ -export const store = markRaw(new Storage('base', { +type StateDef = Record; + +type State = { [K in keyof T]: T[K]['default']; }; + +type ArrayElement = A extends readonly (infer T)[] ? T : never; + +type PizzaxChannelMessage = { + where: 'device' | 'deviceAccount'; + key: keyof T; + value: T[keyof T]['default']; + userId?: string; +}; + +class Store extends Pizzax> { + public readonly def: T; + + 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}` | ''; + + 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(def: T) { + const data = {} as State; + + for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { + data[k] = v.default; + } + + super(data); + + const key = 'base'; + 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.ready = this.init(); + this.loaded = this.ready.then(() => this.load()); + } + + private async init(): Promise { + const deviceState: State = await idb.get(this.deviceStateKeyName) || {}; + const deviceAccountState = $i ? await idb.get(this.deviceAccountStateKeyName) || {} : {}; + const registryCache = $i ? await idb.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.rewrite(k, this.mergeState(deviceState[k], v.default)); + } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { + this.rewrite(k, this.mergeState(registryCache[k], v.default)); + } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { + this.rewrite(k, this.mergeState(deviceAccountState[k], v.default)); + } else { + this.rewrite(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.rewrite(key, value); + + this.addIdbSetJob(async () => { + const cache = await idb.get(this.registryCacheKeyName); + if (cache[key] !== value) { + cache[key] = value; + await idb.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.rewrite(k, (kvs as Partial)[k]); + cache[k] = (kvs as Partial)[k]; + } else { + this.r[k].value = this.s[k] = v.default; + } + } + } + + return idb.set(this.registryCacheKeyName, cache); + }) + .then(() => resolve()); + }, 1); + } else { + resolve(); + } + }); + } + + 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; + } +} + +const STORE_DEF = { accountSetupWizard: { where: 'account', default: 0, @@ -465,9 +619,12 @@ export const store = markRaw(new Storage('base', { }, }, //#endregion -})); +} satisfies StateDef; -// TODO: 他のタブと永続化されたstateを同期 +/** + * 「状態」を管理するストア(not「設定」) + */ +export const store = markRaw(new Store(STORE_DEF)); const PREFIX = 'miux:' as const;