See the best posts from any Bluesky account
0
fork

Configure Feed

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

Add searchahead dropdown for Bluesky handle input using typeahead API

Adds a typeahead autocomplete dropdown to both the landing page and header
search inputs. As users type, results from Bluesky's searchActorsTypeahead
API appear with avatars, display names, and handles. Selecting a result
navigates directly to the profile page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+265 -36
+1
.adonisjs/server/controllers.ts
··· 8 8 Landing: () => import('#controllers/landing_controller'), 9 9 Profile: () => import('#controllers/profile_controller'), 10 10 Search: () => import('#controllers/search_controller'), 11 + Typeahead: () => import('#controllers/typeahead_controller'), 11 12 }
+3
.adonisjs/server/routes.d.ts
··· 6 6 ALL: { 7 7 'home': { paramsTuple?: []; params?: {} } 8 8 'search': { paramsTuple?: []; params?: {} } 9 + 'api.typeahead': { paramsTuple?: []; params?: {} } 9 10 'about': { paramsTuple?: []; params?: {} } 10 11 'profile.show': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 11 12 'profile.likes': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } ··· 17 18 GET: { 18 19 'home': { paramsTuple?: []; params?: {} } 19 20 'search': { paramsTuple?: []; params?: {} } 21 + 'api.typeahead': { paramsTuple?: []; params?: {} } 20 22 'about': { paramsTuple?: []; params?: {} } 21 23 'profile.show': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 22 24 'profile.likes': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } ··· 28 30 HEAD: { 29 31 'home': { paramsTuple?: []; params?: {} } 30 32 'search': { paramsTuple?: []; params?: {} } 33 + 'api.typeahead': { paramsTuple?: []; params?: {} } 31 34 'about': { paramsTuple?: []; params?: {} } 32 35 'profile.show': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 33 36 'profile.likes': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} }
+33
app/controllers/typeahead_controller.ts
··· 1 + import { inject } from '@adonisjs/core' 2 + import type { HttpContext } from '@adonisjs/core/http' 3 + import { AtprotoClient } from '#lib/atproto/index' 4 + 5 + @inject() 6 + export default class TypeaheadController { 7 + constructor(private readonly atprotoClient: AtprotoClient) {} 8 + 9 + async search({ request, response }: HttpContext) { 10 + const q = request.input('q', '').trim() 11 + 12 + if (q.length < 2) { 13 + return response.json({ actors: [] }) 14 + } 15 + 16 + // Strip leading @ if present 17 + const query = q.startsWith('@') ? q.slice(1) : q 18 + 19 + try { 20 + const actors = await this.atprotoClient.searchActorsTypeahead(query, 5) 21 + 22 + return response.json({ 23 + actors: actors.map((a) => ({ 24 + handle: a.handle, 25 + displayName: a.displayName || null, 26 + avatar: a.avatar || null, 27 + })), 28 + }) 29 + } catch { 30 + return response.json({ actors: [] }) 31 + } 32 + } 33 + }
+48 -2
app/lib/atproto/client.ts
··· 102 102 * throughput against `public.api.bsky.app`. See commands/backfill_run.ts. 103 103 */ 104 104 export interface AtprotoRequestEvent { 105 - endpoint: 'resolveHandle' | 'getAuthorFeed' | 'getPosts' | 'getProfile' 105 + endpoint: 'resolveHandle' | 'getAuthorFeed' | 'getPosts' | 'getProfile' | 'searchActorsTypeahead' 106 106 attempt: number 107 107 latencyMs: number 108 108 ok: boolean ··· 160 160 * The AgentLike interface describes the minimal surface of @atproto/api's 161 161 * Agent that AtprotoClient depends on. This is the injection point for tests. 162 162 */ 163 + export interface TypeaheadActor { 164 + did: string 165 + handle: string 166 + displayName?: string 167 + avatar?: string 168 + } 169 + 163 170 export interface AgentLike { 164 171 com: { 165 172 atproto: { ··· 178 185 params: { actor: string }, 179 186 opts?: unknown 180 187 ) => Promise<{ 181 - data: { postsCount?: number; displayName?: string; avatar?: string; [key: string]: unknown } 188 + data: { 189 + postsCount?: number 190 + displayName?: string 191 + avatar?: string 192 + [key: string]: unknown 193 + } 194 + headers: Record<string, string | undefined> 195 + }> 196 + searchActorsTypeahead: ( 197 + params: { q: string; limit?: number }, 198 + opts?: unknown 199 + ) => Promise<{ 200 + data: { actors: TypeaheadActor[] } 182 201 headers: Record<string, string | undefined> 183 202 }> 184 203 } ··· 368 387 displayName: response.data.displayName ?? null, 369 388 avatarUrl: response.data.avatar ?? null, 370 389 } 390 + } 391 + 392 + /** 393 + * Prefix search for actors, intended for typeahead/autocomplete. 394 + * 395 + * @param q - Search prefix (e.g. "ali" to find "alice.bsky.social") 396 + * @param limit - Max results (default 5) 397 + * @returns Matching actors with handle, displayName, avatar 398 + */ 399 + async searchActorsTypeahead(q: string, limit: number = 5): Promise<TypeaheadActor[]> { 400 + const { 401 + value: response, 402 + attempts, 403 + totalLatencyMs, 404 + } = await this.withRetry('searchActorsTypeahead', () => 405 + this.agent.app.bsky.actor.searchActorsTypeahead({ q, limit }) 406 + ) 407 + const headers = response.headers as Record<string, string | undefined> 408 + this.emit({ 409 + endpoint: 'searchActorsTypeahead', 410 + attempt: attempts, 411 + latencyMs: totalLatencyMs, 412 + ok: true, 413 + status: 200, 414 + headers: pickRateLimitHeaders(headers), 415 + }) 416 + return response.data.actors 371 417 } 372 418 373 419 /**
+1
app/lib/atproto/index.ts
··· 31 31 AgentLike, 32 32 FeedViewPost, 33 33 PostView, 34 + TypeaheadActor, 34 35 AtprotoRequestEvent, 35 36 AtprotoRequestHook, 36 37 AuthorFeedFilter,
+3 -3
app/middleware/theme_middleware.ts
··· 8 8 const theme = match ? match[1] : '' 9 9 10 10 if ('view' in ctx) { 11 - ;(ctx as HttpContext & { view: { share: (data: Record<string, unknown>) => void } }).view.share( 12 - { theme } 13 - ) 11 + ;( 12 + ctx as HttpContext & { view: { share: (data: Record<string, unknown>) => void } } 13 + ).view.share({ theme }) 14 14 } 15 15 16 16 return next()
+2 -6
commands/backfill_run.ts
··· 153 153 } 154 154 } catch (err) { 155 155 if (err instanceof BlueskyRateLimitedError) { 156 - this.logger.error( 157 - `Rate limited — all retries exhausted after ${err.attempts} attempts` 158 - ) 156 + this.logger.error(`Rate limited — all retries exhausted after ${err.attempts} attempts`) 159 157 } else { 160 - this.logger.error( 161 - `Backfill failed: ${err instanceof Error ? err.message : String(err)}` 162 - ) 158 + this.logger.error(`Backfill failed: ${err instanceof Error ? err.message : String(err)}`) 163 159 } 164 160 // Fall through to print summary of what we did manage to fetch. 165 161 }
+8
resources/js/app.js
··· 1 1 import Alpine from '@alpinejs/csp' 2 2 import { createBackfillProgress } from './backfill_progress.ts' 3 + import { createSearchAhead } from './search_ahead.ts' 3 4 4 5 Alpine.data('darkMode', function () { 5 6 return { ··· 38 39 navigate: (url) => { 39 40 window.location.href = url 40 41 }, 42 + }) 43 + ) 44 + 45 + Alpine.data( 46 + 'searchAhead', 47 + createSearchAhead((url) => { 48 + window.location.href = url 41 49 }) 42 50 ) 43 51
+102
resources/js/search_ahead.ts
··· 1 + interface Actor { 2 + handle: string 3 + displayName: string | null 4 + avatar: string | null 5 + } 6 + 7 + export function createSearchAhead(navigateFn: (url: string) => void) { 8 + return function () { 9 + return { 10 + query: '', 11 + actors: [] as Actor[], 12 + open: false, 13 + activeIndex: -1, 14 + loading: false, 15 + _debounceTimer: null as ReturnType<typeof setTimeout> | null, 16 + 17 + init() { 18 + // Nothing needed on init 19 + }, 20 + 21 + onInput() { 22 + if (this._debounceTimer) { 23 + clearTimeout(this._debounceTimer) 24 + } 25 + 26 + if (this.query.trim().length < 2) { 27 + this.actors = [] 28 + this.open = false 29 + this.activeIndex = -1 30 + return 31 + } 32 + 33 + this.loading = true 34 + this._debounceTimer = setTimeout(() => { 35 + this.fetchResults() 36 + }, 200) 37 + }, 38 + 39 + onKeydown(event: { key: string; preventDefault: () => void }) { 40 + if (!this.open || this.actors.length === 0) return 41 + 42 + if (event.key === 'ArrowDown') { 43 + event.preventDefault() 44 + this.activeIndex = (this.activeIndex + 1) % this.actors.length 45 + } else if (event.key === 'ArrowUp') { 46 + event.preventDefault() 47 + this.activeIndex = this.activeIndex <= 0 ? this.actors.length - 1 : this.activeIndex - 1 48 + } else if (event.key === 'Enter' && this.activeIndex >= 0) { 49 + event.preventDefault() 50 + this.selectActor(this.actors[this.activeIndex]) 51 + } else if (event.key === 'Escape') { 52 + this.close() 53 + } 54 + }, 55 + 56 + onBlur() { 57 + // Delay so click on dropdown item registers first 58 + setTimeout(() => { 59 + this.close() 60 + }, 150) 61 + }, 62 + 63 + selectActor(actor: Actor) { 64 + this.query = actor.handle 65 + this.close() 66 + this.navigate(actor.handle) 67 + }, 68 + 69 + fetchResults() { 70 + const q = this.query.trim() 71 + if (q.length < 2) { 72 + this.loading = false 73 + return 74 + } 75 + 76 + fetch('/api/typeahead?q=' + encodeURIComponent(q)) 77 + .then((res: { json: () => Promise<unknown> }) => res.json()) 78 + .then((data: unknown) => { 79 + const result = data as { actors: Actor[] } 80 + this.actors = result.actors 81 + this.open = this.actors.length > 0 82 + this.activeIndex = -1 83 + this.loading = false 84 + }) 85 + .catch(() => { 86 + this.actors = [] 87 + this.open = false 88 + this.loading = false 89 + }) 90 + }, 91 + 92 + navigate(handle: string) { 93 + navigateFn('/profile/' + encodeURIComponent(handle) + '/likes') 94 + }, 95 + 96 + close() { 97 + this.open = false 98 + this.activeIndex = -1 99 + }, 100 + } 101 + } 102 + }
+3 -11
resources/views/components/layout.edge
··· 32 32 <span class="font-semibold text-sm">skystar</span> 33 33 </a> 34 34 <div class="flex items-center gap-2"> 35 - <form action="/search" method="GET" class="flex gap-1.5"> 36 - <input 37 - type="text" 38 - name="q" 39 - placeholder="@handle" 40 - class="w-44 px-2.5 py-1.5 text-sm border border-gray-300 dark:border-gray-700 rounded-md outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500" 41 - /> 42 - <button 43 - type="submit" 44 - class="px-3 py-1.5 text-sm bg-blue-600 text-white border-none rounded-md cursor-pointer hover:bg-blue-700" 45 - >Go</button> 35 + <form action="/search" method="GET"> 36 + @component('components/search_ahead', { size: 'sm' }) 37 + @endcomponent 46 38 </form> 47 39 <button 48 40 x-data="darkMode"
+55
resources/views/components/search_ahead.edge
··· 1 + @let(size = $props.has('size') ? $props.get('size') : 'base') 2 + @let(autofocusAttr = $props.has('autofocus')) 3 + 4 + <div x-data="searchAhead" class="relative"> 5 + <div class="flex gap-{{ size === 'sm' ? '1.5' : '2' }}"> 6 + <input 7 + type="text" 8 + x-model="query" 9 + x-on:input="onInput" 10 + x-on:keydown="onKeydown" 11 + x-on:blur="onBlur" 12 + name="q" 13 + placeholder="{{ size === 'sm' ? '@handle' : '@handle or handle.bsky.social' }}" 14 + autocomplete="off" 15 + {{ autofocusAttr ? 'autofocus' : '' }} 16 + class="{{ size === 'sm' ? 'w-44 px-2.5 py-1.5 text-sm' : 'flex-1 px-3.5 py-2.5 text-base' }} border border-gray-300 dark:border-gray-700 rounded-md outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500" 17 + /> 18 + <button 19 + type="submit" 20 + class="{{ size === 'sm' ? 'px-3 py-1.5 text-sm' : 'px-5 py-2.5 text-base' }} bg-blue-600 text-white border-none rounded-md cursor-pointer hover:bg-blue-700" 21 + >Go</button> 22 + </div> 23 + 24 + <div 25 + x-show="open" 26 + x-cloak 27 + class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden" 28 + > 29 + <template x-for="(actor, index) in actors"> 30 + <button 31 + type="button" 32 + x-on:mousedown="selectActor(actor)" 33 + class="w-full flex items-center gap-2.5 px-3 py-2 text-left cursor-pointer hover:bg-blue-50 dark:hover:bg-gray-800 border-none bg-transparent" 34 + x-bind:class="activeIndex === index ? 'bg-blue-50 dark:bg-gray-800' : ''" 35 + > 36 + <img 37 + x-bind:src="actor.avatar || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><circle cx=%2216%22 cy=%2216%22 r=%2216%22 fill=%22%23ddd%22/></svg>'" 38 + class="w-8 h-8 rounded-full object-cover shrink-0" 39 + alt="" 40 + /> 41 + <div class="min-w-0"> 42 + <div 43 + x-show="actor.displayName" 44 + x-text="actor.displayName" 45 + class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" 46 + ></div> 47 + <div 48 + x-text="actor.handle" 49 + class="text-sm text-gray-500 dark:text-gray-400 truncate" 50 + ></div> 51 + </div> 52 + </button> 53 + </template> 54 + </div> 55 + </div>
+3 -14
resources/views/pages/landing.edge
··· 17 17 See any Bluesky account's most-liked and most-reposted posts. 18 18 </p> 19 19 20 - <form action="/search" method="GET" class="flex gap-2 mb-4"> 21 - <input 22 - type="text" 23 - name="q" 24 - placeholder="@handle or handle.bsky.social" 25 - autofocus 26 - class="flex-1 px-3.5 py-2.5 text-base border border-gray-300 dark:border-gray-700 rounded-md outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500" 27 - /> 28 - <button 29 - type="submit" 30 - class="px-5 py-2.5 text-base bg-blue-600 text-white border-none rounded-md cursor-pointer hover:bg-blue-700" 31 - > 32 - Go 33 - </button> 20 + <form action="/search" method="GET" class="mb-4"> 21 + @component('components/search_ahead', { autofocus: true }) 22 + @endcomponent 34 23 </form> 35 24 36 25 @if(error)
+3
start/routes.ts
··· 11 11 12 12 const LandingController = () => import('#controllers/landing_controller') 13 13 const SearchController = () => import('#controllers/search_controller') 14 + const TypeaheadController = () => import('#controllers/typeahead_controller') 14 15 const ProfileController = () => import('#controllers/profile_controller') 15 16 const HealthChecksController = () => import('#controllers/health_checks_controller') 16 17 ··· 25 26 // --------------------------------------------------------------------------- 26 27 27 28 router.get('/search', [SearchController, 'redirect']).as('search') 29 + 30 + router.get('/api/typeahead', [TypeaheadController, 'search']).as('api.typeahead') 28 31 29 32 // --------------------------------------------------------------------------- 30 33 // About