From bf9e2245530f860f0182f79d2987b490735cf97d Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:14:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?enhance(frontend):=20=E8=A4=87=E6=95=B0?= =?UTF-8?q?=E3=82=BF=E3=83=96=E3=81=A7Misskey=E3=82=92=E9=96=8B=E3=81=84?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=82=8B=E5=A0=B4=E5=90=88=E3=80=81=E3=81=9D?= =?UTF-8?q?=E3=81=AE=E3=81=86=E3=81=A1=E3=81=AE=E4=B8=80=E3=81=A4=E3=81=A0?= =?UTF-8?q?=E3=81=91=E3=81=A7=E3=82=B5=E3=82=A6=E3=83=B3=E3=83=89=E3=82=92?= =?UTF-8?q?=E5=86=8D=E7=94=9F=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/frontend/src/lib/TabManager.ts | 217 ++++++++++++++++++++++++ packages/frontend/src/preferences.ts | 2 +- packages/frontend/src/tab-id.ts | 9 - packages/frontend/src/tab.ts | 21 +++ packages/frontend/src/utility/sound.ts | 4 +- 5 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 packages/frontend/src/lib/TabManager.ts delete mode 100644 packages/frontend/src/tab-id.ts create mode 100644 packages/frontend/src/tab.ts diff --git a/packages/frontend/src/lib/TabManager.ts b/packages/frontend/src/lib/TabManager.ts new file mode 100644 index 0000000000..618766ac5d --- /dev/null +++ b/packages/frontend/src/lib/TabManager.ts @@ -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 { + private readonly channel: BroadcastChannel; + private readonly myId: string; + private lastActiveTime: number; + + // 他のタブの状態を保持するMap + private peers: Map = 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(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()) { + 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) clearInterval(this.heartbeatIntervalId); + window.removeEventListener('focus', this.updateActivity); + window.removeEventListener('click', this.updateActivity); + window.removeEventListener('keydown', this.updateActivity); + this.channel.close(); + } +} diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 7b03823407..e468c42f88 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -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'; diff --git a/packages/frontend/src/tab-id.ts b/packages/frontend/src/tab-id.ts deleted file mode 100644 index db8a5b147f..0000000000 --- a/packages/frontend/src/tab-id.ts +++ /dev/null @@ -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); diff --git a/packages/frontend/src/tab.ts b/packages/frontend/src/tab.ts new file mode 100644 index 0000000000..2f9638c439 --- /dev/null +++ b/packages/frontend/src/tab.ts @@ -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(false); + +tabManager.on('changeMainStatus', (isMain) => { + _isMainTab.value = isMain; +}); + +export const isMainTab = readonly(_isMainTab); diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts index 8e79841647..fd0921caa1 100644 --- a/packages/frontend/src/utility/sound.ts +++ b/packages/frontend/src/utility/sound.ts @@ -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 { From f993c87a7c21e7a7c01eba094c1746087ab99c1a Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:45:30 +0900 Subject: [PATCH 2/3] Update Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0dd9b3fea..8739902abe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - ### Client +- Enhance: 複数タブでMisskeyを開いている場合に通知音が重複して再生されないように - Fix: 特定の条件下でMisskeyが起動せず空白のページが表示されることがある問題を軽減 - Fix: 初回読み込み時などに、言語設定で不整合が発生することがある問題を修正 - Fix: 削除されたノートのリノートが正しく動作されない問題を修正 From 9ae8096021513d6a3140f90dc7494b41be1005ca Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:46:11 +0900 Subject: [PATCH 3/3] fix lint and test --- packages/frontend/src/lib/TabManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/frontend/src/lib/TabManager.ts b/packages/frontend/src/lib/TabManager.ts index 618766ac5d..07e59cde04 100644 --- a/packages/frontend/src/lib/TabManager.ts +++ b/packages/frontend/src/lib/TabManager.ts @@ -10,7 +10,7 @@ import { BroadcastChannel } from 'broadcast-channel'; type TabState = { id: string; lastActiveTime: number; // ユーザーが最後に操作した時間 - lastHeartbeat: number; // 最後に生存報告を受け取った時間 + lastHeartbeat: number; // 最後に生存報告を受け取った時間 }; type BroadcastMessage = { @@ -44,7 +44,7 @@ export class TabManager extends EventEmitter { // 定数設定 private readonly BROADCAST_CHANNEL_NAME = 'tabSync'; private readonly HEARTBEAT_INTERVAL = 1000; // 1秒ごとに定期処理 - private readonly PEER_TIMEOUT = 3000; // 3秒連絡がなければ死亡とみなす + private readonly PEER_TIMEOUT = 3000; // 3秒連絡がなければ死亡とみなす constructor(tabId: string) { super(); @@ -183,7 +183,7 @@ export class TabManager extends EventEmitter { if (this.isMain !== amILeader) { this.isMain = amILeader; - if (window.document.hasFocus()) { + if (window.document.hasFocus() && 'requestIdleCallback' in window) { window.requestIdleCallback(() => { this.onChangeStatus(this.isMain); }, { timeout: 100 }); @@ -208,7 +208,7 @@ export class TabManager extends EventEmitter { * リソースの解放 */ public destroy() { - if (this.heartbeatIntervalId != null) clearInterval(this.heartbeatIntervalId); + if (this.heartbeatIntervalId != null) window.clearInterval(this.heartbeatIntervalId); window.removeEventListener('focus', this.updateActivity); window.removeEventListener('click', this.updateActivity); window.removeEventListener('keydown', this.updateActivity);