diff options
Diffstat (limited to 'packages/client/src')
| -rw-r--r-- | packages/client/src/init.ts | 6 | ||||
| -rw-r--r-- | packages/client/src/pizzax.ts | 262 |
2 files changed, 189 insertions, 79 deletions
diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts index af70aec70a..bdd07b63a7 100644 --- a/packages/client/src/init.ts +++ b/packages/client/src/init.ts @@ -38,9 +38,12 @@ import { reloadChannel } from '@/scripts/unison-reload'; import { reactionPicker } from '@/scripts/reaction-picker'; import { getUrlWithoutLoginId } from '@/scripts/login-id'; import { getAccountFromId } from '@/scripts/get-account-from-id'; +import { deckStore } from './ui/deck/deck-store'; console.info(`Misskey v${version}`); +await defaultStore.ready; + if (_DEV_) { console.warn('Development mode!!!'); @@ -204,6 +207,9 @@ if (splash) splash.addEventListener('transitionend', () => { const rootEl = document.createElement('div'); document.body.appendChild(rootEl); + +if (ui === 'deck') await deckStore.ready; + app.mount(rootEl); // boot.jsのやつを解除 diff --git a/packages/client/src/pizzax.ts b/packages/client/src/pizzax.ts index dbbbfc228a..17a91af58b 100644 --- a/packages/client/src/pizzax.ts +++ b/packages/client/src/pizzax.ts @@ -1,126 +1,208 @@ import { onUnmounted, Ref, ref, watch } from 'vue'; import { $i } from './account'; import { api } from './os'; +import { get, set } from './scripts/idb-proxy'; +import { defaultStore } from './store'; import { stream } from './stream'; +import * as deepcopy from 'deepcopy'; +// SafariがBroadcastChannel未実装なのでライブラリを使う +import { BroadcastChannel } from 'broadcast-channel'; type StateDef = Record<string, { where: 'account' | 'device' | 'deviceAccount'; default: any; }>; +type State<T extends StateDef> = { [K in keyof T]: T[K]['default']; }; +type ReactiveState<T extends StateDef> = { [K in keyof T]: Ref<T[K]['default']>; }; + type ArrayElement<A> = A extends readonly (infer T)[] ? T : never; +type PizzaxChannelMessage<T extends StateDef> = { + where: 'device' | 'deviceAccount'; + key: keyof T; + value: T[keyof T]['default']; + userId?: string; +}; + const connection = $i && stream.useChannel('main'); export class Storage<T extends StateDef> { + public readonly ready: Promise<void>; + public readonly loaded: Promise<void>; + public readonly key: string; - public readonly keyForLocalStorage: 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 - public readonly state: { [K in keyof T]: T[K]['default'] }; - public readonly reactiveState: { [K in keyof T]: Ref<T[K]['default']> }; + public readonly state: State<T>; + public readonly reactiveState: ReactiveState<T>; + + private pizzaxChannel: BroadcastChannel<PizzaxChannelMessage<T>>; + + // 簡易的にキューイングして占有ロックとする + private currentIdbJob: Promise<any> = Promise.resolve(); + private addIdbSetJob<T>(job: () => Promise<T>) { + const promise = this.currentIdbJob.then(job, e => { + console.error('Pizzax failed to save data to idb!', e); + return job(); + }); + this.currentIdbJob = promise; + return promise; + } constructor(key: string, def: T) { this.key = key; - this.keyForLocalStorage = 'pizzax::' + key; + this.deviceStateKeyName = `pizzax::${key}`; + this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : ''; + this.registryCacheKeyName = $i ? `pizzax::${key}::cache::${$i.id}` : ''; 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) || '{}') : {}; + this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); + + this.state = {} as State<T>; + this.reactiveState = {} as ReactiveState<T>; + + 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 async init(): Promise<void> { + await this.migrate(); - const state = {}; - const reactiveState = {}; - for (const [k, v] of Object.entries(def)) { + const deviceState: State<T> = 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)) { - state[k] = deviceState[k]; + this.reactiveState[k].value = this.state[k] = deviceState[k]; } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { - state[k] = registryCache[k]; + this.reactiveState[k].value = this.state[k] = registryCache[k]; } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { - state[k] = deviceAccountState[k]; + this.reactiveState[k].value = this.state[k] = deviceAccountState[k]; } else { - state[k] = v.default; + this.reactiveState[k].value = this.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; + + 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) { - // なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう) - 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; + 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; - 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)); - } + 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); + } + }); }); } } - public set<K extends keyof T>(key: K, value: T[K]['default']): void { - if (_DEV_) console.log('set', key, value); - - this.state[key] = value; - this.reactiveState[key].value = value; + private load(): Promise<void> { + return new Promise((resolve, reject) => { + if ($i) { + // api関数と循環参照なので一応setTimeoutしておく + setTimeout(async () => { + await defaultStore.ready; - 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; + api('i/registry/get-all', { scope: ['client', this.key] }) + .then(kvs => { + const cache: Partial<T> = {}; + 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<T>)[k]; + cache[k] = (kvs as Partial<T>)[k]; + } else { + this.reactiveState[k].value = this.state[k] = v.default; + } + } + } + + return set(this.registryCacheKeyName, cache); + }) + .then(() => resolve()); + }, 1); } - 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; + + resolve(); + }); + } + + public set<K extends keyof T>(key: K, value: T[K]['default']): Promise<void> { + // IndexedDBやBroadcastChannelで扱うために単純なオブジェクトにする + // (JSON.parse(JSON.stringify(value))の代わり) + const rawValue = deepcopy(value); + + if (_DEV_) console.log('set', key, rawValue, value); + + this.reactiveState[key].value = this.state[key] = rawValue; + + return this.addIdbSetJob(async () => { + if (_DEV_) console.log(`set ${key} start`); + 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 api('i/registry/set', { + scope: ['client', this.key], + key: key.toString(), + value: rawValue + }); + break; + } } - } + if (_DEV_) console.log(`set ${key} complete`); + }); } public push<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): void { @@ -130,6 +212,7 @@ export class Storage<T extends StateDef> { public reset(key: keyof T) { this.set(key, this.def[key].default); + return this.def[key].default; } /** @@ -164,4 +247,25 @@ export class Storage<T extends StateDef> { } }; } + + // 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); + } + } } |