enhance(frontend): アカウントオーバーライド設定とデバイス間同期の併用に対応

This commit is contained in:
syuilo 2025-03-12 14:34:10 +09:00
parent 8410611512
commit f8e244f48d
3 changed files with 72 additions and 31 deletions

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div :class="$style.root"> <div :class="$style.root" @contextmenu.prevent.stop="showMenu($event, true)">
<div :class="$style.body"> <div :class="$style.body">
<slot></slot> <slot></slot>
</div> </div>
@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="isSyncEnabled" class="ti ti-cloud-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i> <i v-if="isSyncEnabled" class="ti ti-cloud-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i>
<i v-if="isAccountOverrided" class="ti ti-user-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i> <i v-if="isAccountOverrided" class="ti ti-user-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i>
<div :class="$style.buttons"> <div :class="$style.buttons">
<button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu"><i class="ti ti-dots"></i></button> <button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu($event)"><i class="ti ti-dots"></i></button>
</div> </div>
</div> </div>
</div> </div>
@ -32,16 +32,22 @@ const props = withDefaults(defineProps<{
const isAccountOverrided = ref(prefer.isAccountOverrided(props.k)); const isAccountOverrided = ref(prefer.isAccountOverrided(props.k));
const isSyncEnabled = ref(prefer.isSyncEnabled(props.k)); const isSyncEnabled = ref(prefer.isSyncEnabled(props.k));
function showMenu(ev: MouseEvent) { function showMenu(ev: MouseEvent, contextmenu?: boolean) {
const i = window.setInterval(() => { const i = window.setInterval(() => {
isAccountOverrided.value = prefer.isAccountOverrided(props.k); isAccountOverrided.value = prefer.isAccountOverrided(props.k);
isSyncEnabled.value = prefer.isSyncEnabled(props.k); isSyncEnabled.value = prefer.isSyncEnabled(props.k);
}, 100); }, 100);
if (contextmenu) {
os.contextMenu(prefer.getPerPrefMenu(props.k), ev).then(() => {
window.clearInterval(i);
});
} else {
os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, { os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
onClosing: () => { onClosing: () => {
window.clearInterval(i); window.clearInterval(i);
}, },
}); });
}
} }
</script> </script>

View File

@ -7,7 +7,7 @@ import { v4 as uuid } from 'uuid';
import type { PreferencesProfile, StorageProvider } from '@/preferences/profile.js'; import type { PreferencesProfile, StorageProvider } from '@/preferences/profile.js';
import { cloudBackup } from '@/preferences/utility.js'; import { cloudBackup } from '@/preferences/utility.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { ProfileManager } from '@/preferences/profile.js'; import { isSameCond, ProfileManager } from '@/preferences/profile.js';
import { store } from '@/store.js'; import { store } from '@/store.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
@ -28,22 +28,27 @@ function createProfileManager(storageProvider: StorageProvider) {
return new ProfileManager(profile, storageProvider); return new ProfileManager(profile, storageProvider);
} }
const syncGroup = 'default';
const storageProvider: StorageProvider = { const storageProvider: StorageProvider = {
save: (ctx) => { save: (ctx) => {
miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile)); miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile));
miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`); miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`);
}, },
cloudGet: async (ctx) => { cloudGet: async (ctx) => {
// TODO: この取得方法だとアカウントが変わると保存場所も変わってしまうので改修する // TODO: この取得方法だとアカウントが変わると保存場所も変わってしまうので改修する
// 例えば複数アカウントある場合でも設定値を保存するための「プライマリアカウント」を設定できるようにするとか // 例えば複数アカウントある場合でも設定値を保存するための「プライマリアカウント」を設定できるようにするとか
// TODO: keyのcondに応じた取得 // TODO: keyのcondに応じた取得
try { try {
const value = await misskeyApi('i/registry/get', { const cloudData = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'sync'], scope: ['client', 'preferences', 'sync'],
key: ctx.key, key: syncGroup + ':' + ctx.key,
}); }) as [any, any][];
const target = cloudData.find(([cond]) => isSameCond(cond, ctx.cond));
if (target == null) return null;
return { return {
value, value: target[1],
}; };
} catch (err: any) { } catch (err: any) {
if (err.code === 'NO_SUCH_KEY') { if (err.code === 'NO_SUCH_KEY') {
@ -53,11 +58,34 @@ const storageProvider: StorageProvider = {
} }
} }
}, },
cloudSet: async (ctx) => { cloudSet: async (ctx) => {
let cloudData: [any, any][] = [];
try {
cloudData = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'sync'],
key: syncGroup + ':' + ctx.key,
}) as [any, any][];
} catch (err: any) {
if (err.code === 'NO_SUCH_KEY') {
cloudData = [];
} else {
throw err;
}
}
const i = cloudData.findIndex(([cond]) => isSameCond(cond, ctx.cond));
if (i === -1) {
cloudData.push([ctx.cond, ctx.value]);
} else {
cloudData[i] = [ctx.cond, ctx.value];
}
await misskeyApi('i/registry/set', { await misskeyApi('i/registry/set', {
scope: ['client', 'preferences', 'sync'], scope: ['client', 'preferences', 'sync'],
key: ctx.key, key: syncGroup + ':' + ctx.key,
value: ctx.value, value: cloudData,
}); });
}, },
}; };

View File

