feat: initialize Discord clone application with core backend services and Electron frontend.

This commit is contained in:
Bryan1029384756
2026-02-09 23:54:49 -06:00
parent e64cf20116
commit 516cfdbbd8
26 changed files with 622 additions and 161 deletions

Binary file not shown.

View File

@@ -1,14 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>discord</title>
<script type="module" crossorigin src="./assets/index-UkvvH8Ct.js"></script>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>discord</title>
<script type="module" crossorigin src="./assets/index-B1qeTixj.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css">
</head>
<body>
<div id="root"></div>
</body>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,20 +1,13 @@
import React, { useState, useEffect } from 'react';
import { ColoredIcon } from './Sidebar'; // Reusing helper if valid, or will define local
import friendsIcon from '../assets/icons/friends.svg'; // Need to import or mock
// Attempting to reuse common styles or define new ones.
import React, { useState, useEffect, useRef } from 'react';
const DMList = ({ onSelectDM }) => {
const [users, setUsers] = useState([]);
useEffect(() => {
// Fetch all users to simulate DMs
fetch('http://localhost:3000/api/auth/users/public-keys')
.then(res => res.json())
.then(data => setUsers(data))
.catch(err => console.error(err));
}, []);
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [showUserPicker, setShowUserPicker] = useState(false);
const [allUsers, setAllUsers] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const searchRef = useRef(null);
const getUserColor = (username) => {
if (!username) return '#5865F2';
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
for (let i = 0; i < username.length; i++) {
@@ -23,9 +16,34 @@ const DMList = ({ onSelectDM }) => {
return colors[Math.abs(hash) % colors.length];
};
const handleOpenUserPicker = () => {
setShowUserPicker(true);
setSearchQuery('');
// Fetch all users for the picker
fetch('http://localhost:3000/api/auth/users/public-keys')
.then(res => res.json())
.then(data => {
const myId = localStorage.getItem('userId');
setAllUsers(data.filter(u => u.id !== myId));
})
.catch(err => console.error(err));
};
useEffect(() => {
if (showUserPicker && searchRef.current) {
searchRef.current.focus();
}
}, [showUserPicker]);
const filteredUsers = allUsers.filter(u =>
u.username?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}>
<button
{/* Search / New DM Button */}
<button
onClick={handleOpenUserPicker}
style={{
width: '100%',
textAlign: 'left',
@@ -42,75 +60,183 @@ const DMList = ({ onSelectDM }) => {
Find or start a conversation
</button>
<div
{/* User Picker Modal/Dropdown */}
{showUserPicker && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={() => setShowUserPicker(false)}
>
<div
style={{
backgroundColor: '#36393f',
borderRadius: '8px',
padding: '16px',
width: '400px',
maxHeight: '500px',
display: 'flex',
flexDirection: 'column'
}}
onClick={e => e.stopPropagation()}
>
<h3 style={{ color: '#fff', margin: '0 0 4px 0', fontSize: '16px' }}>Select a User</h3>
<p style={{ color: '#b9bbbe', fontSize: '12px', margin: '0 0 12px 0' }}>Start a new direct message conversation.</p>
<input
ref={searchRef}
type="text"
placeholder="Type a username..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%',
backgroundColor: '#202225',
border: '1px solid #040405',
borderRadius: '4px',
color: '#dcddde',
padding: '8px 12px',
fontSize: '14px',
outline: 'none',
marginBottom: '8px',
boxSizing: 'border-box'
}}
/>
<div style={{ flex: 1, overflowY: 'auto', maxHeight: '300px' }}>
{filteredUsers.map(user => (
<div
key={user.id}
onClick={() => {
setShowUserPicker(false);
onOpenDM(user.id, user.username);
}}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px',
borderRadius: '4px',
cursor: 'pointer',
color: '#dcddde'
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.06)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
>
<div style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: getUserColor(user.username),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: '600', marginRight: '12px', flexShrink: 0
}}>
{(user.username ?? '?').substring(0, 1).toUpperCase()}
</div>
<span style={{ fontWeight: '500' }}>{user.username}</span>
</div>
))}
{filteredUsers.length === 0 && (
<div style={{ color: '#72767d', textAlign: 'center', padding: '16px', fontSize: '13px' }}>
No users found.
</div>
)}
</div>
</div>
</div>
)}
{/* Friends Button */}
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '10px 8px',
borderRadius: '4px',
backgroundColor: 'rgba(255,255,255,0.04)', // Selected state for "Friends"
color: '#fff',
backgroundColor: !activeDMChannel ? 'rgba(255,255,255,0.04)' : 'transparent',
color: !activeDMChannel ? '#fff' : '#96989d',
cursor: 'pointer',
marginBottom: '16px'
}}
onClick={() => onSelectDM('friends')}
onMouseEnter={e => { if (activeDMChannel) e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.02)'; }}
onMouseLeave={e => { if (activeDMChannel) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div style={{ marginRight: '12px' }}>
{/* Friends Icon Mock */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M13.5 2C13.5 2.82843 12.8284 3.5 12 3.5C11.1716 3.5 10.5 2.82843 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2Z" fill="currentColor"/>
{/* Use a generic user-group icon path later, keeping simple for now */}
<path d="M7 13C7 11.8954 7.89543 11 9 11H15C16.1046 11 17 11.8954 17 13V15H7V13Z" fill="#fff"/>
</svg>
</div>
<span style={{ fontWeight: 500 }}>Friends</span>
</div>
{/* DM List Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 8px 8px', color: '#96989d', fontSize: '11px', fontWeight: 'bold', textTransform: 'uppercase' }}>
<span>Direct Messages</span>
<span style={{ cursor: 'pointer', fontSize: '16px' }}>+</span>
<span
style={{ cursor: 'pointer', fontSize: '16px' }}
onClick={handleOpenUserPicker}
title="New DM"
>+</span>
</div>
{/* DM Channel List */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{users.map(user => (
<div
key={user.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px',
borderRadius: '4px',
cursor: 'pointer',
color: '#96989d',
':hover': { backgroundColor: 'rgba(255,255,255,0.02)', color: '#dcddde' }
}}
>
<div style={{ position: 'relative', marginRight: '12px' }}>
<div style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: getUserColor(user.username),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: '600'
}}>
{user.username.substring(0,1).toUpperCase()}
</div>
<div style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
backgroundColor: '#3ba55c', // Assume online
border: '2px solid #2f3136'
}} />
</div>
<div style={{ overflow: 'hidden' }}>
<div style={{ color: '#dcddde', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>{user.username}</div>
<div style={{ fontSize: '11px', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }}>
Online
{(dmChannels || []).map(dm => {
const isActive = activeDMChannel?.channel_id === dm.channel_id;
return (
<div
key={dm.channel_id}
onClick={() => onSelectDM({ channel_id: dm.channel_id, other_username: dm.other_username })}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px',
borderRadius: '4px',
cursor: 'pointer',
color: isActive ? '#fff' : '#96989d',
backgroundColor: isActive ? 'rgba(255,255,255,0.06)' : 'transparent'
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.02)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div style={{ position: 'relative', marginRight: '12px' }}>
<div style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: getUserColor(dm.other_username),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: '600'
}}>
{(dm.other_username ?? '?').substring(0, 1).toUpperCase()}
</div>
<div style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
backgroundColor: '#3ba55c',
border: '2px solid #2f3136'
}} />
</div>
<div style={{ overflow: 'hidden' }}>
<div style={{ color: isActive ? '#fff' : '#dcddde', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
{dm.other_username}
</div>
</div>
</div>
);
})}
{(!dmChannels || dmChannels.length === 0) && (
<div style={{ color: '#72767d', fontSize: '13px', textAlign: 'center', padding: '16px 8px' }}>
No DMs yet. Click + to start a conversation.
</div>
))}
)}
</div>
</div>
);

