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
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
1
Frontend/Electron/src/assets/icons/crown.svg
Normal file
1
Frontend/Electron/src/assets/icons/crown.svg
Normal 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 |
1
Frontend/Electron/src/assets/icons/invite_user.svg
Normal file
1
Frontend/Electron/src/assets/icons/invite_user.svg
Normal 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 |
134
Frontend/Electron/src/components/AvatarCropModal.jsx
Normal file
134
Frontend/Electron/src/components/AvatarCropModal.jsx
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
712
Frontend/Electron/src/components/UserSettings.jsx
Normal file
712
Frontend/Electron/src/components/UserSettings.jsx
Normal 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;
|
||||
47
Frontend/Electron/src/contexts/PresenceContext.jsx
Normal file
47
Frontend/Electron/src/contexts/PresenceContext.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user