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'}
setShowUserSettings(true)}>
{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
{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
Channel Type
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' &&
}
Cancel
Create Channel
);
};
const CreateCategoryModal = ({ onClose, onSubmit }) => {
const [categoryName, setCategoryName] = useState('');
const handleSubmit = () => {
if (!categoryName.trim()) return;
onSubmit(categoryName.trim());
onClose();
};
return (
e.stopPropagation()}>
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.
Cancel
Create Category
);
};
// --- 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, serverName = 'Secure Chat', serverIconUrl }) => {
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' }}
>
);
};
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)}>{serverName}
{
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
e.preventDefault();
setChannelListContextMenu({ x: e.clientX, y: e.clientY });
}
}}>
{isCreating && (
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)' : ''}
{
e.stopPropagation();
setEditingChannel(channel);
}}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
display: 'flex', alignItems: 'center',
}}
>
}
{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' }}
>
{serverIconUrl ? (
) : (
serverName.substring(0, 2)
)}
{view === 'me' ? renderDMView() : renderServerView()}
{connectionState === 'connected' && (
{voiceChannelName} / {serverName}
room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
)}
{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}
{ e.stopPropagation(); onAddChannel(); }} title="Create Channel">
+
);
export default Sidebar;