From 9ff248528bf44a42a65c10987395c9ba8a025602 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 7 Jul 2026 14:21:58 +0300 Subject: [PATCH] Automatically resolve hosting provider from handle (#11053) Co-authored-by: Claude Fable 5 Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey --- eslint-suppressions.json | 62 -- package.json | 1 + pnpm-lock.yaml | 3 + src/components/dialogs/ServerInput.tsx | 4 +- src/components/forms/FormError.tsx | 30 - src/components/forms/TextField.tsx | 4 + src/lib/strings/url-helpers.ts | 30 + src/screens/Login/ChooseAccountForm.tsx | 24 +- src/screens/Login/ForgotPasswordForm.tsx | 50 +- src/screens/Login/LoginForm.tsx | 666 +++++++++++++----- src/screens/Login/PasswordUpdatedForm.tsx | 10 +- src/screens/Login/SetNewPasswordForm.tsx | 55 +- .../components/AuthLayout/Header/index.tsx | 7 +- .../ConfirmHostingProviderDialog.tsx | 170 +++++ .../components/HostingProviderDialog.tsx | 262 +++++++ src/screens/Login/index.tsx | 54 +- src/screens/Signup/StepCaptcha/index.tsx | 4 +- src/screens/Signup/StepInfo/index.tsx | 7 +- src/screens/Signup/index.tsx | 219 +++--- src/state/queries/pds-detection.ts | 330 +++++++++ src/view/com/auth/LoggedOut.tsx | 7 +- src/view/com/auth/SplashScreen.tsx | 37 +- src/view/com/util/layouts/LoggedOutLayout.tsx | 19 +- 23 files changed, 1532 insertions(+), 523 deletions(-) delete mode 100644 src/components/forms/FormError.tsx create mode 100644 src/screens/Login/components/ConfirmHostingProviderDialog.tsx create mode 100644 src/screens/Login/components/HostingProviderDialog.tsx create mode 100644 src/state/queries/pds-detection.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 6fa740978b..3cdac1d101 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1121,69 +1121,7 @@ "count": 2 } }, - "src/screens/Login/ChooseAccountForm.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "@typescript-eslint/no-misused-promises": { - "count": 1 - } - }, - "src/screens/Login/ForgotPasswordForm.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "@typescript-eslint/no-misused-promises": { - "count": 1 - }, - "@typescript-eslint/no-unsafe-call": { - "count": 1 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 1 - } - }, - "src/screens/Login/LoginForm.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "@typescript-eslint/no-floating-promises": { - "count": 1 - }, - "@typescript-eslint/no-misused-promises": { - "count": 3 - }, - "@typescript-eslint/no-unsafe-call": { - "count": 4 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 4 - }, - "react-hooks/refs": { - "count": 1 - } - }, - "src/screens/Login/SetNewPasswordForm.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "@typescript-eslint/no-misused-promises": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-call": { - "count": 1 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 1 - } - }, "src/screens/Login/index.tsx": { - "@typescript-eslint/no-misused-promises": { - "count": 1 - }, - "react-hooks/purity": { - "count": 1 - }, "react-hooks/refs": { "count": 1 }, diff --git a/package.json b/package.json index 36b03ea251..8843628e44 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ }, "dependencies": { "@atproto/api": "0.20.25", + "@atproto/common-web": "0.5.3", "@atproto/syntax": "0.6.4", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b47a7bde03..eeb429173d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,9 @@ importers: '@atproto/api': specifier: 0.20.25 version: 0.20.25 + '@atproto/common-web': + specifier: 0.5.3 + version: 0.5.3 '@atproto/syntax': specifier: 0.6.4 version: 0.6.4 diff --git a/src/components/dialogs/ServerInput.tsx b/src/components/dialogs/ServerInput.tsx index 2a8c263aa6..2b6ddf19dd 100644 --- a/src/components/dialogs/ServerInput.tsx +++ b/src/components/dialogs/ServerInput.tsx @@ -131,11 +131,11 @@ function DialogInner({ style={web({maxWidth: 500})}> - Choose your account provider + Choose your hosting provider - - - - {error} - - - - ) -} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index ec116d64a4..df5e1d54dc 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -47,6 +47,10 @@ const Context = createContext<{ }) Context.displayName = 'TextFieldContext' +export function useTextFieldContext() { + return useContext(Context) +} + export type RootProps = React.PropsWithChildren< {isInvalid?: boolean} & TextStyleProp > diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 38d3d70813..4fb8034a51 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -8,6 +8,7 @@ import {startUriToStarterPackUri} from '#/lib/strings/starter-pack' import {logger} from '#/logger' export const BSKY_APP_HOST = 'https://bsky.app' +export const BSKY_HOSTING_ENDSWITH = '.host.bsky.network' const BSKY_TRUSTED_HOSTS = [ 'bsky\\.app', 'bsky\\.social', @@ -91,6 +92,35 @@ export function toBskyAppUrl(url: string): string { return new URL(url, BSKY_APP_HOST).toString() } +export function toNiceHostingUrl(url: string): string { + try { + const urlp = new URL(url) + if (urlp.host.endsWith(BSKY_HOSTING_ENDSWITH)) { + return 'Bluesky' + } + return urlp.host + } catch { + return url + } +} + +/** + * Whether the given service URL points at a Bluesky-operated PDS. True when the + * host is `bsky.social` (the {@link BSKY_SERVICE} host) or ends with + * `.host.bsky.network`. Returns false if the URL can't be parsed. + */ +export function isBlueskyHostedUrl(url: string): boolean { + try { + const {host} = new URL(url) + return ( + host === new URL(BSKY_SERVICE).host || + host.endsWith(BSKY_HOSTING_ENDSWITH) + ) + } catch { + return false + } +} + export function isBskyAppUrl(url: string): boolean { return url.startsWith('https://bsky.app/') } diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx index 8ce8cf4bc2..317a3bcdc3 100644 --- a/src/screens/Login/ChooseAccountForm.tsx +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -1,8 +1,6 @@ import {useCallback, useState} from 'react' import {View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {logger} from '#/logger' import {type SessionAccount, useSession, useSessionApi} from '#/state/session' @@ -24,7 +22,7 @@ export const ChooseAccountForm = ({ onPressBack: () => void }) => { const [pendingDid, setPendingDid] = useState(null) - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() const {currentAccount} = useSession() const {resumeSession} = useSessionApi() @@ -43,7 +41,7 @@ export const ChooseAccountForm = ({ } if (account.did === currentAccount?.did) { setShowLoggedOut(false) - Toast.show(_(msg`Already signed in as @${account.handle}`)) + Toast.show(l`Already signed in as @${account.handle}`) return } try { @@ -53,10 +51,10 @@ export const ChooseAccountForm = ({ logContext: 'ChooseAccountForm', withPassword: false, }) - Toast.show(_(msg`Signed in as @${account.handle}`)) - } catch (e: any) { + Toast.show(l`Signed in as @${account.handle}`) + } catch (err) { logger.warn('choose account: initSession failed', { - message: e instanceof Error ? e.message : 'Unknown error', + message: err instanceof Error ? err.message : String(err), }) // Move to login form. onSelectAccount(account) @@ -70,7 +68,7 @@ export const ChooseAccountForm = ({ pendingDid, onSelectAccount, setShowLoggedOut, - _, + l, ax, ], ) @@ -83,11 +81,11 @@ export const ChooseAccountForm = ({ {IS_WEB && ( - Sign in as... + Sign in as… )} void onSelect(account)} onSelectOther={() => onSelectAccount()} pendingDid={pendingDid} /> @@ -95,11 +93,11 @@ export const ChooseAccountForm = ({ {IS_WEB && ( diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx index 1cf3051129..79fdbc5d6a 100644 --- a/src/screens/Login/ForgotPasswordForm.tsx +++ b/src/screens/Login/ForgotPasswordForm.tsx @@ -1,17 +1,15 @@ import {useCallback, useState} from 'react' import {Keyboard, View} from 'react-native' import {type ComAtprotoServerDescribeServer} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import * as EmailValidator from 'email-validator' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {Agent} from '#/state/session/agent' import {atoms as a, useTheme, web} from '#/alf' +import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {FormError} from '#/components/forms/FormError' import {HostingProvider} from '#/components/forms/HostingProvider' import * as TextField from '#/components/forms/TextField' import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' @@ -42,7 +40,7 @@ export const ForgotPasswordForm = ({ const t = useTheme() const [isProcessing, setIsProcessing] = useState(false) const [email, setEmail] = useState('') - const {_} = useLingui() + const {t: l} = useLingui() const onPressSelectService = useCallback(() => { Keyboard.dismiss() @@ -50,7 +48,7 @@ export const ForgotPasswordForm = ({ const onPressNext = async () => { if (!EmailValidator.validate(email)) { - return setError(_(msg`Your email appears to be invalid.`)) + return setError(l`Your email appears to be invalid.`) } setError('') @@ -60,18 +58,15 @@ export const ForgotPasswordForm = ({ const agent = new Agent(null, {service: serviceUrl}) await agent.com.atproto.server.requestPasswordReset({email}) onEmailSent() - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to request password reset', {error: e}) + } catch (err) { + logger.warn('Failed to request password reset', {error: err}) setIsProcessing(false) - if (isNetworkError(e)) { + if (isNetworkError(err)) { setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), + l`Unable to contact your service. Please check your Internet connection.`, ) } else { - setError(cleanError(errMsg)) + setError(cleanError(err)) } } } @@ -98,7 +93,7 @@ export const ForgotPasswordForm = ({ - Enter the email you used to create your account. We'll send you a "reset code" so you can set a new password. - - - + {error && {error}} {IS_WEB && ( <> ) : ( - - + {!isAuthFactorTokenNeeded && ( + + )} {isAuthFactorTokenNeeded && ( @@ -278,10 +436,10 @@ export const LoginForm = ({ 2FA Confirmation - + void onPressNext()} editable={!isProcessing} - accessibilityHint={_( - msg`Input the code which has been emailed to you`, - )} + accessibilityHint={l`Input the code which has been emailed to you`} style={{ textTransform: authFactorToken === '' ? 'none' : 'uppercase', }} @@ -310,24 +466,70 @@ export const LoginForm = ({ )} - - - {IS_WEB && ( - + + {/* + * At most one error is visible at a time. The inline username error + * (under the field) wins; otherwise the resolution-failure error takes + * precedence over the generic form error. + */} + {!showUnresolvedError && + (showResolveError ? ( + + + + + + + We couldn’t verify your hosting provider. Check your + internet connection, or{' '} + serverInputControl.open())}> + set your hosting provider manually + + . + + + + + + ) : ( + error && ( + {error} + ) + ))} + + + {gtMobile && ( + <> + + + + serverInputControl.open()} + /> + + )} {!serviceDescription && error ? ( ) : !serviceDescription ? ( ) : ( )} + + {IS_NATIVE && ( + + + New to Bluesky?{' '} + onPressCreateAccount())}> + Sign up + + + + )} + + {!gtMobile && ( + serverInputControl.open()} + /> + )} ) } + +function RevealPasswordButton({ + active, + hasPassword, + onPress, +}: { + active: boolean + hasPassword: boolean + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const context = TextField.useTextFieldContext() + + const Icon = !active ? EyeSlashIcon : EyeIcon + + if (!hasPassword && !context.focused) return null + + return ( + + + + ) +} + +function HostingProviderIndicator({ + state, + onPress, +}: { + state: HostingProviderState + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const {gtMobile} = useBreakpoints() + + return ( + + ) +} diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx index 754d201d91..29db3d0d83 100644 --- a/src/screens/Login/PasswordUpdatedForm.tsx +++ b/src/screens/Login/PasswordUpdatedForm.tsx @@ -1,7 +1,5 @@ import {View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {atoms as a, useBreakpoints, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' @@ -13,7 +11,7 @@ export const PasswordUpdatedForm = ({ }: { onPressNext: () => void }) => { - const {_} = useLingui() + const {t: l} = useLingui() const {gtMobile} = useBreakpoints() return ( @@ -29,8 +27,8 @@ export const PasswordUpdatedForm = ({ + + + + + + ) +} diff --git a/src/screens/Login/components/HostingProviderDialog.tsx b/src/screens/Login/components/HostingProviderDialog.tsx new file mode 100644 index 0000000000..a1316700ee --- /dev/null +++ b/src/screens/Login/components/HostingProviderDialog.tsx @@ -0,0 +1,262 @@ +import {useCallback, useImperativeHandle, useRef, useState} from 'react' +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import * as persisted from '#/state/persisted' +import {atoms as a, useTheme, web} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as SegmentedControl from '#/components/forms/SegmentedControl' +import * as TextField from '#/components/forms/TextField' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' +import {InlineLinkText} from '#/components/Link' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' + +type SegmentedControlOptions = 'automatic' | 'manual' + +/** + * Login-specific fork of the server-input dialog. Instead of picking between + * "Bluesky" and a custom URL, the user chooses between "Automatic" (the PDS is + * autodetected from the typed identifier) and "Manual" (an explicit PDS URL). + * + * Selecting Automatic clears any manual override; selecting Manual with a + * non-empty address sets it. A Manual selection with an empty address is + * treated as Automatic. + */ +export function HostingProviderDialog({ + control, + currentOverride, + isEmail, + onSelectManual, + onSelectAutomatic, +}: { + control: Dialog.DialogOuterProps['control'] + /** + * The PDS URL currently forced by a manual override, or `null` when + * detection is automatic. Determines which tab the dialog opens on. + */ + currentOverride: string | null + /** + * Whether the typed identifier is an email address. Emails cannot resolve + * to a PDS, so the Automatic tab explains that detection is unavailable and + * the default service will be used instead. + */ + isEmail: boolean + onSelectManual: (url: string) => void + onSelectAutomatic: () => void +}) { + const ax = useAnalytics() + const formRef = useRef(null) + + // persist these options between dialog open/close + const [fixedOption, setFixedOption] = useState( + currentOverride ? 'manual' : 'automatic', + ) + const [previousCustomAddress, setPreviousCustomAddress] = useState( + currentOverride ?? '', + ) + + const onClose = useCallback(() => { + const result = formRef.current?.getFormState() + const nextOverride = result ?? null + if (nextOverride) { + onSelectManual(nextOverride) + setPreviousCustomAddress(nextOverride) + } else { + onSelectAutomatic() + } + ax.metric('signin:hostingProviderPressed', { + hostingProviderDidChange: nextOverride !== currentOverride, + }) + }, [ax, onSelectManual, onSelectAutomatic, currentOverride]) + + return ( + + + + + ) +} + +type DialogInnerRef = {getFormState: () => string | null} + +function DialogInner({ + formRef, + fixedOption, + setFixedOption, + initialCustomAddress, + isEmail, +}: { + formRef: React.Ref + fixedOption: SegmentedControlOptions + setFixedOption: (opt: SegmentedControlOptions) => void + initialCustomAddress: string + isEmail: boolean +}) { + const control = Dialog.useDialogContext() + const {t: l} = useLingui() + const t = useTheme() + const [customAddress, setCustomAddress] = useState(initialCustomAddress) + const [pdsAddressHistory, setPdsAddressHistory] = useState( + persisted.get('pdsAddressHistory') || [], + ) + + useImperativeHandle( + formRef, + () => ({ + getFormState: () => { + if (fixedOption !== 'manual') { + return null + } + let url = customAddress.trim().toLowerCase() + if (!url) { + return null + } + if (!url.startsWith('http://') && !url.startsWith('https://')) { + if (url === 'localhost' || url.startsWith('localhost:')) { + url = `http://${url}` + } else { + url = `https://${url}` + } + } + + if (!pdsAddressHistory.includes(url)) { + const newHistory = [url, ...pdsAddressHistory.slice(0, 4)] + setPdsAddressHistory(newHistory) + void persisted.write('pdsAddressHistory', newHistory) + } + + return url + }, + }), + [customAddress, fixedOption, pdsAddressHistory], + ) + + return ( + + + + Choose your hosting provider + + + + + {l`Automatic`} + + + + {l`Manual`} + + + + {fixedOption === 'automatic' && ( + + + {isEmail ? ( + + Your hosting provider can’t be detected from an email address, + so the default Bluesky service will be used. Enter your + username instead, or set your provider manually. + + ) : ( + + Your hosting provider is detected automatically from the + username you enter. + + )} + + + )} + + {fixedOption === 'manual' && ( + + + Server address + + + + + + {pdsAddressHistory.length > 0 && ( + + {pdsAddressHistory.map(uri => ( + + ))} + + )} + + )} + + + + + Bluesky is an open network where you can choose your hosting + provider. If you're a developer, you can host your own server. + {' '} + + Learn more. + + + + + + + + + ) +} diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx index c1640b9598..bcb590025f 100644 --- a/src/screens/Login/index.tsx +++ b/src/screens/Login/index.tsx @@ -1,8 +1,7 @@ import {useEffect, useRef, useState} from 'react' -import {KeyboardAvoidingView} from 'react-native' +import {KeyboardAvoidingView} from 'react-native-keyboard-controller' import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {DEFAULT_SERVICE} from '#/lib/constants' import {logger} from '#/logger' @@ -37,10 +36,16 @@ const OrderedForms = [ Forms.PasswordUpdated, ] as const -export const Login = ({onPressBack}: {onPressBack: () => void}) => { - const {_} = useLingui() +export const Login = ({ + onPressBack, + onPressCreateAccount, +}: { + onPressBack: () => void + onPressCreateAccount: () => void +}) => { + const {t: l} = useLingui() const failedAttemptCountRef = useRef(0) - const startTimeRef = useRef(Date.now()) + const [startTime] = useState(() => Date.now()) const {accounts} = useSession() const {requestedAccountSwitchTo} = useLoggedOutView() @@ -92,9 +97,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { useEffect(() => { if (serviceError) { setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), + l`Unable to contact your service. Please check your Internet connection.`, ) logger.warn(`Failed to fetch service description for ${serviceUrl}`, { error: String(serviceError), @@ -103,7 +106,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { } else { setError('') } - }, [serviceError, serviceUrl, _]) + }, [serviceError, serviceUrl, l, ax]) const onPressForgotPassword = () => { gotoForm(Forms.ForgotPassword) @@ -121,7 +124,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { const onAttemptSuccess = () => { ax.metric('signin:success', { isUsingCustomProvider: serviceUrl !== DEFAULT_SERVICE, - timeTakenSeconds: Math.round((Date.now() - startTimeRef.current) / 1000), + timeTakenSeconds: Math.round((Date.now() - startTime) / 1000), failedAttemptsCount: failedAttemptCountRef.current, }) } @@ -137,8 +140,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { switch (currentForm) { case Forms.Login: - title = _(msg`Sign in`) - description = _(msg`Enter your username and password`) + title = l`Sign in` + description = l`Enter your username and password` goBack = () => accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack() content = ( @@ -153,13 +156,14 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { setServiceUrl={setServiceUrl} onPressBack={goBack} onPressForgotPassword={onPressForgotPassword} - onPressRetryConnect={refetchService} + onPressRetryConnect={() => void refetchService()} + onPressCreateAccount={onPressCreateAccount} /> ) break case Forms.ChooseAccount: - title = _(msg`Sign in`) - description = _(msg`Select from an existing account`) + title = l`Sign in` + description = l`Select from an existing account` goBack = handlePressBack content = ( void}) => { ) break case Forms.ForgotPassword: - title = _(msg`Forgot Password`) - description = _(msg`Let's get your password reset!`) + title = l`Forgot Password` + description = l`Let's get your password reset!` goBack = () => gotoForm(Forms.Login) content = ( void}) => { ) break case Forms.SetNewPassword: - title = _(msg`Forgot Password`) - description = _(msg`Let's get your password reset!`) + title = l`Forgot Password` + description = l`Let's get your password reset!` goBack = () => gotoForm(Forms.ForgotPassword) content = ( void}) => { ) break case Forms.PasswordUpdated: - title = _(msg`Password updated`) - description = _(msg`You can now sign in with your new password.`) + title = l`Password updated` + description = l`You can now sign in with your new password.` content = ( gotoForm(Forms.Login)} /> ) @@ -215,7 +219,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { + style={a.flex_1} + automaticOffset> @@ -229,7 +234,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { + direction={screenTransitionDirection} + style={a.flex_1}> {content} diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx index 626c5a1232..2c5d88f2c8 100644 --- a/src/screens/Signup/StepCaptcha/index.tsx +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -10,7 +10,7 @@ import {logger} from '#/logger' import {useSignupContext} from '#/screens/Signup/state' import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' import {atoms as a, useTheme} from '#/alf' -import {FormError} from '#/components/forms/FormError' +import {Admonition} from '#/components/Admonition' import {useAnalytics} from '#/analytics' import {GCP_PROJECT_ID, IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' import {BackNextButtons} from '../BackNextButtons' @@ -168,7 +168,7 @@ function StepCaptchaInner({ )} - + {state.error && {state.error}} - + {state.error && ( + + {state.error} + + )} void}) { return ( - - - {showStarterPackCard && - bsky.dangerousIsType( - starterPack.record, - AppBskyGraphStarterpack.isRecord, - ) ? ( - - - - {starterPack.record.name} - - - {starterPack.feeds?.length ? ( - - You'll follow the suggested users and feeds once you - finish creating your account! - - ) : ( - - You'll follow the suggested users once you finish - creating your account! - - )} - - - - ) : null} - - - - + + + + {showStarterPackCard && + bsky.dangerousIsType( + starterPack.record, + AppBskyGraphStarterpack.isRecord, + ) ? ( + + - - Step {state.activeStep + 1} of{' '} - {state.serviceDescription && - !state.serviceDescription.phoneVerificationRequired - ? '2' - : '3'} - + style={[a.font_semi_bold, a.text_xl, {color: 'white'}]}> + {starterPack.record.name} - - {state.activeStep === SignupStep.INFO ? ( - Your account - ) : state.activeStep === SignupStep.HANDLE ? ( - Choose your username + + {starterPack.feeds?.length ? ( + + You'll follow the suggested users and feeds once you + finish creating your account! + ) : ( - Complete the challenge + + You'll follow the suggested users once you finish + creating your account! + )} - - - {state.activeStep === SignupStep.INFO ? ( - void refetch()} - /> - ) : state.activeStep === SignupStep.HANDLE ? ( - - ) : ( - - )} - - - + + + ) : null} + + - - + - Having trouble?{' '} - - Contact support - + + Step {state.activeStep + 1} of{' '} + {state.serviceDescription && + !state.serviceDescription.phoneVerificationRequired + ? '2' + : '3'} + + + + {state.activeStep === SignupStep.INFO ? ( + Your account + ) : state.activeStep === SignupStep.HANDLE ? ( + Choose your username + ) : ( + Complete the challenge + )} + + {state.activeStep === SignupStep.INFO ? ( + void refetch()} + /> + ) : state.activeStep === SignupStep.HANDLE ? ( + + ) : ( + + )} + + + + + + + + Having trouble?{' '} + + Contact support + + + + - - - - - + + + + + ) diff --git a/src/state/queries/pds-detection.ts b/src/state/queries/pds-detection.ts new file mode 100644 index 0000000000..697963aa93 --- /dev/null +++ b/src/state/queries/pds-detection.ts @@ -0,0 +1,330 @@ +import {useState} from 'react' +import {type DidDocument, getPdsEndpoint} from '@atproto/common-web' +import {useQuery, useQueryClient} from '@tanstack/react-query' + +import {DEFAULT_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' +import {isNetworkError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {STALE} from '#/state/queries' +import {Agent} from '#/state/session/agent' + +const RQKEY_ROOT = 'pds-detection' +export const RQKEY = (identifier: string) => [RQKEY_ROOT, identifier] + +/** + * Normalize a login identifier for detection: lowercase, trim, and strip a + * single leading `@`. Handles are often typed as `@alice.example.com`; without + * stripping the `@` the identifier looks like an email and detection is + * disabled. A real email (`a@b.com`) has no leading `@`, so it still contains + * an `@` after normalization and classifies as an email correctly. + */ +function normalizeIdentifier(identifier: string): string { + return identifier.trim().toLowerCase().replace(/^@/, '') +} + +/** + * Per-request timeout for identity/PDS resolution network calls. Without it a + * hanging plc.directory / did:web `.well-known` fetch (or handle resolution) + * could leave the sign-in button spinning indefinitely. + */ +const RESOLVE_TIMEOUT = 20e3 + +/** + * Run a resolution network op with a per-request timeout. `run` receives an + * `AbortSignal` that fires after `RESOLVE_TIMEOUT`, so callers that support + * cancellation (fetch, the XRPC client) abort the in-flight request. A timeout + * is surfaced as a network error so the caller fails the login rather than + * silently falling back to the default service. + */ +async function withResolveTimeout( + run: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT) + try { + return await run(controller.signal) + } catch (err) { + if (controller.signal.aborted) { + throw new Error('Network request failed: resolution timed out') + } + throw err + } finally { + clearTimeout(timer) + } +} + +/** + * Whether a non-ok HTTP status from an identity fetch is server-side or + * transient (5xx or 429) rather than a genuine "not found / invalid" (other + * 4xx). Transient statuses must not be treated as "identity doesn't exist", + * since that would silently fall back to the default service. + */ +function isTransientHttpStatus(status: number): boolean { + return status >= 500 || status === 429 +} + +/** + * Resolve a DID document without a session. + * + * `com.atproto.identity.resolveIdentity` would give us the DID doc in a single + * call, but it requires auth on the entryway and is not implemented on the + * appview, so it is unusable here. Instead we resolve the DID doc directly: + * `did:plc` via the PLC directory, `did:web` via its `.well-known` endpoint. + * + * Returns `null` for a genuine "not found / invalid" response (a 4xx or an + * unsupported DID method). Throws a network error for transient server-side + * failures (5xx, 429) so the caller fails the login rather than silently + * submitting the password to the default service during, e.g., a plc.directory + * blip. + */ +async function resolveDidDoc( + did: string, + signal?: AbortSignal, +): Promise { + if (did.startsWith('did:plc:')) { + const res = await fetch(`https://plc.directory/${did}`, {signal}) + if (!res.ok) { + logger.debug('pds-detection: plc.directory returned non-ok status', { + did, + status: res.status, + }) + if (isTransientHttpStatus(res.status)) { + throw new Error( + `Network request failed: plc.directory returned ${res.status}`, + ) + } + return null + } + return (await res.json()) as DidDocument + } + if (did.startsWith('did:web:')) { + const domain = did.slice('did:web:'.length) + /* + * did:web method-specific ids are domains; a `:` would indicate a path + * component, which the network does not support. Reject those rather than + * building a malformed URL. + */ + if (domain.includes(':')) return null + const res = await fetch( + `https://${decodeURIComponent(domain)}/.well-known/did.json`, + {signal}, + ) + if (!res.ok) { + logger.debug( + 'pds-detection: did:web .well-known returned non-ok status', + { + did, + status: res.status, + }, + ) + if (isTransientHttpStatus(res.status)) { + throw new Error( + `Network request failed: did:web .well-known returned ${res.status}`, + ) + } + return null + } + return (await res.json()) as DidDocument + } + logger.debug('pds-detection: unsupported DID method', {did}) + return null +} + +/** + * Resolve the identity behind a given identifier (handle or DID). + * + * Returns the resolved DID together with the PDS URL declared by its DID + * document (verbatim). `pdsUrl` is `null` when the DID resolved but its + * document declares no PDS endpoint. Returns `null` altogether when the + * identifier itself cannot be resolved (unknown handle, broken identity, + * unsupported DID method). + * + * Rethrows only on genuine network errors, so a "not found" during typing + * stays quiet. + */ +export async function resolvePdsForIdentifier( + identifier: string, +): Promise<{did: string; pdsUrl: string | null} | null> { + const norm = normalizeIdentifier(identifier) + const agent = new Agent(null, {service: PUBLIC_BSKY_SERVICE}) + try { + let did: string + if (norm.startsWith('did:')) { + did = norm + } else { + const res = await withResolveTimeout(signal => + agent.resolveHandle({handle: norm}, {signal}), + ) + did = res.data.did + } + logger.debug('pds-detection: resolved identifier to DID', { + identifier: norm, + did, + }) + const doc = await withResolveTimeout(signal => resolveDidDoc(did, signal)) + logger.debug('pds-detection: resolved DID doc', { + did, + foundDoc: !!doc, + }) + if (!doc) return null + const pds = getPdsEndpoint(doc) + logger.debug('pds-detection: got PDS endpoint', { + did, + pds: pds ?? null, + }) + return {did, pdsUrl: pds ?? null} + } catch (err) { + logger.debug('pds-detection: resolution failed', { + identifier: norm, + error: String(err), + isNetworkError: isNetworkError(err), + }) + if (isNetworkError(err)) throw err + return null + } +} + +/** + * The detection lifecycle for a login identifier, derived from the debounced + * resolution query plus any manual override. + */ +export type HostingProviderState = + /** Empty, or not yet a plausible handle (e.g. a bare username). */ + | {status: 'idle'} + /** The identifier is an email address, so PDS detection is skipped. */ + | {status: 'email'} + /** A resolution query is in flight for the current identifier. */ + | {status: 'detecting'} + /** Resolved to a PDS endpoint. */ + | {status: 'detected'; pdsUrl: string} + /** + * The handle genuinely did not resolve (unknown handle, broken identity). + * This is the only state that should admonish the user about typos. + */ + | {status: 'unresolved'} + /** + * Resolution failed for a network/transient reason (offline, plc.directory + * 5xx). Distinct from `unresolved` because it is not evidence the handle is + * invalid, so the UI must not suggest a typo. Pressing "Sign in" surfaces the + * connectivity error via `resolveService` re-throwing. + */ + | {status: 'error'} + /** The user manually selected a provider. */ + | {status: 'overridden'; pdsUrl: string} + +/** + * Autodetects the hosting provider (PDS) for a login identifier as the user + * types, with a manual override escape hatch. + * + * The effective `service` is `override ?? detected ?? defaultService`. + * `resolveService` awaits any in-flight detection against the current + * (non-debounced) identifier so that pressing "Sign in" mid-detection waits + * for resolution and then continues. It resolves to `{service, did}`: the + * service to log in against, plus the identifier's resolved DID (`null` when + * no DID was resolved - manual override, email, or bare username). It falls + * back to `defaultService` for anything that legitimately can't resolve a PDS + * (emails, bare usernames, unknown handles) but rethrows genuine network + * errors, so a flaky connection fails the login instead of silently + * submitting to the default server. + */ +export function useHostingProvider({ + identifier, + defaultService = DEFAULT_SERVICE, +}: { + identifier: string + defaultService?: string +}): { + state: HostingProviderState + service: string + override: (url: string) => void + clearOverride: () => void + resolveService: ( + currentIdentifier: string, + ) => Promise<{service: string; did: string | null}> +} { + const queryClient = useQueryClient() + const [override, setOverride] = useState(null) + + const normalized = normalizeIdentifier(identifier) + const isEmail = normalized.includes('@') + const isPlausibleHandle = + !!normalized && + !isEmail && + (normalized.includes('.') || normalized.startsWith('did:')) + + const debounced = useDebouncedValue(normalized, 500) + const enabled = isPlausibleHandle && normalized === debounced + + const query = useQuery({ + enabled, + queryKey: RQKEY(debounced), + queryFn: () => resolvePdsForIdentifier(debounced), + staleTime: STALE.MINUTES.FIVE, + }) + + let state: HostingProviderState + if (override != null) { + state = {status: 'overridden', pdsUrl: override} + } else if (isEmail) { + state = {status: 'email'} + } else if (!isPlausibleHandle) { + state = {status: 'idle'} + } else if (normalized !== debounced) { + /* + * The identifier changed but the debounce hasn't caught up, so the query is + * still keyed on the old value. Report 'detecting' rather than the stale + * query state, which would otherwise show the previous handle's PDS as + * 'detected'. + */ + state = {status: 'detecting'} + } else if (query.isPending || query.isFetching) { + state = {status: 'detecting'} + } else if (query.isError && isNetworkError(query.error)) { + /* + * A network/transient failure is not evidence the handle is invalid, so + * report 'error' instead of 'unresolved' to avoid a misleading typo hint. + */ + state = {status: 'error'} + } else if (query.isError || query.data == null || query.data.pdsUrl == null) { + state = {status: 'unresolved'} + } else { + state = {status: 'detected', pdsUrl: query.data.pdsUrl} + } + + const service = + override ?? (state.status === 'detected' ? state.pdsUrl : defaultService) + + return { + state, + service, + override: (url: string) => setOverride(url), + clearOverride: () => setOverride(null), + resolveService: async (currentIdentifier: string) => { + if (override != null) return {service: override, did: null} + const norm = normalizeIdentifier(currentIdentifier) + // Emails and bare usernames can't resolve a PDS on their own. + if (norm.includes('@')) return {service: defaultService, did: null} + if (!norm.includes('.') && !norm.startsWith('did:')) { + return {service: defaultService, did: null} + } + /* + * `resolvePdsForIdentifier` only throws on genuine network errors; + * anything unresolvable (unknown handle, broken identity) resolves to + * `null`, which we treat as the default service. Network errors are left + * to propagate so the caller can fail the login rather than silently + * submit the password to the wrong server. + */ + const resolved = await queryClient.ensureQueryData({ + queryKey: RQKEY(norm), + queryFn: () => resolvePdsForIdentifier(norm), + staleTime: STALE.MINUTES.FIVE, + }) + // The DID is known even when its doc declares no PDS endpoint. + return { + service: resolved?.pdsUrl ?? defaultService, + did: resolved?.did ?? null, + } + }, + } +} diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 25f6819f44..da0038a9cc 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -160,7 +160,12 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { /> ) : undefined} {screenState === ScreenState.S_Login ? ( - + { + setScreenState(ScreenState.S_CreateAccount) + }} + /> ) : undefined} {screenState === ScreenState.S_CreateAccount ? ( diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 8f83155360..54867aedc7 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -80,7 +80,18 @@ export const SplashScreen = ({ - { + onPressCreateAccount() + playHaptic('Light') + }} + label={_(msg`Create new account`)} + accessibilityHint={_( + msg`Opens flow to create a new Bluesky account`, + )} + size="large" + color={isDarkMode ? 'secondary_inverted' : 'secondary'} style={[ t.atoms.shadow_md, { @@ -91,23 +102,10 @@ export const SplashScreen = ({ }, }, ]}> - - + + Create account + +