diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 804b0a01..78f69994 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -156,6 +156,9 @@ export function handleClientEvent( case 'dm_call_end': handleDmCallEnd(event, userId); break; + case 'voice_status': + handleVoiceStatus(event, userId); + break; default: connectionManager.sendToUser(userId, { type: 'error', @@ -738,6 +741,27 @@ function handleChannelAck(event: Record, userId: string): void }); } +function handleVoiceStatus(event: Record, userId: string): void { + const isMuted = event.isMuted === true; + const isDeafened = event.isDeafened === true; + + const channelId = connectionManager.getUserVoiceChannel(userId); + if (!channelId) return; + + const serverId = getChannelServerId(channelId); + if (!serverId) return; + + connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened); + + connectionManager.sendToServer(serverId, { + type: 'voice_status_update', + userId, + channelId, + isMuted, + isDeafened, + }); +} + // ─── DM Call Handlers ────────────────────────────────────────────────────────── function handleDmCallStart(event: Record, userId: string, username: string): void { diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 893b3165..c232da87 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -44,6 +44,8 @@ class ConnectionManager { private wsToUser: Map = new Map(); // dmChannelId → { callerId, startedAt } — active DM calls private activeCalls: Map = new Map(); + // userId → { isMuted, isDeafened } — voice user status (mute/deafen state) + private voiceUserStates: Map = new Map(); addConnection(userId: string, ws: WebSocket): void { if (!this.connections.has(userId)) { @@ -110,9 +112,11 @@ class ConnectionManager { this.voiceStates.delete(channelId); } } + this.voiceUserStates.delete(userId); } leaveAllVoice(userId: string): string | null { + this.voiceUserStates.delete(userId); for (const [channelId, users] of this.voiceStates) { if (users.has(userId)) { users.delete(userId); @@ -138,6 +142,23 @@ class ConnectionManager { return null; } + // Voice user status management + setVoiceUserStatus(userId: string, isMuted: boolean, isDeafened: boolean): void { + this.voiceUserStates.set(userId, { isMuted, isDeafened }); + } + + getVoiceUserStatus(userId: string): { isMuted: boolean; isDeafened: boolean } | undefined { + return this.voiceUserStates.get(userId); + } + + clearVoiceUserStatus(userId: string): void { + this.voiceUserStates.delete(userId); + } + + getAllVoiceUserStates(): Map { + return this.voiceUserStates; + } + // DM call management startCall(dmChannelId: string, callerId: string): boolean { if (this.activeCalls.has(dmChannelId)) return false; // Already in a call @@ -206,6 +227,7 @@ function buildReadyPayload(userId: string): { dmChannels: DmChannel[]; folders: ServerFolder[]; voiceStates: Record; + voiceUserStates: Record; readStates: ReadState[]; } { const db = getDb(); @@ -422,6 +444,20 @@ function buildReadyPayload(userId: string): { } } + // Build voice user states — tell the client mute/deafen status of voice users + const voiceUserStates: Record = {}; + for (const chId of Object.keys(voiceStates)) { + const usersInChannel = voiceStates[chId]; + if (usersInChannel) { + for (const uid of usersInChannel) { + const status = connectionManager.getVoiceUserStatus(uid); + if (status) { + voiceUserStates[uid] = status; + } + } + } + } + // Fetch read states for unread tracking const readStateRows = db.select() .from(schema.readStates) @@ -433,7 +469,7 @@ function buildReadyPayload(userId: string): { lastReadMessageId: rs.lastReadMessageId, })); - return { user, servers, dmChannels, folders, voiceStates, readStates }; + return { user, servers, dmChannels, folders, voiceStates, voiceUserStates, readStates }; } export async function registerWebSocket(app: FastifyInstance): Promise { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ae3669e9..aaafe2dc 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -185,11 +185,12 @@ export type ClientEvent = | { type: 'dm_call_start'; dmChannelId: string } | { type: 'dm_call_accept'; dmChannelId: string } | { type: 'dm_call_reject'; dmChannelId: string } - | { type: 'dm_call_end'; dmChannelId: string }; + | { type: 'dm_call_end'; dmChannelId: string } + | { type: 'voice_status'; isMuted: boolean; isDeafened: boolean }; // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record; readStates?: ReadState[] } + | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[] } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } @@ -211,6 +212,7 @@ export type ServerEvent = | { type: 'dm_call_accepted'; dmChannelId: string } | { type: 'dm_call_rejected'; dmChannelId: string } | { type: 'dm_call_ended'; dmChannelId: string } + | { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean } | { type: 'error'; message: string }; // ─── API Request/Response Types ───────────────────────────────────────────── diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 172bbc21..7a96ce33 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -40,10 +40,42 @@ export function ChannelSidebar() { } } toggleMic(); + // Broadcast mute status via WebSocket so non-joined users can see it + const willBeMuted = !isMuted; + wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened }); }; - const handleDeafenToggle = () => { + const handleDeafenToggle = async () => { + const room = getActiveRoom(); + const willDeafen = !isDeafened; + // Update store FIRST so updateParticipants reads correct state when LiveKit events fire toggleDeafen(); + if (willDeafen && !isMuted) toggleMic(); + if (!willDeafen && isMuted) toggleMic(); + // Broadcast status via WebSocket so non-joined users can see it + const willBeMuted = willDeafen ? true : false; + wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened: willDeafen }); + if (room) { + try { + if (willDeafen) { + await room.localParticipant.setMicrophoneEnabled(false); + room.remoteParticipants.forEach((p) => p.setVolume(0)); + } else { + const outputVolume = useVoiceStore.getState().outputVolume; + const scaled = outputVolume / 100; + room.remoteParticipants.forEach((p) => p.setVolume(scaled)); + await room.localParticipant.setMicrophoneEnabled(true); + } + // Broadcast deafen state to other participants via LiveKit data channel + const encoder = new TextEncoder(); + room.localParticipant.publishData( + encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })), + { reliable: true } + ).catch(() => {}); + } catch (err) { + console.error('[ChannelSidebar] Failed to toggle deafen:', err); + } + } }; const server = servers.find(s => s.id === currentServerId); diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx index b9d872e1..795845be 100644 --- a/packages/web/src/components/voice/VoiceChannel.tsx +++ b/packages/web/src/components/voice/VoiceChannel.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; +import { useAuthStore } from '../../stores/authStore'; const EMPTY_VOICE_USERS: string[] = []; import { useServerStore } from '../../stores/serverStore'; @@ -15,6 +16,10 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS; const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId); const participants = useVoiceStore((s) => s.participants); + const localIsDeafened = useVoiceStore((s) => s.isDeafened); + const localIsMuted = useVoiceStore((s) => s.isMuted); + const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); + const currentUserId = useAuthStore((s) => s.user?.id); const members = useServerStore((s) => s.members); const isActive = currentVoiceChannel === channelId; @@ -39,22 +44,29 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
{voiceUsers.map((userId) => { const member = members.find(m => m.userId === userId); - if (!member) return null; - const displayName = member.user.displayName ?? member.user.username; - - // Find participant for status badges - const participant = participants.find(p => p.identity === userId || p.username === member.user.username); - const isMuted = participant ? !participant.audioTrack : false; - const hasCamera = participant ? participant.videoTrack !== null : false; - const isScreenSharing = participant ? participant.screenTrack !== null : false; + const participant = participants.find(p => p.userId === userId); + const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId; + const avatar = member?.user.avatar ?? null; + const status = member?.user.status; + // Resolve status: for local user use store directly, for remote users + // try LiveKit participant first, then fall back to WebSocket voiceUserStates + const wsStatus = voiceUserStates.get(userId); + const isParticipantDeafened = userId === currentUserId + ? localIsDeafened + : (participant?.isDeafened ?? wsStatus?.isDeafened ?? false); + const isMuted = userId === currentUserId + ? localIsMuted + : (participant?.isMuted ?? wsStatus?.isMuted ?? false); + const hasCamera = participant?.isCameraOn ?? false; + const isScreenSharing = participant?.isScreenSharing ?? false; return (
{displayName} {/* Status badges */} @@ -66,6 +78,12 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr )} + {isParticipantDeafened && ( + + + + + )} {hasCamera && ( diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index 64052b7b..dc282e75 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; import { useServerStore } from '../../stores/serverStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; +import { VideoQualityPopover } from './VideoQualityPopover'; /** * VoiceControls renders the voice status + button rows. @@ -10,62 +11,22 @@ import { wsSend } from '../../hooks/useWebSocket'; */ export function VoiceControls() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); - 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 toggleCamera = useVoiceStore((s) => s.toggleCamera); const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); - const toggleMic = useVoiceStore((s) => s.toggleMic); - const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); + const noiseSuppression = useVoiceStore((s) => s.noiseSuppression); + const toggleNoiseSuppression = useVoiceStore((s) => s.toggleNoiseSuppression); const connectionError = useVoiceStore((s) => s.connectionError); const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); const channels = useServerStore((s) => s.channels); + const [showVideoQuality, setShowVideoQuality] = useState(false); if (!currentVoiceChannelId) return null; const channel = channels.find(c => c.id === currentVoiceChannelId); const channelName = channel?.name ?? 'Voice Channel'; - const handleMute = async () => { - const room = getActiveRoom(); - if (room) { - try { - await room.localParticipant.setMicrophoneEnabled(isMuted); - } catch (err) { - console.error('[VoiceControls] Failed to toggle mic:', err); - } - } - toggleMic(); - }; - - const handleDeafen = async () => { - const room = getActiveRoom(); - if (room) { - try { - const willDeafen = !isDeafened; - if (willDeafen) { - await room.localParticipant.setMicrophoneEnabled(false); - room.remoteParticipants.forEach((p) => { - p.setVolume(0); - }); - if (!isMuted) toggleMic(); - } else { - const outputVolume = useVoiceStore.getState().outputVolume; - const scaled = outputVolume / 100; - room.remoteParticipants.forEach((p) => { - p.setVolume(scaled); - }); - await room.localParticipant.setMicrophoneEnabled(true); - if (isMuted) toggleMic(); - } - } catch (err) { - console.error('[VoiceControls] Failed to toggle deafen:', err); - } - } - toggleDeafen(); - }; - const handleCamera = async () => { const room = getActiveRoom(); if (!room) return; @@ -88,6 +49,28 @@ export function VoiceControls() { } }; + const handleNoiseSuppression = async () => { + const room = getActiveRoom(); + if (room) { + try { + const micPub = room.localParticipant.getTrackPublications().find( + p => p.source === 'microphone' + ); + const mediaTrack = micPub?.track?.mediaStreamTrack; + if (mediaTrack) { + await mediaTrack.applyConstraints({ + noiseSuppression: !noiseSuppression, + echoCancellation: true, + autoGainControl: true, + }); + } + } catch (err) { + console.error('[VoiceControls] Failed to toggle noise suppression:', err); + } + } + toggleNoiseSuppression(); + }; + const handleDisconnect = () => { wsSend({ type: 'voice_leave' }); useVoiceStore.getState().leaveVoice(); @@ -145,39 +128,8 @@ export function VoiceControls() {
- {/* Row 2: Mute, Deafen, Camera, Screen Share */} -
- - - - + {/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */} +
+ + {/* Video Quality */} + + + {/* Noise Suppression */} + + + {/* Video Quality Popover */} + setShowVideoQuality(false)} + />
); diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 17bc85d1..7b8a6e15 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -19,9 +19,9 @@ export function VoiceUser({ participant, large }: VoiceUserProps) { const perUserVolume = participantVolumes.get(participant.userId) ?? 100; const isLocal = participant.isLocal; - // Determine active video track — prioritize screen share, check readyState - const liveScreen = participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null; - const liveCamera = participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null; + // Determine active video track — prioritize screen share, check both enabled flag and readyState + const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null; + const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null; const activeVideoTrack = liveScreen ?? liveCamera; const hasVideo = activeVideoTrack !== null; const isScreenShare = liveScreen !== null; @@ -154,6 +154,14 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
)} + {(isLocal ? isDeafened : participant.isDeafened) && ( +
+ + + + +
+ )} {participant.isScreenSharing && !isScreenShare && (
diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 613375cc..9a492b06 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -44,6 +44,7 @@ export interface ParticipantInfo { username: string; isSpeaking: boolean; isMuted: boolean; + isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean; isLocal: boolean; @@ -118,16 +119,34 @@ export function useLiveKit() { const mt = track.mediaStreamTrack; if (!mt || mt.readyState !== 'live') return; if (pub.source === Track.Source.Microphone) audioTrack = mt; - else if (pub.source === Track.Source.Camera) videoTrack = mt; - else if (pub.source === Track.Source.ScreenShare) screenTrack = mt; + else if (pub.source === Track.Source.Camera && p.isCameraEnabled) videoTrack = mt; + else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled) screenTrack = mt; }); - allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack }); + let isDeafened = false; + if (isLocal) { + isDeafened = useVoiceStore.getState().isDeafened; + } else { + isDeafened = useVoiceStore.getState().deafenedUserIds.has(userId); + } + allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isDeafened, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack }); }; processParticipant(r.localParticipant, true); r.remoteParticipants.forEach((p) => processParticipant(p, false)); setParticipants(allParticipants); }, []); + const handleDataReceived = useCallback((payload: Uint8Array, participant?: RemoteParticipant) => { + try { + const text = new TextDecoder().decode(payload); + const msg = JSON.parse(text); + if (msg.type === 'deafen' && participant) { + const { userId } = parseIdentity(participant.identity); + useVoiceStore.getState().setUserDeafened(userId, msg.deafened === true); + updateParticipants(); + } + } catch {} + }, [updateParticipants]); + const connect = useCallback(async (channelId: string) => { if (connectedChannelRef.current === channelId && roomRef.current) return; const gen = ++_connectGeneration; @@ -141,7 +160,17 @@ export function useLiveKit() { const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } }); roomRef.current = newRoom; const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); }; - newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate); + newRoom.on(RoomEvent.ParticipantConnected, (participant) => { + guardedUpdate(); + // Re-broadcast local deafen state to newly connected participant + if (useVoiceStore.getState().isDeafened) { + const encoder = new TextEncoder(); + newRoom.localParticipant.publishData( + encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })), + { reliable: true } + ).catch(() => {}); + } + }); newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate); newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); @@ -150,6 +179,8 @@ export function useLiveKit() { newRoom.on(RoomEvent.TrackMuted, guardedUpdate); newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); + newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate); + newRoom.on(RoomEvent.DataReceived, handleDataReceived); newRoom.on(RoomEvent.ConnectionStateChanged, (state) => { if (roomRef.current === newRoom) { const connected = state === ConnectionState.Connected; @@ -171,7 +202,7 @@ export function useLiveKit() { try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); } } catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); } finally { if (gen === _connectGeneration) setIsConnecting(false); } - }, [updateParticipants]); + }, [updateParticipants, handleDataReceived]); const connectDm = useCallback(async (dmChannelId: string) => { const gen = ++_connectGeneration; @@ -208,7 +239,7 @@ export function useLiveKit() { try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); } } catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); } finally { if (gen === _connectGeneration) setIsConnecting(false); } - }, [updateParticipants]); + }, [updateParticipants, handleDataReceived]); const disconnect = useCallback(async () => { _connectGeneration++; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index e8fb92a3..97c6e507 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -16,7 +16,7 @@ function handleEvent(event: ServerEvent): void { const { setUser } = useAuthStore.getState(); const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState(); const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState(); - const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers } = useVoiceStore.getState(); + const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState(); switch (event.type) { case 'ready': @@ -45,6 +45,21 @@ function handleEvent(event: ServerEvent): void { setVoiceUsers(channelId, userIds); } } + // Populate voice user statuses (mute/deafen) from server + if (event.voiceUserStates) { + for (const [uid, status] of Object.entries(event.voiceUserStates)) { + setVoiceUserStatus(uid, status.isMuted, status.isDeafened); + } + } + // Re-register in voice channel if we're still connected to LiveKit + // (WebSocket reconnect causes server to drop our voice tracking) + { + const { currentVoiceChannelId, isMuted: curMuted, isDeafened: curDeafened } = useVoiceStore.getState(); + if (currentVoiceChannelId) { + wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }); + wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened }); + } + } break; case 'message_created': @@ -78,9 +93,14 @@ function handleEvent(event: ServerEvent): void { addVoiceUser(event.channelId, event.userId); } else { removeVoiceUser(event.channelId, event.userId); + clearVoiceUserStatus(event.userId); } break; + case 'voice_status_update': + setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened); + break; + case 'member_joined': addMember(event.member); break; diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 55636a67..716acdaf 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -41,6 +41,14 @@ interface VoiceState { toggleDeafen: () => void; setFocusedParticipant: (id: string | null) => void; setVideoQuality: (quality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p') => void; + noiseSuppression: boolean; + toggleNoiseSuppression: () => void; + deafenedUserIds: Set; + setUserDeafened: (userId: string, deafened: boolean) => void; + // WebSocket-based voice user status (visible without joining LiveKit) + voiceUserStates: Map; + setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean) => void; + clearVoiceUserStatus: (userId: string) => void; getVoiceUsers: (channelId: string) => string[]; clearAllVoiceUsers: () => void; leaveVoice: () => void; @@ -123,10 +131,36 @@ export const useVoiceStore = create((set, get) => ({ setFocusedParticipant: (id) => set({ focusedParticipantId: id }), setVideoQuality: (quality) => set({ videoQuality: quality }), + noiseSuppression: true, + toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })), + deafenedUserIds: new Set(), + setUserDeafened: (userId, deafened) => { + set((state) => { + const newSet = new Set(state.deafenedUserIds); + if (deafened) newSet.add(userId); else newSet.delete(userId); + return { deafenedUserIds: newSet }; + }); + }, + + voiceUserStates: new Map(), + setVoiceUserStatus: (userId, isMuted, isDeafened) => { + set((state) => { + const newMap = new Map(state.voiceUserStates); + newMap.set(userId, { isMuted, isDeafened }); + return { voiceUserStates: newMap }; + }); + }, + clearVoiceUserStatus: (userId) => { + set((state) => { + const newMap = new Map(state.voiceUserStates); + newMap.delete(userId); + return { voiceUserStates: newMap }; + }); + }, getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [], - clearAllVoiceUsers: () => set({ voiceUsers: new Map() }), + clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }), // Leave voice without wiping the voiceUsers map (so sidebar still shows others) leaveVoice: () => set({ @@ -143,6 +177,7 @@ export const useVoiceStore = create((set, get) => ({ focusedParticipantId: null, activeDmCall: null, outgoingCall: null, + deafenedUserIds: new Set(), }), reset: () => set({ @@ -162,5 +197,7 @@ export const useVoiceStore = create((set, get) => ({ incomingCall: null, outgoingCall: null, activeDmCall: null, + deafenedUserIds: new Set(), + voiceUserStates: new Map(), }), }));