From d30ddd4c2ebcacc0d0b49c74e8dfe05b5422ba2e Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 9 Mar 2025 12:34:08 +0900
Subject: Refine preferences (#15597)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* wip

* test

* wip rollup pluginでsearchIndexの情報生成

* wip

* SPDX

* wip: markerIdを自動付与

* rollupでビルド時・devモード時に毎回uuidを生成するように

* 開発サーバーでだけ必要な挙動は開発サーバーのみで

* 条件が逆

* wip: childrenの生成

* update comment

* update comment

* rename auto generated file

* hashをパスと行数から決定

* Update privacy.vue

* Update privacy.vue

* wip

* Update general.vue

* Update general.vue

* wip

* wip

* Update SearchMarker.vue

* wip

* Update profile.vue

* Update mute-block.vue

* Update mute-block.vue

* Update general.vue

* Update general.vue

* childrenがduplicate key errorを吐く問題をいったん解決

* マーカーの形を成形

* loggerを置きかえ

* とりあえず省略記法に対応

* Refactor and Format codes

* wip

* Update settings-search-index.ts

* wip

* wip

* とりあえず不確定要因の仮置きidを削除

* hashの生成を正規化（絶対パスになっていたのを緩和）

* pathの入力を省略可能に

* adminでもパス生成できるように

* Update settings-search-index.ts

* Update privacy.vue

* wip

* build searchIndex

* wip

* build

* Update general.vue

* build

* Update sounds.vue

* build

* build

* Update sounds.vue

* 🎨

* 🎨

* Update privacy.vue

* Update privacy.vue

* Update security.vue

* create-search-indexを多少改善

* build

* Update 2fa.vue

* wip

* 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義

* キャッシュはdevServerでなくても更新

* Revert "wip"

This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054.

* inlining

* wip

* Update theme.vue

* 🎨

* wip normalize

* Update theme.vue

* キャッシュのパス変換

* build

* wip

* wip

* Update SearchMarker.vue

* i18n.ts['key'] の形式が取り出せない問題のFix

* build

* 仮でpath入れ

* 必ず絶対パスが使われるように

* wip

* 🎨

* storybookビルド時はcreateSearchIndexをしない

* inliningの構造化

* format code

* Update index.vue

* wip

* wip

* 🎨

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* clean up

* wip

* wip

* wip

* Update rollup-plugin-unwind-css-module-class-name.test.ts

* Update navbar.vue

* clean up

* wip

* wip

* wip

* wip

* wip

* Update preferences-backups.vue

* Update common.ts

* Update preferences.ts

* wip

* wip

* wip

* wip

* Update MkPreferenceContainer.vue

* Update MkPreferenceContainer.vue

* Update MkPreferenceContainer.vue

* enhance: 検索で上下矢印を使用することで検索結果を移動できるように

* Update main-boot.ts

* refactor

* wip

* Update sounds.vue

* fix(frontend): PageWindowでSearchMarkerが動作するように

* enhance(frontend): SearchMarkerの点滅を一定時間で止める

* wip

* lint fix

* fix: 子要素監視が抜けていたのを修正

* アニメーションの回数はCSSで制御するように

* refactor

* enhance(frontend): 検索インデックス作成時のログを削減

* revert

* fix

* fix

* Update preferences.ts

* Update preferences.ts

* wip

* Update preferences.ts

* wip

* 🎨

* wip

* Update MkPreferenceContainer.vue

* wip

* Update preferences.ts

* wip

* Update preferences.ts

* Update preferences.ts

* wip

* wip

* Update preferences.ts

* wip

* wip

* Update preferences.ts

* Update CHANGELOG.md

* Update preferences.ts

* Update deck-store.ts

* deckStoreをdefaultStoreに統合

* wip

* defaultStore -> store

* Update profile.ts

* wip

* refactor

* wip: plugin

* plugin

* plugin

* plugin

* Update plugin.ts

* wip

* Update plugin.vue

* Update preferences.ts

* Update main-boot.ts

* wip

* fix test

* Update plugin.vue

* Update plugin.vue

* Update utility.ts

* wip

* wip

* Update utility.ts

* wip

* wip

* clean up

* Update utility.ts

---------

Co-authored-by: tai-cha <dev@taichan.site>
Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com>
Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
---
 .../src/components/MkPreferenceContainer.vue       | 94 ++++++++++++++++++++++
 1 file changed, 94 insertions(+)
 create mode 100644 packages/frontend/src/components/MkPreferenceContainer.vue

(limited to 'packages/frontend/src/components/MkPreferenceContainer.vue')

diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue
new file mode 100644
index 0000000000..85fab462cd
--- /dev/null
+++ b/packages/frontend/src/components/MkPreferenceContainer.vue
@@ -0,0 +1,94 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+	<div :class="$style.body">
+		<slot></slot>
+	</div>
+	<div :class="$style.menu">
+		<i v-if="isAccountOverrided" class="ti ti-user-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i>
+		<div :class="$style.buttons">
+			<button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu"><i class="ti ti-dots"></i></button>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import type { PREF_DEF } from '@/preferences/def.js';
+import * as os from '@/os.js';
+import { profileManager } from '@/preferences.js';
+
+const props = withDefaults(defineProps<{
+	k: keyof typeof PREF_DEF;
+}>(), {
+});
+
+const isAccountOverrided = ref(profileManager.isAccountOverrided(props.k));
+
+function showMenu(ev: MouseEvent) {
+	const i = window.setInterval(() => {
+		isAccountOverrided.value = profileManager.isAccountOverrided(props.k);
+	}, 100);
+	os.popupMenu(profileManager.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
+		onClosing: () => {
+			window.clearInterval(i);
+		},
+	});
+}
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	display: flex;
+
+	&:hover {
+		&::after {
+			content: '';
+			position: absolute;
+			top: -8px;
+			left: -8px;
+			width: calc(100% + 16px);
+			height: calc(100% + 16px);
+			border-radius: 8px;
+			background: light-dark(rgba(0, 0, 0, 0.02), rgba(255, 255, 255, 0.02));
+			pointer-events: none;
+		}
+
+		.menu {
+			.buttons {
+				opacity: 0.7;
+			}
+		}
+	}
+
+	.body {
+		flex: 1;
+	}
+
+	.menu {
+		display: flex;
+		gap: 8px;
+		align-items: center;
+		margin-left: 12px;
+		font-size: 12px;
+		padding-left: 8px;
+		border-left: solid 1px var(--MI_THEME-divider);
+
+		&:hover {
+			.buttons {
+				opacity: 1;
+			}
+		}
+
+		.buttons {
+			opacity: 0.3;
+		}
+	}
+}
+</style>
-- 
cgit v1.2.3-freya


