feat: initialize Discord clone application with core backend services and Electron frontend.
This commit is contained in:
47
.claude/plans/sorted-beaming-comet.md
Normal file
47
.claude/plans/sorted-beaming-comet.md
Normal 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
|
||||
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
79
Backend/routes/dms.js
Normal 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;
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
32
Backend/scripts/migrate_dms.js
Normal file
32
Backend/scripts/migrate_dms.js
Normal 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();
|
||||
@@ -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) => {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
CLAUDE.md
Normal file
1
CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
**Update this file when making significant changes.**
|
||||
File diff suppressed because one or more lines are too long
BIN
Frontend/Electron/dist-react/assets/ping-LfakLpwb.mp3
Normal file
BIN
Frontend/Electron/dist-react/assets/ping-LfakLpwb.mp3
Normal file
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
<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>
|
||||
<script type="module" crossorigin src="./assets/index-B1qeTixj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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' }}>
|
||||
{/* 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
|
||||
style={{
|
||||
{/* 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',
|
||||
padding: '10px 8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'rgba(255,255,255,0.04)', // Selected state for "Friends"
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '16px'
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={() => onSelectDM('friends')}
|
||||
onClick={() => setShowUserPicker(false)}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{users.map(user => (
|
||||
<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: '#96989d',
|
||||
':hover': { backgroundColor: 'rgba(255,255,255,0.02)', color: '#dcddde' }
|
||||
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={{
|
||||
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: !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'
|
||||
}}>
|
||||
{user.username.substring(0,1).toUpperCase()}
|
||||
{(dm.other_username ?? '?').substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -2, right: -2,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: '#3ba55c', // Assume online
|
||||
backgroundColor: '#3ba55c',
|
||||
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
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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,7 +48,7 @@ const FriendsView = () => {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
{['Online', 'All', 'Pending', 'Blocked'].map(tab => (
|
||||
{['Online', 'All'].map(tab => (
|
||||
<div
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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,8 +60,96 @@ 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
|
||||
@@ -90,31 +223,20 @@ const Chat = () => {
|
||||
onSelectChannel={setActiveChannel}
|
||||
username={username}
|
||||
channelKeys={channelKeys}
|
||||
onChannelCreated={refreshData} // Use refresh instead of reload
|
||||
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
10
package-lock.json
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "discord-clone",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "discord-clone"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
package.json
Normal file
13
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user