import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useSearch } from '../contexts/SearchContext'; import { parseFilters } from '../utils/searchUtils'; import { usePlatform } from '../platform'; import { LinkPreview } from './ChatArea'; import { extractUrls } from './MessageItem'; 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, '>'); } function linkifyHtml(html) { if (!html) return ''; return html.replace(/(https?:\/\/[^\s<]+)/g, '$1'); } 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
Loading image...
; if (error) return null; return {metadata.filename}; }; 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
Loading video...
; if (error) return null; const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); }; return (
); }; 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 (
📄
{metadata.filename}
{sizeStr &&
{sizeStr}
} {url && e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download} {loading &&
Decrypting...
}
); }; const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => { const { search, isReady } = useSearch() || {}; const { links } = usePlatform(); 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 (
{results.length} result{results.length !== 1 ? 's' : ''}
{showSortMenu && (
{ onSortChange('newest'); setShowSortMenu(false); }} > Newest
{ onSortChange('oldest'); setShowSortMenu(false); }} > Oldest
)}
{filterChips.length > 0 && (
{filterChips.map(chip => ( {chip.label} ))}
)}
{!isReady && (
Search database is loading...
)} {isReady && searching &&
Searching...
} {isReady && !searching && results.length === 0 && (
No results found
)} {Object.entries(grouped).map(([chName, msgs]) => (
{isDM ? chName : `#${chName}`}
{msgs.map(r => (
handleResultClick(r)} >
{r.username?.[0]?.toUpperCase()}
{r.username} {formatTime(r.created_at)}
{!(r.has_attachment && r.attachment_meta) && (
{ if (e.target.tagName === 'A' && e.target.href) { e.preventDefault(); e.stopPropagation(); links.openExternal(e.target.href); } }} /> )} {r.has_attachment && r.attachment_meta ? (() => { try { const meta = JSON.parse(r.attachment_meta); if (r.attachment_type?.startsWith('image/')) return ; if (r.attachment_type?.startsWith('video/')) return ; return ; } catch { return File; } })() : r.has_attachment ? File : null} {r.has_link && r.content && (() => { const urls = extractUrls(r.content); return urls.map((url, i) => ); })()} {r.pinned && Pinned}
))}
))}
); }; export default SearchPanel;