A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

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

Add HandleStart island: live handle lookup + OAuth start

+151
+113
src/components/HandleStart.tsx
··· 1 + import { useEffect, useRef, useState } from 'react'; 2 + import { createOAuthClient } from '../lib/auth/oauth'; 3 + import { isValidAccountInput, isValidHandleOrDid, normalizeHandle } from '../lib/auth/config'; 4 + import { lookupActor, type ActorPreview } from '../lib/landing/actor-lookup'; 5 + 6 + /** 7 + * The landing page's primary CTA (client-only). As you type a handle it debounces and 8 + * looks you up on the public Bluesky AppView, showing a confirmation card. Submitting 9 + * hands the raw value to the existing OAuth flow — Start ALWAYS works; the lookup is 10 + * delightful reassurance, never a gate (Decision: brainstorm 2026-06-09). 11 + */ 12 + export default function HandleStart() { 13 + const [ value, setValue ] = useState( '' ); 14 + const [ preview, setPreview ] = useState< ActorPreview | null >( null ); 15 + const [ looking, setLooking ] = useState( false ); 16 + const [ error, setError ] = useState< string | null >( null ); 17 + const [ avatarOk, setAvatarOk ] = useState( true ); 18 + const reqId = useRef( 0 ); 19 + 20 + // Debounced live lookup. Each keystroke cancels the pending timer; a request id 21 + // guards against out-of-order responses overwriting a newer query. 22 + useEffect( () => { 23 + const trimmed = value.trim(); 24 + if ( ! isValidHandleOrDid( trimmed ) ) { 25 + setPreview( null ); 26 + setLooking( false ); 27 + return; 28 + } 29 + const id = ++reqId.current; 30 + setLooking( true ); 31 + const timer = setTimeout( async () => { 32 + const found = await lookupActor( trimmed ); 33 + if ( id === reqId.current ) { 34 + setPreview( found ); 35 + setAvatarOk( true ); 36 + setLooking( false ); 37 + } 38 + }, 350 ); 39 + return () => clearTimeout( timer ); 40 + }, [ value ] ); 41 + 42 + async function onSubmit( event: React.FormEvent ) { 43 + event.preventDefault(); 44 + if ( ! isValidAccountInput( value ) ) { 45 + setError( 'Enter a handle (alice.bsky.social), a DID, or a PDS URL.' ); 46 + return; 47 + } 48 + setError( null ); 49 + try { 50 + const client = await createOAuthClient(); 51 + // Redirects to the authorization server; the promise never resolves. 52 + await client.signIn( normalizeHandle( value ) ); 53 + } catch ( err ) { 54 + setError( err instanceof Error ? err.message : String( err ) ); 55 + } 56 + } 57 + 58 + const name = preview?.displayName ?? preview?.handle ?? null; 59 + 60 + return ( 61 + <form className="handlestart" onSubmit={ onSubmit }> 62 + <div className="handlestart__row"> 63 + <div className="handlestart__field"> 64 + <span className="handlestart__at" aria-hidden="true">@</span> 65 + <input 66 + className="handlestart__input" 67 + name="handle" 68 + autoComplete="username" 69 + autoCapitalize="none" 70 + autoCorrect="off" 71 + spellCheck={ false } 72 + placeholder="you.bsky.social" 73 + aria-label="Your handle, DID, or PDS URL" 74 + value={ value } 75 + onChange={ ( e ) => setValue( e.target.value ) } 76 + /> 77 + { looking && <span className="handlestart__spinner" aria-hidden="true" /> } 78 + </div> 79 + <button className="handlestart__go" type="submit">Start &rarr;</button> 80 + </div> 81 + 82 + { preview && ( 83 + <div className="handlestart__card"> 84 + { preview.avatar && avatarOk ? ( 85 + <img 86 + className="handlestart__avatar" 87 + src={ preview.avatar } 88 + alt="" 89 + width={ 36 } 90 + height={ 36 } 91 + onError={ () => setAvatarOk( false ) } 92 + /> 93 + ) : ( 94 + <span className="handlestart__avatar handlestart__avatar--fallback" aria-hidden="true"> 95 + { ( name ?? '?' ).charAt( 0 ).toUpperCase() } 96 + </span> 97 + ) } 98 + <span className="handlestart__who"> 99 + <span className="handlestart__name">{ name }</span> 100 + <span className="handlestart__handle">@{ preview.handle }</span> 101 + </span> 102 + <span className="handlestart__check" aria-hidden="true">&#10003;</span> 103 + </div> 104 + ) } 105 + 106 + { error ? ( 107 + <p className="handlestart__hint handlestart__hint--error" role="alert">{ error }</p> 108 + ) : ( 109 + <p className="handlestart__hint">Your handle, DID, or PDS — whatever you&rsquo;ve got.</p> 110 + ) } 111 + </form> 112 + ); 113 + }
+38
src/components/HandleStart.wiring.test.ts
··· 1 + /** 2 + * Source-level wiring guard for the landing handle input. The component can't be 3 + * rendered through vitest (jsdom is pinned for the WordPress block suites), so we pin 4 + * the wiring at the source level — the same strategy as src/pages/index.phase.test.ts. 5 + */ 6 + import { readFileSync } from 'node:fs'; 7 + import { dirname, join } from 'node:path'; 8 + import { fileURLToPath } from 'node:url'; 9 + import { describe, expect, it } from 'vitest'; 10 + 11 + // Resolve the sibling .tsx via a plain path join. `new URL( './HandleStart.tsx', 12 + // import.meta.url )` can't be used here: vite rewrites a .tsx module URL to the dev 13 + // server (http://localhost…), which fileURLToPath rejects. The .astro-reading guard in 14 + // src/pages/index.phase.test.ts avoids this because vite leaves .astro URLs as file://. 15 + const src = readFileSync( 16 + join( dirname( fileURLToPath( import.meta.url ) ), 'HandleStart.tsx' ), 17 + 'utf8' 18 + ); 19 + 20 + describe( 'HandleStart wiring', () => { 21 + it( 'looks the writer up via the public-AppView helper', () => { 22 + expect( src ).toMatch( /import \{ lookupActor.*\} from '\.\.\/lib\/landing\/actor-lookup'/ ); 23 + } ); 24 + 25 + it( 'debounces the lookup with a timer', () => { 26 + expect( src ).toMatch( /setTimeout\(/ ); 27 + expect( src ).toMatch( /clearTimeout\(/ ); 28 + } ); 29 + 30 + it( 'starts sign-in through the existing OAuth client', () => { 31 + expect( src ).toMatch( /createOAuthClient/ ); 32 + expect( src ).toMatch( /\.signIn\(\s*normalizeHandle\(/ ); 33 + } ); 34 + 35 + it( 'guards submission with isValidAccountInput so Start fails closed only on garbage', () => { 36 + expect( src ).toMatch( /isValidAccountInput\(/ ); 37 + } ); 38 + } );