feat: Introduce multi-platform architecture for Electron and Web clients with shared UI components, Convex backend for messaging, and integrated search functionality.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-02-16 13:08:39 -06:00
parent 8ff9213b34
commit ec12313996
49 changed files with 2449 additions and 3914 deletions

View File

@@ -5,6 +5,8 @@ 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' },
@@ -18,6 +20,7 @@ const TABS = [
{ 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 }) => {
@@ -111,6 +114,7 @@ const UserSettings = ({ onClose, userId, username, onLogout }) => {
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'voice' && <VoiceVideoTab />}
{activeTab === 'keybinds' && <KeybindsTab />}
{activeTab === 'search' && <SearchTab userId={userId} />}
</div>
{/* Right spacer with close button */}
@@ -845,4 +849,259 @@ const KeybindsTab = () => {
);
};
/* =========================================
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;