/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { markRaw, ref } from 'vue'; 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 { 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'; import { deepClone } from '@/utility/clone.js'; type StateDef = Record; type State = { [K in keyof T]: T[K]['default']; }; type PizzaxChannelMessage = { where: 'device' | 'deviceAccount'; key: keyof T; value: T[keyof T]['default']; userId?: string; }; // TODO: export消す export 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, key = 'base') { 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); 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()); this.addListener('updated', ({ key, value }) => { // 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 idb.get(this.deviceStateKeyName) || {}; deviceState[key] = rawValue; await idb.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 idb.get(this.deviceAccountStateKeyName) || {}; deviceAccountState[key] = rawValue; await idb.set(this.deviceAccountStateKeyName, deviceAccountState); break; } case 'account': { if ($i == null) break; const cache = await idb.get(this.registryCacheKeyName) || {}; cache[key] = rawValue; await idb.set(this.registryCacheKeyName, cache); await misskeyApi('i/registry/set', { scope: ['client', this.key], key: key.toString(), value: rawValue, }); break; } } }); }); } 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, }, timelineTutorials: { where: 'account', default: { home: false, local: false, social: false, global: false, }, }, abusesTutorial: { where: 'account', default: false, }, memo: { where: 'account', default: null, }, reactions: { where: 'account', default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], }, pinnedEmojis: { where: 'account', default: [], }, reactionAcceptance: { where: 'account', default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, }, mutedAds: { where: 'account', default: [] as string[], }, visibility: { where: 'deviceAccount', default: 'public' as (typeof Misskey.noteVisibilities)[number], }, localOnly: { where: 'deviceAccount', default: false, }, showPreview: { where: 'device', default: false, }, tl: { where: 'deviceAccount', default: { src: 'home' as 'home' | 'local' | 'social' | 'global' | `list:${string}`, userList: null as Misskey.entities.UserList | null, filter: { withReplies: true, withRenotes: true, withSensitive: true, onlyFiles: false, }, }, }, darkMode: { where: 'device', default: false, }, recentlyUsedEmojis: { where: 'device', default: [] as string[], }, recentlyUsedUsers: { where: 'device', default: [] as string[], }, menuDisplay: { where: 'device', default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', }, postFormWithHashtags: { where: 'device', default: false, }, postFormHashtags: { where: 'device', default: '', }, additionalUnicodeEmojiIndexes: { where: 'device', default: {} as Record>, }, defaultWithReplies: { where: 'account', default: false, }, pluginTokens: { where: 'deviceAccount', default: {} as Record, // plugin id, token }, 'deck.profile': { where: 'deviceAccount', default: 'default', }, 'deck.columns': { where: 'deviceAccount', default: [] as Column[], }, 'deck.layout': { where: 'deviceAccount', default: [] as Column['id'][][], }, enablePreferencesAutoCloudBackup: { where: 'device', default: false, }, showPreferencesAutoCloudBackupSuggestion: { where: 'device', default: true, }, //#region TODO: そのうち消す (preferに移行済み) widgets: { where: 'account', default: [] as { name: string; id: string; place: string | null; data: Record; }[], }, overridedDeviceKind: { where: 'device', default: null as DeviceKind | null, }, defaultSideView: { where: 'device', default: false, }, defaultNoteVisibility: { where: 'account', default: 'public' as (typeof Misskey.noteVisibilities)[number], }, defaultNoteLocalOnly: { where: 'account', default: false, }, keepCw: { where: 'account', default: true, }, collapseRenotes: { where: 'account', default: true, }, rememberNoteVisibility: { where: 'account', default: false, }, uploadFolder: { where: 'account', default: null as string | null, }, keepOriginalUploading: { where: 'account', default: false, }, menu: { where: 'deviceAccount', default: [], }, statusbars: { where: 'deviceAccount', default: [] as { name: string; id: string; type: string; size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge'; black: boolean; props: Record; }[], }, pinnedUserLists: { where: 'deviceAccount', default: [] as Misskey.entities.UserList[], }, serverDisconnectedBehavior: { where: 'device', default: 'quiet' as 'quiet' | 'reload' | 'dialog', }, nsfw: { where: 'device', default: 'respect' as 'respect' | 'force' | 'ignore', }, highlightSensitiveMedia: { where: 'device', default: false, }, animation: { where: 'device', default: !window.matchMedia('(prefers-reduced-motion)').matches, }, animatedMfm: { where: 'device', default: !window.matchMedia('(prefers-reduced-motion)').matches, }, advancedMfm: { where: 'device', default: true, }, showReactionsCount: { where: 'device', default: false, }, enableQuickAddMfmFunction: { where: 'device', default: false, }, loadRawImages: { where: 'device', default: false, }, imageNewTab: { where: 'device', default: false, }, disableShowingAnimatedImages: { where: 'device', default: window.matchMedia('(prefers-reduced-motion)').matches, }, emojiStyle: { where: 'device', default: 'twemoji', // twemoji / fluentEmoji / native }, menuStyle: { where: 'device', default: 'auto' as 'auto' | 'popup' | 'drawer', }, useBlurEffectForModal: { where: 'device', default: DEFAULT_DEVICE_KIND === 'desktop', }, useBlurEffect: { where: 'device', default: DEFAULT_DEVICE_KIND === 'desktop', }, showFixedPostForm: { where: 'device', default: false, }, showFixedPostFormInChannel: { where: 'device', default: false, }, enableInfiniteScroll: { where: 'device', default: true, }, useReactionPickerForContextMenu: { where: 'device', default: false, }, showGapBetweenNotesInTimeline: { where: 'device', default: false, }, instanceTicker: { where: 'device', default: 'remote' as 'none' | 'remote' | 'always', }, emojiPickerScale: { where: 'device', default: 1, }, emojiPickerWidth: { where: 'device', default: 1, }, emojiPickerHeight: { where: 'device', default: 2, }, emojiPickerStyle: { where: 'device', default: 'auto' as 'auto' | 'popup' | 'drawer', }, reportError: { where: 'device', default: false, }, squareAvatars: { where: 'device', default: false, }, showAvatarDecorations: { where: 'device', default: true, }, numberOfPageCache: { where: 'device', default: 3, }, showNoteActionsOnlyHover: { where: 'device', default: false, }, showClipButtonInNoteFooter: { where: 'device', default: false, }, reactionsDisplaySize: { where: 'device', default: 'medium' as 'small' | 'medium' | 'large', }, limitWidthOfReaction: { where: 'device', default: true, }, forceShowAds: { where: 'device', default: false, }, aiChanMode: { where: 'device', default: false, }, devMode: { where: 'device', default: false, }, mediaListWithOneImageAppearance: { where: 'device', default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3', }, notificationPosition: { where: 'device', default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom', }, notificationStackAxis: { where: 'device', default: 'horizontal' as 'vertical' | 'horizontal', }, enableCondensedLine: { where: 'device', default: true, }, keepScreenOn: { where: 'device', default: false, }, disableStreamingTimeline: { where: 'device', default: false, }, useGroupedNotifications: { where: 'device', default: true, }, dataSaver: { where: 'device', default: { media: false, avatar: false, urlPreview: false, code: false, } as Record, }, enableSeasonalScreenEffect: { where: 'device', default: false, }, enableHorizontalSwipe: { where: 'device', default: true, }, useNativeUIForVideoAudioPlayer: { where: 'device', default: false, }, keepOriginalFilename: { where: 'device', default: true, }, alwaysConfirmFollow: { where: 'device', default: true, }, confirmWhenRevealingSensitiveMedia: { where: 'device', default: false, }, contextMenu: { where: 'device', default: 'app' as 'app' | 'appWithShift' | 'native', }, skipNoteRender: { where: 'device', default: true, }, showSoftWordMutedWord: { where: 'device', default: false, }, confirmOnReact: { where: 'device', default: false, }, hemisphere: { where: 'device', default: hemisphere as 'N' | 'S', }, sound_masterVolume: { where: 'device', default: 0.3, }, sound_notUseSound: { where: 'device', default: false, }, sound_useSoundOnlyWhenActive: { where: 'device', default: false, }, sound_note: { where: 'device', default: { type: 'syuilo/n-aec', volume: 1 }, }, sound_noteMy: { where: 'device', default: { type: 'syuilo/n-cea-4va', volume: 1 }, }, sound_notification: { where: 'device', default: { type: 'syuilo/n-ea', volume: 1 }, }, sound_reaction: { where: 'device', default: { type: 'syuilo/bubble2', volume: 1 }, }, dropAndFusion: { where: 'device', default: { bgmVolume: 0.25, sfxVolume: 1, }, }, //#endregion } satisfies StateDef; /** * 「状態」を管理するストア(not「設定」) */ export const store = markRaw(new Store(STORE_DEF)); const PREFIX = 'miux:' as const; interface Watcher { key: string; callback: (value: unknown) => void; } // TODO: 消す(preferに移行済みのため) /** * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) */ export class ColdDeviceStorage { public static default = { lightTheme, // TODO: 消す(preferに移行済みのため) darkTheme, // TODO: 消す(preferに移行済みのため) syncDeviceDarkMode: true, // TODO: 消す(preferに移行済みのため) plugins: [] as Plugin[], // TODO: 消す(preferに移行済みのため) }; public static watchers: Watcher[] = []; public static get(key: T): typeof ColdDeviceStorage.default[T] { // TODO: indexedDBにする // ただしその際はnullチェックではなくキー存在チェックにしないとダメ // (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある) const value = miLocalStorage.getItem(`${PREFIX}${key}`); if (value == null) { return ColdDeviceStorage.default[key]; } else { return JSON.parse(value); } } public static getAll(): Partial { 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; }, {}); } public static set(key: T, value: typeof ColdDeviceStorage.default[T]): void { // 呼び出し側のバグ等で undefined が来ることがある // undefined を文字列として miLocalStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視 if (value === undefined) { console.error(`attempt to store undefined value for key '${key}'`); return; } miLocalStorage.setItem(`${PREFIX}${key}`, JSON.stringify(value)); for (const watcher of this.watchers) { if (watcher.key === key) watcher.callback(value); } } public static watch(key, callback) { this.watchers.push({ key, callback }); } // TODO: VueのcustomRef使うと良い感じになるかも public static ref(key: T) { const v = ColdDeviceStorage.get(key); const r = ref(v); // TODO: このままではwatcherがリークするので開放する方法を考える this.watch(key, v => { r.value = v; }); return r; } /** * 特定のキーの、簡易的なgetter/setterを作ります * 主にvue場で設定コントロールのmodelとして使う用 */ public static model(key: K) { // TODO: VueのcustomRef使うと良い感じになるかも const valueRef = ColdDeviceStorage.ref(key); return { get: () => { return valueRef.value; }, set: (value: typeof ColdDeviceStorage.default[K]) => { const val = value; ColdDeviceStorage.set(key, val); }, }; } }