Files
DiscordClone/Frontend/Electron/src/components/UserSettings.jsx
Bryan1029384756 cb4361da1a
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
feat: Implement core Discord features including members list, direct messages, user presence, authentication, and chat UI.
2026-02-11 04:36:40 -06:00

713 lines
26 KiB
JavaScript

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;