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

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

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { io } from 'socket.io-client';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Sidebar from '../components/Sidebar';
import ChatArea from '../components/ChatArea';
import VoiceStage from '../components/VoiceStage';
@@ -9,66 +10,71 @@ import FriendsView from '../components/FriendsView';
const Chat = () => {
const [view, setView] = useState('server'); // 'server' | 'me'
const [activeChannel, setActiveChannel] = useState(null);
const [channels, setChannels] = useState([]);
const [username, setUsername] = useState('');
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 convex = useConvex();
// Reactive channel list from Convex (auto-updates!)
const channels = useQuery(api.channels.list) || [];
// Reactive channel keys from Convex
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
userId ? { userId } : "skip"
);
// Reactive DM channels from Convex
const dmChannels = useQuery(
api.dms.listDMs,
userId ? { userId } : "skip"
) || [];
// Initialize user from localStorage
useEffect(() => {
const storedUsername = localStorage.getItem('username');
const userId = localStorage.getItem('userId');
const privateKey = sessionStorage.getItem('privateKey');
const storedUserId = localStorage.getItem('userId');
if (storedUsername) setUsername(storedUsername);
if (userId) setUserId(userId);
if (userId && privateKey) {
// Fetch Encrypted Channel Keys
fetch(`http://localhost:3000/api/channels/keys/${userId}`)
.then(res => res.json())
.then(async (data) => {
const keys = {};
for (const item of data) {
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);
})
.catch(err => console.error('Error fetching channel keys:', err));
}
fetch('http://localhost:3000/api/channels')
.then(res => res.json())
.then(data => {
setChannels(data);
if (!activeChannel && data.length > 0) {
const firstTextChannel = data.find(c => c.type === 'text');
if (firstTextChannel) {
setActiveChannel(firstTextChannel.id);
}
}
})
.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));
if (storedUserId) setUserId(storedUserId);
}, []);
// Decrypt channel keys when raw keys change
useEffect(() => {
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
const privateKey = sessionStorage.getItem('privateKey');
if (!privateKey) return;
const decryptKeys = async () => {
const keys = {};
for (const item of rawChannelKeys) {
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);
};
decryptKeys();
}, [rawChannelKeys]);
// Auto-select first text channel when channels load
useEffect(() => {
if (!activeChannel && channels.length > 0) {
const firstTextChannel = channels.find(c => c.type === 'text');
if (firstTextChannel) {
setActiveChannel(firstTextChannel._id);
}
}
}, [channels, activeChannel]);
const openDM = useCallback(async (targetUserId, targetUsername) => {
const uid = localStorage.getItem('userId');
const privateKey = sessionStorage.getItem('privateKey');
@@ -76,12 +82,10 @@ const Chat = () => {
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 convex.mutation(api.dms.openDM, {
userId: uid,
targetUserId
});
const { channelId, created } = await res.json();
// 2. If newly created, generate + distribute an AES key for both users
if (created) {
@@ -90,8 +94,7 @@ const Chat = () => {
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 allUsers = await convex.query(api.auth.getPublicKeys, {});
const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId);
const batchKeys = [];
@@ -112,65 +115,22 @@ const Chat = () => {
}
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);
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
}
// Channel keys will auto-update via reactive query
}
// 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');
socket.on('new_channel', (channel) => {
console.log("New Channel Detected:", channel);
refreshData(); // Re-fetch keys/channels
});
socket.on('channel_renamed', () => refreshData());
socket.on('channel_deleted', (id) => {
refreshData();
if (activeChannel === id) setActiveChannel(null);
});
return () => socket.disconnect();
}, []);
}, [convex]);
// Helper to get active channel object
const activeChannelObj = channels.find(c => c.id === activeChannel);
const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates } = useVoice();
@@ -223,13 +183,9 @@ const Chat = () => {
onSelectChannel={setActiveChannel}
username={username}
channelKeys={channelKeys}
onChannelCreated={refreshData}
view={view}
onViewChange={(v) => {
setView(v);
if (v === 'me') {
fetchDMChannels();
}
}}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}