View File

@@ -1,17 +1,19 @@
import React, { useState, useEffect } from 'react';
const FriendsView = () => {
const FriendsView = ({ onOpenDM }) => {
const [users, setUsers] = useState([]);
const [activeTab, setActiveTab] = useState('Online'); // Online, All, Pending, Blocked, AddFriend
const [activeTab, setActiveTab] = useState('Online');
useEffect(() => {
const myId = localStorage.getItem('userId');
fetch('http://localhost:3000/api/auth/users/public-keys')
.then(res => res.json())
.then(data => setUsers(data))
.then(data => setUsers(data.filter(u => u.id !== myId)))
.catch(err => console.error(err));
}, []);
const getUserColor = (username) => {
if (!username) return '#747f8d';
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
for (let i = 0; i < username.length; i++) {
@@ -25,7 +27,7 @@ const FriendsView = () => {
// In real app, "Online" would filter by status.
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: '#36393f', height: '100vh' }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', height: '100vh' }}>
{/* Top Bar */}
<div style={{
height: '48px',
@@ -46,8 +48,8 @@ const FriendsView = () => {
</div>
<div style={{ display: 'flex', gap: '16px' }}>
{['Online', 'All', 'Pending', 'Blocked'].map(tab => (
<div
{['Online', 'All'].map(tab => (
<div
key={tab}
onClick={() => setActiveTab(tab)}
style={{
@@ -61,17 +63,6 @@ const FriendsView = () => {
{tab}
</div>
))}
<div
style={{
cursor: 'pointer',
color: '#fff',
backgroundColor: '#3ba55c',
padding: '2px 8px',
borderRadius: '4px'
}}
>
Add Friend
</div>
</div>
</div>
@@ -112,7 +103,7 @@ const FriendsView = () => {
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: '600'
}}>
{user.username.substring(0,1).toUpperCase()}
{(user.username ?? '?').substring(0,1).toUpperCase()}
</div>
<div style={{
position: 'absolute', bottom: -2, right: -2,
@@ -123,7 +114,7 @@ const FriendsView = () => {
</div>
<div>
<div style={{ color: '#fff', fontWeight: '600' }}>
{user.username}
{user.username ?? 'Unknown'}
</div>
<div style={{ color: '#b9bbbe', fontSize: '12px' }}>
Online
@@ -133,7 +124,11 @@ const FriendsView = () => {
{/* Actions */}
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ padding: 8, backgroundColor: '#2f3136', borderRadius: '50%', cursor: 'pointer' }}>
<div
style={{ padding: 8, backgroundColor: '#2f3136', borderRadius: '50%', cursor: 'pointer' }}
onClick={() => onOpenDM && onOpenDM(user.id, user.username)}
title="Message"
>
💬
</div>
<div style={{ padding: 8, backgroundColor: '#2f3136', borderRadius: '50%', cursor: 'pointer' }}>

View File

@@ -181,7 +181,7 @@ const UserControlPanel = ({ username }) => {
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, onChannelCreated, view, onViewChange }) => {
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, onChannelCreated, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); // New State
const [newChannelName, setNewChannelName] = useState('');
@@ -437,7 +437,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
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 */}
@@ -469,7 +468,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
{/* Channel List Area */}
{view === 'me' ? (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<DMList onSelectDM={() => {}} />
<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' }}>
@@ -670,6 +680,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</React.Fragment>
))}
</div>
)}
</div>
{/* Voice Connection Panel */}
{connectionState === 'connected' && (

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { io } from 'socket.io-client';
import Sidebar from '../components/Sidebar';
import ChatArea from '../components/ChatArea';
@@ -14,6 +14,10 @@ const Chat = () => {
const [userId, setUserId] = useState(null);
const [channelKeys, setChannelKeys] = useState({}); // { channelId: key_hex }
// DM state
const [activeDMChannel, setActiveDMChannel] = useState(null); // { channel_id, other_username }
const [dmChannels, setDMChannels] = useState([]);
const refreshData = () => {
const storedUsername = localStorage.getItem('username');
const userId = localStorage.getItem('userId');
@@ -56,9 +60,97 @@ const Chat = () => {
.catch(err => console.error('Error fetching channels:', err));
};
const fetchDMChannels = useCallback(() => {
const uid = localStorage.getItem('userId');
if (!uid) return;
fetch(`http://localhost:3000/api/dms/user/${uid}`)
.then(res => res.json())
.then(data => setDMChannels(data))
.catch(err => console.error('Error fetching DM channels:', err));
}, []);
const openDM = useCallback(async (targetUserId, targetUsername) => {
const uid = localStorage.getItem('userId');
const privateKey = sessionStorage.getItem('privateKey');
if (!uid) return;
try {
// 1. Find or create the DM channel
const res = await fetch('http://localhost:3000/api/dms/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: uid, targetUserId })
});
const { channelId, created } = await res.json();
// 2. If newly created, generate + distribute an AES key for both users
if (created) {
const keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
// Fetch both users' public keys
const usersRes = await fetch('http://localhost:3000/api/auth/users/public-keys');
const allUsers = await usersRes.json();
const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId);
const batchKeys = [];
for (const u of participants) {
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 DM key for user', u.id, e);
}
}
if (batchKeys.length > 0) {
await fetch('http://localhost:3000/api/channels/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batchKeys)
});
}
// Refresh channel keys so the new DM key is available
if (privateKey) {
const keysRes = await fetch(`http://localhost:3000/api/channels/keys/${uid}`);
const keysData = await keysRes.json();
const keys = {};
for (const item of keysData) {
try {
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
const bundle = JSON.parse(bundleJson);
Object.assign(keys, bundle);
} catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
}
}
setChannelKeys(keys);
}
}
// 3. Set active DM and switch to me view
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
setView('me');
fetchDMChannels();
} catch (err) {
console.error('Error opening DM:', err);
}
}, [fetchDMChannels]);
useEffect(() => {
refreshData();
fetchDMChannels();
// Listen for updates (requires socket connection)
const socket = io('http://localhost:3000');
@@ -82,6 +174,47 @@ const Chat = () => {
const { room, voiceStates } = useVoice();
// Determine what to render in the main area
const renderMainContent = () => {
if (view === 'me') {
if (activeDMChannel) {
return (
<ChatArea
channelId={activeDMChannel.channel_id}
channelName={activeDMChannel.other_username}
channelKey={channelKeys[activeDMChannel.channel_id]}
username={username}
userId={userId}
/>
);
}
return <FriendsView onOpenDM={openDM} />;
}
if (activeChannel) {
if (activeChannelObj?.type === 'voice') {
return <VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />;
}
return (
<ChatArea
channelId={activeChannel}
channelName={activeChannelObj?.name || activeChannel}
channelKey={channelKeys[activeChannel]}
username={username}
userId={userId}
/>
);
}
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe', flexDirection: 'column' }}>
<h2>Welcome to Secure Chat</h2>
<p>No channels found.</p>
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
</div>
);
};
return (
<div className="app-container">
<Sidebar
@@ -89,32 +222,21 @@ const Chat = () => {
activeChannel={activeChannel}
onSelectChannel={setActiveChannel}
username={username}
channelKeys={channelKeys}
onChannelCreated={refreshData} // Use refresh instead of reload
channelKeys={channelKeys}
onChannelCreated={refreshData}
view={view}
onViewChange={setView}
onViewChange={(v) => {
setView(v);
if (v === 'me') {
fetchDMChannels();
}
}}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
/>
{view === 'me' ? (
<FriendsView />
) : activeChannel ? (
activeChannelObj?.type === 'voice' ? (
<VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />
) : (
<ChatArea
channelId={activeChannel}
channelName={activeChannelObj?.name || activeChannel}
channelKey={channelKeys[activeChannel]}
username={username}
userId={userId}
/>
)
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe', flexDirection: 'column' }}>
<h2>Welcome to Secure Chat</h2>
<p>No channels found.</p>
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
</div>
)}
{renderMainContent()}
</div>
);
};