This commit is contained in:
かっこかり 2026-02-02 00:19:36 +09:00 committed by GitHub
commit 7fd4947da6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 243 additions and 11 deletions

View File

@ -64,6 +64,7 @@ v2025.12.0で行われた「configの`trustProxy`のデフォルト値を`false`
## 2025.12.1
### Client
- Enhance: 複数タブでMisskeyを開いている場合に通知音が重複して再生されないように
- Fix: 特定の条件下でMisskeyが起動せず空白のページが表示されることがある問題を軽減
- Fix: 初回読み込み時などに、言語設定で不整合が発生することがある問題を修正
- Fix: 削除されたノートのリノートが正しく動作されない問題を修正

View File

@ -0,0 +1,217 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EventEmitter } from 'eventemitter3';
import { BroadcastChannel } from 'broadcast-channel';
// メッセージの型定義
type TabState = {
id: string;
lastActiveTime: number; // ユーザーが最後に操作した時間
lastHeartbeat: number; // 最後に生存報告を受け取った時間
};
type BroadcastMessage = {
type: 'HEARTBEAT' | 'UNLOAD';
payload: {
id: string;
lastActiveTime: number;
};
};
type EventTypes = {
changeMainStatus: (isMain: boolean) => void;
becomeMain: () => void;
resignMain: () => void;
};
/**
*
*/
export class TabManager extends EventEmitter<EventTypes> {
private readonly channel: BroadcastChannel<BroadcastMessage>;
private readonly myId: string;
private lastActiveTime: number;
// 他のタブの状態を保持するMap
private peers: Map<string, TabState> = new Map();
protected isMain: boolean = false;
private heartbeatIntervalId: number | null = null;
// 定数設定
private readonly BROADCAST_CHANNEL_NAME = 'tabSync';
private readonly HEARTBEAT_INTERVAL = 1000; // 1秒ごとに定期処理
private readonly PEER_TIMEOUT = 3000; // 3秒連絡がなければ死亡とみなす
constructor(tabId: string) {
super();
this.myId = tabId;
this.channel = new BroadcastChannel<BroadcastMessage>(this.BROADCAST_CHANNEL_NAME);
this.lastActiveTime = Date.now(); // 初期化時は現在時刻
// bind this
this.updateActivity = this.updateActivity.bind(this);
this.init();
}
private init() {
// 1. メッセージ受信設定
this.channel.addEventListener('message', (msg) => {
this.handleMessage(msg);
});
// 2. ユーザーのアクティビティ監視 (フォーカス時に時刻更新)
window.addEventListener('focus', this.updateActivity, { passive: true });
window.addEventListener('click', this.updateActivity, { passive: true });
window.addEventListener('keydown', this.updateActivity, { passive: true });
// 3. ページが閉じられる際の処理
window.addEventListener('beforeunload', () => {
this.broadcast('UNLOAD');
this.destroy();
});
// 4. 定期処理の開始
this.startHeartbeat();
// 初期状態ですぐにアクティブ更新をブロードキャスト
this.updateActivity();
}
/**
*
* lastActiveTimeを更新し
*/
private updateActivity() {
this.lastActiveTime = Date.now();
this.broadcast('HEARTBEAT');
// 操作したタイミングでリーダー再計算(自分がリーダーになるため)
this.determineLeader();
}
/**
*
*/
private broadcast(type: BroadcastMessage['type']) {
const message: BroadcastMessage = {
type,
payload: {
id: this.myId,
lastActiveTime: this.lastActiveTime,
},
};
this.channel.postMessage(message);
}
/**
*
*/
private handleMessage(message: BroadcastMessage) {
const { id, lastActiveTime } = message.payload;
// 自分自身のメッセージは無視
if (id === this.myId) return;
if (message.type === 'UNLOAD') {
this.peers.delete(id);
} else if (message.type === 'HEARTBEAT') {
this.peers.set(id, {
id,
lastActiveTime,
lastHeartbeat: Date.now(),
});
}
// 状態が変わった可能性があるのでリーダー判定を行う
this.determineLeader();
}
/**
*
*
* - (Heartbeat)
* -
* -
*/
private startHeartbeat() {
this.heartbeatIntervalId = window.setInterval(() => {
this.broadcast('HEARTBEAT');
this.pruneDeadPeers();
this.determineLeader();
}, this.HEARTBEAT_INTERVAL);
}
/**
*
*/
private pruneDeadPeers() {
const now = Date.now();
for (const [id, state] of this.peers.entries()) {
if (now - state.lastHeartbeat > this.PEER_TIMEOUT) {
this.peers.delete(id); // 死亡とみなしてリストから削除
}
}
}
/**
*
* lastActiveTime
*/
private determineLeader() {
// 自分を含めた全タブのリストを作成
const allTabs: TabState[] = [
{ id: this.myId, lastActiveTime: this.lastActiveTime, lastHeartbeat: Date.now() },
...Array.from(this.peers.values())
];
// lastActiveTime の降順(新しい順)にソート。
// 時刻が完全に同じ場合はIDで比較して順序を固定する。
allTabs.sort((a, b) => {
if (b.lastActiveTime !== a.lastActiveTime) {
return b.lastActiveTime - a.lastActiveTime;
}
return a.id.localeCompare(b.id);
});
const leader = allTabs[0];
const amILeader = leader.id === this.myId;
if (this.isMain !== amILeader) {
this.isMain = amILeader;
if (window.document.hasFocus() && 'requestIdleCallback' in window) {
window.requestIdleCallback(() => {
this.onChangeStatus(this.isMain);
}, { timeout: 100 });
} else {
this.onChangeStatus(this.isMain);
}
}
}
private onChangeStatus(isMain: boolean) {
this.emit('changeMainStatus', isMain);
if (isMain) {
if (_DEV_) console.log('This tab became the main tab.');
this.emit('becomeMain');
} else {
if (_DEV_) console.log('This tab is no longer the main tab.');
this.emit('resignMain');
}
}
/**
*
*/
public destroy() {
if (this.heartbeatIntervalId != null) window.clearInterval(this.heartbeatIntervalId);
window.removeEventListener('focus', this.updateActivity);
window.removeEventListener('click', this.updateActivity);
window.removeEventListener('keydown', this.updateActivity);
this.channel.close();
}
}

