mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
273 lines
8.3 KiB
TypeScript
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 “{debouncedQuery}”
|
|
</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>
|
|
);
|
|
}
|