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 isServerMuted = useVoiceStore((s) => user ? s.serverMutedUserIds.has(user.id) : false);
const isServerDeafened = useVoiceStore((s) => user ? s.serverDeafenedUserIds.has(user.id) : false);
const navigate = useNavigate();
const location = useLocation();
const handleMicToggle = async () => {
if (isServerMuted || isServerDeafened) return; // Blocked by server mute/deafen
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 () => {
if (isServerDeafened) return; // Blocked by server deafen
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 channelPermissions = useSpaceStore((s) => s.channelPermissions);
const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS);
const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE);
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 (
<>
{/* Placeholder nav items */}
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 */}
{canCreateInvite && (
)}
{/* Channels */}
{/* Text Channels */}
{canManageChannels && (
)}
{textChannels.map((channel) => {
const isActive = currentChannelId === channel.id;
const isUnread = unreadChannels.has(channel.id) && !isActive;
return (
);
})}
{/* Voice Channels */}
{canManageChannels && (
)}
{voiceChannels.map((channel) => {
const chPerms = channelPermissions.get(channel.id);
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
return (
canConnect && handleVoiceJoin(channel.id)}
locked={!canConnect}
/>
);
})}
{floatingPanel}
>
);
}
/* ─── User Area Panel ──────────────────────────────────────────────────────── */
function UserAreaPanel({
user,
isMuted,
isDeafened,
isServerMuted,
isServerDeafened,
isAdmin,
onMicToggle,
onDeafenToggle,
onSettingsClick,
onAdminClick,
}: {
user: any;
isMuted: boolean;
isDeafened: boolean;
isServerMuted: boolean;
isServerDeafened: 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 */}
{/* 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 */}
);
}