feat: Implement core Discord features including members list, direct messages, user presence, authentication, and chat UI.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-02-11 04:36:40 -06:00
parent a29858fd32
commit cb4361da1a
32 changed files with 2051 additions and 144 deletions

View File

@@ -1,16 +1,100 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
import Chat from './pages/Chat';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function AuthGuard({ children }) {
const [authState, setAuthState] = useState('loading'); // 'loading' | 'authenticated' | 'unauthenticated'
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
let cancelled = false;
async function restoreSession() {
// Already have keys in sessionStorage — current session is active
if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
if (!cancelled) setAuthState('authenticated');
return;
}
// Try restoring from safeStorage
if (window.sessionPersistence) {
try {
const session = await window.sessionPersistence.load();
if (session && session.savedAt && (Date.now() - session.savedAt) < THIRTY_DAYS_MS) {
// Restore to localStorage + sessionStorage
localStorage.setItem('userId', session.userId);
localStorage.setItem('username', session.username);
if (session.publicKey) localStorage.setItem('publicKey', session.publicKey);
sessionStorage.setItem('signingKey', session.signingKey);
sessionStorage.setItem('privateKey', session.privateKey);
if (!cancelled) setAuthState('authenticated');
return;
}
// Expired — clear stale session
if (session && session.savedAt) {
await window.sessionPersistence.clear();
}
} catch (err) {
console.error('Session restore failed:', err);
}
}
if (!cancelled) setAuthState('unauthenticated');
}
restoreSession();
return () => { cancelled = true; };
}, []);
// Redirect once after auth state is determined (not on every route change)
const hasRedirected = useRef(false);
useEffect(() => {
if (authState === 'loading' || hasRedirected.current) return;
hasRedirected.current = true;
const isAuthPage = location.pathname === '/' || location.pathname === '/register';
if (authState === 'authenticated' && isAuthPage) {
navigate('/chat', { replace: true });
} else if (authState === 'unauthenticated' && !isAuthPage) {
navigate('/', { replace: true });
}
}, [authState]);
if (authState === 'loading') {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
backgroundColor: 'var(--bg-primary, #313338)',
color: 'var(--text-normal, #dbdee1)',
fontSize: '16px',
}}>
Loading...
</div>
);
}
return children;
}
function App() {
return (
<Routes>
<Route path="/" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/chat" element={<Chat />} />
</Routes>
<AuthGuard>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/chat" element={<Chat />} />
</Routes>
</AuthGuard>
);
}

View File

@@ -0,0 +1 @@
<svg class="ownerIcon__5d473 icon__5d473" aria-describedby="«r7fs»" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M5 18a1 1 0 0 0-1 1 3 3 0 0 0 3 3h10a3 3 0 0 0 3-3 1 1 0 0 0-1-1H5ZM3.04 7.76a1 1 0 0 0-1.52 1.15l2.25 6.42a1 1 0 0 0 .94.67h14.55a1 1 0 0 0 .95-.71l1.94-6.45a1 1 0 0 0-1.55-1.1l-4.11 3-3.55-5.33.82-.82a.83.83 0 0 0 0-1.18l-1.17-1.17a.83.83 0 0 0-1.18 0l-1.17 1.17a.83.83 0 0 0 0 1.18l.82.82-3.61 5.42-4.41-3.07Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 555 B

View File

@@ -0,0 +1 @@
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M14.5 8a3 3 0 1 0-2.7-4.3c-.2.4.06.86.44 1.12a5 5 0 0 1 2.14 3.08c.01.06.06.1.12.1ZM16.62 13.17c-.22.29-.65.37-.92.14-.34-.3-.7-.57-1.09-.82-.52-.33-.7-1.05-.47-1.63.11-.27.2-.57.26-.87.11-.54.55-1 1.1-.92 1.6.2 3.04.92 4.15 1.98.3.27-.25.95-.65.95a3 3 0 0 0-2.38 1.17ZM15.19 15.61c.13.16.02.39-.19.39a3 3 0 0 0-1.52 5.59c.2.12.26.41.02.41h-8a.5.5 0 0 1-.5-.5v-2.1c0-.25-.31-.33-.42-.1-.32.67-.67 1.58-.88 2.54a.2.2 0 0 1-.2.16A1.5 1.5 0 0 1 2 20.5a7.5 7.5 0 0 1 13.19-4.89ZM9.5 12a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM15.5 22Z" class=""></path><path fill="currentColor" d="M19 14a1 1 0 0 1 1 1v3h3a1 1 0 0 1 0 2h-3v3a1 1 0 0 1-2 0v-3h-3a1 1 0 1 1 0-2h3v-3a1 1 0 0 1 1-1Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 841 B

View File

@@ -0,0 +1,134 @@
import React, { useState, useCallback, useEffect } from 'react';
import Cropper from 'react-easy-crop';
function getCroppedImg(imageSrc, pixelCrop) {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.drawImage(
image,
pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height,
0, 0, 256, 256
);
canvas.toBlob((blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
}, 'image/png');
};
image.onerror = reject;
image.src = imageSrc;
});
}
const AvatarCropModal = ({ imageUrl, onApply, onCancel }) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
const onCropComplete = useCallback((_croppedArea, croppedPixels) => {
setCroppedAreaPixels(croppedPixels);
}, []);
const handleApply = useCallback(async () => {
if (!croppedAreaPixels) return;
const blob = await getCroppedImg(imageUrl, croppedAreaPixels);
onApply(blob);
}, [imageUrl, croppedAreaPixels, onApply]);
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onCancel();
}
};
window.addEventListener('keydown', handleKey, true);
return () => window.removeEventListener('keydown', handleKey, true);
}, [onCancel]);
return (
<div className="avatar-crop-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
<div className="avatar-crop-dialog">
{/* Header */}
<div className="avatar-crop-header">
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: 'var(--header-primary)' }}>
Edit Image
</h2>
<button
onClick={onCancel}
style={{
background: 'none', border: 'none', color: 'var(--header-secondary)',
fontSize: '24px', cursor: 'pointer', padding: '4px', lineHeight: 1,
}}
>
</button>
</div>
{/* Crop area */}
<div className="avatar-crop-area">
<Cropper
image={imageUrl}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
{/* Zoom slider */}
<div className="avatar-crop-slider-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="avatar-crop-slider"
/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
</div>
{/* Actions */}
<div className="avatar-crop-actions">
<button
onClick={onCancel}
style={{
background: 'none', border: 'none', color: 'var(--header-primary)',
cursor: 'pointer', fontSize: '14px', fontWeight: 500, padding: '8px 16px',
}}
>
Cancel
</button>
<button
onClick={handleApply}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '8px 24px', cursor: 'pointer',
fontSize: '14px', fontWeight: 500,
}}
>
Apply
</button>
</div>
</div>
</div>
);
};
export default AvatarCropModal;