@ -60,6 +60,12 @@ function makeCond(cond: Partial<{
return c; return c;
} }
export function isSameCond(a: Cond, b: Cond): boolean {
// null と undefined (キー無し) は区別したくないので == で比較
// eslint-disable-next-line eqeqeq
return a.server == b.server && a.account == b.account && a.device == b.device;
}
export type PreferencesProfile = { export type PreferencesProfile = {
id: string; id: string;
version: string; version: string;
@ -73,8 +79,8 @@ export type PreferencesProfile = {
export type StorageProvider = { export type StorageProvider = {
save: (ctx: { profile: PreferencesProfile; }) => void; save: (ctx: { profile: PreferencesProfile; }) => void;
cloudGet: <K extends keyof PREF>(ctx: { key: K; }) => Promise<{ value: ValueOf<K>; } | null>; cloudGet: <K extends keyof PREF>(ctx: { key: K; cond: Cond; }) => Promise<{ value: ValueOf<K>; } | null>;
cloudSet: <K extends keyof PREF>(ctx: { key: K; value: ValueOf<K>; }) => Promise<void>; cloudSet: <K extends keyof PREF>(ctx: { key: K; cond: Cond; value: ValueOf<K>; }) => Promise<void>;
}; };
export class ProfileManager { export class ProfileManager {
@ -121,7 +127,7 @@ export class ProfileManager {
this.rewriteRawState(key, value); this.rewriteRawState(key, value);
const record = this.getMatchedRecord(key); const record = this.getMatchedRecordOf(key);
if (parseCond(record[0]).account == null && PREF_DEF[key].accountDependent) { if (parseCond(record[0]).account == null && PREF_DEF[key].accountDependent) {
this.profile.preferences[key].push([makeCond({ this.profile.preferences[key].push([makeCond({
account: `${host}/${$i!.id}`, account: `${host}/${$i!.id}`,
@ -130,14 +136,14 @@ export class ProfileManager {
return; return;
} }
record[1] = value;
this.save();
if (record[2].sync) { if (record[2].sync) {
// awaitの必要なし // awaitの必要なし
// TODO: リクエストを間引く // TODO: リクエストを間引く
this.storageProvider.cloudSet({ key, value }); this.storageProvider.cloudSet({ key, cond: record[0], value: record[1] });
} }
record[1] = value;
this.save();
} }
/** /**
@ -180,7 +186,7 @@ export class ProfileManager {
private genStates() { private genStates() {
const states = {} as { [K in keyof PREF]: ValueOf<K> }; const states = {} as { [K in keyof PREF]: ValueOf<K> };
for (const key in PREF_DEF) { for (const key in PREF_DEF) {
const record = this.getMatchedRecord(key); const record = this.getMatchedRecordOf(key);
states[key] = record[1]; states[key] = record[1];
} }
@ -192,9 +198,9 @@ export class ProfileManager {
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
for (const key in PREF_DEF) { for (const key in PREF_DEF) {
const record = this.getMatchedRecord(key); const record = this.getMatchedRecordOf(key);
if (record[2].sync) { if (record[2].sync) {
const getting = this.storageProvider.cloudGet({ key }); const getting = this.storageProvider.cloudGet({ key, cond: record[0] });
promises.push(getting.then((res) => { promises.push(getting.then((res) => {
if (res == null) return; if (res == null) return;
const value = res.value; const value = res.value;
@ -261,7 +267,7 @@ export class ProfileManager {
this.storageProvider.save({ profile: this.profile }); this.storageProvider.save({ profile: this.profile });
} }
public getMatchedRecord<K extends keyof PREF>(key: K): PrefRecord<K> { public getMatchedRecordOf<K extends keyof PREF>(key: K): PrefRecord<K> {
const records = this.profile.preferences[key]; const records = this.profile.preferences[key];
if ($i == null) return records.find(([cond, v]) => parseCond(cond).account == null)!; if ($i == null) return records.find(([cond, v]) => parseCond(cond).account == null)!;
@ -302,19 +308,21 @@ export class ProfileManager {
records.splice(index, 1); records.splice(index, 1);
this.rewriteRawState(key, this.getMatchedRecord(key)[1]); this.rewriteRawState(key, this.getMatchedRecordOf(key)[1]);
this.save(); this.save();
} }
public isSyncEnabled<K extends keyof PREF>(key: K): boolean { public isSyncEnabled<K extends keyof PREF>(key: K): boolean {
return this.getMatchedRecord(key)[2].sync ?? false; return this.getMatchedRecordOf(key)[2].sync ?? false;
} }
public async enableSync<K extends keyof PREF>(key: K): Promise<{ enabled: boolean; } | null> { public async enableSync<K extends keyof PREF>(key: K): Promise<{ enabled: boolean; } | null> {
if (this.isSyncEnabled(key)) return Promise.resolve(null); if (this.isSyncEnabled(key)) return Promise.resolve(null);
const existing = await this.storageProvider.cloudGet({ key }); const record = this.getMatchedRecordOf(key);
const existing = await this.storageProvider.cloudGet({ key, cond: record[0] });
if (existing != null) { if (existing != null) {
const { canceled, result } = await os.select({ const { canceled, result } = await os.select({
title: i18n.ts.preferenceSyncConflictTitle, title: i18n.ts.preferenceSyncConflictTitle,
@ -340,12 +348,11 @@ export class ProfileManager {
} }
} }
const record = this.getMatchedRecord(key);
record[2].sync = true; record[2].sync = true;
this.save(); this.save();
// awaitの必要性は無い // awaitの必要性は無い
this.storageProvider.cloudSet({ key, value: this.s[key] }); this.storageProvider.cloudSet({ key, cond: record[0], value: this.s[key] });
return { enabled: true }; return { enabled: true };
} }
@ -353,7 +360,7 @@ export class ProfileManager {
public disableSync<K extends keyof PREF>(key: K) { public disableSync<K extends keyof PREF>(key: K) {
if (!this.isSyncEnabled(key)) return; if (!this.isSyncEnabled(key)) return;
const record = this.getMatchedRecord(key); const record = this.getMatchedRecordOf(key);
delete record[2].sync; delete record[2].sync;
this.save(); this.save();
} }