diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index f14cf87c..44171d64 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -537,6 +537,14 @@ function handleVoiceJoin(event: Record, userId: string): void { const left = connectionManager.leaveCurrentRoom(userId); if (left) { broadcastRoomLeave(left.roomId, left.room, userId); + + // Notify all of the user's tabs so the displaced tab tears down LiveKit + connectionManager.sendToUser(userId, { + type: 'voice_disconnected', + userId, + channelId: left.roomId, + reason: 'displaced', + }); } // Cancel any ringing DM rooms where this user is the caller diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8fb36614..0e3e6d97 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -330,7 +330,7 @@ export type ServerEvent = | { type: 'voice_space_deafened'; userId: string; channelId: string; spaceId: string; deafened: boolean } | { type: 'voice_permission_muted'; userId: string; spaceId: string; muted: boolean } | { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string } - | { type: 'voice_disconnected'; userId: string; channelId: string } + | { type: 'voice_disconnected'; userId: string; channelId: string; reason?: 'displaced' } | { type: 'user_updated'; user: User } | { type: 'member_banned'; spaceId: string; reason: string | null } | { type: 'category_created'; category: ChannelCategory; spaceId: string } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index b2612a9f..075f9555 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -9,6 +9,8 @@ import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls'; import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice'; import { registerSelfId } from '../utils/identity'; +import { getActiveRoom } from './useLiveKit'; +import { useUIStore } from '../stores/uiStore'; // ─── Connection state ───────────────────────────────────────────────────────── @@ -428,7 +430,14 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'voice_disconnected': { const myDisconnectId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); if (event.userId === myDisconnectId) { - useVoiceStore.getState().handleForceDisconnect(); + const { currentVoiceChannelId } = useVoiceStore.getState(); + if (event.channelId === currentVoiceChannelId) { + useVoiceStore.getState().handleForceDisconnect(); + getActiveRoom()?.disconnect(); + if (event.reason === 'displaced') { + useUIStore.getState().addToast('Voice disconnected — joined from another session', 'info'); + } + } } break; }