Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhance: PizzaxデータをindexedDBに保存するように #9225

Merged
merged 13 commits into from
Feb 2, 2023
5 changes: 5 additions & 0 deletions packages/frontend/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ 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';
import { miLocalStorage } from './local-storage';
import { claimAchievement, claimedAchievements } from './scripts/achievements';
import { fetchCustomEmojis } from './custom-emojis';
Expand Down Expand Up @@ -217,6 +218,8 @@ import { fetchCustomEmojis } from './custom-emojis';
splash.remove();
});

await deckStore.ready;

// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
// なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する
const rootEl = (() => {
Expand Down Expand Up @@ -267,6 +270,8 @@ import { fetchCustomEmojis } from './custom-emojis';
}
}

await defaultStore.loaded;

// NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため)
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
Expand Down
271 changes: 183 additions & 88 deletions packages/frontend/src/pizzax.ts
Original file line number Diff line number Diff line change
@@ -1,136 +1,209 @@
// PIZZAX --- A lightweight store

import { onUnmounted, Ref, ref, watch } from 'vue';
import { BroadcastChannel } from 'broadcast-channel';
import { $i } from './account';
import { api } from './os';
import { get, set } from './scripts/idb-proxy';
import { defaultStore } from './store';
import { stream } from './stream';
import { deepClone } from './scripts/clone';

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 ready: Promise<void>;
private markAsReady: () => void = () => {};
public readonly state: State<T>;
public readonly reactiveState: ReactiveState<T>;

constructor(key: string, def: T) {
this.ready = new Promise((res) => {
this.markAsReady = res;
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関数内でエラーになる(おそらく循環参照してることに原因がありそう)
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));
this.markAsReady();
});
}, 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);
}
});
});
} else {
this.markAsReady();
}
}

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しておく
window.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);
} else {
resolve();
}
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 set<K extends keyof T>(key: K, value: T[K]['default']): Promise<void> {
// IndexedDBやBroadcastChannelで扱うために単純なオブジェクトにする
// (JSON.parse(JSON.stringify(value))の代わり)
const rawValue = deepClone(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 {
Expand All @@ -140,6 +213,7 @@ export class Storage<T extends StateDef> {

public reset(key: keyof T) {
this.set(key, this.def[key].default);
return this.def[key].default;
}

/**
Expand Down Expand Up @@ -174,4 +248,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);
}
}
}
10 changes: 10 additions & 0 deletions packages/frontend/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,16 @@ export class ColdDeviceStorage {
}
}

public static getAll(): Partial<typeof this.default> {
return (Object.keys(this.default) as (keyof typeof this.default)[]).reduce((acc, key) => {
const value = localStorage.getItem(PREFIX + key);
if (value != null) {
acc[key] = JSON.parse(value);
}
return acc;
}, {} as any);
}

public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void {
// 呼び出し側のバグ等で undefined が来ることがある
// undefined を文字列として miLocalStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/ui/classic.vue
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ if (window.innerWidth < 1024) {

document.documentElement.style.overflowY = 'scroll';

defaultStore.ready.then(() => {
defaultStore.loaded.then(() => {
if (defaultStore.state.widgets.length === 0) {
defaultStore.set('widgets', [{
name: 'calendar',
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/ui/universal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ if (window.innerWidth > 1024) {
}
}

defaultStore.ready.then(() => {
defaultStore.loaded.then(() => {
if (defaultStore.state.widgets.length === 0) {
defaultStore.set('widgets', [{
name: 'calendar',
Expand Down