Files
DiscordClone/Frontend/Electron/src/pages/Chat.jsx
Bryan1029384756 b0f889cb68
All checks were successful
Build and Release / build-and-release (push) Successful in 9m12s
feat: Implement core chat page with channel navigation, direct messages, and voice chat integration.
2026-02-11 17:44:50 -06:00

271 lines
11 KiB
JavaScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
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';
import { useVoice } from '../contexts/VoiceContext';
import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader';
import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext';
const Chat = () => {
const [view, setView] = useState('server');
const [activeChannel, setActiveChannel] = useState(null);
const [username, setUsername] = useState('');
const [userId, setUserId] = useState(null);
const [channelKeys, setChannelKeys] = useState({});
const [activeDMChannel, setActiveDMChannel] = useState(null);
const [showMembers, setShowMembers] = useState(true);
const [showPinned, setShowPinned] = useState(false);
const convex = useConvex();
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
const prevDmChannelsRef = useRef(null);
const { toggleMute } = useVoice();
// Keyboard shortcuts
useEffect(() => {
const handler = (e) => {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
// Quick switcher placeholder - could open a search modal
}
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
toggleMute();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [toggleMute]);
const channels = useQuery(api.channels.list) || [];
const categories = useQuery(api.categories.list) || [];
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
userId ? { userId } : "skip"
);
const dmChannels = useQuery(
api.dms.listDMs,
userId ? { userId } : "skip"
) || [];
useEffect(() => {
const storedUsername = localStorage.getItem('username');
const storedUserId = localStorage.getItem('userId');
if (storedUsername) setUsername(storedUsername);
if (storedUserId) setUserId(storedUserId);
}, []);
useEffect(() => {
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
const privateKey = sessionStorage.getItem('privateKey');
if (!privateKey) return;
async function decryptKeys() {
const keys = {};
for (const item of rawChannelKeys) {
try {
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
Object.assign(keys, JSON.parse(bundleJson));
} catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
}
}
setChannelKeys(keys);
}
decryptKeys();
}, [rawChannelKeys]);
useEffect(() => {
if (activeChannel || channels.length === 0) return;
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');
if (!uid) return;
try {
const { channelId, created } = await convex.mutation(api.dms.openDM, {
userId: uid,
targetUserId
});
if (created) {
const keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
const allUsers = await convex.query(api.auth.getPublicKeys, {});
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 convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
}
}
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
setView('me');
} catch (err) {
console.error('Error opening DM:', err);
}
}, [convex]);
const handleSelectChannel = useCallback((channelId) => {
setActiveChannel(channelId);
setShowPinned(false);
}, []);
const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates } = useVoice();
const isDMView = view === 'me' && activeDMChannel;
const isServerTextChannel = view === 'server' && activeChannel && activeChannelObj?.type !== 'voice';
const currentChannelId = isDMView ? activeDMChannel.channel_id : activeChannel;
function renderMainContent() {
if (view === 'me') {
if (activeDMChannel) {
return (
<div className="chat-container">
<ChatHeader
channelName={activeDMChannel.other_username}
channelType="dm"
onToggleMembers={() => {}}
showMembers={false}
onTogglePinned={() => setShowPinned(p => !p)}
/>
<div className="chat-content">
<ChatArea
channelId={activeDMChannel.channel_id}
channelName={activeDMChannel.other_username}
channelType="dm"
channelKey={channelKeys[activeDMChannel.channel_id]}
username={username}
userId={userId}
showMembers={false}
onToggleMembers={() => {}}
onOpenDM={openDM}
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/>
</div>
</div>
);
}
return <FriendsView onOpenDM={openDM} />;
}
if (activeChannel) {
if (activeChannelObj?.type === 'voice') {
return <VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />;
}
return (
<div className="chat-container">
<ChatHeader
channelName={activeChannelObj?.name || activeChannel}
channelType="text"
channelTopic={activeChannelObj?.topic}
onToggleMembers={() => setShowMembers(!showMembers)}
showMembers={showMembers}
onTogglePinned={() => setShowPinned(p => !p)}
serverName="Secure Chat"
/>
<div className="chat-content">
<ChatArea
channelId={activeChannel}
channelName={activeChannelObj?.name || activeChannel}
channelType="text"
channelKey={channelKeys[activeChannel]}
username={username}
userId={userId}
showMembers={showMembers}
onToggleMembers={() => setShowMembers(!showMembers)}
onOpenDM={openDM}
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/>
<MembersList
channelId={activeChannel}
visible={showMembers}
onMemberClick={(member) => {}}
/>
</div>
</div>
);
}
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>
);
}
if (!userId) {
return (
<div className="app-container">
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe' }}>
Loading...
</div>
</div>
);
}
return (
<PresenceProvider userId={userId}>
<div className="app-container">
<Sidebar
channels={channels}
categories={categories}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={setView}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
/>
{renderMainContent()}
<ToastContainer />
</div>
</PresenceProvider>
);
};
export default Chat;