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 (
Find or start a conversation
{/* Nitro */}
{/* Shop */}
Direct Messages
openModal('newDm')}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="New Direct Message"
>
{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}
)}
{ e.stopPropagation(); }}
className="opacity-0 group-hover:opacity-100 text-discord-text-muted hover:text-discord-text-primary transition-opacity flex-shrink-0 ml-1"
title="Close DM"
>
);
})}
{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 */}
openModal('serverSettings')}
className="h-12 px-4 flex items-center justify-between shadow-header z-10 hover:bg-discord-modifier-hover transition-colors group"
>
{server.name}
{/* Channels */}
{/* Text Channels */}
{isAdminUser && (
{
e.stopPropagation();
openModal('createChannel');
}}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Create Channel"
>
)}
{textChannels.map((channel) => {
const isUnread = unreadChannels.has(channel.id) && currentChannelId !== channel.id;
return (
handleChannelClick(channel.id)}
className={`w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${
currentChannelId === channel.id
? 'bg-discord-modifier-selected text-white'
: isUnread
? 'text-white hover:text-white hover:bg-discord-modifier-hover'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'
}`}
>
{isUnread && (
)}
{channel.name}
);
})}
{/* Voice Channels */}
{isAdminUser && (
{
e.stopPropagation();
openModal('createChannel');
}}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Create Channel"
>
)}
{voiceChannels.map((channel) => (
handleVoiceJoin(channel.id)}
/>
))}
{/* Restore Invite Button */}
openModal('invite')}
className="w-full flex items-center gap-2 px-2 h-8 rounded-[4px] text-[15px] font-medium text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover transition-colors"
>
Invite People
{/* 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 */}
setShowInputDeviceList(!showInputDeviceList)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
>
Input Device
{selectedInputLabel}
{showInputDeviceList && (
{inputDevices.map(d => (
selectInput(d)}
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${
selectedInput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'
}`}
>
{selectedInput === d.deviceId && (
)}
{d.label || 'Default'}
))}
)}
{/* 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 */}
Voice Settings
)}
{/* Output settings panel */}
{openPanel === 'output' && (
{/* Output Device */}
setShowOutputDeviceList(!showOutputDeviceList)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
>
Output Device
{selectedOutputLabel}
{showOutputDeviceList && (
{outputDevices.map(d => (
selectOutput(d)}
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${
selectedOutput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'
}`}
>
{selectedOutput === d.deviceId && (
)}
{d.label || 'Default'}
))}
)}
{/* Output Volume */}
{/* Voice Settings link */}
Voice Settings
)}
{/* User area bar */}
{/* Avatar + name */}
{user.displayName ?? user.username}
@{user.username}
{/* Controls */}
{/* Mic */}
{isMuted && }
{/* Input chevron */}
togglePanel('input')}
className={`w-[18px] h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-r-[4px] transition-colors ${
openPanel === 'input' ? 'text-discord-text-primary bg-discord-modifier-hover' : 'text-discord-text-muted hover:text-discord-text-primary'
}`}
title="Input Devices"
>
{/* Headphones */}
{isDeafened && }
{/* Output chevron */}
togglePanel('output')}
className={`w-[18px] h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-r-[4px] transition-colors ${
openPanel === 'output' ? 'text-discord-text-primary bg-discord-modifier-hover' : 'text-discord-text-muted hover:text-discord-text-primary'
}`}
title="Output Devices"
>
{/* Settings */}
);
}