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:
@@ -6,6 +6,10 @@ import crypto from './crypto.js';
|
||||
import session from './session.js';
|
||||
import settings from './settings.js';
|
||||
import idle from './idle.js';
|
||||
import searchStorage from './searchStorage.js';
|
||||
import SearchDatabase from '@discord-clone/shared/src/utils/SearchDatabase';
|
||||
|
||||
const searchDB = new SearchDatabase(searchStorage, crypto);
|
||||
|
||||
const webPlatform = {
|
||||
crypto,
|
||||
@@ -31,10 +35,12 @@ const webPlatform = {
|
||||
},
|
||||
windowControls: null,
|
||||
updates: null,
|
||||
searchDB,
|
||||
features: {
|
||||
hasWindowControls: false,
|
||||
hasScreenCapture: true,
|
||||
hasNativeUpdates: false,
|
||||
hasSearch: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
54
packages/platform-web/src/searchStorage.js
Normal file
54
packages/platform-web/src/searchStorage.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const DB_NAME = 'discord-clone-search';
|
||||
const STORE_NAME = 'databases';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
function openIDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
const searchStorage = {
|
||||
async load(userId) {
|
||||
const db = await openIDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.get(`search-db-${userId}`);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async save(userId, bytes) {
|
||||
const db = await openIDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.put(bytes, `search-db-${userId}`);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async clear(userId) {
|
||||
const db = await openIDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.delete(`search-db-${userId}`);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default searchStorage;
|
||||
@@ -19,6 +19,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql.js": "^1.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
244
packages/shared/src/components/SearchDropdown.jsx
Normal file
244
packages/shared/src/components/SearchDropdown.jsx
Normal 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); }}
|
||||
>
|
||||
×
|
||||
</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;
|
||||
408
packages/shared/src/components/SearchPanel.jsx
Normal file
408
packages/shared/src/components/SearchPanel.jsx
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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}>×</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;
|
||||
@@ -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 });
|
||||
}
|
||||
}}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
75
packages/shared/src/contexts/SearchContext.jsx
Normal file
75
packages/shared/src/contexts/SearchContext.jsx
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
389
packages/shared/src/utils/SearchDatabase.js
Normal file
389
packages/shared/src/utils/SearchDatabase.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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;
|
||||
}
|
||||
47
packages/shared/src/utils/searchUtils.js
Normal file
47
packages/shared/src/utils/searchUtils.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user