import React, { useState, useRef, useEffect, useLayoutEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useConvex, useMutation, useQuery } from 'convex/react'; import { api } from '../../../../convex/_generated/api'; import Tooltip from './Tooltip'; import { useVoice } from '../contexts/VoiceContext'; import ChannelSettingsModal from './ChannelSettingsModal'; import ServerSettingsModal from './ServerSettingsModal'; import ScreenShareModal from './ScreenShareModal'; import DMList from './DMList'; import Avatar from './Avatar'; import UserSettings from './UserSettings'; import { Track } from 'livekit-client'; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, useDraggable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import muteIcon from '../assets/icons/mute.svg'; import mutedIcon from '../assets/icons/muted.svg'; import defeanIcon from '../assets/icons/defean.svg'; import defeanedIcon from '../assets/icons/defeaned.svg'; import settingsIcon from '../assets/icons/settings.svg'; import voiceIcon from '../assets/icons/voice.svg'; import disconnectIcon from '../assets/icons/disconnect.svg'; import cameraIcon from '../assets/icons/camera.svg'; import screenIcon from '../assets/icons/screen.svg'; import inviteUserIcon from '../assets/icons/invite_user.svg'; import personalMuteIcon from '../assets/icons/personal_mute.svg'; import serverMuteIcon from '../assets/icons/server_mute.svg'; import categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg'; import PingSound from '../assets/sounds/ping.mp3'; import screenShareStartSound from '../assets/sounds/screenshare_start.mp3'; import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3'; import { getUserPref, setUserPref } from '../utils/userPreferences'; const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245']; const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)'; const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)'; const SERVER_MUTE_RED = 'color-mix(in oklab, hsl(1.343 calc(1*84.81%) 69.02% /1) 100%, #000 0%)'; const controlButtonStyle = { background: 'transparent', border: 'none', cursor: 'pointer', padding: '6px', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }; function getUserColor(name) { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } return USER_COLORS[Math.abs(hash) % USER_COLORS.length]; } function bytesToHex(bytes) { return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); } function randomHex(length) { const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); return bytesToHex(bytes); } const ColoredIcon = ({ src, color, size = '20px' }) => (
); const VoiceTimer = () => { const [elapsed, setElapsed] = React.useState(0); React.useEffect(() => { const start = Date.now(); const interval = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000); return () => clearInterval(interval); }, []); const hours = Math.floor(elapsed / 3600); const mins = Math.floor((elapsed % 3600) / 60); const secs = elapsed % 60; const time = hours > 0 ? `${hours}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}` : `${mins}:${String(secs).padStart(2, '0')}`; return {time}; }; const STATUS_OPTIONS = [ { value: 'online', label: 'Online', color: '#3ba55c' }, { value: 'idle', label: 'Idle', color: '#faa61a' }, { value: 'dnd', label: 'Do Not Disturb', color: '#ed4245' }, { value: 'invisible', label: 'Invisible', color: '#747f8d' }, ]; const UserControlPanel = ({ username, userId }) => { const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice(); const [showStatusMenu, setShowStatusMenu] = useState(false); const [showUserSettings, setShowUserSettings] = useState(false); const [currentStatus, setCurrentStatus] = useState('online'); const updateStatusMutation = useMutation(api.auth.updateStatus); const navigate = useNavigate(); const manualStatusRef = useRef(false); const preIdleStatusRef = useRef('online'); // Fetch stored status preference from server and sync local state const allUsers = useQuery(api.auth.getPublicKeys) || []; const myUser = allUsers.find(u => u.id === userId); React.useEffect(() => { if (myUser) { if (myUser.status && myUser.status !== 'offline') { setCurrentStatus(myUser.status); // dnd/invisible are manual overrides; idle is auto-set so don't count it manualStatusRef.current = (myUser.status === 'dnd' || myUser.status === 'invisible'); } else if (!myUser.status || myUser.status === 'offline') { // First login or no preference set yet — default to "online" setCurrentStatus('online'); manualStatusRef.current = false; if (userId) { updateStatusMutation({ userId, status: 'online' }).catch(() => {}); } } } }, [myUser?.status]); const handleLogout = async () => { // Disconnect voice if connected if (connectionState === 'connected') { try { disconnectVoice(); } catch {} } // Clear persisted session if (window.sessionPersistence) { try { await window.sessionPersistence.clear(); } catch {} } // Clear storage (preserve theme and user preferences) const theme = localStorage.getItem('theme'); const savedPrefs = {}; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key.startsWith('userPrefs_')) { savedPrefs[key] = localStorage.getItem(key); } } localStorage.clear(); if (theme) localStorage.setItem('theme', theme); for (const [key, value] of Object.entries(savedPrefs)) { localStorage.setItem(key, value); } sessionStorage.clear(); navigate('/'); }; const effectiveMute = isMuted || isDeafened; const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c'; const handleStatusChange = async (status) => { manualStatusRef.current = (status !== 'online'); setCurrentStatus(status); setShowStatusMenu(false); if (userId) { try { await updateStatusMutation({ userId, status }); } catch (e) { console.error('Failed to update status:', e); } } }; // Auto-idle detection via Electron powerMonitor useEffect(() => { if (!window.idleAPI || !userId) return; const handleIdleChange = (data) => { if (manualStatusRef.current) return; if (data.isIdle) { preIdleStatusRef.current = currentStatus; setCurrentStatus('idle'); updateStatusMutation({ userId, status: 'idle' }).catch(() => {}); } else { const restoreTo = preIdleStatusRef.current || 'online'; setCurrentStatus(restoreTo); updateStatusMutation({ userId, status: restoreTo }).catch(() => {}); } }; window.idleAPI.onIdleStateChanged(handleIdleChange); return () => window.idleAPI.removeIdleStateListener(); }, [userId]); return (
{showStatusMenu && (
{STATUS_OPTIONS.map(opt => (
handleStatusChange(opt.value)} >
{opt.label}
))}
)}
setShowStatusMenu(!showStatusMenu)}>
{username || 'Unknown'}
{STATUS_OPTIONS.find(s => s.value === currentStatus)?.label || 'Online'}
{showUserSettings && ( setShowUserSettings(false)} userId={userId} username={username} onLogout={handleLogout} /> )}
); }; const headerButtonStyle = { background: 'transparent', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: '18px', padding: '0 4px' }; const voicePanelButtonStyle = { flex: 1, alignItems: 'center', minHeight: '32px', background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)', border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)', borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', borderRadius: '8px', cursor: 'pointer', padding: '4px', display: 'flex', justifyContent: 'center' }; const liveBadgeStyle = { backgroundColor: '#ed4245', borderRadius: '8px', padding: '0 6px', textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', textAlign: 'center', height: '16px', minHeight: '16px', minWidth: '16px', color: 'hsl(0 calc(1*0%) 100% /1)', fontSize: '12px', fontWeight: '700', letterSpacing: '.02em', lineHeight: '1.3333333333333333', textTransform: 'uppercase', display: 'flex', alignItems: 'center', marginRight: '4px' }; const ACTIVE_SPEAKER_SHADOW = '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)'; const VOICE_ACTIVE_COLOR = "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)"; async function encryptKeyForUsers(convex, channelId, keyHex) { const users = await convex.query(api.auth.getPublicKeys, {}); const batchKeys = []; for (const u of users) { if (!u.public_identity_key) continue; try { const payload = JSON.stringify({ [channelId]: keyHex }); const encryptedKeyHex = await window.cryptoAPI.publicEncrypt(u.public_identity_key, payload); batchKeys.push({ channelId, userId: u.id, encryptedKeyBundle: encryptedKeyHex, keyVersion: 1 }); } catch (e) { console.error("Failed to encrypt for user", u.id, e); } } await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys }); } function getScreenCaptureConstraints(selection) { if (selection.type === 'device') { return { video: { deviceId: { exact: selection.deviceId } }, audio: false }; } return { audio: selection.shareAudio ? { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: selection.sourceId } } : false, video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: selection.sourceId } } }; } const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onMessage, isSelf, userVolume, onVolumeChange }) => { const menuRef = useRef(null); const [pos, setPos] = useState({ top: y, left: x }); useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]); useLayoutEffect(() => { if (!menuRef.current) return; const rect = menuRef.current.getBoundingClientRect(); let newTop = y, newLeft = x; if (x + rect.width > window.innerWidth) newLeft = x - rect.width; if (y + rect.height > window.innerHeight) newTop = y - rect.height; if (newLeft < 0) newLeft = 10; if (newTop < 0) newTop = 10; setPos({ top: newTop, left: newLeft }); }, [x, y]); const sliderPercent = (userVolume / 200) * 100; return (
e.stopPropagation()}> {!isSelf && ( <>
e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
User Volume {userVolume}%
onVolumeChange(Number(e.target.value))} className="context-menu-volume-slider" style={{ background: `linear-gradient(to right, hsl(235 86% 65%) ${sliderPercent}%, var(--bg-tertiary) ${sliderPercent}%)` }} />
)}
{ e.stopPropagation(); onMute(); }} > Mute
{isMuted ? ( ) : ( )}
{hasPermission && (
{ e.stopPropagation(); onServerMute(); }} > Server Mute
{isServerMuted ? ( ) : ( )}
)}
{ e.stopPropagation(); onMessage(); onClose(); }}> Message
); }; const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCategory }) => { const menuRef = useRef(null); const [pos, setPos] = useState({ top: y, left: x }); useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]); useLayoutEffect(() => { if (!menuRef.current) return; const rect = menuRef.current.getBoundingClientRect(); let newTop = y, newLeft = x; if (x + rect.width > window.innerWidth) newLeft = x - rect.width; if (y + rect.height > window.innerHeight) newTop = y - rect.height; if (newLeft < 0) newLeft = 10; if (newTop < 0) newTop = 10; setPos({ top: newTop, left: newLeft }); }, [x, y]); return (
e.stopPropagation()}>
{ e.stopPropagation(); onCreateChannel(); onClose(); }}> Create Channel
{ e.stopPropagation(); onCreateCategory(); onClose(); }}> Create Category
); }; const CreateChannelModal = ({ onClose, onSubmit, categoryId }) => { const [channelType, setChannelType] = useState('text'); const [channelName, setChannelName] = useState(''); const handleSubmit = () => { if (!channelName.trim()) return; onSubmit(channelName.trim(), channelType, categoryId); onClose(); }; return (
e.stopPropagation()}>

Create Channel

in Text Channels

setChannelType('text')} >
#
Text
Send messages, images, GIFs, emoji, opinions, and puns
{channelType === 'text' &&
}
setChannelType('voice')} >
Voice
Hang out together with voice, video, and screen share
{channelType === 'voice' &&
}
{channelType === 'text' ? '#' : '🔊'} setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'))} onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }} className="create-channel-name-input" />
); }; const CreateCategoryModal = ({ onClose, onSubmit }) => { const [categoryName, setCategoryName] = useState(''); const handleSubmit = () => { if (!categoryName.trim()) return; onSubmit(categoryName.trim()); onClose(); }; return (
e.stopPropagation()}>

Create Category

setCategoryName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }} className="create-channel-name-input" />
Private Category

