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
;
};
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 (
);
};
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 && (
)}
{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;