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 ( setShowSearch(!showSearch())} class="dark:bg-dark-100/70 text-baseline mr-1 box-border flex h-7 items-center gap-1 rounded-md border-[0.5px] border-neutral-300 bg-neutral-100/70 px-2 text-xs hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" > Search} > {/Mac/i.test(navigator.platform) ? "⌘" : "⌃"}K ); }; 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); }} > PDS URL, AT URI, NSID, DID, or handle { 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); } } }} /> setInput(undefined)} > 0 || search()?.length)}> e.preventDefault()} > {/* Prefix suggestions */} {(prefixItem, index) => ( { setInput(prefixItem.prefix); setSelectedIndex(-1); searchInput.focus(); }} > {prefixItem.prefix} {prefixItem.description} )} {/* 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} )} ); }} setOpenList(true)} > > ); }; export { Search, SearchButton };
{name}