import { Client, simpleFetchHandler } from "@atcute/client"; import { Nsid } from "@atcute/lexicons"; import { A, useNavigate } from "@solidjs/router"; import { createEffect, createResource, createSignal, For, onCleanup, onMount, Show, } from "solid-js"; import { isTouchDevice } from "../layout"; import { resolveLexiconAuthority } from "../utils/api"; import { appHandleLink, appList, appName, AppUrl } from "../utils/app-urls"; import { createDebouncedValue } from "../utils/hooks/debounced"; import { Modal } from "./modal"; export const [showSearch, setShowSearch] = createSignal(false); const SEARCH_PREFIXES: { prefix: string; description: string }[] = [ { prefix: "@", description: "example.com" }, { prefix: "did:", description: "web:example.com" }, { prefix: "at:", description: "//example.com/com.example.test/self" }, { prefix: "lex:", description: "com.example.test" }, { prefix: "pds:", description: "host.example.com" }, ]; const parsePrefix = (input: string): { prefix: string | null; query: string } => { const matchedPrefix = SEARCH_PREFIXES.find((p) => input.startsWith(p.prefix)); if (matchedPrefix) { return { prefix: matchedPrefix.prefix, query: input.slice(matchedPrefix.prefix.length), }; } return { prefix: null, query: input }; }; const SearchButton = () => { onMount(() => window.addEventListener("keydown", keyEvent)); onCleanup(() => window.removeEventListener("keydown", keyEvent)); const keyEvent = (ev: KeyboardEvent) => { if (document.querySelector("[data-modal]")) return; if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") { ev.preventDefault(); if (showSearch()) { const searchInput = document.querySelector("#input") as HTMLInputElement; if (searchInput && document.activeElement !== searchInput) { searchInput.focus(); } else { setShowSearch(false); } } else { setShowSearch(true); } } else if (ev.key == "Escape") { ev.preventDefault(); setShowSearch(false); } }; return ( ); }; const Search = () => { const navigate = useNavigate(); let searchInput!: HTMLInputElement; const rpc = new Client({ handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), }); onMount(() => { const handlePaste = (e: ClipboardEvent) => { if (e.target === searchInput) return; if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; const pastedText = e.clipboardData?.getData("text"); if (pastedText) processInput(pastedText); }; window.addEventListener("paste", handlePaste); onCleanup(() => window.removeEventListener("paste", handlePaste)); }); createEffect(() => { if (showSearch()) searchInput.focus(); }); const fetchTypeahead = async (input: string) => { const { prefix, query } = parsePrefix(input); if (prefix === "@") { if (!query.length) return []; const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", { params: { q: query, limit: 5 }, }); if (res.ok) { return res.data.actors; } } return []; }; const [input, setInput] = createSignal(); const [selectedIndex, setSelectedIndex] = createSignal(-1); const [isFocused, setIsFocused] = createSignal(false); const [search] = createResource(createDebouncedValue(input, 200), fetchTypeahead); const getPrefixSuggestions = () => { const currentInput = input(); if (!currentInput) return SEARCH_PREFIXES; const { prefix, query } = parsePrefix(currentInput); if (prefix && query.length > 0) return []; return SEARCH_PREFIXES.filter((p) => p.prefix.startsWith(currentInput.toLowerCase())); }; const processInput = async (input: string) => { input = input.trim().replace(/^@/, ""); if (!input.length) return; setShowSearch(false); setInput(undefined); setSelectedIndex(-1); const { prefix, query } = parsePrefix(input); if (prefix === "@") { navigate(`/at://${query}`); } else if (prefix === "did:") { navigate(`/at://did:${query}`); } else if (prefix === "at:") { navigate(`/${input}`); } else if (prefix === "lex:") { const nsid = query as Nsid; const res = await resolveLexiconAuthority(nsid); navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`); } else if (prefix === "pds:") { navigate(`/${query}`); } else if (input.startsWith("https://") || input.startsWith("http://")) { const hostLength = input.indexOf("/", 8); const host = input.slice(0, hostLength).replace("https://", "").replace("http://", ""); if (!(host in appList)) { navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`); } else { const app = appList[host as AppUrl]; const path = input.slice(hostLength + 1).split("/"); const uri = appHandleLink[app](path); navigate(`/${uri}`); } } else { navigate(`/at://${input.replace("at://", "")}`); } }; return (
{ e.preventDefault(); processInput(searchInput.value); }} >
{ setInput(e.currentTarget.value); setSelectedIndex(-1); }} onFocus={() => setIsFocused(true)} onBlur={() => { setSelectedIndex(-1); setIsFocused(false); }} onKeyDown={(e) => { const results = search(); const prefixSuggestions = getPrefixSuggestions(); const totalSuggestions = (prefixSuggestions.length || 0) + (results?.length || 0); if (!totalSuggestions) return; if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % totalSuggestions)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((prev) => prev === -1 ? totalSuggestions - 1 : (prev - 1 + totalSuggestions) % totalSuggestions, ); } else if (e.key === "Enter") { const index = selectedIndex(); if (index >= 0) { e.preventDefault(); if (index < prefixSuggestions.length) { const selectedPrefix = prefixSuggestions[index]; setInput(selectedPrefix.prefix); setSelectedIndex(-1); searchInput.focus(); } else { const adjustedIndex = index - prefixSuggestions.length; if (results && results[adjustedIndex]) { setShowSearch(false); setInput(undefined); navigate(`/at://${results[adjustedIndex].did}`); setSelectedIndex(-1); } } } else if (results?.length && prefixSuggestions.length === 0) { e.preventDefault(); setShowSearch(false); setInput(undefined); navigate(`/at://${results[0].did}`); setSelectedIndex(-1); } } }} />
0 || search()?.length)}>
e.preventDefault()} > {/* Prefix suggestions */} {(prefixItem, index) => ( )} {/* Typeahead results */} {(actor, index) => { const adjustedIndex = getPrefixSuggestions().length + index(); return ( setShowSearch(false)} >
{actor.displayName} @{actor.handle}
); }}
); }; const ListUrlsTooltip = () => { const [openList, setOpenList] = createSignal(false); let urls: Record = {}; for (const [appUrl, appView] of Object.entries(appList)) { if (!urls[appView]) urls[appView] = [appUrl as AppUrl]; else urls[appView].push(appUrl as AppUrl); } return ( <> setOpenList(false)}>
Supported URLs
Links that will be parsed automatically, as long as all the data necessary is on the URL.
{([appView, name]) => { return (

{name}

{(url) => ( {url} )}
); }}
); }; export { Search, SearchButton };