browse the protocol like its 2008 ibex.desertthunder.dev
ubuntu atproto svelte
7

Configure Feed

Select the types of activity you want to include in your feed.

feat: typeahead

Owais Jamil (Jun 9, 2026, 3:32 PM -0500) 71dfa7fb e0cda5ba

+245 -8
+23 -2
src/lib/atproto/identity.ts
··· 3 3 import type { Handle } from '@atcute/lexicons/syntax'; 4 4 import type {} from '@atcute/atproto'; 5 5 import type {} from '@atcute/bluesky'; 6 - import type { AccountIdentity, DidDocument } from './types'; 6 + import type { AccountIdentity, ActorTypeaheadResult, DidDocument } from './types'; 7 7 8 - export type { AccountIdentity } from './types'; 8 + export type { AccountIdentity, ActorTypeaheadResult } from './types'; 9 9 10 10 export const defaultIdentity = { handle: 'desertthunder.dev', did: 'did:plc:xg2vq45muivyy3xwatcehspu' }; 11 11 ··· 27 27 ); 28 28 29 29 return hydratePublicIdentity(identity.did, normalizedHandle); 30 + } 31 + 32 + export async function searchActorsTypeahead(query: string, limit = 6): Promise<ActorTypeaheadResult[]> { 33 + const normalizedQuery = query.trim().replace(/^@/, ''); 34 + 35 + if (!normalizedQuery) { 36 + return []; 37 + } 38 + 39 + const result = await ok( 40 + publicApi.get('app.bsky.actor.searchActorsTypeahead', { 41 + params: { q: normalizedQuery, limit } 42 + }) 43 + ); 44 + 45 + return result.actors.map((actor) => ({ 46 + handle: actor.handle, 47 + did: actor.did, 48 + displayName: actor.displayName ?? null, 49 + avatar: actor.avatar ?? null 50 + })); 30 51 } 31 52 32 53 export async function hydratePublicIdentity(did: string, fallbackHandle = did): Promise<AccountIdentity> {
+2
src/lib/atproto/types.ts
··· 7 7 description: string | null; 8 8 }; 9 9 10 + export type ActorTypeaheadResult = Pick<AccountIdentity, 'handle' | 'did' | 'displayName' | 'avatar'>; 11 + 10 12 export type DidDocument = { 11 13 service?: Array<{ id?: string; type?: string; serviceEndpoint?: string | string[] | Record<string, unknown> }>; 12 14 };
+220 -6
src/lib/components/SetupDialog.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from '$app/navigation'; 3 - import { resolveAccount } from '$lib/atproto/identity'; 4 - import type { AccountIdentity } from '$lib/atproto/types'; 3 + import { hydratePublicIdentity, resolveAccount, searchActorsTypeahead } from '$lib/atproto/identity'; 4 + import type { AccountIdentity, ActorTypeaheadResult } from '$lib/atproto/types'; 5 5 import { accountSetup, setupDefaults } from '$lib/atproto/setup.svelte'; 6 6 import { errorMessage } from '$lib/utils/errors'; 7 7 ··· 9 9 let resolvedIdentity = $state<AccountIdentity | null>(null); 10 10 let error = $state<string | null>(null); 11 11 let isResolving = $state(false); 12 + let suggestions = $state<ActorTypeaheadResult[]>([]); 13 + let activeSuggestionIndex = $state(-1); 14 + let typeaheadError = $state<string | null>(null); 15 + let isSearching = $state(false); 16 + let hasInputFocus = $state(false); 17 + let typeaheadRequestId = 0; 12 18 13 19 const didMatchesKnownAccount = $derived(resolvedIdentity?.did === setupDefaults.did); 20 + const showTypeahead = $derived(hasInputFocus && (suggestions.length > 0 || isSearching || typeaheadError !== null)); 21 + 22 + $effect(() => { 23 + const query = handle.trim().replace(/^@/, ''); 24 + 25 + if (!hasInputFocus || query.length < 2 || resolvedIdentity?.handle === query) { 26 + suggestions = []; 27 + activeSuggestionIndex = -1; 28 + typeaheadError = null; 29 + isSearching = false; 30 + return; 31 + } 32 + 33 + const requestId = ++typeaheadRequestId; 34 + isSearching = true; 35 + typeaheadError = null; 36 + 37 + const timeout = window.setTimeout(async () => { 38 + try { 39 + const results = await searchActorsTypeahead(query); 40 + 41 + if (requestId === typeaheadRequestId) { 42 + suggestions = results; 43 + activeSuggestionIndex = results.length > 0 ? 0 : -1; 44 + } 45 + } catch (unknownError) { 46 + if (requestId === typeaheadRequestId) { 47 + suggestions = []; 48 + activeSuggestionIndex = -1; 49 + typeaheadError = errorMessage(unknownError, 'Could not search handles.'); 50 + } 51 + } finally { 52 + if (requestId === typeaheadRequestId) { 53 + isSearching = false; 54 + } 55 + } 56 + }, 220); 57 + 58 + return () => window.clearTimeout(timeout); 59 + }); 14 60 15 61 async function resolveHandle() { 16 62 isResolving = true; 17 63 error = null; 18 64 resolvedIdentity = null; 65 + hasInputFocus = false; 19 66 20 67 try { 21 68 resolvedIdentity = await resolveAccount(handle); ··· 26 73 } 27 74 } 28 75 76 + function handleInput() { 77 + resolvedIdentity = null; 78 + error = null; 79 + hasInputFocus = true; 80 + } 81 + 82 + function handleKeydown(event: KeyboardEvent) { 83 + if (!showTypeahead || suggestions.length === 0) { 84 + return; 85 + } 86 + 87 + if (event.key === 'ArrowDown') { 88 + event.preventDefault(); 89 + activeSuggestionIndex = (activeSuggestionIndex + 1) % suggestions.length; 90 + } else if (event.key === 'ArrowUp') { 91 + event.preventDefault(); 92 + activeSuggestionIndex = (activeSuggestionIndex - 1 + suggestions.length) % suggestions.length; 93 + } else if (event.key === 'Enter' && activeSuggestionIndex >= 0) { 94 + event.preventDefault(); 95 + void selectSuggestion(suggestions[activeSuggestionIndex]); 96 + } else if (event.key === 'Escape') { 97 + hasInputFocus = false; 98 + } 99 + } 100 + 101 + async function selectSuggestion(suggestion: ActorTypeaheadResult) { 102 + handle = suggestion.handle; 103 + hasInputFocus = false; 104 + suggestions = []; 105 + activeSuggestionIndex = -1; 106 + isResolving = true; 107 + error = null; 108 + 109 + try { 110 + resolvedIdentity = await hydratePublicIdentity(suggestion.did, suggestion.handle); 111 + } catch (unknownError) { 112 + error = errorMessage(unknownError, 'Could not resolve that handle.'); 113 + } finally { 114 + isResolving = false; 115 + } 116 + } 117 + 29 118 function useAccount() { 30 119 if (resolvedIdentity) { 31 120 accountSetup.save(resolvedIdentity); ··· 54 143 <form class="handle-form" onsubmit={(event) => event.preventDefault()}> 55 144 <label for="handle">Handle</label> 56 145 <div class="handle-row"> 57 - <!-- TODO: setup typeahead 58 - https://docs.bsky.app/docs/api/app-bsky-actor-search-actors-typeahead 59 - --> 60 - <input id="handle" bind:value={handle} placeholder="desertthunder.dev" autocomplete="username" /> 146 + <div class="typeahead-field"> 147 + <input 148 + id="handle" 149 + bind:value={handle} 150 + placeholder="desertthunder.dev" 151 + autocomplete="off" 152 + aria-autocomplete="list" 153 + aria-controls="handle-suggestions" 154 + aria-expanded={showTypeahead} 155 + onfocus={() => (hasInputFocus = true)} 156 + onblur={() => (hasInputFocus = false)} 157 + oninput={handleInput} 158 + onkeydown={handleKeydown} /> 159 + 160 + {#if showTypeahead} 161 + <div id="handle-suggestions" class="typeahead-menu" role="listbox" aria-label="Suggested handles"> 162 + {#if isSearching} 163 + <p class="typeahead-status">Searching public profiles…</p> 164 + {:else if typeaheadError} 165 + <p class="typeahead-status error-text">{typeaheadError}</p> 166 + {:else} 167 + {#each suggestions as suggestion, index (suggestion.did)} 168 + <button 169 + type="button" 170 + class:active={index === activeSuggestionIndex} 171 + role="option" 172 + aria-selected={index === activeSuggestionIndex} 173 + onmousedown={(event) => event.preventDefault()} 174 + onclick={() => selectSuggestion(suggestion)}> 175 + {#if suggestion.avatar} 176 + <img src={suggestion.avatar} alt="" /> 177 + {:else} 178 + <img src="/icons/humanity/places/user-home.svg" alt="" /> 179 + {/if} 180 + <span> 181 + <strong>{suggestion.displayName ?? suggestion.handle}</strong> 182 + <small>@{suggestion.handle}</small> 183 + </span> 184 + </button> 185 + {/each} 186 + {/if} 187 + </div> 188 + {/if} 189 + </div> 61 190 <button type="button" onclick={resolveHandle} disabled={isResolving}> 62 191 {isResolving ? 'Resolving…' : 'Resolve'} 63 192 </button> ··· 188 317 gap: var(--space-2); 189 318 } 190 319 320 + .typeahead-field { 321 + position: relative; 322 + min-width: 0; 323 + } 324 + 191 325 input { 326 + width: 100%; 192 327 height: 2rem; 193 328 padding: 0 var(--space-3); 194 329 color: var(--text); ··· 196 331 border: 1px solid #8e7654; 197 332 border-radius: var(--radius-2); 198 333 box-shadow: var(--shadow-sunken); 334 + } 335 + 336 + .typeahead-menu { 337 + position: absolute; 338 + z-index: 20; 339 + top: calc(100% + 2px); 340 + left: 0; 341 + right: 0; 342 + max-height: 15rem; 343 + overflow-y: auto; 344 + padding: 2px; 345 + background: #fffdf7; 346 + border: 1px solid #8e7654; 347 + border-radius: var(--radius-2); 348 + box-shadow: 0 4px 10px rgb(62 42 20 / 0.28); 349 + } 350 + 351 + .typeahead-menu button { 352 + display: grid; 353 + grid-template-columns: 2rem minmax(0, 1fr); 354 + align-items: center; 355 + width: 100%; 356 + height: auto; 357 + min-height: 2.5rem; 358 + padding: var(--space-1) var(--space-2); 359 + gap: var(--space-2); 360 + text-align: left; 361 + background: transparent; 362 + border: 0; 363 + border-radius: 0; 364 + box-shadow: none; 365 + font-weight: 400; 366 + } 367 + 368 + .typeahead-menu button.active, 369 + .typeahead-menu button:hover { 370 + color: white; 371 + background: #c66b1f; 372 + text-shadow: 0 1px 0 rgb(0 0 0 / 0.35); 373 + } 374 + 375 + .typeahead-menu img { 376 + width: 2rem; 377 + height: 2rem; 378 + object-fit: cover; 379 + background: #ead8ba; 380 + border: 1px solid #b29165; 381 + border-radius: var(--radius-2); 382 + } 383 + 384 + .typeahead-menu span { 385 + display: grid; 386 + min-width: 0; 387 + } 388 + 389 + .typeahead-menu strong, 390 + .typeahead-menu small { 391 + overflow: hidden; 392 + text-overflow: ellipsis; 393 + white-space: nowrap; 394 + } 395 + 396 + .typeahead-menu small { 397 + color: var(--text-muted); 398 + font-size: var(--text-0); 399 + } 400 + 401 + .typeahead-menu button.active small, 402 + .typeahead-menu button:hover small { 403 + color: #fff0d8; 404 + } 405 + 406 + .typeahead-status { 407 + padding: var(--space-2) var(--space-3); 408 + font-size: var(--text-1); 409 + } 410 + 411 + .error-text { 412 + color: #721c0d; 199 413 } 200 414 201 415 button {