Files
Remote-Terminal-for-MeshCore/frontend/src/components/SearchView.tsx
Jack Kingsman 01a5dc8d93 A11y bug bash
2026-03-05 10:24:22 -08:00

273 lines
8.3 KiB
TypeScript

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(
<mark key={i} className="bg-primary/30 text-foreground rounded-sm px-0.5">
{segments[i]}
</mark>
);
} 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<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex flex-col h-full">
{/* Header */}
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Message Search
</h2>
{/* Search input */}
<div className="px-4 py-3 border-b border-border">
<Input
ref={inputRef}
type="text"
placeholder="Search all messages..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-9 text-sm"
aria-label="Search messages"
/>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto">
{!debouncedQuery && (
<div className="p-8 text-center text-muted-foreground text-sm">
Type to search across all messages
</div>
)}
{debouncedQuery && results.length === 0 && !loading && (
<div className="p-8 text-center text-muted-foreground text-sm">
No messages found for &ldquo;{debouncedQuery}&rdquo;
</div>
)}
{results.map((result) => {
const convName = getConversationName(result);
const typeBadge = result.type === 'CHAN' ? 'Channel' : 'DM';
return (
<div
key={result.id}
className="px-4 py-3 border-b border-border/50 cursor-pointer hover:bg-accent/50 transition-colors"
role="button"
tabIndex={0}
onClick={() => handleResultClick(result)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleResultClick(result);
}
}}
>
<div className="flex items-center gap-2 mb-1">
<span
className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded',
result.type === 'CHAN'
? 'bg-primary/20 text-primary'
: 'bg-secondary text-secondary-foreground'
)}
>
{typeBadge}
</span>
<span className="text-[12px] font-medium text-foreground truncate">{convName}</span>
<span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0">
{formatTime(result.received_at)}
</span>
</div>
<div className="text-[13px] text-foreground/80 line-clamp-2 break-words">
{result.sender_name && !result.outgoing && (
<span className="text-muted-foreground">{result.sender_name}: </span>
)}
{result.outgoing && <span className="text-muted-foreground">You: </span>}
{highlightMatch(
result.sender_name && result.text.startsWith(`${result.sender_name}: `)
? result.text.slice(result.sender_name.length + 2)
: result.text,
debouncedQuery
)}
</div>
</div>
);
})}
{loading && (
<div className="p-4 text-center text-muted-foreground text-sm" role="status">
Searching...
</div>
)}
{hasMore && !loading && (
<div className="p-4 text-center">
<Button variant="outline" size="sm" onClick={loadMore}>
Load more results
</Button>
</div>
)}
</div>
</div>
);
}