View File

@@ -65,8 +65,36 @@ const extractUrls = (text) => {
return text.match(urlRegex) || [];
};
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
const DirectVideo = ({ src, marginTop = 8 }) => {
const ref = useRef(null);
const [showControls, setShowControls] = useState(false);
const handlePlay = () => {
setShowControls(true);
if (ref.current) ref.current.play();
};
return (
<div style={{ marginTop, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
<video
ref={ref}
src={src}
controls={showControls}
preload="metadata"
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '8px', backgroundColor: 'black', display: 'block' }}
/>
{!showControls && (
<div className="play-icon" onClick={handlePlay} style={{ cursor: 'pointer' }}>
</div>
)}
</div>
);
};
const getYouTubeId = (link) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|shorts\/|watch\?v=|&v=)([^#&?]*).*/;
const match = link.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
@@ -105,6 +133,16 @@ const isNewDay = (current, previous) => {
|| current.getFullYear() !== previous.getFullYear();
};
const getProviderClass = (url) => {
try {
const hostname = new URL(url).hostname.replace(/^www\./, '');
if (hostname === 'twitter.com' || hostname === 'x.com') return 'twitter-preview';
if (hostname === 'open.spotify.com') return 'spotify-preview';
if (hostname === 'reddit.com') return 'reddit-preview';
} catch {}
return '';
};
const LinkPreview = ({ url }) => {
const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
const [loading, setLoading] = useState(!metadataCache.has(url));
@@ -139,6 +177,11 @@ const LinkPreview = ({ url }) => {
const videoId = getYouTubeId(url);
const isYouTube = !!videoId;
const isDirectVideoUrl = isVideoUrl(url);
if (isDirectVideoUrl) {
return <DirectVideo src={url} />;
}
if (loading || !metadata || (!metadata.title && !metadata.image && !metadata.video)) return null;
@@ -173,10 +216,14 @@ const LinkPreview = ({ url }) => {
);
}
const providerClass = getProviderClass(url);
const isLargeImage = providerClass === 'twitter-preview' || metadata.type === 'article' || metadata.type === 'summary_large_image';
return (
<div className={`link-preview ${isYouTube ? 'youtube-preview' : ''}`} style={{ borderLeftColor: metadata.themeColor || '#202225' }}>
<div className={`link-preview ${isYouTube ? 'youtube-preview' : ''} ${providerClass} ${isLargeImage && !isYouTube ? 'large-image-layout' : ''}`} style={{ borderLeftColor: metadata.themeColor || '#202225' }}>
<div className="preview-content">
{metadata.siteName && <div className="preview-site-name">{metadata.siteName}</div>}
{metadata.author && <div className="preview-author">{metadata.author}</div>}
{metadata.title && (
<a href={url} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(url); }} className="preview-title">
{metadata.title}
@@ -194,11 +241,21 @@ const LinkPreview = ({ url }) => {
/>
</div>
)}
{isLargeImage && !isYouTube && metadata.image && (
<div className="preview-image-container large-image">
<img src={metadata.image} alt="Preview" className="preview-image" />
</div>
)}
</div>
{metadata.image && (!isYouTube || !playing) && (
<div className="preview-image-container" onClick={() => isYouTube && setPlaying(true)} style={isYouTube ? { cursor: 'pointer' } : {}}>
{!isLargeImage && !isYouTube && metadata.image && (
<div className="preview-image-container">
<img src={metadata.image} alt="Preview" className="preview-image" />
{isYouTube && <div className="play-icon"></div>}
</div>
)}
{isYouTube && metadata.image && !playing && (
<div className="preview-image-container" onClick={() => setPlaying(true)} style={{ cursor: 'pointer' }}>
<img src={metadata.image} alt="Preview" className="preview-image" />
<div className="play-icon"></div>
</div>
)}
</div>
@@ -1042,15 +1099,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const urls = extractUrls(msg.content);
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
const isDirectVideo = isOnlyUrl && isVideoUrl(urls[0]);
return (
<>
{!isGif && (
{!isGif && !isDirectVideo && (
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
{formatEmojis(formatMentions(msg.content))}
</ReactMarkdown>
)}
{urls.map((url, i) => <LinkPreview key={i} url={url} />)}
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
{urls.filter(u => !(isDirectVideo && u === urls[0])).map((url, i) => (
<LinkPreview key={i} url={url} />
))}
</>
);
};
@@ -1263,6 +1324,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onBlur={saveSelection}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
onInput={(e) => {
const textContent = e.currentTarget.textContent;
setInput(textContent);

View File

@@ -3,6 +3,7 @@ import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import Avatar from './Avatar';
import { useOnlineUsers } from '../contexts/PresenceContext';
const STATUS_COLORS = {
online: '#3ba55c',
@@ -37,6 +38,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [searchFocused, setSearchFocused] = useState(false);
const searchRef = useRef(null);
const searchInputRef = useRef(null);
const { resolveStatus } = useOnlineUsers();
const convex = useConvex();
@@ -200,7 +202,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
<div style={{ flex: 1, overflowY: 'auto' }}>
{(dmChannels || []).map(dm => {
const isActive = activeDMChannel?.channel_id === dm.channel_id;
const status = dm.other_user_status || 'online';
const effectiveStatus = resolveStatus(dm.other_user_status, dm.other_user_id);
return (
<div
key={dm.channel_id}
@@ -213,7 +215,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
<div style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
backgroundColor: STATUS_COLORS[status] || STATUS_COLORS.online,
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
border: '2px solid var(--bg-secondary)'
}} />
</div>
@@ -222,7 +224,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
{dm.other_username}
</div>
<div className="dm-item-status">
{STATUS_LABELS[status] || 'Online'}
{STATUS_LABELS[effectiveStatus] || 'Offline'}
</div>
</div>
</div>

View File

@@ -2,12 +2,14 @@ import React, { useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar';
import { useOnlineUsers } from '../contexts/PresenceContext';
const FriendsView = ({ onOpenDM }) => {
const [activeTab, setActiveTab] = useState('Online');
const [addFriendSearch, setAddFriendSearch] = useState('');
const myId = localStorage.getItem('userId');
const { resolveStatus } = useOnlineUsers();
const allUsers = useQuery(api.auth.getPublicKeys) || [];
const users = allUsers.filter(u => u.id !== myId);
@@ -31,7 +33,7 @@ const FriendsView = ({ onOpenDM }) => {
};
const filteredUsers = activeTab === 'Online'
? users.filter(u => u.status !== 'offline' && u.status !== 'invisible')
? users.filter(u => resolveStatus(u.status, u.id) !== 'offline')
: activeTab === 'Add Friend'
? users.filter(u => u.username?.toLowerCase().includes(addFriendSearch.toLowerCase()))
: users;
@@ -91,7 +93,9 @@ const FriendsView = ({ onOpenDM }) => {
{/* Friends List */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 20px' }}>
{filteredUsers.map(user => (
{filteredUsers.map(user => {
const effectiveStatus = resolveStatus(user.status, user.id);
return (
<div
key={user.id}
className="friend-item"
@@ -102,7 +106,7 @@ const FriendsView = ({ onOpenDM }) => {
<div style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
backgroundColor: STATUS_COLORS[user.status] || STATUS_COLORS.online,
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
border: '2px solid var(--bg-primary)'
}} />
</div>
@@ -111,7 +115,7 @@ const FriendsView = ({ onOpenDM }) => {
{user.username ?? 'Unknown'}
</div>
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
{user.status === 'dnd' ? 'Do Not Disturb' : (user.status || 'Online').charAt(0).toUpperCase() + (user.status || 'online').slice(1)}
{effectiveStatus === 'dnd' ? 'Do Not Disturb' : effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)}
</div>
</div>
</div>
@@ -134,7 +138,8 @@ const FriendsView = ({ onOpenDM }) => {
</div>
</div>
</div>
))}
);
})}
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { useOnlineUsers } from '../contexts/PresenceContext';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -25,11 +26,12 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
api.members.getChannelMembers,
channelId ? { channelId } : "skip"
) || [];
const { resolveStatus } = useOnlineUsers();
if (!visible) return null;
const onlineMembers = members.filter(m => m.status !== 'offline' && m.status !== 'invisible');
const offlineMembers = members.filter(m => m.status === 'offline' || m.status === 'invisible');
const onlineMembers = members.filter(m => resolveStatus(m.status, m.id) !== 'offline');
const offlineMembers = members.filter(m => resolveStatus(m.status, m.id) === 'offline');
// Group online members by highest hoisted role
const roleGroups = {};
@@ -54,13 +56,14 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
const renderMember = (member) => {
const topRole = member.roles.length > 0 ? member.roles[0] : null;
const nameColor = topRole && topRole.name !== '@everyone' ? topRole.color : '#fff';
const effectiveStatus = resolveStatus(member.status, member.id);
return (
<div
key={member.id}
className="member-item"
onClick={() => onMemberClick && onMemberClick(member)}
style={member.status === 'offline' || member.status === 'invisible' ? { opacity: 0.3 } : {}}
style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
>
<div className="member-avatar-wrapper">
{member.avatarUrl ? (
@@ -80,7 +83,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
)}
<div
className="member-status-dot"
style={{ backgroundColor: STATUS_COLORS[member.status] || STATUS_COLORS.online }}
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
/>
</div>
<div className="member-info">

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useConvex, useMutation } from 'convex/react';
import { useNavigate } from 'react-router-dom';
import { useConvex, useMutation, useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import { useVoice } from '../contexts/VoiceContext';
@@ -8,7 +9,7 @@ import ServerSettingsModal from './ServerSettingsModal';
import ScreenShareModal from './ScreenShareModal';
import DMList from './DMList';
import Avatar from './Avatar';
import ThemeSelector from './ThemeSelector';
import UserSettings from './UserSettings';
import { Track } from 'livekit-client';
import muteIcon from '../assets/icons/mute.svg';
import mutedIcon from '../assets/icons/muted.svg';
@@ -19,6 +20,7 @@ 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';
import inviteUserIcon from '../assets/icons/invite_user.svg';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -101,11 +103,46 @@ const STATUS_OPTIONS = [
];
const UserControlPanel = ({ username, userId }) => {
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [showThemeSelector, setShowThemeSelector] = useState(false);
const [showUserSettings, setShowUserSettings] = useState(false);
const [currentStatus, setCurrentStatus] = useState('online');
const updateStatusMutation = useMutation(api.auth.updateStatus);
const navigate = useNavigate();
// Fetch stored status preference from server and sync local state
const allUsers = useQuery(api.auth.getPublicKeys) || [];
const myUser = allUsers.find(u => u.id === userId);
React.useEffect(() => {
if (myUser) {
if (myUser.status && myUser.status !== 'offline') {
setCurrentStatus(myUser.status);
} else if (!myUser.status || myUser.status === 'offline') {
// First login or no preference set yet — default to "online"
setCurrentStatus('online');
if (userId) {
updateStatusMutation({ userId, status: 'online' }).catch(() => {});
}
}
}
}, [myUser?.status]);
const handleLogout = async () => {
// Disconnect voice if connected
if (connectionState === 'connected') {
try { disconnectVoice(); } catch {}
}
// Clear persisted session
if (window.sessionPersistence) {
try { await window.sessionPersistence.clear(); } catch {}
}
// Clear storage (preserve theme)
const theme = localStorage.getItem('theme');
localStorage.clear();
if (theme) localStorage.setItem('theme', theme);
sessionStorage.clear();
navigate('/');
};
const effectiveMute = isMuted || isDeafened;
const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
@@ -191,15 +228,31 @@ const UserControlPanel = ({ username, userId }) => {
</button>
</Tooltip>
<Tooltip text="User Settings" position="top">
<button style={controlButtonStyle} onClick={() => setShowThemeSelector(true)}>
<button style={controlButtonStyle} onClick={() => setShowUserSettings(true)}>
<ColoredIcon
src={settingsIcon}
color={ICON_COLOR_DEFAULT}
/>
</button>
</Tooltip>
<Tooltip text="Log Out" position="top">
<button style={controlButtonStyle} onClick={handleLogout}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M16 17L21 12L16 7" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 12H9" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</Tooltip>
</div>
{showThemeSelector && <ThemeSelector onClose={() => setShowThemeSelector(false)} />}
{showUserSettings && (
<UserSettings
onClose={() => setShowUserSettings(false)}
userId={userId}
username={username}
onLogout={handleLogout}
/>
)}
</div>
);
};
@@ -445,7 +498,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
};
const renderDMView = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<DMList
dmChannels={dmChannels}
activeDMChannel={activeDMChannel}
@@ -504,13 +557,15 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}, [channels]);
const renderServerView = () => (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<div className="server-header" onClick={() => setIsServerSettingsOpen(true)}>
<span>Secure Chat</span>
<span className="server-header-chevron"></span>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}>
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>Secure Chat</span>
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
<img src={inviteUserIcon} alt="Invite" />
</button>
</div>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }}>
{isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}>

View File

@@ -0,0 +1,712 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar';
import AvatarCropModal from './AvatarCropModal';
import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
const THEME_PREVIEWS = {
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
[THEMES.DARK]: { bg: '#313338', sidebar: '#2b2d31', tertiary: '#1e1f22', text: '#f2f3f5' },
[THEMES.ASH]: { bg: '#202225', sidebar: '#1a1b1e', tertiary: '#111214', text: '#f0f1f3' },
[THEMES.ONYX]: { bg: '#0c0c14', sidebar: '#080810', tertiary: '#000000', text: '#e0def0' },
};
const TABS = [
{ id: 'account', label: 'My Account', section: 'USER SETTINGS' },
{ id: 'appearance', label: 'Appearance', section: 'USER SETTINGS' },
{ id: 'voice', label: 'Voice & Video', section: 'APP SETTINGS' },
{ id: 'keybinds', label: 'Keybinds', section: 'APP SETTINGS' },
];
const UserSettings = ({ onClose, userId, username, onLogout }) => {
const [activeTab, setActiveTab] = useState('account');
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
const renderSidebar = () => {
let lastSection = null;
const items = [];
TABS.forEach((tab, i) => {
if (tab.section !== lastSection) {
if (lastSection !== null) {
items.push(<div key={`sep-${i}`} style={{ height: '1px', backgroundColor: 'var(--border-subtle)', margin: '8px 10px' }} />);
}
items.push(
<div key={`hdr-${tab.section}`} style={{
fontSize: '12px', fontWeight: '700', color: 'var(--text-muted)',
marginBottom: '6px', textTransform: 'uppercase', padding: '0 10px'
}}>
{tab.section}
</div>
);
lastSection = tab.section;
}
items.push(
<div
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
padding: '6px 10px', borderRadius: '4px', cursor: 'pointer', marginBottom: '2px', fontSize: '15px',
backgroundColor: activeTab === tab.id ? 'var(--background-modifier-selected)' : 'transparent',
color: activeTab === tab.id ? 'var(--header-primary)' : 'var(--header-secondary)',
}}
>
{tab.label}
</div>
);
});
items.push(<div key="sep-logout" style={{ height: '1px', backgroundColor: 'var(--border-subtle)', margin: '8px 10px' }} />);
items.push(
<div
key="logout"
onClick={onLogout}
style={{
padding: '6px 10px', borderRadius: '4px', cursor: 'pointer', fontSize: '15px',
color: '#ed4245', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}
>
Log Out
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M16 17L21 12L16 7" stroke="#ed4245" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 12H9" stroke="#ed4245" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke="#ed4245" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
);
return items;
};
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'var(--bg-primary)', zIndex: 1000,
display: 'flex', color: 'var(--text-normal)',
}}>
{/* Sidebar */}
<div style={{
width: '218px', backgroundColor: 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', alignItems: 'flex-end',
padding: '60px 6px 60px 20px', overflowY: 'auto',
}}>
<div style={{ width: '100%' }}>
{renderSidebar()}
</div>
</div>
{/* Content */}
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
{activeTab === 'account' && <MyAccountTab userId={userId} username={username} />}
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'voice' && <VoiceVideoTab />}
{activeTab === 'keybinds' && <KeybindsTab />}
</div>
{/* Right spacer with close button */}
<div style={{ flex: '0 0 36px', paddingTop: '60px', marginLeft: '8px' }}>
<button
onClick={onClose}
style={{
width: '36px', height: '36px', borderRadius: '50%',
border: '2px solid var(--header-secondary)', background: 'transparent',
color: 'var(--header-secondary)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '18px',
}}
>
</button>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--header-secondary)', textAlign: 'center', marginTop: '4px' }}>
ESC
</div>
</div>
<div style={{ flex: '0.5' }} />
</div>
</div>
);
};
/* =========================================
MY ACCOUNT TAB
========================================= */
const MyAccountTab = ({ userId, username }) => {
const allUsers = useQuery(api.auth.getPublicKeys);
const convex = useConvex();
const currentUser = allUsers?.find(u => u.id === userId);
const [displayName, setDisplayName] = useState('');
const [aboutMe, setAboutMe] = useState('');
const [customStatus, setCustomStatus] = useState('');
const [avatarFile, setAvatarFile] = useState(null);
const [avatarPreview, setAvatarPreview] = useState(null);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [showCropModal, setShowCropModal] = useState(false);
const [rawImageUrl, setRawImageUrl] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
if (currentUser) {
setDisplayName(currentUser.displayName || '');
setAboutMe(currentUser.aboutMe || '');
setCustomStatus(currentUser.customStatus || '');
}
}, [currentUser]);
useEffect(() => {
if (!currentUser) return;
const changed =
displayName !== (currentUser.displayName || '') ||
aboutMe !== (currentUser.aboutMe || '') ||
customStatus !== (currentUser.customStatus || '') ||
avatarFile !== null;
setHasChanges(changed);
}, [displayName, aboutMe, customStatus, avatarFile, currentUser]);
const handleAvatarChange = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
setRawImageUrl(url);
setShowCropModal(true);
e.target.value = '';
};
const handleCropApply = (blob) => {
const file = new File([blob], 'avatar.png', { type: 'image/png' });
setAvatarFile(file);
const previewUrl = URL.createObjectURL(blob);
setAvatarPreview(previewUrl);
if (rawImageUrl) URL.revokeObjectURL(rawImageUrl);
setRawImageUrl(null);
setShowCropModal(false);
};
const handleCropCancel = () => {
if (rawImageUrl) URL.revokeObjectURL(rawImageUrl);
setRawImageUrl(null);
setShowCropModal(false);
};
const handleSave = async () => {
if (!userId || saving) return;
setSaving(true);
try {
let avatarStorageId;
if (avatarFile) {
const uploadUrl = await convex.mutation(api.files.generateUploadUrl);
const res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': avatarFile.type },
body: avatarFile,
});
const { storageId } = await res.json();
avatarStorageId = storageId;
}
const args = { userId, displayName, aboutMe, customStatus };
if (avatarStorageId) args.avatarStorageId = avatarStorageId;
await convex.mutation(api.auth.updateProfile, args);
setAvatarFile(null);
if (avatarPreview) {
URL.revokeObjectURL(avatarPreview);
setAvatarPreview(null);
}
} catch (err) {
console.error('Failed to save profile:', err);
alert('Failed to save profile: ' + err.message);
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (currentUser) {
setDisplayName(currentUser.displayName || '');
setAboutMe(currentUser.aboutMe || '');
setCustomStatus(currentUser.customStatus || '');
}
setAvatarFile(null);
if (avatarPreview) {
URL.revokeObjectURL(avatarPreview);
setAvatarPreview(null);
}
if (rawImageUrl) {
URL.revokeObjectURL(rawImageUrl);
setRawImageUrl(null);
}
setShowCropModal(false);
};
const avatarUrl = avatarPreview || currentUser?.avatarUrl;
return (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>My Account</h2>
{/* Profile card */}
<div style={{ backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', overflow: 'hidden' }}>
{/* Banner */}
<div style={{ height: '100px', backgroundColor: 'var(--brand-experiment)' }} />
{/* Profile body */}
<div style={{ padding: '0 16px 16px', position: 'relative' }}>
{/* Avatar */}
<div
className="user-settings-avatar-wrapper"
onClick={() => fileInputRef.current?.click()}
style={{ marginTop: '-40px', marginBottom: '12px', width: 'fit-content', cursor: 'pointer', position: 'relative' }}
>
<Avatar username={username} avatarUrl={avatarUrl} size={80} />
<div className="user-settings-avatar-overlay">
CHANGE<br/>AVATAR
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarChange}
style={{ display: 'none' }}
/>
</div>
{/* Fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Username (read-only) */}
<div>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
}}>
Username
</label>
<div style={{
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', padding: '10px',
color: 'var(--text-muted)', fontSize: '16px',
}}>
{username}
</div>
</div>
{/* Display Name */}
<div>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
}}>
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="How others see you in chat"
style={{
width: '100%', backgroundColor: 'var(--bg-tertiary)', border: 'none',
borderRadius: '4px', padding: '10px', color: 'var(--text-normal)',
fontSize: '16px', outline: 'none', boxSizing: 'border-box',
}}
/>
</div>
{/* About Me */}
<div>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
}}>
About Me
</label>
<textarea
value={aboutMe}
onChange={(e) => setAboutMe(e.target.value.slice(0, 190))}
placeholder="Tell others about yourself"
rows={3}
style={{
width: '100%', backgroundColor: 'var(--bg-tertiary)', border: 'none',
borderRadius: '4px', padding: '10px', color: 'var(--text-normal)',
fontSize: '16px', outline: 'none', resize: 'none', fontFamily: 'inherit',
boxSizing: 'border-box',
}}
/>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', textAlign: 'right' }}>
{aboutMe.length}/190
</div>
</div>
{/* Custom Status */}
<div>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
}}>
Custom Status
</label>
<input
type="text"
value={customStatus}
onChange={(e) => setCustomStatus(e.target.value)}
placeholder="Set a custom status"
style={{
width: '100%', backgroundColor: 'var(--bg-tertiary)', border: 'none',
borderRadius: '4px', padding: '10px', color: 'var(--text-normal)',
fontSize: '16px', outline: 'none', boxSizing: 'border-box',
}}
/>
</div>
</div>
</div>
</div>
{/* Save bar */}
{hasChanges && (
<div style={{
position: 'sticky', bottom: '0', left: 0, right: 0,
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px',
padding: '10px 16px', marginTop: '16px',
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '12px',
boxShadow: '0 -2px 10px rgba(0,0,0,0.2)',
}}>
<span style={{ color: 'var(--text-muted)', fontSize: '14px', marginRight: 'auto' }}>
Careful you have unsaved changes!
</span>
<button
onClick={handleReset}
style={{
background: 'transparent', border: 'none', color: 'var(--header-primary)',
cursor: 'pointer', fontSize: '14px', fontWeight: '500', padding: '8px 16px',
}}
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving}
style={{
backgroundColor: '#3ba55c', color: 'white', border: 'none',
borderRadius: '4px', padding: '8px 24px', cursor: saving ? 'not-allowed' : 'pointer',
fontSize: '14px', fontWeight: '500', opacity: saving ? 0.7 : 1,
}}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
)}
{showCropModal && rawImageUrl && (
<AvatarCropModal
imageUrl={rawImageUrl}
onApply={handleCropApply}
onCancel={handleCropCancel}
/>
)}
</div>
);
};
/* =========================================
APPEARANCE TAB
========================================= */
const AppearanceTab = () => {
const { theme, setTheme } = useTheme();
return (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Appearance</h2>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '12px',
}}>
Theme
</label>
<div className="theme-selector-grid">
{Object.values(THEMES).map((themeKey) => {
const preview = THEME_PREVIEWS[themeKey];
const isActive = theme === themeKey;
return (
<div
key={themeKey}
className={`theme-card ${isActive ? 'active' : ''}`}
onClick={() => setTheme(themeKey)}
>
<div className="theme-preview" style={{ backgroundColor: preview.bg }}>
<div className="theme-preview-sidebar" style={{ backgroundColor: preview.sidebar }}>
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary }} />
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary, width: '60%' }} />
</div>
<div className="theme-preview-chat">
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.6 }} />
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.4, width: '70%' }} />
</div>
</div>
<div className="theme-card-label">
<div className={`theme-radio ${isActive ? 'active' : ''}`}>
{isActive && <div className="theme-radio-dot" />}
</div>
<span>{THEME_LABELS[themeKey]}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
/* =========================================
VOICE & VIDEO TAB
========================================= */
const VoiceVideoTab = () => {
const [inputDevices, setInputDevices] = useState([]);
const [outputDevices, setOutputDevices] = useState([]);
const [selectedInput, setSelectedInput] = useState(() => localStorage.getItem('voiceInputDevice') || 'default');
const [selectedOutput, setSelectedOutput] = useState(() => localStorage.getItem('voiceOutputDevice') || 'default');
const [inputVolume, setInputVolume] = useState(() => parseInt(localStorage.getItem('voiceInputVolume') || '100'));
const [outputVolume, setOutputVolume] = useState(() => parseInt(localStorage.getItem('voiceOutputVolume') || '100'));
const [micTesting, setMicTesting] = useState(false);
const [micLevel, setMicLevel] = useState(0);
const micStreamRef = useRef(null);
const animFrameRef = useRef(null);
const analyserRef = useRef(null);
useEffect(() => {
const enumerate = async () => {
try {
// Request permission to get labels
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => t.stop());
const devices = await navigator.mediaDevices.enumerateDevices();
setInputDevices(devices.filter(d => d.kind === 'audioinput'));
setOutputDevices(devices.filter(d => d.kind === 'audiooutput'));
} catch (err) {
console.error('Failed to enumerate devices:', err);
}
};
enumerate();
}, []);
useEffect(() => {
localStorage.setItem('voiceInputDevice', selectedInput);
}, [selectedInput]);
useEffect(() => {
localStorage.setItem('voiceOutputDevice', selectedOutput);
}, [selectedOutput]);
useEffect(() => {
localStorage.setItem('voiceInputVolume', String(inputVolume));
}, [inputVolume]);
useEffect(() => {
localStorage.setItem('voiceOutputVolume', String(outputVolume));
}, [outputVolume]);
const startMicTest = async () => {
try {
const constraints = { audio: selectedInput !== 'default' ? { deviceId: { exact: selectedInput } } : true };
const stream = await navigator.mediaDevices.getUserMedia(constraints);
micStreamRef.current = stream;
const audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyserRef.current = analyser;
setMicTesting(true);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const tick = () => {
analyser.getByteFrequencyData(dataArray);
const avg = dataArray.reduce((sum, v) => sum + v, 0) / dataArray.length;
setMicLevel(Math.min(100, (avg / 128) * 100));
animFrameRef.current = requestAnimationFrame(tick);
};
tick();
} catch (err) {
console.error('Mic test failed:', err);
}
};
const stopMicTest = useCallback(() => {
if (micStreamRef.current) {
micStreamRef.current.getTracks().forEach(t => t.stop());
micStreamRef.current = null;
}
if (animFrameRef.current) {
cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = null;
}
setMicTesting(false);
setMicLevel(0);
}, []);
useEffect(() => {
return () => stopMicTest();
}, [stopMicTest]);
const selectStyle = {
width: '100%', backgroundColor: 'var(--bg-tertiary)', border: 'none',
borderRadius: '4px', padding: '10px', color: 'var(--text-normal)',
fontSize: '14px', outline: 'none', boxSizing: 'border-box',
};
const labelStyle = {
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
};
return (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Voice & Video</h2>
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
{/* Input Device */}
<div style={{ flex: 1 }}>
<label style={labelStyle}>Input Device</label>
<select
value={selectedInput}
onChange={(e) => setSelectedInput(e.target.value)}
style={selectStyle}
>
<option value="default">Default</option>
{inputDevices.map(d => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || `Microphone ${d.deviceId.slice(0, 8)}`}
</option>
))}
</select>
</div>
{/* Output Device */}
<div style={{ flex: 1 }}>
<label style={labelStyle}>Output Device</label>
<select
value={selectedOutput}
onChange={(e) => setSelectedOutput(e.target.value)}
style={selectStyle}
>
<option value="default">Default</option>
{outputDevices.map(d => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || `Speaker ${d.deviceId.slice(0, 8)}`}
</option>
))}
</select>
</div>
</div>
{/* Input Volume */}
<div style={{ marginBottom: '20px' }}>
<label style={labelStyle}>Input Volume</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<input
type="range"
min="0"
max="100"
value={inputVolume}
onChange={(e) => setInputVolume(parseInt(e.target.value))}
className="voice-slider"
/>
<span style={{ color: 'var(--text-normal)', fontSize: '14px', minWidth: '36px' }}>{inputVolume}%</span>
</div>
</div>
{/* Output Volume */}
<div style={{ marginBottom: '24px' }}>
<label style={labelStyle}>Output Volume</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<input
type="range"
min="0"
max="100"
value={outputVolume}
onChange={(e) => setOutputVolume(parseInt(e.target.value))}
className="voice-slider"
/>
<span style={{ color: 'var(--text-normal)', fontSize: '14px', minWidth: '36px' }}>{outputVolume}%</span>
</div>
</div>
{/* Mic Test */}
<div style={{ marginBottom: '24px' }}>
<label style={labelStyle}>Mic Test</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<button
onClick={micTesting ? stopMicTest : startMicTest}
style={{
backgroundColor: micTesting ? '#ed4245' : 'var(--brand-experiment)',
color: 'white', border: 'none', borderRadius: '4px',
padding: '8px 16px', cursor: 'pointer', fontSize: '14px', fontWeight: '500',
flexShrink: 0,
}}
>
{micTesting ? 'Stop Testing' : 'Let\'s Check'}
</button>
<div className="mic-level-bar">
<div className="mic-level-fill" style={{ width: `${micLevel}%` }} />
</div>
</div>
</div>
</div>
);
};
/* =========================================
KEYBINDS TAB
========================================= */
const KeybindsTab = () => {
const keybinds = [
{ action: 'Quick Switcher', keys: 'Ctrl+K' },
{ action: 'Toggle Mute', keys: 'Ctrl+Shift+M' },
];
return (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Keybinds</h2>
<div style={{
backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', padding: '16px',
marginBottom: '16px',
}}>
<p style={{ color: 'var(--text-muted)', fontSize: '14px', margin: '0 0 16px' }}>
Keybind configuration coming soon. Current keybinds are shown below.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{keybinds.map(kb => (
<div key={kb.action} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 12px', backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px',
}}>
<span style={{ color: 'var(--text-normal)', fontSize: '14px' }}>{kb.action}</span>
<kbd style={{
backgroundColor: 'var(--bg-primary)', padding: '4px 8px', borderRadius: '4px',
fontSize: '13px', color: 'var(--header-primary)', fontFamily: 'inherit',
border: '1px solid var(--border-subtle)',
}}>
{kb.keys}
</kbd>
</div>
))}
</div>
</div>
</div>
);
};
export default UserSettings;

