904d003d32
Topbar search box → debounced crm.search → dropdown of hits (chat/mail, highlighted snippet). Clicking a hit switches to the right tab and focuses the exact thread: mail → Inbox, chat → Messenger (via the SDK's new focusThreadId, 0.1.6). Snippet HTML is escaped except the <em> highlight. Demo mode searches an in-memory set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
3.2 KiB
TypeScript
87 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
// Global conversation search in the topbar: type → debounced crm.search → dropdown of hits; click a
|
|
// hit to deep-link to exactly where it lives (mail → Inbox, chat → Messenger, on that thread).
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Icon } from "./ui";
|
|
import { useGlobalSearch, type SearchResult } from "@/lib/search-api";
|
|
|
|
/** Escape HTML but keep the engine's <em> highlight tags — so a match snippet can't inject markup. */
|
|
function safeSnippet(s: string): string {
|
|
const esc = s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
return esc.replace(/<em>/g, "<em>").replace(/<\/em>/g, "</em>");
|
|
}
|
|
|
|
export function GlobalSearch({ onNavigate }: { onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
|
|
const search = useGlobalSearch();
|
|
const [open, setOpen] = useState(false);
|
|
const [q, setQ] = useState("");
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const term = q.trim();
|
|
if (!term) {
|
|
setResults([]);
|
|
return;
|
|
}
|
|
let alive = true;
|
|
setLoading(true);
|
|
const t = setTimeout(() => {
|
|
search(term)
|
|
.then((r) => { if (alive) setResults(r); })
|
|
.catch(() => { if (alive) setResults([]); })
|
|
.finally(() => { if (alive) setLoading(false); });
|
|
}, 220);
|
|
return () => { alive = false; clearTimeout(t); };
|
|
}, [q, search]);
|
|
|
|
useEffect(() => {
|
|
function onDown(e: MouseEvent) {
|
|
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
|
}
|
|
document.addEventListener("mousedown", onDown);
|
|
return () => document.removeEventListener("mousedown", onDown);
|
|
}, []);
|
|
|
|
function pick(r: SearchResult) {
|
|
onNavigate(r.surface, r.threadId);
|
|
setOpen(false);
|
|
setQ("");
|
|
}
|
|
|
|
return (
|
|
<div className="gs-wrap" ref={wrapRef}>
|
|
<div className="gs-field">
|
|
<Icon name="search" size={16} />
|
|
<input
|
|
className="gs-input"
|
|
placeholder="Search conversations…"
|
|
value={q}
|
|
onFocus={() => setOpen(true)}
|
|
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
|
aria-label="Search conversations"
|
|
/>
|
|
</div>
|
|
{open && q.trim() ? (
|
|
<div className="gs-pop">
|
|
{loading && results.length === 0 ? <div className="gs-empty">Searching…</div> : null}
|
|
{!loading && results.length === 0 ? <div className="gs-empty">No matches.</div> : null}
|
|
{results.map((r) => (
|
|
<button key={r.interactionId} type="button" className="gs-row" onClick={() => pick(r)}>
|
|
<span className="gs-ic"><Icon name={r.surface === "inbox" ? "mail" : "send"} size={14} /></span>
|
|
<span className="gs-main">
|
|
<span className="gs-title">{r.title}</span>
|
|
<span className="gs-snippet" dangerouslySetInnerHTML={{ __html: safeSnippet(r.snippet) }} />
|
|
</span>
|
|
<span className="gs-surface">{r.surface === "inbox" ? "Mail" : "Chat"}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|