A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import {
2 createContext,
3 useCallback,
4 useEffect,
5 useRef,
6 useState,
7 type ReactNode,
8} from 'react';
9import { Agent } from '@atproto/api';
10import type { BrowserOAuthClient, OAuthSession } from '@atproto/oauth-client-browser';
11import { createOAuthClient } from './oauth';
12import {
13 isValidAccountInput,
14 normalizeHandle,
15 getClientMode,
16 redirectUriForLocation,
17} from './config';
18import { resolvePdsUrl } from '../media/pds';
19import { fetchViewerProfile } from './profile';
20
21export type AuthStatus = 'loading' | 'signed-out' | 'signed-in' | 'error';
22
23export interface AuthContextValue {
24 status: AuthStatus;
25 agent: Agent | null;
26 did: string | null;
27 handle: string | null;
28 displayName: string | null;
29 avatar: string | null;
30 /** The writer's PDS endpoint (for getBlob URLs); null until resolved. */
31 pdsUrl: string | null;
32 error: string | null;
33 signIn: ( input: string ) => Promise< void >;
34 signOut: () => Promise< void >;
35}
36
37export const AuthContext = createContext< AuthContextValue | null >( null );
38
39export function AuthProvider( { children }: { children: ReactNode } ) {
40 const clientRef = useRef< BrowserOAuthClient | null >( null );
41 const [ status, setStatus ] = useState< AuthStatus >( 'loading' );
42 const [ agent, setAgent ] = useState< Agent | null >( null );
43 const [ did, setDid ] = useState< string | null >( null );
44 const [ handle, setHandle ] = useState< string | null >( null );
45 const [ displayName, setDisplayName ] = useState< string | null >( null );
46 const [ avatar, setAvatar ] = useState< string | null >( null );
47 const [ pdsUrl, setPdsUrl ] = useState< string | null >( null );
48 const [ error, setError ] = useState< string | null >( null );
49
50 const adoptSession = useCallback( async ( session: OAuthSession ) => {
51 const nextAgent = new Agent( session );
52 setAgent( nextAgent );
53 setDid( session.did );
54 setStatus( 'signed-in' );
55 setError( null );
56 const profile = await fetchViewerProfile( nextAgent, session.did );
57 setHandle( profile.handle );
58 setDisplayName( profile.displayName );
59 setAvatar( profile.avatar );
60 try {
61 setPdsUrl( await resolvePdsUrl( session.did ) );
62 } catch {
63 setPdsUrl( null ); // image upload stays disabled if the PDS can't be resolved
64 }
65 }, [] );
66
67 // Initialise once: process an OAuth callback or restore an existing session.
68 useEffect( () => {
69 let cancelled = false;
70
71 ( async () => {
72 try {
73 const client = await createOAuthClient();
74 if ( cancelled ) {
75 return;
76 }
77 clientRef.current = client;
78
79 const result = await client.init();
80 if ( cancelled ) {
81 return;
82 }
83 if ( result?.session ) {
84 await adoptSession( result.session );
85 } else {
86 setStatus( 'signed-out' );
87 }
88 } catch ( err ) {
89 if ( ! cancelled ) {
90 setError( err instanceof Error ? err.message : String( err ) );
91 setStatus( 'error' );
92 }
93 }
94 } )();
95
96 return () => {
97 cancelled = true;
98 };
99 }, [ adoptSession ] );
100
101 const signIn = useCallback( async ( input: string ) => {
102 const client = clientRef.current;
103 if ( ! client ) {
104 return;
105 }
106 if ( ! isValidAccountInput( input ) ) {
107 setError( 'Enter a handle (alice.bsky.social), a DID, or a PDS URL.' );
108 return;
109 }
110 setError( null );
111 try {
112 // Hosted (prod) registers several redirect URIs (one per OAuth-island route),
113 // and atproto defaults to the first — so without this, signing in from /write
114 // always returned the writer to /editor, losing the in-progress draft. Pin the
115 // round-trip to the page sign-in started from. In loopback (dev) the client's
116 // single redirect URI already equals the current page, so we pass nothing.
117 // atproto brands `redirect_uri` as a URL template type; the value is a registered
118 // app URL (and atproto re-validates it against the client metadata), so cast the
119 // options object to the `signIn` parameter type.
120 const options =
121 getClientMode( window.location.hostname ) === 'hosted'
122 ? ( {
123 redirect_uri: redirectUriForLocation(
124 window.location.origin,
125 window.location.pathname
126 ),
127 } as Parameters< BrowserOAuthClient[ 'signIn' ] >[ 1 ] )
128 : undefined;
129 // Redirects to the user's authorization server; the promise never resolves.
130 await client.signIn( normalizeHandle( input ), options );
131 } catch ( err ) {
132 setError( err instanceof Error ? err.message : String( err ) );
133 }
134 }, [] );
135
136 const signOut = useCallback( async () => {
137 const client = clientRef.current;
138 if ( client && did ) {
139 try {
140 await client.revoke( did );
141 } catch {
142 // best effort — fall through to local reset
143 }
144 }
145 setAgent( null );
146 setDid( null );
147 setHandle( null );
148 setDisplayName( null );
149 setAvatar( null );
150 setPdsUrl( null );
151 setStatus( 'signed-out' );
152 }, [ did ] );
153
154 return (
155 <AuthContext.Provider
156 value={ { status, agent, did, handle, displayName, avatar, pdsUrl, error, signIn, signOut } }
157 >
158 { children }
159 </AuthContext.Provider>
160 );
161}