View File

@@ -0,0 +1,47 @@
import React, { createContext, useContext, useMemo } from 'react';
import usePresence from '@convex-dev/presence/react';
import { api } from '../../../../convex/_generated/api';
const PresenceContext = createContext({
onlineUsers: new Set(),
resolveStatus: (storedStatus, userId) => storedStatus || 'offline',
});
export const useOnlineUsers = () => useContext(PresenceContext);
/**
* Status resolution logic:
* - If user is NOT connected (no heartbeat) → "offline"
* - If user IS connected and chose "invisible" → "offline"
* - If user IS connected → show their chosen status (online/idle/dnd)
*/
function resolveStatusFn(onlineUsers, storedStatus, userId) {
if (!onlineUsers.has(userId)) return 'offline';
if (storedStatus === 'invisible') return 'offline';
return storedStatus || 'online';
}
export const PresenceProvider = ({ userId, children }) => {
const presenceState = usePresence(api.presence, 'global', userId);
const onlineUsers = useMemo(() => {
const set = new Set();
if (presenceState) {
for (const p of presenceState) {
if (p.online) set.add(p.userId);
}
}
return set;
}, [presenceState]);
const value = useMemo(() => ({
onlineUsers,
resolveStatus: (storedStatus, uid) => resolveStatusFn(onlineUsers, storedStatus, uid),
}), [onlineUsers]);
return (
<PresenceContext.Provider value={value}>
{children}
</PresenceContext.Provider>
);
};

