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';
import { useVoice } from '../contexts/VoiceContext';
import { useSearch } from '../contexts/SearchContext';
import { usePlatform } from '../platform';
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: 'security', label: 'Security', 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' },
{ id: 'search', label: 'Search', 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(
);
}
items.push(
{tab.section}
);
lastSection = tab.section;
}
items.push(
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}
);
});
items.push(
);
items.push(
);
return items;
};
return (
{/* Sidebar */}
{/* Content */}
{activeTab === 'account' &&
}
{activeTab === 'security' &&
}
{activeTab === 'appearance' &&
}
{activeTab === 'voice' &&
}
{activeTab === 'keybinds' &&
}
{activeTab === 'search' &&
}
{/* Right spacer with close button */}
);
};
/* =========================================
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);
const [joinSoundFile, setJoinSoundFile] = useState(null);
const [joinSoundPreviewName, setJoinSoundPreviewName] = useState(null);
const [removeJoinSound, setRemoveJoinSound] = useState(false);
const joinSoundInputRef = useRef(null);
const joinSoundAudioRef = 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 ||
joinSoundFile !== null ||
removeJoinSound;
setHasChanges(changed);
}, [displayName, aboutMe, customStatus, avatarFile, joinSoundFile, removeJoinSound, 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 handleJoinSoundChange = (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
alert('Join sound must be under 10MB');
e.target.value = '';
return;
}
setJoinSoundFile(file);
setJoinSoundPreviewName(file.name);
setRemoveJoinSound(false);
e.target.value = '';
};
const handleJoinSoundPreview = () => {
if (joinSoundAudioRef.current) {
joinSoundAudioRef.current.pause();
joinSoundAudioRef.current = null;
}
let src;
if (joinSoundFile) {
src = URL.createObjectURL(joinSoundFile);
} else if (currentUser?.joinSoundUrl) {
src = currentUser.joinSoundUrl;
}
if (src) {
const audio = new Audio(src);
audio.volume = 0.5;
audio.play().catch(e => console.error('Preview failed', e));
joinSoundAudioRef.current = audio;
}
};
const handleRemoveJoinSound = () => {
setJoinSoundFile(null);
setJoinSoundPreviewName(null);
setRemoveJoinSound(true);
};
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;
}
let joinSoundStorageId;
if (joinSoundFile) {
const jsUploadUrl = await convex.mutation(api.files.generateUploadUrl);
const jsRes = await fetch(jsUploadUrl, {
method: 'POST',
headers: { 'Content-Type': joinSoundFile.type },
body: joinSoundFile,
});
const jsData = await jsRes.json();
joinSoundStorageId = jsData.storageId;
}
const args = { userId, displayName, aboutMe, customStatus };
if (avatarStorageId) args.avatarStorageId = avatarStorageId;
if (joinSoundStorageId) args.joinSoundStorageId = joinSoundStorageId;
if (removeJoinSound) args.removeJoinSound = true;
await convex.mutation(api.auth.updateProfile, args);
setAvatarFile(null);
setJoinSoundFile(null);
setJoinSoundPreviewName(null);
setRemoveJoinSound(false);
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);
setJoinSoundFile(null);
setJoinSoundPreviewName(null);
setRemoveJoinSound(false);
if (avatarPreview) {
URL.revokeObjectURL(avatarPreview);
setAvatarPreview(null);
}
if (rawImageUrl) {
URL.revokeObjectURL(rawImageUrl);
setRawImageUrl(null);
}
setShowCropModal(false);
};
const avatarUrl = avatarPreview || currentUser?.avatarUrl;
return (
My Account
{/* Profile card */}
{/* Banner */}
{/* Profile body */}
{/* Avatar */}
{/* Fields */}
{/* Username (read-only) */}
{/* Display Name */}
Display Name
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',
}}
/>
{/* About Me */}
{/* Custom Status */}
Custom Status
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',
}}
/>
{/* Voice Channel Join Sound */}
Voice Channel Join Sound
joinSoundInputRef.current?.click()}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Upload Sound
{(joinSoundPreviewName || (!removeJoinSound && currentUser?.joinSoundUrl)) && (
Preview
)}
{(joinSoundPreviewName || (!removeJoinSound && currentUser?.joinSoundUrl)) && (
Remove
)}
{joinSoundPreviewName
? `Selected: ${joinSoundPreviewName}`
: removeJoinSound
? 'Join sound will be removed on save'
: currentUser?.joinSoundUrl
? 'Custom sound set — plays when you join a voice channel'
: 'Upload a short audio file (max 10MB) — plays for everyone when you join a voice channel'
}
{/* Save bar */}
{hasChanges && (
Careful — you have unsaved changes!
Reset
{saving ? 'Saving...' : 'Save Changes'}
)}
{showCropModal && rawImageUrl && (
)}
);
};
/* =========================================
SECURITY TAB
========================================= */
const SecurityTab = () => {
const [masterKey, setMasterKey] = useState(null);
const [revealed, setRevealed] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
const mk = sessionStorage.getItem('masterKey');
setMasterKey(mk);
}, []);
const formatKey = (hex) => {
if (!hex) return '';
return hex.match(/.{1,4}/g).join(' ');
};
const handleCopy = () => {
if (!masterKey) return;
navigator.clipboard.writeText(masterKey).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handleDownload = () => {
if (!masterKey) return;
const content = [
'=== RECOVERY KEY ===',
'',
'This is your Recovery Key for your encrypted account.',
'Store it in a safe place. If you lose your password, this key is the ONLY way to recover your account.',
'',
'DO NOT share this key with anyone.',
'',
`Recovery Key: ${masterKey}`,
'',
`Exported: ${new Date().toISOString()}`,
].join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'recovery-key.txt';
a.click();
URL.revokeObjectURL(url);
};
const labelStyle = {
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
};
return (
Security
Recovery Key
Your Recovery Key allows you to reset your password without losing access to your encrypted messages.
Store it somewhere safe — if you forget your password, this is the only way to recover your account.
{!masterKey ? (
Recovery Key is not available in this session. Please log out and log back in to access it.
) : !revealed ? (
{/* Warning box */}
Warning
Anyone with your Recovery Key can reset your password and take control of your account.
Only reveal it in a private, secure environment.
setConfirmed(e.target.checked)}
style={{ width: '16px', height: '16px', accentColor: 'var(--brand-experiment)' }}
/>
I understand and want to reveal my Recovery Key
setRevealed(true)}
disabled={!confirmed}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '10px 20px', cursor: confirmed ? 'pointer' : 'not-allowed',
fontSize: '14px', fontWeight: '500', opacity: confirmed ? 1 : 0.5,
}}
>
Reveal Recovery Key
) : (
Your Recovery Key
{formatKey(masterKey)}
{copied ? 'Copied!' : 'Copy'}
Download
{ setRevealed(false); setConfirmed(false); }}
style={{
backgroundColor: 'transparent', color: 'var(--text-muted)', border: '1px solid var(--border-subtle)',
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Hide
)}
);
};
/* =========================================
APPEARANCE TAB
========================================= */
const AppearanceTab = () => {
const { theme, setTheme } = useTheme();
return (
Appearance
Theme
{Object.values(THEMES).map((themeKey) => {
const preview = THEME_PREVIEWS[themeKey];
const isActive = theme === themeKey;
return (
);
})}
);
};
/* =========================================
VOICE & VIDEO TAB
========================================= */
const VoiceVideoTab = () => {
const { switchDevice, setGlobalOutputVolume } = useVoice();
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);
switchDevice('audioinput', selectedInput);
}, [selectedInput]);
useEffect(() => {
localStorage.setItem('voiceOutputDevice', selectedOutput);
switchDevice('audiooutput', selectedOutput);
}, [selectedOutput]);
useEffect(() => {
localStorage.setItem('voiceInputVolume', String(inputVolume));
}, [inputVolume]);
useEffect(() => {
localStorage.setItem('voiceOutputVolume', String(outputVolume));
setGlobalOutputVolume(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 (
Voice & Video
{/* Input Device */}
Input Device
setSelectedInput(e.target.value)}
style={selectStyle}
>
Default
{inputDevices.map(d => (
{d.label || `Microphone ${d.deviceId.slice(0, 8)}`}
))}
{/* Output Device */}
Output Device
setSelectedOutput(e.target.value)}
style={selectStyle}
>
Default
{outputDevices.map(d => (
{d.label || `Speaker ${d.deviceId.slice(0, 8)}`}
))}
{/* Input Volume */}
{/* Output Volume */}
{/* Mic Test */}
Mic Test
{micTesting ? 'Stop Testing' : 'Let\'s Check'}
);
};
/* =========================================
KEYBINDS TAB
========================================= */
const KeybindsTab = () => {
const keybinds = [
{ action: 'Quick Switcher', keys: 'Ctrl+K' },
{ action: 'Toggle Mute', keys: 'Ctrl+Shift+M' },
];
return (
Keybinds
Keybind configuration coming soon. Current keybinds are shown below.
{keybinds.map(kb => (
{kb.action}
{kb.keys}
))}
);
};
/* =========================================
SEARCH TAB
========================================= */
const TAG_HEX_LEN = 32;
const SearchTab = ({ userId }) => {
const convex = useConvex();
const { crypto } = usePlatform();
const searchCtx = useSearch();
const [status, setStatus] = useState('idle'); // idle | rebuilding | done | error
const [progress, setProgress] = useState({ currentChannel: '', channelIndex: 0, totalChannels: 0, messagesIndexed: 0 });
const [errorMsg, setErrorMsg] = useState('');
const cancelledRef = useRef(false);
const handleRebuild = async () => {
if (!userId || !crypto || !searchCtx?.isReady) return;
cancelledRef.current = false;
setStatus('rebuilding');
setProgress({ currentChannel: '', channelIndex: 0, totalChannels: 0, messagesIndexed: 0 });
setErrorMsg('');
try {
// 1. Gather channels + DMs
const [channels, dmChannels, rawKeys] = await Promise.all([
convex.query(api.channels.list, {}),
convex.query(api.dms.listDMs, { userId }),
convex.query(api.channelKeys.getKeysForUser, { userId }),
]);
// 2. Decrypt channel keys
const privateKey = sessionStorage.getItem('privateKey');
if (!privateKey) throw new Error('Private key not found in session. Please re-login.');
const decryptedKeys = {};
for (const item of rawKeys) {
try {
const bundleJson = await crypto.privateDecrypt(privateKey, item.encrypted_key_bundle);
Object.assign(decryptedKeys, JSON.parse(bundleJson));
} catch (e) {
// Skip channels we can't decrypt
}
}
// 3. Build channel list: text channels + DMs that have keys
const textChannels = channels
.filter(c => c.type === 'text' && decryptedKeys[c._id])
.map(c => ({ id: c._id, name: '#' + c.name, key: decryptedKeys[c._id] }));
const dmItems = (dmChannels || [])
.filter(dm => decryptedKeys[dm.channel_id])
.map(dm => ({ id: dm.channel_id, name: '@' + dm.other_username, key: decryptedKeys[dm.channel_id] }));
const allChannels = [...textChannels, ...dmItems];
if (allChannels.length === 0) {
setStatus('done');
setProgress(p => ({ ...p, totalChannels: 0 }));
return;
}
setProgress(p => ({ ...p, totalChannels: allChannels.length }));
let totalIndexed = 0;
// 4. For each channel, paginate and decrypt
for (let i = 0; i < allChannels.length; i++) {
if (cancelledRef.current) break;
const ch = allChannels[i];
setProgress(p => ({ ...p, currentChannel: ch.name, channelIndex: i + 1 }));
let cursor = null;
let isDone = false;
while (!isDone) {
if (cancelledRef.current) break;
const paginationOpts = { numItems: 100, cursor };
const result = await convex.query(api.messages.fetchBulkPage, {
channelId: ch.id,
paginationOpts,
});
if (result.page.length > 0) {
// Build decrypt batch
const decryptItems = [];
const msgMap = [];
for (const msg of result.page) {
if (msg.ciphertext && msg.ciphertext.length >= TAG_HEX_LEN) {
const tag = msg.ciphertext.slice(-TAG_HEX_LEN);
const content = msg.ciphertext.slice(0, -TAG_HEX_LEN);
decryptItems.push({ ciphertext: content, key: ch.key, iv: msg.nonce, tag });
msgMap.push(msg);
}
}
if (decryptItems.length > 0) {
const decryptResults = await crypto.decryptBatch(decryptItems);
const indexItems = [];
for (let j = 0; j < decryptResults.length; j++) {
const plaintext = decryptResults[j];
if (plaintext && plaintext !== '[Decryption Error]') {
indexItems.push({
id: msgMap[j].id,
channel_id: msgMap[j].channel_id,
sender_id: msgMap[j].sender_id,
username: msgMap[j].username,
content: plaintext,
created_at: msgMap[j].created_at,
pinned: msgMap[j].pinned,
replyToId: msgMap[j].replyToId,
});
}
}
if (indexItems.length > 0) {
searchCtx.indexMessages(indexItems);
totalIndexed += indexItems.length;
setProgress(p => ({ ...p, messagesIndexed: totalIndexed }));
}
}
}
isDone = result.isDone;
cursor = result.continueCursor;
// Yield to UI between pages
await new Promise(r => setTimeout(r, 10));
}
}
// 5. Save
await searchCtx.save();
setStatus(cancelledRef.current ? 'idle' : 'done');
setProgress(p => ({ ...p, messagesIndexed: totalIndexed }));
} catch (err) {
console.error('Search index rebuild failed:', err);
setErrorMsg(err.message || 'Unknown error');
setStatus('error');
}
};
const handleCancel = () => {
cancelledRef.current = true;
};
const formatNumber = (n) => n.toLocaleString();
return (
Search
Search Index
Rebuild your local search index by downloading and decrypting all messages from the server. This may take a while for large servers.
{status === 'idle' && (
Rebuild Search Index
)}
{status === 'rebuilding' && (
{/* Progress bar */}
0
? `${(progress.channelIndex / progress.totalChannels) * 100}%`
: '0%',
transition: 'width 0.3s ease',
}} />
Indexing {progress.currentChannel}... ({progress.channelIndex} of {progress.totalChannels} channels)
{formatNumber(progress.messagesIndexed)} messages indexed
Cancel
)}
{status === 'done' && (
Complete! {formatNumber(progress.messagesIndexed)} messages indexed across {progress.totalChannels} channels.
setStatus('idle')}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Rebuild Again
)}
{status === 'error' && (
Error: {errorMsg}
setStatus('idle')}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Retry
)}
);
};
export default UserSettings;