A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import { useEffect, useRef, useState } from 'react';
2import { createOAuthClient } from '../lib/auth/oauth';
3import { isValidAccountInput, isValidHandleOrDid, normalizeHandle } from '../lib/auth/config';
4import { 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 */
12export 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 // Invalidate any in-flight lookup so a late response can't repopulate the card.
26 ++reqId.current;
27 setPreview( null );
28 setLooking( false );
29 return;
30 }
31 const id = ++reqId.current;
32 setLooking( true );
33 const timer = setTimeout( async () => {
34 const found = await lookupActor( trimmed );
35 if ( id === reqId.current ) {
36 setPreview( found );
37 setAvatarOk( true );
38 setLooking( false );
39 }
40 }, 350 );
41 return () => clearTimeout( timer );
42 }, [ value ] );
43
44 async function onSubmit( event: React.FormEvent ) {
45 event.preventDefault();
46 if ( ! isValidAccountInput( value ) ) {
47 setError( 'Enter a handle (alice.bsky.social), a DID, or a PDS URL.' );
48 return;
49 }
50 setError( null );
51 try {
52 const client = await createOAuthClient();
53 // Redirects to the authorization server; the promise never resolves.
54 await client.signIn( normalizeHandle( value ) );
55 } catch ( err ) {
56 setError( err instanceof Error ? err.message : String( err ) );
57 }
58 }
59
60 const name = preview?.displayName ?? preview?.handle ?? null;
61
62 return (
63 <form className="handlestart" onSubmit={ onSubmit }>
64 <div className="handlestart__row">
65 <div className="handlestart__field">
66 <span className="handlestart__at" aria-hidden="true">@</span>
67 <input
68 className="handlestart__input"
69 name="handle"
70 autoComplete="username"
71 autoCapitalize="none"
72 autoCorrect="off"
73 spellCheck={ false }
74 placeholder="you.bsky.social"
75 aria-label="Your handle, DID, or PDS URL"
76 value={ value }
77 onChange={ ( e ) => setValue( e.target.value ) }
78 />
79 { looking && <span className="handlestart__spinner" aria-hidden="true" /> }
80 </div>
81 <button className="handlestart__go" type="submit">Start →</button>
82 </div>
83
84 { preview && (
85 <div className="handlestart__card" aria-live="polite">
86 { preview.avatar && avatarOk ? (
87 <img
88 className="handlestart__avatar"
89 src={ preview.avatar }
90 alt=""
91 width={ 36 }
92 height={ 36 }
93 onError={ () => setAvatarOk( false ) }
94 />
95 ) : (
96 <span className="handlestart__avatar handlestart__avatar--fallback" aria-hidden="true">
97 { ( name ?? '?' ).charAt( 0 ).toUpperCase() }
98 </span>
99 ) }
100 <span className="handlestart__who">
101 <span className="handlestart__name">{ name }</span>
102 <span className="handlestart__handle">@{ preview.handle }</span>
103 </span>
104 <span className="handlestart__check" aria-hidden="true">✓</span>
105 </div>
106 ) }
107
108 { error ? (
109 <p className="handlestart__hint handlestart__hint--error" role="alert">{ error }</p>
110 ) : (
111 <p className="handlestart__hint">Your handle, DID, or PDS — whatever you’ve got.</p>
112 ) }
113 </form>
114 );
115}