feat: Implement core Discord clone functionality including Convex backend, Electron frontend, and UI components for chat, voice, and settings.
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
1
Frontend/Electron/src/assets/icons/spoiler.svg
Normal file
1
Frontend/Electron/src/assets/icons/spoiler.svg
Normal 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 |
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user