View File

@@ -23,9 +23,21 @@ export function ThemeProvider({ children }) {
return localStorage.getItem(STORAGE_KEY) || THEMES.DARK;
});
// On mount, check settings.json as fallback when localStorage is empty
useEffect(() => {
if (!localStorage.getItem(STORAGE_KEY) && window.appSettings) {
window.appSettings.get('theme').then((saved) => {
if (saved && Object.values(THEMES).includes(saved)) {
setTheme(saved);
}
});
}
}, []);
useEffect(() => {
document.documentElement.className = theme;
localStorage.setItem(STORAGE_KEY, theme);
window.appSettings?.set('theme', theme);
}, [theme]);
return (

View File

@@ -135,7 +135,7 @@ body {
.sidebar {
width: 312px;
min-width: 312px;
background-color: var(--bg-secondary);
background-color: var(--bg-tertiary);
display: flex;
flex-direction: row;
flex-shrink: 0;
@@ -152,6 +152,11 @@ body {
flex-shrink: 0;
}
.ownerIcon {
color: var(--text-feedback-warning);
margin-inline-start: 4px;
}
.server-icon {
width: 48px;
height: 48px;
@@ -242,6 +247,7 @@ body {
.messages-list {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
padding: 0 0 20px 0;
display: flex;
@@ -250,11 +256,11 @@ body {
.messages-list::-webkit-scrollbar {
width: 8px;
background-color: var(--bg-secondary);
background-color: var(--bg-primary);
}
.messages-list::-webkit-scrollbar-thumb {
background-color: var(--bg-tertiary);
background-color: #666770;
border-radius: 4px;
}
@@ -574,6 +580,50 @@ body {
font-size: 20px;
}
/* Preview author line */
.preview-author {
font-size: 13px;
color: var(--header-primary);
font-weight: 500;
margin-bottom: 2px;
}
/* Provider-branded previews */
.twitter-preview {
border-left-color: #1da1f2 !important;
}
.twitter-preview .preview-description {
-webkit-line-clamp: 6;
line-clamp: 6;
}
.spotify-preview {
border-left-color: #1db954 !important;
}
.reddit-preview {
border-left-color: #ff4500 !important;
}
/* Large image layout: image below content at full width */
.large-image-layout {
flex-direction: column;
gap: 8px;
}
.large-image-layout .preview-image-container.large-image {
width: 100%;
max-width: 400px;
}
.large-image-layout .preview-image-container.large-image .preview-image {
width: 100%;
max-width: 100%;
max-height: 300px;
object-fit: cover;
}
.youtube-preview {
flex-direction: column;
align-items: flex-start;
@@ -722,7 +772,6 @@ body {
-webkit-app-region: drag;
z-index: 10000;
flex-shrink: 0;
border-bottom: 1px solid var(--bg-tertiary);
}
.titlebar-drag-region {
@@ -864,7 +913,7 @@ body {
.members-list {
width: 240px;
min-width: 240px;
background-color: var(--bg-secondary);
background-color: var(--bg-primary);
border-left: 1px solid var(--border-subtle);
overflow-y: auto;
padding: 16px 8px;
@@ -1894,25 +1943,58 @@ body {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--bg-tertiary);
cursor: pointer;
flex-shrink: 0;
font-weight: 600;
font-size: 15px;
color: var(--header-primary);
gap: 8px;
}
.server-header-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
border-radius: 4px;
padding: 4px 4px;
transition: background-color 0.1s;
}
.server-header:hover {
.server-header-name:hover {
background-color: var(--background-modifier-hover);
}
.server-header-chevron {
font-size: 10px;
color: var(--text-muted);
transition: transform 0.2s;
.server-header-invite {
flex-shrink: 0;
background: none;
border: none;
padding: 4px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--interactive-normal);
transition: color 0.15s, background-color 0.15s;
}
.server-header-invite:hover {
color: var(--interactive-hover);
background-color: var(--background-modifier-hover);
}
.server-header-invite img {
width: 20px;
height: 20px;
filter: brightness(0) invert(0.7);
}
.server-header-invite:hover img {
filter: brightness(0) invert(0.9);
}
/* ============================================
@@ -2280,4 +2362,173 @@ body {
height: 10px;
border-radius: 50%;
background-color: var(--control-primary-background-default);
}
/* ============================================
USER SETTINGS - AVATAR OVERLAY
============================================ */
.user-settings-avatar-wrapper {
position: relative;
border-radius: 50%;
overflow: hidden;
}
.user-settings-avatar-overlay {
position: absolute;
top: 0;
left: 0;
width: 80px;
height: 80px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
text-align: center;
line-height: 1.3;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.user-settings-avatar-wrapper:hover .user-settings-avatar-overlay {
opacity: 1;
}
/* ============================================
AVATAR CROP MODAL
============================================ */
.avatar-crop-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
}
.avatar-crop-dialog {
width: 440px;
background-color: var(--bg-tertiary);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.avatar-crop-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
}
.avatar-crop-area {
position: relative;
height: 300px;
background-color: #000;
}
.avatar-crop-slider-row {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
}
.avatar-crop-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: var(--bg-secondary);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.avatar-crop-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--header-primary);
cursor: pointer;
border: none;
}
.avatar-crop-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--header-primary);
cursor: pointer;
border: none;
}
.avatar-crop-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0 20px 20px;
}
/* ============================================
USER SETTINGS - MIC LEVEL METER
============================================ */
.mic-level-bar {
flex: 1;
height: 8px;
background-color: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.mic-level-fill {
height: 100%;
background-color: #3ba55c;
border-radius: 4px;
transition: width 0.05s ease;
}
/* ============================================
USER SETTINGS - VOICE SLIDER
============================================ */
.voice-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.voice-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--header-primary);
cursor: pointer;
border: none;
}
.voice-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--header-primary);
cursor: pointer;
border: none;
}

View File

@@ -9,6 +9,7 @@ import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader';
import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext';
const Chat = () => {
const [view, setView] = useState('server');
@@ -230,25 +231,37 @@ const Chat = () => {
);
}
if (!userId) {
return (
<div className="app-container">
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe' }}>
Loading...
</div>
</div>
);
}
return (
<div className="app-container">
<Sidebar
channels={channels}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={setView}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
/>
{renderMainContent()}
<ToastContainer />
</div>
<PresenceProvider userId={userId}>
<div className="app-container">
<Sidebar
channels={channels}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={setView}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
/>
{renderMainContent()}
<ToastContainer />
</div>
</PresenceProvider>
);
};

View File

@@ -63,6 +63,22 @@ const Login = () => {
localStorage.setItem('publicKey', verifyData.publicKey);
}
// Persist session via safeStorage for auto-login on restart
if (window.sessionPersistence) {
try {
await window.sessionPersistence.save({
userId: verifyData.userId,
username,
publicKey: verifyData.publicKey || '',
signingKey,
privateKey: rsaPriv,
savedAt: Date.now(),
});
} catch (e) {
console.warn('Session persistence unavailable:', e);
}
}
console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
navigate('/chat');

View File

@@ -50,6 +50,7 @@
--border-muted: rgba(255, 255, 255, 0.04);
--border-normal: rgba(255, 255, 255, 0.2);
--border-strong: rgba(255, 255, 255, 0.44);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
/* Icons */
--icon-default: #dbdee1;
@@ -93,6 +94,8 @@
--background-modifier-active: rgba(78, 80, 88, 0.48);
--background-modifier-selected: rgba(78, 80, 88, 0.6);
--div-border: #1e1f22;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
}
@@ -138,6 +141,7 @@
--border-muted: rgba(0, 0, 0, 0.2);
--border-normal: rgba(0, 0, 0, 0.36);
--border-strong: rgba(0, 0, 0, 0.48);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
/* Icons */
--icon-default: #313338;
@@ -181,6 +185,8 @@
--background-modifier-active: rgba(116, 124, 138, 0.22);
--background-modifier-selected: rgba(116, 124, 138, 0.30);
--div-border: #e1e2e4;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
}
@@ -198,7 +204,7 @@
--chat-background: #202225;
--channeltextarea-background: #252529;
--modal-background: #292b2f;
--panel-bg: #1a1b1e;
--panel-bg: color-mix(in oklab, hsl(240 calc(1*5.882%) 13.333% /1) 100%, #000 0%);
--embed-background: #242529;
/* Text */
@@ -226,6 +232,7 @@
--border-muted: rgba(255, 255, 255, 0.04);
--border-normal: rgba(255, 255, 255, 0.2);
--border-strong: rgba(255, 255, 255, 0.44);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
/* Icons */
--icon-default: #dddfe4;
@@ -254,7 +261,7 @@
/* Compatibility aliases */
--bg-primary: #202225;
--bg-secondary: #1a1b1e;
--bg-tertiary: #111214;
--bg-tertiary: #121214;
--text-normal: #dddfe4;
--header-primary: #f5f5f7;
--header-secondary: #a0a4ad;
@@ -269,6 +276,8 @@
--background-modifier-active: rgba(78, 80, 88, 0.3);
--background-modifier-selected: rgba(78, 80, 88, 0.4);
--div-border: #111214;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
}
@@ -314,6 +323,7 @@
--border-muted: rgba(255, 255, 255, 0.16);
--border-normal: rgba(255, 255, 255, 0.24);
--border-strong: rgba(255, 255, 255, 0.44);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
/* Icons */
--icon-default: #e0def0;
@@ -357,4 +367,6 @@
--background-modifier-active: rgba(78, 73, 106, 0.36);
--background-modifier-selected: rgba(78, 73, 106, 0.48);
--div-border: #080810;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
}