feat: Add initial Discord clone application with Convex backend services and Electron React frontend components.
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user