import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import type { Channel } from '@backspace/shared'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } 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 { ProfileAvatar } from '../ui/ProfileAvatar'; import { Mascot } from '../ui/Mascot'; import { wsSend } from '../../hooks/useWebSocket'; import { AudioManager } from '../../audio/AudioManager'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { DmSearchBar } from './DmSearchBar'; import { DmListItem } from './DmListItem'; import { useDragManager, type DropTarget, type LayoutItem } from '../../hooks/useDragManager'; import { useDelayedLoading } from '../../hooks/useDelayedLoading'; import { useAudioDevices } from '../../hooks/useAudioDevices'; import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId); 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 spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === user?.id)?.userId ?? user?.id); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const isSpaceMuted = !!(myOriginId && spaceId && spaceMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isSpaceDeafened = !!(myOriginId && spaceId && spaceDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`)); const navigate = useNavigate(); const location = useLocation(); const [floatingPanelEl, setFloatingPanelEl] = useState(null); const scrollContainerRef = useRef(null); const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight); const setFloatingPanelHeight = useUIStore((s) => s.setFloatingPanelHeight); useEffect(() => { if (!floatingPanelEl) return; const ro = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) setFloatingPanelHeight(entry.contentRect.height); }); ro.observe(floatingPanelEl); return () => ro.disconnect(); }, [floatingPanelEl, setFloatingPanelHeight]); const handleMicToggle = async () => { if (isSpaceMuted || isSpaceDeafened || isPermissionMuted) return; const wasDeafened = useVoiceStore.getState().isDeafened; toggleMic(); broadcastVoiceStatus(); // If unmuting while deafened cleared deafen, broadcast via LiveKit data channel if (wasDeafened && !useVoiceStore.getState().isDeafened) { broadcastDeafenViaLiveKit(); } }; const handleDeafenToggle = async () => { if (isSpaceDeafened) return; toggleDeafen(); broadcastVoiceStatus(); broadcastDeafenViaLiveKit(); }; const spacePermissions = useSpaceStore((s) => s.spacePermissions); const space = spaces.find(s => s.id === currentSpaceId); const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const isLoadingSpace = !!loadingSpaceId && loadingSpaceId === currentSpaceId; const showChannelSkeleton = useDelayedLoading(isLoadingSpace); 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 categories = useSpaceStore((s) => s.categories); // Delete category confirmation state const [deleteCategoryId, setDeleteCategoryId] = useState(null); const [deleteCategoryLoading, setDeleteCategoryLoading] = useState(false); const [leaveGroupDmId, setLeaveGroupDmId] = useState(null); const [leaveGroupDmLoading, setLeaveGroupDmLoading] = useState(false); // Centralized context menu const openContextMenu = useContextMenuStore((s) => s.open); // Collapse state — persisted in localStorage const collapseKey = `backspace:collapsed-categories:${currentSpaceId}`; const [collapsedCategories, setCollapsedCategories] = useState>(() => { try { const stored = localStorage.getItem(collapseKey); return stored ? new Set(JSON.parse(stored)) : new Set(); } catch { return new Set(); } }); const toggleCollapse = useCallback((categoryId: string) => { setCollapsedCategories(prev => { const next = new Set(prev); if (next.has(categoryId)) next.delete(categoryId); else next.add(categoryId); try { localStorage.setItem(collapseKey, JSON.stringify([...next])); } catch {} return next; }); }, [collapseKey]); // Filter channels by VIEW_CHANNEL (defense-in-depth — server already filters, // but this catches transient races where channels and permissions are briefly out of sync) const visibleChannels = useMemo(() => channels.filter(ch => hasPermissionBit(channelPermissions.get(ch.id), PermissionBits.VIEW_CHANNEL)), [channels, channelPermissions]); // Group channels by category const sortedCategories = useMemo(() => [...categories].sort((a, b) => a.position - b.position), [categories]); const uncategorizedChannels = useMemo(() => visibleChannels.filter(c => !c.categoryId).sort((a, b) => a.position - b.position), [visibleChannels]); const channelsByCategory = useMemo(() => { const map = new Map(); for (const ch of visibleChannels) { if (!ch.categoryId) continue; let arr = map.get(ch.categoryId); if (!arr) { arr = []; map.set(ch.categoryId, arr); } arr.push(ch); } for (const [key, arr] of map) { map.set(key, arr.sort((a, b) => a.position - b.position)); } return map; }, [visibleChannels]); // Check if a collapsed category has unread channels const categoryHasUnread = useCallback((categoryId: string) => { const chs = channelsByCategory.get(categoryId) ?? []; return chs.some(ch => unreadChannels.has(ch.id)); }, [channelsByCategory, unreadChannels]); // --- Centralized drag-and-drop --- // Flat ordered list matching visual sidebar order — used by useDragManager // to normalize 'before B' into 'after A' for a single drop indicator line const orderedItems = useMemo(() => { const items: LayoutItem[] = []; for (const ch of uncategorizedChannels) { items.push({ id: ch.id, type: 'channel' }); } for (const cat of sortedCategories) { items.push({ id: cat.id, type: 'category' }); const catChs = channelsByCategory.get(cat.id) ?? []; if (!collapsedCategories.has(cat.id)) { for (const ch of catChs) { items.push({ id: ch.id, type: 'channel' }); } } } return items; }, [uncategorizedChannels, sortedCategories, channelsByCategory, collapsedCategories]); const canMoveMembers = hasPermissionBit(mySpacePerms, PermissionBits.MOVE_MEMBERS); const handleChannelDrop = useCallback((dragId: string, target: DropTarget) => { if (!currentSpaceId) return; const allChannelsCopy = channels.map(ch => ({ id: ch.id, position: ch.position, categoryId: ch.categoryId, })); if (target.targetType === 'channel') { const targetCh = channels.find(c => c.id === target.targetId); if (targetCh) { const dragCh = allChannelsCopy.find(c => c.id === dragId); if (dragCh) dragCh.categoryId = targetCh.categoryId; } } else if (target.targetType === 'category') { const dragCh = allChannelsCopy.find(c => c.id === dragId); if (dragCh) dragCh.categoryId = target.targetId; } const grouped = new Map(); for (const ch of allChannelsCopy) { let arr = grouped.get(ch.categoryId); if (!arr) { arr = []; grouped.set(ch.categoryId, arr); } arr.push(ch); } for (const [, arr] of grouped) { arr.sort((a, b) => a.position - b.position); const dragIdx = arr.findIndex(c => c.id === dragId); if (dragIdx === -1) continue; const dragItem = arr[dragIdx]!; arr.splice(dragIdx, 1); if (target.targetType === 'channel') { const targetIdx = arr.findIndex(c => c.id === target.targetId); if (targetIdx !== -1) { const insertIdx = target.position === 'before' ? targetIdx : targetIdx + 1; arr.splice(insertIdx, 0, dragItem); } else { arr.push(dragItem); } } else { arr.unshift(dragItem); } arr.forEach((ch, i) => { ch.position = i; }); } const channelUpdates = allChannelsCopy.map(ch => ({ id: ch.id, position: ch.position, categoryId: ch.categoryId, })); const categoryUpdates = sortedCategories.map(c => ({ id: c.id, position: c.position, })); useSpaceStore.getState().setChannels( channels.map(ch => { const update = channelUpdates.find(u => u.id === ch.id); if (update) return { ...ch, position: update.position, categoryId: update.categoryId }; return ch; }).sort((a, b) => a.position - b.position) ); useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: categoryUpdates }); }, [channels, sortedCategories, currentSpaceId]); const handleCategoryDrop = useCallback((dragId: string, target: DropTarget) => { if (!currentSpaceId) return; const catsCopy = sortedCategories.map(c => ({ id: c.id, position: c.position })); const dragIdx = catsCopy.findIndex(c => c.id === dragId); if (dragIdx === -1) return; const dragItem = catsCopy[dragIdx]!; catsCopy.splice(dragIdx, 1); const targetIdx = catsCopy.findIndex(c => c.id === target.targetId); if (targetIdx !== -1) { const insertIdx = target.position === 'before' ? targetIdx : targetIdx + 1; catsCopy.splice(insertIdx, 0, dragItem); } else { catsCopy.push(dragItem); } catsCopy.forEach((c, i) => { c.position = i; }); const channelUpdates = channels.map(ch => ({ id: ch.id, position: ch.position, categoryId: ch.categoryId, })); useSpaceStore.getState().setCategories( categories.map(cat => { const update = catsCopy.find(u => u.id === cat.id); if (update) return { ...cat, position: update.position }; return cat; }).sort((a, b) => a.position - b.position) ); useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: catsCopy }); }, [sortedCategories, categories, channels, currentSpaceId]); const handleVoiceUserDrop = useCallback((userId: string, fromChannelId: string, toChannelId: string) => { const voiceOrigin = getChannelOrigin(fromChannelId); wsSend({ type: 'voice_move', userId, targetChannelId: toChannelId }, voiceOrigin); }, []); const { activeDrag, dropTarget, channelHandlers, categoryHandlers, voiceUserHandlers, voiceChannelDropZone, containerHandlers, } = useDragManager({ scrollContainerRef, canManage: canManageChannels, canMoveMembers, orderedItems, onChannelDrop: handleChannelDrop, onCategoryDrop: handleCategoryDrop, onVoiceUserDrop: handleVoiceUserDrop, }); const handleSidebarContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const items: ContextMenuItem[] = []; if (canManageChannels) { items.push({ key: 'create-channel', type: 'action', label: 'Create Channel', icon: ( ), onClick: () => openModal('createChannel'), }); items.push({ key: 'create-category', type: 'action', label: 'Create Category', icon: ( ), onClick: () => openModal('createCategory'), }); } if (canCreateInvite) { items.push({ key: 'invite', type: 'action', label: 'Invite People', icon: ( ), onClick: () => openModal('invite'), }); } items.push({ key: 'settings', type: 'action', label: 'Space Settings', icon: ( ), onClick: () => openModal('spaceSettings'), }); openContextMenu({ x: e.clientX, y: e.clientY }, items); }, [canManageChannels, canCreateInvite, openModal, openContextMenu]); const handleDmContextMenu = useCallback((e: React.MouseEvent, dmId: string) => { e.preventDefault(); e.stopPropagation(); openContextMenu({ x: e.clientX, y: e.clientY }, [ { key: 'leave-group', type: 'action', label: 'Leave Group', danger: true, icon: ( ), onClick: () => { setLeaveGroupDmId(dmId); }, }, ]); }, [openContextMenu]); 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; } const connectFn = useVoiceStore.getState().connectFn; joinVoiceChannel(channelId, connectFn ?? undefined); 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', tab ? { tab } : {})} />
) : null; if (!space) { return ( <>
Friends
{/* Placeholder nav items */}
Coming Soon
Coming Soon
Direct Messages
{dmChannels.map((dm) => ( { if (currentChannelId === id) { navigate('/channels/@me'); setCurrentChannel(null); } useSpaceStore.getState().closeDm(id); }} onLeave={(id) => setLeaveGroupDmId(id)} onContextMenu={handleDmContextMenu} /> ))} {dmChannels.length === 0 && (

No conversations yet.

)}
{floatingPanel} setLeaveGroupDmId(null)} onConfirm={async () => { if (!leaveGroupDmId) return; setLeaveGroupDmLoading(true); try { if (currentChannelId === leaveGroupDmId) { navigate('/channels/@me'); setCurrentChannel(null); } await useSpaceStore.getState().leaveDm(leaveGroupDmId); setLeaveGroupDmId(null); } catch { // leaveDm already handles errors } finally { setLeaveGroupDmLoading(false); } }} title="Leave Group DM" description="Are you sure you want to leave? You won't be able to rejoin unless someone adds you back." confirmLabel="Leave" variant="danger" loading={leaveGroupDmLoading} /> ); } return ( <>
{/* Space header */}
{canCreateInvite && ( )}
{/* Channels — dynamic category layout */}
{showChannelSkeleton ? (
{/* Category group 1 */}
{Array.from({ length: 3 }, (_, i) => (
))} {/* Category group 2 */}
{Array.from({ length: 4 }, (_, i) => (
))}
) : (<> {/* Uncategorized channels */} {uncategorizedChannels.length > 0 && (
{canManageChannels && sortedCategories.length === 0 && (
)}
{uncategorizedChannels.map((channel) => ( { const chPerms = channelPermissions.get(channel.id); const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT); if (canConnect) handleVoiceJoin(channel.id); }) : (() => handleChannelClick(channel.id))} onSettingsClick={() => openModal('channelSettings', { channelId: channel.id })} channelDragHandlers={channelHandlers(channel.id)} voiceUserHandlers={voiceUserHandlers} voiceChannelDropZone={voiceChannelDropZone(channel.id)} channelPermissions={channelPermissions} handleVoiceJoin={handleVoiceJoin} /> ))}
)} {/* Categories with their channels */} {sortedCategories.map((category) => { const catChannels = channelsByCategory.get(category.id) ?? []; // Hide empty categories for users without MANAGE_CHANNELS permission if (!canManageChannels && catChannels.length === 0) return null; const isCollapsed = collapsedCategories.has(category.id); const hasUnread = isCollapsed && categoryHasUnread(category.id); const categoryHeader = (
toggleCollapse(category.id)} >
{category.name} {category.isPrivate && ( )} {hasUnread && (
)}
{canManageChannels && ( )}
); return (
{/* Category header */} {canManageChannels ? (
{ e.preventDefault(); e.stopPropagation(); openContextMenu({ x: e.clientX, y: e.clientY }, [ { key: 'category-settings', type: 'action', label: 'Category Settings', icon: ( ), onClick: () => openModal('categorySettings', { categoryId: category.id }), }, { key: 'delete-category', type: 'action', label: 'Delete Category', danger: true, icon: ( ), onClick: () => setDeleteCategoryId(category.id), }, ]); }}> {categoryHeader}
) : categoryHeader} {/* Category channels (hidden when collapsed, unless active) */} {!isCollapsed && (
{catChannels.map((channel) => ( { const chPerms = channelPermissions.get(channel.id); const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT); if (canConnect) handleVoiceJoin(channel.id); }) : (() => handleChannelClick(channel.id))} onSettingsClick={() => openModal('channelSettings', { channelId: channel.id })} channelDragHandlers={channelHandlers(channel.id)} voiceUserHandlers={voiceUserHandlers} voiceChannelDropZone={voiceChannelDropZone(channel.id)} channelPermissions={channelPermissions} handleVoiceJoin={handleVoiceJoin} /> ))} {catChannels.length === 0 && (
No channels
)}
)}
); })} {/* Create channel / category buttons */} {canManageChannels && (
{sortedCategories.length > 0 && ( )}
)} )}
{floatingPanel} setDeleteCategoryId(null)} onConfirm={async () => { if (!deleteCategoryId) return; setDeleteCategoryLoading(true); try { await useSpaceStore.getState().deleteCategory(deleteCategoryId); setDeleteCategoryId(null); } catch { // deleteCategory already shows a toast on error } finally { setDeleteCategoryLoading(false); } }} title="Delete Category" description="Are you sure you want to delete this category? Channels in this category will be moved to uncategorized — no channels will be deleted." confirmLabel="Delete" variant="danger" loading={deleteCategoryLoading} /> ); } /* ─── User Area Panel ──────────────────────────────────────────────────────── */ function UserAreaPanel({ user, isMuted, isDeafened, isSpaceMuted, isSpaceDeafened, isPermissionMuted, onMicToggle, onDeafenToggle, onSettingsClick, }: { user: any; isMuted: boolean; isDeafened: boolean; isSpaceMuted: boolean; isSpaceDeafened: boolean; isPermissionMuted: boolean; onMicToggle: () => void; onDeafenToggle: () => void; onSettingsClick: (tab?: string) => void; }) { const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const outputDeviceId = useVoiceStore((s) => s.outputDeviceId); const setInputDevice = useVoiceStore((s) => s.setInputDevice); const setOutputDevice = useVoiceStore((s) => s.setOutputDevice); // Shared hook drives lists, permission state, and live devicechange refresh. const { permState, inputs: inputDevices, outputs: outputDevices, inputLabels, outputLabels, requestPermission } = useAudioDevices(); const selectedInputLabel = inputDeviceId === 'default' ? 'System Default' : inputLabels.get(inputDeviceId) ?? 'System Default'; const selectedOutputLabel = outputDeviceId === 'default' ? 'System Default' : outputLabels.get(outputDeviceId) ?? 'System 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); // 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 { setOpenPanel(panel); setShowInputDeviceList(false); setShowOutputDeviceList(false); // Resume the AudioContext so the mic-level meter starts measuring on open. AudioManager.getInstance().resumeContext(); } }; const selectInput = (deviceId: string) => { setInputDevice(deviceId); // Pure state update → triggers syncMic if in voice call AudioManager.getInstance().setInputDevice(deviceId).catch(() => {}); setShowInputDeviceList(false); }; const selectOutput = (deviceId: string) => { setOutputDevice(deviceId); AudioManager.getInstance().setOutputDevice(deviceId).catch(() => {}); setShowOutputDeviceList(false); }; // 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 && (
{permState !== 'granted' && (
Microphone permission needed.{' '}
)} {permState === 'granted' && ( <> selectInput('default')} /> {inputDevices.filter(d => d.deviceId !== 'default').map(d => ( selectInput(d.deviceId)} /> ))} )}
)}
{/* 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 && (
{permState !== 'granted' && (
Audio permission needed.{' '}
)} {permState === 'granted' && ( <> selectOutput('default')} /> {outputDevices.filter(d => d.deviceId !== 'default').map(d => ( selectOutput(d.deviceId)} /> ))} )}
)}
{/* 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 */} {/* Settings */}
); } /* ─── Channel Item (unified text + voice) ──────────────────────────────────── */ function ChannelItem({ channel, isActive, isUnread, canManage, isDragging, dropIndicator, onChannelClick, onSettingsClick, channelDragHandlers, voiceUserHandlers, voiceChannelDropZone, channelPermissions, handleVoiceJoin, }: { channel: Channel; isActive: boolean; isUnread: boolean; canManage: boolean; isDragging: boolean; dropIndicator: 'before' | 'after' | null; onChannelClick: () => void; onSettingsClick: () => void; channelDragHandlers: { draggable: boolean; onDragStart: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void; onDragEnd: () => void; }; voiceUserHandlers: (userId: string, channelId: string) => { draggable: boolean; isBeingDragged: boolean; onDragStart: (e: React.DragEvent) => void; onDragEnd: (e: React.DragEvent) => void; }; voiceChannelDropZone: { onDragOver: (e: React.DragEvent) => void; onDragEnter: (e: React.DragEvent) => void; onDragLeave: (e: React.DragEvent) => void; onDrop: (e: React.DragEvent) => void; isDragOver: boolean; isValidTarget: boolean; }; channelPermissions: Map; handleVoiceJoin: (channelId: string) => void; }) { if (channel.type === 'voice') { const chPerms = channelPermissions.get(channel.id); const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT); return (
{dropIndicator === 'before' &&
} canConnect && handleVoiceJoin(channel.id)} locked={!canConnect} canManage={canManage} onSettingsClick={onSettingsClick} voiceUserHandlers={voiceUserHandlers} dropZone={voiceChannelDropZone} /> {dropIndicator === 'after' &&
}
); } return (
{dropIndicator === 'before' &&
} {dropIndicator === 'after' &&
}
); }