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

@@ -4,6 +4,7 @@ import Login from './pages/Login';
import Register from './pages/Register';
import Chat from './pages/Chat';
import { usePlatform } from './platform';
import { useSearch } from './contexts/SearchContext';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@@ -12,6 +13,7 @@ function AuthGuard({ children }) {
const location = useLocation();
const navigate = useNavigate();
const { session, settings } = usePlatform();
const searchCtx = useSearch();
useEffect(() => {
let cancelled = false;
@@ -19,6 +21,7 @@ function AuthGuard({ children }) {
async function restoreSession() {
// Already have keys in sessionStorage — current session is active
if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
searchCtx?.initialize();
if (!cancelled) setAuthState('authenticated');
return;
}
@@ -34,6 +37,8 @@ function AuthGuard({ children }) {
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
sessionStorage.setItem('signingKey', savedSession.signingKey);
sessionStorage.setItem('privateKey', savedSession.privateKey);
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
searchCtx?.initialize();
// Restore user preferences from file-based backup into localStorage
if (settings) {
try {

View File

@@ -27,6 +27,7 @@ import MessageItem, { getUserColor } from './MessageItem';
import ColoredIcon from './ColoredIcon';
import { usePlatform } from '../platform';
import { useVoice } from '../contexts/VoiceContext';
import { useSearch } from '../contexts/SearchContext';
const metadataCache = new Map();
const attachmentCache = new Map();
@@ -433,7 +434,7 @@ const EmojiButton = ({ onClick, active }) => {
const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner, canDelete }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); window.addEventListener('close-context-menus', h); return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); }; }, [onClose]);
React.useLayoutEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
@@ -468,7 +469,7 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner, canDelete }) =
const InputContextMenu = ({ x, y, onClose, onPaste }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); window.addEventListener('close-context-menus', h); return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); }; }, [onClose]);
React.useLayoutEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
@@ -492,6 +493,7 @@ const InputContextMenu = ({ x, y, onClose, onPaste }) => {
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned }) => {
const { crypto } = usePlatform();
const { isReceivingScreenShareAudio } = useVoice();
const searchCtx = useSearch();
const [decryptedMessages, setDecryptedMessages] = useState([]);
const [input, setInput] = useState('');
const [zoomedImage, setZoomedImage] = useState(null);
@@ -704,6 +706,25 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
evictCacheIfNeeded();
// Index successfully decrypted messages for search
if (searchCtx?.isReady) {
const toIndex = needsDecryption.map(msg => {
const cached = messageDecryptionCache.get(msg.id);
if (!cached || cached.content.startsWith('[')) return null;
return {
id: msg.id,
channel_id: channelId,
sender_id: msg.sender_id,
username: msg.username,
content: cached.content,
created_at: msg.created_at,
pinned: msg.pinned,
replyToId: msg.replyToId,
};
}).filter(Boolean);
if (toIndex.length > 0) searchCtx.indexMessages(toIndex);
}
if (cancelled) return;
// Phase 3: Re-render with newly decrypted content
@@ -714,6 +735,24 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
return () => { cancelled = true; };
}, [rawMessages, channelKey]);
// Index cached messages when search DB becomes ready (covers messages decrypted before DB init)
useEffect(() => {
if (!searchCtx?.isReady || !channelId || decryptedMessages.length === 0) return;
const toIndex = decryptedMessages
.filter(m => m.content && !m.content.startsWith('['))
.map(m => ({
id: m.id,
channel_id: channelId,
sender_id: m.sender_id,
username: m.username,
content: m.content,
created_at: m.created_at,
pinned: m.pinned,
replyToId: m.replyToId,
}));
if (toIndex.length > 0) searchCtx.indexMessages(toIndex);
}, [searchCtx?.isReady]);
useEffect(() => {
// Don't clear messageDecryptionCache — it persists across channel switches
setDecryptedMessages([]);
@@ -725,7 +764,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setMentionQuery(null);
setUnreadDividerTimestamp(null);
onTogglePinned();
}, [channelId, channelKey]);
}, [channelId]);
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
@@ -1341,7 +1380,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
username={username}
onHover={() => setHoveredMessageId(msg.id)}
onLeave={() => setHoveredMessageId(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
@@ -1440,6 +1479,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onKeyUp={saveSelection}
onContextMenu={(e) => {
e.preventDefault();
window.dispatchEvent(new Event('close-context-menus'));
setInputContextMenu({ x: e.clientX, y: e.clientY });
}}
onPaste={(e) => {

View File

@@ -1,12 +1,40 @@
import React, { useState } from 'react';
import React from 'react';
import Tooltip from './Tooltip';
const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, showMembers, onTogglePinned, serverName, isMobile, onMobileBack }) => {
const [searchFocused, setSearchFocused] = useState(false);
const ChatHeader = ({
channelName,
channelType,
channelTopic,
onToggleMembers,
showMembers,
onTogglePinned,
serverName,
isMobile,
onMobileBack,
// Search props
searchQuery,
onSearchQueryChange,
onSearchSubmit,
onSearchFocus,
onSearchBlur,
searchInputRef,
searchActive,
}) => {
const isDM = channelType === 'dm';
const searchPlaceholder = isDM ? 'Search' : `Search ${serverName || 'Server'}`;
const handleSearchKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
onSearchSubmit?.();
}
if (e.key === 'Escape') {
e.preventDefault();
onSearchBlur?.();
e.target.blur();
}
};
return (
<div className="chat-header">
<div className="chat-header-left">
@@ -65,14 +93,29 @@ const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, s
</Tooltip>
)}
{!isMobile && (
<div className="chat-header-search-wrapper">
<div className="chat-header-search-wrapper" ref={searchInputRef}>
<svg className="chat-header-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
</svg>
<input
type="text"
placeholder={searchPlaceholder}
className={`chat-header-search ${searchFocused ? 'focused' : ''}`}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
className={`chat-header-search ${searchActive ? 'focused' : ''}`}
value={searchQuery || ''}
onChange={(e) => onSearchQueryChange?.(e.target.value)}
onFocus={onSearchFocus}
onKeyDown={handleSearchKeyDown}
/>
{searchQuery && (
<button
className="chat-header-search-clear"
onClick={() => onSearchQueryChange?.('')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z"/>
</svg>
</button>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,244 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { detectActivePrefix } from '../utils/searchUtils';
const FILTER_SUGGESTIONS = [
{ prefix: 'from:', label: 'from:', description: 'user', icon: 'user' },
{ prefix: 'mentions:', label: 'mentions:', description: 'user', icon: 'at' },
{ prefix: 'has:', label: 'has:', description: 'link, file, image, or video', icon: 'has' },
{ prefix: 'in:', label: 'in:', description: 'channel', icon: 'channel' },
{ prefix: 'before:', label: 'before:', description: 'date', icon: 'date' },
{ prefix: 'after:', label: 'after:', description: 'date', icon: 'date' },
{ prefix: 'pinned:', label: 'pinned:', description: 'true or false', icon: 'pin' },
];
const HAS_OPTIONS = [
{ value: 'link', label: 'link' },
{ value: 'file', label: 'file' },
{ value: 'image', label: 'image' },
{ value: 'video', label: 'video' },
];
function getAvatarColor(name) {
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
let hash = 0;
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
function FilterIcon({ type }) {
switch (type) {
case 'user':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
);
case 'at':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10h5v-2h-5c-4.34 0-8-3.66-8-8s3.66-8 8-8 8 3.66 8 8v1.43c0 .79-.71 1.57-1.5 1.57s-1.5-.78-1.5-1.57V12c0-2.76-2.24-5-5-5s-5 2.24-5 5 2.24 5 5 5c1.38 0 2.64-.56 3.54-1.47.65.89 1.77 1.47 2.96 1.47 1.97 0 3.5-1.6 3.5-3.57V12c0-5.52-4.48-10-10-10zm0 13c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>
</svg>
);
case 'has':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
</svg>
);
case 'channel':
return <span style={{ fontSize: 16, fontWeight: 700, opacity: 0.7 }}>#</span>;
case 'date':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/>
</svg>
);
case 'pin':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.3 5.3a1 1 0 00-1.4-1.4L14.6 7.2l-1.5-.8a2 2 0 00-2.2.2L8.5 9a1 1 0 000 1.5l1.8 1.8-4.6 4.6a1 1 0 001.4 1.4l4.6-4.6 1.8 1.8a1 1 0 001.5 0l2.4-2.4a2 2 0 00.2-2.2l-.8-1.5 3.3-3.3z"/>
</svg>
);
default:
return null;
}
}
const SearchDropdown = ({
visible,
searchText,
channels,
members,
searchHistory,
onSelectFilter,
onSelectHistoryItem,
onClearHistory,
onClearHistoryItem,
anchorRef,
onClose,
}) => {
const dropdownRef = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0, width: 420 });
// Position dropdown below anchor
useEffect(() => {
if (!visible || !anchorRef?.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPos({
top: rect.bottom + 4,
left: Math.max(rect.right - 420, 8),
width: 420,
});
}, [visible, anchorRef, searchText]);
// Click outside to close
useEffect(() => {
if (!visible) return;
const handler = (e) => {
if (
dropdownRef.current && !dropdownRef.current.contains(e.target) &&
anchorRef?.current && !anchorRef.current.contains(e.target)
) {
onClose();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [visible, onClose, anchorRef]);
if (!visible) return null;
const activePrefix = detectActivePrefix(searchText);
let content;
if (activePrefix?.prefix === 'from' || activePrefix?.prefix === 'mentions') {
const filtered = (members || []).filter(m =>
m.username.toLowerCase().includes(activePrefix.partial)
);
const headerText = activePrefix.prefix === 'from' ? 'FROM USER' : 'MENTIONS USER';
content = (
<div className="search-dropdown-scrollable">
<div className="search-dropdown-section-header">{headerText}</div>
{filtered.length === 0 && (
<div className="search-dropdown-empty">No matching users</div>
)}
{filtered.map(m => (
<div
key={m.id}
className="search-dropdown-member"
onClick={() => onSelectFilter(activePrefix.prefix, m.username)}
>
{m.avatarUrl ? (
<img src={m.avatarUrl} className="search-dropdown-avatar" alt="" />
) : (
<div className="search-dropdown-avatar" style={{ backgroundColor: getAvatarColor(m.username) }}>
{m.username[0]?.toUpperCase()}
</div>
)}
<span className="search-dropdown-member-name">{m.username}</span>
</div>
))}
</div>
);
} else if (activePrefix?.prefix === 'in') {
const filtered = (channels || []).filter(c =>
c.name?.toLowerCase().includes(activePrefix.partial) && c.type === 'text'
);
content = (
<div className="search-dropdown-scrollable">
<div className="search-dropdown-section-header">IN CHANNEL</div>
{filtered.length === 0 && (
<div className="search-dropdown-empty">No matching channels</div>
)}
{filtered.map(c => (
<div
key={c._id}
className="search-dropdown-channel"
onClick={() => onSelectFilter('in', c.name)}
>
<span className="search-dropdown-channel-hash">#</span>
<span>{c.name}</span>
</div>
))}
</div>
);
} else if (activePrefix?.prefix === 'has') {
const filtered = HAS_OPTIONS.filter(o =>
o.value.includes(activePrefix.partial)
);
content = (
<div className="search-dropdown-scrollable">
<div className="search-dropdown-section-header">MESSAGE CONTAINS</div>
{filtered.map(o => (
<div
key={o.value}
className="search-dropdown-item"
onClick={() => onSelectFilter('has', o.value)}
>
<FilterIcon type="has" />
<span>{o.label}</span>
</div>
))}
</div>
);
} else {
// Default: show filter suggestions + search history
content = (
<div className="search-dropdown-scrollable">
<div className="search-dropdown-section-header">SEARCH OPTIONS</div>
{FILTER_SUGGESTIONS.map(f => (
<div
key={f.prefix}
className="search-dropdown-item"
onClick={() => onSelectFilter(f.prefix.replace(':', ''), null)}
>
<span className="search-dropdown-item-icon"><FilterIcon type={f.icon} /></span>
<span className="search-dropdown-item-label">{f.label}</span>
<span className="search-dropdown-item-desc">{f.description}</span>
</div>
))}
{searchHistory && searchHistory.length > 0 && (
<>
<div className="search-dropdown-section-header search-dropdown-history-header">
<span>SEARCH HISTORY</span>
<button className="search-dropdown-clear-all" onClick={onClearHistory}>Clear</button>
</div>
{searchHistory.map((item, i) => (
<div
key={i}
className="search-dropdown-history-item"
onClick={() => onSelectHistoryItem(item)}
>
<svg className="search-dropdown-history-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3a9 9 0 00-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0013 21a9 9 0 000-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/>
</svg>
<span className="search-dropdown-history-text">{item}</span>
<button
className="search-dropdown-history-delete"
onClick={(e) => { e.stopPropagation(); onClearHistoryItem(i); }}
>
&times;
</button>
</div>
))}
</>
)}
</div>
);
}
return ReactDOM.createPortal(
<div
ref={dropdownRef}
className="search-dropdown"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{content}
</div>,
document.body
);
};
export default SearchDropdown;

View File

@@ -0,0 +1,408 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useSearch } from '../contexts/SearchContext';
import { parseFilters } from '../utils/searchUtils';
import { usePlatform } from '../platform';
function formatTime(ts) {
const d = new Date(ts);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function getAvatarColor(name) {
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
let hash = 0;
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
const CONVEX_PUBLIC_URL = 'http://72.26.56.3:3210';
const rewriteStorageUrl = (url) => {
try {
const u = new URL(url);
const pub = new URL(CONVEX_PUBLIC_URL);
u.hostname = pub.hostname;
u.port = pub.port;
u.protocol = pub.protocol;
return u.toString();
} catch { return url; }
};
const toHexString = (bytes) =>
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
const searchImageCache = new Map();
const SearchResultImage = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
const [error, setError] = useState(null);
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) throw new Error('Invalid file data');
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search image decrypt error:', err);
if (isMounted) { setError('Failed to load'); setLoading(false); }
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading image...</div>;
if (error) return null;
return <img src={url} alt={metadata.filename} style={{ width: '100%', height: 'auto', borderRadius: 4, marginTop: 4, display: 'block' }} />;
};
const SearchResultVideo = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
const [error, setError] = useState(null);
const [showControls, setShowControls] = useState(false);
const videoRef = useRef(null);
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) throw new Error('Invalid file data');
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search video decrypt error:', err);
if (isMounted) { setError('Failed to load'); setLoading(false); }
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading video...</div>;
if (error) return null;
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
return (
<div style={{ marginTop: 4, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
<video ref={videoRef} src={url} controls={showControls} style={{ width: '100%', maxHeight: 200, borderRadius: 4, display: 'block', backgroundColor: 'black' }} />
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}></div>}
</div>
);
};
const SearchResultFile = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) return;
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search file decrypt error:', err);
if (isMounted) setLoading(false);
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
const sizeStr = metadata.size ? `${(metadata.size / 1024).toFixed(1)} KB` : '';
return (
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '8px 10px', borderRadius: 4, marginTop: 4, maxWidth: '100%' }}>
<span style={{ marginRight: 8, fontSize: 20 }}>📄</span>
<div style={{ overflow: 'hidden', flex: 1 }}>
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 }}>{metadata.filename}</div>
{sizeStr && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>{sizeStr}</div>}
{url && <a href={url} download={metadata.filename} onClick={e => e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download</a>}
{loading && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>Decrypting...</div>}
</div>
</div>
);
};
const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => {
const { search, isReady } = useSearch() || {};
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const [showSortMenu, setShowSortMenu] = useState(false);
// Execute search when query changes
useEffect(() => {
if (!visible || !query?.trim() || !search || !isReady) {
if (!query?.trim()) setResults([]);
return;
}
setSearching(true);
const { textQuery, filters } = parseFilters(query);
let channelId;
if (isDM) {
// In DM view — always scope to the DM channel
channelId = dmChannelId;
} else {
channelId = filters.channelName
? channels?.find(c => c.name?.toLowerCase() === filters.channelName.toLowerCase())?._id
: undefined;
}
const params = {
query: textQuery || undefined,
channelId,
senderName: filters.senderName,
hasLink: filters.hasLink,
hasImage: filters.hasImage,
hasVideo: filters.hasVideo,
hasFile: filters.hasFile,
hasMention: filters.hasMention,
before: filters.before,
after: filters.after,
pinned: filters.pinned,
limit: 25,
};
const res = search(params);
let filtered;
if (isDM) {
// In DM view — results are already scoped to dmChannelId
filtered = res;
} else {
// In server view — filter out DM messages
const serverChannelIds = new Set(channels?.map(c => c._id) || []);
filtered = res.filter(r => serverChannelIds.has(r.channel_id));
}
// Sort results
let sorted = [...filtered];
if (sortOrder === 'oldest') {
sorted.sort((a, b) => a.created_at - b.created_at);
} else {
// newest first (default)
sorted.sort((a, b) => b.created_at - a.created_at);
}
setResults(sorted);
setSearching(false);
}, [visible, query, sortOrder, search, isReady, channels, isDM, dmChannelId]);
const handleResultClick = useCallback((result) => {
onJumpToMessage(result.channel_id, result.id);
}, [onJumpToMessage]);
if (!visible) return null;
const channelMap = {};
if (channels) {
for (const c of channels) channelMap[c._id] = c.name;
}
// Group results by channel
const grouped = {};
for (const r of results) {
const chName = channelMap[r.channel_id] || 'Unknown';
if (!grouped[chName]) grouped[chName] = [];
grouped[chName].push(r);
}
const { filters: activeFilters } = query?.trim() ? parseFilters(query) : { filters: {} };
const filterChips = [];
if (activeFilters.senderName) filterChips.push({ label: `from: ${activeFilters.senderName}`, key: 'from' });
if (activeFilters.hasLink) filterChips.push({ label: 'has: link', key: 'hasLink' });
if (activeFilters.hasImage) filterChips.push({ label: 'has: image', key: 'hasImage' });
if (activeFilters.hasVideo) filterChips.push({ label: 'has: video', key: 'hasVideo' });
if (activeFilters.hasFile) filterChips.push({ label: 'has: file', key: 'hasFile' });
if (activeFilters.hasMention) filterChips.push({ label: 'has: mention', key: 'hasMention' });
if (activeFilters.before) filterChips.push({ label: `before: ${activeFilters.before}`, key: 'before' });
if (activeFilters.after) filterChips.push({ label: `after: ${activeFilters.after}`, key: 'after' });
if (activeFilters.pinned) filterChips.push({ label: 'pinned: true', key: 'pinned' });
if (activeFilters.channelName) filterChips.push({ label: `in: ${activeFilters.channelName}`, key: 'in' });
const sortLabel = sortOrder === 'oldest' ? 'Oldest' : 'Newest';
return (
<div className="search-panel">
<div className="search-panel-header">
<div className="search-panel-header-left">
<span className="search-result-count">
{results.length} result{results.length !== 1 ? 's' : ''}
</span>
</div>
<div className="search-panel-header-right">
<div className="search-panel-sort-wrapper">
<button
className="search-panel-sort-btn"
onClick={() => setShowSortMenu(prev => !prev)}
>
{sortLabel}
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 4 }}>
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
{showSortMenu && (
<div className="search-panel-sort-menu">
<div
className={`search-panel-sort-option ${sortOrder === 'newest' ? 'active' : ''}`}
onClick={() => { onSortChange('newest'); setShowSortMenu(false); }}
>
Newest
</div>
<div
className={`search-panel-sort-option ${sortOrder === 'oldest' ? 'active' : ''}`}
onClick={() => { onSortChange('oldest'); setShowSortMenu(false); }}
>
Oldest
</div>
</div>
)}
</div>
<button className="search-panel-close" onClick={onClose}>&times;</button>
</div>
</div>
{filterChips.length > 0 && (
<div className="search-filter-chips">
{filterChips.map(chip => (
<span key={chip.key} className="search-filter-chip">
{chip.label}
</span>
))}
</div>
)}
<div className="search-panel-results">
{!isReady && (
<div className="search-panel-empty">Search database is loading...</div>
)}
{isReady && searching && <div className="search-panel-empty">Searching...</div>}
{isReady && !searching && results.length === 0 && (
<div className="search-panel-empty">
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.3, marginBottom: 8 }}>
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
</svg>
<div>No results found</div>
</div>
)}
{Object.entries(grouped).map(([chName, msgs]) => (
<div key={chName}>
<div className="search-channel-header">{isDM ? chName : `#${chName}`}</div>
{msgs.map(r => (
<div
key={r.id}
className="search-result"
onClick={() => handleResultClick(r)}
>
<div
className="search-result-avatar"
style={{ backgroundColor: getAvatarColor(r.username) }}
>
{r.username?.[0]?.toUpperCase()}
</div>
<div className="search-result-body">
<div className="search-result-header">
<span className="search-result-username">{r.username}</span>
<span className="search-result-time">{formatTime(r.created_at)}</span>
</div>
{!(r.has_attachment && r.attachment_meta) && (
<div
className="search-result-content"
dangerouslySetInnerHTML={{ __html: r.snippet || escapeHtml(r.content) }}
/>
)}
{r.has_attachment && r.attachment_meta ? (() => {
try {
const meta = JSON.parse(r.attachment_meta);
if (r.attachment_type?.startsWith('image/')) return <SearchResultImage metadata={meta} />;
if (r.attachment_type?.startsWith('video/')) return <SearchResultVideo metadata={meta} />;
return <SearchResultFile metadata={meta} />;
} catch { return <span className="search-result-badge">File</span>; }
})() : r.has_attachment ? <span className="search-result-badge">File</span> : null}
{r.has_link && <span className="search-result-badge">Link</span>}
{r.pinned && <span className="search-result-badge">Pinned</span>}
</div>
</div>
))}
</div>
))}
</div>
</div>
);
};
export default SearchPanel;

