import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useServerStore } from '../../stores/serverStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; import { useAuthStore } from '../../stores/authStore'; import { VoiceChannel } from '../voice/VoiceChannel'; import { VoiceControls } from '../voice/VoiceControls'; import { useVoiceStore } from '../../stores/voiceStore'; import { Avatar } from '../ui/Avatar'; import { wsSend } from '../../hooks/useWebSocket'; import { getActiveRoom } from '../../hooks/useLiveKit'; export function ChannelSidebar() { const servers = useServerStore((s) => s.servers); const currentServerId = useServerStore((s) => s.currentServerId); const channels = useServerStore((s) => s.channels); const dmChannels = useServerStore((s) => s.dmChannels); const currentChannelId = useChatStore((s) => s.currentChannelId); const setCurrentChannel = useChatStore((s) => s.setCurrentChannel); const unreadChannels = useChatStore((s) => s.unreadChannels); const openModal = useUIStore((s) => s.openModal); const user = useAuthStore((s) => s.user); const members = useServerStore((s) => s.members); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const setCurrentVoiceChannel = useVoiceStore((s) => s.setCurrentVoiceChannel); const isMuted = useVoiceStore((s) => s.isMuted); const isDeafened = useVoiceStore((s) => s.isDeafened); const toggleMic = useVoiceStore((s) => s.toggleMic); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const navigate = useNavigate(); const handleMicToggle = async () => { const room = getActiveRoom(); if (room) { try { await room.localParticipant.setMicrophoneEnabled(isMuted); } catch (err) { console.error('[ChannelSidebar] Failed to toggle mic:', err); } } toggleMic(); }; const handleDeafenToggle = () => { toggleDeafen(); }; const server = servers.find(s => s.id === currentServerId); const currentMember = members.find(m => m.userId === user?.id); const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner'; const textChannels = channels.filter(c => c.type === 'text'); const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video'); const handleChannelClick = (channelId: string) => { setCurrentChannel(channelId); navigate(`/channels/${currentServerId || '@me'}/${channelId}`); }; const handleHomeClick = () => { setCurrentChannel(null); navigate('/channels/@me'); }; const handleVoiceJoin = (channelId: string) => { // Don't re-join the same channel — prevents duplicate LiveKit connections if (currentVoiceChannelId === channelId) { navigate(`/channels/${currentServerId}/${channelId}`); return; } setCurrentVoiceChannel(channelId); wsSend({ type: 'voice_join', channelId }); navigate(`/channels/${currentServerId}/${channelId}`); }; if (!server) { return (
Friends
{/* Nitro */}
Nitro
{/* Shop */}
Shop
Direct Messages
{dmChannels.map((dm) => { const otherUser = dm.members.find(m => m.id !== user?.id); if (!otherUser) return null; const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id; return (
handleChannelClick(dm.id)} className={`relative flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer transition-colors group ${ currentChannelId === dm.id ? 'bg-discord-modifier-selected text-white' : isDmUnread ? 'text-white hover:bg-discord-modifier-hover' : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary' }`} > {isDmUnread && (
)}
{otherUser.displayName ?? otherUser.username}
{dm.lastMessage && (
{dm.lastMessage.content}
)}
); })} {dmChannels.length === 0 && (

No DM conversations yet.

)}
{/* Voice controls — visible when in a call, even in DM view */} {currentVoiceChannelId && } {/* User area at bottom */} {user && ( openModal('userSettings')} /> )}
); } return (
{/* Server header */} {/* Channels */}
{/* Text Channels */}
Text Channels
{isAdminUser && ( )}
{textChannels.map((channel) => { const isUnread = unreadChannels.has(channel.id) && currentChannelId !== channel.id; return ( ); })}
{/* Voice Channels */}
Voice Channels
{isAdminUser && ( )}
{voiceChannels.map((channel) => ( handleVoiceJoin(channel.id)} /> ))}
{/* Restore Invite Button */}
{/* Voice controls — VoiceControls reads state and calls LiveKit SDK directly */} {currentVoiceChannelId && } {/* User area */} {user && ( openModal('userSettings')} /> )}
); } /* ─── User Area Panel ──────────────────────────────────────────────────────── */ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle, onSettingsClick, }: { user: any; isMuted: boolean; isDeafened: boolean; onMicToggle: () => void; onDeafenToggle: () => void; onSettingsClick: () => void; }) { const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const [inputDevices, setInputDevices] = useState([]); const [outputDevices, setOutputDevices] = useState([]); const [selectedInput, setSelectedInput] = useState('default'); const [selectedOutput, setSelectedOutput] = useState('default'); const [selectedInputLabel, setSelectedInputLabel] = useState('Default'); const [selectedOutputLabel, setSelectedOutputLabel] = useState('Default'); const inputVolume = useVoiceStore((s) => s.inputVolume); const storeSetInputVolume = useVoiceStore((s) => s.setInputVolume); const outputVolume = useVoiceStore((s) => s.outputVolume); const storeSetOutputVolume = useVoiceStore((s) => s.setOutputVolume); const [showInputDeviceList, setShowInputDeviceList] = useState(false); const [showOutputDeviceList, setShowOutputDeviceList] = useState(false); const [micLevel, setMicLevel] = useState(0); const panelRef = useRef(null); const analyserRef = useRef(null); const animFrameRef = useRef(0); const loadDevices = useCallback(async () => { try { // Need to request permission first to get labels await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); const devices = await navigator.mediaDevices.enumerateDevices(); setInputDevices(devices.filter(d => d.kind === 'audioinput')); setOutputDevices(devices.filter(d => d.kind === 'audiooutput')); } catch { // permission denied } }, []); // Start mic level monitoring when input panel opens useEffect(() => { if (openPanel !== 'input') { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); analyserRef.current = null; setMicLevel(0); return; } let stream: MediaStream | null = null; let ctx: AudioContext | null = null; const start = async () => { try { stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: selectedInput !== 'default' ? selectedInput : undefined } }); ctx = new AudioContext(); const source = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = 256; source.connect(analyser); analyserRef.current = analyser; const data = new Uint8Array(analyser.frequencyBinCount); const tick = () => { if (!analyserRef.current) return; analyserRef.current.getByteFrequencyData(data); const avg = data.reduce((a, b) => a + b, 0) / data.length; setMicLevel(Math.min(avg / 128, 1)); animFrameRef.current = requestAnimationFrame(tick); }; tick(); } catch { /* no mic access */ } }; start(); return () => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); analyserRef.current = null; stream?.getTracks().forEach(t => t.stop()); ctx?.close(); }; }, [openPanel, selectedInput]); useEffect(() => { const handleClick = (e: MouseEvent) => { if (panelRef.current && !panelRef.current.contains(e.target as Node)) { setOpenPanel(null); setShowInputDeviceList(false); setShowOutputDeviceList(false); } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, []); const togglePanel = (panel: 'input' | 'output') => { if (openPanel === panel) { setOpenPanel(null); } else { loadDevices(); setOpenPanel(panel); setShowInputDeviceList(false); setShowOutputDeviceList(false); } }; const selectInput = (device: MediaDeviceInfo) => { setSelectedInput(device.deviceId); setSelectedInputLabel(device.label || 'Default'); setShowInputDeviceList(false); const room = getActiveRoom(); if (room) room.switchActiveDevice('audioinput', device.deviceId).catch(() => {}); }; const selectOutput = (device: MediaDeviceInfo) => { setSelectedOutput(device.deviceId); setSelectedOutputLabel(device.label || 'Default'); setShowOutputDeviceList(false); const room = getActiveRoom(); if (room) room.switchActiveDevice('audiooutput', device.deviceId).catch(() => {}); }; // Generate mic level bars (20 bars like Discord) const micBars = 20; const activeBars = Math.round(micLevel * micBars * (inputVolume / 100)); return (
{/* Input settings panel */} {openPanel === 'input' && (
{/* Input Device */}
{showInputDeviceList && (
{inputDevices.map(d => ( ))}
)}
{/* Input Volume */}
Input Volume
{ const vol = Number(e.target.value); storeSetInputVolume(vol); // Apply gain to mic: at 0 = mute, 100 = normal, 200 = 2x boost const room = getActiveRoom(); if (room && room.localParticipant.isMicrophoneEnabled) { if (vol === 0) { room.localParticipant.setMicrophoneEnabled(false).catch(() => {}); } else { // Re-enable mic if it was muted by volume slider room.localParticipant.setMicrophoneEnabled(true).catch(() => {}); } } }} className="w-full h-1.5 rounded-full appearance-none cursor-pointer accent-discord-blurple bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md" style={{ background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${inputVolume / 2}%, #4e5058 ${inputVolume / 2}%, #4e5058 100%)`, }} /> {/* Mic level meter */}
{Array.from({ length: micBars }).map((_, i) => (
))}
{/* Voice Settings link */}
)} {/* Output settings panel */} {openPanel === 'output' && (
{/* Output Device */}
{showOutputDeviceList && (
{outputDevices.map(d => ( ))}
)}
{/* Output Volume */}
Output Volume
{ const vol = Number(e.target.value); storeSetOutputVolume(vol); // Apply volume to all remote participants const room = getActiveRoom(); if (room) { const scaled = vol / 100; // 0-2 range (0%=0, 100%=1, 200%=2) room.remoteParticipants.forEach((participant) => { participant.setVolume(scaled); }); } }} className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md" style={{ background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${outputVolume / 2}%, #4e5058 ${outputVolume / 2}%, #4e5058 100%)`, }} />
{/* Voice Settings link */}
)} {/* User area bar */}
{/* Avatar + name */}
{user.displayName ?? user.username}
@{user.username}
{/* Controls */}
{/* Mic */} {/* Input chevron */} {/* Headphones */} {/* Output chevron */} {/* Settings */}
); }