By making a category private, only selected members and roles will be able to view this category. Synced channels will automatically match this category's permissions.

); }; // --- DnD wrapper components --- const SortableCategory = ({ id, children }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, data: { type: 'category' }, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
{React.Children.map(children, (child, i) => { // First child is the category header — attach drag listeners to it if (i === 0 && React.isValidElement(child)) { return React.cloneElement(child, { dragListeners: listeners }); } return child; })}
); }; const SortableChannel = ({ id, children }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, data: { type: 'channel' }, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
{typeof children === 'function' ? children(listeners) : children}
); }; const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: `voice-user-${userId}`, data: { type: 'voice-user', userId, channelId }, disabled, }); return (
{children}
); }; const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId }) => { const [isCreating, setIsCreating] = useState(false); const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); const [newChannelName, setNewChannelName] = useState(''); const [newChannelType, setNewChannelType] = useState('text'); const [editingChannel, setEditingChannel] = useState(null); const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false); const [collapsedCategories, setCollapsedCategories] = useState(() => { const effectiveUserId = userId || localStorage.getItem('userId'); return getUserPref(effectiveUserId, 'collapsedCategories', {}); }); useEffect(() => { if (userId) { setCollapsedCategories(getUserPref(userId, 'collapsedCategories', {})); } }, [userId]); const [channelListContextMenu, setChannelListContextMenu] = useState(null); const [voiceUserMenu, setVoiceUserMenu] = useState(null); const [showCreateChannelModal, setShowCreateChannelModal] = useState(false); const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false); const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null); const [activeDragItem, setActiveDragItem] = useState(null); const [dragOverChannelId, setDragOverChannelId] = useState(null); const convex = useConvex(); // Permissions for move_members gating const myPermissions = useQuery( api.roles.getMyPermissions, userId ? { userId } : "skip" ) || {}; // DnD sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ); // Unread tracking const channelIds = React.useMemo(() => [ ...channels.map(c => c._id), ...dmChannels.map(dm => dm.channel_id) ], [channels, dmChannels]); const allReadStates = useQuery( api.readState.getAllReadStates, userId ? { userId } : "skip" ) || []; const latestTimestamps = useQuery( api.readState.getLatestMessageTimestamps, channelIds.length > 0 ? { channelIds } : "skip" ) || []; const unreadChannels = React.useMemo(() => { const set = new Set(); const readMap = new Map(); for (const rs of allReadStates) { readMap.set(rs.channelId, rs.lastReadTimestamp); } for (const lt of latestTimestamps) { const lastRead = readMap.get(lt.channelId); if (lastRead === undefined || lt.latestTimestamp > lastRead) { set.add(lt.channelId); } } return set; }, [allReadStates, latestTimestamps]); const unreadDMs = React.useMemo(() => dmChannels.filter(dm => unreadChannels.has(dm.channel_id) && !(view === 'me' && activeDMChannel?.channel_id === dm.channel_id) ), [dmChannels, unreadChannels, view, activeDMChannel] ); const prevUnreadDMsRef = useRef(null); useEffect(() => { const currentIds = new Set( dmChannels.filter(dm => unreadChannels.has(dm.channel_id)).map(dm => dm.channel_id) ); if (prevUnreadDMsRef.current === null) { prevUnreadDMsRef.current = currentIds; return; } for (const id of currentIds) { if (!prevUnreadDMsRef.current.has(id)) { const audio = new Audio(PingSound); audio.volume = 0.5; audio.play().catch(() => {}); break; } } prevUnreadDMsRef.current = currentIds; }, [dmChannels, unreadChannels]); const onRenameChannel = () => {}; const onDeleteChannel = (id) => { if (activeChannel === id) onSelectChannel(null); }; const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, isServerMuted, serverSettings, getUserVolume, setUserVolume } = useVoice(); const handleStartCreate = () => { setIsCreating(true); setNewChannelName(''); setNewChannelType('text'); }; const handleSubmitCreate = async (e) => { if (e) e.preventDefault(); if (!newChannelName.trim()) { setIsCreating(false); return; } const name = newChannelName.trim(); const userId = localStorage.getItem('userId'); if (!userId) { alert("Please login first."); setIsCreating(false); return; } try { const { id: channelId } = await convex.mutation(api.channels.create, { name, type: newChannelType }); const keyHex = randomHex(32); try { await encryptKeyForUsers(convex, channelId, keyHex); } catch (keyErr) { console.error("Critical: Failed to distribute keys", keyErr); alert("Channel created but key distribution failed."); } } catch (err) { console.error(err); alert("Failed to create channel: " + err.message); } finally { setIsCreating(false); } }; const handleCreateInvite = async () => { const userId = localStorage.getItem('userId'); if (!userId) { alert("Error: No User ID found. Please login again."); return; } const generalChannel = channels.find(c => c.name === 'general'); const targetChannelId = generalChannel ? generalChannel._id : activeChannel; if (!targetChannelId) { alert("No channel selected."); return; } const targetKey = channelKeys?.[targetChannelId]; if (!targetKey) { alert("Error: You don't have the key for this channel yet, so you can't invite others."); return; } try { const inviteCode = crypto.randomUUID(); const inviteSecret = randomHex(32); const payload = JSON.stringify({ [targetChannelId]: targetKey }); const encrypted = await window.cryptoAPI.encryptData(payload, inviteSecret); const blob = JSON.stringify({ c: encrypted.content, t: encrypted.tag, iv: encrypted.iv }); await convex.mutation(api.invites.create, { code: inviteCode, encryptedPayload: blob, createdBy: userId, keyVersion: 1 }); const baseUrl = import.meta.env.VITE_APP_URL || window.location.origin; const link = `${baseUrl}/#/register?code=${inviteCode}&key=${inviteSecret}`; navigator.clipboard.writeText(link); alert(`Invite Link Copied to Clipboard!\n\n${link}`); } catch (e) { console.error("Invite Error:", e); alert("Failed to create invite. See console."); } }; const handleScreenShareSelect = async (selection) => { if (!room) return; try { if (room.localParticipant.isScreenShareEnabled) { await room.localParticipant.setScreenShareEnabled(false); } let stream; try { stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection)); } catch (audioErr) { // Audio capture may fail (e.g. macOS/Linux) — retry video-only if (selection.shareAudio) { console.warn("Audio capture failed, falling back to video-only:", audioErr.message); stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints({ ...selection, shareAudio: false })); } else { throw audioErr; } } const track = stream.getVideoTracks()[0]; if (!track) return; await room.localParticipant.publishTrack(track, { name: 'screen_share', source: Track.Source.ScreenShare }); // Publish audio track if present (system audio from desktop capture) const audioTrack = stream.getAudioTracks()[0]; if (audioTrack) { await room.localParticipant.publishTrack(audioTrack, { name: 'screen_share_audio', source: Track.Source.ScreenShareAudio }); } new Audio(screenShareStartSound).play(); setScreenSharing(true); track.onended = () => { // Clean up audio track when video track ends if (audioTrack) { audioTrack.stop(); room.localParticipant.unpublishTrack(audioTrack); } setScreenSharing(false); room.localParticipant.setScreenShareEnabled(false).catch(console.error); }; } catch (err) { console.error("Error sharing screen:", err); alert("Failed to share screen: " + err.message); } }; const handleScreenShareClick = () => { if (room?.localParticipant.isScreenShareEnabled) { // Clean up any screen share audio tracks before stopping for (const pub of room.localParticipant.trackPublications.values()) { const source = pub.source ? pub.source.toString().toLowerCase() : ''; const name = pub.trackName ? pub.trackName.toLowerCase() : ''; if (source === 'screen_share_audio' || name === 'screen_share_audio') { if (pub.track) pub.track.stop(); room.localParticipant.unpublishTrack(pub.track); } } room.localParticipant.setScreenShareEnabled(false); new Audio(screenShareStopSound).play(); setScreenSharing(false); } else { setIsScreenShareModalOpen(true); } }; const handleChannelClick = (channel) => { if (channel.type === 'voice' && voiceChannelId !== channel._id) { connectToVoice(channel._id, channel.name, localStorage.getItem('userId')); } else { onSelectChannel(channel._id); } }; const renderDMView = () => (
setActiveDMChannel(dm === 'friends' ? null : dm)} onOpenDM={onOpenDM} />
); const renderVoiceUsers = (channel) => { const users = voiceStates[channel._id]; if (channel.type !== 'voice' || !users?.length) return null; return (
{users.map(user => (
{ e.preventDefault(); e.stopPropagation(); setVoiceUserMenu({ x: e.clientX, y: e.clientY, user }); }} > {user.username}
{user.isScreenSharing &&
Live
} {user.isServerMuted ? ( ) : isPersonallyMuted(user.userId) ? ( ) : (user.isMuted || user.isDeafened) ? ( ) : null} {user.isDeafened && ( )}
))}
); }; const renderCollapsedVoiceUsers = (channel) => { const users = voiceStates[channel._id]; if (channel.type !== 'voice' || !users?.length) return null; return (
handleChannelClick(channel)} style={{ position: 'relative', display: 'flex', alignItems: 'center', paddingRight: '8px' }} >
{users.map(user => (
))}
); }; const toggleCategory = (cat) => { const next = { ...collapsedCategories, [cat]: !collapsedCategories[cat] }; setCollapsedCategories(next); setUserPref(userId, 'collapsedCategories', next); }; // Group channels by categoryId const groupedChannels = React.useMemo(() => { const groups = []; const channelsByCategory = new Map(); channels.forEach(ch => { const catId = ch.categoryId || '__uncategorized__'; if (!channelsByCategory.has(catId)) channelsByCategory.set(catId, []); channelsByCategory.get(catId).push(ch); }); // Sort channels within each category by position for (const [, list] of channelsByCategory) { list.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); } // Add uncategorized at top const uncategorized = channelsByCategory.get('__uncategorized__'); if (uncategorized?.length) { groups.push({ id: '__uncategorized__', name: 'Channels', channels: uncategorized }); } // Add categories in position order for (const cat of (categories || [])) { groups.push({ id: cat._id, name: cat.name, channels: channelsByCategory.get(cat._id) || [] }); } return groups; }, [channels, categories]); // DnD items const categoryDndIds = React.useMemo(() => groupedChannels.map(g => `category-${g.id}`), [groupedChannels]); const handleDragStart = (event) => { const { active } = event; const activeType = active.data.current?.type; if (activeType === 'category') { const catId = active.id.replace('category-', ''); const group = groupedChannels.find(g => g.id === catId); setActiveDragItem({ type: 'category', name: group?.name || '' }); } else if (activeType === 'channel') { const chId = active.id.replace('channel-', ''); const ch = channels.find(c => c._id === chId); setActiveDragItem({ type: 'channel', channel: ch }); } else if (activeType === 'voice-user') { const targetUserId = active.data.current.userId; const sourceChannelId = active.data.current.channelId; const users = voiceStates[sourceChannelId]; const user = users?.find(u => u.userId === targetUserId); setActiveDragItem({ type: 'voice-user', user, sourceChannelId }); } }; const handleDragOver = (event) => { const { active, over } = event; if (!active?.data.current || active.data.current.type !== 'voice-user') { setDragOverChannelId(null); return; } if (over) { // Check if hovering over a voice channel (channel item or its DnD wrapper) const overType = over.data.current?.type; if (overType === 'channel') { const chId = over.id.replace('channel-', ''); const ch = channels.find(c => c._id === chId); if (ch?.type === 'voice') { setDragOverChannelId(ch._id); return; } } } setDragOverChannelId(null); }; const handleDragEnd = async (event) => { setActiveDragItem(null); setDragOverChannelId(null); const { active, over } = event; if (!over || active.id === over.id) return; const activeType = active.data.current?.type; const overType = over.data.current?.type; // Handle voice-user drag if (activeType === 'voice-user') { if (overType !== 'channel') return; const targetChId = over.id.replace('channel-', ''); const targetChannel = channels.find(c => c._id === targetChId); if (!targetChannel || targetChannel.type !== 'voice') return; const sourceChannelId = active.data.current.channelId; if (sourceChannelId === targetChId) return; try { await convex.mutation(api.voiceState.moveUser, { actorUserId: userId, targetUserId: active.data.current.userId, targetChannelId: targetChId, }); } catch (e) { console.error('Failed to move voice user:', e); } return; } if (activeType === 'category' && overType === 'category') { // Reorder categories const oldIndex = groupedChannels.findIndex(g => `category-${g.id}` === active.id); const newIndex = groupedChannels.findIndex(g => `category-${g.id}` === over.id); if (oldIndex === -1 || newIndex === -1) return; // Build reordered array (only real categories, skip uncategorized) const reordered = [...groupedChannels]; const [moved] = reordered.splice(oldIndex, 1); reordered.splice(newIndex, 0, moved); const updates = reordered .filter(g => g.id !== '__uncategorized__') .map((g, i) => ({ id: g.id, position: i * 1000 })); if (updates.length > 0) { try { await convex.mutation(api.categories.reorder, { updates }); } catch (e) { console.error('Failed to reorder categories:', e); } } } else if (activeType === 'channel') { const activeChId = active.id.replace('channel-', ''); if (overType === 'channel') { const overChId = over.id.replace('channel-', ''); const activeChannel = channels.find(c => c._id === activeChId); const overChannel = channels.find(c => c._id === overChId); if (!activeChannel || !overChannel) return; const targetCategoryId = overChannel.categoryId; const targetGroup = groupedChannels.find(g => g.id === (targetCategoryId || '__uncategorized__')); if (!targetGroup) return; // Build new order for the target category const targetChannels = [...targetGroup.channels]; // Remove active channel if it's already in this category const existingIdx = targetChannels.findIndex(c => c._id === activeChId); if (existingIdx !== -1) targetChannels.splice(existingIdx, 1); // Insert at the position of the over channel const overIdx = targetChannels.findIndex(c => c._id === overChId); targetChannels.splice(overIdx, 0, activeChannel); const updates = targetChannels.map((ch, i) => ({ id: ch._id, categoryId: targetCategoryId, position: i * 1000, })); try { await convex.mutation(api.channels.reorderChannels, { updates }); } catch (e) { console.error('Failed to reorder channels:', e); } } else if (overType === 'category') { // Drop channel onto a category header — move it to end of that category const targetCatId = over.id.replace('category-', ''); const targetCategoryId = targetCatId === '__uncategorized__' ? undefined : targetCatId; const targetGroup = groupedChannels.find(g => g.id === targetCatId); const maxPos = (targetGroup?.channels || []).reduce((max, c) => Math.max(max, c.position ?? 0), -1000); try { await convex.mutation(api.channels.moveChannel, { id: activeChId, categoryId: targetCategoryId, position: maxPos + 1000, }); } catch (e) { console.error('Failed to move channel:', e); } } } }; const renderServerView = () => (
setIsServerSettingsOpen(true)}>Secure Chat
{ if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) { e.preventDefault(); setChannelListContextMenu({ x: e.clientX, y: e.clientY }); } }}> {isCreating && (
setNewChannelName(e.target.value)} style={{ width: '100%', background: 'var(--bg-tertiary)', border: '1px solid var(--brand-experiment)', borderRadius: '4px', color: 'var(--text-normal)', padding: '4px 8px', fontSize: '14px', outline: 'none' }} />
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
)} {groupedChannels.map(group => { const channelDndIds = group.channels.map(ch => `channel-${ch._id}`); return ( toggleCategory(group.id)} onAddChannel={() => { setCreateChannelCategoryId(group.id === '__uncategorized__' ? null : group.id); setShowCreateChannelModal(true); }} /> {(() => { const isCollapsed = collapsedCategories[group.id]; const visibleChannels = isCollapsed ? group.channels.filter(ch => ch._id === activeChannel || (ch.type === 'voice' && voiceStates[ch._id]?.length > 0) ) : group.channels; if (visibleChannels.length === 0) return null; const visibleDndIds = visibleChannels.map(ch => `channel-${ch._id}`); return ( {visibleChannels.map(channel => { const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id); return ( {(channelDragListeners) => ( {!(isCollapsed && channel.type === 'voice' && voiceStates[channel._id]?.length > 0) &&
handleChannelClick(channel)} {...channelDragListeners} style={{ position: 'relative', display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingRight: '8px' }} > {isUnread &&
}
{channel.type === 'voice' ? (
0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"} />
) : ( # )} {channel.name}{serverSettings?.afkChannelId === channel._id ? ' (AFK)' : ''}
} {isCollapsed ? renderCollapsedVoiceUsers(channel) : renderVoiceUsers(channel)} )} ); })} ); })()} ); })} {activeDragItem?.type === 'channel' && activeDragItem.channel && (
{activeDragItem.channel.type === 'voice' ? ( ) : ( # )} {activeDragItem.channel.name}
)} {activeDragItem?.type === 'category' && (
{activeDragItem.name}
)} {activeDragItem?.type === 'voice-user' && activeDragItem.user && (
{activeDragItem.user.username}
)}
); return (
onViewChange('me')} style={{ backgroundColor: view === 'me' ? 'var(--brand-experiment)' : 'var(--bg-primary)', color: view === 'me' ? '#fff' : 'var(--text-normal)', cursor: 'pointer' }} >
{unreadDMs.map(dm => (
{ setActiveDMChannel(dm); onViewChange('me'); }} >
))}
onViewChange('server')} style={{ cursor: 'pointer' }} >Sc
{view === 'me' ? renderDMView() : renderServerView()}
{connectionState === 'connected' && (
Voice Connected
{voiceChannelName} / Secure Chat
)} {editingChannel && ( setEditingChannel(null)} onRename={onRenameChannel} onDelete={onDeleteChannel} /> )} {isServerSettingsOpen && ( setIsServerSettingsOpen(false)} /> )} {isScreenShareModalOpen && ( setIsScreenShareModalOpen(false)} onSelectSource={handleScreenShareSelect} /> )} {channelListContextMenu && ( setChannelListContextMenu(null)} onCreateChannel={() => { setCreateChannelCategoryId(null); setShowCreateChannelModal(true); }} onCreateCategory={() => setShowCreateCategoryModal(true)} /> )} {voiceUserMenu && ( setVoiceUserMenu(null)} isSelf={voiceUserMenu.user.userId === userId} isMuted={voiceUserMenu.user.userId === userId ? selfMuted : isPersonallyMuted(voiceUserMenu.user.userId)} onMute={() => voiceUserMenu.user.userId === userId ? toggleMute() : togglePersonalMute(voiceUserMenu.user.userId)} isServerMuted={isServerMuted(voiceUserMenu.user.userId)} onServerMute={() => serverMute(voiceUserMenu.user.userId, !isServerMuted(voiceUserMenu.user.userId))} hasPermission={!!myPermissions.mute_members} onMessage={() => { onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username); onViewChange('me'); }} userVolume={getUserVolume(voiceUserMenu.user.userId)} onVolumeChange={(vol) => setUserVolume(voiceUserMenu.user.userId, vol)} /> )} {showCreateChannelModal && ( setShowCreateChannelModal(false)} onSubmit={async (name, type, catId) => { const userId = localStorage.getItem('userId'); if (!userId) { alert("Please login first."); return; } try { const createArgs = { name, type }; if (catId) createArgs.categoryId = catId; const { id: channelId } = await convex.mutation(api.channels.create, createArgs); const keyHex = randomHex(32); try { await encryptKeyForUsers(convex, channelId, keyHex); } catch (keyErr) { console.error("Critical: Failed to distribute keys", keyErr); alert("Channel created but key distribution failed."); } } catch (err) { console.error(err); alert("Failed to create channel: " + err.message); } }} /> )} {showCreateCategoryModal && ( setShowCreateCategoryModal(false)} onSubmit={async (name) => { try { await convex.mutation(api.categories.create, { name }); } catch (err) { console.error(err); alert("Failed to create category: " + err.message); } }} /> )}
); }; // Category header component (extracted for DnD drag handle) const CategoryHeader = ({ group, collapsed, onToggle, onAddChannel, dragListeners }) => (
{group.name}
); export default Sidebar;