feat: Implement core Discord clone functionality including Convex backend, Electron frontend, and UI components for chat, voice, and settings.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user