enhance(frontend): 設定の同期をオンにするときに競合したときに値をマージできるように

This commit is contained in:
syuilo 2025-05-31 12:49:10 +09:00
parent 9f196bbf75
commit 0254570fbf
8 changed files with 85 additions and 28 deletions

View File

@ -39,6 +39,7 @@
- Feat: 絵文字をミュート可能にする機能 - Feat: 絵文字をミュート可能にする機能
- 絵文字(ユニコードの絵文字・カスタム絵文字)毎にミュートし、不可視化することができるようになりました - 絵文字(ユニコードの絵文字・カスタム絵文字)毎にミュートし、不可視化することができるようになりました
- Feat: モバイルデバイスで折りたたまれたUIの展開表示に全画面ページを使用できるように(実験的) - Feat: モバイルデバイスで折りたたまれたUIの展開表示に全画面ページを使用できるように(実験的)
- Enhance: 設定の同期をオンにするときに競合したときに値をマージできるように
- Enhance: メモリ使用量を軽減しました - Enhance: メモリ使用量を軽減しました
- Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加 - Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加
- Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように - Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように

10
locales/index.d.ts vendored
View File

@ -5335,15 +5335,19 @@ export interface Locale extends ILocale {
*/ */
"preferenceSyncConflictTitle": string; "preferenceSyncConflictTitle": string;
/** /**
* *
*/ */
"preferenceSyncConflictText": string; "preferenceSyncConflictText": string;
/** /**
* *
*/
"preferenceSyncConflictChoiceMerge": string;
/**
*
*/ */
"preferenceSyncConflictChoiceServer": string; "preferenceSyncConflictChoiceServer": string;
/** /**
* *
*/ */
"preferenceSyncConflictChoiceDevice": string; "preferenceSyncConflictChoiceDevice": string;
/** /**

View File

@ -1329,9 +1329,10 @@ skip: "スキップ"
restore: "復元" restore: "復元"
syncBetweenDevices: "デバイス間で同期" syncBetweenDevices: "デバイス間で同期"
preferenceSyncConflictTitle: "サーバーに設定値が存在します" preferenceSyncConflictTitle: "サーバーに設定値が存在します"
preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか?" preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どうしますか?"
preferenceSyncConflictChoiceServer: "サーバーの設定値" preferenceSyncConflictChoiceMerge: "統合する"
preferenceSyncConflictChoiceDevice: "デバイスの設定値" preferenceSyncConflictChoiceServer: "サーバーの設定値で上書き"
preferenceSyncConflictChoiceDevice: "デバイスの設定値で上書き"
preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル" preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル"
paste: "ペースト" paste: "ペースト"
emojiPalette: "絵文字パレット" emojiPalette: "絵文字パレット"

View File

@ -69,6 +69,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { PREF_DEF } from '@/preferences/def.js'; import { PREF_DEF } from '@/preferences/def.js';
import { getInitialPrefValue } from '@/preferences/manager.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@ -106,7 +107,7 @@ async function save() {
} }
function reset() { function reset() {
items.value = PREF_DEF.menu.default.map(x => ({ items.value = getInitialPrefValue('menu').map(x => ({
id: Math.random().toString(), id: Math.random().toString(),
type: x, type: x,
})); }));

View File

@ -75,6 +75,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import { PREF_DEF } from '@/preferences/def.js'; import { PREF_DEF } from '@/preferences/def.js';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { getInitialPrefValue } from '@/preferences/manager.js';
const notUseSound = prefer.model('sound.notUseSound'); const notUseSound = prefer.model('sound.notUseSound');
const useSoundOnlyWhenActive = prefer.model('sound.useSoundOnlyWhenActive'); const useSoundOnlyWhenActive = prefer.model('sound.useSoundOnlyWhenActive');
@ -113,7 +114,7 @@ async function updated(type: keyof typeof sounds.value, sound) {
function reset() { function reset() {
for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) { for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) {
const v = PREF_DEF[`sound.on.${sound}`].default; const v = getInitialPrefValue(`sound.on.${sound}`);
prefer.commit(`sound.on.${sound}`, v); prefer.commit(`sound.on.${sound}`, v);
sounds.value[sound] = v; sounds.value[sound] = v;
} }

View File

@ -5,6 +5,7 @@
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { hemisphere } from '@@/js/intl-const.js'; import { hemisphere } from '@@/js/intl-const.js';
import { v4 as uuid } from 'uuid';
import type { Theme } from '@/theme.js'; import type { Theme } from '@/theme.js';
import type { SoundType } from '@/utility/sound.js'; import type { SoundType } from '@/utility/sound.js';
import type { Plugin } from '@/plugin.js'; import type { Plugin } from '@/plugin.js';
@ -49,15 +50,15 @@ export const PREF_DEF = {
}, },
widgets: { widgets: {
accountDependent: true, accountDependent: true,
default: [{ default: () => [{
name: 'calendar', name: 'calendar',
id: 'a', place: 'right', data: {}, id: uuid(), place: 'right', data: {},
}, { }, {
name: 'notifications', name: 'notifications',
id: 'b', place: 'right', data: {}, id: uuid(), place: 'right', data: {},
}, { }, {
name: 'trends', name: 'trends',
id: 'c', place: 'right', data: {}, id: uuid(), place: 'right', data: {},
}] as { }] as {
name: string; name: string;
id: string; id: string;
@ -76,8 +77,8 @@ export const PREF_DEF = {
emojiPalettes: { emojiPalettes: {
serverDependent: true, serverDependent: true,
default: [{ default: () => [{
id: 'a', id: uuid(),
name: '', name: '',
emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
}] as { }] as {
@ -85,6 +86,11 @@ export const PREF_DEF = {
name: string; name: string;
emojis: string[]; emojis: string[];
}[], }[],
mergeStrategy: (a, b) => {
const sameIdExists = a.some(x => b.some(y => x.id === y.id));
if (sameIdExists) throw new Error();
return a.concat(b);
},
}, },
emojiPaletteForReaction: { emojiPaletteForReaction: {
serverDependent: true, serverDependent: true,
@ -100,6 +106,11 @@ export const PREF_DEF = {
}, },
themes: { themes: {
default: [] as Theme[], default: [] as Theme[],
mergeStrategy: (a, b) => {
const sameIdExists = a.some(x => b.some(y => x.id === y.id));
if (sameIdExists) throw new Error();
return a.concat(b);
},
}, },
lightTheme: { lightTheme: {
default: null as Theme | null, default: null as Theme | null,
@ -345,9 +356,19 @@ export const PREF_DEF = {
}, },
plugins: { plugins: {
default: [] as Plugin[], default: [] as Plugin[],
mergeStrategy: (a, b) => {
const sameIdExists = a.some(x => b.some(y => x.installId === y.installId));
if (sameIdExists) throw new Error();
const sameNameExists = a.some(x => b.some(y => x.name === y.name));
if (sameNameExists) throw new Error();
return a.concat(b);
},
}, },
mutingEmojis: { mutingEmojis: {
default: [] as string[], default: [] as string[],
mergeStrategy: (a, b) => {
return [...new Set(a.concat(b))];
},
}, },
'sound.masterVolume': { 'sound.masterVolume': {

View File

@ -22,7 +22,10 @@ import { deepEqual } from '@/utility/deep-equal.js';
//}; //};
type PREF = typeof PREF_DEF; type PREF = typeof PREF_DEF;
type ValueOf<K extends keyof PREF> = PREF[K]['default']; type DefaultValues = {
[K in keyof PREF]: PREF[K]['default'] extends (...args: any) => infer R ? R : PREF[K]['default'];
};
type ValueOf<K extends keyof PREF> = DefaultValues[K];
type Scope = Partial<{ type Scope = Partial<{
server: string | null; // host server: string | null; // host
@ -84,11 +87,22 @@ export type StorageProvider = {
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>; cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>;
}; };
export type PreferencesDefinition = Record<string, { type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
default: any; default: Default;
accountDependent?: boolean; accountDependent?: boolean;
serverDependent?: boolean; serverDependent?: boolean;
}>; mergeStrategy?: (a: T, b: T) => T;
};
export type PreferencesDefinition = Record<string, PreferencesDefinitionRecord<any>>;
export function getInitialPrefValue<K extends keyof PREF>(k: K): ValueOf<K> {
if (typeof PREF_DEF[k].default === 'function') { // factory
return PREF_DEF[k].default();
} else {
return PREF_DEF[k].default;
}
}
export class PreferencesManager { export class PreferencesManager {
private storageProvider: StorageProvider; private storageProvider: StorageProvider;
@ -262,7 +276,7 @@ export class PreferencesManager {
public static newProfile(): PreferencesProfile { public static newProfile(): PreferencesProfile {
const data = {} as PreferencesProfile['preferences']; const data = {} as PreferencesProfile['preferences'];
for (const key in PREF_DEF) { for (const key in PREF_DEF) {
data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; data[key] = [[makeScope({}), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]];
} }
return { return {
id: uuid(), id: uuid(),
@ -279,7 +293,7 @@ export class PreferencesManager {
for (const key in PREF_DEF) { for (const key in PREF_DEF) {
const records = profileLike.preferences[key]; const records = profileLike.preferences[key];
if (records == null || records.length === 0) { if (records == null || records.length === 0) {
data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; data[key] = [[makeScope({}), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]];
continue; continue;
} else { } else {
data[key] = records; data[key] = records;
@ -367,10 +381,20 @@ export class PreferencesManager {
const existing = await this.storageProvider.cloudGet({ key, scope: record[0] }); const existing = await this.storageProvider.cloudGet({ key, scope: record[0] });
if (existing != null && !deepEqual(existing.value, record[1])) { if (existing != null && !deepEqual(existing.value, record[1])) {
const { canceled, result } = await os.select({ const merge = (PREF_DEF as PreferencesDefinition)[key].mergeStrategy;
let mergedValue: ValueOf<K> | undefined = undefined; // null と区別したいため
try {
if (merge != null) mergedValue = merge(record[1], existing.value);
} catch (err) {
// nop
}
const { canceled, result: choice } = await os.select({
title: i18n.ts.preferenceSyncConflictTitle, title: i18n.ts.preferenceSyncConflictTitle,
text: i18n.ts.preferenceSyncConflictText, text: i18n.ts.preferenceSyncConflictText,
items: [{ items: [...(mergedValue !== undefined ? [{
text: i18n.ts.preferenceSyncConflictChoiceMerge,
value: 'merge',
}] : []), {
text: i18n.ts.preferenceSyncConflictChoiceServer, text: i18n.ts.preferenceSyncConflictChoiceServer,
value: 'remote', value: 'remote',
}, { }, {
@ -380,14 +404,16 @@ export class PreferencesManager {
text: i18n.ts.preferenceSyncConflictChoiceCancel, text: i18n.ts.preferenceSyncConflictChoiceCancel,
value: null, value: null,
}], }],
default: 'remote', default: mergedValue !== undefined ? 'merge' : 'remote',
}); });
if (canceled || result == null) return { enabled: false }; if (canceled || choice == null) return { enabled: false };
if (result === 'remote') { if (choice === 'remote') {
this.commit(key, existing.value); this.commit(key, existing.value);
} else if (result === 'local') { } else if (choice === 'local') {
// nop // nop
} else if (choice === 'merge') {
this.commit(key, mergedValue!);
} }
} }
@ -457,7 +483,7 @@ export class PreferencesManager {
text: i18n.ts.resetToDefaultValue, text: i18n.ts.resetToDefaultValue,
danger: true, danger: true,
action: () => { action: () => {
this.commit(key, PREF_DEF[key].default); this.commit(key, getInitialPrefValue(key));
}, },
}, { }, {
type: 'divider', type: 'divider',

View File

@ -6,6 +6,7 @@
import type { SoundStore } from '@/preferences/def.js'; import type { SoundStore } from '@/preferences/def.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { PREF_DEF } from '@/preferences/def.js'; import { PREF_DEF } from '@/preferences/def.js';
import { getInitialPrefValue } from '@/preferences/manager.js';
let ctx: AudioContext; let ctx: AudioContext;
const cache = new Map<string, AudioBuffer>(); const cache = new Map<string, AudioBuffer>();
@ -133,7 +134,8 @@ export function playMisskeySfx(operationType: OperationType) {
playMisskeySfxFile(sound).then((succeed) => { playMisskeySfxFile(sound).then((succeed) => {
if (!succeed && sound.type === '_driveFile_') { if (!succeed && sound.type === '_driveFile_') {
// ドライブファイルが存在しない場合はデフォルトのサウンドを再生する // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
const soundName = PREF_DEF[`sound.on.${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>; const default_ = getInitialPrefValue(`sound.on.${operationType}`);
const soundName = default_.type as Exclude<SoundType, '_driveFile_'>;
if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`); if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
playMisskeySfxFileInternal({ playMisskeySfxFileInternal({
type: soundName, type: soundName,