Files
DiscordClone/Frontend/Electron/src/components/Sidebar.jsx
Bryan1029384756 56a9523e38
All checks were successful
Build and Release / build-and-release (push) Successful in 11m1s
feat: implement initial Electron chat application with core UI components and Convex backend integration.
2026-02-13 07:18:19 -06:00

1699 lines
80 KiB
JavaScript

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' }) => (
<div style={{
width: size,
height: size,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<img
src={src}
alt=""
style={{
width: size,
height: size,
transform: 'translateX(-1000px)',
filter: `drop-shadow(1000px 0 0 ${color})`
}}
/>
</div>
);
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 <span className="voice-timer">{time}</span>;
};
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 (
<div style={{
height: '64px',
margin: '0 8px 8px 8px',
borderRadius: connectionState === 'connected' ? '0px 0px 8px 8px' : '8px',
backgroundColor: 'var(--panel-bg)',
display: 'flex',
alignItems: 'center',
padding: '0 8px',
flexShrink: 0,
position: 'relative'
}}>
{showStatusMenu && (
<div className="status-menu">
{STATUS_OPTIONS.map(opt => (
<div
key={opt.value}
className="status-menu-item"
onClick={() => handleStatusChange(opt.value)}
>
<div className="status-menu-dot" style={{ backgroundColor: opt.color }} />
<span>{opt.label}</span>
</div>
))}
</div>
)}
<div className="user-control-info" onClick={() => setShowStatusMenu(!showStatusMenu)}>
<div style={{ position: 'relative', marginRight: '8px' }}>
<Avatar username={username} avatarUrl={myUser?.avatarUrl} size={32} />
<div style={{
position: 'absolute',
bottom: '-2px',
right: '-2px',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: statusColor,
border: '2px solid var(--panel-bg)',
cursor: 'pointer'
}} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ color: 'var(--header-primary)', fontWeight: '600', fontSize: '14px', lineHeight: '18px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{username || 'Unknown'}
</div>
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', lineHeight: '13px' }}>
{STATUS_OPTIONS.find(s => s.value === currentStatus)?.label || 'Online'}
</div>
</div>
</div>
<div style={{ display: 'flex' }}>
<Tooltip text={effectiveMute ? "Unmute" : "Mute"} position="top">
<button onClick={toggleMute} style={controlButtonStyle}>
<ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
</Tooltip>
<Tooltip text={isDeafened ? "Undeafen" : "Deafen"} position="top">
<button onClick={toggleDeafen} style={controlButtonStyle}>
<ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
</Tooltip>
<Tooltip text="User Settings" position="top">
<button style={controlButtonStyle} onClick={() => setShowUserSettings(true)}>
<ColoredIcon
src={settingsIcon}
color={ICON_COLOR_DEFAULT}
/>
</button>
</Tooltip>
</div>
{showUserSettings && (
<UserSettings
onClose={() => setShowUserSettings(false)}
userId={userId}
username={username}
onLogout={handleLogout}
/>
)}
</div>
);
};
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 (
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
{!isSelf && (
<>
<div className="context-menu-volume" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<div className="context-menu-volume-label">
<span>User Volume</span>
<span>{userVolume}%</span>
</div>
<input
type="range"
min="0"
max="200"
value={userVolume}
onChange={(e) => 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}%)` }}
/>
</div>
<div className="context-menu-separator" />
</>
)}
<div
className="context-menu-item context-menu-checkbox-item"
role="menuitemcheckbox"
aria-checked={isMuted}
onClick={(e) => { e.stopPropagation(); onMute(); }}
>
<span>Mute</span>
<div className="context-menu-checkbox">
<div className={`context-menu-checkbox-indicator ${isMuted ? 'checked' : ''}`}>
{isMuted ? (
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="white" d="M8.99991 16.17L4.82991 12L3.40991 13.41L8.99991 19L20.9999 7L19.5899 5.59L8.99991 16.17Z" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10">
</svg>
)}
</div>
</div>
</div>
{hasPermission && (
<div
className="context-menu-item context-menu-checkbox-item"
role="menuitemcheckbox"
aria-checked={isServerMuted}
onClick={(e) => { e.stopPropagation(); onServerMute(); }}
>
<span style={{ color: SERVER_MUTE_RED }}>Server Mute</span>
<div className="context-menu-checkbox">
<div
className={`context-menu-checkbox-indicator ${isServerMuted ? 'checked' : ''}`}
style={isServerMuted ? { backgroundColor: SERVER_MUTE_RED, borderColor: SERVER_MUTE_RED } : {}}
>
{isServerMuted ? (
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="white" d="M8.99991 16.17L4.82991 12L3.40991 13.41L8.99991 19L20.9999 7L19.5899 5.59L8.99991 16.17Z" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10">
</svg>
)}
</div>
</div>
</div>
)}
<div className="context-menu-separator" />
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}>
<span>Message</span>
</div>
</div>
);
};
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 (
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onCreateChannel(); onClose(); }}>
<span>Create Channel</span>
</div>
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onCreateCategory(); onClose(); }}>
<span>Create Category</span>
</div>
</div>
);
};
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 (
<div className="create-channel-modal-overlay" onClick={onClose}>
<div className="create-channel-modal" onClick={(e) => e.stopPropagation()}>
<div className="create-channel-modal-header">
<div>
<h2 style={{ margin: 0, color: 'var(--header-primary)', fontSize: '20px', fontWeight: 700 }}>Create Channel</h2>
<p style={{ margin: '4px 0 0', color: 'var(--header-secondary)', fontSize: '12px' }}>in Text Channels</p>
</div>
<button className="create-channel-modal-close" onClick={onClose}>
<svg width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
<div style={{ padding: '0 16px' }}>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: 700, textTransform: 'uppercase', marginBottom: '8px' }}>Channel Type</label>
<div
className={`channel-type-option ${channelType === 'text' ? 'selected' : ''}`}
onClick={() => setChannelType('text')}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
<span style={{ fontSize: '24px', color: 'var(--interactive-normal)', width: '24px', textAlign: 'center' }}>#</span>
<div>
<div style={{ color: 'var(--header-primary)', fontWeight: 500, fontSize: '16px' }}>Text</div>
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', marginTop: '2px' }}>Send messages, images, GIFs, emoji, opinions, and puns</div>
</div>
</div>
<div className={`channel-type-radio ${channelType === 'text' ? 'selected' : ''}`}>
{channelType === 'text' && <div className="channel-type-radio-dot" />}
</div>
</div>
<div
className={`channel-type-option ${channelType === 'voice' ? 'selected' : ''}`}
onClick={() => setChannelType('voice')}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
<svg width="24" height="24" viewBox="0 0 24 24" style={{ flexShrink: 0, color: 'var(--interactive-normal)' }}>
<path fill="currentColor" d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
</svg>
<div>
<div style={{ color: 'var(--header-primary)', fontWeight: 500, fontSize: '16px' }}>Voice</div>
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', marginTop: '2px' }}>Hang out together with voice, video, and screen share</div>
</div>
</div>
<div className={`channel-type-radio ${channelType === 'voice' ? 'selected' : ''}`}>
{channelType === 'voice' && <div className="channel-type-radio-dot" />}
</div>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: 700, textTransform: 'uppercase', marginBottom: '8px' }}>Channel Name</label>
<div className="create-channel-name-input-wrapper">
<span style={{ color: 'var(--interactive-normal)', fontSize: '16px', marginRight: '4px' }}>
{channelType === 'text' ? '#' : '🔊'}
</span>
<input
autoFocus
type="text"
placeholder="new-channel"
value={channelName}
onChange={(e) => setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
className="create-channel-name-input"
/>
</div>
</div>
</div>
<div className="create-channel-modal-footer">
<button className="create-channel-cancel-btn" onClick={onClose}>Cancel</button>
<button
className="create-channel-submit-btn"
onClick={handleSubmit}
disabled={!channelName.trim()}
>
Create Channel
</button>
</div>
</div>
</div>
);
};
const CreateCategoryModal = ({ onClose, onSubmit }) => {
const [categoryName, setCategoryName] = useState('');
const handleSubmit = () => {
if (!categoryName.trim()) return;
onSubmit(categoryName.trim());
onClose();
};
return (
<div className="create-channel-modal-overlay" onClick={onClose}>
<div className="create-channel-modal" onClick={(e) => e.stopPropagation()}>
<div className="create-channel-modal-header">
<h2 style={{ margin: 0, color: 'var(--header-primary)', fontSize: '20px', fontWeight: 700 }}>Create Category</h2>
<button className="create-channel-modal-close" onClick={onClose}>
<svg width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
<div style={{ padding: '0 16px' }}>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: 700, textTransform: 'uppercase', marginBottom: '8px' }}>Category Name</label>
<div className="create-channel-name-input-wrapper">
<input
autoFocus
type="text"
placeholder="New Category"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
className="create-channel-name-input"
/>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<svg width="16" height="16" viewBox="0 0 24 24" style={{ color: 'var(--interactive-normal)' }}>
<path fill="currentColor" d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
</svg>
<span style={{ color: 'var(--header-primary)', fontSize: '14px', fontWeight: 500 }}>Private Category</span>
</div>
<div className="category-toggle-switch">
<div className="category-toggle-knob" />
</div>
</div>
<p style={{ color: 'var(--header-secondary)', fontSize: '12px', margin: '0 0 16px', lineHeight: '16px' }}>
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.
</p>
</div>
<div className="create-channel-modal-footer">
<button className="create-channel-cancel-btn" onClick={onClose}>Cancel</button>
<button
className="create-channel-submit-btn"
onClick={handleSubmit}
disabled={!categoryName.trim()}
>
Create Category
</button>
</div>
</div>
</div>
);
};
// --- 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 (
<div ref={setNodeRef} style={style} {...attributes}>
{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;
})}
</div>
);
};
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 (
<div ref={setNodeRef} style={style} {...attributes}>
{typeof children === 'function' ? children(listeners) : children}
</div>
);
};
const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `voice-user-${userId}`,
data: { type: 'voice-user', userId, channelId },
disabled,
});
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
opacity: isDragging ? 0.4 : 1,
cursor: disabled ? 'default' : 'grab',
}}
>
{children}
</div>
);
};
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 = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<DMList
dmChannels={dmChannels}
activeDMChannel={activeDMChannel}
onSelectDM={(dm) => setActiveDMChannel(dm === 'friends' ? null : dm)}
onOpenDM={onOpenDM}
/>
</div>
);
const renderVoiceUsers = (channel) => {
const users = voiceStates[channel._id];
if (channel.type !== 'voice' || !users?.length) return null;
return (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{users.map(user => (
<DraggableVoiceUser
key={user.userId}
userId={user.userId}
channelId={channel._id}
disabled={!myPermissions.move_members}
>
<div
className="voice-user-item"
style={{ display: 'flex', alignItems: 'center', marginBottom: 2 }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setVoiceUserMenu({ x: e.clientX, y: e.clientY, user });
}}
>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={24}
style={{
marginRight: 8,
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
}}
/>
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{user.isServerMuted ? (
<ColoredIcon src={serverMuteIcon} color={SERVER_MUTE_RED} size="14px" />
) : isPersonallyMuted(user.userId) ? (
<ColoredIcon src={personalMuteIcon} color="var(--header-secondary)" size="14px" />
) : (user.isMuted || user.isDeafened) ? (
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
) : null}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
)}
</div>
</div>
</DraggableVoiceUser>
))}
</div>
);
};
const renderCollapsedVoiceUsers = (channel) => {
const users = voiceStates[channel._id];
if (channel.type !== 'voice' || !users?.length) return null;
return (
<div
className={`channel-item ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => handleChannelClick(channel)}
style={{ position: 'relative', display: 'flex', alignItems: 'center', paddingRight: '8px' }}
>
<div style={{ marginRight: 6 }}>
<ColoredIcon src={voiceIcon} size="16px" color={VOICE_ACTIVE_COLOR} />
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
{users.map(user => (
<div key={user.userId} style={{ marginRight: -6, position: 'relative', zIndex: 1 }}>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={24}
style={{
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none',
borderRadius: '50%',
}}
/>
</div>
))}
</div>
</div>
);
};
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 = () => (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}>
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>Secure Chat</span>
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
<img src={inviteUserIcon} alt="Invite" />
</button>
</div>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={(e) => {
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
e.preventDefault();
setChannelListContextMenu({ x: e.clientX, y: e.clientY });
}
}}>
{isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}>
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<label style={{ color: newChannelType==='text'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
Text
</label>
<label style={{ color: newChannelType==='voice'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
Voice
</label>
</div>
<input
autoFocus
type="text"
placeholder={`new-${newChannelType}-channel`}
value={newChannelName}
onChange={(e) => 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'
}}
/>
</form>
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2, textAlign: 'right' }}>
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
</div>
</div>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
{groupedChannels.map(group => {
const channelDndIds = group.channels.map(ch => `channel-${ch._id}`);
return (
<SortableCategory key={group.id} id={`category-${group.id}`}>
<CategoryHeader
group={group}
collapsed={collapsedCategories[group.id]}
onToggle={() => 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 (
<SortableContext items={visibleDndIds} strategy={verticalListSortingStrategy}>
{visibleChannels.map(channel => {
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
return (
<SortableChannel key={channel._id} id={`channel-${channel._id}`}>
{(channelDragListeners) => (
<React.Fragment>
{!(isCollapsed && channel.type === 'voice' && voiceStates[channel._id]?.length > 0) && <div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''} ${dragOverChannelId === channel._id ? 'voice-drop-target' : ''}`}
onClick={() => handleChannelClick(channel)}
{...channelDragListeners}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px'
}}
>
{isUnread && <div className="channel-unread-indicator" />}
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
{channel.type === 'voice' ? (
<div style={{ marginRight: 6 }}>
<ColoredIcon
src={voiceIcon}
size="16px"
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
/>
</div>
) : (
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
)}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>
{channel.name}{serverSettings?.afkChannelId === channel._id ? ' (AFK)' : ''}
</span>
</div>
<button
className="channel-settings-icon"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel);
}}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
display: 'flex', alignItems: 'center',
}}
>
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
</button>
</div>}
{isCollapsed
? renderCollapsedVoiceUsers(channel)
: renderVoiceUsers(channel)}
</React.Fragment>
)}
</SortableChannel>
);
})}
</SortableContext>
);
})()}
</SortableCategory>
);
})}
</SortableContext>
<DragOverlay>
{activeDragItem?.type === 'channel' && activeDragItem.channel && (
<div className="drag-overlay-channel">
{activeDragItem.channel.type === 'voice' ? (
<ColoredIcon src={voiceIcon} size="16px" color="var(--interactive-normal)" />
) : (
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px' }}>#</span>
)}
<span>{activeDragItem.channel.name}</span>
</div>
)}
{activeDragItem?.type === 'category' && (
<div className="drag-overlay-category">
{activeDragItem.name}
</div>
)}
{activeDragItem?.type === 'voice-user' && activeDragItem.user && (
<div className="drag-overlay-voice-user">
<Avatar
username={activeDragItem.user.username}
avatarUrl={activeDragItem.user.avatarUrl}
size={24}
style={{ marginRight: 8 }}
/>
<span>{activeDragItem.user.username}</span>
</div>
)}
</DragOverlay>
</DndContext>
</div>
</div>
);
return (
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list">
<div className="server-item-wrapper">
<div className={`server-pill ${view === 'me' ? 'active' : ''}`} />
<Tooltip text="Direct Messages" position="right">
<div
className={`server-icon ${view === 'me' ? 'active' : ''}`}
onClick={() => onViewChange('me')}
style={{
backgroundColor: view === 'me' ? 'var(--brand-experiment)' : 'var(--bg-primary)',
color: view === 'me' ? '#fff' : 'var(--text-normal)',
cursor: 'pointer'
}}
>
<svg width="28" height="20" viewBox="0 0 28 20">
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
</svg>
</div>
</Tooltip>
</div>
{unreadDMs.map(dm => (
<div key={dm.channel_id} className="server-item-wrapper">
<div className={`server-pill ${
view === 'me' && activeDMChannel?.channel_id === dm.channel_id ? 'active' : ''
}`} />
<Tooltip text={dm.other_username} position="right">
<div
className={`server-icon dm-server-icon ${
view === 'me' && activeDMChannel?.channel_id === dm.channel_id ? 'active' : ''
}`}
onClick={() => {
setActiveDMChannel(dm);
onViewChange('me');
}}
>
<Avatar
username={dm.other_username}
avatarUrl={dm.other_user_avatar_url}
size={48}
/>
<div className="dm-notification-dot" />
</div>
</Tooltip>
</div>
))}
<div className="server-separator" />
<div className="server-item-wrapper">
<div className={`server-pill ${view === 'server' ? 'active' : ''}`} />
<Tooltip text="Secure Chat" position="right">
<div
className={`server-icon ${view === 'server' ? 'active' : ''}`}
onClick={() => onViewChange('server')}
style={{ cursor: 'pointer' }}
>Sc</div>
</Tooltip>
</div>
</div>
{view === 'me' ? renderDMView() : renderServerView()}
</div>
{connectionState === 'connected' && (
<div style={{
backgroundColor: 'var(--panel-bg)',
borderRadius: '8px 8px 0px 0px',
padding: 'calc(16px - 8px + 4px)',
margin: '8px 8px 0px 8px',
display: 'flex',
flexDirection: 'column',
borderBottom: "1px solid color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)"
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ color: '#43b581', fontWeight: 'bold', fontSize: 13 }}>Voice Connected</div>
<button
onClick={disconnectVoice}
title="Disconnect"
style={{
background: 'transparent', border: 'none', cursor: 'pointer', padding: '0', display: 'flex', justifyContent: 'center'
}}
>
<ColoredIcon src={disconnectIcon} color="var(--header-secondary)" size="20px" />
</button>
</div>
<div style={{ color: 'var(--text-normal)', fontSize: 12, marginBottom: 4 }}>{voiceChannelName} / Secure Chat</div>
<div style={{ marginBottom: 8 }}><VoiceTimer /></div>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
<ColoredIcon src={cameraIcon} color="var(--header-secondary)" size="20px" />
</button>
<button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
<ColoredIcon src={screenIcon} color="var(--header-secondary)" size="20px" />
</button>
</div>
</div>
)}
<UserControlPanel username={username} userId={userId} />
{editingChannel && (
<ChannelSettingsModal
channel={editingChannel}
onClose={() => setEditingChannel(null)}
onRename={onRenameChannel}
onDelete={onDeleteChannel}
/>
)}
{isServerSettingsOpen && (
<ServerSettingsModal onClose={() => setIsServerSettingsOpen(false)} />
)}
{isScreenShareModalOpen && (
<ScreenShareModal
onClose={() => setIsScreenShareModalOpen(false)}
onSelectSource={handleScreenShareSelect}
/>
)}
{channelListContextMenu && (
<ChannelListContextMenu
x={channelListContextMenu.x}
y={channelListContextMenu.y}
onClose={() => setChannelListContextMenu(null)}
onCreateChannel={() => {
setCreateChannelCategoryId(null);
setShowCreateChannelModal(true);
}}
onCreateCategory={() => setShowCreateCategoryModal(true)}
/>
)}
{voiceUserMenu && (
<VoiceUserContextMenu
x={voiceUserMenu.x}
y={voiceUserMenu.y}
user={voiceUserMenu.user}
onClose={() => 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 && (
<CreateChannelModal
categoryId={createChannelCategoryId}
onClose={() => 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 && (
<CreateCategoryModal
onClose={() => 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);
}
}}
/>
)}
</div>
);
};
// Category header component (extracted for DnD drag handle)
const CategoryHeader = ({ group, collapsed, onToggle, onAddChannel, dragListeners }) => (
<div className="channel-category-header" onClick={onToggle} {...(dragListeners || {})}>
<span className="category-label">{group.name}</span>
<div className={`category-chevron ${collapsed ? 'collapsed' : ''}`}>
<ColoredIcon src={categoryCollapsedIcon} color="currentColor" size="12px" />
</div>
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); onAddChannel(); }} title="Create Channel">
+
</button>
</div>
);
export default Sidebar;