diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 58a60232be..a24c3893b7 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -13,5 +13,6 @@ export type Gate = | 'suggested_feeds_interstitial' | 'suggested_follows_interstitial' | 'ungroup_follow_backs' + | 'use_oauth' | 'videos' | 'small_avi_thumb' diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 35b124b611..0edaa32f30 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -10,6 +10,12 @@ import { ComAtprotoServerCreateSession, ComAtprotoServerDescribeServer, } from '@atproto/api' +import { + AuthorizeOptions, + BrowserOAuthClient, + LoginContinuedInParentWindowError, + OAuthAgent, +} from '@atproto/oauth-client-browser' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -21,6 +27,7 @@ import {logger} from '#/logger' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' +import {useGate} from 'lib/statsig/statsig' import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -36,6 +43,106 @@ import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema +const client = new BrowserOAuthClient({ + plcDirectoryUrl: 'https://plc.directory', + handleResolver: 'https://bsky.social', +}) + +export function useOAuth(client: BrowserOAuthClient) { + const [agent, setAgent] = useState(null) + const [loading, setLoading] = useState(true) + + const clientRef = useRef() + React.useEffect(() => { + // In strict mode, we don't want to reinitialize the client if it's the same + if (clientRef.current === client) return + clientRef.current = client + + setLoading(true) + setAgent(null) + + client + .init() + .then(async r => { + if (clientRef.current !== client) return + + setAgent(r?.agent || null) + }) + .catch(err => { + console.error('Failed to init:', err) + + if (clientRef.current !== client) return + if (err instanceof LoginContinuedInParentWindowError) return + + setAgent(null) + }) + .finally(() => { + if (clientRef.current !== client) return + + setLoading(false) + }) + }, [client]) + + React.useEffect(() => { + if (!agent) return + + const clear = ({detail}: {detail: {sub: string}}) => { + if (detail.sub === agent.sub) { + setAgent(null) + } + } + + client.addEventListener('deleted', clear) + + return () => { + client.removeEventListener('deleted', clear) + } + }, [client, agent]) + + const signOut = React.useCallback(async () => { + if (!agent) return + + setAgent(null) + setLoading(true) + + try { + await agent.signOut() + } catch (err) { + console.error('Failed to clear credentials', err) + throw err + } finally { + setLoading(false) + } + }, [agent]) + + const signIn = React.useCallback( + async (input: string, options?: AuthorizeOptions) => { + // if (agent) return + + setLoading(true) + + try { + const agent = await client.signIn(input, options) + setAgent(agent) + } catch (err) { + console.error('Failed to login', err) + throw err + } finally { + setLoading(false) + } + }, + [client], + ) + + return { + agent, + loading, + signedIn: agent != null, + signIn, + signOut, + } +} + export const LoginForm = ({ error, serviceUrl, @@ -73,11 +180,20 @@ export const LoginForm = ({ const {setShowLoggedOut} = useLoggedOutViewControls() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() + const gate = useGate() + const oauthEnabled = __DEV__ || gate('use_oauth') + const oauth = useOAuth(client) + const [handle, setHandle] = React.useState('') + const onPressSelectService = React.useCallback(() => { Keyboard.dismiss() track('Signin:PressedSelectService') }, [track]) + const onPressNextOauth = async () => { + await oauth.signIn(handle) + } + const onPressNext = async () => { if (isProcessing) return Keyboard.dismiss() @@ -175,91 +291,127 @@ export const LoginForm = ({ return ( Sign in}> - - - Hosting provider - - - - - - Account - - - - - { - identifierValueRef.current = v - checkIsReady() - }} - onSubmitEditing={() => { - passwordRef.current?.focus() - }} - blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field - editable={!isProcessing} - accessibilityHint={_( - msg`Input the username or email address you used at signup`, - )} + {oauthEnabled ? ( + <> + + Handle + + + + + { + setHandle(v) + checkIsReady() + }} + onSubmitEditing={() => { + // @TODO submit + }} + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field + editable={!isProcessing} + accessibilityHint={_(msg`Input the handle for your account`)} + /> + + + + ) : ( + <> + + + Hosting provider + + - + + + + Account + + + + + { + identifierValueRef.current = v + checkIsReady() + }} + onSubmitEditing={() => { + passwordRef.current?.focus() + }} + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field + editable={!isProcessing} + accessibilityHint={_( + msg`Input the username or email address you used at signup`, + )} + /> + - - - { - passwordValueRef.current = v - checkIsReady() - }} - onSubmitEditing={onPressNext} - blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing - editable={!isProcessing} - accessibilityHint={_(msg`Input your password`)} - /> - - - - + + + { + passwordValueRef.current = v + checkIsReady() + }} + onSubmitEditing={onPressNext} + blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing + editable={!isProcessing} + accessibilityHint={_(msg`Input your password`)} + /> + + + + + + )} {isAuthFactorTokenNeeded && ( @@ -325,7 +477,7 @@ export const LoginForm = ({ Connecting... - ) : isReady ? ( + ) : isReady || oauthEnabled ? (