1import { Client, CredentialManager } from "@atcute/client";
2import { Nsid } from "@atcute/lexicons";
3import { A, useLocation, useNavigate } from "@solidjs/router";
4import { createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js";
5import { isTouchDevice } from "../layout";
6import { resolveLexiconAuthority } from "../utils/api";
7import { appHandleLink, appList, appName, AppUrl } from "../utils/app-urls";
8import { createDebouncedValue } from "../utils/hooks/debounced";
9import { Modal } from "./modal";
10
11export const [showSearch, setShowSearch] = createSignal(false);
12
13const SEARCH_PREFIXES: { prefix: string; description: string }[] = [
14 { prefix: "@", description: "example.com" },
15 { prefix: "did:", description: "web:example.com" },
16 { prefix: "at:", description: "//example.com/com.example.test/self" },
17 { prefix: "lex:", description: "com.example.test" },
18 { prefix: "pds:", description: "host.example.com" },
19];
20
21const parsePrefix = (input: string): { prefix: string | null; query: string } => {
22 const matchedPrefix = SEARCH_PREFIXES.find((p) => input.startsWith(p.prefix));
23 if (matchedPrefix) {
24 return {
25 prefix: matchedPrefix.prefix,
26 query: input.slice(matchedPrefix.prefix.length),
27 };
28 }
29 return { prefix: null, query: input };
30};
31
32const SearchButton = () => {
33 onMount(() => window.addEventListener("keydown", keyEvent));
34 onCleanup(() => window.removeEventListener("keydown", keyEvent));
35
36 const keyEvent = (ev: KeyboardEvent) => {
37 if (document.querySelector("[data-modal]")) return;
38
39 if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") {
40 ev.preventDefault();
41 setShowSearch(!showSearch());
42 } else if (ev.key == "Escape") {
43 ev.preventDefault();
44 setShowSearch(false);
45 }
46 };
47
48 return (
49 <button
50 onclick={() => setShowSearch(!showSearch())}
51 class={`flex items-center gap-1 rounded-md ${isTouchDevice ? "p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" : "dark:bg-dark-100/70 text-baseline mr-1 box-border h-7 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"}`}
52 >
53 <span class={`iconify lucide--search ${isTouchDevice ? "text-lg" : ""}`}></span>
54 <Show when={!isTouchDevice}>
55 <kbd class="font-sans leading-none text-neutral-500 select-none dark:text-neutral-400">
56 {/Mac/i.test(navigator.platform) ? "⌘" : "⌃"}K
57 </kbd>
58 </Show>
59 </button>
60 );
61};
62
63const Search = () => {
64 const navigate = useNavigate();
65 let searchInput!: HTMLInputElement;
66 const rpc = new Client({
67 handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
68 });
69
70 onMount(() => {
71 if (useLocation().pathname !== "/") searchInput.focus();
72
73 const handlePaste = (e: ClipboardEvent) => {
74 if (e.target === searchInput) return;
75 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
76
77 const pastedText = e.clipboardData?.getData("text");
78 if (pastedText) processInput(pastedText);
79 };
80
81 window.addEventListener("paste", handlePaste);
82 onCleanup(() => window.removeEventListener("paste", handlePaste));
83 });
84
85 const fetchTypeahead = async (input: string) => {
86 const { prefix, query } = parsePrefix(input);
87
88 if (prefix === "@") {
89 if (!query.length) return [];
90
91 const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", {
92 params: { q: query, limit: 5 },
93 });
94 if (res.ok) {
95 return res.data.actors;
96 }
97 }
98
99 return [];
100 };
101
102 const [input, setInput] = createSignal<string>();
103 const [selectedIndex, setSelectedIndex] = createSignal(-1);
104 const [isFocused, setIsFocused] = createSignal(false);
105 const [search] = createResource(createDebouncedValue(input, 200), fetchTypeahead);
106
107 const getPrefixSuggestions = () => {
108 const currentInput = input();
109 if (!currentInput) return SEARCH_PREFIXES;
110
111 const { prefix } = parsePrefix(currentInput);
112 if (prefix) return [];
113
114 return SEARCH_PREFIXES.filter((p) => p.prefix.startsWith(currentInput.toLowerCase()));
115 };
116
117 const processInput = async (input: string) => {
118 input = input.trim().replace(/^@/, "");
119 if (!input.length) return;
120
121 setShowSearch(false);
122 setInput(undefined);
123 setSelectedIndex(-1);
124
125 const { prefix, query } = parsePrefix(input);
126
127 if (prefix === "@") {
128 navigate(`/at://${query}`);
129 } else if (prefix === "did:") {
130 navigate(`/at://did:${query}`);
131 } else if (prefix === "at:") {
132 navigate(`/${input}`);
133 } else if (prefix === "lex:") {
134 const nsid = query as Nsid;
135 const res = await resolveLexiconAuthority(nsid);
136 navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`);
137 } else if (prefix === "pds:") {
138 navigate(`/${query}`);
139 } else if (input.startsWith("https://") || input.startsWith("http://")) {
140 const hostLength = input.indexOf("/", 8);
141 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", "");
142
143 if (!(host in appList)) {
144 navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`);
145 } else {
146 const app = appList[host as AppUrl];
147 const path = input.slice(hostLength + 1).split("/");
148
149 const uri = appHandleLink[app](path);
150 navigate(`/${uri}`);
151 }
152 } else {
153 navigate(`/at://${input.replace("at://", "")}`);
154 }
155 };
156
157 return (
158 <form
159 class="relative w-full"
160 onsubmit={(e) => {
161 e.preventDefault();
162 processInput(searchInput.value);
163 }}
164 >
165 <label for="input" class="hidden">
166 PDS URL, AT URI, NSID, DID, or handle
167 </label>
168 <div class="dark:bg-dark-100 dark:shadow-dark-700 flex items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400">
169 <label
170 for="input"
171 class="iconify lucide--search text-neutral-500 dark:text-neutral-400"
172 ></label>
173 <input
174 type="text"
175 spellcheck={false}
176 placeholder="Handle, DID, AT URI, NSID, PDS"
177 ref={searchInput}
178 id="input"
179 class="grow py-1 select-none placeholder:text-sm focus:outline-none"
180 value={input() ?? ""}
181 onInput={(e) => {
182 setInput(e.currentTarget.value);
183 setSelectedIndex(-1);
184 }}
185 onFocus={() => setIsFocused(true)}
186 onBlur={() => {
187 setSelectedIndex(-1);
188 setIsFocused(false);
189 }}
190 onKeyDown={(e) => {
191 const results = search();
192 const prefixSuggestions = getPrefixSuggestions();
193 const totalSuggestions = (prefixSuggestions.length || 0) + (results?.length || 0);
194
195 if (!totalSuggestions) return;
196
197 if (e.key === "ArrowDown") {
198 e.preventDefault();
199 setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % totalSuggestions));
200 } else if (e.key === "ArrowUp") {
201 e.preventDefault();
202 setSelectedIndex((prev) =>
203 prev === -1 ?
204 totalSuggestions - 1
205 : (prev - 1 + totalSuggestions) % totalSuggestions,
206 );
207 } else if (e.key === "Enter") {
208 const index = selectedIndex();
209 if (index >= 0) {
210 e.preventDefault();
211 if (index < prefixSuggestions.length) {
212 const selectedPrefix = prefixSuggestions[index];
213 setInput(selectedPrefix.prefix);
214 setSelectedIndex(-1);
215 searchInput.focus();
216 } else {
217 const adjustedIndex = index - prefixSuggestions.length;
218 if (results && results[adjustedIndex]) {
219 setShowSearch(false);
220 setInput(undefined);
221 navigate(`/at://${results[adjustedIndex].did}`);
222 setSelectedIndex(-1);
223 }
224 }
225 } else if (results?.length && prefixSuggestions.length === 0) {
226 e.preventDefault();
227 setShowSearch(false);
228 setInput(undefined);
229 navigate(`/at://${results[0].did}`);
230 setSelectedIndex(-1);
231 }
232 }
233 }}
234 />
235 <Show when={input()} fallback={ListUrlsTooltip()}>
236 <button
237 type="button"
238 class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-600 dark:active:bg-neutral-500"
239 onClick={() => setInput(undefined)}
240 >
241 <span class="iconify lucide--x"></span>
242 </button>
243 </Show>
244 </div>
245 <Show when={isFocused() && (getPrefixSuggestions().length > 0 || search()?.length)}>
246 <div
247 class="dark:bg-dark-300 dark:shadow-dark-700 absolute z-30 mt-1 flex w-full flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"
248 onMouseDown={(e) => e.preventDefault()}
249 >
250 {/* Prefix suggestions */}
251 <For each={getPrefixSuggestions()}>
252 {(prefixItem, index) => (
253 <button
254 type="button"
255 class={`flex items-center rounded-lg p-2 ${
256 index() === selectedIndex() ?
257 "bg-neutral-200 dark:bg-neutral-700"
258 : "hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
259 }`}
260 onClick={() => {
261 setInput(prefixItem.prefix);
262 setSelectedIndex(-1);
263 searchInput.focus();
264 }}
265 >
266 <span class={`text-sm font-semibold`}>{prefixItem.prefix}</span>
267 <span class="text-sm text-neutral-600 dark:text-neutral-400">
268 {prefixItem.description}
269 </span>
270 </button>
271 )}
272 </For>
273
274 {/* Typeahead results */}
275 <For each={search()}>
276 {(actor, index) => {
277 const adjustedIndex = getPrefixSuggestions().length + index();
278 return (
279 <A
280 class={`flex items-center gap-2 rounded-lg p-2 ${
281 adjustedIndex === selectedIndex() ?
282 "bg-neutral-200 dark:bg-neutral-700"
283 : "hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
284 }`}
285 href={`/at://${actor.did}`}
286 onClick={() => setShowSearch(false)}
287 >
288 <img
289 src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")}
290 class="size-8 rounded-full"
291 />
292 <span>{actor.handle}</span>
293 </A>
294 );
295 }}
296 </For>
297 </div>
298 </Show>
299 </form>
300 );
301};
302
303const ListUrlsTooltip = () => {
304 const [openList, setOpenList] = createSignal(false);
305
306 let urls: Record<string, AppUrl[]> = {};
307 for (const [appUrl, appView] of Object.entries(appList)) {
308 if (!urls[appView]) urls[appView] = [appUrl as AppUrl];
309 else urls[appView].push(appUrl as AppUrl);
310 }
311
312 return (
313 <>
314 <Modal open={openList()} onClose={() => setOpenList(false)}>
315 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-16 left-[50%] w-88 -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 sm:w-104 dark:border-neutral-700 starting:opacity-0">
316 <div class="mb-2 flex items-center gap-1 font-semibold">
317 <span class="iconify lucide--link"></span>
318 <span>Supported URLs</span>
319 </div>
320 <div class="mb-2 text-sm text-neutral-600 dark:text-neutral-400">
321 Links that will be parsed automatically, as long as all the data necessary is on the
322 URL.
323 </div>
324 <div class="flex flex-col gap-2 text-sm">
325 <For each={Object.entries(appName)}>
326 {([appView, name]) => {
327 return (
328 <div>
329 <p class="font-semibold">{name}</p>
330 <div class="grid grid-cols-2 gap-x-4 text-neutral-600 dark:text-neutral-400">
331 <For each={urls[appView]}>
332 {(url) => (
333 <a
334 href={`${url.startsWith("localhost:") ? "http://" : "https://"}${url}`}
335 target="_blank"
336 class="hover:underline active:underline"
337 >
338 {url}
339 </a>
340 )}
341 </For>
342 </div>
343 </div>
344 );
345 }}
346 </For>
347 </div>
348 </div>
349 </Modal>
350 <button
351 type="button"
352 class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-600 dark:active:bg-neutral-500"
353 onClick={() => setOpenList(true)}
354 >
355 <span class="iconify lucide--help-circle"></span>
356 </button>
357 </>
358 );
359};
360
361export { Search, SearchButton };