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

View File

@@ -0,0 +1,47 @@
# Fix JSX Syntax Errors Blocking Electron Dev
## Problem
`npm run dev:electron` fails with two issues:
1. **Sidebar.jsx:632** — Fatal: "Adjacent JSX elements must be wrapped in an enclosing tag"
2. **ScreenShareModal.jsx:70** — Warning: Duplicate `width` key in object literal
## Root Cause Analysis
### Sidebar.jsx — Duplicate opening `<div>`
Lines 402 and 403 are identical:
```jsx
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}> // line 402 ← DUPLICATE
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}> // line 403
```
Line 403's div closes at line 632, but line 402's div **never closes**. This leaves the Voice Connection Panel (line 633), UserControlPanel (line 681), and modals (line 684+) as adjacent siblings without a common parent.
### ScreenShareModal.jsx — Duplicate style key
Line 70 has `width` specified twice:
```jsx
style={{ position: 'relative', width: '100%', height: '250px', width: '450px', ... }}
```
The second `width: '450px'` silently overrides `width: '100%'`.
## Fix
### 1. Sidebar.jsx — Remove duplicate div (line 402)
**File:** `FrontEnd/Electron/src/components/Sidebar.jsx`
Delete line 402 entirely. The remaining structure becomes:
```
Line 401: <div className="sidebar"> ← outermost
Line 403: <div style={{ display: 'flex', flex: 1, minHeight: 0 }}> ← flex container
...server-list + channel-list...
Line 632: </div> ← closes flex container
Voice Connection Panel, UserControlPanel, Modals
Line 701: </div> ← closes sidebar
```
### 2. ScreenShareModal.jsx — Remove duplicate width
**File:** `FrontEnd/Electron/src/components/ScreenShareModal.jsx`
On line 70, remove the first `width: '100%',` — keep only `width: '450px'`.
## Verification
- Run `npm run dev:electron` — Vite should compile without errors
- The Electron window should open and render the sidebar correctly

View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch(domain:labs.convex.dev)",
"Bash(npm install:*)",
"Bash(npm uninstall:*)",
"Bash(npm init:*)",
"Bash(node -e:*)",
"Bash(npx convex dev:*)",
"Bash(npx convex:*)",
"Bash(npx @convex-dev/auth:*)"
]
}
}

View File

