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