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