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,7 +1,8 @@
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import { Room, RoomEvent } from 'livekit-client';
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
import { io } from 'socket.io-client';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import '@livekit/components-styles';
import joinSound from '../assets/sounds/join_call.mp3';
@@ -21,13 +22,15 @@ export const VoiceProvider = ({ children }) => {
const [connectionState, setConnectionState] = useState('disconnected');
const [room, setRoom] = useState(null);
const [token, setToken] = useState(null);
const [voiceStates, setVoiceStates] = useState({}); // { channelId: [users...] }
const [activeSpeakers, setActiveSpeakers] = useState(new Set()); // Set<userId>
const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
const socketRef = useRef(null);
const convex = useConvex();
// Reactive voice states from Convex (replaces socket.io)
const voiceStates = useQuery(api.voiceState.getAll) || {};
// Sound Helper
const playSound = (type) => {
@@ -47,52 +50,8 @@ export const VoiceProvider = ({ children }) => {
}
};
// Initialize Socket for Voice States
useEffect(() => {
const socket = io('http://localhost:3000');
socketRef.current = socket;
// ... (Socket logic same as before) ...
socket.on('full_voice_state', (states) => {
setVoiceStates(states);
});
socket.on('voice_state_update', (data) => {
setVoiceStates(prev => {
const newState = { ...prev };
const currentUsers = newState[data.channelId] || [];
if (data.action === 'joined') {
if (!currentUsers.find(u => u.userId === data.userId)) {
currentUsers.push({
userId: data.userId,
username: data.username,
isMuted: data.isMuted,
isDeafened: data.isDeafened
});
}
} else if (data.action === 'left') {
const index = currentUsers.findIndex(u => u.userId === data.userId);
if (index !== -1) currentUsers.splice(index, 1);
} else if (data.action === 'state_update') {
const user = currentUsers.find(u => u.userId === data.userId);
if (user) {
if (data.isMuted !== undefined) user.isMuted = data.isMuted;
if (data.isDeafened !== undefined) user.isDeafened = data.isDeafened;
if (data.isScreenSharing !== undefined) user.isScreenSharing = data.isScreenSharing;
}
}
newState[data.channelId] = [...currentUsers];
return newState;
});
});
socket.emit('request_voice_state');
return () => socket.disconnect();
}, []);
const connectToVoice = async (channelId, channelName, userId) => {
if (activeChannelId === channelId) return;
if (activeChannelId === channelId) return;
if (room) await room.disconnect();
@@ -101,21 +60,22 @@ export const VoiceProvider = ({ children }) => {
setConnectionState('connecting');
try {
const res = await fetch('http://localhost:3000/api/voice/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': userId },
body: JSON.stringify({ channelId })
// Get LiveKit token via Convex action
const { token: lkToken } = await convex.action(api.voice.getToken, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown'
});
const data = await res.json();
if (!data.token) throw new Error('Failed to get token');
setToken(data.token);
if (!lkToken) throw new Error('Failed to get token');
setToken(lkToken);
// Disable adaptiveStream to ensure all tracks are available/subscribed immediately
const newRoom = new Room({ adaptiveStream: false, dynacast: false, autoSubscribe: true });
const liveKitUrl = 'ws://localhost:7880';
await newRoom.connect(liveKitUrl, data.token);
const liveKitUrl = import.meta.env.VITE_LIVEKIT_URL;
await newRoom.connect(liveKitUrl, lkToken);
// Auto-enable microphone & Apply Mute/Deafen State
const shouldEnableMic = !isMuted && !isDeafened;
await newRoom.localParticipant.setMicrophoneEnabled(shouldEnableMic);
@@ -125,22 +85,17 @@ export const VoiceProvider = ({ children }) => {
window.voiceRoom = newRoom; // For debugging
playSound('join');
// Emit Join Event
if (socketRef.current) {
socketRef.current.emit('voice_state_change', {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown',
username: localStorage.getItem('username') || 'Unknown',
action: 'joined',
isMuted: isMuted,
isDeafened: isDeafened,
isScreenSharing: false // Initial state is always false
});
}
// Update voice state in Convex
await convex.mutation(api.voiceState.join, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown',
isMuted: isMuted,
isDeafened: isDeafened,
});
// Events
newRoom.on(RoomEvent.Disconnected, (reason) => {
newRoom.on(RoomEvent.Disconnected, async (reason) => {
console.warn('Voice Room Disconnected. Reason:', reason);
playSound('leave');
setConnectionState('disconnected');
@@ -148,20 +103,16 @@ export const VoiceProvider = ({ children }) => {
setRoom(null);
setToken(null);
setActiveSpeakers(new Set());
// Emit Leave Event
if (socketRef.current) {
socketRef.current.emit('voice_state_change', {
channelId,
userId,
action: 'left'
});
// Remove voice state in Convex
try {
await convex.mutation(api.voiceState.leave, { userId });
} catch (e) {
console.error('Failed to leave voice state:', e);
}
});
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
// speakers is generic Participant[]
// We need to map to user identities (which we used as userId in token usually)
// LiveKit identity = our userId
const newActive = new Set();
speakers.forEach(p => newActive.add(p.identity));
setActiveSpeakers(newActive);
@@ -179,88 +130,70 @@ export const VoiceProvider = ({ children }) => {
if (room) room.disconnect();
};
const toggleMute = () => {
const toggleMute = async () => {
const nextState = !isMuted;
setIsMuted(nextState);
playSound(nextState ? 'mute' : 'unmute');
if (room) {
room.localParticipant.setMicrophoneEnabled(!nextState);
}
if (socketRef.current && activeChannelId) {
socketRef.current.emit('voice_state_change', {
channelId: activeChannelId,
userId: localStorage.getItem('userId'),
username: localStorage.getItem('username'),
action: 'state_update',
isMuted: nextState
});
const userId = localStorage.getItem('userId');
if (userId && activeChannelId) {
try {
await convex.mutation(api.voiceState.updateState, {
userId,
isMuted: nextState,
});
} catch (e) {
console.error('Failed to update mute state:', e);
}
}
};
const toggleDeafen = () => {
const toggleDeafen = async () => {
const nextState = !isDeafened;
setIsDeafened(nextState);
playSound(nextState ? 'deafen' : 'undeafen');
// Logic: if deafened, mute mic too (usually)
if (nextState) {
// If becoming deafened, ensuring mic is muted too is standard discord behavior
if (room && !isMuted) {
room.localParticipant.setMicrophoneEnabled(false);
}
} else {
// If undeafening, restore mic if it wasn't explicitly muted?
// Simplified: If undeafened, and isMuted is false, unmute mic.
if (room && !isMuted) {
room.localParticipant.setMicrophoneEnabled(true);
}
}
if (socketRef.current && activeChannelId) {
socketRef.current.emit('voice_state_change', {
channelId: activeChannelId,
userId: localStorage.getItem('userId'),
username: localStorage.getItem('username'),
action: 'state_update',
isDeafened: nextState // Send the NEW State
const userId = localStorage.getItem('userId');
if (userId && activeChannelId) {
try {
await convex.mutation(api.voiceState.updateState, {
userId,
isDeafened: nextState,
});
} catch (e) {
console.error('Failed to update deafen state:', e);
}
}
};
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
const setScreenSharing = (active) => {
const setScreenSharing = async (active) => {
setIsScreenSharingLocal(active);
// Optimistic Update for UI
setVoiceStates(prev => {
const newState = { ...prev };
if (activeChannelId) {
const currentUsers = newState[activeChannelId] || [];
// Use map to ensure we create a new array/object reference if needed,
// though mutation in React state setter is risky, strictly we should clone.
// But strictly:
const userId = localStorage.getItem('userId');
const userIndex = currentUsers.findIndex(u => u.userId === userId);
if (userIndex !== -1) {
const updatedUser = { ...currentUsers[userIndex], isScreenSharing: active };
const updatedUsers = [...currentUsers];
updatedUsers[userIndex] = updatedUser;
newState[activeChannelId] = updatedUsers;
}
}
return newState;
});
if (socketRef.current && activeChannelId) {
socketRef.current.emit('voice_state_change', {
channelId: activeChannelId,
userId: localStorage.getItem('userId'),
username: localStorage.getItem('username'),
action: 'state_update',
isScreenSharing: active
});
}
const userId = localStorage.getItem('userId');
if (userId && activeChannelId) {
try {
await convex.mutation(api.voiceState.updateState, {
userId,
isScreenSharing: active,
});
} catch (e) {
console.error('Failed to update screen sharing state:', e);
}
}
};
return (
@@ -281,44 +214,6 @@ export const VoiceProvider = ({ children }) => {
isScreenSharing,
setScreenSharing
}}>
{/* Provide LiveKit Context globally if room exists, or a dummy context?
Actually LiveKitRoom requires a token or room. If room is null, we can't render it.
But we need children to always render.
Solution: Only wrap in LiveKitRoom if room exists.
BUT: If we later mistakenly expect context when room is null, it's fine.
However, if we wrap conditionally, the component tree changes when room logic changes, which might unmount children?
No, children are passed in. If we put conditional wrapper around children:
{room ? <LiveKitRoom>{children}</LiveKitRoom> : children}
This WILL remount children when room connects/disconnects. That is BAD for ChatArea etc.
Better: LiveKitRoom ALWAYS, but pass null room? LiveKitRoom might not like null room.
Documentation says: "If you want to manage the room connection yourself... pass the room instance."
Alternative: We only needed LiveKitRoom for VoiceStage (and audio renderer).
ChatArea doesn't need it.
VoiceStage is only rendered when activeChannel.type is voice, which implies we are likely connected (or clicking it connects).
Wait, if I use the conditional wrapper in VoiceContext to wrap children, `Sidebar` (which is a child) might remount?
Yes, `Sidebar` is inside `VoiceProvider`.
If `VoiceProvider` changes structure, `Sidebar` remounts.
Sidebar holds a lot of state? No, usually lifted. But remounting Sidebar is jarring.
Maybe we DON'T wrap children in VoiceContext.
Instead, we keep `VoiceContext` as is (rendering audio renderer), AND `VoiceStage` wraps itself in `LiveKitRoom`.
BUT `VoiceStage` causing disconnect suggests `LiveKitRoom` cleanup is the problem.
Why did `VoiceStage`'s `LiveKitRoom` cause disconnect?
Because on Unmount, `LiveKitRoom` calls `room.disconnect()`.
When user clicks channel, `VoiceStage` Mounts.
But user said "I click on it again and it disconnects me".
Wait, user clicks "again" to SHOW `VoiceStage`.
So `VoiceStage` MOUNTS.
Why does it disconnect?
Maybe `LiveKitRoom` ON MOUNT does something?
Or maybe `Sidebar` logic caused a re-render/disconnect?
In `Sidebar`: `connectToVoice` calls `if (room) await room.disconnect()`.
*/}
{children}
{room && (
<LiveKitRoom