From b03bcf26cd89183c86dea73dc8ef30ae68fe2eb2 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Wed, 12 Mar 2025 11:39:05 +0900
Subject: enhance(frontend): 設定値の同期を実装(実験的)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md                                       |   1 +
 locales/index.d.ts                                 |  24 ++
 locales/ja-JP.yml                                  |   6 +
 .../src/components/MkPreferenceContainer.vue       |  10 +-
 packages/frontend/src/preferences.ts               |  53 +++-
 packages/frontend/src/preferences/def.ts           |   1 +
 packages/frontend/src/preferences/profile.ts       | 319 ++++++++++++++++-----
 packages/frontend/src/preferences/store.ts         |  94 ------
 packages/frontend/src/preferences/utility.ts       |  20 +-
 9 files changed, 342 insertions(+), 186 deletions(-)
 delete mode 100644 packages/frontend/src/preferences/store.ts

(limited to 'packages/frontend/src/components/MkPreferenceContainer.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 748f3aa8eb..d02b37cbdb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
 ### Client
 - Feat: 設定の管理が強化されました
   - 自動でバックアップされるように
+	- 任意の設定項目をデバイス間で同期できるように(実験的)
 - Enhance: プラグインの管理が強化されました
 - Enhance: CWの注釈テキストが入力されていない場合, Postボタンを非アクティブに
 - Enhance: CWを無効にした場合, 注釈テキストが最大入力文字数を超えていても投稿できるように
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 409ad3835b..297b56e289 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5310,6 +5310,30 @@ export interface Locale extends ILocale {
      * 復元
      */
     "restore": string;
+    /**
+     * デバイス間で同期
+     */
+    "syncBetweenDevices": string;
+    /**
+     * サーバーに設定値が存在します
+     */
+    "preferenceSyncConflictTitle": string;
+    /**
+     * 同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか？
+     */
+    "preferenceSyncConflictText": string;
+    /**
+     * サーバーの設定値
+     */
+    "preferenceSyncConflictChoiceServer": string;
+    /**
+     * デバイスの設定値
+     */
+    "preferenceSyncConflictChoiceDevice": string;
+    /**
+     * 同期の有効化をキャンセル
+     */
+    "preferenceSyncConflictChoiceCancel": string;
     "_settings": {
         /**
          * ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 5d24282849..23aeb59863 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1323,6 +1323,12 @@ untitled: "無題"
 noName: "名前はありません"
 skip: "スキップ"
 restore: "復元"
+syncBetweenDevices: "デバイス間で同期"
+preferenceSyncConflictTitle: "サーバーに設定値が存在します"
+preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか？"
+preferenceSyncConflictChoiceServer: "サーバーの設定値"
+preferenceSyncConflictChoiceDevice: "デバイスの設定値"
+preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル"
 
 _settings:
   driveBanner: "ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。"
diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue
index 85fab462cd..7c9484a88c 100644
--- a/packages/frontend/src/components/MkPreferenceContainer.vue
+++ b/packages/frontend/src/components/MkPreferenceContainer.vue
@@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<slot></slot>
 	</div>
 	<div :class="$style.menu">
+		<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>
 		<div :class="$style.buttons">
 			<button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu"><i class="ti ti-dots"></i></button>
@@ -21,20 +22,21 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import type { PREF_DEF } from '@/preferences/def.js';
 import * as os from '@/os.js';
-import { profileManager } from '@/preferences.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	k: keyof typeof PREF_DEF;
 }>(), {
 });
 
-const isAccountOverrided = ref(profileManager.isAccountOverrided(props.k));
+const isAccountOverrided = ref(prefer.isAccountOverrided(props.k));
+const isSyncEnabled = ref(prefer.isSyncEnabled(props.k));
 
 function showMenu(ev: MouseEvent) {
 	const i = window.setInterval(() => {
-		isAccountOverrided.value = profileManager.isAccountOverrided(props.k);
+		isAccountOverrided.value = prefer.isAccountOverrided(props.k);
 	}, 100);
-	os.popupMenu(profileManager.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
+	os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
 		onClosing: () => {
 			window.clearInterval(i);
 		},
diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts
index a38f1a2a33..ab234a926a 100644
--- a/packages/frontend/src/preferences.ts
+++ b/packages/frontend/src/preferences.ts
@@ -4,16 +4,17 @@
  */
 
 import { v4 as uuid } from 'uuid';
-import type { PreferencesProfile } from '@/preferences/profile.js';
+import type { PreferencesProfile, StorageProvider } from '@/preferences/profile.js';
 import { cloudBackup } from '@/preferences/utility.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { ProfileManager } from '@/preferences/profile.js';
 import { store } from '@/store.js';
 import { $i } from '@/account.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const TAB_ID = uuid();
 
-function createProfileManager() {
+function createProfileManager(storageProvider: StorageProvider) {
 	let profile: PreferencesProfile;
 
 	const savedProfileRaw = miLocalStorage.getItem('preferences');
@@ -24,15 +25,44 @@ function createProfileManager() {
 		profile = ProfileManager.normalizeProfile(JSON.parse(savedProfileRaw));
 	}
 
-	return new ProfileManager(profile);
+	return new ProfileManager(profile, storageProvider);
 }
 
-export const profileManager = createProfileManager();
-profileManager.addListener('updated', ({ profile: p }) => {
-	miLocalStorage.setItem('preferences', JSON.stringify(p));
-	miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`);
-});
-export const prefer = profileManager.store;
+const storageProvider: StorageProvider = {
+	save: (ctx) => {
+		miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile));
+		miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`);
+	},
+	cloudGet: async (ctx) => {
+		// TODO: この取得方法だとアカウントが変わると保存場所も変わってしまうので改修する
+		// 例えば複数アカウントある場合でも設定値を保存するための「プライマリアカウント」を設定できるようにするとか
+		// TODO: keyのcondに応じた取得
+		try {
+			const value = await misskeyApi('i/registry/get', {
+				scope: ['client', 'preferences', 'sync'],
+				key: ctx.key,
+			});
+			return {
+				value,
+			};
+		} catch (err: any) {
+			if (err.code === 'NO_SUCH_KEY') {
+				return null;
+			} else {
+				throw err;
+			}
+		}
+	},
+	cloudSet: async (ctx) => {
+		await misskeyApi('i/registry/set', {
+			scope: ['client', 'preferences', 'sync'],
+			key: ctx.key,
+			value: ctx.value,
+		});
+	},
+};
+
+export const prefer = createProfileManager(storageProvider);
 
 let latestSyncedAt = Date.now();
 
@@ -46,7 +76,7 @@ function syncBetweenTabs() {
 	if (latestTab === TAB_ID) return;
 	if (latestAt <= latestSyncedAt) return;
 
-	profileManager.rewriteProfile(ProfileManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!)));
+	prefer.rewriteProfile(ProfileManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!)));
 
 	latestSyncedAt = Date.now();
 