View File

@ -11,7 +11,7 @@ import { isSameScope, PreferencesManager } from '@/preferences/manager.js';
import { store } from '@/store.js';
import { $i } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { TAB_ID } from '@/tab-id.js';
import { TAB_ID } from '@/tab.js';
// クラウド同期用グループ名
const syncGroup = 'default';

View File

@ -1,9 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { genId } from '@/utility/id.js';
export const TAB_ID = genId();
if (_DEV_) console.log('TAB_ID', TAB_ID);

View File

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readonly, ref } from 'vue';
import { TabManager } from '@/lib/TabManager.js';
import { genId } from '@/utility/id.js';
export const TAB_ID = genId();
if (_DEV_) console.log('TAB_ID', TAB_ID);
export const tabManager = new TabManager(TAB_ID);
const _isMainTab = ref<boolean>(false);
tabManager.on('changeMainStatus', (isMain) => {
_isMainTab.value = isMain;
});
export const isMainTab = readonly(_isMainTab);

View File

@ -5,7 +5,7 @@
import type { SoundStore } from '@/preferences/def.js';
import { prefer } from '@/preferences.js';
import { PREF_DEF } from '@/preferences/def.js';
import { isMainTab } from '@/tab.js';
import { getInitialPrefValue } from '@/preferences/manager.js';
let ctx: AudioContext;
@ -156,6 +156,8 @@ export async function playMisskeySfxFile(soundStore: SoundStore): Promise<boolea
if ('userActivation' in navigator && !navigator.userActivation.hasBeenActive) return false;
// サウンドがない場合は再生しない
if (soundStore.type === null || soundStore.type === '_driveFile_' && !soundStore.fileUrl) return false;
// メインタブでない場合は再生しない
if (!isMainTab.value) return false;
canPlay = false;
return await playMisskeySfxFileInternal(soundStore).finally(() => {