269 lines
10 KiB
JavaScript
269 lines
10 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 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}
|
|
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;
|