@@ -67,7 +97,7 @@ window.setInterval(() => {
 	if ($i == null) return;
 	if (!store.s.enablePreferencesAutoCloudBackup) return;
 	if (document.visibilityState !== 'visible') return; // 同期されていない古い値がバックアップされるのを防ぐ
-	if (profileManager.profile.modifiedAt <= latestBackupAt) return;
+	if (prefer.profile.modifiedAt <= latestBackupAt) return;
 
 	cloudBackup().then(() => {
 		latestBackupAt = Date.now();
@@ -75,7 +105,6 @@ window.setInterval(() => {
 }, 1000 * 60 * 3);
 
 if (_DEV_) {
-	(window as any).profileManager = profileManager;
 	(window as any).prefer = prefer;
 	(window as any).cloudBackup = cloudBackup;
 }
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index 68e0b08f92..b75b99d6b5 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -327,4 +327,5 @@ export const PREF_DEF = {
 } satisfies Record<string, {
 	default: any;
 	accountDependent?: boolean;
+	serverDependent?: boolean;
 }>;
diff --git a/packages/frontend/src/preferences/profile.ts b/packages/frontend/src/preferences/profile.ts
index defa2747eb..fc8057540a 100644
--- a/packages/frontend/src/preferences/profile.ts
+++ b/packages/frontend/src/preferences/profile.ts
@@ -3,16 +3,16 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { ref, watch } from 'vue';
+import { computed, onUnmounted, ref, watch } from 'vue';
 import { v4 as uuid } from 'uuid';
 import { host, version } from '@@/js/config.js';
-import { EventEmitter } from 'eventemitter3';
 import { PREF_DEF } from './def.js';
-import { Store } from './store.js';
+import type { Ref, WritableComputedRef } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
 import { $i } from '@/account.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
 
 // NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
 
@@ -24,11 +24,41 @@ type PREF = typeof PREF_DEF;
 type ValueOf<K extends keyof PREF> = PREF[K]['default'];
 type Account = string; // <host>/<userId>
 
-type Cond = {
+type Cond = Partial<{
 	server: string | null; // 将来のため
 	account: Account | null;
 	device: string | null; // 将来のため
-};
+}>;
+
+type ValueMeta = Partial<{
+	sync: boolean;
+}>;
+
+type PrefRecord<K extends keyof PREF> = [cond: Cond, value: ValueOf<K>, meta: ValueMeta];
+
+function parseCond(cond: Cond): {
+	server: string | null;
+	account: Account | null;
+	device: string | null;
+} {
+	return {
+		server: cond.server ?? null,
+		account: cond.account ?? null,
+		device: cond.device ?? null,
+	};
+}
+
+function makeCond(cond: Partial<{
+	server: string | null;
+	account: Account | null;
+	device: string | null;
+}>): Cond {
+	const c = {} as Cond;
+	if (cond.server != null) c.server = cond.server;
+	if (cond.account != null) c.account = cond.account;
+	if (cond.device != null) c.device = cond.device;
+	return c;
+}
 
 export type PreferencesProfile = {
 	id: string;
@@ -37,53 +67,119 @@ export type PreferencesProfile = {
 	modifiedAt: number;
 	name: string;
 	preferences: {
-		[K in keyof PREF]: [Cond, ValueOf<K>][];
+		[K in keyof PREF]: PrefRecord<K>[];
 	};
-	syncByAccount: [Account, keyof PREF][],
 };
 
-// TODO: 任意のプロパティをデバイス間で同期できるようにする？
+export type StorageProvider = {
+	save: (ctx: { profile: PreferencesProfile; }) => void;
+	cloudGet: <K extends keyof PREF>(ctx: { key: K; }) => Promise<{ value: ValueOf<K>; } | null>;
+	cloudSet: <K extends keyof PREF>(ctx: { key: K; value: ValueOf<K>; }) => Promise<void>;
+};
 
-export class ProfileManager extends EventEmitter<{
-	updated: (ctx: {
-		profile: PreferencesProfile
-	}) => void;
-}> {
+export class ProfileManager {
+	private storageProvider: StorageProvider;
 	public profile: PreferencesProfile;
-	public store: Store<{
+
+	/**
+	 * static / state の略 (static が予約語のため)
+	 */
+	public s = {} as {
 		[K in keyof PREF]: ValueOf<K>;
-	}>;
+	};
 
-	constructor(profile: PreferencesProfile) {
-		super();
+	/**
+	 * reactive の略
+	 */
+	public r = {} as {
+		[K in keyof PREF]: Ref<ValueOf<K>>;
+	};
+
+	constructor(profile: PreferencesProfile, storageProvider: StorageProvider) {
 		this.profile = profile;
+		this.storageProvider = storageProvider;
 
 		const states = this.genStates();
 
-		this.store = new Store(states);
-		this.store.addListener('updated', ({ key, value }) => {
-			console.log('prefer:set', key, value);
+		for (const key in states) {
+			this.s[key] = states[key];
+			this.r[key] = ref(this.s[key]);
+		}
 
-			const record = this.getMatchedRecord(key);
-			if (record[0].account == null && PREF_DEF[key].accountDependent) {
-				this.profile.preferences[key].push([{
-					server: null,
-					account: `${host}/${$i!.id}`,
-					device: null,
-				}, value]);
-				this.save();
-				return;
-			}
+		this.fetchCloudValues();
+
+		// TODO: 定期的にクラウドの値をフェッチ
+	}
+
+	private rewriteRawState<K extends keyof PREF>(key: K, value: ValueOf<K>) {
+		const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
+		this.r[key].value = this.s[key] = v;
+	}
 
-			record[1] = value;
+	public commit<K extends keyof PREF>(key: K, value: ValueOf<K>) {
+		console.log('prefer:commit', key, value);
+
+		this.rewriteRawState(key, value);
+
+		const record = this.getMatchedRecord(key);
+		if (parseCond(record[0]).account == null && PREF_DEF[key].accountDependent) {
+			this.profile.preferences[key].push([makeCond({
+				account: `${host}/${$i!.id}`,
+			}), value, {}]);
 			this.save();
+			return;
+		}
+
+		if (record[2].sync) {
+			// awaitの必要なし
+			// TODO: リクエストを間引く
+			this.storageProvider.cloudSet({ key, value });
+		}
+
+		record[1] = value;
+		this.save();
+	}
+
+	/**
+	 * 特定のキーの、簡易的なcomputed refを作ります
+	 * 主にvue上で設定コントロールのmodelとして使う用
+	 */
+	public model<K extends keyof PREF, V extends ValueOf<K> = ValueOf<K>>(
+		key: K,
+		getter?: (v: ValueOf<K>) => V,
+		setter?: (v: V) => ValueOf<K>,
+	): WritableComputedRef<V> {
+		const valueRef = ref(this.s[key]);
+
+		const stop = watch(this.r[key], val => {
+			valueRef.value = val;
+		});
+
+		// NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
+		onUnmounted(() => {
+			stop();
+		});
+
+		// TODO: VueのcustomRef使うと良い感じになるかも
+		return computed({
+			get: () => {
+				if (getter) {
+					return getter(valueRef.value);
+				} else {
+					return valueRef.value;
+				}
+			},
+			set: (value) => {
+				const val = setter ? setter(value) : value;
+				this.commit(key, val);
+				valueRef.value = val;
+			},
 		});
 	}
 
 	private genStates() {
 		const states = {} as { [K in keyof PREF]: ValueOf<K> };
-		let key: keyof PREF;
-		for (key in PREF_DEF) {
+		for (const key in PREF_DEF) {
 			const record = this.getMatchedRecord(key);
 			states[key] = record[1];
 		}
@@ -91,15 +187,37 @@ export class ProfileManager extends EventEmitter<{
 		return states;
 	}
 
+	private fetchCloudValues() {
+		// TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要)
+
+		const promises: Promise<void>[] = [];
+		for (const key in PREF_DEF) {
+			const record = this.getMatchedRecord(key);
+			if (record[2].sync) {
+				const getting = this.storageProvider.cloudGet({ key });
+				promises.push(getting.then((res) => {
+					if (res == null) return;
+					const value = res.value;
+					if (value !== this.s[key]) {
+						this.rewriteRawState(key, value);
+						record[1] = value;
+						console.log('cloud fetched', key, value);
+					}
+				}));
+			}
+		}
+		Promise.all(promises).then(() => {
+			console.log('cloud fetched all');
+			this.save();
+
+			console.log(this.s.showFixedPostForm, this.r.showFixedPostForm.value);
+		});
+	}
+
 	public static newProfile(): PreferencesProfile {
 		const data = {} as PreferencesProfile['preferences'];
-		let key: keyof PREF;
-		for (key in PREF_DEF) {
-			data[key] = [[{
-				server: null,
-				account: null,
-				device: null,
-			}, PREF_DEF[key].default]];
+		for (const key in PREF_DEF) {
+			data[key] = [[makeCond({}), PREF_DEF[key].default, {}]];
 		}
 		return {
 			id: uuid(),
@@ -108,29 +226,31 @@ export class ProfileManager extends EventEmitter<{
 			modifiedAt: Date.now(),
 			name: '',
 			preferences: data,
-			syncByAccount: [],
 		};
 	}
 
-	public static normalizeProfile(profile: any): PreferencesProfile {
+	public static normalizeProfile(profileLike: any): PreferencesProfile {
 		const data = {} as PreferencesProfile['preferences'];
-		let key: keyof PREF;
-		for (key in PREF_DEF) {
-			const records = profile.preferences[key];
+		for (const key in PREF_DEF) {
+			const records = profileLike.preferences[key];
 			if (records == null || records.length === 0) {
-				data[key] = [[{
-					server: null,
-					account: null,
-					device: null,
-				}, PREF_DEF[key].default]];
+				data[key] = [[makeCond({}), PREF_DEF[key].default, {}]];
 				continue;
 			} else {
 				data[key] = records;
+
+				// alpha段階ではmetaが無かったのでマイグレート
+				// TODO: そのうち消す
+				for (const record of data[key] as any[][]) {
+					if (record.length === 2) {
+						record.push({});
+					}
+				}
 			}
 		}
 
 		return {
-			...profile,
+			...profileLike,
 			preferences: data,
 		};
 	}
@@ -138,24 +258,24 @@ export class ProfileManager extends EventEmitter<{
 	public save() {
 		this.profile.modifiedAt = Date.now();
 		this.profile.version = version;
-		this.emit('updated', { profile: this.profile });
+		this.storageProvider.save({ profile: this.profile });
 	}
 
-	public getMatchedRecord<K extends keyof PREF>(key: K): [Cond, ValueOf<K>] {
+	public getMatchedRecord<K extends keyof PREF>(key: K): PrefRecord<K> {
 		const records = this.profile.preferences[key];
 
-		if ($i == null) return records.find(([cond, v]) => cond.account == null)!;
+		if ($i == null) return records.find(([cond, v]) => parseCond(cond).account == null)!;
 
-		const accountOverrideRecord = records.find(([cond, v]) => cond.account === `${host}/${$i!.id}`);
+		const accountOverrideRecord = records.find(([cond, v]) => parseCond(cond).account === `${host}/${$i!.id}`);
 		if (accountOverrideRecord) return accountOverrideRecord;
 
-		const record = records.find(([cond, v]) => cond.account == null);
+		const record = records.find(([cond, v]) => parseCond(cond).account == null);
 		return record!;
 	}
 
 	public isAccountOverrided<K extends keyof PREF>(key: K): boolean {
 		if ($i == null) return false;
-		return this.profile.preferences[key].some(([cond, v]) => cond.account === `${host}/${$i!.id}`) ?? false;
+		return this.profile.preferences[key].some(([cond, v]) => parseCond(cond).account === `${host}/${$i!.id}`) ?? false;
 	}
 
 	public setAccountOverride<K extends keyof PREF>(key: K) {
@@ -164,11 +284,9 @@ export class ProfileManager extends EventEmitter<{
 		if (this.isAccountOverrided(key)) return;
 
 		const records = this.profile.preferences[key];
-		records.push([{
-			server: null,
+		records.push([makeCond({
 			account: `${host}/${$i!.id}`,
-			device: null,
-		}, this.store.s[key]]);
+		}), this.s[key], {}]);
 
 		this.save();
 	}
@@ -179,16 +297,67 @@ export class ProfileManager extends EventEmitter<{
 
 		const records = this.profile.preferences[key];
 
-		const index = records.findIndex(([cond, v]) => cond.account === `${host}/${$i!.id}`);
+		const index = records.findIndex(([cond, v]) => parseCond(cond).account === `${host}/${$i!.id}`);
 		if (index === -1) return;
 
 		records.splice(index, 1);
 
-		this.store.rewrite(key, this.getMatchedRecord(key)[1]);
+		this.rewriteRawState(key, this.getMatchedRecord(key)[1]);
 
 		this.save();
 	}
 
+	public isSyncEnabled<K extends keyof PREF>(key: K): boolean {
+		return this.getMatchedRecord(key)[2].sync ?? false;
+	}
+
+	public async enableSync<K extends keyof PREF>(key: K): Promise<{ enabled: boolean; } | null> {
+		if (this.isSyncEnabled(key)) return Promise.resolve(null);
+
+		const existing = await this.storageProvider.cloudGet({ key });
+		if (existing != null) {
+			const { canceled, result } = await os.select({
+				title: i18n.ts.preferenceSyncConflictTitle,
+				text: i18n.ts.preferenceSyncConflictText,
+				items: [{
+					text: i18n.ts.preferenceSyncConflictChoiceServer,
+					value: 'remote',
+				}, {
+					text: i18n.ts.preferenceSyncConflictChoiceDevice,
+					value: 'local',
+				}, {
+					text: i18n.ts.preferenceSyncConflictChoiceCancel,
+					value: null,
+				}],
+				default: 'remote',
+			});
+			if (canceled || result == null) return { enabled: false };
+
+			if (result === 'remote') {
+				this.commit(key, existing.value);
+			} else if (result === 'local') {
+				// nop
+			}
+		}
+
+		const record = this.getMatchedRecord(key);
+		record[2].sync = true;
+		this.save();
+
+		// awaitの必要性は無い
+		this.storageProvider.cloudSet({ key, value: this.s[key] });
+
+		return { enabled: true };
+	}
+
+	public disableSync<K extends keyof PREF>(key: K) {
+		if (!this.isSyncEnabled(key)) return;
+
+		const record = this.getMatchedRecord(key);
+		delete record[2].sync;
+		this.save();
+	}
+
 	public renameProfile(name: string) {
 		this.profile.name = name;
 		this.save();
@@ -198,13 +367,14 @@ export class ProfileManager extends EventEmitter<{
 		this.profile = profile;
 		const states = this.genStates();
 		for (const key in states) {
-			this.store.rewrite(key, states[key]);
+			this.rewriteRawState(key, states[key]);
 		}
+
+		this.fetchCloudValues();
 	}
 
 	public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] {
 		const overrideByAccount = ref(this.isAccountOverrided(key));
-
 		watch(overrideByAccount, () => {
 			if (overrideByAccount.value) {
 				this.setAccountOverride(key);
@@ -213,6 +383,18 @@ export class ProfileManager extends EventEmitter<{
 			}
 		});
 
+		const sync = ref(this.isSyncEnabled(key));
+		watch(sync, () => {
+			if (sync.value) {
+				this.enableSync(key).then((res) => {
+					if (res == null) return;
+					if (!res.enabled) sync.value = false;
+				});
+			} else {
+				this.disableSync(key);
+			}
+		});
+
 		return [{
 			icon: 'ti ti-copy',
 			text: i18n.ts.copyPreferenceId,
@@ -224,7 +406,7 @@ export class ProfileManager extends EventEmitter<{
 			text: i18n.ts.resetToDefaultValue,
 			danger: true,
 			action: () => {
-				this.store.commit(key, PREF_DEF[key].default);
+				this.commit(key, PREF_DEF[key].default);
 			},
 		}, {
 			type: 'divider',
@@ -233,6 +415,11 @@ export class ProfileManager extends EventEmitter<{
 			icon: 'ti ti-user-cog',
 			text: i18n.ts.overrideByAccount,
 			ref: overrideByAccount,
+		}, {
+			type: 'switch',
+			icon: 'ti ti-cloud-cog',
+			text: i18n.ts.syncBetweenDevices,
+			ref: sync,
 		}];
 	}
 }
diff --git a/packages/frontend/src/preferences/store.ts b/packages/frontend/src/preferences/store.ts
deleted file mode 100644
index e061021be3..0000000000
--- a/packages/frontend/src/preferences/store.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { computed, onUnmounted, ref, watch } from 'vue';
-import { EventEmitter } from 'eventemitter3';
-import type { Ref, WritableComputedRef } from 'vue';
-
-// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
-
-//type DottedToNested<T extends Record<string, any>> = {
-//	[K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K];
-//};
-
-type StoreEvent<Data extends Record<string, any>> = {
-	updated: <K extends keyof Data>(ctx: {
-		key: K;
-		value: Data[K];
-	}) => void;
-};
-
-export class Store<Data extends Record<string, any>> extends EventEmitter<StoreEvent<Data>> {
-	/**
-	 * static / state の略 (static が予約語のため)
-	 */
-	public s = {} as {
-		[K in keyof Data]: Data[K];
-	};
-
-	/**
-	 * reactive の略
-	 */
-	public r = {} as {
-		[K in keyof Data]: Ref<Data[K]>;
-	};
-
-	constructor(data: { [K in keyof Data]: Data[K] }) {
-		super();
-
-		for (const key in data) {
-			this.s[key] = data[key];
-			this.r[key] = ref(this.s[key]);
-		}
-	}
-
-	public commit<K extends keyof Data>(key: K, value: Data[K]) {
-		const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
-		this.r[key].value = this.s[key] = v;
-		this.emit('updated', { key, value: v });
-	}
-
-	public rewrite<K extends keyof Data>(key: K, value: Data[K]) {
-		const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
-		this.r[key].value = this.s[key] = v;
-	}
-
-	/**
-	 * 特定のキーの、簡易的なcomputed refを作ります
-	 * 主にvue上で設定コントロールのmodelとして使う用
-	 */
-	public model<K extends keyof Data, V extends Data[K] = Data[K]>(
-		key: K,
-		getter?: (v: Data[K]) => V,
-		setter?: (v: V) => Data[K],
-	): WritableComputedRef<V> {
-		const valueRef = ref(this.s[key]);
-
-		const stop = watch(this.r[key], val => {
-			valueRef.value = val;
-		});
-
-		// NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
-		onUnmounted(() => {
-			stop();
-		});
-
-		// TODO: VueのcustomRef使うと良い感じになるかも
-		return computed({
-			get: () => {
-				if (getter) {
-					return getter(valueRef.value);
-				} else {
-					return valueRef.value;
-				}
-			},
-			set: (value) => {
-				const val = setter ? setter(value) : value;
-				this.commit(key, val);
-				valueRef.value = val;
-			},
-		});
-	}
-}
diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts
index 64b2bde4de..fc6eff5f49 100644
--- a/packages/frontend/src/preferences/utility.ts
+++ b/packages/frontend/src/preferences/utility.ts
@@ -9,7 +9,7 @@ import type { MenuItem } from '@/types/menu.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { prefer, profileManager } from '@/preferences.js';
+import { prefer } from '@/preferences.js';
 import * as os from '@/os.js';
 import { store } from '@/store.js';
 import { $i } from '@/account.js';
@@ -17,7 +17,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { unisonReload } from '@/utility/unison-reload.js';
 
 function canAutoBackup() {
-	return profileManager.profile.name != null && profileManager.profile.name.trim() !== '';
+	return prefer.profile.name != null && prefer.profile.name.trim() !== '';
 }
 
 export function getPreferencesProfileMenu(): MenuItem[] {
@@ -42,7 +42,7 @@ export function getPreferencesProfileMenu(): MenuItem[] {
 
 	const menu: MenuItem[] = [{
 		type: 'label',
-		text: profileManager.profile.name || `(${i18n.ts.noName})`,
+		text: prefer.profile.name || `(${i18n.ts.noName})`,
 	}, {
 		text: i18n.ts.rename,
 		icon: 'ti ti-pencil',
@@ -83,7 +83,7 @@ export function getPreferencesProfileMenu(): MenuItem[] {
 			text: 'Copy profile as text',
 			icon: 'ti ti-clipboard',
 			action: () => {
-				copyToClipboard(JSON.stringify(profileManager.profile, null, '\t'));
+				copyToClipboard(JSON.stringify(prefer.profile, null, '\t'));
 			},
 		});
 	}
@@ -95,16 +95,16 @@ async function renameProfile() {
 	const { canceled, result: name } = await os.inputText({
 		title: i18n.ts._preferencesProfile.profileName,
 		text: i18n.ts._preferencesProfile.profileNameDescription + '\n' + i18n.ts._preferencesProfile.profileNameDescription2,
-		placeholder: profileManager.profile.name || null,
-		default: profileManager.profile.name || null,
+		placeholder: prefer.profile.name || null,
+		default: prefer.profile.name || null,
 	});
 	if (canceled || name == null || name.trim() === '') return;
 
-	profileManager.renameProfile(name);
+	prefer.renameProfile(name);
 }
 
 function exportCurrentProfile() {
-	const p = profileManager.profile;
+	const p = prefer.profile;
 	const txtBlob = new Blob([JSON.stringify(p)], { type: 'text/plain' });
 	const dummya = document.createElement('a');
 	dummya.href = URL.createObjectURL(txtBlob);
@@ -140,8 +140,8 @@ export async function cloudBackup() {
 
 	await misskeyApi('i/registry/set', {
 		scope: ['client', 'preferences', 'backups'],
-		key: profileManager.profile.name,
-		value: profileManager.profile,
+		key: prefer.profile.name,
+		value: prefer.profile,
 	});
 }
 
-- 
cgit v1.2.3-freya


From caab1ec7c3fbd6b8924a23afb1d934838b28e8ad Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Wed, 12 Mar 2025 13:04:41 +0900
Subject: 🎨
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/frontend/src/components/MkButton.vue              | 12 ++++++------
 packages/frontend/src/components/MkMenu.vue                | 10 +++++-----
 packages/frontend/src/components/MkPreferenceContainer.vue |  1 +
 3 files changed, 12 insertions(+), 11 deletions(-)

(limited to 'packages/frontend/src/components/MkPreferenceContainer.vue')

diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 311facb4aa..667e624853 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -220,28 +220,28 @@ function onMousedown(evt: MouseEvent): void {
 		background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
 
 		&:not(:disabled):hover {
-			background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
+			background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5)));
 		}
 
 		&:not(:disabled):active {
-			background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
+			background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5)));
 		}
 	}
 
 	&.danger {
 		font-weight: bold;
-		color: #ff2a2a;
+		color: var(--MI_THEME-error);
 
 		&.primary {
 			color: #fff;
-			background: #ff2a2a;
+			background: var(--MI_THEME-error);
 
 			&:not(:disabled):hover {
-				background: #ff4242;
+				background: hsl(from var(--MI_THEME-error) h s calc(l + 10));
 			}
 
 			&:not(:disabled):active {
-				background: #d42e2e;
+				background: hsl(from var(--MI_THEME-error) h s calc(l - 10));
 			}
 		}
 	}
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index 61b3fa2fee..aa53c19c33 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -177,12 +177,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts">
 import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue';
-import MkSwitchButton from '@/components/MkSwitch.button.vue';
 import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
+import type { Keymap } from '@/utility/hotkey.js';
+import MkSwitchButton from '@/components/MkSwitch.button.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { isTouchUsing } from '@/utility/touch.js';
-import type { Keymap } from '@/utility/hotkey.js';
 import { isFocusable } from '@/utility/focus.js';
 import { getNodeOrNull } from '@/utility/get-dom-node-or-null.js';
 
@@ -558,11 +558,11 @@ onBeforeUnmount(() => {
 	}
 
 	&.danger {
-		--menuFg: #ff2a2a;
+		--menuFg: var(--MI_THEME-error);
 		--menuHoverFg: #fff;
-		--menuHoverBg: #ff4242;
+		--menuHoverBg: var(--MI_THEME-error);
 		--menuActiveFg: #fff;
-		--menuActiveBg: #d42e2e;
+		--menuActiveBg: hsl(from var(--MI_THEME-error) h s calc(l - 10));
 	}
 
 	&.radio {
diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue
index 7c9484a88c..40a9a4dca7 100644
--- a/packages/frontend/src/components/MkPreferenceContainer.vue
+++ b/packages/frontend/src/components/MkPreferenceContainer.vue
@@ -35,6 +35,7 @@ const isSyncEnabled = ref(prefer.isSyncEnabled(props.k));
 function showMenu(ev: MouseEvent) {
 	const i = window.setInterval(() => {
 		isAccountOverrided.value = prefer.isAccountOverrided(props.k);
+		isSyncEnabled.value = prefer.isSyncEnabled(props.k);
 	}, 100);
 	os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
 		onClosing: () => {
-- 
cgit v1.2.3-freya


From f8e244f48d8382b9024a384f29605326ee4abef3 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Wed, 12 Mar 2025 14:34:10 +0900
Subject: enhance(frontend):
 アカウントオーバーライド設定とデバイス間同期の併用に対応
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/components/MkPreferenceContainer.vue       | 20 +++++++----
 packages/frontend/src/preferences.ts               | 42 ++++++++++++++++++----
 packages/frontend/src/preferences/profile.ts       | 41 ++++++++++++---------
 3 files changed, 72 insertions(+), 31 deletions(-)

(limited to 'packages/frontend/src/components/MkPreferenceContainer.vue')

diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue
index 40a9a4dca7..acdd2a8d3b 100644
--- a/packages/frontend/src/components/MkPreferenceContainer.vue
+++ b/packages/frontend/src/components/MkPreferenceContainer.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div :class="$style.root">
+<div :class="$style.root" @contextmenu.prevent.stop="showMenu($event, true)">
 	<div :class="$style.body">
 		<slot></slot>
 	</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="isAccountOverrided" class="ti ti-user-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i>
 		<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>
@@ -32,16 +32,22 @@ const props = withDefaults(defineProps<{
 const isAccountOverrided = ref(prefer.isAccountOverrided(props.k));
 const isSyncEnabled = ref(prefer.isSyncEnabled(props.k));
 
-function showMenu(ev: MouseEvent) {
+function showMenu(ev: MouseEvent, contextmenu?: boolean) {
 	const i = window.setInterval(() => {
 		isAccountOverrided.value = prefer.isAccountOverrided(props.k);
 		isSyncEnabled.value = prefer.isSyncEnabled(props.k);
 	}, 100);
-	os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
-		onClosing: () => {
+	if (contextmenu) {
+		os.contextMenu(prefer.getPerPrefMenu(props.k), ev).then(() => {
 			window.clearInterval(i);
-		},
-	});
+		});
+	} else {
+		os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
+			onClosing: () => {
+				window.clearInterval(i);
+			},
+		});
+	}
 }
 </script>
 
diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts
index ab234a926a..474abe22ab 100644
--- a/packages/frontend/src/preferences.ts
+++ b/packages/frontend/src/preferences.ts
@@ -7,7 +7,7 @@ import { v4 as uuid } from 'uuid';
 import type { PreferencesProfile, StorageProvider } from '@/preferences/profile.js';
 import { cloudBackup } from '@/preferences/utility.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 { $i } from '@/account.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -28,22 +28,27 @@ function createProfileManager(storageProvider: StorageProvider) {
 	return new ProfileManager(profile, storageProvider);
 }
 
+const syncGroup = 'default';
+
 const storageProvider: StorageProvider = {
 	save: (ctx) => {
 		miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile));
 		miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`);
 	},
+
 	cloudGet: async (ctx) => {
 		// TODO: この取得方法だとアカウントが変わると保存場所も変わってしまうので改修する
 		// 例えば複数アカウントある場合でも設定値を保存するための「プライマリアカウント」を設定できるようにするとか
 		// TODO: keyのcondに応じた取得
 		try {
-			const value = await misskeyApi('i/registry/get', {
+			const cloudData = await misskeyApi('i/registry/get', {
 				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 {
-				value,
+				value: target[1],
 			};
 		} catch (err: any) {
 			if (err.code === 'NO_SUCH_KEY') {
@@ -53,11 +58,34 @@ const storageProvider: StorageProvider = {
 			}
 		}
 	},
+
 	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', {
 			scope: ['client', 'preferences', 'sync'],
-			key: ctx.key,
-			value: ctx.value,
+			key: syncGroup + ':' + ctx.key,
+			value: cloudData,
 		});
 	},
 };
diff --git a/packages/frontend/src/preferences/profile.ts b/packages/frontend/src/preferences/profile.ts
index fc8057540a..de1c674e5c 100644
--- a/packages/frontend/src/preferences/profile.ts
+++ b/packages/frontend/src/preferences/profile.ts
@@ -60,6 +60,12 @@ function makeCond(cond: Partial<{
 	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 = {
 	id: string;
 	version: string;
@@ -73,8 +79,8 @@ export type PreferencesProfile = {
 
 export type StorageProvider = {
 	save: (ctx: { profile: PreferencesProfile; }) => void;
-	cloudGet: <K extends keyof PREF>(ctx: { key: K; }) => Promise<{ value: ValueOf<K>; } | null>;
-	cloudSet: <K extends keyof PREF>(ctx: { key: K; value: ValueOf<K>; }) => Promise<void>;
+	cloudGet: <K extends keyof PREF>(ctx: { key: K; cond: Cond; }) => Promise<{ value: ValueOf<K>; } | null>;
+	cloudSet: <K extends keyof PREF>(ctx: { key: K; cond: Cond; value: ValueOf<K>; }) => Promise<void>;
 };
 
 export class ProfileManager {
@@ -121,7 +127,7 @@ export class ProfileManager {
 
 		this.rewriteRawState(key, value);
 
-		const record = this.getMatchedRecord(key);
+		const record = this.getMatchedRecordOf(key);
 		if (parseCond(record[0]).account == null && PREF_DEF[key].accountDependent) {
 			this.profile.preferences[key].push([makeCond({
 				account: `${host}/${$i!.id}`,
@@ -130,14 +136,14 @@ export class ProfileManager {
 			return;
 		}
 
+		record[1] = value;
+		this.save();
+
 		if (record[2].sync) {
 			// awaitの必要なし
 			// 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() {
 		const states = {} as { [K in keyof PREF]: ValueOf<K> };
 		for (const key in PREF_DEF) {
-			const record = this.getMatchedRecord(key);
+			const record = this.getMatchedRecordOf(key);
 			states[key] = record[1];
 		}
 
@@ -192,9 +198,9 @@ export class ProfileManager {
 
 		const promises: Promise<void>[] = [];
 		for (const key in PREF_DEF) {
-			const record = this.getMatchedRecord(key);
+			const record = this.getMatchedRecordOf(key);
 			if (record[2].sync) {
-				const getting = this.storageProvider.cloudGet({ key });
+				const getting = this.storageProvider.cloudGet({ key, cond: record[0] });
 				promises.push(getting.then((res) => {
 					if (res == null) return;
 					const value = res.value;
@@ -261,7 +267,7 @@ export class ProfileManager {
 		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];
 
 		if ($i == null) return records.find(([cond, v]) => parseCond(cond).account == null)!;
@@ -302,19 +308,21 @@ export class ProfileManager {
 
 		records.splice(index, 1);
 
-		this.rewriteRawState(key, this.getMatchedRecord(key)[1]);
+		this.rewriteRawState(key, this.getMatchedRecordOf(key)[1]);
 
 		this.save();
 	}
 
 	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> {
 		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) {
 			const { canceled, result } = await os.select({
 				title: i18n.ts.preferenceSyncConflictTitle,
@@ -340,12 +348,11 @@ export class ProfileManager {
 			}
 		}
 
-		const record = this.getMatchedRecord(key);
 		record[2].sync = true;
 		this.save();
 
 		// awaitの必要性は無い
-		this.storageProvider.cloudSet({ key, value: this.s[key] });
+		this.storageProvider.cloudSet({ key, cond: record[0], value: this.s[key] });
 
 		return { enabled: true };
 	}
@@ -353,7 +360,7 @@ export class ProfileManager {
 	public disableSync<K extends keyof PREF>(key: K) {
 		if (!this.isSyncEnabled(key)) return;
 
-		const record = this.getMatchedRecord(key);
+		const record = this.getMatchedRecordOf(key);
 		delete record[2].sync;
 		this.save();
 	}
-- 
cgit v1.2.3-freya


From ce6eba77d9eb9be11d91c982210eb7bd4293e63f Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Thu, 13 Mar 2025 09:07:22 +0900
Subject: 🎨
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 locales/index.d.ts                                         | 2 +-
 locales/ja-JP.yml                                          | 2 +-
 packages/frontend/src/components/MkPreferenceContainer.vue | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

(limited to 'packages/frontend/src/components/MkPreferenceContainer.vue')

diff --git a/locales/index.d.ts b/locales/index.d.ts
index a4233cf7c8..7efca999b9 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5368,7 +5368,7 @@ export interface Locale extends ILocale {
          */
         "accountData": string;
         /**
-         * アカウントのデータをエクスポート/インポートして管理できます。
+         * アカウントデータのアーカイブをエクスポート/インポートして管理できます。
          */
         "accountDataBanner": string;
         /**
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index c45553817d..96d2439247 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1339,7 +1339,7 @@ _settings:
   serviceConnection: "サービス連携"
   serviceConnectionBanner: "外部のアプリ・サービスと連携するためのアクセストークンやWebhookの管理と設定が行えます。"
   accountData: "アカウントのデータ"
-  accountDataBanner: "アカウントのデータをエクスポート/インポートして管理できます。"
+  accountDataBanner: "アカウントデータのアーカイブをエクスポート/インポートして管理できます。"
   muteAndBlockBanner: "非表示にするコンテンツの設定や、特定のユーザーからのアクションを制限する設定と管理を行えます。"
   accessibilityBanner: "クライアントの視覚や動作に関するパーソナライズを行い、より最適に使用できるように設定できます。"
   privacyBanner: "コンテンツの公開範囲、見つけやすさ、フォローの承認制などアカウントのプライバシーに関する設定を行えます。"
diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue
index acdd2a8d3b..70b111513c 100644
--- a/packages/frontend/src/components/MkPreferenceContainer.vue
+++ b/packages/frontend/src/components/MkPreferenceContainer.vue
@@ -57,7 +57,7 @@ function showMenu(ev: MouseEvent, contextmenu?: boolean) {
 	display: flex;
 
 	&:hover {
-		&::after {
+		&::before {
 			content: '';
 			position: absolute;
 			top: -8px;
-- 
cgit v1.2.3-freya

