···
export const [showSearch, setShowSearch] = createSignal(false);
13
+
const 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" },
21
+
const parsePrefix = (input: string): { prefix: string | null; query: string } => {
22
+
const matchedPrefix = SEARCH_PREFIXES.find((p) => input.startsWith(p.prefix));
23
+
if (matchedPrefix) {
25
+
prefix: matchedPrefix.prefix,
26
+
query: input.slice(matchedPrefix.prefix.length),
29
+
return { prefix: null, query: input };
const SearchButton = () => {
onMount(() => window.addEventListener("keydown", keyEvent));
onCleanup(() => window.removeEventListener("keydown", keyEvent));
···
52
-
if (!isTouchDevice || useLocation().pathname !== "/") searchInput.focus();
71
+
if (useLocation().pathname !== "/") searchInput.focus();
73
+
const handlePaste = (e: ClipboardEvent) => {
74
+
if (e.target === searchInput) return;
75
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
77
+
const pastedText = e.clipboardData?.getData("text");
78
+
if (pastedText) processInput(pastedText);
81
+
window.addEventListener("paste", handlePaste);
82
+
onCleanup(() => window.removeEventListener("paste", handlePaste));
const fetchTypeahead = async (input: string) => {
56
-
if (!input.length) return [];
57
-
const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", {
58
-
params: { q: input, limit: 5 },
61
-
return res.data.actors;
86
+
const { prefix, query } = parsePrefix(input);
88
+
if (prefix === "@") {
89
+
if (!query.length) return [];
91
+
const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", {
92
+
params: { q: query, limit: 5 },
95
+
return res.data.actors;
const [input, setInput] = createSignal<string>();
const [selectedIndex, setSelectedIndex] = createSignal(-1);
68
-
const [search] = createResource(createDebouncedValue(input, 250), fetchTypeahead);
104
+
const [isFocused, setIsFocused] = createSignal(false);
105
+
const [search] = createResource(createDebouncedValue(input, 200), fetchTypeahead);
107
+
const getPrefixSuggestions = () => {
108
+
const currentInput = input();
109
+
if (!currentInput) return SEARCH_PREFIXES;
111
+
const { prefix } = parsePrefix(currentInput);
112
+
if (prefix) return [];
114
+
return SEARCH_PREFIXES.filter((p) => p.prefix.startsWith(currentInput.toLowerCase()));
const processInput = async (input: string) => {
input = input.trim().replace(/^@/, "");
if (!input.length) return;
73
-
const index = selectedIndex() >= 0 ? selectedIndex() : 0;
76
-
if (search()?.length && selectedIndex() !== -1) {
77
-
navigate(`/at://${search()![index].did}`);
123
+
setSelectedIndex(-1);
125
+
const { prefix, query } = parsePrefix(input);
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}`);
} else if (input.startsWith("https://") || input.startsWith("http://")) {
const hostLength = input.indexOf("/", 8);
const host = input.slice(0, hostLength).replace("https://", "").replace("http://", "");
···
const uri = appHandleLink[app](path);
91
-
} else if (input.startsWith("lex:")) {
92
-
const nsid = input.replace("lex:", "") as Nsid;
93
-
const res = await resolveLexiconAuthority(nsid);
94
-
navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`);
navigate(`/at://${input.replace("at://", "")}`);
98
-
setSelectedIndex(-1);
···
120
-
placeholder="PDS, AT URI, NSID, DID, or handle"
176
+
placeholder="Handle, DID, AT URI, NSID, PDS"
class="grow py-1 select-none placeholder:text-sm focus:outline-none"
···
setInput(e.currentTarget.value);
185
+
onFocus={() => setIsFocused(true)}
187
+
setSelectedIndex(-1);
188
+
setIsFocused(false);
const results = search();
131
-
if (!results?.length) return;
192
+
const prefixSuggestions = getPrefixSuggestions();
193
+
const totalSuggestions = (prefixSuggestions.length || 0) + (results?.length || 0);
195
+
if (!totalSuggestions) return;
if (e.key === "ArrowDown") {
135
-
setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % results.length));
199
+
setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % totalSuggestions));
} else if (e.key === "ArrowUp") {
setSelectedIndex((prev) =>
139
-
prev === -1 ? results.length - 1 : (prev - 1 + results.length) % results.length,
204
+
totalSuggestions - 1
205
+
: (prev - 1 + totalSuggestions) % totalSuggestions,
207
+
} else if (e.key === "Enter") {
208
+
const index = selectedIndex();
210
+
e.preventDefault();
211
+
if (index < prefixSuggestions.length) {
212
+
const selectedPrefix = prefixSuggestions[index];
213
+
setInput(selectedPrefix.prefix);
214
+
setSelectedIndex(-1);
215
+
searchInput.focus();
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);
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);
···
154
-
<Show when={search()?.length && input()}>
155
-
<div 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">
156
-
<For each={search()}>
157
-
{(actor, index) => (
159
-
class={`flex items-center gap-2 rounded-lg p-1 transition-colors duration-150 ${
245
+
<Show when={isFocused() && (getPrefixSuggestions().length > 0 || search()?.length)}>
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()}
250
+
{/* Prefix suggestions */}
251
+
<For each={getPrefixSuggestions()}>
252
+
{(prefixItem, index) => (
255
+
class={`flex items-center rounded-lg p-2 transition-colors duration-150 ${
index() === selectedIndex() ?
"bg-neutral-200 dark:bg-neutral-700"
: "hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
164
-
href={`/at://${actor.did}`}
165
-
onClick={() => setShowSearch(false)}
261
+
setInput(prefixItem.prefix);
262
+
setSelectedIndex(-1);
263
+
searchInput.focus();
168
-
src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")}
169
-
class="size-8 rounded-full"
171
-
<span>{actor.handle}</span>
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}
274
+
{/* Typeahead results */}
275
+
<For each={search()}>
276
+
{(actor, index) => {
277
+
const adjustedIndex = getPrefixSuggestions().length + index();
280
+
class={`flex items-center gap-2 rounded-lg p-2 transition-colors duration-150 ${
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"
285
+
href={`/at://${actor.did}`}
286
+
onClick={() => setShowSearch(false)}
289
+
src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")}
290
+
class="size-8 rounded-full"
292
+
<span>{actor.handle}</span>