View File

@@ -93,7 +93,7 @@ const STATUS_OPTIONS = [
];
const UserControlPanel = React.memo(({ username, userId }) => {
const { session, idle } = usePlatform();
const { session, idle, searchDB } = usePlatform();
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [showUserSettings, setShowUserSettings] = useState(false);
@@ -137,6 +137,10 @@ const UserControlPanel = React.memo(({ username, userId }) => {
if (connectionState === 'connected') {
try { disconnectVoice(); } catch {}
}
// Save and close search DB
if (searchDB?.isOpen()) {
try { await searchDB.save(); searchDB.close(); } catch {}
}
// Clear persisted session
if (session) {
try { await session.clear(); } catch {}
@@ -383,7 +387,8 @@ const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMu
useEffect(() => {
const h = () => onClose();
window.addEventListener('click', h);
return () => window.removeEventListener('click', h);
window.addEventListener('close-context-menus', h);
return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); };
}, [onClose]);
useLayoutEffect(() => {
@@ -489,7 +494,8 @@ const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCatego
useEffect(() => {
const h = () => onClose();
window.addEventListener('click', h);
return () => window.removeEventListener('click', h);
window.addEventListener('close-context-menus', h);
return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); };
}, [onClose]);
useLayoutEffect(() => {
@@ -1062,6 +1068,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
window.dispatchEvent(new Event('close-context-menus'));
setVoiceUserMenu({ x: e.clientX, y: e.clientY, user });
}}
>
@@ -1332,6 +1339,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={(e) => {
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
e.preventDefault();
window.dispatchEvent(new Event('close-context-menus'));
setChannelListContextMenu({ x: e.clientX, y: e.clientY });
}
}}>

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;

View File

@@ -0,0 +1,75 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { usePlatform } from '../platform';
const SearchContext = createContext(null);
export function SearchProvider({ children }) {
const { searchDB, features } = usePlatform();
const [isReady, setIsReady] = useState(false);
const [initSignal, setInitSignal] = useState(0);
const initialize = useCallback(() => {
setInitSignal(s => s + 1);
}, []);
useEffect(() => {
if (!features?.hasSearch || !searchDB) return;
// Already open from a previous run
if (searchDB.isOpen()) {
setIsReady(true);
return;
}
const dbKey = sessionStorage.getItem('searchDbKey');
const userId = localStorage.getItem('userId');
if (!dbKey || !userId) return;
searchDB.open(dbKey, userId)
.then(() => {
setIsReady(true);
console.log('Search DB initialized');
})
.catch(err => {
console.error('Search DB init failed:', err);
});
const handleBeforeUnload = () => {
if (searchDB.isOpen()) {
searchDB.save();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [searchDB, features?.hasSearch, initSignal]);
const indexMessages = useCallback((messages) => {
if (!searchDB?.isOpen() || !messages?.length) return;
searchDB.indexMessages(messages);
}, [searchDB]);
const search = useCallback((params) => {
if (!searchDB?.isOpen()) return [];
return searchDB.search(params);
}, [searchDB]);
const save = useCallback(async () => {
if (searchDB?.isOpen()) {
await searchDB.save();
}
}, [searchDB]);
const value = useMemo(() => (
{ isReady, indexMessages, search, save, searchDB, initialize }
), [isReady, indexMessages, search, save, searchDB, initialize]);
return (
<SearchContext.Provider value={value}>
{children}
</SearchContext.Provider>
);
}
export function useSearch() {
return useContext(SearchContext);
}

View File

@@ -236,6 +236,7 @@ body {
display: flex;
flex: 1;
min-height: 0;
position: relative;
}
.chat-area {
@@ -889,25 +890,60 @@ body {
.chat-header-search-wrapper {
margin-left: 4px;
position: relative;
display: flex;
align-items: center;
}
.chat-header-search-icon {
position: absolute;
left: 8px;
color: var(--text-muted);
pointer-events: none;
z-index: 1;
display: flex;
align-items: center;
}
.chat-header-search {
width: 160px;
width: 214px;
height: 28px;
background-color: var(--bg-tertiary);
border: none;
background-color: #17171a;
border: 1px solid color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.2) 100%,hsl(0 0% 0% /0.2) 0%);
border-radius: 4px;
color: var(--text-normal);
padding: 0 8px;
color: color-mix(in oklab, hsl(240 calc(1*6.667%) 94.118% /1) 100%, #000 0%);
padding: 0 28px 0 28px;
font-size: 13px;
outline: none;
transition: width 0.25s ease;
font-family: inherit;
}
.chat-header-search::placeholder {
color: var(--text-muted);
}
.chat-header-search.focused {
width: 240px;
}
.chat-header-search-clear {
position: absolute;
right: 4px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
border-radius: 2px;
}
.chat-header-search-clear:hover {
color: var(--header-primary);
}
/* ============================================
MEMBERS LIST
============================================ */
@@ -3191,4 +3227,468 @@ body {
.is-mobile .friends-view {
width: 100vw;
}
/* Search panel full-width on mobile */
.is-mobile .search-panel {
width: 100vw;
right: 0;
border-radius: 0;
}
}
/* ============================================
SEARCH DROPDOWN (appears below header input)
============================================ */
.search-dropdown {
position: fixed;
z-index: 10001;
background-color: #111214;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
animation: searchDropdownIn 0.15s ease;
}
@keyframes searchDropdownIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.search-dropdown-scrollable {
max-height: 500px;
overflow-y: auto;
padding: 8px 0;
}
.search-dropdown-scrollable::-webkit-scrollbar {
width: 6px;
}
.search-dropdown-scrollable::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-auto-thumb, var(--bg-tertiary));
border-radius: 3px;
}
.search-dropdown-section-header {
font-size: 12px;
font-weight: 700;
color: var(--header-secondary);
text-transform: uppercase;
padding: 8px 16px 4px;
letter-spacing: 0.02em;
display: flex;
align-items: center;
justify-content: space-between;
}
.search-dropdown-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
cursor: pointer;
color: var(--text-normal);
font-size: 14px;
transition: background-color 0.1s;
}
.search-dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-dropdown-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
color: var(--text-muted);
flex-shrink: 0;
}
.search-dropdown-item-label {
font-weight: 600;
color: var(--header-primary);
}
.search-dropdown-item-desc {
color: var(--text-muted);
font-size: 13px;
}
.search-dropdown-member {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.1s;
}
.search-dropdown-member:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-dropdown-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 11px;
flex-shrink: 0;
object-fit: cover;
}
img.search-dropdown-avatar {
object-fit: cover;
}
.search-dropdown-member-name {
color: var(--text-normal);
font-size: 14px;
font-weight: 500;
}
.search-dropdown-channel {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
color: var(--text-normal);
font-size: 14px;
transition: background-color 0.1s;
}
.search-dropdown-channel:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-dropdown-channel-hash {
font-size: 18px;
font-weight: 700;
color: var(--text-muted);
width: 24px;
text-align: center;
flex-shrink: 0;
}
.search-dropdown-history-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.search-dropdown-clear-all {
background: none;
border: none;
color: var(--text-link);
font-size: 12px;
cursor: pointer;
padding: 0;
font-weight: 500;
}
.search-dropdown-clear-all:hover {
text-decoration: underline;
}
.search-dropdown-history-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.1s;
}
.search-dropdown-history-item:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-dropdown-history-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.search-dropdown-history-text {
flex: 1;
color: var(--text-normal);
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-dropdown-history-delete {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
opacity: 0;
transition: opacity 0.1s;
line-height: 1;
}
.search-dropdown-history-item:hover .search-dropdown-history-delete {
opacity: 1;
}
.search-dropdown-history-delete:hover {
color: var(--header-primary);
}
.search-dropdown-empty {
padding: 12px 16px;
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
/* ============================================
SEARCH PANEL (results)
============================================ */
.search-panel {
position: absolute;
top: 0;
right: 0;
width: 420px;
height: 100%;
background-color: var(--bg-secondary);
border-left: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
z-index: 100;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3);
}
.search-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.search-panel-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.search-panel-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.search-result-count {
color: var(--header-secondary);
font-size: 13px;
font-weight: 600;
}
.search-panel-sort-wrapper {
position: relative;
}
.search-panel-sort-btn {
display: flex;
align-items: center;
background: none;
border: none;
color: var(--text-link);
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-family: inherit;
}
.search-panel-sort-btn:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-panel-sort-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background-color: #111214;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 10;
overflow: hidden;
min-width: 120px;
}
.search-panel-sort-option {
padding: 8px 12px;
color: var(--text-normal);
font-size: 14px;
cursor: pointer;
transition: background-color 0.1s;
}
.search-panel-sort-option:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-panel-sort-option.active {
color: var(--text-link);
}
.search-panel-close {
background: none;
border: none;
color: var(--header-secondary);
font-size: 20px;
cursor: pointer;
line-height: 1;
padding: 4px;
}
.search-panel-close:hover {
color: var(--header-primary);
}
.search-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 16px;
}
.search-filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
background-color: var(--brand-experiment);
color: white;
font-size: 12px;
font-weight: 500;
}
.search-panel-results {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
}
.search-panel-results::-webkit-scrollbar {
width: 6px;
}
.search-panel-results::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-auto-thumb, var(--bg-tertiary));
border-radius: 3px;
}
.search-panel-empty {
text-align: center;
color: var(--text-muted);
padding: 32px 16px;
display: flex;
flex-direction: column;
align-items: center;
}
.search-channel-header {
font-size: 12px;
font-weight: 600;
color: var(--header-secondary);
text-transform: uppercase;
padding: 8px 8px 4px;
letter-spacing: 0.02em;
}
.search-result {
background-color: var(--bg-primary);
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 4px;
cursor: pointer;
transition: background-color 0.1s;
display: flex;
gap: 12px;
}
.search-result:hover {
background-color: var(--bg-mod-faint);
}
.search-result-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 15px;
flex-shrink: 0;
}
.search-result-body {
flex: 1;
min-width: 0;
}
.search-result-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.search-result-username {
color: var(--header-primary);
font-size: 1rem;
font-weight: 600;
}
.search-result-time {
color: var(--text-muted);
font-size: 0.75rem;
margin-left: 0;
}
.search-result-content {
color: var(--text-normal);
font-size: 0.9375rem;
line-height: 1.375;
word-break: break-word;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.search-result-content mark {
background-color: rgba(250, 166, 26, 0.3);
color: var(--text-normal);
border-radius: 2px;
padding: 0 1px;
}
.search-result-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
background-color: var(--bg-tertiary);
padding: 1px 6px;
border-radius: 3px;
margin-top: 4px;
margin-right: 4px;
text-transform: uppercase;
}

