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 typeahead to login page, strip leading @ from handle input

Reuse the existing search_ahead component on the OAuth login page so
users get actor suggestions while typing their handle. The component
now accepts configurable `component` and `name` props so the same
template works for both the landing search (searchAhead / name=q) and
login (loginSearchAhead / name=handle). createSearchAhead takes an
optional buildUrl function so the login variant navigates to
/oauth/login?handle=… instead of /profile/…/likes.

Also normalise the handle in OAuthController.login (trim + strip
leading @) so form submission works regardless of how the user types
their handle.

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

+178 -23
+2 -1
app/controllers/oauth_controller.ts
··· 23 23 * If ?handle is provided, starts the OAuth flow. Otherwise renders a login form. 24 24 */ 25 25 async login({ request, response, session, view }: HttpContext) { 26 - const handle = request.input('handle') 26 + const rawHandle = request.input('handle', '').trim() 27 + const handle = rawHandle.startsWith('@') ? rawHandle.slice(1) : rawHandle 27 28 28 29 if (!handle) { 29 30 return view.render('pages/oauth/login')
+10
resources/js/app.js
··· 49 49 }) 50 50 ) 51 51 52 + Alpine.data( 53 + 'loginSearchAhead', 54 + createSearchAhead( 55 + (url) => { 56 + window.location.href = url 57 + }, 58 + { buildUrl: (handle) => '/oauth/login?handle=' + encodeURIComponent(handle) } 59 + ) 60 + ) 61 + 52 62 Alpine.data('postEngagement', function () { 53 63 return { 54 64 liked: false,
+7 -2
resources/js/search_ahead.ts
··· 4 4 avatar: string | null 5 5 } 6 6 7 - export function createSearchAhead(navigateFn: (url: string) => void) { 7 + export interface SearchAheadOptions { 8 + buildUrl?: (handle: string) => string 9 + } 10 + 11 + export function createSearchAhead(navigateFn: (url: string) => void, options?: SearchAheadOptions) { 12 + const buildUrl = options?.buildUrl ?? ((handle: string) => '/profile/' + encodeURIComponent(handle) + '/likes') 8 13 return function () { 9 14 return { 10 15 query: '', ··· 90 95 }, 91 96 92 97 navigate(handle: string) { 93 - navigateFn('/profile/' + encodeURIComponent(handle) + '/likes') 98 + navigateFn(buildUrl(handle)) 94 99 }, 95 100 96 101 close() {
+4 -2
resources/views/components/search_ahead.edge
··· 1 1 @let(size = $props.has('size') ? $props.get('size') : 'base') 2 2 @let(autofocusAttr = $props.has('autofocus')) 3 + @let(alpineComponent = $props.has('component') ? $props.get('component') : 'searchAhead') 4 + @let(inputName = $props.has('name') ? $props.get('name') : 'q') 3 5 4 - <div x-data="searchAhead" class="relative"> 6 + <div x-data="{{ alpineComponent }}" class="relative"> 5 7 <div class="flex gap-{{ size === 'sm' ? '1.5' : '2' }}"> 6 8 <input 7 9 type="text" ··· 9 11 x-on:input="onInput" 10 12 x-on:keydown="onKeydown" 11 13 x-on:blur="onBlur" 12 - name="q" 14 + name="{{ inputName }}" 13 15 placeholder="{{ size === 'sm' ? '@handle' : '@handle or handle.bsky.social' }}" 14 16 autocomplete="off" 15 17 {{ autofocusAttr ? 'autofocus' : '' }}
+5 -18
resources/views/pages/oauth/login.edge
··· 15 15 </div> 16 16 @endif 17 17 18 + <label for="handle" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> 19 + Handle 20 + </label> 18 21 <form action="/oauth/login" method="GET"> 19 - <label for="handle" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> 20 - Handle 21 - </label> 22 - <input 23 - type="text" 24 - id="handle" 25 - name="handle" 26 - placeholder="you.bsky.social" 27 - required 28 - autofocus 29 - class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" 30 - /> 31 - <button 32 - type="submit" 33 - class="mt-3 w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors" 34 - > 35 - Sign in 36 - </button> 22 + @component('components/search_ahead', { autofocus: true, component: 'loginSearchAhead', name: 'handle' }) 23 + @endcomponent 37 24 </form> 38 25 </div> 39 26 @endslot
+26
tests/functional/oauth.spec.ts
··· 50 50 response.assertTextIncludes('handle') 51 51 }) 52 52 53 + test('login form includes the typeahead component', async ({ client }) => { 54 + const response = await client.get('/oauth/login') 55 + response.assertStatus(200) 56 + response.assertTextIncludes('loginSearchAhead') 57 + response.assertTextIncludes('x-on:input') 58 + }) 59 + 60 + test('login form submits handle via Go button or Enter', async ({ client }) => { 61 + const response = await client.get('/oauth/login') 62 + response.assertStatus(200) 63 + // The input must be named "handle" so the form GETs /oauth/login?handle=... 64 + response.assertTextIncludes('name="handle"') 65 + }) 66 + 67 + test('strips leading @ from handle before starting OAuth', async ({ client }) => { 68 + // With a leading @, the controller should still render the login form 69 + // (we can't test the full OAuth redirect without mocking the service, 70 + // but we can verify the handle is normalized by checking that @nonexistent 71 + // behaves the same as nonexistent — both hit the authorize path and flash 72 + // an error because the handle doesn't resolve) 73 + const withAt = await client.get('/oauth/login').qs({ handle: '@test.bsky.social' }).redirects(0) 74 + const withoutAt = await client.get('/oauth/login').qs({ handle: 'test.bsky.social' }).redirects(0) 75 + // Both should behave identically (redirect to auth server, or flash error) 76 + withAt.assertStatus(withoutAt.response.status) 77 + }) 78 + 53 79 test('redirects authenticated users away (guest middleware)', async ({ client }) => { 54 80 const account = await Account.create({ 55 81 did: 'did:plc:oauthtest1',
+124
tests/unit/alpine/search_ahead.spec.ts
··· 1 + /** 2 + * Unit tests for createSearchAhead — the pure factory behind the 3 + * Alpine `searchAhead` component. 4 + * 5 + * No jsdom, no Alpine runtime: the factory takes a navigate callback 6 + * (and optionally a URL builder), and we drive it with fakes. 7 + */ 8 + import { test } from '@japa/runner' 9 + import { createSearchAhead } from '../../../resources/js/search_ahead.ts' 10 + 11 + // --------------------------------------------------------------------------- 12 + // Helpers 13 + // --------------------------------------------------------------------------- 14 + 15 + function setup(options?: { buildUrl?: (handle: string) => string }) { 16 + const navigations: string[] = [] 17 + const navigateFn = (url: string) => navigations.push(url) 18 + const component = options?.buildUrl 19 + ? createSearchAhead(navigateFn, { buildUrl: options.buildUrl })() 20 + : createSearchAhead(navigateFn)() 21 + return { component, navigations } 22 + } 23 + 24 + // --------------------------------------------------------------------------- 25 + // Tests 26 + // --------------------------------------------------------------------------- 27 + 28 + test.group('createSearchAhead | default URL builder', () => { 29 + test('navigate() builds /profile/:handle/likes by default', ({ assert }) => { 30 + const { component, navigations } = setup() 31 + component.navigate('alice.bsky.social') 32 + assert.deepEqual(navigations, ['/profile/alice.bsky.social/likes']) 33 + }) 34 + 35 + test('selectActor sets query and navigates', ({ assert }) => { 36 + const { component, navigations } = setup() 37 + component.selectActor({ handle: 'bob.bsky.social', displayName: 'Bob', avatar: null }) 38 + assert.equal(component.query, 'bob.bsky.social') 39 + assert.isFalse(component.open) 40 + assert.deepEqual(navigations, ['/profile/bob.bsky.social/likes']) 41 + }) 42 + }) 43 + 44 + test.group('createSearchAhead | custom URL builder', () => { 45 + test('navigate() uses the custom buildUrl function', ({ assert }) => { 46 + const { component, navigations } = setup({ 47 + buildUrl: (handle) => '/oauth/login?handle=' + encodeURIComponent(handle), 48 + }) 49 + component.navigate('carol.bsky.social') 50 + assert.deepEqual(navigations, ['/oauth/login?handle=carol.bsky.social']) 51 + }) 52 + 53 + test('selectActor navigates using custom URL builder', ({ assert }) => { 54 + const { component, navigations } = setup({ 55 + buildUrl: (handle) => '/oauth/login?handle=' + encodeURIComponent(handle), 56 + }) 57 + component.selectActor({ handle: 'dave.bsky.social', displayName: 'Dave', avatar: null }) 58 + assert.equal(component.query, 'dave.bsky.social') 59 + assert.deepEqual(navigations, ['/oauth/login?handle=dave.bsky.social']) 60 + }) 61 + }) 62 + 63 + test.group('createSearchAhead | keyboard navigation', () => { 64 + test('arrow keys cycle through actors', ({ assert }) => { 65 + const { component } = setup() 66 + component.actors = [ 67 + { handle: 'a.bsky.social', displayName: 'A', avatar: null }, 68 + { handle: 'b.bsky.social', displayName: 'B', avatar: null }, 69 + ] 70 + component.open = true 71 + 72 + const prevented: string[] = [] 73 + const event = (key: string) => ({ key, preventDefault: () => prevented.push(key) }) 74 + 75 + component.onKeydown(event('ArrowDown')) 76 + assert.equal(component.activeIndex, 0) 77 + 78 + component.onKeydown(event('ArrowDown')) 79 + assert.equal(component.activeIndex, 1) 80 + 81 + component.onKeydown(event('ArrowDown')) 82 + assert.equal(component.activeIndex, 0) // wraps 83 + 84 + component.onKeydown(event('ArrowUp')) 85 + assert.equal(component.activeIndex, 1) // wraps back 86 + }) 87 + 88 + test('Enter selects the active actor', ({ assert }) => { 89 + const { component, navigations } = setup() 90 + component.actors = [ 91 + { handle: 'a.bsky.social', displayName: 'A', avatar: null }, 92 + ] 93 + component.open = true 94 + component.activeIndex = 0 95 + 96 + component.onKeydown({ key: 'Enter', preventDefault: () => {} }) 97 + assert.deepEqual(navigations, ['/profile/a.bsky.social/likes']) 98 + }) 99 + 100 + test('Escape closes the dropdown', ({ assert }) => { 101 + const { component } = setup() 102 + component.actors = [{ handle: 'z.bsky.social', displayName: null, avatar: null }] 103 + component.open = true 104 + component.activeIndex = 0 105 + 106 + component.onKeydown({ key: 'Escape', preventDefault: () => {} }) 107 + assert.isFalse(component.open) 108 + assert.equal(component.activeIndex, -1) 109 + }) 110 + }) 111 + 112 + test.group('createSearchAhead | input handling', () => { 113 + test('short queries clear actors and close dropdown', ({ assert }) => { 114 + const { component } = setup() 115 + component.actors = [{ handle: 'x.bsky.social', displayName: null, avatar: null }] 116 + component.open = true 117 + component.query = 'a' // too short (< 2 chars) 118 + 119 + component.onInput() 120 + assert.deepEqual(component.actors, []) 121 + assert.isFalse(component.open) 122 + assert.equal(component.activeIndex, -1) 123 + }) 124 + })