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
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user