feat: Implement core Discord clone functionality including Convex backend, Electron frontend, and UI components for chat, voice, and settings.

This commit is contained in:
Bryan1029384756
2026-02-10 04:41:10 -06:00
parent 516cfdbbd8
commit 47f173c79b
63 changed files with 4467 additions and 5292 deletions

View File

@@ -1,16 +1,18 @@
import React, { useState } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { useVoice } from '../contexts/VoiceContext';
import ChannelSettingsModal from './ChannelSettingsModal';
import ServerSettingsModal from './ServerSettingsModal';
import ChannelSettingsModal from './ChannelSettingsModal';
import ServerSettingsModal from './ServerSettingsModal';
import ScreenShareModal from './ScreenShareModal';
import DMList from './DMList'; // Import DMList
import DMList from './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 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';
@@ -24,28 +26,26 @@ const ColoredIcon = ({ src, color, size = '20px' }) => (
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0 // Prevent shrinking in flex containers
flexShrink: 0
}}>
<img
src={src}
alt=""
<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;
@@ -70,14 +70,14 @@ const UserControlPanel = ({ username }) => {
flexShrink: 0
}}>
{/* User Info */}
<div style={{
display: 'flex',
alignItems: 'center',
<div style={{
display: 'flex',
alignItems: 'center',
marginRight: 'auto',
padding: '4px',
borderRadius: '4px',
cursor: 'pointer',
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' }
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' }
}}>
<div style={{ position: 'relative', marginRight: '8px' }}>
<div style={{
@@ -118,7 +118,7 @@ const UserControlPanel = ({ username }) => {
{/* Controls */}
<div style={{ display: 'flex' }}>
<button
<button
onClick={toggleMute}
title={effectiveMute ? "Unmute" : "Mute"}
style={{
@@ -132,12 +132,12 @@ const UserControlPanel = ({ username }) => {
justifyContent: 'center'
}}
>
<ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
<ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
<button
onClick={toggleDeafen}
title={isDeafened ? "Undeafen" : "Deafen"}
style={{
@@ -151,12 +151,12 @@ const UserControlPanel = ({ username }) => {
justifyContent: 'center'
}}
>
<ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
<ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
<button
title="User Settings"
style={{
background: 'transparent',
@@ -169,9 +169,9 @@ const UserControlPanel = ({ username }) => {
justifyContent: 'center'
}}
>
<ColoredIcon
src={settingsIcon}
color={ICON_COLOR_DEFAULT}
<ColoredIcon
src={settingsIcon}
color={ICON_COLOR_DEFAULT}
/>
</button>
</div>
@@ -181,31 +181,28 @@ const UserControlPanel = ({ username }) => {
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, onChannelCreated, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); // New State
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
const [newChannelName, setNewChannelName] = useState('');
const [newChannelType, setNewChannelType] = useState('text'); // 'text' or 'voice'
const [newChannelType, setNewChannelType] = useState('text');
const [editingChannel, setEditingChannel] = useState(null);
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
// Callbacks for Modal
const convex = useConvex();
// Callbacks for Modal - Convex is reactive, no need to manually refresh
const onRenameChannel = (id, newName) => {
if (onChannelCreated) onChannelCreated();
// Convex reactive queries auto-update
};
const onDeleteChannel = (id) => {
if (activeChannel === id) onSelectChannel(null);
if (onChannelCreated) onChannelCreated();
// Convex reactive queries auto-update
};
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('');
@@ -222,7 +219,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const name = newChannelName.trim();
const type = newChannelType;
const userId = localStorage.getItem('userId');
if (!userId) {
alert("Please login first.");
setIsCreating(false);
@@ -230,38 +227,27 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
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);
// 1. Create Channel via Convex
const { id: channelId } = await convex.mutation(api.channels.create, { name, type });
// 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?)
// 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 (Group Logic)
// 3. Encrypt Key for ALL Users
try {
// Fetch all public keys
const usersRes = await fetch('http://localhost:3000/api/auth/users/public-keys');
const users = await usersRes.json();
const users = await convex.query(api.auth.getPublicKeys, {});
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,
@@ -273,24 +259,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
}
// 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' });
// 4. Upload Keys Batch via Convex
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
// No need to notify - Convex queries are reactive!
} catch (keyErr) {
console.error("Critical: Failed to distribute keys", keyErr);
alert("Channel created but key distribution failed.");
}
// 6. Refresh
// 5. Done - Convex reactive queries auto-update the channel list
setIsCreating(false);
if (onChannelCreated) onChannelCreated();
} catch (err) {
console.error(err);
@@ -299,11 +279,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
};
// ... (rest of logic)
const handleCreateInvite = async () => {
const userId = localStorage.getItem('userId');
if (!userId) {
@@ -320,8 +295,8 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
// 2. Prepare Key Bundle
const generalChannel = channels.find(c => c.name === 'general');
const targetChannelId = generalChannel ? generalChannel.id : activeChannel; // Fallback to active if no general
const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
if (!targetChannelId) {
alert("No channel selected.");
return;
@@ -334,33 +309,27 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
return;
}
const payload = JSON.stringify({
[targetChannelId]: targetKey
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
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
})
// 4. Create invite via Convex
await convex.mutation(api.invites.create, {
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);
@@ -375,7 +344,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
// Screen Share Handler
const handleScreenShareSelect = async (selection) => {
if (!room) return;
try {
// Unpublish existing screen share if any
if (room.localParticipant.isScreenShareEnabled) {
@@ -409,9 +378,9 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
name: 'screen_share',
source: Track.Source.ScreenShare
});
setScreenSharing(true);
track.onended = () => {
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
@@ -423,7 +392,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
alert("Failed to share screen: " + err.message);
}
};
// Toggle Modal instead of direct toggle
const handleScreenShareClick = () => {
if (room?.localParticipant.isScreenShareEnabled) {
@@ -440,31 +409,29 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list">
{/* Home Button */}
<div
<div
className={`server-icon ${view === 'me' ? 'active' : ''}`}
onClick={() => onViewChange('me')}
style={{
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
<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' }}>
@@ -484,7 +451,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
) : (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span
<span
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}
onClick={() => setIsServerSettingsOpen(true)}
title="Server Settings"
@@ -492,7 +459,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
Secure Chat
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
<button
onClick={handleStartCreate}
title="Create New Channel"
style={{
@@ -507,7 +474,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
>
+
</button>
<button
<button
onClick={handleCreateInvite}
title="Create Invite Link"
style={{
@@ -523,7 +490,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</button>
</div>
</div>
{/* Inline Create Channel Input */}
{isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
@@ -561,37 +528,37 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
)}
{channels.map(channel => (
<React.Fragment key={channel.id}>
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel.id ? 'active' : ''} ${voiceChannelId === channel.id ? 'voice-active' : ''}`}
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => {
if (channel.type === 'voice') {
if (voiceChannelId === channel.id) {
onSelectChannel(channel.id);
if (voiceChannelId === channel._id) {
onSelectChannel(channel._id);
} else {
connectToVoice(channel.id, channel.name, localStorage.getItem('userId'));
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
}
} else {
onSelectChannel(channel.id);
onSelectChannel(channel._id);
}
}}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px' // Space for icon
paddingRight: '8px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
{channel.type === 'voice' ? (
<div style={{ marginRight: 6 }}>
<ColoredIcon
src={voiceIcon}
<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
color={voiceStates[channel._id]?.length > 0
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)"
: "#8e9297"
}
/>
</div>
@@ -602,7 +569,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div>
<button
className="channel-settings-icon"
className="channel-settings-icon"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel);
@@ -621,20 +588,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
>
</button>
{/* Participant List (Only for Voice Channels) */}
</div>
{channel.type === 'voice' && voiceStates[channel.id] && voiceStates[channel.id].length > 0 && (
{channel.type === 'voice' && voiceStates[channel._id] && voiceStates[channel._id].length > 0 && (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{voiceStates[channel.id].map(user => (
{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',
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%)'
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()}
@@ -643,7 +608,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && (
<div style={{
backgroundColor: '#ed4245', // var(--red-400) fallback
backgroundColor: '#ed4245',
borderRadius: '8px',
padding: '0 6px',
textOverflow: 'ellipsis',
@@ -695,7 +660,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ color: '#43b581', fontWeight: 'bold', fontSize: 13 }}>Voice Connected</div>
<button
<button
onClick={disconnectVoice}
title="Disconnect"
style={{
@@ -707,7 +672,7 @@ 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
<button
onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)}
title="Turn On Camera"
style={{
@@ -716,7 +681,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
>
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
</button>
<button
<button
onClick={handleScreenShareClick}
title="Share Screen"
style={{
@@ -734,8 +699,8 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
{/* Modals */}
{editingChannel && (
<ChannelSettingsModal
channel={editingChannel}
<ChannelSettingsModal
channel={editingChannel}
onClose={() => setEditingChannel(null)}
onRename={onRenameChannel}
onDelete={onDeleteChannel}
@@ -745,7 +710,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<ServerSettingsModal onClose={() => setIsServerSettingsOpen(false)} />
)}
{isScreenShareModalOpen && (
<ScreenShareModal
<ScreenShareModal
onClose={() => setIsScreenShareModalOpen(false)}
onSelectSource={handleScreenShareSelect}
/>