@@ -147,7 +147,7 @@ router.post('/login/verify', async (req, res) => {
router.get('/users/public-keys', async (req, res) => { router.get('/users/public-keys', async (req, res) => {
try { try {
const result = await db.query('SELECT id, public_identity_key FROM users'); const result = await db.query('SELECT id, username, public_identity_key FROM users');
res.json(result.rows); res.json(result.rows);
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@@ -4,7 +4,7 @@ const db = require('../db');
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const result = await db.query('SELECT * FROM channels ORDER BY name ASC'); const result = await db.query("SELECT * FROM channels WHERE type != 'dm' ORDER BY name ASC");
res.json(result.rows); res.json(result.rows);
} catch (err) { } catch (err) {
console.error(err); console.error(err);

79
Backend/routes/dms.js Normal file
View File

@@ -0,0 +1,79 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
// POST /api/dms/open — Find-or-create a DM channel between two users
router.post('/open', async (req, res) => {
const { userId, targetUserId } = req.body;
if (!userId || !targetUserId) {
return res.status(400).json({ error: 'userId and targetUserId required' });
}
if (userId === targetUserId) {
return res.status(400).json({ error: 'Cannot DM yourself' });
}
// Deterministic channel name so the same pair always maps to one channel
const sorted = [userId, targetUserId].sort();
const dmName = `dm-${sorted[0]}-${sorted[1]}`;
try {
// Check if DM channel already exists
const existing = await db.query(
'SELECT id FROM channels WHERE name = $1 AND type = $2',
[dmName, 'dm']
);
if (existing.rows.length > 0) {
return res.json({ channelId: existing.rows[0].id, created: false });
}
// Create the DM channel + participants in a transaction
await db.query('BEGIN');
const channelResult = await db.query(
'INSERT INTO channels (name, type) VALUES ($1, $2) RETURNING id',
[dmName, 'dm']
);
const channelId = channelResult.rows[0].id;
await db.query(
'INSERT INTO dm_participants (channel_id, user_id) VALUES ($1, $2), ($1, $3)',
[channelId, userId, targetUserId]
);
await db.query('COMMIT');
res.json({ channelId, created: true });
} catch (err) {
await db.query('ROLLBACK');
console.error('Error opening DM:', err);
res.status(500).json({ error: 'Server error' });
}
});
// GET /api/dms/user/:userId — List all DM channels for a user with the other user's info
router.get('/user/:userId', async (req, res) => {
const { userId } = req.params;
try {
const result = await db.query(`
SELECT c.id AS channel_id, c.name AS channel_name, c.created_at,
other_user.id AS other_user_id, other_user.username AS other_username
FROM dm_participants my
JOIN channels c ON c.id = my.channel_id AND c.type = 'dm'
JOIN dm_participants other ON other.channel_id = my.channel_id AND other.user_id != $1
JOIN users other_user ON other_user.id = other.user_id
WHERE my.user_id = $1
ORDER BY c.created_at DESC
`, [userId]);
res.json(result.rows);
} catch (err) {
console.error('Error fetching DM channels:', err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;

View File

@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS channels ( CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT UNIQUE NOT NULL, name TEXT UNIQUE NOT NULL,
type TEXT DEFAULT 'text' CHECK (type IN ('text', 'voice')), type TEXT DEFAULT 'text' CHECK (type IN ('text', 'voice', 'dm')),
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
@@ -65,3 +65,9 @@ CREATE TABLE IF NOT EXISTS invites (
key_version INTEGER NOT NULL, -- Which key version is inside? (So we can invalidate leaks) key_version INTEGER NOT NULL, -- Which key version is inside? (So we can invalidate leaks)
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS dm_participants (
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
);

View File

@@ -0,0 +1,32 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const db = require('../db');
async function migrate() {
try {
console.log('Running DM migration...');
// 1. Update the check constraint to allow 'dm' type
await db.query("ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check");
await db.query("ALTER TABLE channels ADD CONSTRAINT channels_type_check CHECK (type IN ('text', 'voice', 'dm'))");
console.log(' Updated channels type constraint to allow dm.');
// 2. Create dm_participants table
await db.query(`
CREATE TABLE IF NOT EXISTS dm_participants (
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
)
`);
console.log(' Created dm_participants table.');
console.log('DM migration complete.');
process.exit(0);
} catch (err) {
console.error('DM migration failed:', err);
process.exit(1);
}
}
migrate();

View File

@@ -19,6 +19,7 @@ const channelRoutes = require('./routes/channels');
const uploadRoutes = require('./routes/upload'); const uploadRoutes = require('./routes/upload');
const inviteRoutes = require('./routes/invites'); const inviteRoutes = require('./routes/invites');
const rolesRoutes = require('./routes/roles'); const rolesRoutes = require('./routes/roles');
const dmRoutes = require('./routes/dms');
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
@@ -39,6 +40,7 @@ app.use('/api/upload', uploadRoutes);
app.use('/api/invites', inviteRoutes); app.use('/api/invites', inviteRoutes);
app.use('/api/invites', inviteRoutes); app.use('/api/invites', inviteRoutes);
app.use('/api/roles', rolesRoutes); app.use('/api/roles', rolesRoutes);
app.use('/api/dms', dmRoutes);
app.use('/api/voice', require('./routes/voice')); app.use('/api/voice', require('./routes/voice'));
app.get('/', (req, res) => { app.get('/', (req, res) => {

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
**Update this file when making significant changes.**

Binary file not shown.

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="./vite.svg" /> <link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>discord</title> <title>discord</title>
<script type="module" crossorigin src="./assets/index-UkvvH8Ct.js"></script> <script type="module" crossorigin src="./assets/index-B1qeTixj.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css"> <link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css">
</head> </head>
<body> <body>

View File

@@ -1,20 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } 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.
const DMList = ({ onSelectDM }) => { const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [users, setUsers] = useState([]); const [showUserPicker, setShowUserPicker] = useState(false);
const [allUsers, setAllUsers] = useState([]);
useEffect(() => { const [searchQuery, setSearchQuery] = useState('');
// Fetch all users to simulate DMs const searchRef = useRef(null);
fetch('http://localhost:3000/api/auth/users/public-keys')
.then(res => res.json())
.then(data => setUsers(data))
.catch(err => console.error(err));
}, []);
const getUserColor = (username) => { const getUserColor = (username) => {
if (!username) return '#5865F2';
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245']; const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0; let hash = 0;
for (let i = 0; i < username.length; i++) { for (let i = 0; i < username.length; i++) {
@@ -23,9 +16,34 @@ const DMList = ({ onSelectDM }) => {
return colors[Math.abs(hash) % colors.length]; 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 ( return (
<div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}> <div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Search / New DM Button */}
<button <button
onClick={handleOpenUserPicker}
style={{ style={{
width: '100%', width: '100%',
textAlign: 'left', textAlign: 'left',
@@ -42,75 +60,183 @@ const DMList = ({ onSelectDM }) => {
Find or start a conversation Find or start a conversation
</button> </button>
<div {/* User Picker Modal/Dropdown */}
style={{ {showUserPicker && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
zIndex: 100,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '10px 8px', justifyContent: 'center'
borderRadius: '4px',
backgroundColor: 'rgba(255,255,255,0.04)', // Selected state for "Friends"
color: '#fff',
cursor: 'pointer',
marginBottom: '16px'
}} }}
onClick={() => onSelectDM('friends')} onClick={() => setShowUserPicker(false)}
> >
<div style={{ marginRight: '12px' }}> <div
{/* Friends Icon Mock */} style={{
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"> backgroundColor: '#36393f',
<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"/> borderRadius: '8px',
{/* Use a generic user-group icon path later, keeping simple for now */} padding: '16px',
<path d="M7 13C7 11.8954 7.89543 11 9 11H15C16.1046 11 17 11.8954 17 13V15H7V13Z" fill="#fff"/> width: '400px',
</svg> maxHeight: '500px',
</div> display: 'flex',
<span style={{ fontWeight: 500 }}>Friends</span> flexDirection: 'column'
</div> }}
onClick={e => e.stopPropagation()}
<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> <h3 style={{ color: '#fff', margin: '0 0 4px 0', fontSize: '16px' }}>Select a User</h3>
<span style={{ cursor: 'pointer', fontSize: '16px' }}>+</span> <p style={{ color: '#b9bbbe', fontSize: '12px', margin: '0 0 12px 0' }}>Start a new direct message conversation.</p>
</div> <input
ref={searchRef}
<div style={{ flex: 1, overflowY: 'auto' }}> type="text"
{users.map(user => ( 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 <div
key={user.id} key={user.id}
onClick={() => {
setShowUserPicker(false);
onOpenDM(user.id, user.username);
}}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '8px', padding: '8px',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
color: '#96989d', color: '#dcddde'
':hover': { backgroundColor: 'rgba(255,255,255,0.02)', color: '#dcddde' }
}} }}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.06)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
> >
<div style={{ position: 'relative', marginRight: '12px' }}>
<div style={{ <div style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
borderRadius: '50%', borderRadius: '50%',
backgroundColor: getUserColor(user.username), backgroundColor: getUserColor(user.username),
display: 'flex', alignItems: 'center', justifyContent: 'center', 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: !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' }}>
<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"/>
<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' }}
onClick={handleOpenUserPicker}
title="New DM"
>+</span>
</div>
{/* DM Channel List */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{(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' color: 'white', fontWeight: '600'
}}> }}>
{user.username.substring(0,1).toUpperCase()} {(dm.other_username ?? '?').substring(0, 1).toUpperCase()}
</div> </div>
<div style={{ <div style={{
position: 'absolute', bottom: -2, right: -2, position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%', width: 10, height: 10, borderRadius: '50%',
backgroundColor: '#3ba55c', // Assume online backgroundColor: '#3ba55c',
border: '2px solid #2f3136' border: '2px solid #2f3136'
}} /> }} />
</div> </div>
<div style={{ overflow: 'hidden' }}> <div style={{ overflow: 'hidden' }}>
<div style={{ color: '#dcddde', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>{user.username}</div> <div style={{ color: isActive ? '#fff' : '#dcddde', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
<div style={{ fontSize: '11px', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }}> {dm.other_username}
Online
</div> </div>
</div> </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>
</div> </div>
); );

View File

@@ -1,17 +1,19 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
const FriendsView = () => { const FriendsView = ({ onOpenDM }) => {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [activeTab, setActiveTab] = useState('Online'); // Online, All, Pending, Blocked, AddFriend const [activeTab, setActiveTab] = useState('Online');
useEffect(() => { useEffect(() => {
const myId = localStorage.getItem('userId');
fetch('http://localhost:3000/api/auth/users/public-keys') fetch('http://localhost:3000/api/auth/users/public-keys')
.then(res => res.json()) .then(res => res.json())
.then(data => setUsers(data)) .then(data => setUsers(data.filter(u => u.id !== myId)))
.catch(err => console.error(err)); .catch(err => console.error(err));
}, []); }, []);
const getUserColor = (username) => { const getUserColor = (username) => {
if (!username) return '#747f8d';
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245']; const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0; let hash = 0;
for (let i = 0; i < username.length; i++) { for (let i = 0; i < username.length; i++) {
@@ -25,7 +27,7 @@ const FriendsView = () => {
// In real app, "Online" would filter by status. // In real app, "Online" would filter by status.
return ( 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 */} {/* Top Bar */}
<div style={{ <div style={{
height: '48px', height: '48px',
@@ -46,7 +48,7 @@ const FriendsView = () => {
</div> </div>
<div style={{ display: 'flex', gap: '16px' }}> <div style={{ display: 'flex', gap: '16px' }}>
{['Online', 'All', 'Pending', 'Blocked'].map(tab => ( {['Online', 'All'].map(tab => (
<div <div
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
@@ -61,17 +63,6 @@ const FriendsView = () => {
{tab} {tab}
</div> </div>
))} ))}
<div
style={{
cursor: 'pointer',
color: '#fff',
backgroundColor: '#3ba55c',
padding: '2px 8px',
borderRadius: '4px'
}}
>
Add Friend
</div>
</div> </div>
</div> </div>
@@ -112,7 +103,7 @@ const FriendsView = () => {
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: '600' color: 'white', fontWeight: '600'
}}> }}>
{user.username.substring(0,1).toUpperCase()} {(user.username ?? '?').substring(0,1).toUpperCase()}
</div> </div>
<div style={{ <div style={{
position: 'absolute', bottom: -2, right: -2, position: 'absolute', bottom: -2, right: -2,
@@ -123,7 +114,7 @@ const FriendsView = () => {
</div> </div>
<div> <div>
<div style={{ color: '#fff', fontWeight: '600' }}> <div style={{ color: '#fff', fontWeight: '600' }}>
{user.username} {user.username ?? 'Unknown'}
</div> </div>
<div style={{ color: '#b9bbbe', fontSize: '12px' }}> <div style={{ color: '#b9bbbe', fontSize: '12px' }}>
Online Online
@@ -133,7 +124,11 @@ const FriendsView = () => {
{/* Actions */} {/* Actions */}
<div style={{ display: 'flex', gap: '8px' }}> <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>
<div style={{ padding: 8, backgroundColor: '#2f3136', borderRadius: '50%', cursor: 'pointer' }}> <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 [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); // New State const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); // New State
const [newChannelName, setNewChannelName] = useState(''); const [newChannelName, setNewChannelName] = useState('');
@@ -437,7 +437,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
return ( return (
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}> <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 style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list"> <div className="server-list">
{/* Home Button */} {/* Home Button */}
@@ -469,7 +468,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
{/* Channel List Area */} {/* Channel List Area */}
{view === 'me' ? ( {view === 'me' ? (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}> <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>
) : ( ) : (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}> <div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
@@ -670,6 +680,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
)}
</div> </div>
{/* Voice Connection Panel */} {/* Voice Connection Panel */}
{connectionState === 'connected' && ( {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 { io } from 'socket.io-client';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
import ChatArea from '../components/ChatArea'; import ChatArea from '../components/ChatArea';
@@ -14,6 +14,10 @@ const Chat = () => {
const [userId, setUserId] = useState(null); const [userId, setUserId] = useState(null);
const [channelKeys, setChannelKeys] = useState({}); // { channelId: key_hex } 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 refreshData = () => {
const storedUsername = localStorage.getItem('username'); const storedUsername = localStorage.getItem('username');
const userId = localStorage.getItem('userId'); const userId = localStorage.getItem('userId');
@@ -56,8 +60,96 @@ const Chat = () => {
.catch(err => console.error('Error fetching channels:', err)); .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(() => { useEffect(() => {
refreshData(); refreshData();
fetchDMChannels();
// Listen for updates (requires socket connection) // Listen for updates (requires socket connection)
const socket = io('http://localhost:3000'); const socket = io('http://localhost:3000');
@@ -82,6 +174,47 @@ const Chat = () => {
const { room, voiceStates } = useVoice(); 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 ( return (
<div className="app-container"> <div className="app-container">
<Sidebar <Sidebar
@@ -90,31 +223,20 @@ const Chat = () => {
onSelectChannel={setActiveChannel} onSelectChannel={setActiveChannel}
username={username} username={username}
channelKeys={channelKeys} channelKeys={channelKeys}
onChannelCreated={refreshData} // Use refresh instead of reload onChannelCreated={refreshData}
view={view} view={view}
onViewChange={setView} onViewChange={(v) => {
setView(v);
if (v === 'me') {
fetchDMChannels();
}
}}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
/> />
{view === 'me' ? ( {renderMainContent()}
<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>
)}
</div> </div>
); );
}; };

10
package-lock.json generated Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "discord-clone",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "discord-clone"
}
}
}

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "discord-clone",
"private": true,
"scripts": {
"backend": "node Backend/server.js",
"frontend": "cd Frontend/Electron && npm run dev",
"electron": "cd Frontend/Electron && npm run electron:dev",
"electron:build": "cd Frontend/Electron && npm run electron:build",
"install:backend": "cd Backend && npm install",
"install:frontend": "cd Frontend/Electron && npm install",
"install:all": "cd Backend && npm install && cd ../../Frontend/Electron && npm install"
}
}