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

@@ -22,6 +22,7 @@ import EmojiesColored from './emojies_colored.png';
import EmojiesGreyscale from './emojies_greyscale.png';
import TypingIcon from './typing.svg';
import DMIcon from './dm.svg';
import SpoilerIcon from './spoiler.svg';
export {
AddIcon,
@@ -47,7 +48,8 @@ export {
DeleteIcon,
PinIcon,
TypingIcon,
DMIcon
DMIcon,
SpoilerIcon
};
export const Icons = {
@@ -74,5 +76,6 @@ export const Icons = {
Delete: DeleteIcon,
Pin: PinIcon,
Typing: TypingIcon,
DM: DMIcon
DM: DMIcon,
Spoiler: SpoilerIcon
};

View File

@@ -0,0 +1 @@
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M12 5C5.648 5 1 12 1 12s4.648 7 11 7 11-7 11-7-4.648-7-11-7m0 12c-2.761 0-5-2.239-5-5s2.239-5 5-5 5 2.239 5 5-2.239 5-5 5"/><circle fill="currentColor" cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@@ -1,49 +1,41 @@
import React, { useState } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
const [name, setName] = useState(channel.name);
const [activeTab, setActiveTab] = useState('Overview');
const convex = useConvex();
const handleSave = async () => {
try {
const res = await fetch(`http://localhost:3000/api/channels/${channel.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (res.ok) {
onRename(channel.id, name);
onClose();
} else {
alert('Failed to update channel');
}
await convex.mutation(api.channels.rename, { id: channel._id, name });
onRename(channel._id, name);
onClose();
} catch (err) {
console.error(err);
alert('Failed to update channel: ' + err.message);
}
};
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this channel? This cannot be undone.')) return;
try {
const res = await fetch(`http://localhost:3000/api/channels/${channel.id}`, {
method: 'DELETE'
});
if (res.ok) {
onDelete(channel.id);
onClose();
} else {
alert('Failed to delete channel');
}
await convex.mutation(api.channels.remove, { id: channel._id });
onDelete(channel._id);
onClose();
} catch (err) {
console.error(err);
alert('Failed to delete channel: ' + err.message);
}
};
return (
<div style={{
position: 'fixed',
top: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
@@ -71,8 +63,8 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
}}>
{channel.name} Text Channels
</div>
<div
<div
onClick={() => setActiveTab('Overview')}
style={{
padding: '6px 10px',
@@ -86,11 +78,11 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
>
Overview
</div>
<div style={{ height: '1px', backgroundColor: '#3f4147', margin: '8px 0' }} />
<div
onClick={() => setActiveTab('Delete')} // Simplify: Just switch content or trigger? UI screenshot implies a tab
<div
onClick={() => setActiveTab('Delete')}
style={{
padding: '6px 10px',
borderRadius: '4px',
@@ -113,7 +105,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
<h2 style={{ color: 'white', margin: 0 }}>
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
</h2>
<button
<button
onClick={onClose}
style={{
background: 'transparent',
@@ -143,8 +135,8 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
}}>
Channel Name
</label>
<input
type="text"
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
style={{
@@ -205,8 +197,8 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
)}
</div>
<div style={{ flex: 0.5 }}>
<div style={{ flex: 0.5 }}>
{/* Right side spacer like real Discord */}
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [showUserPicker, setShowUserPicker] = useState(false);
@@ -6,6 +8,8 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [searchQuery, setSearchQuery] = useState('');
const searchRef = useRef(null);
const convex = useConvex();
const getUserColor = (username) => {
if (!username) return '#5865F2';
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -16,17 +20,17 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
return colors[Math.abs(hash) % colors.length];
};
const handleOpenUserPicker = () => {
const handleOpenUserPicker = async () => {
setShowUserPicker(true);
setSearchQuery('');
// Fetch all users for the picker
fetch('http://localhost:3000/api/auth/users/public-keys')
.then(res => res.json())
.then(data => {
const myId = localStorage.getItem('userId');
setAllUsers(data.filter(u => u.id !== myId));
})
.catch(err => console.error(err));
// Fetch all users via Convex query
try {
const data = await convex.query(api.auth.getPublicKeys, {});
const myId = localStorage.getItem('userId');
setAllUsers(data.filter(u => u.id !== myId));
} catch (err) {
console.error(err);
}
};
useEffect(() => {

View File

@@ -1,16 +1,15 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const FriendsView = ({ onOpenDM }) => {
const [users, setUsers] = useState([]);
const [activeTab, setActiveTab] = useState('Online');
useEffect(() => {
const myId = localStorage.getItem('userId');
fetch('http://localhost:3000/api/auth/users/public-keys')
.then(res => res.json())
.then(data => setUsers(data.filter(u => u.id !== myId)))
.catch(err => console.error(err));
}, []);
const myId = localStorage.getItem('userId');
// Reactive query for all users' public keys
const allUsers = useQuery(api.auth.getPublicKeys) || [];
const users = allUsers.filter(u => u.id !== myId);
const getUserColor = (username) => {
if (!username) return '#747f8d';
@@ -22,9 +21,7 @@ const FriendsView = ({ onOpenDM }) => {
return colors[Math.abs(hash) % colors.length];
};
// Filter logic
const filteredUsers = users; // For now, assume all are friends.
// In real app, "Online" would filter by status.
const filteredUsers = users;
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', height: '100vh' }}>
@@ -46,7 +43,7 @@ const FriendsView = ({ onOpenDM }) => {
</svg>
Friends
</div>
<div style={{ display: 'flex', gap: '16px' }}>
{['Online', 'All'].map(tab => (
<div
@@ -81,7 +78,7 @@ const FriendsView = ({ onOpenDM }) => {
{/* Friends List */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 20px' }}>
{filteredUsers.map(user => (
<div
<div
key={user.id}
style={{
display: 'flex',
@@ -121,7 +118,7 @@ const FriendsView = ({ onOpenDM }) => {
</div>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '8px' }}>
<div

View File

@@ -1,5 +1,7 @@
import CategorizedEmojis, { AllEmojis } from '../assets/emojis';
import React, { useState, useEffect, useRef } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) => {
const [search, setSearch] = useState('');
@@ -7,7 +9,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [internalActiveTab, setInternalActiveTab] = useState(initialTab || 'GIFs');
// Resolve effective active tab
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
const setActiveTab = (tab) => {
@@ -19,22 +21,22 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
const [collapsedCategories, setCollapsedCategories] = useState({});
const inputRef = useRef(null);
const convex = useConvex();
useEffect(() => {
// Fetch categories on mount
fetch('http://localhost:3000/api/gifs/categories')
.then(res => res.json())
// Fetch categories via Convex action
convex.action(api.gifs.categories, {})
.then(data => {
if (data.categories) setCategories(data.categories);
})
.catch(err => console.error('Failed to load categories', err));
// Auto focus
if(inputRef.current) inputRef.current.focus();
// Load Emoji categories
setEmojiCategories(CategorizedEmojis);
// Initialize collapsed state (all true)
const initialCollapsed = {};
Object.keys(CategorizedEmojis).forEach(cat => initialCollapsed[cat] = true);
@@ -43,14 +45,13 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
useEffect(() => {
const fetchResults = async () => {
if (!search || activeTab !== 'GIFs') { // Only search API for GIFs
if (!search || activeTab !== 'GIFs') {
setResults([]);
return;
}
setLoading(true);
try {
const res = await fetch(`http://localhost:3000/api/gifs/search?q=${encodeURIComponent(search)}`);
const data = await res.json();
const data = await convex.action(api.gifs.search, { q: search });
setResults(data.results || []);
} catch (err) {
console.error(err);
@@ -75,7 +76,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
};
return (
<div
<div
className="gif-picker"
style={{
position: 'absolute',
@@ -91,12 +92,12 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
overflow: 'hidden',
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()} // Prevent close when clicking inside
onClick={(e) => e.stopPropagation()}
>
{/* Header / Tabs */}
<div style={{ padding: '16px 16px 8px 16px', display: 'flex', gap: '16px', borderBottom: '1px solid #202225' }}>
{['GIFs', 'Stickers', 'Emoji'].map(tab => (
<button
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
@@ -164,9 +165,9 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
(search || results.length > 0) ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{results.map(gif => (
<img
key={gif.id}
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
<img
key={gif.id}
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
alt={gif.title}
style={{ width: '100%', borderRadius: '4px', cursor: 'pointer' }}
onMouseDown={(e) => e.preventDefault()}
@@ -178,10 +179,10 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
) : (
// GIF Categories
<div>
<div style={{
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
borderRadius: '4px',
padding: '20px',
<div style={{
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
borderRadius: '4px',
padding: '20px',
marginBottom: '12px',
color: '#fff',
fontWeight: 'bold',
@@ -194,7 +195,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
{/* Grid of Categories */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{categories.map(cat => (
<div
<div
key={cat.name}
onClick={() => handleCategoryClick(cat.name)}
style={{
@@ -206,11 +207,11 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
backgroundColor: '#202225'
}}
>
<video
src={cat.src}
autoPlay
loop
muted
<video
src={cat.src}
autoPlay
loop
muted
style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: 0.6 }}
/>
<div style={{
@@ -238,12 +239,12 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
// Emoji Search Results
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' }}>
{AllEmojis.filter(e => e.name.toLowerCase().includes(search.toLowerCase().replace(/:/g, '')))
.slice(0, 100) // Optimization: Limit to top 100 results
.slice(0, 100)
.map((emoji, idx) => (
<div
<div
key={idx}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })} // Pass object to distinguish
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
title={`:${emoji.name}:`}
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
@@ -254,13 +255,12 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
))}
</div>
) : (
// Emoji Categories
// Emoji Categories
Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} style={{ marginBottom: '8px' }}>
<div
<div
onClick={() => toggleCategory(category)}
style={{
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
@@ -271,17 +271,17 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#b9bbbe"
strokeWidth="3"
strokeLinecap="round"
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#b9bbbe"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
style={{
marginRight: '8px',
transform: collapsedCategories[category] ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
@@ -289,10 +289,10 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<h3 style={{
color: '#b9bbbe',
fontSize: '12px',
textTransform: 'uppercase',
<h3 style={{
color: '#b9bbbe',
fontSize: '12px',
textTransform: 'uppercase',
fontWeight: 700,
margin: 0
}}>
@@ -302,7 +302,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
{!collapsedCategories[category] && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' }}>
{emojis.map((emoji, idx) => (
<div
<div
key={idx}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
@@ -311,10 +311,10 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<img
src={emoji.src}
alt={emoji.name}
style={{ width: '32px', height: '32px' }}
<img
src={emoji.src}
alt={emoji.name}
style={{ width: '32px', height: '32px' }}
loading="lazy"
/>
</div>

View File

@@ -1,91 +1,66 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const ServerSettingsModal = ({ onClose }) => {
const [activeTab, setActiveTab] = useState('Overview');
const [roles, setRoles] = useState([]);
const [members, setMembers] = useState([]);
const [selectedRole, setSelectedRole] = useState(null);
const [permissions, setPermissions] = useState({
manage_channels: false,
manage_roles: false,
create_invite: false,
embed_links: true,
attach_files: true
});
const [myPermissions, setMyPermissions] = useState({});
const userId = localStorage.getItem('userId');
const convex = useConvex();
useEffect(() => {
if (!userId) return;
fetchRoles();
fetchMembers();
fetchMyPermissions();
}, [userId]);
const getHeaders = () => ({
'Content-Type': 'application/json',
'x-user-id': userId
});
const fetchMyPermissions = async () => {
try {
const res = await fetch('http://localhost:3000/api/roles/permissions', { headers: getHeaders() });
const data = await res.json();
setMyPermissions(data);
} catch (e) { console.error(e); }
};
const fetchRoles = async () => {
const res = await fetch('http://localhost:3000/api/roles', { headers: getHeaders() });
const data = await res.json();
setRoles(data);
};
const fetchMembers = async () => {
const res = await fetch('http://localhost:3000/api/roles/members', { headers: getHeaders() });
const data = await res.json();
setMembers(data);
};
// Reactive queries from Convex
const roles = useQuery(api.roles.list) || [];
const members = useQuery(api.roles.listMembers) || [];
const myPermissions = useQuery(
api.roles.getMyPermissions,
userId ? { userId } : "skip"
) || {};
const handleCreateRole = async () => {
const res = await fetch('http://localhost:3000/api/roles', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ name: 'new role', color: '#99aab5' })
});
const newRole = await res.json();
setRoles([...roles, newRole]);
setSelectedRole(newRole);
try {
const newRole = await convex.mutation(api.roles.create, {
name: 'new role',
color: '#99aab5'
});
setSelectedRole(newRole);
} catch (e) {
console.error('Failed to create role:', e);
}
};
const handleUpdateRole = async (id, updates) => {
const res = await fetch(`http://localhost:3000/api/roles/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(updates)
});
const updated = await res.json();
setRoles(roles.map(r => r.id === id ? updated : r));
if (selectedRole && selectedRole.id === id) {
setSelectedRole(updated);
try {
const updated = await convex.mutation(api.roles.update, { id, ...updates });
if (selectedRole && selectedRole._id === id) {
setSelectedRole(updated);
}
} catch (e) {
console.error('Failed to update role:', e);
}
};
const handleDeleteRole = async (id) => {
if (!confirm('Delete this role?')) return;
await fetch(`http://localhost:3000/api/roles/${id}`, { method: 'DELETE', headers: getHeaders() });
setRoles(roles.filter(r => r.id !== id));
if (selectedRole && selectedRole.id === id) setSelectedRole(null);
try {
await convex.mutation(api.roles.remove, { id });
if (selectedRole && selectedRole._id === id) setSelectedRole(null);
} catch (e) {
console.error('Failed to delete role:', e);
}
};
const handleAssignRole = async (roleId, userId, isAdding) => {
const endpoint = isAdding ? 'assign' : 'remove';
await fetch(`http://localhost:3000/api/roles/${roleId}/${endpoint}`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ userId })
});
fetchMembers(); // Refresh to show
const handleAssignRole = async (roleId, targetUserId, isAdding) => {
try {
if (isAdding) {
await convex.mutation(api.roles.assign, { roleId, userId: targetUserId });
} else {
await convex.mutation(api.roles.unassign, { roleId, userId: targetUserId });
}
// Convex reactive queries auto-update members list
} catch (e) {
console.error('Failed to assign/unassign role:', e);
}
};
// Render Tabs
@@ -100,7 +75,7 @@ const ServerSettingsModal = ({ onClose }) => {
Server Settings
</div>
{['Overview', 'Roles', 'Members'].map(tab => (
<div
<div
key={tab}
onClick={() => setActiveTab(tab)}
style={{
@@ -128,12 +103,12 @@ const ServerSettingsModal = ({ onClose }) => {
)}
</div>
{roles.filter(r => r.name !== 'Owner').map(r => (
<div
key={r.id}
<div
key={r._id}
onClick={() => setSelectedRole(r)}
style={{
padding: '6px',
backgroundColor: selectedRole?.id === r.id ? '#40444b' : 'transparent',
style={{
padding: '6px',
backgroundColor: selectedRole?._id === r._id ? '#40444b' : 'transparent',
borderRadius: '4px', cursor: 'pointer', color: r.color || '#b9bbbe',
display: 'flex', alignItems: 'center'
}}
@@ -148,20 +123,20 @@ const ServerSettingsModal = ({ onClose }) => {
{selectedRole ? (
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}>
<h2 style={{ color: 'white', marginTop: 0 }}>Edit Role - {selectedRole.name}</h2>
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>ROLE NAME</label>
<input
value={selectedRole.name}
onChange={(e) => handleUpdateRole(selectedRole.id, { name: e.target.value })}
<input
value={selectedRole.name}
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
disabled={!myPermissions.manage_roles}
style={{ width: '100%', padding: 10, background: '#202225', border: 'none', borderRadius: 4, color: 'white', marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
/>
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>ROLE COLOR</label>
<input
<input
type="color"
value={selectedRole.color}
onChange={(e) => handleUpdateRole(selectedRole.id, { color: e.target.value })}
value={selectedRole.color}
onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })}
disabled={!myPermissions.manage_roles}
style={{ width: '100%', height: 40, border: 'none', padding: 0, marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
/>
@@ -170,12 +145,12 @@ const ServerSettingsModal = ({ onClose }) => {
{['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files'].map(perm => (
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid #3f4147' }}>
<span style={{ color: 'white', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
<input
<input
type="checkbox"
checked={selectedRole.permissions?.[perm] || false}
onChange={(e) => {
const newPerms = { ...selectedRole.permissions, [perm]: e.target.checked };
handleUpdateRole(selectedRole.id, { permissions: newPerms });
handleUpdateRole(selectedRole._id, { permissions: newPerms });
}}
disabled={!myPermissions.manage_roles}
style={{ transform: 'scale(1.5)', opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
@@ -185,7 +160,7 @@ const ServerSettingsModal = ({ onClose }) => {
{/* Prevent deleting Default Roles */}
{myPermissions.manage_roles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
<button onClick={() => handleDeleteRole(selectedRole.id)} style={{ color: '#ed4245', background: 'transparent', border: '1px solid #ed4245', padding: '6px 12px', borderRadius: 4, marginTop: 20, cursor: 'pointer' }}>
<button onClick={() => handleDeleteRole(selectedRole._id)} style={{ color: '#ed4245', background: 'transparent', border: '1px solid #ed4245', padding: '6px 12px', borderRadius: 4, marginTop: 20, cursor: 'pointer' }}>
Delete Role
</button>
)}
@@ -208,23 +183,22 @@ const ServerSettingsModal = ({ onClose }) => {
<div style={{ color: 'white', fontWeight: 'bold' }}>{m.username}</div>
<div style={{ display: 'flex', gap: 4 }}>
{m.roles && m.roles.map(r => (
<span key={r.id} style={{ fontSize: 10, background: r.color, color: 'white', padding: '2px 4px', borderRadius: 4 }}>
<span key={r._id} style={{ fontSize: 10, background: r.color, color: 'white', padding: '2px 4px', borderRadius: 4 }}>
{r.name}
</span>
))}
</div>
</div>
{/* Add Role Dropdown/Buttons logic here - simplified for now */}
<div style={{ display: 'flex', gap: 4 }}>
{roles.filter(r => r.name !== 'Owner').map(r => {
const hasRole = m.roles?.some(ur => ur.id === r.id);
const hasRole = m.roles?.some(ur => ur._id === r._id);
return (
myPermissions.manage_roles && (
<button
key={r.id}
onClick={() => handleAssignRole(r.id, m.id, !hasRole)}
style={{
width: 16, height: 16, borderRadius: '50%',
<button
key={r._id}
onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
style={{
width: 16, height: 16, borderRadius: '50%',
border: `2px solid ${r.color}`,
background: hasRole ? r.color : 'transparent',
cursor: 'pointer'

View File

@@ -1,16 +1,18 @@
import React, { useState } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { useVoice } from '../contexts/VoiceContext';
import ChannelSettingsModal from './ChannelSettingsModal';
import ServerSettingsModal from './ServerSettingsModal';
import ChannelSettingsModal from './ChannelSettingsModal';
import ServerSettingsModal from './ServerSettingsModal';
import ScreenShareModal from './ScreenShareModal';
import DMList from './DMList'; // Import DMList
import DMList from './DMList';
import { Track } from 'livekit-client';
import muteIcon from '../assets/icons/mute.svg';
import mutedIcon from '../assets/icons/muted.svg';
import defeanIcon from '../assets/icons/defean.svg';
import defeanedIcon from '../assets/icons/defeaned.svg';
import settingsIcon from '../assets/icons/settings.svg';
import voiceIcon from '../assets/icons/voice.svg';
import voiceIcon from '../assets/icons/voice.svg';
import disconnectIcon from '../assets/icons/disconnect.svg';
import cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.svg';
@@ -24,28 +26,26 @@ const ColoredIcon = ({ src, color, size = '20px' }) => (
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0 // Prevent shrinking in flex containers
flexShrink: 0
}}>
<img
src={src}
alt=""
<img
src={src}
alt=""
style={{
width: size,
height: size,
transform: 'translateX(-1000px)',
filter: `drop-shadow(1000px 0 0 ${color})`
}}
}}
/>
</div>
);
const UserControlPanel = ({ username }) => {
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
// Check if muted explicitly OR implicitly via deafen
// User requested: "turn the mic icon red to show they are muted also"
const effectiveMute = isMuted || isDeafened;
const getUserColor = (name) => {
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
@@ -70,14 +70,14 @@ const UserControlPanel = ({ username }) => {
flexShrink: 0
}}>
{/* User Info */}
<div style={{
display: 'flex',
alignItems: 'center',
<div style={{
display: 'flex',
alignItems: 'center',
marginRight: 'auto',
padding: '4px',
borderRadius: '4px',
cursor: 'pointer',
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' }
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' }
}}>
<div style={{ position: 'relative', marginRight: '8px' }}>
<div style={{
@@ -118,7 +118,7 @@ const UserControlPanel = ({ username }) => {
{/* Controls */}
<div style={{ display: 'flex' }}>
<button
<button
onClick={toggleMute}
title={effectiveMute ? "Unmute" : "Mute"}
style={{
@@ -132,12 +132,12 @@ const UserControlPanel = ({ username }) => {
justifyContent: 'center'
}}
>
<ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
<ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
<button
onClick={toggleDeafen}
title={isDeafened ? "Undeafen" : "Deafen"}
style={{
@@ -151,12 +151,12 @@ const UserControlPanel = ({ username }) => {
justifyContent: 'center'
}}
>
<ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
<ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
<button
title="User Settings"
style={{
background: 'transparent',
@@ -169,9 +169,9 @@ const UserControlPanel = ({ username }) => {
justifyContent: 'center'
}}
>
<ColoredIcon
src={settingsIcon}
color={ICON_COLOR_DEFAULT}
<ColoredIcon
src={settingsIcon}
color={ICON_COLOR_DEFAULT}
/>
</button>
</div>
@@ -181,31 +181,28 @@ const UserControlPanel = ({ username }) => {
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, onChannelCreated, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); // New State
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
const [newChannelName, setNewChannelName] = useState('');
const [newChannelType, setNewChannelType] = useState('text'); // 'text' or 'voice'
const [newChannelType, setNewChannelType] = useState('text');
const [editingChannel, setEditingChannel] = useState(null);
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
// Callbacks for Modal
const convex = useConvex();
// Callbacks for Modal - Convex is reactive, no need to manually refresh
const onRenameChannel = (id, newName) => {
if (onChannelCreated) onChannelCreated();
// Convex reactive queries auto-update
};
const onDeleteChannel = (id) => {
if (activeChannel === id) onSelectChannel(null);
if (onChannelCreated) onChannelCreated();
// Convex reactive queries auto-update
};
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
// ... helper for public key ...
const getMyPublicKey = async (userId) => {
return null;
};
const handleStartCreate = () => {
setIsCreating(true);
setNewChannelName('');
@@ -222,7 +219,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const name = newChannelName.trim();
const type = newChannelType;
const userId = localStorage.getItem('userId');
if (!userId) {
alert("Please login first.");
setIsCreating(false);
@@ -230,38 +227,27 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
try {
// 1. Create Channel
const createRes = await fetch('http://localhost:3000/api/channels/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type })
});
const { id: channelId, error } = await createRes.json();
if (error) throw new Error(error);
// 1. Create Channel via Convex
const { id: channelId } = await convex.mutation(api.channels.create, { name, type });
// 2. Generate Key (Only needed for encrypted TEXT channels roughly, but we do it for all to simplify logic?
// Actually, Voice only needs access token. But keeping key logic doesn't hurt for consistent DB.
// Voice channels might use the key for text chat INTside the voice channel later?)
// 2. Generate Key
const keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
// 3. Encrypt Key for ALL Users (Group Logic)
// 3. Encrypt Key for ALL Users
try {
// Fetch all public keys
const usersRes = await fetch('http://localhost:3000/api/auth/users/public-keys');
const users = await usersRes.json();
const users = await convex.query(api.auth.getPublicKeys, {});
const batchKeys = [];
for (const u of users) {
if (!u.public_identity_key) continue;
try {
// Correct Format: JSON Stringify { [channelId]: key }
const payload = JSON.stringify({ [channelId]: keyHex });
const encryptedKeyHex = await window.cryptoAPI.publicEncrypt(u.public_identity_key, payload);
batchKeys.push({
channelId,
userId: u.id,
@@ -273,24 +259,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
}
// 4. Upload Keys Batch
await fetch('http://localhost:3000/api/channels/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batchKeys)
});
// 5. Notify Everyone (NOW it is safe)
await fetch(`http://localhost:3000/api/channels/${channelId}/notify`, { method: 'POST' });
// 4. Upload Keys Batch via Convex
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
// No need to notify - Convex queries are reactive!
} catch (keyErr) {
console.error("Critical: Failed to distribute keys", keyErr);
alert("Channel created but key distribution failed.");
}
// 6. Refresh
// 5. Done - Convex reactive queries auto-update the channel list
setIsCreating(false);
if (onChannelCreated) onChannelCreated();
} catch (err) {
console.error(err);
@@ -299,11 +279,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
};
// ... (rest of logic)
const handleCreateInvite = async () => {
const userId = localStorage.getItem('userId');
if (!userId) {
@@ -320,8 +295,8 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
// 2. Prepare Key Bundle
const generalChannel = channels.find(c => c.name === 'general');
const targetChannelId = generalChannel ? generalChannel.id : activeChannel; // Fallback to active if no general
const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
if (!targetChannelId) {
alert("No channel selected.");
return;
@@ -334,33 +309,27 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
return;
}
const payload = JSON.stringify({
[targetChannelId]: targetKey
const payload = JSON.stringify({
[targetChannelId]: targetKey
});
// 3. Encrypt Payload
const encrypted = await window.cryptoAPI.encryptData(payload, inviteSecret);
const blob = JSON.stringify({
c: encrypted.content,
t: encrypted.tag,
iv: encrypted.iv
const blob = JSON.stringify({
c: encrypted.content,
t: encrypted.tag,
iv: encrypted.iv
});
// 4. Send to Server
const res = await fetch('http://localhost:3000/api/invites/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: inviteCode,
encryptedPayload: blob,
createdBy: userId,
keyVersion: 1
})
// 4. Create invite via Convex
await convex.mutation(api.invites.create, {
code: inviteCode,
encryptedPayload: blob,
createdBy: userId,
keyVersion: 1
});
if (!res.ok) throw new Error('Server rejected invite creation');
// 5. Show Link
const link = `http://localhost:5173/#/register?code=${inviteCode}&key=${inviteSecret}`;
navigator.clipboard.writeText(link);
@@ -375,7 +344,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
// Screen Share Handler
const handleScreenShareSelect = async (selection) => {
if (!room) return;
try {
// Unpublish existing screen share if any
if (room.localParticipant.isScreenShareEnabled) {
@@ -409,9 +378,9 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
name: 'screen_share',
source: Track.Source.ScreenShare
});
setScreenSharing(true);
track.onended = () => {
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
@@ -423,7 +392,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
alert("Failed to share screen: " + err.message);
}
};
// Toggle Modal instead of direct toggle
const handleScreenShareClick = () => {
if (room?.localParticipant.isScreenShareEnabled) {
@@ -440,31 +409,29 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list">
{/* Home Button */}
<div
<div
className={`server-icon ${view === 'me' ? 'active' : ''}`}
onClick={() => onViewChange('me')}
style={{
style={{
backgroundColor: view === 'me' ? '#5865F2' : '#36393f',
color: view === 'me' ? '#fff' : '#dcddde',
marginBottom: '8px',
cursor: 'pointer'
}}
>
{/* Discord Logo / Home Icon */}
<svg width="28" height="20" viewBox="0 0 28 20">
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
</svg>
</div>
{/* Add separator logic if needed, or just list other servers (currently just 1 hardcoded placeholder in UI) */}
{/* The Server Icon (Secure Chat) */}
<div
<div
className={`server-icon ${view === 'server' ? 'active' : ''}`}
onClick={() => onViewChange('server')}
style={{ cursor: 'pointer' }}
>Sc</div>
</div>
{/* Channel List Area */}
{view === 'me' ? (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
@@ -484,7 +451,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
) : (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span
<span
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}
onClick={() => setIsServerSettingsOpen(true)}
title="Server Settings"
@@ -492,7 +459,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
Secure Chat
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
<button
onClick={handleStartCreate}
title="Create New Channel"
style={{
@@ -507,7 +474,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
>
+
</button>
<button
<button
onClick={handleCreateInvite}
title="Create Invite Link"
style={{
@@ -523,7 +490,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</button>
</div>
</div>
{/* Inline Create Channel Input */}
{isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
@@ -561,37 +528,37 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
)}
{channels.map(channel => (
<React.Fragment key={channel.id}>
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel.id ? 'active' : ''} ${voiceChannelId === channel.id ? 'voice-active' : ''}`}
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => {
if (channel.type === 'voice') {
if (voiceChannelId === channel.id) {
onSelectChannel(channel.id);
if (voiceChannelId === channel._id) {
onSelectChannel(channel._id);
} else {
connectToVoice(channel.id, channel.name, localStorage.getItem('userId'));
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
}
} else {
onSelectChannel(channel.id);
onSelectChannel(channel._id);
}
}}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px' // Space for icon
paddingRight: '8px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
{channel.type === 'voice' ? (
<div style={{ marginRight: 6 }}>
<ColoredIcon
src={voiceIcon}
<ColoredIcon
src={voiceIcon}
size="16px"
color={voiceStates[channel.id]?.length > 0
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)" // Active Green
: "#8e9297" // Default Gray
color={voiceStates[channel._id]?.length > 0
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)"
: "#8e9297"
}
/>
</div>
@@ -602,7 +569,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div>
<button
className="channel-settings-icon"
className="channel-settings-icon"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel);
@@ -621,20 +588,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
>
</button>
{/* Participant List (Only for Voice Channels) */}
</div>
{channel.type === 'voice' && voiceStates[channel.id] && voiceStates[channel.id].length > 0 && (
{channel.type === 'voice' && voiceStates[channel._id] && voiceStates[channel._id].length > 0 && (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{voiceStates[channel.id].map(user => (
{voiceStates[channel._id].map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
backgroundColor: '#5865F2',
backgroundColor: '#5865F2',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 8, fontSize: 10, color: 'white',
boxShadow: activeSpeakers.has(user.userId)
? '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)'
boxShadow: activeSpeakers.has(user.userId)
? '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)'
: 'none'
}}>
{user.username.substring(0, 1).toUpperCase()}
@@ -643,7 +608,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && (
<div style={{
backgroundColor: '#ed4245', // var(--red-400) fallback
backgroundColor: '#ed4245',
borderRadius: '8px',
padding: '0 6px',
textOverflow: 'ellipsis',
@@ -695,7 +660,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ color: '#43b581', fontWeight: 'bold', fontSize: 13 }}>Voice Connected</div>
<button
<button
onClick={disconnectVoice}
title="Disconnect"
style={{
@@ -707,7 +672,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div>
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div>
<div style={{ display: 'flex', gap: 4 }}>
<button
<button
onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)}
title="Turn On Camera"
style={{
@@ -716,7 +681,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
>
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
</button>
<button
<button
onClick={handleScreenShareClick}
title="Share Screen"
style={{
@@ -734,8 +699,8 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
{/* Modals */}
{editingChannel && (
<ChannelSettingsModal
channel={editingChannel}
<ChannelSettingsModal
channel={editingChannel}
onClose={() => setEditingChannel(null)}
onRename={onRenameChannel}
onDelete={onDeleteChannel}
@@ -745,7 +710,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<ServerSettingsModal onClose={() => setIsServerSettingsOpen(false)} />
)}
{isScreenShareModalOpen && (
<ScreenShareModal
<ScreenShareModal
onClose={() => setIsScreenShareModalOpen(false)}
onSelectSource={handleScreenShareSelect}
/>

View File

@@ -4,28 +4,27 @@ import {
VideoConference,
RoomAudioRenderer,
} from '@livekit/components-react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import '@livekit/components-styles';
import { Track } from 'livekit-client';
const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
const [token, setToken] = useState('');
const convex = useConvex();
useEffect(() => {
const fetchToken = async () => {
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 })
const { token: lkToken } = await convex.action(api.voice.getToken, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown'
});
const data = await res.json();
if (data.token) {
setToken(data.token);
if (lkToken) {
setToken(lkToken);
} else {
console.error('Failed to get token:', data);
console.error('Failed to get token');
onDisconnect();
}
} catch (err) {
@@ -41,15 +40,15 @@ const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
if (!token) return <div style={{ color: 'white', padding: 20 }}>Connecting to Voice...</div>;
const liveKitUrl = 'ws://localhost:7880'; // Should come from env/config
const liveKitUrl = import.meta.env.VITE_LIVEKIT_URL;
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
<div style={{
padding: '10px 20px',
background: '#1a1b1e',
color: 'white',
display: 'flex',
<div style={{
padding: '10px 20px',
background: '#1a1b1e',
color: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid #2f3136'
@@ -59,7 +58,7 @@ const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
<span style={{ fontWeight: 'bold' }}>{channelName}</span>
<span style={{ fontSize: 12, color: '#43b581', marginLeft: 8 }}>Connected</span>
</div>
<button
<button
onClick={onDisconnect}
style={{
background: '#ed4245',
@@ -77,7 +76,7 @@ const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
<div style={{ flex: 1, position: 'relative' }}>
<LiveKitRoom
video={false} // Start with video off? Or let user choose
video={false}
audio={true}
token={token}
serverUrl={liveKitUrl}
@@ -85,15 +84,8 @@ const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
style={{ height: '100%' }}
onDisconnected={onDisconnect}
>
{/* The VideoConference component provides the default UI grid */}
<VideoConference />
{/* Ensure audio is rendered */}
<VideoConference />
<RoomAudioRenderer />
{/* Custom Control Bar if needed, but VideoConference includes one by default usually.
Let's verify standard components behavior. VideoConference is a high-level UI.
*/}
</LiveKitRoom>
</div>
</div>

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

View File

@@ -1,17 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { ConvexProvider, ConvexReactClient } from 'convex/react';
import App from './App';
import './index.css';
import { VoiceProvider } from './contexts/VoiceContext';
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<VoiceProvider>
<HashRouter>
<App />
</HashRouter>
</VoiceProvider>
<ConvexProvider client={convex}>
<VoiceProvider>
<HashRouter>
<App />
</HashRouter>
</VoiceProvider>
</ConvexProvider>
</React.StrictMode>,
);

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { io } from 'socket.io-client';
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';
@@ -9,66 +10,71 @@ import FriendsView from '../components/FriendsView';
const Chat = () => {
const [view, setView] = useState('server'); // 'server' | 'me'
const [activeChannel, setActiveChannel] = useState(null);
const [channels, setChannels] = useState([]);
const [username, setUsername] = useState('');
const [userId, setUserId] = useState(null);
const [channelKeys, setChannelKeys] = useState({}); // { channelId: key_hex }
// DM state
const [activeDMChannel, setActiveDMChannel] = useState(null); // { channel_id, other_username }
const [dmChannels, setDMChannels] = useState([]);
const refreshData = () => {
const convex = useConvex();
// Reactive channel list from Convex (auto-updates!)
const channels = useQuery(api.channels.list) || [];
// Reactive channel keys from Convex
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
userId ? { userId } : "skip"
);
// Reactive DM channels from Convex
const dmChannels = useQuery(
api.dms.listDMs,
userId ? { userId } : "skip"
) || [];
// Initialize user from localStorage
useEffect(() => {
const storedUsername = localStorage.getItem('username');
const userId = localStorage.getItem('userId');
const privateKey = sessionStorage.getItem('privateKey');
const storedUserId = localStorage.getItem('userId');
if (storedUsername) setUsername(storedUsername);
if (userId) setUserId(userId);
if (userId && privateKey) {
// Fetch Encrypted Channel Keys
fetch(`http://localhost:3000/api/channels/keys/${userId}`)
.then(res => res.json())
.then(async (data) => {
const keys = {};
for (const item of data) {
try {
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
const bundle = JSON.parse(bundleJson);
Object.assign(keys, bundle);
} catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
}
}
setChannelKeys(keys);
})
.catch(err => console.error('Error fetching channel keys:', err));
}
fetch('http://localhost:3000/api/channels')
.then(res => res.json())
.then(data => {
setChannels(data);
if (!activeChannel && data.length > 0) {
const firstTextChannel = data.find(c => c.type === 'text');
if (firstTextChannel) {
setActiveChannel(firstTextChannel.id);
}
}
})
.catch(err => console.error('Error fetching channels:', err));
};
const fetchDMChannels = useCallback(() => {
const uid = localStorage.getItem('userId');
if (!uid) return;
fetch(`http://localhost:3000/api/dms/user/${uid}`)
.then(res => res.json())
.then(data => setDMChannels(data))
.catch(err => console.error('Error fetching DM channels:', err));
if (storedUserId) setUserId(storedUserId);
}, []);
// Decrypt channel keys when raw keys change
useEffect(() => {
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
const privateKey = sessionStorage.getItem('privateKey');
if (!privateKey) return;
const decryptKeys = async () => {
const keys = {};
for (const item of rawChannelKeys) {
try {
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
const bundle = JSON.parse(bundleJson);
Object.assign(keys, bundle);
} catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
}
}
setChannelKeys(keys);
};
decryptKeys();
}, [rawChannelKeys]);
// Auto-select first text channel when channels load
useEffect(() => {
if (!activeChannel && channels.length > 0) {
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');
const privateKey = sessionStorage.getItem('privateKey');
@@ -76,12 +82,10 @@ const Chat = () => {
try {
// 1. Find or create the DM channel
const res = await fetch('http://localhost:3000/api/dms/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: uid, targetUserId })
const { channelId, created } = await convex.mutation(api.dms.openDM, {
userId: uid,
targetUserId
});
const { channelId, created } = await res.json();
// 2. If newly created, generate + distribute an AES key for both users
if (created) {
@@ -90,8 +94,7 @@ const Chat = () => {
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
// Fetch both users' public keys
const usersRes = await fetch('http://localhost:3000/api/auth/users/public-keys');
const allUsers = await usersRes.json();
const allUsers = await convex.query(api.auth.getPublicKeys, {});
const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId);
const batchKeys = [];
@@ -112,65 +115,22 @@ const Chat = () => {
}
if (batchKeys.length > 0) {
await fetch('http://localhost:3000/api/channels/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batchKeys)
});
}
// Refresh channel keys so the new DM key is available
if (privateKey) {
const keysRes = await fetch(`http://localhost:3000/api/channels/keys/${uid}`);
const keysData = await keysRes.json();
const keys = {};
for (const item of keysData) {
try {
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
const bundle = JSON.parse(bundleJson);
Object.assign(keys, bundle);
} catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
}
}
setChannelKeys(keys);
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
}
// Channel keys will auto-update via reactive query
}
// 3. Set active DM and switch to me view
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
setView('me');
fetchDMChannels();
} catch (err) {
console.error('Error opening DM:', err);
}
}, [fetchDMChannels]);
useEffect(() => {
refreshData();
fetchDMChannels();
// Listen for updates (requires socket connection)
const socket = io('http://localhost:3000');
socket.on('new_channel', (channel) => {
console.log("New Channel Detected:", channel);
refreshData(); // Re-fetch keys/channels
});
socket.on('channel_renamed', () => refreshData());
socket.on('channel_deleted', (id) => {
refreshData();
if (activeChannel === id) setActiveChannel(null);
});
return () => socket.disconnect();
}, []);
}, [convex]);
// Helper to get active channel object
const activeChannelObj = channels.find(c => c.id === activeChannel);
const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates } = useVoice();
@@ -223,13 +183,9 @@ const Chat = () => {
onSelectChannel={setActiveChannel}
username={username}
channelKeys={channelKeys}
onChannelCreated={refreshData}
view={view}
onViewChange={(v) => {
setView(v);
if (v === 'me') {
fetchDMChannels();
}
}}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const Login = () => {
const [username, setUsername] = useState('');
@@ -7,6 +9,7 @@ const Login = () => {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const convex = useConvex();
const handleLogin = async (e) => {
e.preventDefault();
@@ -16,30 +19,19 @@ const Login = () => {
try {
console.log('Starting login for:', username);
// 1. Get Salt
const saltRes = await fetch('http://localhost:3000/api/auth/login/salt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const { salt } = await saltRes.json();
// 1. Get Salt (via Convex query)
const { salt } = await convex.query(api.auth.getSalt, { username });
console.log('Got salt');
// 2. Derive Keys (DEK, DAK)
const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt);
console.log('Derived keys');
// 3. Verify with Server
const verifyRes = await fetch('http://localhost:3000/api/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, dak })
});
// 3. Verify with Convex
const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
throw new Error(verifyData.error || 'Login failed');
if (verifyData.error) {
throw new Error(verifyData.error);
}
console.log('Login verified. Response data:', verifyData);
@@ -66,10 +58,10 @@ const Login = () => {
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
// Decrypt Ed25519 Signing Key
const edPrivObj = encryptedPrivateKeysObj.ed; // Already an object
const edPrivObj = encryptedPrivateKeysObj.ed;
const signingKey = await window.cryptoAPI.decryptData(
edPrivObj.content,
mkHex, // MK acts as the key
mkHex,
edPrivObj.iv,
edPrivObj.tag
);
@@ -78,14 +70,14 @@ const Login = () => {
const rsaPrivObj = encryptedPrivateKeysObj.rsa;
const rsaPriv = await window.cryptoAPI.decryptData(
rsaPrivObj.content,
mkHex, // MK acts as the key
mkHex,
rsaPrivObj.iv,
rsaPrivObj.tag
);
// Store Keys in Session (Memory-like) storage
sessionStorage.setItem('signingKey', signingKey);
sessionStorage.setItem('privateKey', rsaPriv); // Store RSA Key
sessionStorage.setItem('privateKey', rsaPriv);
console.log('Keys decrypted and stored in session.');
localStorage.setItem('username', username);

View File

@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const Register = () => {
const [username, setUsername] = useState('');
@@ -12,6 +14,7 @@ const Register = () => {
const navigate = useNavigate();
const location = useLocation();
const convex = useConvex();
// Helper to process code/key
const processInvite = async (code, secret) => {
@@ -21,32 +24,32 @@ const Register = () => {
}
try {
// Fetch Invite
const res = await fetch(`http://localhost:3000/api/invites/${code}`);
if (!res.ok) throw new Error('Invalid or expired invite');
const { encryptedPayload } = await res.json();
// Fetch Invite via Convex
const result = await convex.query(api.invites.use, { code });
if (result.error) throw new Error(result.error);
const { encryptedPayload } = result;
// Decrypt Payload
const blob = JSON.parse(encryptedPayload);
const decrypted = await window.cryptoAPI.decryptData(blob.c, secret, blob.iv, blob.t);
const keys = JSON.parse(decrypted);
console.log('Invite keys decrypted successfully:', Object.keys(keys).length);
setInviteKeys(keys);
setActiveInviteCode(code); // Store code for backend validation
setError(''); // Clear errors
setActiveInviteCode(code);
setError('');
} catch (err) {
console.error('Invite error:', err);
setError('Invite verification failed: ' + err.message);
}
};
// Handle Invite Link parsing from URL (if somehow navigated)
// Handle Invite Link parsing from URL
useEffect(() => {
const params = new URLSearchParams(location.search);
const code = params.get('code');
const secret = params.get('key');
const secret = params.get('key');
if (code && secret) {
console.log('Invite detected in URL');
processInvite(code, secret);
@@ -55,14 +58,6 @@ const Register = () => {
const handleManualInvite = () => {
try {
// Support full URL or just code? Full URL is easier for user (copy-paste)
// Format: .../#/register?code=UUID&key=HEX
const urlObj = new URL(inviteLinkInput);
// In HashRouter, params are after #.
// URL: http://.../#/register?code=X&key=Y
// urlObj.hash -> "#/register?code=X&key=Y"
// We can just regex it to be safe
const codeMatch = inviteLinkInput.match(/[?&]code=([^&]+)/);
const keyMatch = inviteLinkInput.match(/[?&]key=([^&]+)/);
@@ -86,7 +81,7 @@ const Register = () => {
// 1. Generate Salt and Master Key (MK)
const salt = await window.cryptoAPI.randomBytes(16);
const mk = await window.cryptoAPI.randomBytes(32); // 256-bit MK for AES-256
const mk = await window.cryptoAPI.randomBytes(32);
console.log('Generated Salt and MK');
@@ -96,7 +91,7 @@ const Register = () => {
// 3. Encrypt MK with DEK
const encryptedMKObj = await window.cryptoAPI.encryptData(mk, dek);
const encryptedMK = JSON.stringify(encryptedMKObj); // Store as JSON string {content, tag, iv}
const encryptedMK = JSON.stringify(encryptedMKObj);
// 4. Hash DAK for Auth Proof
const hak = await window.cryptoAPI.sha256(dak);
@@ -113,8 +108,8 @@ const Register = () => {
ed: encryptedEdPriv
});
// 7. Send to Backend
const payload = {
// 7. Register via Convex
const data = await convex.mutation(api.auth.createUserWithProfile, {
username,
salt,
encryptedMK,
@@ -122,19 +117,11 @@ const Register = () => {
publicKey: keys.rsaPub,
signingKey: keys.edPub,
encryptedPrivateKeys,
inviteCode: activeInviteCode // Enforce Invite
};
const response = await fetch('http://localhost:3000/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
inviteCode: activeInviteCode || undefined
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Registration failed');
if (data.error) {
throw new Error(data.error);
}
console.log('Registration successful:', data);
@@ -142,31 +129,27 @@ const Register = () => {
// 8. Upload Invite Keys (If present)
if (inviteKeys && data.userId) {
console.log('Uploading invite keys...');
const batchKeys = [];
for (const [channelId, channelKeyHex] of Object.entries(inviteKeys)) {
// Encrypt Channel Key with User's RSA Public Key
// Hybrid Encrypt? No, for now simplistic: encrypt the 32-byte hex key string (64 chars) with RSA-2048.
// RSA-2048 can encrypt ~200 bytes. 64 chars is fine.
try {
// Match Sidebar.jsx format: payload is JSON string { [channelId]: key }
const payload = JSON.stringify({ [channelId]: channelKeyHex });
const encryptedKeyBundle = await window.cryptoAPI.publicEncrypt(keys.rsaPub, payload);
// Upload
await fetch('http://localhost:3000/api/channels/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelId,
userId: data.userId,
encryptedKeyBundle,
keyVersion: 1
})
batchKeys.push({
channelId,
userId: data.userId,
encryptedKeyBundle,
keyVersion: 1
});
console.log(`Uploaded key for channel ${channelId}`);
} catch (keyErr) {
console.error('Failed to upload key for channel:', channelId, keyErr);
console.error('Failed to encrypt key for channel:', channelId, keyErr);
}
}
if (batchKeys.length > 0) {
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
console.log('Uploaded invite keys');
}
}
navigate('/');
@@ -186,14 +169,14 @@ const Register = () => {
<p>Join the secure chat! {inviteKeys ? '(Invite Active)' : ''}</p>
</div>
{error && <div style={{ color: 'red', marginBottom: 10, textAlign: 'center' }}>{error}</div>}
{/* Manual Invite Input - Fallback for Desktop App */}
{!inviteKeys && (
<div style={{ marginBottom: '15px' }}>
<div style={{ display: 'flex' }}>
<input
type="text"
placeholder="Paste Invite Link Here..."
<input
type="text"
placeholder="Paste Invite Link Here..."
value={inviteLinkInput}
onChange={(e) => setInviteLinkInput(e.target.value)}
style={{ flex: 1, marginRight: '8px' }}
@@ -237,14 +220,14 @@ const Register = () => {
<div style={{ textAlign: 'center', marginTop: '20px', color: '#b9bbbe' }}>
<p>Registration is Invite-Only.</p>
<p style={{ fontSize: '0.9em' }}>Please paste a valid invite link above to proceed.</p>
{/* Backdoor for First User */}
<p style={{ marginTop: '20px', fontSize: '0.8em', cursor: 'pointer', color: '#7289da' }} onClick={() => setInviteKeys({})}>
(First User / Verify Setup)
</p>
</div>
)}
<div className="auth-footer">
Already have an account? <Link to="/">Log In</Link>
</div>