import { useState, useEffect, useRef, useCallback } from 'react'; import { api, isAbortError } from '../api'; import type { Contact, Channel } from '../types'; import { formatTime } from '../utils/messageParser'; import { Input } from './ui/input'; import { Button } from './ui/button'; import { cn } from '@/lib/utils'; const SEARCH_PAGE_SIZE = 50; const DEBOUNCE_MS = 300; interface SearchResult { id: number; type: 'PRIV' | 'CHAN'; conversation_key: string; text: string; received_at: number; outgoing: boolean; sender_name: string | null; } export interface SearchNavigateTarget { id: number; type: 'PRIV' | 'CHAN'; conversation_key: string; conversation_name: string; } interface SearchViewProps { contacts: Contact[]; channels: Channel[]; onNavigateToMessage: (target: SearchNavigateTarget) => void; } function highlightMatch(text: string, query: string): React.ReactNode[] { if (!query) return [text]; const parts: React.ReactNode[] = []; const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); const segments = text.split(regex); for (let i = 0; i < segments.length; i++) { if (regex.test(segments[i])) { parts.push( {segments[i]} ); } else { parts.push(segments[i]); } // Reset lastIndex since we're using test() in a loop regex.lastIndex = 0; } return parts; } export function SearchView({ contacts, channels, onNavigateToMessage }: SearchViewProps) { const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(false); const [offset, setOffset] = useState(0); const abortRef = useRef(null); const inputRef = useRef(null); // Debounce query useEffect(() => { const timer = setTimeout(() => { setDebouncedQuery(query.trim()); }, DEBOUNCE_MS); return () => clearTimeout(timer); }, [query]); // Reset results when query changes useEffect(() => { setResults([]); setOffset(0); setHasMore(false); }, [debouncedQuery]); // Fetch search results useEffect(() => { if (!debouncedQuery) { setResults([]); setHasMore(false); return; } abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setLoading(true); api .getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset: 0 }, controller.signal) .then((data) => { setResults(data as SearchResult[]); setHasMore(data.length >= SEARCH_PAGE_SIZE); setOffset(data.length); }) .catch((err) => { if (!isAbortError(err)) { console.error('Search failed:', err); } }) .finally(() => { setLoading(false); }); return () => controller.abort(); }, [debouncedQuery]); const loadMore = useCallback(() => { if (!debouncedQuery || loading) return; abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setLoading(true); api .getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal) .then((data) => { setResults((prev) => [...prev, ...(data as SearchResult[])]); setHasMore(data.length >= SEARCH_PAGE_SIZE); setOffset((prev) => prev + data.length); }) .catch((err) => { if (!isAbortError(err)) { console.error('Search load more failed:', err); } }) .finally(() => { setLoading(false); }); }, [debouncedQuery, loading, offset]); // Resolve conversation name from contacts/channels const getConversationName = useCallback( (result: SearchResult): string => { if (result.type === 'CHAN') { const channel = channels.find( (c) => c.key.toUpperCase() === result.conversation_key.toUpperCase() ); return channel?.name || result.conversation_key.slice(0, 8); } const contact = contacts.find( (c) => c.public_key.toLowerCase() === result.conversation_key.toLowerCase() ); return contact?.name || result.conversation_key.slice(0, 12); }, [contacts, channels] ); const handleResultClick = useCallback( (result: SearchResult) => { onNavigateToMessage({ id: result.id, type: result.type, conversation_key: result.conversation_key, conversation_name: getConversationName(result), }); }, [onNavigateToMessage, getConversationName] ); // Focus input on mount useEffect(() => { inputRef.current?.focus(); }, []); return (
{/* Header */}

Message Search

{/* Search input */}
setQuery(e.target.value)} className="h-9 text-sm" aria-label="Search messages" />
{/* Results */}
{!debouncedQuery && (
Type to search across all messages
)} {debouncedQuery && results.length === 0 && !loading && (
No messages found for “{debouncedQuery}”
)} {results.map((result) => { const convName = getConversationName(result); const typeBadge = result.type === 'CHAN' ? 'Channel' : 'DM'; return (
handleResultClick(result)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleResultClick(result); } }} >
{typeBadge} {convName} {formatTime(result.received_at)}
{result.sender_name && !result.outgoing && ( {result.sender_name}: )} {result.outgoing && You: } {highlightMatch( result.sender_name && result.text.startsWith(`${result.sender_name}: `) ? result.text.slice(result.sender_name.length + 2) : result.text, debouncedQuery )}
); })} {loading && (
Searching...
)} {hasMore && !loading && (
)}
); }