import { useT } from "@agent-native/core/client/i18n"; import type { EmailMessage } from "@shared/types"; import { IconLoader2, IconX } from "@tabler/icons-react"; import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { useState, useRef, useEffect, useCallback, useMemo, type KeyboardEvent, } from "react"; import { useNavigate } from "react-router"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useContacts, type Contact, type InfiniteEmails, } from "@/hooks/use-emails"; import { ensureThread } from "@/lib/thread-cache"; import { groupIntoThreads, type ThreadSummary } from "@/lib/threads"; import { cn } from "@/lib/utils"; const LOCAL_MATCH_LIMIT = 8; const MIN_REMOTE_QUERY_LENGTH = 3; interface SearchBarProps { onClose: () => void; initialQuery?: string; autoFocus?: boolean; hasActiveSearch?: boolean; } export function SearchBar({ onClose, initialQuery = "", autoFocus = true, hasActiveSearch = false, }: SearchBarProps) { const t = useT(); const navigate = useNavigate(); const [query, setQuery] = useState(initialQuery); const [selectedIndex, setSelectedIndex] = useState(-1); const [isFocused, setIsFocused] = useState(autoFocus); const inputRef = useRef(null); const listRef = useRef(null); const debounceRef = useRef>(undefined); const lastSyncedQueryRef = useRef(initialQuery); const { data: contacts = [] } = useContacts(); const queryClient = useQueryClient(); // Sync from URL when it changes externally (e.g. browser back/forward). // Track the last prop we absorbed so user typing isn't clobbered when the // debounced navigate round-trips back through the URL. useEffect(() => { if (initialQuery !== lastSyncedQueryRef.current) { lastSyncedQueryRef.current = initialQuery; setQuery(initialQuery); } }, [initialQuery]); // Filter contacts matching the query const matchedContacts = useMemo(() => { const q = query.trim().toLowerCase(); if (!q || q.length < 2) return []; return contacts .filter( (c) => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q), ) .slice(0, 6); }, [query, contacts]); // Instant local matches over already-cached email pages (subject/from/snippet // substring), so something shows up before the debounced remote Gmail search // fires and while it's in flight. Cheap and quota-free — no network call. const localMatches = useMemo(() => { const q = query.trim().toLowerCase(); if (!q || q.length < 2) return []; const cached = queryClient.getQueriesData({ queryKey: ["emails"], }); const seenThreadIds = new Set(); const messages: EmailMessage[] = []; for (const [, data] of cached) { for (const page of data?.pages ?? []) { for (const email of page.emails) { const threadKey = email.threadId || email.id; if (seenThreadIds.has(threadKey)) continue; const haystack = `${email.subject} ${email.from.name} ${email.from.email} ${email.snippet}`.toLowerCase(); if (!haystack.includes(q)) continue; seenThreadIds.add(threadKey); messages.push(email); } } } return groupIntoThreads(messages).slice(0, LOCAL_MATCH_LIMIT); }, [query, queryClient]); // True while a live Gmail search for the current query is in flight, so we // can show a "searching Gmail" row under the instant local matches. const remoteSearchPending = useIsFetching({ queryKey: ["emails", "all", query.trim()] }) > 0; const showLocalResults = isFocused && query.trim().length >= 2; const showDropdown = isFocused && (matchedContacts.length > 0 || showLocalResults); // Reset selection when matches change useEffect(() => { setSelectedIndex(-1); }, [matchedContacts.length, localMatches.length]); const executeSearch = useCallback( (q: string) => { const trimmed = q.trim(); if (trimmed && trimmed !== lastSyncedQueryRef.current) { lastSyncedQueryRef.current = trimmed; navigate(`/all?q=${encodeURIComponent(trimmed)}`); } }, [navigate], ); const selectContact = useCallback( (contact: Contact) => { const q = contact.email; setQuery(q); lastSyncedQueryRef.current = q; navigate(`/all?q=${encodeURIComponent(q)}`); inputRef.current?.blur(); }, [navigate], ); const selectThread = useCallback( (thread: ThreadSummary) => { const email = thread.latestMessage; const targetThreadId = email.threadId || email.id; void ensureThread(targetThreadId, email.accountEmail).catch(() => {}); navigate(`/all/${targetThreadId}`); inputRef.current?.blur(); }, [navigate], ); // Debounced auto-search as you type (only for text queries, not contact // selection). Kept at 400ms and gated to 3+ chars — Gmail's per-user search // quota is tight, so this must not fire a live round trip per keystroke. // Instant local matches (above) cover the gap while this waits/runs. useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); const q = query.trim(); if (q.length >= MIN_REMOTE_QUERY_LENGTH) { debounceRef.current = setTimeout(() => { executeSearch(q); }, 400); } return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [query, executeSearch]); // Combined keyboard-navigable list: contacts first, then instant local // thread matches, matching the visual order of the dropdown. const combinedMatchCount = matchedContacts.length + localMatches.length; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); if (showDropdown) { setSelectedIndex((prev) => Math.min(prev + 1, combinedMatchCount - 1)); } } else if (e.key === "ArrowUp") { e.preventDefault(); if (showDropdown) { setSelectedIndex((prev) => Math.max(prev - 1, -1)); } } else if (e.key === "Enter") { e.preventDefault(); if (selectedIndex >= 0 && selectedIndex < matchedContacts.length) { selectContact(matchedContacts[selectedIndex]); } else if (selectedIndex >= matchedContacts.length) { const thread = localMatches[selectedIndex - matchedContacts.length]; if (thread) selectThread(thread); } else if (query.trim().length >= MIN_REMOTE_QUERY_LENGTH) { executeSearch(query); inputRef.current?.blur(); } else if (localMatches[0]) { // Below the remote-search minimum: only ever run the local filter, // never a live Gmail round trip for a 1-2 char query. selectThread(localMatches[0]); } } else if (e.key === "Escape") { e.preventDefault(); setQuery(""); onClose(); } }; // Scroll selected item into view useEffect(() => { if (selectedIndex < 0 || !listRef.current) return; const items = listRef.current.querySelectorAll("[data-contact-item]"); items[selectedIndex]?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); // Highlight matching text const highlight = (text: string, q: string) => { if (!q) return text; const idx = text.toLowerCase().indexOf(q.toLowerCase()); if (idx === -1) return text; return ( <> {text.slice(0, idx)} {text.slice(idx, idx + q.length)} {text.slice(idx + q.length)} ); }; const handleClear = useCallback(() => { setQuery(""); lastSyncedQueryRef.current = ""; onClose(); }, [onClose]); return (
setQuery(e.target.value)} onKeyDown={handleKeyDown} onFocus={() => setIsFocused(true)} onBlur={(e) => { // Don't close if clicking on a dropdown item if ( e.relatedTarget && (e.relatedTarget as HTMLElement).closest("[data-search-dropdown]") ) { return; } setIsFocused(false); // Keep the bar mounted while a search is active — the user needs // to see what they searched. Only collapse when empty. if (hasActiveSearch || query.trim()) return; setTimeout(onClose, 100); }} placeholder={t("mail.search.placeholder")} className={cn( "h-8 sm:h-7 flex-1 min-w-0 bg-transparent border-none px-2.5 text-[13px] text-foreground placeholder:text-muted-foreground/60 outline-none", hasActiveSearch && "font-medium", )} /> {(hasActiveSearch || query) && ( {t("mail.search.clear")} )}
{/* Contact + instant local-match suggestions dropdown */} {showDropdown && (
{matchedContacts.map((contact, i) => ( ))} {showLocalResults && localMatches.length > 0 && (
0 && "border-t", )} >
{t("mail.search.localResults")}
{localMatches.map((thread, i) => { const combinedIndex = matchedContacts.length + i; const email = thread.latestMessage; return ( ); })}
)} {showLocalResults && remoteSearchPending && (
{t("mail.search.searchingGmail")}
)}
)}
); }