import "./SearchBox.css"; import Spinner from "../Icons/Spinner"; import Icon from "../Icons/Icon"; import { FormattedMessage, useIntl } from "react-intl"; import { fetchNip05Pubkey } from "../Nip05/Verifier"; import { ChangeEvent, useEffect, useRef, useState } from "react"; import { NostrLink, tryParseNostrLink } from "@snort/system"; import { useLocation, useNavigate } from "react-router-dom"; import { unixNow } from "@snort/shared"; import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../Feed/TimelineFeed"; import { fuzzySearch, FuzzySearchResult } from "@/index"; import ProfileImage from "@/Element/User/ProfileImage"; import { socialGraphInstance } from "@snort/system"; const MAX_RESULTS = 3; export default function SearchBox() { const { formatMessage } = useIntl(); const [search, setSearch] = useState(""); const [searching, setSearching] = useState(false); const [isFocused, setIsFocused] = useState(false); const navigate = useNavigate(); const location = useLocation(); const inputRef = useRef(null); const [activeIndex, setActiveIndex] = useState(-1); const resultListRef = useRef(null); const options: TimelineFeedOptions = { method: "LIMIT_UNTIL", window: undefined, now: unixNow(), }; const subject: TimelineSubject = { type: "profile_keyword", discriminator: search, items: [search], relay: undefined, streams: false, }; const { main } = useTimelineFeed(subject, options); const [results, setResults] = useState([]); useEffect(() => { const searchString = search.trim(); const fuseResults = fuzzySearch.search(searchString); const followDistanceNormalizationFactor = 3; const combinedResults = fuseResults.map(result => { const fuseScore = result.score === undefined ? 1 : result.score; const followDistance = socialGraphInstance.getFollowDistance(result.item.pubkey) / followDistanceNormalizationFactor; const startsWithSearchString = [result.item.name, result.item.display_name, result.item.nip05].some( field => field && field.toLowerCase?.().startsWith(searchString.toLowerCase()), ); const boostFactor = startsWithSearchString ? 0.25 : 1; const weightForFuseScore = 0.8; const weightForFollowDistance = 0.2; const combinedScore = (fuseScore * weightForFuseScore + followDistance * weightForFollowDistance) * boostFactor; return { ...result, combinedScore }; }); // Sort by combined score, lower is better combinedResults.sort((a, b) => a.combinedScore - b.combinedScore); setResults(combinedResults.map(r => r.item)); }, [search, main]); useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setSearch(""); } if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); inputRef.current?.focus(); } }; document.addEventListener("keydown", handleGlobalKeyDown); return () => { document.removeEventListener("keydown", handleGlobalKeyDown); }; }, []); useEffect(() => { // Close the search on navigation setSearch(""); setActiveIndex(-1); }, [location]); const executeSearch = async () => { try { setSearching(true); const link = tryParseNostrLink(search); if (link) { navigate(`/${link.encode()}`); return; } if (search.includes("@")) { const [handle, domain] = search.split("@"); const pk = await fetchNip05Pubkey(handle, domain); if (pk) { navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, pk).encode()}`); return; } } navigate(`/search/${encodeURIComponent(search)}`); } finally { setSearching(false); } }; const handleChange = (e: ChangeEvent) => { if (!e.target.value.match(/nsec1[a-zA-Z0-9]{20,65}/gi)) { setSearch(e.target.value); } }; const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case "Enter": if (activeIndex === 0) { navigate(`/search/${encodeURIComponent(search)}`); } else if (activeIndex > 0 && results) { const selectedResult = results[activeIndex - 1]; navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, selectedResult.pubkey).encode()}`); inputRef.current?.blur(); } else { executeSearch(); } break; case "ArrowDown": e.preventDefault(); setActiveIndex(prev => Math.min(prev + 1, Math.min(MAX_RESULTS, results ? results.length : 0))); break; case "ArrowUp": e.preventDefault(); setActiveIndex(prev => Math.max(prev - 1, 0)); break; default: break; } }; return (
setIsFocused(true)} onBlur={() => setTimeout(() => setIsFocused(false), 150)} /> {searching ? ( ) : ( navigate("/search")} /> )} {search && !searching && isFocused && (
setActiveIndex(0)} onClick={() => navigate(`/search/${encodeURIComponent(search)}`, { state: { forceRefresh: true } })}> : {search}
{results?.slice(0, MAX_RESULTS).map((result, idx) => (
setActiveIndex(idx + 1)}>
))}
)}
); }