diff --git a/CHANGELOG.md b/CHANGELOG.md index 4544e5acba..5c11e6e07e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Feat: 絵文字をミュート可能にする機能 - 絵文字(ユニコードの絵文字・カスタム絵文字)毎にミュートし、不可視化することができるようになりました - Feat: モバイルデバイスで折りたたまれたUIの展開表示に全画面ページを使用できるように(実験的) +- Enhance: 設定の同期をオンにするときに競合したときに値をマージできるように - Enhance: メモリ使用量を軽減しました - Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加 - Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように diff --git a/locales/index.d.ts b/locales/index.d.ts index 21da0c171a..73bcb2f1c8 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5335,15 +5335,19 @@ export interface Locale extends ILocale { */ "preferenceSyncConflictTitle": string; /** - * 同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか? + * 同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どうしますか? */ "preferenceSyncConflictText": string; /** - * サーバーの設定値 + * 統合する + */ + "preferenceSyncConflictChoiceMerge": string; + /** + * サーバーの設定値で上書き */ "preferenceSyncConflictChoiceServer": string; /** - * デバイスの設定値 + * デバイスの設定値で上書き */ "preferenceSyncConflictChoiceDevice": string; /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b81529790f..c7971507aa 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1329,9 +1329,10 @@ skip: "スキップ" restore: "復元" syncBetweenDevices: "デバイス間で同期" preferenceSyncConflictTitle: "サーバーに設定値が存在します" -preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか?" -preferenceSyncConflictChoiceServer: "サーバーの設定値" -preferenceSyncConflictChoiceDevice: "デバイスの設定値" +preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どうしますか?" +preferenceSyncConflictChoiceMerge: "統合する" +preferenceSyncConflictChoiceServer: "サーバーの設定値で上書き" +preferenceSyncConflictChoiceDevice: "デバイスの設定値で上書き" preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル" paste: "ペースト" emojiPalette: "絵文字パレット" diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index b322b03a21..ef698fcd6e 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -69,6 +69,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; import { PREF_DEF } from '@/preferences/def.js'; +import { getInitialPrefValue } from '@/preferences/manager.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -106,7 +107,7 @@ async function save() { } function reset() { - items.value = PREF_DEF.menu.default.map(x => ({ + items.value = getInitialPrefValue('menu').map(x => ({ id: Math.random().toString(), type: x, })); diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 4461ee1ab1..590db19bca 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -75,6 +75,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import { PREF_DEF } from '@/preferences/def.js'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { getInitialPrefValue } from '@/preferences/manager.js'; const notUseSound = prefer.model('sound.notUseSound'); const useSoundOnlyWhenActive = prefer.model('sound.useSoundOnlyWhenActive'); @@ -113,7 +114,7 @@ async function updated(type: keyof typeof sounds.value, sound) { function reset() { for (const sound of Object.keys(sounds.value) as Array) { - const v = PREF_DEF[`sound.on.${sound}`].default; + const v = getInitialPrefValue(`sound.on.${sound}`); prefer.commit(`sound.on.${sound}`, v); sounds.value[sound] = v; } diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 8a2cfa23b1..b8a5a84279 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -5,6 +5,7 @@ import * as Misskey from 'misskey-js'; import { hemisphere } from '@@/js/intl-const.js'; +import { v4 as uuid } from 'uuid'; import type { Theme } from '@/theme.js'; import type { SoundType } from '@/utility/sound.js'; import type { Plugin } from '@/plugin.js'; @@ -49,15 +50,15 @@ export const PREF_DEF = { }, widgets: { accountDependent: true, - default: [{ + default: () => [{ name: 'calendar', - id: 'a', place: 'right', data: {}, + id: uuid(), place: 'right', data: {}, }, { name: 'notifications', - id: 'b', place: 'right', data: {}, + id: uuid(), place: 'right', data: {}, }, { name: 'trends', - id: 'c', place: 'right', data: {}, + id: uuid(), place: 'right', data: {}, }] as { name: string; id: string; @@ -76,8 +77,8 @@ export const PREF_DEF = { emojiPalettes: { serverDependent: true, - default: [{ - id: 'a', + default: () => [{ + id: uuid(), name: '', emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], }] as { @@ -85,6 +86,11 @@ export const PREF_DEF = { name: 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: { serverDependent: true, @@ -100,6 +106,11 @@ export const PREF_DEF = { }, themes: { 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: { default: null as Theme | null, @@ -345,9 +356,19 @@ export const PREF_DEF = { }, plugins: { 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: { default: [] as string[], + mergeStrategy: (a, b) => { + return [...new Set(a.concat(b))]; + }, }, 'sound.masterVolume': { diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index cac659f1fe..016e1ad85b 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -22,7 +22,10 @@ import { deepEqual } from '@/utility/deep-equal.js'; //}; type PREF = typeof PREF_DEF; -type ValueOf = PREF[K]['default']; +type DefaultValues = { + [K in keyof PREF]: PREF[K]['default'] extends (...args: any) => infer R ? R : PREF[K]['default']; +}; +type ValueOf = DefaultValues[K]; type Scope = Partial<{ server: string | null; // host @@ -84,11 +87,22 @@ export type StorageProvider = { cloudSet: (ctx: { key: K; scope: Scope; value: ValueOf; }) => Promise; }; -export type PreferencesDefinition = Record infer R ? R : Default> = { + default: Default; accountDependent?: boolean; serverDependent?: boolean; -}>; + mergeStrategy?: (a: T, b: T) => T; +}; + +export type PreferencesDefinition = Record>; + +export function getInitialPrefValue(k: K): ValueOf { + if (typeof PREF_DEF[k].default === 'function') { // factory + return PREF_DEF[k].default(); + } else { + return PREF_DEF[k].default; + } +} export class PreferencesManager { private storageProvider: StorageProvider; @@ -262,7 +276,7 @@ export class PreferencesManager { public static newProfile(): PreferencesProfile { const data = {} as PreferencesProfile['preferences']; for (const key in PREF_DEF) { - data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; + data[key] = [[makeScope({}), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]; } return { id: uuid(), @@ -279,7 +293,7 @@ export class PreferencesManager { for (const key in PREF_DEF) { const records = profileLike.preferences[key]; if (records == null || records.length === 0) { - data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; + data[key] = [[makeScope({}), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]; continue; } else { data[key] = records; @@ -367,10 +381,20 @@ export class PreferencesManager { const existing = await this.storageProvider.cloudGet({ key, scope: record[0] }); 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 | 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, text: i18n.ts.preferenceSyncConflictText, - items: [{ + items: [...(mergedValue !== undefined ? [{ + text: i18n.ts.preferenceSyncConflictChoiceMerge, + value: 'merge', + }] : []), { text: i18n.ts.preferenceSyncConflictChoiceServer, value: 'remote', }, { @@ -380,14 +404,16 @@ export class PreferencesManager { text: i18n.ts.preferenceSyncConflictChoiceCancel, 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); - } else if (result === 'local') { + } else if (choice === 'local') { // nop + } else if (choice === 'merge') { + this.commit(key, mergedValue!); } } @@ -457,7 +483,7 @@ export class PreferencesManager { text: i18n.ts.resetToDefaultValue, danger: true, action: () => { - this.commit(key, PREF_DEF[key].default); + this.commit(key, getInitialPrefValue(key)); }, }, { type: 'divider', diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts index 217da9c8b2..8e79841647 100644 --- a/packages/frontend/src/utility/sound.ts +++ b/packages/frontend/src/utility/sound.ts @@ -6,6 +6,7 @@ import type { SoundStore } from '@/preferences/def.js'; import { prefer } from '@/preferences.js'; import { PREF_DEF } from '@/preferences/def.js'; +import { getInitialPrefValue } from '@/preferences/manager.js'; let ctx: AudioContext; const cache = new Map(); @@ -133,7 +134,8 @@ export function playMisskeySfx(operationType: OperationType) { playMisskeySfxFile(sound).then((succeed) => { if (!succeed && sound.type === '_driveFile_') { // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する - const soundName = PREF_DEF[`sound.on.${operationType}`].default.type as Exclude; + const default_ = getInitialPrefValue(`sound.on.${operationType}`); + const soundName = default_.type as Exclude; if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`); playMisskeySfxFileInternal({ type: soundName,