From ddbc83b2e46de5c2931ea3ea421408b8182b25ba Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 11 Mar 2025 20:42:06 +0900 Subject: [PATCH 01/40] chore(frontend): tweak settings page --- packages/frontend/src/pages/settings/index.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 26677a188f..7bbec82757 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -91,11 +91,6 @@ const menuDef = computed(() => [{ text: i18n.ts.emojiPicker, to: '/settings/emoji-picker', active: currentPage.value?.route.name === 'emojiPicker', - }, { - icon: 'ti ti-cloud', - text: i18n.ts.drive, - to: '/settings/drive', - active: currentPage.value?.route.name === 'drive', }, { icon: 'ti ti-bell', text: i18n.ts.notifications, @@ -146,6 +141,11 @@ const menuDef = computed(() => [{ }], }, { items: [{ + icon: 'ti ti-cloud', + text: i18n.ts.drive, + to: '/settings/drive', + active: currentPage.value?.route.name === 'drive', + }, { icon: 'ti ti-badges', text: i18n.ts.roles, to: '/settings/roles', 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: [PATCH 02/40] =?UTF-8?q?enhance(frontend):=20=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E5=80=A4=E3=81=AE=E5=90=8C=E6=9C=9F=E3=82=92=E5=AE=9F=E8=A3=85?= =?UTF-8?q?(=E5=AE=9F=E9=A8=93=E7=9A=84)?= 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 | 321 ++++++++++++++---- packages/frontend/src/preferences/store.ts | 94 ----- packages/frontend/src/preferences/utility.ts | 20 +- 9 files changed, 343 insertions(+), 187 deletions(-) delete mode 100644 packages/frontend/src/preferences/store.ts 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
+
@@ -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; 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 = PREF[K]['default']; type Account = string; // / -type Cond = { +type Cond = Partial<{ server: string | null; // 将来のため account: Account | null; device: string | null; // 将来のため -}; +}>; + +type ValueMeta = Partial<{ + sync: boolean; +}>; + +type PrefRecord = [cond: Cond, value: ValueOf, 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 in keyof PREF]: PrefRecord[]; }; - syncByAccount: [Account, keyof PREF][], }; -// TODO: 任意のプロパティをデバイス間で同期できるようにする? +export type StorageProvider = { + save: (ctx: { profile: PreferencesProfile; }) => void; + cloudGet: (ctx: { key: K; }) => Promise<{ value: ValueOf; } | null>; + cloudSet: (ctx: { key: K; value: ValueOf; }) => Promise; +}; -export class ProfileManager extends EventEmitter<{ - updated: (ctx: { - profile: PreferencesProfile - }) => void; -}> { +export class ProfileManager { + private storageProvider: StorageProvider; public profile: PreferencesProfile; - public store: Store<{ - [K in keyof PREF]: ValueOf; - }>; - constructor(profile: PreferencesProfile) { - super(); + /** + * static / state の略 (static が予約語のため) + */ + public s = {} as { + [K in keyof PREF]: ValueOf; + }; + + /** + * reactive の略 + */ + public r = {} as { + [K in keyof PREF]: Ref>; + }; + + 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(); - record[1] = value; + // TODO: 定期的にクラウドの値をフェッチ + } + + private rewriteRawState(key: K, value: ValueOf) { + const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除 + this.r[key].value = this.s[key] = v; + } + + public commit(key: K, value: ValueOf) { + 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 = ValueOf>( + key: K, + getter?: (v: ValueOf) => V, + setter?: (v: V) => ValueOf, + ): WritableComputedRef { + 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 }; - 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[] = []; + 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(key: K): [Cond, ValueOf] { + public getMatchedRecord(key: K): PrefRecord { 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(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(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(key: K): boolean { + return this.getMatchedRecord(key)[2].sync ?? false; + } + + public async enableSync(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(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(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> = { -// [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> = { - updated: (ctx: { - key: K; - value: Data[K]; - }) => void; -}; - -export class Store> extends EventEmitter> { - /** - * static / state の略 (static が予約語のため) - */ - public s = {} as { - [K in keyof Data]: Data[K]; - }; - - /** - * reactive の略 - */ - public r = {} as { - [K in keyof Data]: Ref; - }; - - 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(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(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( - key: K, - getter?: (v: Data[K]) => V, - setter?: (v: V) => Data[K], - ): WritableComputedRef { - 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, }); } From ffade9740ea759150e4a0f6c50d7928331f358e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Mar 2025 03:03:37 +0000 Subject: [PATCH 03/40] Bump version to 2025.3.2-alpha.7 --- package.json | 2 +- packages/misskey-js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d6324ca38c..591aa2a573 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.3.2-alpha.6", + "version": "2025.3.2-alpha.7", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index d9d7429e9a..d44e70c4f9 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2025.3.2-alpha.6", + "version": "2025.3.2-alpha.7", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", 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: [PATCH 04/40] =?UTF-8?q?=F0=9F=8E=A8?= 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 +++++----- .../src/components/MkPreferenceContainer.vue | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) 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 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: (ctx: { key: K; }) => Promise<{ value: ValueOf; } | null>; - cloudSet: (ctx: { key: K; value: ValueOf; }) => Promise; + cloudGet: (ctx: { key: K; cond: Cond; }) => Promise<{ value: ValueOf; } | null>; + cloudSet: (ctx: { key: K; cond: Cond; value: ValueOf; }) => Promise; }; 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 }; 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[] = []; 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(key: K): PrefRecord { + public getMatchedRecordOf(key: K): PrefRecord { 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(key: K): boolean { - return this.getMatchedRecord(key)[2].sync ?? false; + return this.getMatchedRecordOf(key)[2].sync ?? false; } public async enableSync(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(key: K) { if (!this.isSyncEnabled(key)) return; - const record = this.getMatchedRecord(key); + const record = this.getMatchedRecordOf(key); delete record[2].sync; this.save(); } From a36972179101b2132a27b836d87ff73ae8cc2f87 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 12 Mar 2025 14:35:22 +0900 Subject: [PATCH 06/40] remove todo --- packages/frontend/src/preferences.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 474abe22ab..27c61349a4 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -39,7 +39,6 @@ const storageProvider: StorageProvider = { cloudGet: async (ctx) => { // TODO: この取得方法だとアカウントが変わると保存場所も変わってしまうので改修する // 例えば複数アカウントある場合でも設定値を保存するための「プライマリアカウント」を設定できるようにするとか - // TODO: keyのcondに応じた取得 try { const cloudData = await misskeyApi('i/registry/get', { scope: ['client', 'preferences', 'sync'], From e594fb0037f440f589c36c01e0108f011545a681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Wed, 12 Mar 2025 14:37:57 +0900 Subject: [PATCH 07/40] =?UTF-8?q?enhance(dev):=20frontend=E3=81=AE?= =?UTF-8?q?=E6=A4=9C=E7=B4=A2=E3=82=A4=E3=83=B3=E3=83=87=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=82=B9=E4=BD=9C=E6=88=90=E3=82=92=E5=8D=98=E7=8B=AC=E3=81=AE?= =?UTF-8?q?=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=E3=81=A7=E8=A1=8C=E3=81=88?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#15653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + .../lib/vite-plugin-create-search-index.ts | 31 +++++++++++-------- packages/frontend/package.json | 2 ++ .../frontend/scripts/generate-search-index.ts | 15 +++++++++ packages/frontend/vite-node.config.ts | 3 ++ packages/frontend/vite.config.ts | 19 ++++++++---- pnpm-lock.yaml | 3 ++ 7 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 packages/frontend/scripts/generate-search-index.ts create mode 100644 packages/frontend/vite-node.config.ts diff --git a/package.json b/package.json index 591aa2a573..7142e1f660 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", "build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api", + "build-frontend-search-index": "pnpm --filter frontend build-search-index", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", diff --git a/packages/frontend/lib/vite-plugin-create-search-index.ts b/packages/frontend/lib/vite-plugin-create-search-index.ts index e194872640..d506e84bb6 100644 --- a/packages/frontend/lib/vite-plugin-create-search-index.ts +++ b/packages/frontend/lib/vite-plugin-create-search-index.ts @@ -1428,6 +1428,23 @@ async function processVueFile( }; } +export async function generateSearchIndex(options: Options, transformedCodeCache: Record = {}) { + const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => { + const matchedFiles = glob.sync(filePathPattern); + return [...acc, ...matchedFiles]; + }, []); + + for (const filePath of filePaths) { + const id = path.resolve(filePath); // 絶対パスに変換 + const code = fs.readFileSync(filePath, 'utf-8'); // ファイル内容を読み込む + const { transformedCodeCache: newCache } = await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す + transformedCodeCache = newCache; // キャッシュを更新 + } + + await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行 + + return transformedCodeCache; // キャッシュを返す +} // Rollup プラグインとして export export default function pluginCreateSearchIndex(options: Options): Plugin { @@ -1445,19 +1462,7 @@ export default function pluginCreateSearchIndex(options: Options): Plugin { return; } - const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => { - const matchedFiles = glob.sync(filePathPattern); - return [...acc, ...matchedFiles]; - }, []); - - for (const filePath of filePaths) { - const id = path.resolve(filePath); // 絶対パスに変換 - const code = fs.readFileSync(filePath, 'utf-8'); // ファイル内容を読み込む - const { transformedCodeCache: newCache } = await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す - transformedCodeCache = newCache; // キャッシュを更新 - } - - await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行 + transformedCodeCache = await generateSearchIndex(options, transformedCodeCache); }, async transform(code, id) { diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 946a0be48f..d829a34804 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -5,6 +5,7 @@ "scripts": { "watch": "vite", "build": "vite build", + "build-search-index": "vite-node --config \"./vite-node.config.ts\" \"./scripts/generate-search-index.ts\"", "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", "build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", "build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static", @@ -133,6 +134,7 @@ "start-server-and-test": "2.0.10", "storybook": "8.6.4", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", + "vite-node": "3.0.8", "vite-plugin-turbosnap": "1.0.3", "vitest": "3.0.8", "vitest-fetch-mock": "0.4.5", diff --git a/packages/frontend/scripts/generate-search-index.ts b/packages/frontend/scripts/generate-search-index.ts new file mode 100644 index 0000000000..cbb4bb8c51 --- /dev/null +++ b/packages/frontend/scripts/generate-search-index.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { searchIndexes } from '../vite.config.js'; +import { generateSearchIndex } from '../lib/vite-plugin-create-search-index.js'; + +async function main() { + for (const searchIndex of searchIndexes) { + await generateSearchIndex(searchIndex); + } +} + +main(); diff --git a/packages/frontend/vite-node.config.ts b/packages/frontend/vite-node.config.ts new file mode 100644 index 0000000000..c049f46e10 --- /dev/null +++ b/packages/frontend/vite-node.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({}); diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index a28fc553f4..ec80e71ae4 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -1,7 +1,8 @@ import path from 'path'; import pluginReplace from '@rollup/plugin-replace'; import pluginVue from '@vitejs/plugin-vue'; -import { type UserConfig, defineConfig } from 'vite'; +import { defineConfig } from 'vite'; +import type { UserConfig } from 'vite'; import * as yaml from 'js-yaml'; import { promises as fsp } from 'fs'; @@ -11,12 +12,22 @@ import packageInfo from './package.json' with { type: 'json' }; import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js'; import pluginJson5 from './vite.json5.js'; import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js'; +import type { Options as SearchIndexOptions } from './lib/vite-plugin-create-search-index.js'; const url = process.env.NODE_ENV === 'development' ? yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')).url : null; const host = url ? (new URL(url)).hostname : undefined; const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue']; +/** + * 検索インデックスの生成設定 + */ +export const searchIndexes = [{ + targetFilePaths: ['src/pages/settings/*.vue'], + exportFilePath: './src/utility/autogen/settings-search-index.ts', + verbose: process.env.FRONTEND_SEARCH_INDEX_VERBOSE === 'true', +}] satisfies SearchIndexOptions[]; + /** * Misskeyのフロントエンドにバンドルせず、CDNなどから別途読み込むリソースを記述する。 * CDNを使わずにバンドルしたい場合、以下の配列から該当要素を削除orコメントアウトすればOK @@ -84,11 +95,7 @@ export function getConfig(): UserConfig { }, plugins: [ - pluginCreateSearchIndex({ - targetFilePaths: ['src/pages/settings/*.vue'], - exportFilePath: './src/utility/autogen/settings-search-index.ts', - verbose: process.env.FRONTEND_SEARCH_INDEX_VERBOSE === 'true', - }), + ...searchIndexes.map(options => pluginCreateSearchIndex(options)), pluginVue(), pluginUnwindCssModuleClassName(), pluginJson5(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b70b273d0..681cf6fb18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1040,6 +1040,9 @@ importers: storybook-addon-misskey-theme: specifier: github:misskey-dev/storybook-addon-misskey-theme version: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640(@storybook/blocks@8.6.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.4(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)))(@storybook/components@8.6.4(storybook@8.6.4(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)))(@storybook/core-events@8.6.4(storybook@8.6.4(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)))(@storybook/manager-api@8.6.4(storybook@8.6.4(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)))(@storybook/preview-api@8.6.4(storybook@8.6.4(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)))(@storybook/theming@8.6.4(storybook@8.6.4(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)))(@storybook/types@8.6.4(storybook@8.6.4(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5)))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + vite-node: + specifier: 3.0.8 + version: 3.0.8(@types/node@22.13.9)(sass@1.85.1)(terser@5.39.0)(tsx@4.19.3) vite-plugin-turbosnap: specifier: 1.0.3 version: 1.0.3 From 8508c4dadc51fa597884655195b6fe80ca2d4e08 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:07:45 +0900 Subject: [PATCH 08/40] refactor --- packages/frontend/src/preferences.ts | 15 +++++++ packages/frontend/src/preferences/profile.ts | 42 +++++++++++--------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 27c61349a4..bfcd8b8dd7 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -87,6 +87,21 @@ const storageProvider: StorageProvider = { value: cloudData, }); }, + + cloudGets: async (ctx) => { + // TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要) + const fetchings = ctx.needs.map(need => storageProvider.cloudGet(need).then(res => [need.key, res] as const)); + const cloudDatas = await Promise.all(fetchings); + + const res = {} as Partial>; + for (const cloudData of cloudDatas) { + if (cloudData[1] != null) { + res[cloudData[0]] = cloudData[1].value; + } + } + + return res; + }, }; export const prefer = createProfileManager(storageProvider); diff --git a/packages/frontend/src/preferences/profile.ts b/packages/frontend/src/preferences/profile.ts index de1c674e5c..2ac4e58d14 100644 --- a/packages/frontend/src/preferences/profile.ts +++ b/packages/frontend/src/preferences/profile.ts @@ -79,6 +79,7 @@ export type PreferencesProfile = { export type StorageProvider = { save: (ctx: { profile: PreferencesProfile; }) => void; + cloudGets: (ctx: { needs: { key: K; cond: Cond; }[] }) => Promise>>>; cloudGet: (ctx: { key: K; cond: Cond; }) => Promise<{ value: ValueOf; } | null>; cloudSet: (ctx: { key: K; cond: Cond; value: ValueOf; }) => Promise; }; @@ -193,31 +194,34 @@ export class ProfileManager { return states; } - private fetchCloudValues() { - // TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要) - - const promises: Promise[] = []; + private async fetchCloudValues() { + const needs = [] as { key: keyof PREF; cond: Cond; }[]; for (const key in PREF_DEF) { const record = this.getMatchedRecordOf(key); if (record[2].sync) { - const getting = this.storageProvider.cloudGet({ key, cond: record[0] }); - 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); - } - })); + needs.push({ + key, + cond: record[0], + }); } } - Promise.all(promises).then(() => { - console.log('cloud fetched all'); - this.save(); - console.log(this.s.showFixedPostForm, this.r.showFixedPostForm.value); - }); + const cloudValues = await this.storageProvider.cloudGets({ needs }); + + for (const key in PREF_DEF) { + const record = this.getMatchedRecordOf(key); + if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) { + const cloudValue = cloudValues[key]; + if (cloudValue !== this.s[key]) { + this.rewriteRawState(key, cloudValue); + record[1] = cloudValue; + console.log('cloud fetched', key, cloudValue); + } + } + } + + this.save(); + console.log('cloud fetch completed'); } public static newProfile(): PreferencesProfile { From 15685be4cc02a7bc1b15028cdd498de52e35a5ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Mar 2025 06:10:35 +0000 Subject: [PATCH 09/40] Bump version to 2025.3.2-alpha.8 --- package.json | 2 +- packages/misskey-js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7142e1f660..8f89108665 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.3.2-alpha.7", + "version": "2025.3.2-alpha.8", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index d44e70c4f9..021c5a54bd 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2025.3.2-alpha.7", + "version": "2025.3.2-alpha.8", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", From aa1cc2f817b0980609ca44715d72bf341f3c2e91 Mon Sep 17 00:00:00 2001 From: zyoshoka <107108195+zyoshoka@users.noreply.github.com> Date: Wed, 12 Mar 2025 16:51:10 +0900 Subject: [PATCH 10/40] fix(storybook): use type-only imports in generated stories (#15654) --- packages/frontend/.storybook/generate.tsx | 61 ++++++++++++++++++++--- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 3cd08191f5..89d4214141 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -17,8 +17,52 @@ interface SatisfiesExpression extends estree.BaseExpression { reference: estree.Identifier; } +interface ImportDeclaration extends estree.ImportDeclaration { + kind?: 'type'; +} + const generator = { ...GENERATOR, + ImportDeclaration(node: ImportDeclaration, state: State) { + state.write('import '); + if (node.kind === 'type') state.write('type '); + const { specifiers } = node; + if (specifiers.length > 0) { + let i = 0; + for (; i < specifiers.length; i++) { + if (i > 0) { + state.write(', '); + } + const specifier = specifiers[i]!; + if (specifier.type === 'ImportDefaultSpecifier') { + state.write(specifier.local.name, specifier); + } else if (specifier.type === 'ImportNamespaceSpecifier') { + state.write(`* as ${specifier.local.name}`, specifier); + } else { + break; + } + } + if (i < specifiers.length) { + state.write('{'); + for (; i < specifiers.length; i++) { + const specifier = specifiers[i]! as estree.ImportSpecifier; + const { name } = specifier.imported as estree.Identifier; + state.write(name, specifier); + if (name !== specifier.local.name) { + state.write(` as ${specifier.local.name}`); + } + if (i < specifiers.length - 1) { + state.write(', '); + } + } + state.write('}'); + } + state.write(' from '); + } + this.Literal(node.source, state); + + state.write(';'); + }, SatisfiesExpression(node: SatisfiesExpression, state: State) { switch (node.expression.type) { case 'ArrowFunctionExpression': { @@ -62,7 +106,7 @@ type ToKebab = T extends readonly [ : T extends readonly [ infer XH extends string, ...infer XR extends readonly string[] - ] + ] ? `${XH}${XR extends readonly string[] ? `-${ToKebab}` : ''}` : ''; @@ -132,7 +176,7 @@ function toStories(component: string): Promise { kind={'init' as const} shorthand /> as estree.Property, - ] + ] : []), ]} /> as estree.ObjectExpression; @@ -155,7 +199,8 @@ function toStories(component: string): Promise { /> as estree.ImportSpecifier, ]), ]} - /> as estree.ImportDeclaration, + kind={'type'} + /> as ImportDeclaration, ...(hasMsw ? [ { local={ as estree.Identifier} /> as estree.ImportNamespaceSpecifier, ]} - /> as estree.ImportDeclaration, - ] + /> as ImportDeclaration, + ] : []), ...(hasImplStories ? [] @@ -176,8 +221,8 @@ function toStories(component: string): Promise { specifiers={[ as estree.ImportDefaultSpecifier, ]} - /> as estree.ImportDeclaration, - ]), + /> as ImportDeclaration, + ]), ...(hasMetaStories ? [ { local={ as estree.Identifier} /> as estree.ImportNamespaceSpecifier, ]} - /> as estree.ImportDeclaration, + /> as ImportDeclaration, ] : []), Date: Wed, 12 Mar 2025 18:54:36 +0900 Subject: [PATCH 11/40] add todo --- packages/frontend/src/preferences.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index bfcd8b8dd7..ab73fe2b77 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -50,7 +50,7 @@ const storageProvider: StorageProvider = { value: target[1], }; } catch (err: any) { - if (err.code === 'NO_SUCH_KEY') { + if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する return null; } else { throw err; @@ -66,7 +66,7 @@ const storageProvider: StorageProvider = { key: syncGroup + ':' + ctx.key, }) as [any, any][]; } catch (err: any) { - if (err.code === 'NO_SUCH_KEY') { + if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する cloudData = []; } else { throw err; From 3129fcf164369ecd1ed7fd26e17e0806ada4e435 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 12 Mar 2025 20:17:54 +0900 Subject: [PATCH 12/40] fix(frontend): fix type errors --- packages/frontend/src/preferences.ts | 4 +-- packages/frontend/src/preferences/def.ts | 9 ++---- .../preferences/{profile.ts => manager.ts} | 30 ++++++++++++++----- packages/frontend/src/preferences/utility.ts | 2 +- 4 files changed, 28 insertions(+), 17 deletions(-) rename packages/frontend/src/preferences/{profile.ts => manager.ts} (92%) diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index ab73fe2b77..7d1821b72b 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -4,10 +4,10 @@ */ import { v4 as uuid } from 'uuid'; -import type { PreferencesProfile, StorageProvider } from '@/preferences/profile.js'; +import type { PreferencesProfile, StorageProvider } from '@/preferences/manager.js'; import { cloudBackup } from '@/preferences/utility.js'; import { miLocalStorage } from '@/local-storage.js'; -import { isSameCond, ProfileManager } from '@/preferences/profile.js'; +import { isSameCond, ProfileManager } from '@/preferences/manager.js'; import { store } from '@/store.js'; import { $i } from '@/account.js'; import { misskeyApi } from '@/utility/misskey-api.js'; diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index b75b99d6b5..47d0ab5cbc 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -9,7 +9,8 @@ import type { Theme } from '@/theme.js'; import type { SoundType } from '@/utility/sound.js'; import type { Plugin } from '@/plugin.js'; import type { DeviceKind } from '@/utility/device-kind.js'; -import type { Column, DeckProfile } from '@/deck.js'; +import type { DeckProfile } from '@/deck.js'; +import type { PreferencesDefinition } from './manager.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; /** サウンド設定 */ @@ -324,8 +325,4 @@ export const PREF_DEF = { sfxVolume: 1, }, }, -} satisfies Record; +} satisfies PreferencesDefinition; diff --git a/packages/frontend/src/preferences/profile.ts b/packages/frontend/src/preferences/manager.ts similarity index 92% rename from packages/frontend/src/preferences/profile.ts rename to packages/frontend/src/preferences/manager.ts index 2ac4e58d14..9866227d93 100644 --- a/packages/frontend/src/preferences/profile.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -84,6 +84,12 @@ export type StorageProvider = { cloudSet: (ctx: { key: K; cond: Cond; value: ValueOf; }) => Promise; }; +export type PreferencesDefinition = Record; + export class ProfileManager { private storageProvider: StorageProvider; public profile: PreferencesProfile; @@ -118,6 +124,10 @@ export class ProfileManager { // TODO: 定期的にクラウドの値をフェッチ } + private isAccountDependentKey(key: K): boolean { + return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true; + } + private rewriteRawState(key: K, value: ValueOf) { const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除 this.r[key].value = this.s[key] = v; @@ -129,7 +139,7 @@ export class ProfileManager { this.rewriteRawState(key, value); const record = this.getMatchedRecordOf(key); - if (parseCond(record[0]).account == null && PREF_DEF[key].accountDependent) { + if (parseCond(record[0]).account == null && this.isAccountDependentKey(key)) { this.profile.preferences[key].push([makeCond({ account: `${host}/${$i!.id}`, }), value, {}]); @@ -186,9 +196,10 @@ export class ProfileManager { private genStates() { const states = {} as { [K in keyof PREF]: ValueOf }; - for (const key in PREF_DEF) { + for (const _key in PREF_DEF) { + const key = _key as keyof PREF; const record = this.getMatchedRecordOf(key); - states[key] = record[1]; + (states[key] as any) = record[1]; } return states; @@ -196,7 +207,8 @@ export class ProfileManager { private async fetchCloudValues() { const needs = [] as { key: keyof PREF; cond: Cond; }[]; - for (const key in PREF_DEF) { + for (const _key in PREF_DEF) { + const key = _key as keyof PREF; const record = this.getMatchedRecordOf(key); if (record[2].sync) { needs.push({ @@ -208,7 +220,8 @@ export class ProfileManager { const cloudValues = await this.storageProvider.cloudGets({ needs }); - for (const key in PREF_DEF) { + for (const _key in PREF_DEF) { + const key = _key as keyof PREF; const record = this.getMatchedRecordOf(key); if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) { const cloudValue = cloudValues[key]; @@ -290,7 +303,7 @@ export class ProfileManager { public setAccountOverride(key: K) { if ($i == null) return; - if (PREF_DEF[key].accountDependent) throw new Error('already account-dependent'); + if (this.isAccountDependentKey(key)) throw new Error('already account-dependent'); if (this.isAccountOverrided(key)) return; const records = this.profile.preferences[key]; @@ -303,7 +316,7 @@ export class ProfileManager { public clearAccountOverride(key: K) { if ($i == null) return; - if (PREF_DEF[key].accountDependent) throw new Error('cannot clear override for this account-dependent property'); + if (this.isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property'); const records = this.profile.preferences[key]; @@ -377,7 +390,8 @@ export class ProfileManager { public rewriteProfile(profile: PreferencesProfile) { this.profile = profile; const states = this.genStates(); - for (const key in states) { + for (const _key in states) { + const key = _key as keyof PREF; this.rewriteRawState(key, states[key]); } diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index fc6eff5f49..c37dbcf96b 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -4,7 +4,7 @@ */ import { ref, watch } from 'vue'; -import type { PreferencesProfile } from './profile.js'; +import type { PreferencesProfile } from './manager.js'; import type { MenuItem } from '@/types/menu.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; From a06b9eefaa550b2fa67ad661384d431cc842bfc2 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 12 Mar 2025 21:05:39 +0900 Subject: [PATCH 13/40] enhance(frontend): suppress needless confirmation when turn on pref sync --- packages/frontend/src/preferences/manager.ts | 3 +- packages/frontend/src/utility/deep-equal.ts | 29 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/src/utility/deep-equal.ts diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 9866227d93..3f3eba6389 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -13,6 +13,7 @@ import { $i } from '@/account.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { deepEqual } from '@/utility/deep-equal.js'; // NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない @@ -340,7 +341,7 @@ export class ProfileManager { const record = this.getMatchedRecordOf(key); const existing = await this.storageProvider.cloudGet({ key, cond: record[0] }); - if (existing != null) { + if (existing != null && !deepEqual(existing.value, record[1])) { const { canceled, result } = await os.select({ title: i18n.ts.preferenceSyncConflictTitle, text: i18n.ts.preferenceSyncConflictText, diff --git a/packages/frontend/src/utility/deep-equal.ts b/packages/frontend/src/utility/deep-equal.ts new file mode 100644 index 0000000000..c64d82c6cc --- /dev/null +++ b/packages/frontend/src/utility/deep-equal.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function deepEqual(a: any, b: any): boolean { + if (a === b) return true; + + if (a === null) return b === null; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } else if (((typeof a) === 'object') && ((typeof b) === 'object')) { + const aks = Object.keys(a); + const bks = Object.keys(b); + if (aks.length !== bks.length) return false; + for (let i = 0; i < aks.length; i++) { + const k = aks[i]; + if (!deepEqual(a[k], b[k])) return false; + } + return true; + } + + return false; +} From 4a73feb041a554e813e490cc5cd63c105e142623 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 12 Mar 2025 21:12:08 +0900 Subject: [PATCH 14/40] enhance(frontend): make deck profiles syncable --- locales/index.d.ts | 4 ++ locales/ja-JP.yml | 1 + packages/frontend/src/pages/settings/deck.vue | 40 ++++++++++++++----- .../utility/autogen/settings-search-index.ts | 7 ++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index 297b56e289..a4233cf7c8 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9848,6 +9848,10 @@ export interface Locale extends ILocale { * 幅を自動調整 */ "flexible": string; + /** + * プロファイル情報のデバイス間同期を有効にする + */ + "enableSyncBetweenDevicesForProfiles": string; "_columns": { /** * メイン diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 23aeb59863..c45553817d 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2603,6 +2603,7 @@ _deck: useSimpleUiForNonRootPages: "非ルートページは簡易UIで表示" usedAsMinWidthWhenFlexible: "「幅を自動調整」が有効の場合、これが幅の最小値となります" flexible: "幅を自動調整" + enableSyncBetweenDevicesForProfiles: "プロファイル情報のデバイス間同期を有効にする" _columns: main: "メイン" diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index e7c5c942e9..2c4ec01344 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -4,23 +4,27 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/frontend/src/pages/settings/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue new file mode 100644 index 0000000000..398228e226 --- /dev/null +++ b/packages/frontend/src/pages/settings/emoji-palette.vue @@ -0,0 +1,251 @@ + + + + + + + diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue deleted file mode 100644 index d8f27078ae..0000000000 --- a/packages/frontend/src/pages/settings/emoji-picker.vue +++ /dev/null @@ -1,288 +0,0 @@ - - - - - - - diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 7bbec82757..debcd4bd3e 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -86,11 +86,6 @@ const menuDef = computed(() => [{ text: i18n.ts.privacy, to: '/settings/privacy', active: currentPage.value?.route.name === 'privacy', - }, { - icon: 'ti ti-mood-happy', - text: i18n.ts.emojiPicker, - to: '/settings/emoji-picker', - active: currentPage.value?.route.name === 'emojiPicker', }, { icon: 'ti ti-bell', text: i18n.ts.notifications, @@ -118,6 +113,11 @@ const menuDef = computed(() => [{ text: i18n.ts.theme, to: '/settings/theme', active: currentPage.value?.route.name === 'theme', + }, { + icon: 'ti ti-mood-happy', + text: i18n.ts.emojiPalette, + to: '/settings/emoji-palette', + active: currentPage.value?.route.name === 'emoji-palette', }, { icon: 'ti ti-device-desktop', text: i18n.ts.appearance, diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 47d0ab5cbc..6a926c4b26 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -29,6 +29,8 @@ export type SoundStore = { volume: number; }; +// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる) + export const PREF_DEF = { pinnedUserLists: { accountDependent: true, @@ -56,6 +58,27 @@ export const PREF_DEF = { default: [] as DeckProfile[], }, + emojiPalettes: { + serverDependent: true, + default: [{ + id: 'a', + name: '', + emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + }] as { + id: string; + name: string; + emojis: string[]; + }[], + }, + emojiPaletteForReaction: { + serverDependent: true, + default: null as string | null, + }, + emojiPaletteForMain: { + serverDependent: true, + default: null as string | null, + }, + overridedDeviceKind: { default: null as DeviceKind | null, }, @@ -180,13 +203,13 @@ export const PREF_DEF = { default: 'remote' as 'none' | 'remote' | 'always', }, emojiPickerScale: { - default: 1, + default: 2, }, emojiPickerWidth: { - default: 1, + default: 2, }, emojiPickerHeight: { - default: 2, + default: 3, }, emojiPickerStyle: { default: 'auto' as 'auto' | 'popup' | 'drawer', diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index 93dd081127..752356497e 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -66,9 +66,9 @@ const routes: RouteDef[] = [{ name: 'privacy', component: page(() => import('@/pages/settings/privacy.vue')), }, { - path: '/emoji-picker', - name: 'emojiPicker', - component: page(() => import('@/pages/settings/emoji-picker.vue')), + path: '/emoji-palette', + name: 'emoji-palette', + component: page(() => import('@/pages/settings/emoji-palette.vue')), }, { path: '/drive', name: 'drive', diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 738a57d233..6eebcd1ead 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -39,14 +39,6 @@ export const store = markRaw(new Storage('base', { where: 'account', default: null, }, - reactions: { - where: 'account', - default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], - }, - pinnedEmojis: { - where: 'account', - default: [], - }, reactionAcceptance: { where: 'account', default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, @@ -127,6 +119,14 @@ export const store = markRaw(new Storage('base', { }, //#region TODO: そのうち消す (preferに移行済み) + reactions: { + where: 'account', + default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + }, + pinnedEmojis: { + where: 'account', + default: [], + }, widgets: { where: 'account', default: [] as { diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts index 52100ab639..4f1a94f266 100644 --- a/packages/frontend/src/utility/autogen/settings-search-index.ts +++ b/packages/frontend/src/utility/autogen/settings-search-index.ts @@ -536,6 +536,57 @@ export const searchIndexes: SearchIndexItem[] = [ path: '/settings/mute-block', icon: 'ti ti-ban', }, + { + id: 'yR1OSyLiT', + children: [ + { + id: 'yMJzyzOUk', + label: i18n.ts._emojiPalette.enableSyncBetweenDevicesForPalettes, + keywords: ['sync', 'palettes', 'devices'], + }, + { + id: 'wCE09vgZr', + label: i18n.ts._emojiPalette.paletteForMain, + keywords: ['main', 'palette'], + }, + { + id: 'uCzRPrSNx', + label: i18n.ts._emojiPalette.paletteForReaction, + keywords: ['reaction', 'palette'], + }, + { + id: 'hgQr28WUk', + children: [ + { + id: 'fY04NIHSQ', + label: i18n.ts.size, + keywords: ['emoji', 'picker', 'scale', 'size'], + }, + { + id: '3j7vlaL7t', + label: i18n.ts.numberOfColumn, + keywords: ['emoji', 'picker', 'width', 'column', 'size'], + }, + { + id: 'zPX8z1Bcy', + label: i18n.ts.height, + keywords: ['emoji', 'picker', 'height', 'size'], + }, + { + id: '2CSkZa4tl', + label: i18n.ts.style, + keywords: ['emoji', 'picker', 'style'], + }, + ], + label: i18n.ts.emojiPickerDisplay, + keywords: ['emoji', 'picker', 'display'], + }, + ], + label: i18n.ts.emojiPalette, + keywords: ['emoji', 'palette'], + path: '/settings/emoji-palette', + icon: 'ti ti-mood-happy', + }, { id: '3Tcxw4Fwl', children: [ @@ -608,23 +659,28 @@ export const searchIndexes: SearchIndexItem[] = [ id: 'FfZdOs8y', children: [ { - id: 'lVlkdP4zN', + id: 'B1ZU6Ur54', + label: i18n.ts._deck.enableSyncBetweenDevicesForProfiles, + keywords: ['sync', 'profiles', 'devices'], + }, + { + id: 'iEF0gqNAo', label: i18n.ts._deck.useSimpleUiForNonRootPages, keywords: ['ui', 'root', 'page'], }, { - id: 'avgxEYgsi', + id: 'BNdSeWxZn', label: i18n.ts.defaultNavigationBehaviour, keywords: ['default', 'navigation', 'behaviour', 'window'], }, { - id: 'ma7OSw5JK', + id: 'zT9pGm8DF', label: i18n.ts._deck.alwaysShowMainColumn, keywords: ['always', 'show', 'main', 'column'], }, { - id: 'jjTlUDhJH', - label: 'Unnamed marker', + id: '5dk2xv1vc', + label: i18n.ts._deck.columnAlign, keywords: ['column', 'align'], }, ], diff --git a/packages/frontend/src/utility/emoji-picker.ts b/packages/frontend/src/utility/emoji-picker.ts index e7275b86f2..6279786b2d 100644 --- a/packages/frontend/src/utility/emoji-picker.ts +++ b/packages/frontend/src/utility/emoji-picker.ts @@ -3,10 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { defineAsyncComponent, ref } from 'vue'; +import { defineAsyncComponent, ref, watch } from 'vue'; import type { Ref } from 'vue'; import { popup } from '@/os.js'; -import { store } from '@/store.js'; +import { prefer } from '@/preferences.js'; /** * 絵文字ピッカーを表示する。 @@ -25,7 +25,14 @@ class EmojiPicker { } public async init() { - const emojisRef = store.r.pinnedEmojis; + const emojisRef = ref([]); + + watch([prefer.r.emojiPaletteForMain, prefer.r.emojiPalettes], () => { + emojisRef.value = prefer.s.emojiPaletteForMain == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForMain)?.emojis ?? []; + }, { + immediate: true, + }); + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src: this.src, pinnedEmojis: emojisRef, diff --git a/packages/frontend/src/utility/reaction-picker.ts b/packages/frontend/src/utility/reaction-picker.ts index 200fb0b686..7c159fa2da 100644 --- a/packages/frontend/src/utility/reaction-picker.ts +++ b/packages/frontend/src/utility/reaction-picker.ts @@ -4,10 +4,10 @@ */ import * as Misskey from 'misskey-js'; -import { defineAsyncComponent, ref } from 'vue'; +import { defineAsyncComponent, ref, watch } from 'vue'; import type { Ref } from 'vue'; import { popup } from '@/os.js'; -import { store } from '@/store.js'; +import { prefer } from '@/preferences.js'; class ReactionPicker { private src: Ref = ref(null); @@ -21,7 +21,14 @@ class ReactionPicker { } public async init() { - const reactionsRef = store.r.reactions; + const reactionsRef = ref([]); + + watch([prefer.r.emojiPaletteForReaction, prefer.r.emojiPalettes], () => { + reactionsRef.value = prefer.s.emojiPaletteForReaction == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForReaction)?.emojis ?? []; + }, { + immediate: true, + }); + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src: this.src, pinnedEmojis: reactionsRef, From 5d228fb0f32aca9337c8b8a9ea9544f28d981f34 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 13 Mar 2025 17:39:53 +0900 Subject: [PATCH 26/40] enhance(frontend): re-organize settings page --- locales/index.d.ts | 4 + locales/ja-JP.yml | 1 + .../src/pages/settings/accessibility.vue | 51 ++ .../src/pages/settings/appearance.vue | 325 --------- .../frontend/src/pages/settings/index.vue | 5 - .../src/pages/settings/preferences.vue | 668 ++++++++++++------ packages/frontend/src/router/definition.ts | 4 - .../utility/autogen/settings-search-index.ts | 423 ++++++----- 8 files changed, 702 insertions(+), 779 deletions(-) delete mode 100644 packages/frontend/src/pages/settings/appearance.vue diff --git a/locales/index.d.ts b/locales/index.d.ts index b814bb70e1..f579aadb5d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5342,6 +5342,10 @@ export interface Locale extends ILocale { * 絵文字パレット */ "emojiPalette": string; + /** + * 投稿フォーム + */ + "postForm": string; "_emojiPalette": { /** * パレット diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b51a839715..2151a06611 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1331,6 +1331,7 @@ preferenceSyncConflictChoiceDevice: "デバイスの設定値" preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル" paste: "ペースト" emojiPalette: "絵文字パレット" +postForm: "投稿フォーム" _emojiPalette: palettes: "パレット" diff --git a/packages/frontend/src/pages/settings/accessibility.vue b/packages/frontend/src/pages/settings/accessibility.vue index 3dbb039a17..f7b1e7d2a0 100644 --- a/packages/frontend/src/pages/settings/accessibility.vue +++ b/packages/frontend/src/pages/settings/accessibility.vue @@ -60,6 +60,17 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + + + + + + + + + @@ -70,6 +81,22 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + + + + + + + + +
@@ -84,6 +111,8 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { miLocalStorage } from '@/local-storage.js'; +import MkRadios from '@/components/MkRadios.vue'; const reduceAnimation = prefer.model('animation', v => !v, v => !v); const animatedMfm = prefer.model('animatedMfm'); @@ -92,10 +121,32 @@ const keepScreenOn = prefer.model('keepScreenOn'); const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe'); const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer'); const contextMenu = prefer.model('contextMenu'); +const menuStyle = prefer.model('menuStyle'); + +const fontSize = ref(miLocalStorage.getItem('fontSize')); +const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); + +watch(fontSize, () => { + if (fontSize.value == null) { + miLocalStorage.removeItem('fontSize'); + } else { + miLocalStorage.setItem('fontSize', fontSize.value); + } +}); + +watch(useSystemFont, () => { + if (useSystemFont.value) { + miLocalStorage.setItem('useSystemFont', 't'); + } else { + miLocalStorage.removeItem('useSystemFont'); + } +}); watch([ keepScreenOn, contextMenu, + fontSize, + useSystemFont, ], async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); diff --git a/packages/frontend/src/pages/settings/appearance.vue b/packages/frontend/src/pages/settings/appearance.vue deleted file mode 100644 index 3fda5bc4c8..0000000000 --- a/packages/frontend/src/pages/settings/appearance.vue +++ /dev/null @@ -1,325 +0,0 @@ - - - - - diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index debcd4bd3e..3b7c44fbfe 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -118,11 +118,6 @@ const menuDef = computed(() => [{ text: i18n.ts.emojiPalette, to: '/settings/emoji-palette', active: currentPage.value?.route.name === 'emoji-palette', - }, { - icon: 'ti ti-device-desktop', - text: i18n.ts.appearance, - to: '/settings/appearance', - active: currentPage.value?.route.name === 'appearance', }, { icon: 'ti ti-music', text: i18n.ts.sounds, diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 374477c510..b9a596067c 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -10,121 +10,174 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._settings.preferencesBanner }} - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - {{ i18n.ts.add }} - {{ i18n.ts.remove }} - - - - - - - - - - -
-
- - -
- - - - - - - - - - - - - - - - - -
- - - - - - - - - - - {{ i18n.ts._visibility.disableFederation }} - -
-
-
-
-
-
- - - - + + +
+ + + + + + + + + + + + + + + + + +
- - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + +
+
+
+
+ + + + + + {{ i18n.ts.add }} + {{ i18n.ts.remove }} + + +
+
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + +
+
@@ -157,6 +210,14 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + @@ -164,40 +225,6 @@ SPDX-License-Identifier: AGPL-3.0-only -
-
-
-
- - - - - -
- - - - - - - -
-
-
- - - - - -
-
- - - - - - - @@ -206,47 +233,70 @@ SPDX-License-Identifier: AGPL-3.0-only +
- - - - - - - + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - + + + + + + + + + + + - - - - - - - + + + + + + + + + + - - - - - - - + + + + + + + + + + +
+ +
+ + + + +
+
@@ -254,6 +304,123 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + {{ i18n.ts._visibility.disableFederation }} + +
+
+
+
+
+
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ i18n.ts._notification.checkNotificationBehavior }} +
+
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
@@ -276,47 +443,6 @@ SPDX-License-Identifier: AGPL-3.0-only - - - - -
- {{ i18n.ts.reloadRequiredToApplySettings }} - -
- {{ i18n.ts.enableAll }} - {{ i18n.ts.disableAll }} -
-
- - {{ i18n.ts._dataSaver._media.title }} - - - - {{ i18n.ts._dataSaver._avatar.title }} - - - - {{ i18n.ts._dataSaver._urlPreview.title }} - - - - {{ i18n.ts._dataSaver._code.title }} - - -
-
-
-
-
- -
- - - - - -
@@ -347,18 +473,47 @@ SPDX-License-Identifier: AGPL-3.0-only
- - {{ i18n.ts.navbar }} - {{ i18n.ts.statusbar }} - + - -
- {{ i18n.ts.deck }} -
-
+ + + + +
+ {{ i18n.ts.reloadRequiredToApplySettings }} + +
+ {{ i18n.ts.enableAll }} + {{ i18n.ts.disableAll }} +
+
+ + {{ i18n.ts._dataSaver._media.title }} + + + + {{ i18n.ts._dataSaver._avatar.title }} + + + + {{ i18n.ts._dataSaver._urlPreview.title }} + + + + {{ i18n.ts._dataSaver._code.title }} + + +
+
+
+
+ + {{ i18n.ts.navbar }} + {{ i18n.ts.statusbar }} + {{ i18n.ts.deck }} + {{ i18n.ts.customCss }} @@ -366,6 +521,7 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index 62b13d22be..73920766d7 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -57,10 +57,6 @@ const routes: RouteDef[] = [{ path: '/avatar-decoration', name: 'avatarDecoration', component: page(() => import('@/pages/settings/avatar-decoration.vue')), - }, { - path: '/roles', - name: 'roles', - component: page(() => import('@/pages/settings/roles.vue')), }, { path: '/privacy', name: 'privacy', diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts index 734dc0c99c..e44910e850 100644 --- a/packages/frontend/src/utility/autogen/settings-search-index.ts +++ b/packages/frontend/src/utility/autogen/settings-search-index.ts @@ -271,55 +271,55 @@ export const searchIndexes: SearchIndexItem[] = [ id: '3yCAv0IsZ', children: [ { - id: 'kMJ5laK3n', + id: 'AKvDrxSj5', children: [ { - id: 'EC8J177N8', + id: 'cAszhShB0', label: i18n.ts.uiLanguage, keywords: ['language'], }, { - id: 'CHKy9gnrh', + id: 'apz9AutPm', label: i18n.ts.overridedDeviceKind, keywords: ['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop'], }, { - id: 'snyCQ5oKE', + id: 'nqRVtw1xw', label: i18n.ts.useBlurEffect, keywords: ['blur'], }, { - id: '8j36S4Ev6', + id: 'EO5WHBeG8', label: i18n.ts.useBlurEffectForModal, keywords: ['blur', 'modal'], }, { - id: 'cytWLyF1V', + id: 'CWpyT9vLK', label: i18n.ts.showAvatarDecorations, keywords: ['avatar', 'icon', 'decoration', 'show'], }, { - id: 'odi1d2SWy', + id: '1wwACqQz1', label: i18n.ts.alwaysConfirmFollow, keywords: ['follow', 'confirm', 'always'], }, { - id: 'm43Eu3Ypg', + id: '1x3JNXj8N', label: i18n.ts.highlightSensitiveMedia, keywords: ['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail'], }, { - id: 'cjfAtxMzP', + id: 'CfAg0Qekq', label: i18n.ts.confirmWhenRevealingSensitiveMedia, keywords: ['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm'], }, { - id: 'aefexW9fD', + id: '4LxdiOMNh', label: i18n.ts.emojiStyle, keywords: ['emoji', 'style', 'native', 'system', 'fluent', 'twemoji'], }, { - id: 'p7aiLj6A0', + id: '67knC3FWp', label: i18n.ts.pinnedList, keywords: ['pinned', 'list'], }, @@ -328,35 +328,35 @@ export const searchIndexes: SearchIndexItem[] = [ keywords: ['general'], }, { - id: 'khT3n6byY', + id: 'hDdVkBFJP', children: [ { - id: 'DftdlLbNu', + id: 'igFN7RIUa', label: i18n.ts.showFixedPostForm, keywords: ['post', 'form', 'timeline'], }, { - id: 'FbhoeuRAD', + id: '9uxocbLO0', label: i18n.ts.showFixedPostFormInChannel, keywords: ['post', 'form', 'timeline', 'channel'], }, { - id: 'rq69GTeB4', + id: 'eaT1O1Fao', label: i18n.ts.collapseRenotes, keywords: ['renote', i18n.ts.collapseRenotesDescription], }, { - id: 'omxZk3eET', + id: 'jC7LtTnmc', label: i18n.ts.showGapBetweenNotesInTimeline, keywords: ['note', 'timeline', 'gap'], }, { - id: 'epvi2Nv2G', + id: 'p2wlrnwLo', label: i18n.ts.enableInfiniteScroll, keywords: ['load', 'auto', 'more'], }, { - id: 'v26JSj9mH', + id: 'eqMBMY6LU', label: i18n.ts.disableStreamingTimeline, keywords: ['disable', 'streaming', 'timeline'], }, @@ -365,65 +365,65 @@ export const searchIndexes: SearchIndexItem[] = [ keywords: ['timeline'], }, { - id: '7Uf8ksn3q', + id: '2LNjwv1cr', children: [ { - id: 'tLGyaQagB', + id: '6ylW3eIcD', label: i18n.ts.showNoteActionsOnlyHover, keywords: ['hover', 'show', 'footer', 'action'], }, { - id: '7W6g8Dcqz', + id: 'lBbtAg0Hm', label: i18n.ts.showClipButtonInNoteFooter, keywords: ['footer', 'action', 'clip', 'show'], }, { - id: 'uAOoH3LFF', + id: 'E9whefUtX', label: i18n.ts.enableAdvancedMfm, keywords: ['mfm', 'enable', 'show', 'advanced'], }, { - id: 'eCiyZLC8n', + id: 'iQaBbJBva', label: i18n.ts.showReactionsCount, keywords: ['reaction', 'count', 'show'], }, { - id: '68u9uRmFP', + id: 'hgEVGgJa1', label: i18n.ts.confirmOnReact, keywords: ['reaction', 'confirm'], }, { - id: 'rHWm4sXIe', + id: 'yxehrHZ6x', label: i18n.ts.loadRawImages, keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment'], }, { - id: '9L2XGJw7e', + id: 'DdoFLaSG8', label: i18n.ts.useReactionPickerForContextMenu, keywords: ['reaction', 'picker', 'contextmenu', 'open'], }, { - id: 'uIMCIK7kG', + id: 'fyod6U3QX', label: i18n.ts.reactionsDisplaySize, keywords: ['reaction', 'size', 'scale', 'display'], }, { - id: 'uMckjO9bz', + id: 'kmdsnVIQX', label: i18n.ts.limitWidthOfReaction, keywords: ['reaction', 'size', 'scale', 'display', 'width', 'limit'], }, { - id: 'yeghU4qiH', + id: 'hacQ9br20', label: i18n.ts.mediaListWithOneImageAppearance, keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height'], }, { - id: 'yYSOPoAKE', + id: 'vE7KeV4U4', label: i18n.ts.instanceTicker, keywords: ['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation'], }, { - id: 'iOHiIu32L', + id: '3reoOxO26', label: i18n.ts.displayOfSensitiveMedia, keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility'], }, @@ -432,25 +432,25 @@ export const searchIndexes: SearchIndexItem[] = [ keywords: ['note'], }, { - id: 'zrJicawH9', + id: 'eROFRMtXv', children: [ { - id: 'iuEuPe6pa', + id: 'bezWaWd6M', label: i18n.ts.keepCw, keywords: ['remember', 'keep', 'note', 'cw'], }, { - id: '9WrGgANqN', + id: '90ngq28Nx', label: i18n.ts.rememberNoteVisibility, keywords: ['remember', 'keep', 'note', 'visibility'], }, { - id: 'Cu7ErCM7C', + id: 'ERGQVw6ml', label: i18n.ts.enableQuickAddMfmFunction, keywords: ['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn'], }, { - id: 'oQl8xwiyI', + id: 'g0otcvWv3', label: i18n.ts.defaultNoteVisibility, keywords: ['default', 'note', 'visibility'], }, @@ -459,20 +459,20 @@ export const searchIndexes: SearchIndexItem[] = [ keywords: ['post', 'form'], }, { - id: 'xFmAg2tDe', + id: 'AWLIP02IT', children: [ { - id: 'mepqKL5Ow', + id: 'rDLJRu99', label: i18n.ts.useGroupedNotifications, keywords: ['group'], }, { - id: 'wUuUOEO1g', + id: '70WDijfPH', label: i18n.ts.position, keywords: ['position'], }, { - id: '27em8eC8R', + id: 'xKUzsSrKy', label: i18n.ts.stackAxis, keywords: ['stack', 'axis', 'direction'], }, @@ -481,45 +481,45 @@ export const searchIndexes: SearchIndexItem[] = [ keywords: ['notification'], }, { - id: 'AzymHsnrp', + id: '2E7vdIUQd', children: [ { - id: 'DFUrEO2DI', + id: 'C2iXtZKb3', label: i18n.ts.squareAvatars, keywords: ['avatar', 'icon', 'square'], }, { - id: 'r9DX60AxL', + id: 'DCfJg0bva', label: i18n.ts.seasonalScreenEffect, keywords: ['effect', 'show'], }, { - id: 'sJ3fqncSD', + id: 'AV0iGW0vg', label: i18n.ts.openImageInNewTab, keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'], }, { - id: 'p7s0hwZ8A', + id: '5h8vhCX1S', label: i18n.ts.whenServerDisconnected, keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'], }, { - id: 'yCleENWNf', + id: 'zZxyXHk3A', label: i18n.ts.numberOfPageCache, keywords: ['cache', 'page'], }, { - id: 'omEy5Q3Ev', + id: '7ix3kvMyU', label: i18n.ts.forceShowAds, keywords: ['ad', 'show'], }, { - id: 'aWitQSBtD', + id: '6RxgjmMLN', label: i18n.ts.hemisphere, keywords: [], }, { - id: 'hUQAXl1H4', + id: '5iMpm5rES', label: i18n.ts.additionalEmojiDictionary, keywords: ['emoji', 'dictionary', 'additional', 'extra'], }, @@ -528,7 +528,7 @@ export const searchIndexes: SearchIndexItem[] = [ keywords: ['other'], }, { - id: 'aSbKFHbOy', + id: 'fnR7PRww5', label: i18n.ts.dataSaver, keywords: ['datasaver'], }, @@ -550,26 +550,31 @@ export const searchIndexes: SearchIndexItem[] = [ children: [ { id: 'msAcN6u3S', - label: i18n.ts.accountInfo, + label: i18n.ts._role.policies, keywords: ['account', 'info'], }, { - id: 'ts8DgdnZV', + id: 'pbTLsgRO7', + label: i18n.ts.rolesAssignedToMe, + keywords: ['roles'], + }, + { + id: 'fQpvZyfLK', label: i18n.ts.accountMigration, keywords: ['account', 'move', 'migration'], }, { - id: '4BG7nBECm', + id: 'xhfur5m2z', label: i18n.ts.closeAccount, keywords: ['account', 'close', 'delete', i18n.ts._accountDelete.requestAccountDelete], }, { - id: '2qI6ruPgi', + id: 'oAXB8zm2U', label: i18n.ts.experimentalFeatures, keywords: ['experimental', 'feature', 'flags'], }, { - id: 'cIeaax47o', + id: '95OjjGSo7', label: i18n.ts.developer, keywords: ['developer', 'mode', 'debug'], }, From 44073736debda0210b77a53d0f61a68783d28e7c Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 13 Mar 2025 19:44:23 +0900 Subject: [PATCH 31/40] enhance(frontend): improve preferences --- packages/frontend/src/boot/main-boot.ts | 1 + .../src/components/MkFollowButton.vue | 5 +- .../src/pages/settings/account-data.vue | 4 +- .../frontend/src/pages/settings/other.vue | 180 ++++++++---------- .../src/pages/settings/preferences.vue | 83 ++++---- packages/frontend/src/preferences/def.ts | 3 + packages/frontend/src/store.ts | 8 +- packages/frontend/src/style.scss | 2 +- .../utility/autogen/settings-search-index.ts | 43 +++-- 9 files changed, 164 insertions(+), 165 deletions(-) diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 0ad333b203..be72eeb9e1 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -191,6 +191,7 @@ export async function mainBoot() { prefer.commit('skipNoteRender', store.s.skipNoteRender); prefer.commit('showSoftWordMutedWord', store.s.showSoftWordMutedWord); prefer.commit('confirmOnReact', store.s.confirmOnReact); + prefer.commit('defaultFollowWithReplies', store.s.defaultWithReplies); prefer.commit('sound.masterVolume', store.s.sound_masterVolume); prefer.commit('sound.notUseSound', store.s.sound_notUseSound); prefer.commit('sound.useSoundOnlyWhenActive', store.s.sound_useSoundOnlyWhenActive); diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index 3d5d0ec5ab..a063854520 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -45,7 +45,6 @@ import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/utility/achievements.js'; import { pleaseLogin } from '@/utility/please-login.js'; import { $i } from '@/account.js'; -import { store } from '@/store.js'; import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ @@ -121,11 +120,11 @@ async function onClick() { } else { await misskeyApi('following/create', { userId: props.user.id, - withReplies: store.s.defaultWithReplies, + withReplies: prefer.s.defaultFollowWithReplies, }); emit('update:user', { ...props.user, - withReplies: store.s.defaultWithReplies, + withReplies: prefer.s.defaultFollowWithReplies, }); hasPendingFollowRequestFromYou.value = true; diff --git a/packages/frontend/src/pages/settings/account-data.vue b/packages/frontend/src/pages/settings/account-data.vue index 7e7036a8dc..ed5fe48821 100644 --- a/packages/frontend/src/pages/settings/account-data.vue +++ b/packages/frontend/src/pages/settings/account-data.vue @@ -168,12 +168,12 @@ import { selectFile } from '@/utility/select-file.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { $i } from '@/account.js'; -import { store } from '@/store.js'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { prefer } from '@/preferences.js'; const excludeMutingUsers = ref(false); const excludeInactiveUsers = ref(false); -const withReplies = ref(store.s.defaultWithReplies); +const withReplies = ref(prefer.s.defaultFollowWithReplies); const onExportSuccess = () => { os.alert({ diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index b60db78071..62b0f5c941 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -16,112 +16,102 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.sendErrorReports }} --> - -
- - - - +
+ + + + -
- - - - +
+ + + + - - - - + + + + - - - + + + -
-
- {{ policy }} ... {{ $i.policies[policy] }} -
+
+
+ {{ policy }} ... {{ $i.policies[policy] }}
- -
- - +
+
+
+ + - - - - + + + + - - - + + + - - - - + + + + - - - + + + - - - - + + + + -
- {{ i18n.ts._accountDelete.mayTakeTime }} - {{ i18n.ts._accountDelete.sendEmail }} - {{ i18n.ts._accountDelete.requestAccountDelete }} - {{ i18n.ts._accountDelete.inProgress }} -
-
-
+
+ {{ i18n.ts._accountDelete.mayTakeTime }} + {{ i18n.ts._accountDelete.sendEmail }} + {{ i18n.ts._accountDelete.requestAccountDelete }} + {{ i18n.ts._accountDelete.inProgress }} +
+
+
- - - - + + + + -
- - - - - - -
-
-
+
+ + + + + + +
+
+
- - - - + + + + -
- - - -
-
-
-
- +
+ + + +
+
+
+
- - {{ i18n.ts.registry }} - +
- -
- {{ i18n.ts.withRepliesByDefaultForNewlyFollowed }} - {{ i18n.ts.showRepliesToOthersInTimelineAll }} - {{ i18n.ts.hideRepliesToOthersInTimelineAll }} -
-
+ {{ i18n.ts.registry }}
@@ -137,7 +127,6 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { store } from '@/store.js'; import { signout, signinRequired } from '@/account.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; @@ -152,7 +141,6 @@ const reportError = prefer.model('reportError'); const enableCondensedLine = prefer.model('enableCondensedLine'); const skipNoteRender = prefer.model('skipNoteRender'); const devMode = prefer.model('devMode'); -const defaultWithReplies = computed(store.makeGetterSetter('defaultWithReplies')); watch(skipNoteRender, async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); @@ -182,16 +170,6 @@ async function deleteAccount() { await signout(); } -async function updateRepliesAll(withReplies: boolean) { - const { canceled } = await os.confirm({ - type: 'warning', - text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll, - }); - if (canceled) return; - - misskeyApi('following/update-all', { withReplies }); -} - const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 94d154e9c7..87d80602ad 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -393,6 +393,39 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+ {{ i18n.ts.reloadRequiredToApplySettings }} + +
+ {{ i18n.ts.enableAll }} + {{ i18n.ts.disableAll }} +
+
+ + {{ i18n.ts._dataSaver._media.title }} + + + + {{ i18n.ts._dataSaver._avatar.title }} + + + + {{ i18n.ts._dataSaver._urlPreview.title }} + + + + {{ i18n.ts._dataSaver._code.title }} + + +
+
+
+
+ @@ -422,6 +455,14 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + @@ -477,43 +518,14 @@ SPDX-License-Identifier: AGPL-3.0-only + - - - +
-
- {{ i18n.ts.reloadRequiredToApplySettings }} - -
- {{ i18n.ts.enableAll }} - {{ i18n.ts.disableAll }} -
-
- - {{ i18n.ts._dataSaver._media.title }} - - - - {{ i18n.ts._dataSaver._avatar.title }} - - - - {{ i18n.ts._dataSaver._urlPreview.title }} - - - - {{ i18n.ts._dataSaver._code.title }} - - -
-
-
-
- - {{ i18n.ts.navbar }} - {{ i18n.ts.statusbar }} - {{ i18n.ts.deck }} +
+ {{ i18n.ts.navbar }} + {{ i18n.ts.statusbar }} + {{ i18n.ts.deck }} {{ i18n.ts.customCss }}
@@ -592,6 +604,7 @@ const nsfw = prefer.model('nsfw'); const emojiStyle = prefer.model('emojiStyle'); const useBlurEffectForModal = prefer.model('useBlurEffectForModal'); const useBlurEffect = prefer.model('useBlurEffect'); +const defaultFollowWithReplies = prefer.model('defaultFollowWithReplies'); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 6a926c4b26..eb3d6eeac4 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -306,6 +306,9 @@ export const PREF_DEF = { confirmOnReact: { default: false, }, + defaultFollowWithReplies: { + default: false, + }, plugins: { default: [] as Plugin[], }, diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 6eebcd1ead..9a61e63d0e 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -100,10 +100,6 @@ export const store = markRaw(new Storage('base', { where: 'device', default: {} as Record>, }, - defaultWithReplies: { - where: 'account', - default: false, - }, pluginTokens: { where: 'deviceAccount', default: {} as Record, // plugin id, token @@ -119,6 +115,10 @@ export const store = markRaw(new Storage('base', { }, //#region TODO: そのうち消す (preferに移行済み) + defaultWithReplies: { + where: 'account', + default: false, + }, reactions: { where: 'account', default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 48aacf10bc..fb2c805b1b 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -128,7 +128,7 @@ optgroup, option { } hr { - margin: var(--MI-margin) 0 var(--MI-margin) 0; + margin: 0; border: none; height: 1px; background: var(--MI_THEME-divider); diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts index e44910e850..ebc67eb58d 100644 --- a/packages/frontend/src/utility/autogen/settings-search-index.ts +++ b/packages/frontend/src/utility/autogen/settings-search-index.ts @@ -482,44 +482,54 @@ export const searchIndexes: SearchIndexItem[] = [ }, { id: '2E7vdIUQd', + label: i18n.ts.dataSaver, + keywords: ['datasaver'], + }, + { + id: '6ZbRRIhA6', children: [ { - id: 'C2iXtZKb3', + id: 'soNZaKfiW', label: i18n.ts.squareAvatars, keywords: ['avatar', 'icon', 'square'], }, { - id: 'DCfJg0bva', + id: 'nhwHJJ2tl', label: i18n.ts.seasonalScreenEffect, keywords: ['effect', 'show'], }, { - id: 'AV0iGW0vg', + id: 'oMAVUuxTm', label: i18n.ts.openImageInNewTab, keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'], }, { - id: '5h8vhCX1S', + id: 'hSqX5JKM7', + label: i18n.ts.withRepliesByDefaultForNewlyFollowed, + keywords: ['follow', 'replies'], + }, + { + id: 'fm98eqzke', label: i18n.ts.whenServerDisconnected, keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'], }, { - id: 'zZxyXHk3A', + id: '1rWDVig8Y', label: i18n.ts.numberOfPageCache, keywords: ['cache', 'page'], }, { - id: '7ix3kvMyU', + id: 'vXLtihtCp', label: i18n.ts.forceShowAds, keywords: ['ad', 'show'], }, { - id: '6RxgjmMLN', + id: '77YljFpiH', label: i18n.ts.hemisphere, keywords: [], }, { - id: '5iMpm5rES', + id: 'CZgDNPP1h', label: i18n.ts.additionalEmojiDictionary, keywords: ['emoji', 'dictionary', 'additional', 'extra'], }, @@ -527,11 +537,6 @@ export const searchIndexes: SearchIndexItem[] = [ label: i18n.ts.other, keywords: ['other'], }, - { - id: 'fnR7PRww5', - label: i18n.ts.dataSaver, - keywords: ['datasaver'], - }, ], label: i18n.ts.preferences, keywords: ['general', 'preferences', i18n.ts._settings.preferencesBanner], @@ -549,32 +554,32 @@ export const searchIndexes: SearchIndexItem[] = [ id: 'F1uK9ssiY', children: [ { - id: 'msAcN6u3S', + id: 'E0ndmaP6Q', label: i18n.ts._role.policies, keywords: ['account', 'info'], }, { - id: 'pbTLsgRO7', + id: 'r5SjfwZJc', label: i18n.ts.rolesAssignedToMe, keywords: ['roles'], }, { - id: 'fQpvZyfLK', + id: 'cm7LrjgaW', label: i18n.ts.accountMigration, keywords: ['account', 'move', 'migration'], }, { - id: 'xhfur5m2z', + id: 'ozfqNviP3', label: i18n.ts.closeAccount, keywords: ['account', 'close', 'delete', i18n.ts._accountDelete.requestAccountDelete], }, { - id: 'oAXB8zm2U', + id: 'tpywgkpxy', label: i18n.ts.experimentalFeatures, keywords: ['experimental', 'feature', 'flags'], }, { - id: '95OjjGSo7', + id: '54wETGawJ', label: i18n.ts.developer, keywords: ['developer', 'mode', 'debug'], }, From 8c9ec5827fa2040c8d705d2a01329da593d19fa3 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 13 Mar 2025 22:12:23 +0900 Subject: [PATCH 32/40] enhance(frontend): improve accounts management --- packages/frontend/src/account.ts | 390 ------------------ packages/frontend/src/accounts.ts | 341 +++++++++++++++ packages/frontend/src/aiscript/api.ts | 2 +- packages/frontend/src/boot/common.ts | 10 +- packages/frontend/src/boot/main-boot.ts | 24 +- .../src/components/MkAnnouncementDialog.vue | 5 +- .../frontend/src/components/MkAuthConfirm.vue | 9 +- .../frontend/src/components/MkClipPreview.vue | 2 +- .../src/components/MkCropperDialog.vue | 2 +- .../frontend/src/components/MkDrive.file.vue | 2 +- .../frontend/src/components/MkEmojiPicker.vue | 2 +- .../src/components/MkFollowButton.vue | 2 +- .../src/components/MkInstanceStats.vue | 2 +- .../frontend/src/components/MkMediaAudio.vue | 2 +- .../frontend/src/components/MkMediaImage.vue | 2 +- .../frontend/src/components/MkMediaVideo.vue | 2 +- .../frontend/src/components/MkMention.vue | 2 +- packages/frontend/src/components/MkNote.vue | 2 +- .../src/components/MkNoteDetailed.vue | 2 +- .../frontend/src/components/MkNoteSub.vue | 2 +- .../src/components/MkNotification.vue | 2 +- .../src/components/MkPasswordDialog.vue | 2 +- .../frontend/src/components/MkPostForm.vue | 5 +- .../frontend/src/components/MkPreview.vue | 2 +- .../MkPushNotificationAllowButton.vue | 3 +- .../components/MkReactionsViewer.reaction.vue | 2 +- packages/frontend/src/components/MkSignin.vue | 5 +- .../src/components/MkSignupDialog.form.vue | 4 +- .../frontend/src/components/MkTimeline.vue | 2 +- .../src/components/MkTokenGenerateWindow.vue | 2 +- .../src/components/MkTutorialDialog.Note.vue | 2 +- .../components/MkTutorialDialog.Sensitive.vue | 2 +- .../frontend/src/components/MkUserInfo.vue | 2 +- .../frontend/src/components/MkUserPopup.vue | 2 +- .../src/components/MkUserSelectDialog.vue | 2 +- .../components/MkUserSetupDialog.Profile.vue | 2 +- .../frontend/src/components/global/MkAd.vue | 2 +- .../src/components/global/MkCustomEmoji.vue | 2 +- .../src/components/global/MkPageHeader.vue | 3 +- packages/frontend/src/i.ts | 34 ++ packages/frontend/src/local-storage.ts | 1 - packages/frontend/src/navbar.ts | 2 +- packages/frontend/src/pages/about-misskey.vue | 2 +- packages/frontend/src/pages/about.emojis.vue | 2 +- packages/frontend/src/pages/achievements.vue | 2 +- packages/frontend/src/pages/admin-file.vue | 2 +- packages/frontend/src/pages/admin-user.vue | 2 +- packages/frontend/src/pages/announcement.vue | 5 +- packages/frontend/src/pages/announcements.vue | 5 +- packages/frontend/src/pages/auth.vue | 3 +- .../pages/avatar-decoration-edit-dialog.vue | 2 +- .../frontend/src/pages/avatar-decorations.vue | 2 +- packages/frontend/src/pages/channel.vue | 2 +- packages/frontend/src/pages/clip.vue | 2 +- .../src/pages/drop-and-fusion.game.vue | 2 +- packages/frontend/src/pages/emojis.emoji.vue | 2 +- packages/frontend/src/pages/flash/flash.vue | 2 +- .../frontend/src/pages/follow-requests.vue | 2 +- packages/frontend/src/pages/gallery/post.vue | 2 +- packages/frontend/src/pages/instance-info.vue | 2 +- packages/frontend/src/pages/invite.vue | 2 +- .../frontend/src/pages/my-lists/index.vue | 2 +- packages/frontend/src/pages/my-lists/list.vue | 2 +- packages/frontend/src/pages/note.vue | 2 +- .../src/pages/page-editor/page-editor.vue | 2 +- packages/frontend/src/pages/page.vue | 2 +- .../frontend/src/pages/reversi/game.board.vue | 2 +- .../src/pages/reversi/game.setting.vue | 2 +- packages/frontend/src/pages/reversi/game.vue | 2 +- packages/frontend/src/pages/reversi/index.vue | 2 +- packages/frontend/src/pages/scratchpad.vue | 2 +- packages/frontend/src/pages/search.note.vue | 2 +- .../src/pages/settings/2fa.qrdialog.vue | 2 +- packages/frontend/src/pages/settings/2fa.vue | 5 +- .../src/pages/settings/account-data.vue | 2 +- .../frontend/src/pages/settings/accounts.vue | 103 ++--- .../settings/avatar-decoration.decoration.vue | 2 +- .../settings/avatar-decoration.dialog.vue | 2 +- .../src/pages/settings/avatar-decoration.vue | 2 +- .../frontend/src/pages/settings/drive.vue | 2 +- .../frontend/src/pages/settings/email.vue | 2 +- .../frontend/src/pages/settings/index.vue | 3 +- .../frontend/src/pages/settings/migration.vue | 2 +- .../settings/mute-block.instance-mute.vue | 2 +- .../src/pages/settings/mute-block.vue | 2 +- .../src/pages/settings/notifications.vue | 2 +- .../frontend/src/pages/settings/other.vue | 3 +- .../frontend/src/pages/settings/privacy.vue | 2 +- .../frontend/src/pages/settings/profile.vue | 2 +- .../frontend/src/pages/signup-complete.vue | 2 +- packages/frontend/src/pages/tag.vue | 2 +- packages/frontend/src/pages/theme-editor.vue | 2 +- packages/frontend/src/pages/timeline.vue | 2 +- .../frontend/src/pages/user/achievements.vue | 2 +- packages/frontend/src/pages/user/home.vue | 2 +- packages/frontend/src/pages/user/index.vue | 2 +- packages/frontend/src/pages/welcome.setup.vue | 2 +- packages/frontend/src/pizzax.ts | 2 +- packages/frontend/src/preferences.ts | 2 +- packages/frontend/src/preferences/def.ts | 4 + packages/frontend/src/preferences/manager.ts | 2 +- packages/frontend/src/preferences/utility.ts | 2 +- packages/frontend/src/router/definition.ts | 2 +- packages/frontend/src/signout.ts | 54 +++ packages/frontend/src/store.ts | 4 + packages/frontend/src/stream.ts | 2 +- packages/frontend/src/theme-store.ts | 2 +- packages/frontend/src/timelines.ts | 2 +- .../src/ui/_common_/PreferenceRestore.vue | 2 +- .../src/ui/_common_/announcements.vue | 2 +- packages/frontend/src/ui/_common_/common.ts | 2 +- packages/frontend/src/ui/_common_/common.vue | 2 +- .../src/ui/_common_/navbar-for-mobile.vue | 3 +- packages/frontend/src/ui/_common_/navbar.vue | 3 +- .../frontend/src/ui/_common_/sw-inject.ts | 3 +- packages/frontend/src/ui/classic.header.vue | 4 +- packages/frontend/src/ui/classic.sidebar.vue | 3 +- packages/frontend/src/ui/deck.vue | 2 +- packages/frontend/src/ui/universal.vue | 2 +- packages/frontend/src/use/use-note-capture.ts | 2 +- packages/frontend/src/utility/achievements.ts | 2 +- .../utility/autogen/settings-search-index.ts | 7 + .../frontend/src/utility/check-permissions.ts | 2 +- .../frontend/src/utility/get-note-menu.ts | 2 +- .../frontend/src/utility/get-user-menu.ts | 2 +- .../frontend/src/utility/isFfVisibleForMe.ts | 2 +- packages/frontend/src/utility/misskey-api.ts | 2 +- packages/frontend/src/utility/please-login.ts | 2 +- .../frontend/src/utility/show-moved-dialog.ts | 2 +- packages/frontend/src/utility/upload.ts | 2 +- .../frontend/src/widgets/WidgetActivity.vue | 2 +- .../frontend/src/widgets/WidgetAiscript.vue | 2 +- .../src/widgets/WidgetAiscriptApp.vue | 2 +- .../src/widgets/WidgetBirthdayFollowings.vue | 2 +- .../frontend/src/widgets/WidgetButton.vue | 2 +- .../frontend/src/widgets/WidgetProfile.vue | 2 +- packages/frontend/test/aiscript/api.test.ts | 2 +- 137 files changed, 640 insertions(+), 622 deletions(-) delete mode 100644 packages/frontend/src/account.ts create mode 100644 packages/frontend/src/accounts.ts create mode 100644 packages/frontend/src/i.ts create mode 100644 packages/frontend/src/signout.ts diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts deleted file mode 100644 index c90d4da5ec..0000000000 --- a/packages/frontend/src/account.ts +++ /dev/null @@ -1,390 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineAsyncComponent, reactive, ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import { apiUrl } from '@@/js/config.js'; -import type { MenuItem, MenuButton } from '@/types/menu.js'; -import { defaultMemoryStorage } from '@/memory-storage'; -import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js'; -import { i18n } from '@/i18n.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { del, get, set } from '@/utility/idb-proxy.js'; -import { waiting, popup, popupMenu, success, alert } from '@/os.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; -import { unisonReload, reloadChannel } from '@/utility/unison-reload.js'; - -// TODO: 他のタブと永続化されたstateを同期 -// TODO: accountsはpreferences管理にする(tokenは別管理) - -type Account = Misskey.entities.MeDetailed & { token: string }; - -const accountData = miLocalStorage.getItem('account'); - -// TODO: 外部からはreadonlyに -export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; - -export const iAmModerator = $i != null && ($i.isAdmin === true || $i.isModerator === true); -export const iAmAdmin = $i != null && $i.isAdmin; - -export function signinRequired() { - if ($i == null) throw new Error('signin required'); - return $i; -} - -export let notesCount = $i == null ? 0 : $i.notesCount; -export function incNotesCount() { - notesCount++; -} - -export async function signout() { - if (!$i) return; - - defaultMemoryStorage.clear(); - - waiting(); - document.cookie.split(';').forEach((cookie) => { - const cookieName = cookie.split('=')[0].trim(); - if (cookieName === 'token') { - document.cookie = `${cookieName}=; max-age=0; path=/`; - } - }); - miLocalStorage.removeItem('account'); - await removeAccount($i.id); - const accounts = await getAccounts(); - - //#region Remove service worker registration - try { - if (navigator.serviceWorker.controller) { - const registration = await navigator.serviceWorker.ready; - const push = await registration.pushManager.getSubscription(); - if (push) { - await window.fetch(`${apiUrl}/sw/unregister`, { - method: 'POST', - body: JSON.stringify({ - i: $i.token, - endpoint: push.endpoint, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - } - } - - if (accounts.length === 0) { - await navigator.serviceWorker.getRegistrations() - .then(registrations => { - return Promise.all(registrations.map(registration => registration.unregister())); - }); - } - } catch (err) {} - //#endregion - - if (accounts.length > 0) login(accounts[0].token); - else unisonReload('/'); -} - -export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> { - return (await get('accounts')) || []; -} - -export async function addAccount(id: Account['id'], token: Account['token']) { - const accounts = await getAccounts(); - if (!accounts.some(x => x.id === id)) { - await set('accounts', accounts.concat([{ id, token }])); - } -} - -export async function removeAccount(idOrToken: Account['id']) { - const accounts = await getAccounts(); - const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken); - if (i !== -1) accounts.splice(i, 1); - - if (accounts.length > 0) { - await set('accounts', accounts); - } else { - await del('accounts'); - } -} - -function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { - document.cookie = 'token=; path=/; max-age=0'; - document.cookie = `token=${token}; path=/queue; max-age=86400; SameSite=Strict; Secure`; // bull dashboardの認証とかで使う - - return new Promise((done, fail) => { - window.fetch(`${apiUrl}/i`, { - method: 'POST', - body: JSON.stringify({ - i: token, - }), - headers: { - 'Content-Type': 'application/json', - }, - }) - .then(res => new Promise }>((done2, fail2) => { - if (res.status >= 500 && res.status < 600) { - // サーバーエラー(5xx)の場合をrejectとする - // (認証エラーなど4xxはresolve) - return fail2(res); - } - res.json().then(done2, fail2); - })) - .then(async res => { - if ('error' in res) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { - // SUSPENDED - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await showSuspendedDialog(); - } - } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { - // USER_IS_DELETED - // アカウントが削除されている - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await alert({ - type: 'error', - title: i18n.ts.accountDeleted, - text: i18n.ts.accountDeletedDescription, - }); - } - } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { - // AUTHENTICATION_FAILED - // トークンが無効化されていたりアカウントが削除されたりしている - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await alert({ - type: 'error', - title: i18n.ts.tokenRevoked, - text: i18n.ts.tokenRevokedDescription, - }); - } - } else { - await alert({ - type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), - }); - } - - // rejectかつ理由がtrueの場合、削除対象であることを示す - fail(true); - } else { - (res as Account).token = token; - done(res as Account); - } - }) - .catch(fail); - }); -} - -export function updateAccount(accountData: Account) { - if (!$i) return; - for (const key of Object.keys($i)) { - delete $i[key]; - } - for (const [key, value] of Object.entries(accountData)) { - $i[key] = value; - } - miLocalStorage.setItem('account', JSON.stringify($i)); -} - -export function updateAccountPartial(accountData: Partial) { - if (!$i) return; - for (const [key, value] of Object.entries(accountData)) { - $i[key] = value; - } - miLocalStorage.setItem('account', JSON.stringify($i)); -} - -export async function refreshAccount() { - if (!$i) return; - return fetchAccount($i.token, $i.id) - .then(updateAccount, reason => { - if (reason === true) return signout(); - return; - }); -} - -export async function login(token: Account['token'], redirect?: string) { - const showing = ref(true); - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { - success: false, - showing: showing, - }, { - closed: () => dispose(), - }); - if (_DEV_) console.log('logging as token ', token); - const me = await fetchAccount(token, undefined, true) - .catch(reason => { - if (reason === true) { - // 削除対象の場合 - removeAccount(token); - } - - showing.value = false; - throw reason; - }); - miLocalStorage.setItem('account', JSON.stringify(me)); - await addAccount(me.id, token); - - if (redirect) { - // 他のタブは再読み込みするだけ - reloadChannel.postMessage(null); - // このページはredirectで指定された先に移動 - location.href = redirect; - return; - } - - unisonReload(); -} - -export async function openAccountMenu(opts: { - includeCurrentAccount?: boolean; - withExtraOperation: boolean; - active?: Misskey.entities.UserDetailed['id']; - onChoose?: (account: Misskey.entities.UserDetailed) => void; -}, ev: MouseEvent) { - if (!$i) return; - - async function switchAccount(account: Misskey.entities.UserDetailed) { - const storedAccounts = await getAccounts(); - const found = storedAccounts.find(x => x.id === account.id); - if (found == null) return; - switchAccountWithToken(found.token); - } - - function switchAccountWithToken(token: string) { - login(token); - } - - const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); - const accountsPromise = misskeyApi('users/show', { userIds: storedAccounts.map(x => x.id) }); - - function createItem(account: Misskey.entities.UserDetailed) { - return { - type: 'user' as const, - user: account, - active: opts.active != null ? opts.active === account.id : false, - action: () => { - if (opts.onChoose) { - opts.onChoose(account); - } else { - switchAccount(account); - } - }, - }; - } - - const accountItemPromises = storedAccounts.map(a => new Promise | MenuButton>(res => { - accountsPromise.then(accounts => { - const account = accounts.find(x => x.id === a.id); - if (account == null) return res({ - type: 'button' as const, - text: a.id, - action: () => { - switchAccountWithToken(a.token); - }, - }); - - res(createItem(account)); - }); - })); - - const menuItems: MenuItem[] = []; - - if (opts.withExtraOperation) { - menuItems.push({ - type: 'link', - text: i18n.ts.profile, - to: `/@${$i.username}`, - avatar: $i, - }, { - type: 'divider', - }); - - if (opts.includeCurrentAccount) { - menuItems.push(createItem($i)); - } - - menuItems.push(...accountItemPromises); - - menuItems.push({ - type: 'parent', - icon: 'ti ti-plus', - text: i18n.ts.addAccount, - children: [{ - text: i18n.ts.existingAccount, - action: () => { - getAccountWithSigninDialog().then(res => { - if (res != null) { - success(); - } - }); - }, - }, { - text: i18n.ts.createAccount, - action: () => { - getAccountWithSignupDialog().then(res => { - if (res != null) { - switchAccountWithToken(res.token); - } - }); - }, - }], - }, { - type: 'link', - icon: 'ti ti-users', - text: i18n.ts.manageAccounts, - to: '/settings/accounts', - }); - } else { - if (opts.includeCurrentAccount) { - menuItems.push(createItem($i)); - } - - menuItems.push(...accountItemPromises); - } - - popupMenu(menuItems, ev.currentTarget ?? ev.target, { - align: 'left', - }); -} - -export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { - return new Promise((resolve) => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { - done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { - await addAccount(res.id, res.i); - resolve({ id: res.id, token: res.i }); - }, - cancelled: () => { - resolve(null); - }, - closed: () => { - dispose(); - }, - }); - }); -} - -export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> { - return new Promise((resolve) => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { - done: async (res: Misskey.entities.SignupResponse) => { - await addAccount(res.id, res.token); - resolve({ id: res.id, token: res.token }); - }, - cancelled: () => { - resolve(null); - }, - closed: () => { - dispose(); - }, - }); - }); -} - -if (_DEV_) { - (window as any).$i = $i; -} diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts new file mode 100644 index 0000000000..2382a8ec32 --- /dev/null +++ b/packages/frontend/src/accounts.ts @@ -0,0 +1,341 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { apiUrl, host } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; +import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js'; +import { i18n } from '@/i18n.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { waiting, popup, popupMenu, success, alert } from '@/os.js'; +import { unisonReload, reloadChannel } from '@/utility/unison-reload.js'; +import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; +import { $i } from '@/i.js'; +import { signout } from '@/signout.js'; + +// TODO: 他のタブと永続化されたstateを同期 + +type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; + +export async function getAccounts(): Promise<{ + host: string; + user: Misskey.entities.User; + token: string | null; +}[]> { + const tokens = store.s.accountTokens; + const accounts = prefer.s.accounts; + return accounts.map(([host, user]) => ({ + host, + user, + token: tokens[host + '/' + user.id] ?? null, + })); +} + +async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) { + if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) { + store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token }); + prefer.commit('accounts', [...prefer.s.accounts, [host, user]]); + } +} + +export async function removeAccount(host: string, id: AccountWithToken['id']) { + const tokens = JSON.parse(JSON.stringify(store.s.accountTokens)); + delete tokens[host + '/' + id]; + store.set('accountTokens', tokens); + prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id)); +} + +const isAccountDeleted = Symbol('isAccountDeleted'); + +function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { + return new Promise((done, fail) => { + window.fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token, + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(res => new Promise }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if ('error' in res) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + // SUSPENDED + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + // USER_IS_DELETED + // アカウントが削除されている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, + }); + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + // AUTHENTICATION_FAILED + // トークンが無効化されていたりアカウントが削除されたりしている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, + }); + } + } else { + await alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); + } + + fail(isAccountDeleted); + } else { + done(res); + } + }) + .catch(fail); + }); +} + +export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) { + if (!$i) return; + const token = $i.token; + for (const key of Object.keys($i)) { + delete $i[key]; + } + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { + // TODO: $iのホストも比較したいけど通常null + if (user.id === $i.id) { + return [host, $i]; + } else { + return [host, user]; + } + })); + $i.token = token; + miLocalStorage.setItem('account', JSON.stringify($i)); +} + +export function updateCurrentAccountPartial(accountData: Partial) { + if (!$i) return; + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { + // TODO: $iのホストも比較したいけど通常null + if (user.id === $i.id) { + const newUser = JSON.parse(JSON.stringify($i)); + for (const [key, value] of Object.entries(accountData)) { + newUser[key] = value; + } + return [host, newUser]; + } + return [host, user]; + })); + miLocalStorage.setItem('account', JSON.stringify($i)); +} + +export async function refreshCurrentAccount() { + if (!$i) return; + return fetchAccount($i.token, $i.id).then(updateCurrentAccount).catch(reason => { + if (reason === isAccountDeleted) { + removeAccount(host, $i.id); + if (Object.keys(store.s.accountTokens).length > 0) { + login(Object.values(store.s.accountTokens)[0]); + } else { + signout(); + } + } + }); +} + +export async function login(token: AccountWithToken['token'], redirect?: string) { + const showing = ref(true); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, { + closed: () => dispose(), + }); + + const me = await fetchAccount(token, undefined, true).catch(reason => { + showing.value = false; + throw reason; + }); + + miLocalStorage.setItem('account', JSON.stringify({ + ...me, + token, + })); + + await addAccount(host, me, token); + + if (redirect) { + // 他のタブは再読み込みするだけ + reloadChannel.postMessage(null); + // このページはredirectで指定された先に移動 + location.href = redirect; + return; + } + + unisonReload(); +} + +export async function switchAccount(host: string, id: string) { + const token = store.s.accountTokens[host + '/' + id]; + if (token) { + login(token); + } else { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { + store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + res.id]: res.i }); + login(res.i); + }, + closed: () => { + dispose(); + }, + }); + } +} + +export async function openAccountMenu(opts: { + includeCurrentAccount?: boolean; + withExtraOperation: boolean; + active?: Misskey.entities.User['id']; + onChoose?: (account: Misskey.entities.User) => void; +}, ev: MouseEvent) { + if (!$i) return; + + function createItem(host: string, account: Misskey.entities.User): MenuItem { + return { + type: 'user' as const, + user: account, + active: opts.active != null ? opts.active === account.id : false, + action: async () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(host, account.id); + } + }, + }; + } + + const menuItems: MenuItem[] = []; + + // TODO: $iのホストも比較したいけど通常null + const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.user.id !== $i.id))).map(a => createItem(a.host, a.user)); + + if (opts.withExtraOperation) { + menuItems.push({ + type: 'link', + text: i18n.ts.profile, + to: `/@${$i.username}`, + avatar: $i, + }, { + type: 'divider', + }); + + if (opts.includeCurrentAccount) { + menuItems.push(createItem(host, $i)); + } + + menuItems.push(...accountItems); + + menuItems.push({ + type: 'parent', + icon: 'ti ti-plus', + text: i18n.ts.addAccount, + children: [{ + text: i18n.ts.existingAccount, + action: () => { + getAccountWithSigninDialog().then(res => { + if (res != null) { + success(); + } + }); + }, + }, { + text: i18n.ts.createAccount, + action: () => { + getAccountWithSignupDialog().then(res => { + if (res != null) { + switchAccount(host, res.id); + } + }); + }, + }], + }, { + type: 'link', + icon: 'ti ti-users', + text: i18n.ts.manageAccounts, + to: '/settings/accounts', + }); + } else { + if (opts.includeCurrentAccount) { + menuItems.push(createItem(host, $i)); + } + + menuItems.push(...accountItems); + } + + popupMenu(menuItems, ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { + const user = await fetchAccount(res.i, res.id, true); + await addAccount(host, user, res.i); + resolve({ id: res.id, token: res.i }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} + +export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: async (res: Misskey.entities.SignupResponse) => { + const user = JSON.parse(JSON.stringify(res)); + delete user.token; + await addAccount(host, user, res.token); + resolve({ id: res.id, token: res.token }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} diff --git a/packages/frontend/src/aiscript/api.ts b/packages/frontend/src/aiscript/api.ts index 3acc1127c9..e7e396023d 100644 --- a/packages/frontend/src/aiscript/api.ts +++ b/packages/frontend/src/aiscript/api.ts @@ -9,7 +9,7 @@ import { url, lang } from '@@/js/config.js'; import { assertStringAndIsIn } from './common.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 122aa50ac0..73c4256c4b 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -15,7 +15,7 @@ import components from '@/components/index.js'; import { applyTheme } from '@/theme.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { updateI18n, i18n } from '@/i18n.js'; -import { $i, refreshAccount, login } from '@/account.js'; +import { refreshCurrentAccount, login } from '@/accounts.js'; import { store } from '@/store.js'; import { fetchInstance, instance } from '@/instance.js'; import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js'; @@ -29,6 +29,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js'; import { setupRouter } from '@/router/main.js'; import { createMainRouter } from '@/router/definition.js'; import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; export async function common(createVue: () => App) { console.info(`Misskey v${version}`); @@ -38,11 +39,6 @@ export async function common(createVue: () => App) { console.info(`vue ${vueVersion}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).$i = $i; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).$store = store; - window.addEventListener('error', event => { console.error(event); /* @@ -244,7 +240,7 @@ export async function common(createVue: () => App) { console.log('account cache found. refreshing...'); } - refreshAccount(); + refreshCurrentAccount(); } //#endregion diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index be72eeb9e1..64e3a236e8 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -16,7 +16,7 @@ import { i18n } from '@/i18n.js'; import { alert, confirm, popup, post, toast } from '@/os.js'; import { useStream } from '@/stream.js'; import * as sound from '@/utility/sound.js'; -import { $i, signout, updateAccountPartial } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; import { ColdDeviceStorage, store } from '@/store.js'; import { reactionPicker } from '@/utility/reaction-picker.js'; @@ -32,6 +32,8 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { launchPlugins } from '@/plugin.js'; import { unisonReload } from '@/utility/unison-reload.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; +import { signout } from '@/signout.js'; export async function mainBoot() { const { isClientUpdated, lastVersion } = await common(() => { @@ -480,11 +482,11 @@ export async function mainBoot() { // 自分の情報が更新されたとき main.on('meUpdated', i => { - updateAccountPartial(i); + updateCurrentAccountPartial(i); }); main.on('readAllNotifications', () => { - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: false, unreadNotificationsCount: 0, }); @@ -492,39 +494,39 @@ export async function mainBoot() { main.on('unreadNotification', () => { const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: true, unreadNotificationsCount, }); }); main.on('unreadMention', () => { - updateAccountPartial({ hasUnreadMentions: true }); + updateCurrentAccountPartial({ hasUnreadMentions: true }); }); main.on('readAllUnreadMentions', () => { - updateAccountPartial({ hasUnreadMentions: false }); + updateCurrentAccountPartial({ hasUnreadMentions: false }); }); main.on('unreadSpecifiedNote', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: true }); + updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: true }); }); main.on('readAllUnreadSpecifiedNotes', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: false }); + updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: false }); }); main.on('readAllAntennas', () => { - updateAccountPartial({ hasUnreadAntenna: false }); + updateCurrentAccountPartial({ hasUnreadAntenna: false }); }); main.on('unreadAntenna', () => { - updateAccountPartial({ hasUnreadAntenna: true }); + updateCurrentAccountPartial({ hasUnreadAntenna: true }); sound.playMisskeySfx('antenna'); }); main.on('readAllAnnouncements', () => { - updateAccountPartial({ hasUnreadAnnouncement: false }); + updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); }); // 個人宛てお知らせが発行されたとき diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 41fd2564d8..582bb137bc 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -29,7 +29,8 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { $i, updateAccountPartial } from '@/account.js'; +import { $i } from '@/i.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; const props = withDefaults(defineProps<{ announcement: Misskey.entities.Announcement; @@ -51,7 +52,7 @@ async function ok() { modal.value?.close(); misskeyApi('i/read-announcement', { announcementId: props.announcement.id }); - updateAccountPartial({ + updateCurrentAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), }); } diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index 090c31044e..00bf8e68d9 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -117,10 +117,9 @@ SPDX-License-Identifier: AGPL-3.0-only