feat: Implement initial Electron frontend with chat area, user settings, and voice context, and update the TODO list.
All checks were successful
Build and Release / build-and-release (push) Successful in 9m14s

This commit is contained in:
Bryan1029384756
2026-02-13 10:39:01 -06:00
parent 556a561449
commit 63d4208933
5 changed files with 44 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "discord",
"private": true,
"version": "1.0.12",
"version": "1.0.13",
"description": "A Discord clone built with Convex, React, and Electron",
"author": "Moyettes",
"type": "module",

View File

@@ -1255,7 +1255,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
<div className="loading-spinner" />
</div>
)}
{status === 'Exhausted' && decryptedMessages.length > 0 && (
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
<div className="channel-beginning">
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
<h1 className="channel-beginning-title">

View File

@@ -4,6 +4,7 @@ import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar';
import AvatarCropModal from './AvatarCropModal';
import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
import { useVoice } from '../contexts/VoiceContext';
const THEME_PREVIEWS = {
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
@@ -600,6 +601,7 @@ const AppearanceTab = () => {
VOICE & VIDEO TAB
========================================= */
const VoiceVideoTab = () => {
const { switchDevice, setGlobalOutputVolume } = useVoice();
const [inputDevices, setInputDevices] = useState([]);
const [outputDevices, setOutputDevices] = useState([]);
const [selectedInput, setSelectedInput] = useState(() => localStorage.getItem('voiceInputDevice') || 'default');
@@ -631,10 +633,12 @@ const VoiceVideoTab = () => {
useEffect(() => {
localStorage.setItem('voiceInputDevice', selectedInput);
switchDevice('audioinput', selectedInput);
}, [selectedInput]);
useEffect(() => {
localStorage.setItem('voiceOutputDevice', selectedOutput);
switchDevice('audiooutput', selectedOutput);
}, [selectedOutput]);
useEffect(() => {
@@ -643,6 +647,7 @@ const VoiceVideoTab = () => {
useEffect(() => {
localStorage.setItem('voiceOutputVolume', String(outputVolume));
setGlobalOutputVolume(outputVolume);
}, [outputVolume]);
const startMicTest = async () => {

View File

@@ -54,6 +54,9 @@ export const VoiceProvider = ({ children }) => {
const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
const [globalOutputVolume, setGlobalOutputVolume] = useState(() =>
parseInt(localStorage.getItem('voiceOutputVolume') || '100')
);
const isMovingRef = useRef(false);
const convex = useConvex();
@@ -105,9 +108,10 @@ export const VoiceProvider = ({ children }) => {
localStorage.setItem('userVolumes', JSON.stringify(next));
return next;
});
// Apply volume to LiveKit participant
// Apply volume to LiveKit participant (factoring in global output volume)
const participant = room?.remoteParticipants?.get(userId);
if (participant) participant.setVolume(volume / 100);
const globalVol = globalOutputVolume / 100;
if (participant) participant.setVolume((volume / 100) * globalVol);
// Sync personal mute state
if (volume === 0) {
setPersonallyMutedUsers(prev => {
@@ -125,13 +129,14 @@ export const VoiceProvider = ({ children }) => {
return next;
});
}
}, [room]);
}, [room, globalOutputVolume]);
const getUserVolume = useCallback((userId) => {
return userVolumes[userId] ?? 100;
}, [userVolumes]);
const togglePersonalMute = (userId) => {
const globalVol = globalOutputVolume / 100;
setPersonallyMutedUsers(prev => {
const next = new Set(prev);
if (next.has(userId)) {
@@ -140,7 +145,7 @@ export const VoiceProvider = ({ children }) => {
const vol = userVolumes[userId] ?? 100;
const restoreVol = vol === 0 ? 100 : vol;
const participant = room?.remoteParticipants?.get(userId);
if (participant) participant.setVolume(restoreVol / 100);
if (participant) participant.setVolume((restoreVol / 100) * globalVol);
// Update stored volume if it was 0
if (vol === 0) {
setUserVolumes(p => {
@@ -224,6 +229,9 @@ export const VoiceProvider = ({ children }) => {
setToken(lkToken);
const storedInputDevice = localStorage.getItem('voiceInputDevice');
const storedOutputDevice = localStorage.getItem('voiceOutputDevice');
const newRoom = new Room({
adaptiveStream: true,
dynacast: true,
@@ -234,6 +242,7 @@ export const VoiceProvider = ({ children }) => {
noiseSuppression: false,
channelCount: 1,
sampleRate: 48000,
...(storedInputDevice && storedInputDevice !== 'default' ? { deviceId: { exact: storedInputDevice } } : {}),
},
publishDefaults: {
audioPreset: { maxBitrate: 96_000 },
@@ -248,6 +257,12 @@ export const VoiceProvider = ({ children }) => {
});
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
if (storedOutputDevice && storedOutputDevice !== 'default') {
await newRoom.switchActiveDevice('audiooutput', storedOutputDevice).catch(e =>
console.warn('Failed to switch output device on connect:', e)
);
}
await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened);
setRoom(newRoom);
@@ -354,19 +369,21 @@ export const VoiceProvider = ({ children }) => {
// Re-apply personal mutes/volumes when room or participants change
useEffect(() => {
if (!room) return;
const globalVol = globalOutputVolume / 100;
const applyVolumes = () => {
for (const [identity, participant] of room.remoteParticipants) {
if (personallyMutedUsers.has(identity)) {
participant.setVolume(0);
} else {
participant.setVolume((userVolumes[identity] ?? 100) / 100);
const userVol = (userVolumes[identity] ?? 100) / 100;
participant.setVolume(userVol * globalVol);
}
}
};
applyVolumes();
room.on(RoomEvent.ParticipantConnected, applyVolumes);
return () => room.off(RoomEvent.ParticipantConnected, applyVolumes);
}, [room, personallyMutedUsers, userVolumes]);
}, [room, personallyMutedUsers, userVolumes, globalOutputVolume]);
// AFK idle polling: move user to AFK channel when idle exceeds timeout
useEffect(() => {
@@ -587,6 +604,15 @@ export const VoiceProvider = ({ children }) => {
await updateVoiceState({ isScreenSharing: active });
};
const switchDevice = useCallback(async (kind, deviceId) => {
if (!room) return;
try {
await room.switchActiveDevice(kind, deviceId);
} catch (e) {
console.warn(`Failed to switch ${kind} device:`, e);
}
}, [room]);
return (
<VoiceContext.Provider value={{
activeChannelId,
@@ -616,6 +642,9 @@ export const VoiceProvider = ({ children }) => {
serverSettings,
watchingStreamOf,
setWatchingStreamOf,
switchDevice,
globalOutputVolume,
setGlobalOutputVolume,
}}>
{children}
{room && (