From bc51fc6f7e0a3ac6e092b5d02bb5af7e7b6b9976 Mon Sep 17 00:00:00 2001
From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Date: Mon, 23 Feb 2026 20:52:58 +0100
Subject: [PATCH] refactor: unify DM calls with server voice architecture
Merge connectDm() into connect() with isDm flag, eliminating ~145 lines
of duplicated LiveKit room setup. DM calls now inherit all event handlers
(SpeakingDetector cleanup, deafen broadcasts, metadata changes). Replace
monolithic DmCallView with shared VoiceGrid + VoiceControlBar components,
making DM calls group-DM-ready with full feature parity.
---
.../web/src/components/layout/AppLayout.tsx | 6 +-
.../web/src/components/layout/MainContent.tsx | 34 +-
.../web/src/components/voice/DmCallView.tsx | 292 ------------------
.../src/components/voice/VoiceControlBar.tsx | 10 +-
.../src/components/voice/VoiceControls.tsx | 16 +-
packages/web/src/hooks/useLiveKit.ts | 161 +---------
6 files changed, 61 insertions(+), 458 deletions(-)
delete mode 100644 packages/web/src/components/voice/DmCallView.tsx
diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx
index a40c3c41..158cf171 100644
--- a/packages/web/src/components/layout/AppLayout.tsx
+++ b/packages/web/src/components/layout/AppLayout.tsx
@@ -95,7 +95,6 @@ export function AppLayout() {
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const {
connect: connectVoice,
- connectDm: connectDmVoice,
disconnect: disconnectVoice,
isConnected: isVoiceConnected,
isConnecting: isVoiceConnecting,
@@ -133,7 +132,7 @@ export function AppLayout() {
lastAttemptedRef.current = targetChannelId;
if (activeDmCall) {
- await connectDmVoice(activeDmCall.dmChannelId);
+ await connectVoice(activeDmCall.dmChannelId, true);
} else {
await connectVoice(targetChannelId);
}
@@ -162,8 +161,7 @@ export function AppLayout() {
isWsConnected,
isLoading,
user,
- connectVoice,
- connectDmVoice,
+ connectVoice,
disconnectVoice
]);
diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx
index 1ce593e6..f35adcda 100644
--- a/packages/web/src/components/layout/MainContent.tsx
+++ b/packages/web/src/components/layout/MainContent.tsx
@@ -9,7 +9,6 @@ import { TypingIndicator } from '../chat/TypingIndicator';
import { VoiceGrid } from '../voice/VoiceGrid';
import { VoiceControlBar } from '../voice/VoiceControlBar';
import { VoiceChatPanel } from '../voice/VoiceChatPanel';
-import { DmCallView } from '../voice/DmCallView';
import { FriendsPage } from '../chat/FriendsPage';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
@@ -85,8 +84,37 @@ export function MainContent() {
if (isInDmCall) {
return (
-
-
+
+
+
+
+
{dmName}
+ {connectionError ? (
+
Connection Failed
+ ) : isLiveKitConnected ? (
+ <>
+
Connected
+
{participants.length} in call
+ >
+ ) : (
+
Connecting...
+ )}
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/packages/web/src/components/voice/DmCallView.tsx b/packages/web/src/components/voice/DmCallView.tsx
deleted file mode 100644
index 7c29990a..00000000
--- a/packages/web/src/components/voice/DmCallView.tsx
+++ /dev/null
@@ -1,292 +0,0 @@
-import React, { useEffect } from 'react';
-import { useVoiceStore } from '../../stores/voiceStore';
-import { useServerStore } from '../../stores/serverStore';
-import { useAuthStore } from '../../stores/authStore';
-import { getActiveRoom } from '../../hooks/useLiveKit';
-import { wsSend } from '../../hooks/useWebSocket';
-import { SCREEN_QUALITY_MAP, startScreenShare, stopScreenShare } from '../../utils/screenShare';
-
-export function DmCallView() {
- const activeDmCall = useVoiceStore((s) => s.activeDmCall);
- const participants = useVoiceStore((s) => s.participants);
- const isMuted = useVoiceStore((s) => s.isMuted);
- const isDeafened = useVoiceStore((s) => s.isDeafened);
- const isCameraOn = useVoiceStore((s) => s.isCameraOn);
- const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
- const toggleMic = useVoiceStore((s) => s.toggleMic);
- const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
- const toggleCamera = useVoiceStore((s) => s.toggleCamera);
- const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall);
- const leaveVoice = useVoiceStore((s) => s.leaveVoice);
- const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
-
- const dmChannels = useServerStore((s) => s.dmChannels);
- const authUser = useAuthStore((s) => s.user);
-
- const dmChannel = dmChannels.find(dm => dm.id === activeDmCall?.dmChannelId);
- const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id);
- const otherName = otherUser?.displayName ?? otherUser?.username ?? 'User';
-
- // Mute/deafen toggling is handled by syncMic in useLiveKit via store state.
- // DmCallView only needs to toggle store + handle remote audio silencing for deafen.
- const handleMute = () => {
- toggleMic();
- };
-
- const handleDeafen = () => {
- const room = getActiveRoom();
- const willDeafen = !isDeafened;
- // Silence/restore remote participant audio for deafen
- if (room) {
- room.remoteParticipants.forEach((p) => {
- p.audioTrackPublications.forEach((pub) => {
- if (pub.track) {
- (pub.track as any).setVolume?.(willDeafen ? 0 : 1);
- }
- });
- });
- }
- toggleDeafen();
- // Auto-mute on deafen, auto-unmute on undeafen
- if (willDeafen && !isMuted) toggleMic();
- if (!willDeafen && isMuted) toggleMic();
- };
-
- const handleCamera = async () => {
- const room = getActiveRoom();
- if (room) {
- const willEnable = !isCameraOn;
- if (willEnable) {
- const videoQuality = useVoiceStore.getState().videoQuality;
- const preset = SCREEN_QUALITY_MAP[videoQuality];
- if (preset) {
- await room.localParticipant.setCameraEnabled(true,
- { resolution: preset.resolution },
- {
- videoEncoding: preset.encoding,
- simulcast: videoQuality === '1080p' || videoQuality === '720p'
- }
- );
- } else {
- await room.localParticipant.setCameraEnabled(true);
- }
- } else {
- await room.localParticipant.setCameraEnabled(false);
- }
- }
- toggleCamera();
- };
-
- const handleScreenShare = async () => {
- const room = getActiveRoom();
- if (!room) return;
- try {
- if (!isScreenSharing) {
- await startScreenShare(room);
- } else {
- await stopScreenShare(room);
- }
- } catch (err) {
- console.error('[DmCallView] Failed to toggle screen share:', err);
- }
- };
-
- const handleEndCall = () => {
- if (activeDmCall) {
- wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId });
- }
- setActiveDmCall(null);
- leaveVoice();
- };
-
- // Attach video elements
- useEffect(() => {
- participants.forEach((p) => {
- if (p.videoTrack) {
- const el = document.getElementById(`dm-video-${p.userId}`) as HTMLVideoElement | null;
- if (el && (el.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id !== p.videoTrack.id) {
- el.srcObject = new MediaStream([p.videoTrack]);
- }
- }
- });
- }, [participants]);
-
- if (!activeDmCall) return null;
-
- const localParticipant = participants.find(p => p.isLocal);
- const remoteParticipant = participants.find(p => !p.isLocal);
-
- return (
-
- {/* Header */}
-
-
-
-
{otherName}
-
In Call
-
-
-
- {/* Main call area - 1-on-1 layout */}
-
- {/* Remote participant (or waiting) */}
-
- {remoteParticipant?.videoTrack ? (
-
- ) : (
-
-
- {otherName.charAt(0).toUpperCase()}
-
- {remoteParticipant && speakingParticipantIds.has(remoteParticipant.identity) && (
-
- )}
-
- )}
-
- {remoteParticipant ? otherName : 'Connecting...'}
-
- {remoteParticipant?.isMuted && (
-
-
- Muted
-
- )}
-
-
- {/* Local participant */}
-
- {localParticipant?.videoTrack ? (
-
- ) : (
-
-
- {(authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase()}
-
- {localParticipant && speakingParticipantIds.has(localParticipant.identity) && (
-
- )}
-
- )}
-
- {authUser?.displayName ?? authUser?.username ?? 'You'} (You)
-
-
-
-
- {/* Control bar */}
-
- {/* Mute */}
-
-
- {/* Deafen */}
-
-
- {/* Camera */}
-
-
- {/* Screen Share */}
-
-
- {/* Spacer */}
-
-
- {/* End Call */}
-
-
-
- );
-}
diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx
index 97efd3dd..657ef93d 100644
--- a/packages/web/src/components/voice/VoiceControlBar.tsx
+++ b/packages/web/src/components/voice/VoiceControlBar.tsx
@@ -106,8 +106,14 @@ export function VoiceControlBar() {
};
const handleDisconnect = () => {
- wsSend({ type: 'voice_leave' });
- useVoiceStore.getState().leaveVoice();
+ const { activeDmCall } = useVoiceStore.getState();
+ if (activeDmCall) {
+ wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId });
+ useVoiceStore.getState().setActiveDmCall(null);
+ } else {
+ wsSend({ type: 'voice_leave' });
+ useVoiceStore.getState().leaveVoice();
+ }
if (voiceFullscreen) {
useUIStore.getState().setVoiceFullscreen(false);
if (document.fullscreenElement) {
diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx
index 6714ac0c..73d8d2df 100644
--- a/packages/web/src/components/voice/VoiceControls.tsx
+++ b/packages/web/src/components/voice/VoiceControls.tsx
@@ -25,10 +25,12 @@ export function VoiceControls() {
const [showVideoQuality, setShowVideoQuality] = useState(false);
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
- if (!currentVoiceChannelId) return null;
+ const activeDmCall = useVoiceStore((s) => s.activeDmCall);
+
+ if (!currentVoiceChannelId && !activeDmCall) return null;
const channel = channels.find(c => c.id === currentVoiceChannelId);
- const channelName = channel?.name ?? 'Voice Channel';
+ const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
const handleCamera = async () => {
const room = getActiveRoom();
@@ -66,8 +68,14 @@ export function VoiceControls() {
};
const handleDisconnect = () => {
- wsSend({ type: 'voice_leave' });
- useVoiceStore.getState().leaveVoice();
+ const { activeDmCall } = useVoiceStore.getState();
+ if (activeDmCall) {
+ wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId });
+ useVoiceStore.getState().setActiveDmCall(null);
+ } else {
+ wsSend({ type: 'voice_leave' });
+ useVoiceStore.getState().leaveVoice();
+ }
};
const statusColor = connectionError
diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts
index a00e097c..d3aaff2b 100644
--- a/packages/web/src/hooks/useLiveKit.ts
+++ b/packages/web/src/hooks/useLiveKit.ts
@@ -285,8 +285,9 @@ export function useLiveKit() {
};
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled, isScreenSharing]);
- const connect = useCallback(async (channelId: string) => {
- if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return;
+ const connect = useCallback(async (channelId: string, isDm?: boolean) => {
+ const storedId = isDm ? `dm-${channelId}` : channelId;
+ if (connectedChannelRef.current === storedId && roomRef.current?.state === ConnectionState.Connected) return;
const gen = ++_connectGeneration;
// Ensure AudioContext is created and resumed before tracks arrive
@@ -323,7 +324,7 @@ export function useLiveKit() {
}
try {
- const { token, url } = await api.livekit.token(channelId);
+ const { token, url } = isDm ? await api.livekit.dmToken(channelId) : await api.livekit.token(channelId);
if (gen !== _connectGeneration) return;
const newRoom = new Room({ adaptiveStream: false, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
@@ -428,9 +429,9 @@ export function useLiveKit() {
await newRoom.connect(url, token);
if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
- _activeRoom = newRoom;
- connectedChannelRef.current = channelId;
- setConnectedChannelId(channelId);
+ _activeRoom = newRoom;
+ connectedChannelRef.current = storedId;
+ setConnectedChannelId(storedId);
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
@@ -462,152 +463,6 @@ export function useLiveKit() {
finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants, handleDataReceived]);
- const connectDm = useCallback(async (dmChannelId: string) => {
- const gen = ++_connectGeneration;
-
- // Ensure AudioContext is created and resumed before tracks arrive
- await AudioManager.getInstance().resumeContext();
-
- // 1. Reset state immediately
- SpeakingDetector.getInstance().clear();
- setRoom(null);
- useVoiceStore.getState().setParticipants([]);
- useVoiceStore.getState().setSpeakingParticipants(new Set());
- setIsConnected(false);
- setIsConnecting(true);
- setConnectionState(ConnectionState.Connecting);
- setConnectionError(null);
- setConnectedChannelId(null);
-
- useVoiceStore.getState().setConnectionError(null);
- useVoiceStore.getState().setConnectionQuality('unknown');
-
- // 2. Strictly disconnect previous room (Local Ref OR Global Ref)
- const roomToDisconnect = roomRef.current || _activeRoom;
-
- if (roomToDisconnect) {
- try {
- console.log('[LiveKit] Disconnecting previous room (DM):', roomToDisconnect.name);
- await roomToDisconnect.disconnect();
- } catch (err) {
- console.warn('Error disconnecting from previous room:', err);
- }
- roomRef.current = null;
- _activeRoom = null;
- }
-
- try {
- const { token, url } = await api.livekit.dmToken(dmChannelId);
- if (gen !== _connectGeneration) return;
- const newRoom = new Room({ adaptiveStream: false, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: false } });
- roomRef.current = newRoom;
- const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
- newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
- newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
- newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
- if (track.kind === Track.Kind.Audio) {
- (track as RemoteAudioTrack).detach();
- }
- guardedUpdate();
- });
- newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
- if (track.kind === Track.Kind.Audio) {
- (track as RemoteAudioTrack).detach();
- }
- guardedUpdate();
- });
- newRoom.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
- if (publication.source === Track.Source.ScreenShare) {
- const { userId } = parseIdentity(newRoom.localParticipant.identity);
- useVoiceStore.getState().watchStream(userId);
- }
- guardedUpdate();
- });
- newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
- if (publication.source === Track.Source.ScreenShare) {
- const { userId } = parseIdentity(newRoom.localParticipant.identity);
- useVoiceStore.getState().unwatchStream(userId);
- // OS-level "Stop sharing" fires this without going through stopScreenShare
- handleScreenShareUnpublished();
- }
- guardedUpdate();
- });
- newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
- newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
- newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
- if (
- publication.source === Track.Source.ScreenShare ||
- publication.source === Track.Source.ScreenShareAudio
- ) {
- (publication as RemoteTrackPublication).setSubscribed(false);
- }
- guardedUpdate();
- });
- newRoom.on(RoomEvent.TrackUnpublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
- if (publication.source === Track.Source.ScreenShare) {
- const { userId } = parseIdentity(participant.identity);
- const state = useVoiceStore.getState();
- state.unwatchStream(userId);
- state.clearStreamVolume(userId);
- state.clearStreamMute(userId);
- }
- guardedUpdate();
- });
- newRoom.on(RoomEvent.ConnectionQualityChanged, (quality: ConnectionQuality, participant: Participant) => {
- if (participant.identity === newRoom.localParticipant.identity) {
- useVoiceStore.getState().setConnectionQuality(quality as any);
- }
- });
- newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
- if (roomRef.current === newRoom) {
- setConnectionState(state);
- const connected = state === ConnectionState.Connected;
- const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
-
- setIsConnected(connected);
- setIsConnecting(connecting);
-
- useVoiceStore.getState().setIsLiveKitConnected(connected);
-
- if (connected) {
- updateParticipants();
- }
- }
- });
- await newRoom.connect(url, token);
- if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
- const fullId = `dm-${dmChannelId}`;
- _activeRoom = newRoom;
- connectedChannelRef.current = fullId;
- setConnectedChannelId(fullId);
- setRoom(newRoom);
- setIsConnected(true);
- useVoiceStore.getState().setIsLiveKitConnected(true);
- updateParticipants();
-
- // Unsubscribe from any remote screen share tracks that auto-subscribed during connect
- newRoom.remoteParticipants.forEach((rp) => {
- rp.trackPublications.forEach((pub) => {
- if (
- (pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) &&
- pub.isSubscribed
- ) {
- (pub as RemoteTrackPublication).setSubscribed(false);
- }
- });
- });
-
- const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
- useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
-
- if (wasDeafened) {
- newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
- }
- updateParticipants();
- } catch (err) { if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); } }
- finally { if (gen === _connectGeneration) setIsConnecting(false); }
- }, [updateParticipants, handleDataReceived]);
-
const disconnect = useCallback(async () => {
_connectGeneration++;
SpeakingDetector.getInstance().clear();
@@ -704,5 +559,5 @@ export function useLiveKit() {
}, []);
- return { room, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
+ return { room, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, disconnect, toggleMic, toggleCamera, toggleScreenShare };
}