refactor(frontend/pref): refactor preferences manager

Refactored preferences manager to decouple account context and storage provider, improving normalization and loading of profiles. Replaced static profile creation/normalization with instance-based logic, and updated usage in preferences.ts to pass account context explicitly. This enhances maintainability and prepares for better guest account handling.
This commit is contained in:
syuilo 2025-06-26 16:25:43 +09:00
parent 899273554a
commit 9a28fa0534
2 changed files with 110 additions and 112 deletions

View File

@ -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<Record<string, any>>;
@ -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();

View File

@ -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<PreferencesProfile, 'preferences'> & {
preferences: Record<string, any>;
};
export type StorageProvider = {
load: () => PossiblyNonNormalizedPreferencesProfile | null;
save: (ctx: { profile: PreferencesProfile; }) => void;
cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>;
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; } | null>;
@ -120,11 +124,64 @@ function isServerDependentKey<K extends keyof PREF>(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<void>;
@ -142,9 +199,15 @@ export class PreferencesManager {
[K in keyof PREF]: Ref<ValueOf<K>>;
};
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<K extends keyof PREF>(key: K): PrefRecord<K> {
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<K extends keyof PREF>(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<K extends keyof PREF>(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<K extends keyof PREF>(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;