1277 lines
47 KiB
JavaScript
1277 lines
47 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';
|
|
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(<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 === 'security' && <SecurityTab />}
|
|
{activeTab === 'appearance' && <AppearanceTab />}
|
|
{activeTab === 'voice' && <VoiceVideoTab />}
|
|
{activeTab === 'keybinds' && <KeybindsTab />}
|
|
{activeTab === 'search' && <SearchTab userId={userId} />}
|
|
</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);
|
|
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 (
|
|
<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>
|
|
|
|
{/* Voice Channel Join Sound */}
|
|
<div>
|
|
<label style={{
|
|
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
|
|
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
|
|
}}>
|
|
Voice Channel Join Sound
|
|
</label>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
|
<button
|
|
onClick={() => 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
|
|
</button>
|
|
{(joinSoundPreviewName || (!removeJoinSound && currentUser?.joinSoundUrl)) && (
|
|
<button
|
|
onClick={handleJoinSoundPreview}
|
|
title="Preview join sound"
|
|
style={{
|
|
backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-normal)', border: 'none',
|
|
borderRadius: '4px', padding: '8px 12px', cursor: 'pointer', fontSize: '14px',
|
|
display: 'flex', alignItems: 'center', gap: '4px',
|
|
}}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
<polygon points="5,3 19,12 5,21" />
|
|
</svg>
|
|
Preview
|
|
</button>
|
|
)}
|
|
{(joinSoundPreviewName || (!removeJoinSound && currentUser?.joinSoundUrl)) && (
|
|
<button
|
|
onClick={handleRemoveJoinSound}
|
|
style={{
|
|
backgroundColor: 'transparent', color: '#ed4245', border: '1px solid #ed4245',
|
|
borderRadius: '4px', padding: '7px 12px', cursor: 'pointer', fontSize: '14px',
|
|
}}
|
|
>
|
|
Remove
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={joinSoundInputRef}
|
|
type="file"
|
|
accept="audio/mpeg,audio/wav,audio/ogg,audio/webm"
|
|
onChange={handleJoinSoundChange}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</div>
|
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '6px' }}>
|
|
{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'
|
|
}
|
|
</div>
|
|
</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>
|
|
);
|
|
};
|
|
|
|
/* =========================================
|
|
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 (
|
|
<div>
|
|
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Security</h2>
|
|
|
|
<div style={{ backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', padding: '20px' }}>
|
|
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 8px', fontSize: '16px', fontWeight: '600' }}>
|
|
Recovery Key
|
|
</h3>
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '14px', margin: '0 0 16px', lineHeight: '1.4' }}>
|
|
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 <strong style={{ color: 'var(--text-normal)' }}>only way</strong> to recover your account.
|
|
</p>
|
|
|
|
{!masterKey ? (
|
|
<div style={{
|
|
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', padding: '16px',
|
|
color: 'var(--text-muted)', fontSize: '14px',
|
|
}}>
|
|
Recovery Key is not available in this session. Please log out and log back in to access it.
|
|
</div>
|
|
) : !revealed ? (
|
|
<div>
|
|
{/* Warning box */}
|
|
<div style={{
|
|
backgroundColor: 'rgba(250, 166, 26, 0.1)', border: '1px solid rgba(250, 166, 26, 0.4)',
|
|
borderRadius: '4px', padding: '12px', marginBottom: '16px',
|
|
}}>
|
|
<div style={{ color: '#faa61a', fontSize: '14px', fontWeight: '600', marginBottom: '4px' }}>
|
|
Warning
|
|
</div>
|
|
<div style={{ color: 'var(--text-normal)', fontSize: '13px', lineHeight: '1.4' }}>
|
|
Anyone with your Recovery Key can reset your password and take control of your account.
|
|
Only reveal it in a private, secure environment.
|
|
</div>
|
|
</div>
|
|
|
|
<label style={{
|
|
display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer',
|
|
color: 'var(--text-normal)', fontSize: '14px', marginBottom: '16px',
|
|
}}>
|
|
<input
|
|
type="checkbox"
|
|
checked={confirmed}
|
|
onChange={(e) => setConfirmed(e.target.checked)}
|
|
style={{ width: '16px', height: '16px', accentColor: 'var(--brand-experiment)' }}
|
|
/>
|
|
I understand and want to reveal my Recovery Key
|
|
</label>
|
|
|
|
<button
|
|
onClick={() => 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
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<label style={labelStyle}>Your Recovery Key</label>
|
|
<div style={{
|
|
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', padding: '16px',
|
|
fontFamily: 'Consolas, "Courier New", monospace', fontSize: '15px',
|
|
color: 'var(--text-normal)', wordBreak: 'break-all', lineHeight: '1.6',
|
|
letterSpacing: '1px', marginBottom: '12px', userSelect: 'all',
|
|
}}>
|
|
{formatKey(masterKey)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
|
<button
|
|
onClick={handleCopy}
|
|
style={{
|
|
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
|
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
|
|
fontSize: '14px', fontWeight: '500',
|
|
}}
|
|
>
|
|
{copied ? 'Copied!' : 'Copy'}
|
|
</button>
|
|
<button
|
|
onClick={handleDownload}
|
|
style={{
|
|
backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-normal)', border: 'none',
|
|
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
|
|
fontSize: '14px', fontWeight: '500',
|
|
}}
|
|
>
|
|
Download
|
|
</button>
|
|
<button
|
|
onClick={() => { 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
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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 { 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 (
|
|
<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>
|
|
);
|
|
};
|
|
|
|
/* =========================================
|
|
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 (
|
|
<div>
|
|
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Search</h2>
|
|
|
|
<div style={{ backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', padding: '20px' }}>
|
|
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 8px', fontSize: '16px', fontWeight: '600' }}>
|
|
Search Index
|
|
</h3>
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '14px', margin: '0 0 16px', lineHeight: '1.4' }}>
|
|
Rebuild your local search index by downloading and decrypting all messages from the server. This may take a while for large servers.
|
|
</p>
|
|
|
|
{status === 'idle' && (
|
|
<button
|
|
onClick={handleRebuild}
|
|
disabled={!searchCtx?.isReady}
|
|
style={{
|
|
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
|
borderRadius: '4px', padding: '10px 20px', cursor: searchCtx?.isReady ? 'pointer' : 'not-allowed',
|
|
fontSize: '14px', fontWeight: '500', opacity: searchCtx?.isReady ? 1 : 0.5,
|
|
}}
|
|
>
|
|
Rebuild Search Index
|
|
</button>
|
|
)}
|
|
|
|
{status === 'rebuilding' && (
|
|
<div>
|
|
{/* Progress bar */}
|
|
<div style={{
|
|
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', height: '8px',
|
|
overflow: 'hidden', marginBottom: '12px',
|
|
}}>
|
|
<div style={{
|
|
height: '100%', borderRadius: '4px',
|
|
backgroundColor: 'var(--brand-experiment)',
|
|
width: progress.totalChannels > 0
|
|
? `${(progress.channelIndex / progress.totalChannels) * 100}%`
|
|
: '0%',
|
|
transition: 'width 0.3s ease',
|
|
}} />
|
|
</div>
|
|
|
|
<div style={{ color: 'var(--text-normal)', fontSize: '14px', marginBottom: '4px' }}>
|
|
Indexing {progress.currentChannel}... ({progress.channelIndex} of {progress.totalChannels} channels)
|
|
</div>
|
|
<div style={{ color: 'var(--text-muted)', fontSize: '13px', marginBottom: '12px' }}>
|
|
{formatNumber(progress.messagesIndexed)} messages indexed
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleCancel}
|
|
style={{
|
|
backgroundColor: 'transparent', color: '#ed4245', border: '1px solid #ed4245',
|
|
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
|
|
fontSize: '14px', fontWeight: '500',
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'done' && (
|
|
<div>
|
|
<div style={{ color: '#3ba55c', fontSize: '14px', marginBottom: '12px', fontWeight: '500' }}>
|
|
Complete! {formatNumber(progress.messagesIndexed)} messages indexed across {progress.totalChannels} channels.
|
|
</div>
|
|
<button
|
|
onClick={() => setStatus('idle')}
|
|
style={{
|
|
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
|
borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
|
|
fontSize: '14px', fontWeight: '500',
|
|
}}
|
|
>
|
|
Rebuild Again
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'error' && (
|
|
<div>
|
|
<div style={{ color: '#ed4245', fontSize: '14px', marginBottom: '12px' }}>
|
|
Error: {errorMsg}
|
|
</div>
|
|
<button
|
|
onClick={() => setStatus('idle')}
|
|
style={{
|
|
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
|
borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
|
|
fontSize: '14px', fontWeight: '500',
|
|
}}
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UserSettings;
|