import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; import { useAuthStore } from '../../stores/authStore'; import { useInstanceStore } from '../../stores/instanceStore'; import { VoiceChannel } from '../voice/VoiceChannel'; import { VoiceControls } from '../voice/VoiceControls'; import { useVoiceStore } from '../../stores/voiceStore'; import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; import { wsSend } from '../../hooks/useWebSocket'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { AudioManager } from '../../audio/AudioManager'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { parseFederatedUsername, isSelf } from '../../utils/identity'; import { joinVoiceChannel } from '../../utils/voice'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const channels = useSpaceStore((s) => s.channels); const dmChannels = useSpaceStore((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 = useSpaceStore((s) => s.members); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const activeDmCall = useVoiceStore((s) => s.activeDmCall); 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 location = useLocation(); const handleMicToggle = async () => { toggleMic(); // Broadcast mute status via WebSocket so non-joined users can see it const willBeMuted = !isMuted; const { isCameraOn, isScreenSharing } = useVoiceStore.getState(); const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin); }; 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; const { isCameraOn, isScreenSharing } = useVoiceStore.getState(); const voiceOrigin2 = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened: willDeafen, isCameraOn, isScreenSharing }, voiceOrigin2); if (room) { try { // 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 spacePermissions = useSpaceStore((s) => s.spacePermissions); const space = spaces.find(s => s.id === currentSpaceId); const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const federationInstances = useInstanceStore((s) => s.instances); const instanceLabel = useMemo(() => { const origin = (space as any)?._instanceOrigin; if (!origin) return null; const inst = federationInstances.find(i => i.origin === origin); if (inst) return inst.label; try { return new URL(origin).host; } catch { return origin; } }, [space, federationInstances]); const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS); 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/${currentSpaceId || '@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/${currentSpaceId}/${channelId}`); return; } joinVoiceChannel(channelId); navigate(`/channels/${currentSpaceId}/${channelId}`); }; // Floating bottom panel — shared between DM view and server view const floatingPanel = user ? (
{/* Voice controls (expands when connected) */} {(currentVoiceChannelId || activeDmCall) && } {/* Separator between voice and user area */} {(currentVoiceChannelId || activeDmCall) &&
} {/* User area (always visible) */} openModal('userSettings')} onAdminClick={() => openModal('instanceSettings')} />
) : null; if (!space) { return ( <>
Friends
{/* Placeholder nav items */}
Coming Soon
Coming Soon
Direct Messages
{dmChannels.map((dm) => { const otherMembers = dm.members.filter(m => !isSelf(m, user)); if (otherMembers.length === 0) return null; const isGroup = dm.members.length > 2; const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id; const dmDisplayName = isGroup ? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ') : otherMembers[0]?.displayName ?? otherMembers[0]?.username; 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-interactive-selected text-white' : isDmUnread ? 'text-white hover:bg-interactive-hover' : 'text-txt-tertiary hover:bg-interactive-hover hover:text-txt-secondary' }`} > {isDmUnread && (
)} {isGroup ? (
{otherMembers.slice(0, 2).map((m, i) => (
))}
) : ( )}
{isGroup ? (
{dm.members.length} Members
) : dm.lastMessage ? (
{dm.lastMessage.content}
) : null}
); })} {dmChannels.length === 0 && (

No DM conversations yet.

)}
{floatingPanel} ); } return ( <>
{/* Space header */}
{/* Channels */}
{/* Text Channels */}
Text Channels
{canManageChannels && ( )}
{textChannels.map((channel) => { const isActive = currentChannelId === channel.id; const isUnread = unreadChannels.has(channel.id) && !isActive; return ( ); })}
{/* Voice Channels */}
Voice Channels
{canManageChannels && ( )}
{voiceChannels.map((channel) => ( handleVoiceJoin(channel.id)} /> ))}
{floatingPanel} ); } /* ─── User Area Panel ──────────────────────────────────────────────────────── */ function UserAreaPanel({ user, isMuted, isDeafened, isAdmin, onMicToggle, onDeafenToggle, onSettingsClick, onAdminClick, }: { user: any; isMuted: boolean; isDeafened: boolean; isAdmin: boolean; onMicToggle: () => void; onDeafenToggle: () => void; onSettingsClick: () => void; onAdminClick: () => void; }) { const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const [inputDevices, setInputDevices] = useState([]); const [outputDevices, setOutputDevices] = useState([]); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const outputDeviceId = useVoiceStore((s) => s.outputDeviceId); const setInputDevice = useVoiceStore((s) => s.setInputDevice); const setOutputDevice = useVoiceStore((s) => s.setOutputDevice); 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 if (!AudioManager.getInstance().getContext()) { await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); } const devices = await navigator.mediaDevices.enumerateDevices(); // Deduplicate by deviceId — USB devices sharing the same audio chipset // (e.g. C-Media 0d8c:0134) appear as multiple entries with identical IDs. const dedup = (list: MediaDeviceInfo[]): MediaDeviceInfo[] => { const seen = new Set(); return list.filter(d => { if (seen.has(d.deviceId)) return false; seen.add(d.deviceId); return true; }); }; const inputs = dedup(devices.filter(d => d.kind === 'audioinput')); const outputs = dedup(devices.filter(d => d.kind === 'audiooutput')); setInputDevices(inputs); setOutputDevices(outputs); const currentInput = inputs.find(d => d.deviceId === inputDeviceId); if (currentInput) setSelectedInputLabel(currentInput.label || 'Default'); const currentOutput = outputs.find(d => d.deviceId === outputDeviceId); if (currentOutput) setSelectedOutputLabel(currentOutput.label || 'Default'); } catch { // permission denied } }, [inputDeviceId, outputDeviceId]); // Start mic level monitoring when input panel opens useEffect(() => { if (openPanel !== 'input') { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); analyserRef.current = null; setMicLevel(0); return; } const start = async () => { try { await AudioManager.getInstance().resumeContext(); const analyser = AudioManager.getInstance().getAnalyserNode(); analyser.fftSize = 256; 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; }; }, [openPanel]); 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); // Explicitly resume on interaction AudioManager.getInstance().resumeContext(); } }; const selectInput = (device: MediaDeviceInfo) => { setInputDevice(device.deviceId); // Pure state update → triggers syncMic if in voice call AudioManager.getInstance().setInputDevice(device.deviceId); // Immediate preview for mic level meter setSelectedInputLabel(device.label || 'Default'); setShowInputDeviceList(false); }; const selectOutput = (device: MediaDeviceInfo) => { setOutputDevice(device.deviceId); setSelectedOutputLabel(device.label || 'Default'); setShowOutputDeviceList(false); AudioManager.getInstance().setOutputDevice(device.deviceId); }; // 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); }} className="w-full h-1.5 rounded-full appearance-none cursor-pointer accent-accent-primary bg-surface-base [&::-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, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) 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); }} className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-surface-base [&::-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, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${outputVolume / 2}%, rgb(var(--interactive-muted)) ${outputVolume / 2}%, rgb(var(--interactive-muted)) 100%)`, }} />
{/* Voice Settings link */}
)} {/* User area bar */}
{/* Avatar + name */}
{user.displayName ?? user.username}
@{user.username}
{/* Controls */}
{/* Mic */} {/* Input chevron */} {/* Headphones */} {/* Output chevron */} {/* Admin — Instance Settings */} {isAdmin && ( )} {/* Settings */}
); }