feat: Add initial Discord clone application with Convex backend services and Electron React frontend components.

This commit is contained in:
Bryan1029384756
2026-02-10 05:27:10 -06:00
parent 47f173c79b
commit 34e9790db9
29 changed files with 3254 additions and 1398 deletions

View File

@@ -17,7 +17,40 @@ import disconnectIcon from '../assets/icons/disconnect.svg';
import cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.svg';
// Helper Component for coloring SVGs
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 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,
@@ -46,18 +79,6 @@ const UserControlPanel = ({ username }) => {
const effectiveMute = isMuted || isDeafened;
const getUserColor = (name) => {
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
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)';
return (
<div style={{
height: '64px',
@@ -94,7 +115,6 @@ const UserControlPanel = ({ username }) => {
}}>
{(username || '?').substring(0, 1).toUpperCase()}
</div>
{/* Status Indicator */}
<div style={{
position: 'absolute',
bottom: '-2px',
@@ -118,57 +138,19 @@ const UserControlPanel = ({ username }) => {
{/* Controls */}
<div style={{ display: 'flex' }}>
<button
onClick={toggleMute}
title={effectiveMute ? "Unmute" : "Mute"}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button onClick={toggleMute} title={effectiveMute ? "Unmute" : "Mute"} style={controlButtonStyle}>
<ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
onClick={toggleDeafen}
title={isDeafened ? "Undeafen" : "Deafen"}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button onClick={toggleDeafen} title={isDeafened ? "Undeafen" : "Deafen"} style={controlButtonStyle}>
<ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
title="User Settings"
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button title="User Settings" style={controlButtonStyle}>
<ColoredIcon
src={settingsIcon}
color={ICON_COLOR_DEFAULT}
@@ -181,6 +163,92 @@ const UserControlPanel = ({ username }) => {
const headerButtonStyle = {
background: 'transparent',
border: 'none',
color: '#b9bbbe',
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: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selection.sourceId
}
}
};
}
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
@@ -191,14 +259,10 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const convex = useConvex();
// Callbacks for Modal - Convex is reactive, no need to manually refresh
const onRenameChannel = (id, newName) => {
// Convex reactive queries auto-update
};
const onRenameChannel = () => {};
const onDeleteChannel = (id) => {
if (activeChannel === id) onSelectChannel(null);
// Convex reactive queries auto-update
};
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
@@ -211,13 +275,13 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const handleSubmitCreate = async (e) => {
if (e) e.preventDefault();
if (!newChannelName.trim()) {
setIsCreating(false);
return;
}
const name = newChannelName.trim();
const type = newChannelType;
const userId = localStorage.getItem('userId');
if (!userId) {
@@ -227,54 +291,19 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
try {
// 1. Create Channel via Convex
const { id: channelId } = await convex.mutation(api.channels.create, { name, type });
const { id: channelId } = await convex.mutation(api.channels.create, { name, type: newChannelType });
const keyHex = randomHex(32);
// 2. Generate Key
const keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
// 3. Encrypt Key for ALL Users
try {
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);
}
}
// 4. Upload Keys Batch via Convex
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
// No need to notify - Convex queries are reactive!
await encryptKeyForUsers(convex, channelId, keyHex);
} catch (keyErr) {
console.error("Critical: Failed to distribute keys", keyErr);
alert("Channel created but key distribution failed.");
}
// 5. Done - Convex reactive queries auto-update the channel list
setIsCreating(false);
} catch (err) {
console.error(err);
alert("Failed to create channel: " + err.message);
} finally {
setIsCreating(false);
}
};
@@ -286,43 +315,29 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
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 {
// 1. Generate Invite Code & Secret
const inviteCode = crypto.randomUUID();
const inviteSecretBytes = new Uint8Array(32);
crypto.getRandomValues(inviteSecretBytes);
const inviteSecret = Array.from(inviteSecretBytes).map(b => b.toString(16).padStart(2, '0')).join('');
const inviteSecret = randomHex(32);
// 2. Prepare Key Bundle
const generalChannel = channels.find(c => c.name === 'general');
const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
if (!targetChannelId) {
alert("No channel selected.");
return;
}
const targetKey = channelKeys ? channelKeys[targetChannelId] : null;
if (!targetKey) {
alert("Error: You don't have the key for this channel yet, so you can't invite others.");
return;
}
const payload = JSON.stringify({
[targetChannelId]: targetKey
});
// 3. Encrypt Payload
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 });
const blob = JSON.stringify({
c: encrypted.content,
t: encrypted.tag,
iv: encrypted.iv
});
// 4. Create invite via Convex
await convex.mutation(api.invites.create, {
code: inviteCode,
encryptedPayload: blob,
@@ -330,85 +345,219 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
keyVersion: 1
});
// 5. Show Link
const link = `http://localhost:5173/#/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.");
}
};
// Screen Share Handler
const handleScreenShareSelect = async (selection) => {
if (!room) return;
try {
// Unpublish existing screen share if any
if (room.localParticipant.isScreenShareEnabled) {
await room.localParticipant.setScreenShareEnabled(false);
}
// Capture based on selection
let stream;
if (selection.type === 'device') {
stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: selection.deviceId } },
audio: false
});
} else {
// Electron Screen/Window
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selection.sourceId
}
}
});
}
// Publish the video track
const stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection));
const track = stream.getVideoTracks()[0];
if (track) {
await room.localParticipant.publishTrack(track, {
name: 'screen_share',
source: Track.Source.ScreenShare
});
if (!track) return;
setScreenSharing(true);
await room.localParticipant.publishTrack(track, {
name: 'screen_share',
source: Track.Source.ScreenShare
});
track.onended = () => {
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
};
}
setScreenSharing(true);
track.onended = () => {
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
};
} catch (err) {
console.error("Error sharing screen:", err);
alert("Failed to share screen: " + err.message);
}
};
// Toggle Modal instead of direct toggle
const handleScreenShareClick = () => {
if (room?.localParticipant.isScreenShareEnabled) {
room.localParticipant.setScreenShareEnabled(false);
setScreenSharing(false);
} else {
setIsScreenShareModalOpen(true);
}
if (room?.localParticipant.isScreenShareEnabled) {
room.localParticipant.setScreenShareEnabled(false);
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' }}>
<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 => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
backgroundColor: '#5865F2',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 8, fontSize: 10, color: 'white',
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
}}>
{user.username.substring(0, 1).toUpperCase()}
</div>
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
)}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
)}
</div>
</div>
))}
</div>
);
};
const renderServerView = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}
onClick={() => setIsServerSettingsOpen(true)}
title="Server Settings"
>
Secure Chat
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={handleStartCreate} title="Create New Channel" style={{ ...headerButtonStyle, marginRight: '4px' }}>
+
</button>
<button onClick={handleCreateInvite} title="Create Invite Link" style={headerButtonStyle}>
🔗
</button>
</div>
</div>
{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':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
Text
</label>
<label style={{ color: newChannelType==='voice'?'white':'#b9bbbe', 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: '#202225',
border: '1px solid #7289da',
borderRadius: '4px',
color: '#dcddde',
padding: '4px 8px',
fontSize: '14px',
outline: 'none'
}}
/>
</form>
<div style={{ fontSize: 10, color: '#b9bbbe', marginTop: 2, textAlign: 'right' }}>
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
</div>
</div>
)}
{channels.map(channel => (
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => handleChannelClick(channel)}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px'
}}
>
<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 : "#8e9297"}
/>
</div>
) : (
<span style={{ color: '#8e9297', marginRight: '6px', flexShrink: 0 }}>#</span>
)}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
</div>
<button
className="channel-settings-icon"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel);
}}
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '12px',
padding: '2px 4px',
display: 'flex', alignItems: 'center',
opacity: '0.7',
transition: 'opacity 0.2s'
}}
>
</button>
</div>
{renderVoiceUsers(channel)}
</React.Fragment>
))}
</div>
);
return (
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list">
{/* Home Button */}
<div
className={`server-icon ${view === 'me' ? 'active' : ''}`}
onClick={() => onViewChange('me')}
@@ -424,7 +573,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</svg>
</div>
{/* The Server Icon (Secure Chat) */}
<div
className={`server-icon ${view === 'server' ? 'active' : ''}`}
onClick={() => onViewChange('server')}
@@ -432,222 +580,9 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
>Sc</div>
</div>
{/* Channel List Area */}
{view === 'me' ? (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<DMList
dmChannels={dmChannels}
activeDMChannel={activeDMChannel}
onSelectDM={(dm) => {
if (dm === 'friends') {
setActiveDMChannel(null);
} else {
setActiveDMChannel(dm);
}
}}
onOpenDM={onOpenDM}
/>
</div>
) : (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}
onClick={() => setIsServerSettingsOpen(true)}
title="Server Settings"
>
Secure Chat
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleStartCreate}
title="Create New Channel"
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px',
marginRight: '4px'
}}
>
+
</button>
<button
onClick={handleCreateInvite}
title="Create Invite Link"
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px'
}}
>
🔗
</button>
</div>
</div>
{/* Inline Create Channel Input */}
{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':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
Text
</label>
<label style={{ color: newChannelType==='voice'?'white':'#b9bbbe', 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: '#202225',
border: '1px solid #7289da',
borderRadius: '4px',
color: '#dcddde',
padding: '4px 8px',
fontSize: '14px',
outline: 'none'
}}
/>
</form>
<div style={{ fontSize: 10, color: '#b9bbbe', marginTop: 2, textAlign: 'right' }}>
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
</div>
</div>
)}
{channels.map(channel => (
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => {
if (channel.type === 'voice') {
if (voiceChannelId === channel._id) {
onSelectChannel(channel._id);
} else {
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
}
} else {
onSelectChannel(channel._id);
}
}}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px'
}}
>
<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
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)"
: "#8e9297"
}
/>
</div>
) : (
<span style={{ color: '#8e9297', marginRight: '6px', flexShrink: 0 }}>#</span>
)}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
</div>
<button
className="channel-settings-icon"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel);
}}
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '12px',
padding: '2px 4px',
display: 'flex', alignItems: 'center',
opacity: '0.7',
transition: 'opacity 0.2s'
}}
>
</button>
</div>
{channel.type === 'voice' && voiceStates[channel._id] && voiceStates[channel._id].length > 0 && (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{voiceStates[channel._id].map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
backgroundColor: '#5865F2',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 8, fontSize: 10, color: 'white',
boxShadow: activeSpeakers.has(user.userId)
? '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%)'
: 'none'
}}>
{user.username.substring(0, 1).toUpperCase()}
</div>
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && (
<div style={{
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'
}}>
Live
</div>
)}
{(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
)}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
)}
</div>
</div>
))}
</div>
)}
</React.Fragment>
))}
</div>
)}
{view === 'me' ? renderDMView() : renderServerView()}
</div>
{/* Voice Connection Panel */}
{connectionState === 'connected' && (
<div style={{
backgroundColor: '#292b2f',
@@ -672,32 +607,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div>
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)}
title="Turn On Camera"
style={{
flex: 1, alignItems: 'center', minHeight: '32px', padding: "calc(var(--space-8) - 1px) calc(var(--space-16) - 1px)", 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'
}}
>
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
</button>
<button
onClick={handleScreenShareClick}
title="Share Screen"
style={{
flex: 1, alignItems: 'center', minHeight: '32px', padding: "calc(var(--space-8) - 1px) calc(var(--space-16) - 1px)", 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'
}}
>
<button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
<ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" />
</button>
</div>
</div>
)}
{/* User Control Panel at Bottom, Spanning Full Width */}
<UserControlPanel username={username} />
{/* Modals */}
{editingChannel && (
<ChannelSettingsModal
channel={editingChannel}