diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 2208f75094..b8d5e66bf6 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { PreferencesProfile, StorageProvider } from '@/preferences/manager.js'; +import type { StorageProvider } from '@/preferences/manager.js'; import { cloudBackup } from '@/preferences/utility.js'; import { miLocalStorage } from '@/local-storage.js'; import { isSameScope, PreferencesManager } from '@/preferences/manager.js'; @@ -12,23 +12,18 @@ import { $i } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { TAB_ID } from '@/tab-id.js'; -function createPrefManager(storageProvider: StorageProvider) { - let profile: PreferencesProfile; - - const savedProfileRaw = miLocalStorage.getItem('preferences'); - if (savedProfileRaw == null) { - profile = PreferencesManager.newProfile(); - miLocalStorage.setItem('preferences', JSON.stringify(profile)); - } else { - profile = PreferencesManager.normalizeProfile(JSON.parse(savedProfileRaw)); - } - - return new PreferencesManager(profile, storageProvider); -} - const syncGroup = 'default'; -const storageProvider: StorageProvider = { +const io: StorageProvider = { + load: () => { + const savedProfileRaw = miLocalStorage.getItem('preferences'); + if (savedProfileRaw == null) { + return null; + } else { + return JSON.parse(savedProfileRaw); + } + }, + save: (ctx) => { miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile)); miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`); @@ -88,7 +83,7 @@ const storageProvider: StorageProvider = { cloudGetBulk: async (ctx) => { // TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要) - const fetchings = ctx.needs.map(need => storageProvider.cloudGet(need).then(res => [need.key, res] as const)); + const fetchings = ctx.needs.map(need => io.cloudGet(need).then(res => [need.key, res] as const)); const cloudDatas = await Promise.all(fetchings); const res = {} as Partial>; @@ -102,7 +97,7 @@ const storageProvider: StorageProvider = { }, }; -export const prefer = createPrefManager(storageProvider); +export const prefer = new PreferencesManager(io, $i); let latestSyncedAt = Date.now(); @@ -116,7 +111,7 @@ function syncBetweenTabs() { if (latestTab === TAB_ID) return; if (latestAt <= latestSyncedAt) return; - prefer.rewriteProfile(PreferencesManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!))); + prefer.reloadProfile(); latestSyncedAt = Date.now(); diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index d5499edf23..dc7acbdf0b 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -9,7 +9,6 @@ import { PREF_DEF } from './def.js'; import type { Ref, WritableComputedRef } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import { genId } from '@/utility/id.js'; -import { $i } from '@/i.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; @@ -80,7 +79,12 @@ export type PreferencesProfile = { }; }; +export type PossiblyNonNormalizedPreferencesProfile = Omit & { + preferences: Record; +}; + export type StorageProvider = { + load: () => PossiblyNonNormalizedPreferencesProfile | null; save: (ctx: { profile: PreferencesProfile; }) => void; cloudGetBulk: (ctx: { needs: { key: K; scope: Scope; }[] }) => Promise>>>; cloudGet: (ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf; } | null>; @@ -120,11 +124,64 @@ function isServerDependentKey(key: K): boolean { return (PREF_DEF as PreferencesDefinition)[key].serverDependent === true; } -// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すれば$iのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない +function createEmptyProfile(): PossiblyNonNormalizedPreferencesProfile { + return { + id: genId(), + version: version, + type: 'main', + modifiedAt: Date.now(), + name: '', + preferences: {}, + }; +} + +function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfile['preferences'], account: { id: string } | null): PreferencesProfile['preferences'] { + const data = {} as PreferencesProfile['preferences']; + for (const key in PREF_DEF) { + const records = preferences[key]; + if (records == null || records.length === 0) { + const v = getInitialPrefValue(key as keyof typeof PREF_DEF); + if (isAccountDependentKey(key as keyof typeof PREF_DEF)) { + data[key] = account ? [[makeScope({}), v, {}], [makeScope({ + server: host, + account: account.id, + }), v, {}]] : [[makeScope({}), v, {}]]; + } else if (isServerDependentKey(key as keyof typeof PREF_DEF)) { + data[key] = [[makeScope({ + server: host, + }), v, {}]]; + } else { + data[key] = [[makeScope({}), v, {}]]; + } + continue; + } else { + if (account && isAccountDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host && parseScope(scope).account === account!.id)) { + data[key] = records.concat([[makeScope({ + server: host, + account: account.id, + }), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]); + continue; + } + if (account && isServerDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host)) { + data[key] = records.concat([[makeScope({ + server: host, + }), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]); + continue; + } + + data[key] = records; + } + } + + return data; +} + +// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない // と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない // NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる export class PreferencesManager { - private storageProvider: StorageProvider; + private io: StorageProvider; + private currentAccount: { id: string } | null; public profile: PreferencesProfile; public cloudReady: Promise; @@ -142,9 +199,15 @@ export class PreferencesManager { [K in keyof PREF]: Ref>; }; - constructor(profile: PreferencesProfile, storageProvider: StorageProvider) { - this.profile = profile; - this.storageProvider = storageProvider; + constructor(io: StorageProvider, currentAccount: { id: string } | null) { + this.io = io; + this.currentAccount = currentAccount; + + const loadedProfile = this.io.load() ?? createEmptyProfile(); + this.profile = { + ...loadedProfile, + preferences: normalizePreferences(loadedProfile.preferences, currentAccount), + }; const states = this.genStates(); @@ -153,6 +216,11 @@ export class PreferencesManager { this.r[key] = ref(this.s[key]); } + // normalizeの結果変わっていたら保存 + if (!deepEqual(loadedProfile, this.profile)) { + this.save(); + } + this.cloudReady = this.fetchCloudValues(); // TODO: 定期的にクラウドの値をフェッチ @@ -181,7 +249,7 @@ export class PreferencesManager { if (parseScope(record[0]).account == null && isAccountDependentKey(key)) { this.profile.preferences[key].push([makeScope({ server: host, - account: $i!.id, + account: this.currentAccount!.id, }), v, {}]); this.save(); return; @@ -201,7 +269,7 @@ export class PreferencesManager { if (record[2].sync) { // awaitの必要なし // TODO: リクエストを間引く - this.storageProvider.cloudSet({ key, scope: record[0], value: record[1] }); + this.io.cloudSet({ key, scope: record[0], value: record[1] }); } } @@ -266,7 +334,7 @@ export class PreferencesManager { } } - const cloudValues = await this.storageProvider.cloudGetBulk({ needs }); + const cloudValues = await this.io.cloudGetBulk({ needs }); for (const _key in PREF_DEF) { const key = _key as keyof PREF; @@ -285,89 +353,18 @@ export class PreferencesManager { if (_DEV_) console.log('cloud fetch completed'); } - public static newProfile(): PreferencesProfile { - const data = {} as PreferencesProfile['preferences']; - for (const key in PREF_DEF) { - const v = getInitialPrefValue(key as keyof typeof PREF_DEF); - if (isAccountDependentKey(key as keyof typeof PREF_DEF)) { - data[key] = $i ? [[makeScope({}), v, {}], [makeScope({ - server: host, - account: $i.id, - }), v, {}]] : [[makeScope({}), v, {}]]; - } else if (isServerDependentKey(key as keyof typeof PREF_DEF)) { - data[key] = [[makeScope({ - server: host, - }), v, {}]]; - } else { - data[key] = [[makeScope({}), v, {}]]; - } - } - return { - id: genId(), - version: version, - type: 'main', - modifiedAt: Date.now(), - name: '', - preferences: data, - }; - } - - public static normalizeProfile(profileLike: any): PreferencesProfile { - const data = {} as PreferencesProfile['preferences']; - for (const key in PREF_DEF) { - const records = profileLike.preferences[key]; - if (records == null || records.length === 0) { - const v = getInitialPrefValue(key as keyof typeof PREF_DEF); - if (isAccountDependentKey(key as keyof typeof PREF_DEF)) { - data[key] = $i ? [[makeScope({}), v, {}], [makeScope({ - server: host, - account: $i.id, - }), v, {}]] : [[makeScope({}), v, {}]]; - } else if (isServerDependentKey(key as keyof typeof PREF_DEF)) { - data[key] = [[makeScope({ - server: host, - }), v, {}]]; - } else { - data[key] = [[makeScope({}), v, {}]]; - } - continue; - } else { - if ($i && isAccountDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id)) { - data[key] = records.concat([[makeScope({ - server: host, - account: $i.id, - }), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]); - continue; - } - if ($i && isServerDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host)) { - data[key] = records.concat([[makeScope({ - server: host, - }), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]); - continue; - } - - data[key] = records; - } - } - - return { - ...profileLike, - preferences: data, - }; - } - public save() { this.profile.modifiedAt = Date.now(); this.profile.version = version; - this.storageProvider.save({ profile: this.profile }); + this.io.save({ profile: this.profile }); } public getMatchedRecordOf(key: K): PrefRecord { const records = this.profile.preferences[key]; - if ($i == null) return records.find(([scope, v]) => parseScope(scope).account == null)!; + if (this.currentAccount == null) return records.find(([scope, v]) => parseScope(scope).account == null)!; - const accountOverrideRecord = records.find(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id); + const accountOverrideRecord = records.find(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === this.currentAccount!.id); if (accountOverrideRecord) return accountOverrideRecord; const serverOverrideRecord = records.find(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account == null); @@ -383,31 +380,31 @@ export class PreferencesManager { } public isAccountOverrided(key: K): boolean { - if ($i == null) return false; - return this.profile.preferences[key].some(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id); + if (this.currentAccount == null) return false; + return this.profile.preferences[key].some(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === this.currentAccount!.id); } public setAccountOverride(key: K) { - if ($i == null) return; + if (this.currentAccount == null) return; if (isAccountDependentKey(key)) throw new Error('already account-dependent'); if (this.isAccountOverrided(key)) return; const records = this.profile.preferences[key]; records.push([makeScope({ server: host, - account: $i!.id, + account: this.currentAccount!.id, }), this.s[key], {}]); this.save(); } public clearAccountOverride(key: K) { - if ($i == null) return; + if (this.currentAccount == null) return; if (isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property'); const records = this.profile.preferences[key]; - const index = records.findIndex(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id); + const index = records.findIndex(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === this.currentAccount!.id); if (index === -1) return; records.splice(index, 1); @@ -466,7 +463,7 @@ export class PreferencesManager { let newValue = record[1]; - const existing = await this.storageProvider.cloudGet({ key, scope: record[0] }); + const existing = await this.io.cloudGet({ key, scope: record[0] }); if (existing != null && !deepEqual(record[1], existing.value)) { const resolvedValue = await resolveConflict(record[1], existing.value); if (resolvedValue === undefined) return { enabled: false }; // canceled @@ -478,7 +475,7 @@ export class PreferencesManager { const done = os.waiting(); try { - await this.storageProvider.cloudSet({ key, scope: record[0], value: newValue }); + await this.io.cloudSet({ key, scope: record[0], value: newValue }); } catch (err) { done(); @@ -512,8 +509,14 @@ export class PreferencesManager { this.save(); } - public rewriteProfile(profile: PreferencesProfile) { - this.profile = profile; + public reloadProfile() { + const newProfile = this.io.load(); + if (newProfile == null) return; + + this.profile = { + ...newProfile, + preferences: normalizePreferences(newProfile.preferences, this.currentAccount), + }; const states = this.genStates(); for (const _key in states) { const key = _key as keyof PREF;