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) => {
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);
} catch (err) {
console.error(err);

View File

@@ -4,7 +4,7 @@ const db = require('../db');
router.get('/', async (req, res) => {
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);
} catch (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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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()
);
@@ -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)
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 inviteRoutes = require('./routes/invites');
const rolesRoutes = require('./routes/roles');
const dmRoutes = require('./routes/dms');
app.use(cors());
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/roles', rolesRoutes);
app.use('/api/dms', dmRoutes);
app.use('/api/voice', require('./routes/voice'));
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

@@ -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>
);
};

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"
}
}