View File

@@ -9,12 +9,16 @@ import { useVoice } from '../contexts/VoiceContext';
import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader';
import SearchPanel from '../components/SearchPanel';
import SearchDropdown from '../components/SearchDropdown';
import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext';
import { getUserPref, setUserPref } from '../utils/userPreferences';
import { usePlatform } from '../platform';
import { useIsMobile } from '../hooks/useIsMobile';
const MAX_SEARCH_HISTORY = 10;
const Chat = () => {
const { crypto, settings } = usePlatform();
const isMobile = useIsMobile();
@@ -31,6 +35,17 @@ const Chat = () => {
const [showPinned, setShowPinned] = useState(false);
const [mobileView, setMobileView] = useState('sidebar');
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [showSearchDropdown, setShowSearchDropdown] = useState(false);
const [showSearchResults, setShowSearchResults] = useState(false);
const [searchSortOrder, setSearchSortOrder] = useState('newest');
const [searchHistory, setSearchHistory] = useState(() => {
const id = localStorage.getItem('userId');
return id ? getUserPref(id, 'searchHistory', []) : [];
});
const searchInputRef = useRef(null);
const convex = useConvex();
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
const prevDmChannelsRef = useRef(null);
@@ -41,7 +56,11 @@ const Chat = () => {
const handler = (e) => {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
// Quick switcher placeholder - could open a search modal
// Focus the search input
const input = searchInputRef.current?.querySelector('input');
if (input) {
input.focus();
}
}
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
@@ -57,6 +76,7 @@ const Chat = () => {
const serverSettings = useQuery(api.serverSettings.get);
const serverName = serverSettings?.serverName || 'Secure Chat';
const serverIconUrl = serverSettings?.iconUrl || null;
const allMembers = useQuery(api.members.listAll) || [];
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
@@ -195,6 +215,127 @@ const Chat = () => {
}
}, [voiceActiveChannelId]);
// Search handlers
const handleSearchQueryChange = useCallback((val) => {
setSearchQuery(val);
if (val === '') {
setShowSearchResults(false);
}
if (!showSearchDropdown && val !== undefined) {
setShowSearchDropdown(true);
}
}, [showSearchDropdown]);
const handleSearchFocus = useCallback(() => {
setShowSearchDropdown(true);
}, []);
const handleSearchBlur = useCallback(() => {
// Dropdown close is handled by click-outside in SearchDropdown
}, []);
const handleSearchSubmit = useCallback(() => {
if (!searchQuery.trim()) return;
setShowSearchDropdown(false);
setShowSearchResults(true);
// Save to history
setSearchHistory(prev => {
const filtered = prev.filter(h => h !== searchQuery.trim());
const updated = [searchQuery.trim(), ...filtered].slice(0, MAX_SEARCH_HISTORY);
if (userId) {
setUserPref(userId, 'searchHistory', updated, settings);
}
return updated;
});
}, [searchQuery, userId, settings]);
const handleSelectFilter = useCallback((prefix, value) => {
if (value !== null) {
// Replace the current active prefix with the completed token
const beforePrefix = searchQuery.replace(/\b(from|in|has|mentions):\S*$/i, '').trimEnd();
const newQuery = beforePrefix + (beforePrefix ? ' ' : '') + prefix + ':' + value + ' ';
setSearchQuery(newQuery);
} else {
// Just insert the prefix (e.g., clicking "from:" suggestion)
const newQuery = searchQuery + (searchQuery && !searchQuery.endsWith(' ') ? ' ' : '') + prefix + ':';
setSearchQuery(newQuery);
}
// Re-focus input
setTimeout(() => {
const input = searchInputRef.current?.querySelector('input');
if (input) input.focus();
}, 0);
}, [searchQuery]);
const handleSelectHistoryItem = useCallback((item) => {
setSearchQuery(item);
setShowSearchDropdown(false);
setShowSearchResults(true);
}, []);
const handleClearHistory = useCallback(() => {
setSearchHistory([]);
if (userId) {
setUserPref(userId, 'searchHistory', [], settings);
}
}, [userId, settings]);
const handleClearHistoryItem = useCallback((index) => {
setSearchHistory(prev => {
const updated = prev.filter((_, i) => i !== index);
if (userId) {
setUserPref(userId, 'searchHistory', updated, settings);
}
return updated;
});
}, [userId, settings]);
const handleCloseSearchDropdown = useCallback(() => {
setShowSearchDropdown(false);
}, []);
const handleCloseSearchResults = useCallback(() => {
setShowSearchResults(false);
setSearchQuery('');
}, []);
const handleJumpToMessage = useCallback((channelId, messageId) => {
// Switch to the correct channel if needed
const isDM = dmChannels.some(dm => dm.channel_id === channelId);
if (isDM) {
const dm = dmChannels.find(d => d.channel_id === channelId);
if (dm) {
setActiveDMChannel(dm);
setView('me');
}
} else {
setActiveChannel(channelId);
setView('server');
}
setShowSearchResults(false);
setSearchQuery('');
// Give time for channel to render then scroll
setTimeout(() => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
}, 300);
}, [dmChannels]);
// Shared search props for ChatHeader
const searchProps = {
searchQuery,
onSearchQueryChange: handleSearchQueryChange,
onSearchSubmit: handleSearchSubmit,
onSearchFocus: handleSearchFocus,
onSearchBlur: handleSearchBlur,
searchInputRef,
searchActive: showSearchDropdown || showSearchResults,
};
function renderMainContent() {
if (view === 'me') {
if (activeDMChannel) {
@@ -208,6 +349,7 @@ const Chat = () => {
onTogglePinned={() => setShowPinned(p => !p)}
isMobile={isMobile}
onMobileBack={handleMobileBack}
{...searchProps}
/>
<div className="chat-content">
<ChatArea
@@ -223,6 +365,17 @@ const Chat = () => {
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/>
<SearchPanel
visible={showSearchResults}
onClose={handleCloseSearchResults}
channels={channels}
isDM={true}
dmChannelId={activeDMChannel.channel_id}
onJumpToMessage={handleJumpToMessage}
query={searchQuery}
sortOrder={searchSortOrder}
onSortChange={setSearchSortOrder}
/>
</div>
</div>
);
@@ -277,6 +430,7 @@ const Chat = () => {
serverName={serverName}
isMobile={isMobile}
onMobileBack={handleMobileBack}
{...searchProps}
/>
<div className="chat-content">
<ChatArea
@@ -297,6 +451,15 @@ const Chat = () => {
visible={effectiveShowMembers}
onMemberClick={(member) => {}}
/>
<SearchPanel
visible={showSearchResults}
onClose={handleCloseSearchResults}
channels={channels}
onJumpToMessage={handleJumpToMessage}
query={searchQuery}
sortOrder={searchSortOrder}
onSortChange={setSearchSortOrder}
/>
</div>
</div>
);
@@ -359,6 +522,21 @@ const Chat = () => {
)}
{showMainContent && renderMainContent()}
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
{showSearchDropdown && !isMobile && (
<SearchDropdown
visible={showSearchDropdown}
searchText={searchQuery}
channels={channels}
members={allMembers}
searchHistory={searchHistory}
onSelectFilter={handleSelectFilter}
onSelectHistoryItem={handleSelectHistoryItem}
onClearHistory={handleClearHistory}
onClearHistoryItem={handleClearHistoryItem}
anchorRef={searchInputRef}
onClose={handleCloseSearchDropdown}
/>
)}
<ToastContainer />
</div>
</PresenceProvider>

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useConvex } from 'convex/react';
import { usePlatform } from '../platform';
import { useSearch } from '../contexts/SearchContext';
import { api } from '../../../../convex/_generated/api';
const Login = () => {
@@ -12,6 +13,7 @@ const Login = () => {
const navigate = useNavigate();
const convex = useConvex();
const { crypto, session } = usePlatform();
const searchCtx = useSearch();
async function decryptEncryptedField(encryptedJson, keyHex) {
const obj = JSON.parse(encryptedJson);
@@ -32,6 +34,10 @@ const Login = () => {
const { dek, dak } = await crypto.deriveAuthKeys(password, salt);
console.log('Derived keys');
// Derive a separate key for the local search database
const searchKeys = await crypto.deriveAuthKeys(password, 'searchdb-' + username);
sessionStorage.setItem('searchDbKey', searchKeys.dak);
const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
if (verifyData.error) {
@@ -74,6 +80,7 @@ const Login = () => {
publicKey: verifyData.publicKey || '',
signingKey,
privateKey: rsaPriv,
searchDbKey: searchKeys.dak,
savedAt: Date.now(),
});
} catch (e) {
@@ -83,6 +90,7 @@ const Login = () => {
console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
searchCtx?.initialize();
navigate('/chat');
} catch (err) {
console.error('Login error:', err);

View File

@@ -57,11 +57,24 @@
* @property {() => Promise<object>} checkUpdate
*/
/**
* @typedef {Object} PlatformSearchDB
* @property {(dbKeyHex: string, userId: string) => Promise<void>} open
* @property {() => Promise<void>} close
* @property {() => Promise<void>} save
* @property {(messages: Array) => void} indexMessages
* @property {(params: object) => Array} search
* @property {(messageId: string) => boolean} isIndexed
* @property {() => boolean} isOpen
* @property {() => object} getStats
*/
/**
* @typedef {Object} PlatformFeatures
* @property {boolean} hasWindowControls
* @property {boolean} hasScreenCapture
* @property {boolean} hasNativeUpdates
* @property {boolean} hasSearch
*/
/**
@@ -74,6 +87,7 @@
* @property {PlatformScreenCapture|null} screenCapture
* @property {PlatformWindowControls|null} windowControls
* @property {PlatformUpdates|null} updates
* @property {PlatformSearchDB|null} searchDB
* @property {PlatformFeatures} features
*/

View File

@@ -0,0 +1,389 @@
import initSqlJsModule from 'sql.js';
const initSqlJs = initSqlJsModule.default || initSqlJsModule;
import wasmUrl from 'sql.js/dist/sql-wasm.wasm?url';
const URL_RE = /https?:\/\/[^\s<>]+/i;
const MENTION_RE = /@(\w+)/g;
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
sender_id TEXT,
username TEXT,
content TEXT,
created_at INTEGER,
has_attachment INTEGER DEFAULT 0,
has_link INTEGER DEFAULT 0,
has_mention INTEGER DEFAULT 0,
mentioned_users TEXT,
attachment_filename TEXT,
attachment_type TEXT,
attachment_meta TEXT DEFAULT '',
pinned INTEGER DEFAULT 0,
reply_to_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_channel ON messages(channel_id);
CREATE INDEX IF NOT EXISTS idx_sender ON messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_created ON messages(created_at);
`;
let sqlPromise = null;
function getSql() {
if (!sqlPromise) {
sqlPromise = initSqlJs({ locateFile: () => wasmUrl });
}
return sqlPromise;
}
export default class SearchDatabase {
constructor(storageAdapter, cryptoAdapter) {
this.storage = storageAdapter;
this.crypto = cryptoAdapter;
this.db = null;
this.dbKey = null;
this._dirty = false;
this._saveTimer = null;
this._opened = false;
this._userId = null;
}
isOpen() {
return this._opened && this.db !== null;
}
async open(dbKeyHex, userId) {
if (this._opened) return;
this.dbKey = dbKeyHex;
this._userId = userId;
const SQL = await getSql();
// Try loading from storage
let loaded = false;
try {
const blob = await this.storage.load(userId);
if (blob && blob.length > 0) {
const json = new TextDecoder().decode(blob);
const { content, iv, tag } = JSON.parse(json);
const decrypted = await this.crypto.decryptData(content, dbKeyHex, iv, tag);
const bytes = hexToBytes(decrypted);
this.db = new SQL.Database(bytes);
loaded = true;
// Drop old FTS5 artifacts from previous schema
try {
this.db.run('DROP TRIGGER IF EXISTS messages_ai');
this.db.run('DROP TRIGGER IF EXISTS messages_ad');
this.db.run('DROP TRIGGER IF EXISTS messages_au');
} catch {}
try { this.db.run('DROP TABLE IF EXISTS messages_fts'); } catch {}
// Migrate: add attachment_meta column if missing
try { this.db.run("ALTER TABLE messages ADD COLUMN attachment_meta TEXT DEFAULT ''"); } catch {}
console.log('Search DB loaded from encrypted storage');
}
} catch (err) {
console.warn('Search DB decrypt failed, starting fresh:', err.message);
}
if (!loaded) {
this.db = new SQL.Database();
this.db.run(SCHEMA_SQL);
console.log('Search DB created fresh');
}
this._opened = true;
this._dirty = false;
}
async close() {
if (!this._opened) return;
if (this._saveTimer) {
clearTimeout(this._saveTimer);
this._saveTimer = null;
}
if (this._dirty) {
await this.save();
}
if (this.db) {
this.db.close();
this.db = null;
}
this.dbKey = null;
this._opened = false;
this._userId = null;
}
async save() {
if (!this.db || !this.dbKey || !this._userId) return;
try {
const data = this.db.export();
const hex = bytesToHex(data);
const encrypted = await this.crypto.encryptData(hex, this.dbKey);
const json = JSON.stringify({ content: encrypted.content, iv: encrypted.iv, tag: encrypted.tag });
const bytes = new TextEncoder().encode(json);
await this.storage.save(this._userId, bytes);
this._dirty = false;
console.log('Search DB saved');
} catch (err) {
console.error('Search DB save error:', err);
}
}
_scheduleSave() {
if (this._saveTimer) return;
this._saveTimer = setTimeout(() => {
this._saveTimer = null;
this.save();
}, 30000);
}
indexMessages(messages) {
if (!this.db || messages.length === 0) return;
this.db.run('BEGIN TRANSACTION');
try {
const stmt = this.db.prepare(
`INSERT OR REPLACE INTO messages (id, channel_id, sender_id, username, content, created_at, has_attachment, has_link, has_mention, mentioned_users, attachment_filename, attachment_type, attachment_meta, pinned, reply_to_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const msg of messages) {
let content = msg.content || '';
let hasAttachment = 0;
let hasLink = 0;
let hasMention = 0;
let mentionedUsers = '';
let attachmentFilename = '';
let attachmentType = '';
let attachmentMeta = '';
// Parse attachment
try {
if (content.startsWith('{')) {
const parsed = JSON.parse(content);
if (parsed.type === 'attachment') {
hasAttachment = 1;
attachmentFilename = parsed.filename || '';
attachmentType = parsed.mimeType || '';
attachmentMeta = content;
content = `[File: ${attachmentFilename}]`;
}
}
} catch {}
// Check for links
if (URL_RE.test(content)) hasLink = 1;
// Check for mentions
const mentions = [];
let m;
while ((m = MENTION_RE.exec(content)) !== null) {
mentions.push(m[1]);
}
if (mentions.length > 0) {
hasMention = 1;
mentionedUsers = mentions.join(',');
}
const createdAt = typeof msg.created_at === 'number'
? msg.created_at
: new Date(msg.created_at).getTime();
stmt.run([
msg.id,
msg.channel_id,
msg.sender_id || null,
msg.username || null,
content,
createdAt,
hasAttachment,
hasLink,
hasMention,
mentionedUsers || null,
attachmentFilename || null,
attachmentType || null,
attachmentMeta || null,
msg.pinned ? 1 : 0,
msg.replyToId || null,
]);
}
stmt.free();
this.db.run('COMMIT');
this._dirty = true;
this._scheduleSave();
} catch (err) {
try { this.db.run('ROLLBACK'); } catch {}
console.error('Search DB indexing error:', err);
}
}
isIndexed(messageId) {
if (!this.db) return false;
try {
const stmt = this.db.prepare('SELECT 1 FROM messages WHERE id = ?');
stmt.bind([messageId]);
const found = stmt.step();
stmt.free();
return found;
} catch {
return false;
}
}
search({ query, channelId, senderId, senderName, hasLink, hasAttachment, hasImage, hasVideo, hasFile, hasMention, before, after, pinned, limit = 50, offset = 0 }) {
if (!this.db) return [];
try {
let sql, params = [];
const conditions = [];
let queryWords = [];
if (query && query.trim()) {
queryWords = query.trim().split(/\s+/).filter(Boolean);
sql = `SELECT m.* FROM messages m WHERE 1=1`;
for (const word of queryWords) {
conditions.push('(m.content LIKE ? OR m.username LIKE ? OR m.attachment_filename LIKE ?)');
const pattern = `%${word}%`;
params.push(pattern, pattern, pattern);
}
} else {
sql = `SELECT m.* FROM messages m WHERE 1=1`;
}
if (channelId) {
conditions.push('m.channel_id = ?');
params.push(channelId);
}
if (senderId) {
conditions.push('m.sender_id = ?');
params.push(senderId);
}
if (senderName) {
conditions.push('m.username = ?');
params.push(senderName);
}
if (hasLink) {
conditions.push('m.has_link = 1');
}
if (hasAttachment) {
conditions.push('m.has_attachment = 1');
}
if (hasImage) {
conditions.push("m.has_attachment = 1 AND m.attachment_type LIKE 'image/%'");
}
if (hasVideo) {
conditions.push("m.has_attachment = 1 AND m.attachment_type LIKE 'video/%'");
}
if (hasFile) {
conditions.push("m.has_attachment = 1 AND m.attachment_type NOT LIKE 'image/%' AND m.attachment_type NOT LIKE 'video/%'");
}
if (hasMention) {
conditions.push('m.has_mention = 1');
}
if (before) {
conditions.push('m.created_at < ?');
params.push(new Date(before).getTime());
}
if (after) {
conditions.push('m.created_at > ?');
params.push(new Date(after).getTime());
}
if (pinned) {
conditions.push('m.pinned = 1');
}
if (conditions.length > 0) {
sql += ' AND ' + conditions.join(' AND ');
}
sql += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = this.db.prepare(sql);
stmt.bind(params);
const results = [];
while (stmt.step()) {
const row = stmt.getAsObject();
results.push({
id: row.id,
channel_id: row.channel_id,
sender_id: row.sender_id,
username: row.username,
content: row.content,
created_at: row.created_at,
has_attachment: !!row.has_attachment,
has_link: !!row.has_link,
pinned: !!row.pinned,
attachment_type: row.attachment_type || '',
attachment_meta: row.attachment_meta || '',
snippet: queryWords.length > 0
? generateSnippet(row.content || '', queryWords)
: row.content,
reply_to_id: row.reply_to_id,
});
}
stmt.free();
return results;
} catch (err) {
console.error('Search DB query error:', err);
return [];
}
}
getStats() {
if (!this.db) return { count: 0 };
try {
const result = this.db.exec('SELECT COUNT(*) as cnt FROM messages');
return { count: result[0]?.values[0]?.[0] || 0 };
} catch {
return { count: 0 };
}
}
}
function generateSnippet(content, queryWords) {
if (!content) return '';
const lower = content.toLowerCase();
// Find earliest match position
let firstIdx = content.length;
for (const word of queryWords) {
const idx = lower.indexOf(word.toLowerCase());
if (idx !== -1 && idx < firstIdx) firstIdx = idx;
}
if (firstIdx === content.length) firstIdx = 0;
// Extract ~80 chars of context around the match
const start = Math.max(0, firstIdx - 40);
const end = Math.min(content.length, firstIdx + 40);
let slice = content.slice(start, end);
if (start > 0) slice = '...' + slice;
if (end < content.length) slice = slice + '...';
// Escape HTML then wrap matches in <mark>
slice = slice.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
for (const word of queryWords) {
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
slice = slice.replace(new RegExp(escaped, 'gi'), m => `<mark>${m}</mark>`);
}
return slice;
}
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
function bytesToHex(bytes) {
let hex = '';
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}

View File

@@ -0,0 +1,47 @@
const FILTER_RE = /\b(from|has|mentions|before|after|in|pinned):(\S+)/gi;
export function parseFilters(rawQuery) {
const filters = {};
let textQuery = rawQuery;
let match;
while ((match = FILTER_RE.exec(rawQuery)) !== null) {
const key = match[1].toLowerCase();
const val = match[2];
switch (key) {
case 'from': filters.senderName = val; break;
case 'has':
if (val === 'link') filters.hasLink = true;
else if (val === 'file' || val === 'attachment') filters.hasFile = true;
else if (val === 'image') filters.hasImage = true;
else if (val === 'video') filters.hasVideo = true;
else if (val === 'mention') filters.hasMention = true;
break;
case 'mentions': filters.hasMention = true; filters.mentionName = val; break;
case 'before': filters.before = val; break;
case 'after': filters.after = val; break;
case 'in': filters.channelName = val; break;
case 'pinned': filters.pinned = val === 'true' || val === 'yes'; break;
}
textQuery = textQuery.replace(match[0], '');
}
FILTER_RE.lastIndex = 0;
return { textQuery: textQuery.trim(), filters };
}
/**
* Detects if the user is mid-typing a filter token.
* e.g. "hello from:par" → { prefix: 'from', partial: 'par' }
* e.g. "from:" → { prefix: 'from', partial: '' }
* Returns null if no active prefix is detected at the end of text.
*/
export function detectActivePrefix(text) {
if (!text) return null;
// Match a filter prefix at the end of the string, possibly with a partial value
const m = text.match(/\b(from|in|has|mentions):(\S*)$/i);
if (m) {
return { prefix: m[1].toLowerCase(), partial: m[2].toLowerCase() };
}
return null;
}