enhance(frontend): preferenceのタブ間同期にBroadcast Channelを使用するように (#16819)

* enhance(frontend): preferenceのタブ間同期にBroadcast Channelを使用するように

* fix

* refactor: EventEmitterをextendする形に変更
This commit is contained in:
かっこかり 2025-11-24 16:52:46 +09:00 committed by GitHub
parent 25afb5d279
commit 2ee04860fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 59 additions and 28 deletions

View File

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { BroadcastChannel } from 'broadcast-channel';
import type { StorageProvider } from '@/preferences/manager.js';
import { cloudBackup } from '@/preferences/utility.js';
import { miLocalStorage } from '@/local-storage.js';
@ -12,6 +13,7 @@ import { $i } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { TAB_ID } from '@/tab-id.js';
// クラウド同期用グループ名
const syncGroup = 'default';
const io: StorageProvider = {
@ -26,7 +28,6 @@ const io: StorageProvider = {
save: (ctx) => {
miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile));
miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`);
},
cloudGet: async (ctx) => {
@ -99,33 +100,47 @@ const io: StorageProvider = {
export const prefer = new PreferencesManager(io, $i);
let latestSyncedAt = Date.now();
//#region タブ間同期
let latestPreferencesUpdate: {
tabId: string;
timestamp: number;
} | null = null;
function syncBetweenTabs() {
const latest = miLocalStorage.getItem('latestPreferencesUpdate');
if (latest == null) return;
const preferencesChannel = new BroadcastChannel<{
type: 'preferencesUpdate';
tabId: string;
timestamp: number;
}>('preferences');
const latestTab = latest.split('/')[0];
const latestAt = parseInt(latest.split('/')[1]);
if (latestTab === TAB_ID) return;
if (latestAt <= latestSyncedAt) return;
prefer.reloadProfile();
latestSyncedAt = Date.now();
if (_DEV_) console.log('prefer:synced');
}
window.setInterval(syncBetweenTabs, 5000);
window.document.addEventListener('visibilitychange', () => {
if (window.document.visibilityState === 'visible') {
syncBetweenTabs();
}
prefer.on('committed', () => {
latestPreferencesUpdate = {
tabId: TAB_ID,
timestamp: Date.now(),
};
preferencesChannel.postMessage({
type: 'preferencesUpdate',
tabId: TAB_ID,
timestamp: latestPreferencesUpdate.timestamp,
});
});
preferencesChannel.addEventListener('message', (msg) => {
if (msg.type === 'preferencesUpdate') {
if (msg.tabId === TAB_ID) return;
if (latestPreferencesUpdate != null) {
if (msg.timestamp <= latestPreferencesUpdate.timestamp) return;
}
prefer.reloadProfile();
if (_DEV_) console.log('prefer:received update from other tab');
latestPreferencesUpdate = {
tabId: msg.tabId,
timestamp: msg.timestamp,
};
}
});
//#endregion
//#region 定期クラウドバックアップ
let latestBackupAt = 0;
window.setInterval(() => {
@ -138,6 +153,7 @@ window.setInterval(() => {
latestBackupAt = Date.now();
});
}, 1000 * 60 * 3);
//#endregion
if (_DEV_) {
(window as any).prefer = prefer;

View File

@ -4,6 +4,7 @@
*/
import { computed, onUnmounted, ref, watch } from 'vue';
import { EventEmitter } from 'eventemitter3';
import { host, version } from '@@/js/config.js';
import { PREF_DEF } from './def.js';
import type { Ref, WritableComputedRef } from 'vue';
@ -100,6 +101,14 @@ type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) =>
export type PreferencesDefinition = Record<string, PreferencesDefinitionRecord<any>>;
type PreferencesManagerEvents = {
'committed': <K extends keyof PREF>(ctx: {
key: K;
value: ValueOf<K>;
oldValue: ValueOf<K>;
}) => void;
};
export function definePreferences<T extends Record<string, unknown>>(x: {
[K in keyof T]: PreferencesDefinitionRecord<T[K]>
}): {
@ -180,7 +189,7 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi
// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない
// と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない
// NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる
export class PreferencesManager {
export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
private io: StorageProvider;
private currentAccount: { id: string } | null;
public profile: PreferencesProfile;
@ -201,6 +210,8 @@ export class PreferencesManager {
};
constructor(io: StorageProvider, currentAccount: { id: string } | null) {
super();
this.io = io;
this.currentAccount = currentAccount;
@ -246,6 +257,12 @@ export class PreferencesManager {
this.rewriteRawState(key, v);
this.emit('committed', {
key,
value: v,
oldValue: this.s[key],
});
const record = this.getMatchedRecordOf(key);
if (parseScope(record[0]).account == null && isAccountDependentKey(key) && currentAccount != null) {

View File

@ -5,7 +5,5 @@
import { genId } from '@/utility/id.js';
// HMR有効時にバグか知らんけど複数回実行されるのでその対策
export const TAB_ID = window.sessionStorage.getItem('TAB_ID') ?? genId();
window.sessionStorage.setItem('TAB_ID', TAB_ID);
export const TAB_ID = genId();
if (_DEV_) console.log('TAB_ID', TAB_ID);