All checks were successful
Build and Release / build-and-release (push) Successful in 11m1s
1699 lines
80 KiB
JavaScript
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;
|