A build your own ATProto adventure, OAuth already figured out for you.
demo.atpoke.xyz
1<script lang="ts">
2 type ActorSuggestion = { handle: string; displayName?: string };
3 type TypeaheadResponse = { actors?: ActorSuggestion[] };
4
5 let {
6 value = $bindable(''),
7 name = 'handle',
8 placeholder = 'Handle or Display name',
9 required = true,
10 id = 'handle-input',
11 showDisplayName = false
12 }: {
13 value?: string;
14 name?: string;
15 placeholder?: string;
16 required?: boolean;
17 id?: string;
18 showDisplayName?: boolean;
19 } = $props();
20
21 let suggestions = $state<ActorSuggestion[]>([]);
22 let loading = $state(false);
23 let fetchError = $state<string | null>(null);
24
25 let debounceTimer: ReturnType<typeof setTimeout> | null = null;
26 let currentAbort: AbortController | null = null;
27
28 async function fetchSuggestions(q: string) {
29 if (currentAbort) currentAbort.abort();
30 currentAbort = new AbortController();
31 loading = true;
32 fetchError = null;
33
34 if(suggestions.length > 0) {
35 const isThereAnExactMatch = suggestions.find(s => s.handle === q);
36 if (isThereAnExactMatch) {
37 loading = false;
38 return;
39 }
40 }
41
42 try {
43 const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=5`;
44 const res = await fetch(url, { signal: currentAbort.signal });
45 if (!res.ok) throw new Error(`HTTP ${res.status}`);
46 const data: TypeaheadResponse = await res.json();
47 suggestions = data.actors ?? [];
48 if(suggestions.length === 1){
49 value = suggestions[0].handle;
50 }
51 } catch (e) {
52 if (e instanceof DOMException && e.name === 'AbortError') return;
53 fetchError = e instanceof Error ? e.message : 'Failed to load suggestions';
54 suggestions = [];
55 } finally {
56 loading = false;
57 }
58 }
59
60 function onHandleInput(ev: Event) {
61 const target = ev.target as HTMLInputElement;
62 value = target.value;
63
64 if (debounceTimer) clearTimeout(debounceTimer);
65
66 if (value.trim().length >= 3) {
67 debounceTimer = setTimeout(() => {
68 fetchSuggestions(value.trim());
69 }, 300);
70 } else {
71 suggestions = [];
72 fetchError = null;
73 if (currentAbort) {
74 currentAbort.abort();
75 currentAbort = null;
76 }
77 }
78 }
79
80 const datalistId = `${() => id}-suggestions`;
81</script>
82<style>
83 .handle-input {
84 display: flex;
85 flex-direction: column;
86 /*gap: 0.75rem;*/
87 }
88</style>
89<div class="handle-input">
90 <input
91 type="text"
92 {name}
93 {id}
94 {placeholder}
95 {required}
96 bind:value
97 oninput={onHandleInput}
98 list={datalistId}
99 autocomplete="off"
100 spellcheck="false"
101 />
102 <datalist id={datalistId}>
103 {#each suggestions as s, index (s.handle + index)}
104 <option value={s.handle}>
105 {#if s.displayName && showDisplayName}{`(${s.displayName})`}{/if} {s.handle}
106 </option>
107 {/each}
108 </datalist>
109
110 {#if loading}
111 <p>Searching…</p>
112 {/if}
113 {#if fetchError}
114 <p>{fetchError}</p>
115 {/if}
116</div>