427 lines
17 KiB
JavaScript
427 lines
17 KiB
JavaScript
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, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function linkifyHtml(html) {
|
|
if (!html) return '';
|
|
return html.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" class="search-result-link">$1</a>');
|
|
}
|
|
|
|
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 { 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 (
|
|
<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: linkifyHtml(r.snippet || escapeHtml(r.content)) }}
|
|
onClick={(e) => {
|
|
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 <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 && r.content && (() => {
|
|
const urls = extractUrls(r.content);
|
|
return urls.map((url, i) => <LinkPreview key={i} url={url} />);
|
|
})()}
|
|
{r.pinned && <span className="search-result-badge">Pinned</span>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SearchPanel;
|