feat: Add a large collection of emoji and other frontend assets, including a sound file, and a backend package.json.
This commit is contained in:
@@ -1,25 +1,746 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import ChannelSettingsModal from './ChannelSettingsModal';
|
||||
import ServerSettingsModal from './ServerSettingsModal';
|
||||
import ScreenShareModal from './ScreenShareModal';
|
||||
import DMList from './DMList'; // Import DMList
|
||||
import { Track } from 'livekit-client';
|
||||
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';
|
||||
|
||||
// Helper Component for coloring SVGs
|
||||
const ColoredIcon = ({ src, color, size = '20px' }) => (
|
||||
<div style={{
|
||||
width: size,
|
||||
height: size,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0 // Prevent shrinking in flex containers
|
||||
}}>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
transform: 'translateX(-1000px)',
|
||||
filter: `drop-shadow(1000px 0 0 ${color})`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const UserControlPanel = ({ username }) => {
|
||||
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
|
||||
|
||||
// Check if muted explicitly OR implicitly via deafen
|
||||
// User requested: "turn the mic icon red to show they are muted also"
|
||||
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)';
|
||||
|
||||
const Sidebar = ({ channels, activeChannel, onSelectChannel }) => {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="server-list">
|
||||
<div className="server-icon active">H</div>
|
||||
</div>
|
||||
<div className="channel-list">
|
||||
<div className="channel-header">Secure Chat</div>
|
||||
{channels.map(channel => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={`channel-item ${activeChannel === channel.id ? 'active' : ''}`}
|
||||
onClick={() => onSelectChannel(channel.id)}
|
||||
>
|
||||
# {channel.name}
|
||||
<div style={{
|
||||
height: '64px',
|
||||
margin: '0 8px 8px 8px',
|
||||
borderRadius: connectionState === 'connected' ? '0px 0px 8px 8px' : '8px',
|
||||
backgroundColor: '#292b2f',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{/* User Info */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginRight: 'auto',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' }
|
||||
}}>
|
||||
<div style={{ position: 'relative', marginRight: '8px' }}>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getUserColor(username || 'U'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
{(username || '?').substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{/* Status Indicator */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '-2px',
|
||||
right: '-2px',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#3ba55c',
|
||||
border: '2px solid #292b2f'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ color: 'white', fontWeight: '600', fontSize: '14px', lineHeight: '18px' }}>
|
||||
{username || 'Unknown'}
|
||||
</div>
|
||||
<div style={{ color: '#b9bbbe', fontSize: '12px', lineHeight: '13px' }}>
|
||||
#{Math.floor(Math.random() * 9000) + 1000}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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'
|
||||
}}
|
||||
>
|
||||
<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'
|
||||
}}
|
||||
>
|
||||
<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'
|
||||
}}
|
||||
>
|
||||
<ColoredIcon
|
||||
src={settingsIcon}
|
||||
color={ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, onChannelCreated, view, onViewChange }) => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); // New State
|
||||
const [newChannelName, setNewChannelName] = useState('');
|
||||
const [newChannelType, setNewChannelType] = useState('text'); // 'text' or 'voice'
|
||||
const [editingChannel, setEditingChannel] = useState(null);
|
||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||
|
||||
// Callbacks for Modal
|
||||
const onRenameChannel = (id, newName) => {
|
||||
if (onChannelCreated) onChannelCreated();
|
||||
};
|
||||
|
||||
const onDeleteChannel = (id) => {
|
||||
if (activeChannel === id) onSelectChannel(null);
|
||||
if (onChannelCreated) onChannelCreated();
|
||||
};
|
||||
|
||||
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
|
||||
|
||||
// ... helper for public key ...
|
||||
const getMyPublicKey = async (userId) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
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 type = newChannelType;
|
||||
const userId = localStorage.getItem('userId');
|
||||
|
||||
if (!userId) {
|
||||
alert("Please login first.");
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Create Channel
|
||||
const createRes = await fetch('http://localhost:3000/api/channels/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, type })
|
||||
});
|
||||
const { id: channelId, error } = await createRes.json();
|
||||
if (error) throw new Error(error);
|
||||
|
||||
// 2. Generate Key (Only needed for encrypted TEXT channels roughly, but we do it for all to simplify logic?
|
||||
// Actually, Voice only needs access token. But keeping key logic doesn't hurt for consistent DB.
|
||||
// Voice channels might use the key for text chat INTside the voice channel later?)
|
||||
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 (Group Logic)
|
||||
try {
|
||||
// Fetch all public keys
|
||||
const usersRes = await fetch('http://localhost:3000/api/auth/users/public-keys');
|
||||
const users = await usersRes.json();
|
||||
|
||||
const batchKeys = [];
|
||||
|
||||
for (const u of users) {
|
||||
if (!u.public_identity_key) continue;
|
||||
|
||||
try {
|
||||
// Correct Format: JSON Stringify { [channelId]: key }
|
||||
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
|
||||
await fetch('http://localhost:3000/api/channels/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batchKeys)
|
||||
});
|
||||
|
||||
// 5. Notify Everyone (NOW it is safe)
|
||||
await fetch(`http://localhost:3000/api/channels/${channelId}/notify`, { method: 'POST' });
|
||||
|
||||
} catch (keyErr) {
|
||||
console.error("Critical: Failed to distribute keys", keyErr);
|
||||
alert("Channel created but key distribution failed.");
|
||||
}
|
||||
|
||||
// 6. Refresh
|
||||
setIsCreating(false);
|
||||
if (onChannelCreated) onChannelCreated();
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to create channel: " + err.message);
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// ... (rest of logic)
|
||||
|
||||
|
||||
const handleCreateInvite = async () => {
|
||||
const userId = localStorage.getItem('userId');
|
||||
if (!userId) {
|
||||
alert("Error: No User ID found. Please login again.");
|
||||
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('');
|
||||
|
||||
// 2. Prepare Key Bundle
|
||||
const generalChannel = channels.find(c => c.name === 'general');
|
||||
const targetChannelId = generalChannel ? generalChannel.id : activeChannel; // Fallback to active if no general
|
||||
|
||||
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 encrypted = await window.cryptoAPI.encryptData(payload, inviteSecret);
|
||||
|
||||
const blob = JSON.stringify({
|
||||
c: encrypted.content,
|
||||
t: encrypted.tag,
|
||||
iv: encrypted.iv
|
||||
});
|
||||
|
||||
// 4. Send to Server
|
||||
const res = await fetch('http://localhost:3000/api/invites/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: inviteCode,
|
||||
encryptedPayload: blob,
|
||||
createdBy: userId,
|
||||
keyVersion: 1
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Server rejected invite creation');
|
||||
|
||||
// 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 track = stream.getVideoTracks()[0];
|
||||
if (track) {
|
||||
await room.localParticipant.publishTrack(track, {
|
||||
name: 'screen_share',
|
||||
source: Track.Source.ScreenShare
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div className="server-list">
|
||||
{/* Home Button */}
|
||||
<div
|
||||
className={`server-icon ${view === 'me' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('me')}
|
||||
style={{
|
||||
backgroundColor: view === 'me' ? '#5865F2' : '#36393f',
|
||||
color: view === 'me' ? '#fff' : '#dcddde',
|
||||
marginBottom: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{/* Discord Logo / Home Icon */}
|
||||
<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>
|
||||
{/* Add separator logic if needed, or just list other servers (currently just 1 hardcoded placeholder in UI) */}
|
||||
|
||||
{/* The Server Icon (Secure Chat) */}
|
||||
<div
|
||||
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('server')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>Sc</div>
|
||||
</div>
|
||||
|
||||
{/* Channel List Area */}
|
||||
{view === 'me' ? (
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<DMList onSelectDM={() => {}} />
|
||||
</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' // Space for icon
|
||||
}}
|
||||
>
|
||||
<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%)" // Active Green
|
||||
: "#8e9297" // Default Gray
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Participant List (Only for Voice Channels) */}
|
||||
</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', // var(--red-400) fallback
|
||||
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>
|
||||
</div>
|
||||
{/* Voice Connection Panel */}
|
||||
{connectionState === 'connected' && (
|
||||
<div style={{
|
||||
backgroundColor: '#292b2f',
|
||||
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="#b9bbbe" size="20px" />
|
||||
</button>
|
||||
</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'
|
||||
}}
|
||||
>
|
||||
<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'
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
onClose={() => setEditingChannel(null)}
|
||||
onRename={onRenameChannel}
|
||||
onDelete={onDeleteChannel}
|
||||
/>
|
||||
)}
|
||||
{isServerSettingsOpen && (
|
||||
<ServerSettingsModal onClose={() => setIsServerSettingsOpen(false)} />
|
||||
)}
|
||||
{isScreenShareModalOpen && (
|
||||
<ScreenShareModal
|
||||
onClose={() => setIsScreenShareModalOpen(false)}
|
||||
onSelectSource={handleScreenShareSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
Reference in New Issue
Block a user