Automatically resolve hosting provider from handle (#11053)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2026-07-07 14:21:58 +03:00
committed by GitHub
parent 640662d57c
commit 9ff248528b
23 changed files with 1532 additions and 523 deletions
-62
View File
@@ -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
},
+1
View File
@@ -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",
+3
View File
@@ -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
+2 -2
View File
@@ -131,11 +131,11 @@ function DialogInner({
style={web({maxWidth: 500})}>
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Choose your account provider</Trans>
<Trans>Choose your hosting provider</Trans>
</Text>
<SegmentedControl.Root
type="tabs"
label={_(msg`Account provider`)}
label={_(msg`Hosting provider`)}
value={fixedOption}
onChange={setFixedOption}>
<SegmentedControl.Item
-30
View File
@@ -1,30 +0,0 @@
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Text} from '#/components/Typography'
export function FormError({error}: {error?: string}) {
const t = useTheme()
if (!error) return null
return (
<View
style={[
{backgroundColor: t.palette.negative_400},
a.flex_row,
a.rounded_sm,
a.p_md,
a.gap_sm,
]}>
<Warning fill={t.palette.white} size="md" />
<View style={[a.flex_1]}>
<Text
style={[{color: t.palette.white}, a.font_semi_bold, a.leading_snug]}>
{error}
</Text>
</View>
</View>
)
}
+4
View File
@@ -47,6 +47,10 @@ const Context = createContext<{
})
Context.displayName = 'TextFieldContext'
export function useTextFieldContext() {
return useContext(Context)
}
export type RootProps = React.PropsWithChildren<
{isInvalid?: boolean} & TextStyleProp
>
+30
View File
@@ -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/')
}
+11 -13
View File
@@ -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<string | null>(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 = ({
<View>
{IS_WEB && (
<TextField.LabelText>
<Trans>Sign in as...</Trans>
<Trans>Sign in as</Trans>
</TextField.LabelText>
)}
<AccountList
onSelectAccount={onSelect}
onSelectAccount={account => void onSelect(account)}
onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid}
/>
@@ -95,11 +93,11 @@ export const ChooseAccountForm = ({
{IS_WEB && (
<View style={[a.flex_row]}>
<Button
label={_(msg`Back`)}
label={l`Back`}
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>{_(msg`Back`)}</ButtonText>
<ButtonText>{l`Back`}</ButtonText>
</Button>
<View style={[a.flex_1]} />
</View>
+22 -28
View File
@@ -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<boolean>(false)
const [email, setEmail] = useState<string>('')
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 = ({
<TextField.Icon icon={At} />
<TextField.Input
testID="forgotPasswordEmail"
label={_(msg`Enter your email address`)}
label={l`Enter your email address`}
autoCapitalize="none"
autoFocus
autoCorrect={false}
@@ -106,25 +101,22 @@ export const ForgotPasswordForm = ({
value={email}
onChangeText={setEmail}
editable={!isProcessing}
accessibilityHint={_(msg`Sets email for password reset`)}
accessibilityHint={l`Sets email for password reset`}
/>
</TextField.Root>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>
Enter the email you used to create your account. We'll send you a
"reset code" so you can set a new password.
</Trans>
</Text>
<FormError error={error} />
{error && <Admonition type="error">{error}</Admonition>}
<View style={[web([a.flex_row, a.align_center]), a.pt_md]}>
{IS_WEB && (
<>
<Button
label={_(msg`Back`)}
label={l`Back`}
color="secondary"
size="large"
onPress={onPressBack}>
@@ -137,20 +129,22 @@ export const ForgotPasswordForm = ({
)}
{!serviceDescription ? (
<Button
label={_(msg`Connecting to service...`)}
label={l`Connecting to service...`}
size="large"
color="secondary"
disabled>
<ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
<ButtonText>
<Trans>Connecting</Trans>
</ButtonText>
</Button>
) : (
<Button
label={_(msg`Next`)}
accessibilityHint={_(msg`Navigates to the next screen`)}
label={l`Next`}
accessibilityHint={l`Navigates to the next screen`}
color="primary"
size="large"
onPress={onPressNext}
onPress={() => void onPressNext()}
disabled={isProcessing}>
<ButtonText>
<Trans>Next</Trans>
@@ -171,8 +165,8 @@ export const ForgotPasswordForm = ({
<Button
testID="skipSendEmailButton"
onPress={onEmailSent}
label={_(msg`Go to next`)}
accessibilityHint={_(msg`Navigates to the next screen`)}
label={l`Go to next`}
accessibilityHint={l`Navigates to the next screen`}
size="large"
variant="ghost"
color="secondary">
+483 -183
View File
@@ -1,31 +1,42 @@
import {useCallback, useRef, useState} from 'react'
import {useRef, useState} from 'react'
import {Keyboard, type TextInput, View} from 'react-native'
import {
ComAtprotoServerCreateSession,
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 {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles'
import {isBlueskyHostedUrl, toNiceHostingUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useSessionApi} from '#/state/session'
import {
type HostingProviderState,
useHostingProvider,
} from '#/state/queries/pds-detection'
import {useSession, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {atoms as a, ios, useTheme, web} from '#/alf'
import {atoms as a, native, tokens, useBreakpoints, useTheme} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
import {useDialogControl} from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron'
import {Envelope_Stroke2_Corner0_Rounded as EmailIcon} from '#/components/icons/Envelope'
import {Eye_Stroke2_Corner0_Rounded as EyeIcon} from '#/components/icons/Eye'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as TicketIcon} from '#/components/icons/Ticket'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_IOS, IS_WEB} from '#/env'
import {IS_IOS, IS_NATIVE} from '#/env'
import {ConfirmHostingProviderDialog} from './components/ConfirmHostingProviderDialog'
import {HostingProviderDialog} from './components/HostingProviderDialog'
import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -42,6 +53,7 @@ export const LoginForm = ({
onPressForgotPassword,
onAttemptSuccess,
onAttemptFailed,
onPressCreateAccount,
}: {
error: string
serviceUrl: string
@@ -54,6 +66,7 @@ export const LoginForm = ({
onPressForgotPassword: () => void
onAttemptSuccess: () => void
onAttemptFailed: () => void
onPressCreateAccount: () => void
}) => {
const t = useTheme()
const [isProcessing, setIsProcessing] = useState(false)
@@ -61,72 +74,61 @@ export const LoginForm = ({
'none' | 'identifier' | 'password' | '2fa'
>('none')
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const identifierValueRef = useRef<string>(initialHandle || '')
const passwordValueRef = useRef<string>('')
const [showResolveError, setShowResolveError] = useState(false)
const identifierValueRef = useRef(initialHandle || '')
const passwordValueRef = useRef('')
const [identifier, setIdentifier] = useState(initialHandle || '')
const [identifierFocused, setIdentifierFocused] = useState(false)
const [authFactorToken, setAuthFactorToken] = useState('')
const identifierRef = useRef<TextInput>(null)
const passwordRef = useRef<TextInput>(null)
const hasFocusedOnce = useRef<boolean>(false)
const {_} = useLingui()
const hasFocusedOnce = useRef(false)
const [hasPassword, setHasPassword] = useState(false)
const [revealPassword, setRevealPassword] = useState(false)
const {t: l} = useLingui()
const {login} = useSessionApi()
const {accounts} = useSession()
const requestNotificationsPermission = useRequestNotificationsPermission()
const {setShowLoggedOut} = useLoggedOutViewControls()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const serverInputControl = useDialogControl()
const confirmHostingProviderControl = useDialogControl()
const [pendingLogin, setPendingLogin] = useState<{
service: string
fullIdent: string
passwordLength: number
} | null>(null)
const hostingProvider = useHostingProvider({
identifier,
defaultService: serviceUrl,
})
const {gtMobile} = useBreakpoints()
const onPressSelectService = useCallback(() => {
Keyboard.dismiss()
}, [])
/*
* Surface an inline error on the username field only once detection has
* settled on an unresolvable identifier and the user has moved on from the
* field. Hidden while focused so we don't nag mid-type, and it clears
* automatically when the identifier resolves or an override is set (both
* move `state.status` away from 'unresolved').
*/
const showUnresolvedError =
hostingProvider.state.status === 'unresolved' && !identifierFocused
const onPressNext = async () => {
if (isProcessing) return
Keyboard.dismiss()
setError('')
setErrorField('none')
const identifier = identifierValueRef.current.toLowerCase().trim()
/**
* Performs the actual login attempt against a resolved service. Reads the
* password and 2FA token from the current form state, and manages
* `setIsProcessing` itself: it stays processing on success (the app
* transitions away) and clears it on any failure.
*/
const attemptLogin = async (service: string, fullIdent: string) => {
const password = passwordValueRef.current
if (!identifier) {
setError(_(msg`Please enter your username`))
setErrorField('identifier')
return
}
if (!password) {
setError(_(msg`Please enter your password`))
setErrorField('password')
return
}
setIsProcessing(true)
try {
// try to guess the handle if the user just gave their own username
let fullIdent = identifier
if (
!identifier.includes('@') && // not an email
!identifier.includes('.') && // not a domain
serviceDescription &&
serviceDescription.availableUserDomains.length > 0
) {
let matched = false
for (const domain of serviceDescription.availableUserDomains) {
if (fullIdent.endsWith(domain)) {
matched = true
}
}
if (!matched) {
fullIdent = createFullHandle(
identifier,
serviceDescription.availableUserDomains[0],
)
}
}
// TODO remove double login
await login(
{
service: serviceUrl,
service,
identifier: fullIdent,
password,
authFactorToken: authFactorToken.trim(),
@@ -136,12 +138,13 @@ export const LoginForm = ({
onAttemptSuccess()
setShowLoggedOut(false)
setHasCheckedForStarterPack(true)
requestNotificationsPermission('Login')
} catch (e: any) {
const errMsg = e.toString()
void requestNotificationsPermission('Login')
} catch (err) {
const errMsg = String(err)
setIsProcessing(false)
if (
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
err instanceof
ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
) {
setIsAuthFactorTokenNeeded(true)
} else {
@@ -150,7 +153,7 @@ export const LoginForm = ({
logger.debug('Failed to login due to invalid 2fa token', {
error: errMsg,
})
setError(_(msg`Invalid 2FA confirmation code.`))
setError(l`Invalid 2FA confirmation code.`)
setErrorField('2fa')
} else if (
errMsg.includes('Authentication Required') ||
@@ -159,13 +162,11 @@ export const LoginForm = ({
logger.debug('Failed to login due to invalid credentials', {
error: errMsg,
})
setError(_(msg`Incorrect username or password`))
} else if (isNetworkError(e)) {
setError(l`Incorrect username or password`)
} else if (isNetworkError(err)) {
logger.warn('Failed to login due to network error', {error: errMsg})
setError(
_(
msg`Unable to contact your service. Please check your Internet connection.`,
),
l`Unable to contact your service. Please check your Internet connection.`,
)
} else {
logger.warn('Failed to login', {error: errMsg})
@@ -175,102 +176,259 @@ export const LoginForm = ({
}
}
const onPressNext = async () => {
if (isProcessing) return
Keyboard.dismiss()
setError('')
setErrorField('none')
setShowResolveError(false)
const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current
if (!identifier) {
setError(l`Please enter your username`)
setErrorField('identifier')
return
}
if (!password) {
setError(l`Please enter your password`)
setErrorField('password')
return
}
setIsProcessing(true)
// try to guess the handle if the user just gave their own username
let fullIdent = identifier
if (
!identifier.includes('@') && // not an email
!identifier.includes('.') && // not a domain
!identifier.startsWith('did:') && // not a DID
serviceDescription &&
serviceDescription.availableUserDomains.length > 0
) {
let matched = false
for (const domain of serviceDescription.availableUserDomains) {
if (fullIdent.endsWith(domain)) {
matched = true
}
}
if (!matched) {
fullIdent = createFullHandle(
identifier,
serviceDescription.availableUserDomains[0],
)
}
}
/*
* Await autodetection against the current identifier before logging in.
* If detection is still in flight this waits for it (bypassing the
* debounce); otherwise it resolves near-instantly from cache. Falls back
* to the default service on anything unresolvable, but a network error
* throws - in that case we must NOT log in, since we can't be sure which
* server to send the password to.
*/
let service: string
let did: string | null
try {
;({service, did} = await hostingProvider.resolveService(identifier))
} catch (err) {
logger.debug('Failed to resolve hosting provider', {error: String(err)})
setIsProcessing(false)
setShowResolveError(true)
return
}
/*
* If detection landed on a non-Bluesky server, confirm before sending the
* password, to guard against typosquatted handles capturing credentials. A
* manual override is skipped: choosing a server by hand is explicit user
* consent, so only auto-detected hosts need the guard. An identity the
* user has signed into on this device before is also trusted: a
* typosquatted handle would resolve to the attacker's different DID, so
* DID membership in the account list is the correct skip condition
* (handles and hosts are not stable keys - service URLs drift and pdsUrl
* is often unset).
*/
const isKnownAccount =
did != null && accounts.some(account => account.did === did)
const needsConfirmation =
!isBlueskyHostedUrl(service) &&
hostingProvider.state.status !== 'overridden' &&
!isKnownAccount
if (needsConfirmation) {
setIsProcessing(false)
setPendingLogin({service, fullIdent, passwordLength: password.length})
confirmHostingProviderControl.open()
return
}
await attemptLogin(service, fullIdent)
}
return (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
<HostingProviderDialog
control={serverInputControl}
currentOverride={
hostingProvider.state.status === 'overridden'
? hostingProvider.state.pdsUrl
: null
}
isEmail={hostingProvider.state.status === 'email'}
onSelectManual={url => {
hostingProvider.override(url)
setServiceUrl(url)
}}
onSelectAutomatic={() => {
hostingProvider.clearOverride()
setServiceUrl(DEFAULT_SERVICE)
}}
/>
<ConfirmHostingProviderDialog
control={confirmHostingProviderControl}
host={toNiceHostingUrl(pendingLogin?.service ?? '')}
identifier={pendingLogin?.fullIdent ?? ''}
passwordLength={pendingLogin?.passwordLength ?? 0}
onConfirm={() => {
if (pendingLogin) {
void attemptLogin(pendingLogin.service, pendingLogin.fullIdent)
}
}}
/>
<View>
<TextField.LabelText>
<Trans>Hosting provider</Trans>
<Trans>Username or email</Trans>
</TextField.LabelText>
<HostingProvider
serviceUrl={serviceUrl}
onSelectServiceUrl={setServiceUrl}
onOpenDialog={onPressSelectService}
/>
<TextField.Root
isInvalid={errorField === 'identifier' || showUnresolvedError}>
<TextField.Icon
icon={hostingProvider.state.status === 'email' ? EmailIcon : AtIcon}
/>
<TextField.Input
testID="loginUsernameInput"
inputRef={identifierRef}
label={l`Username or email address`}
placeholder={null}
autoCapitalize="none"
autoFocus={!IS_IOS && !initialHandle}
autoCorrect={false}
autoComplete="username"
returnKeyType="next"
textContentType="username"
defaultValue={initialHandle || ''}
onChangeText={v => {
identifierValueRef.current = v
setIdentifier(v)
if (errorField) setErrorField('none')
if (showResolveError) setShowResolveError(false)
}}
onFocus={() => setIdentifierFocused(true)}
onBlur={() => setIdentifierFocused(false)}
onSubmitEditing={() => {
passwordRef.current?.focus()
}}
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
editable={!isProcessing}
accessibilityHint={l`Enter the username or email address you used when you created your account`}
/>
</TextField.Root>
{showUnresolvedError && (
<Text
style={[
a.text_sm,
a.leading_snug,
a.mt_sm,
{color: t.palette.negative_500},
]}>
<Trans>
We couldn't find an account with that username. Please check that
you've typed it correctly, or{' '}
<InlineLinkText
label={l`set your hosting provider manually`}
style={[a.text_sm, a.leading_snug]}
{...createStaticClick(() => serverInputControl.open())}>
set your hosting provider manually
</InlineLinkText>
.
</Trans>
</Text>
)}
</View>
<View>
<TextField.LabelText>
<Trans>Account</Trans>
<Trans>Password</Trans>
</TextField.LabelText>
<View style={[a.gap_sm]}>
<TextField.Root isInvalid={errorField === 'identifier'}>
<TextField.Icon icon={At} />
<TextField.Input
testID="loginUsernameInput"
inputRef={identifierRef}
label={_(msg`Username or email address`)}
autoCapitalize="none"
autoFocus={!IS_IOS}
autoCorrect={false}
autoComplete="username"
returnKeyType="next"
textContentType="username"
defaultValue={initialHandle || ''}
onChangeText={v => {
identifierValueRef.current = v
if (errorField) setErrorField('none')
}}
onSubmitEditing={() => {
passwordRef.current?.focus()
}}
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
editable={!isProcessing}
accessibilityHint={_(
msg`Enter the username or email address you used when you created your account`,
)}
/>
</TextField.Root>
<TextField.Root isInvalid={errorField === 'password'}>
<TextField.Icon icon={LockIcon} />
<TextField.Input
testID="loginPasswordInput"
inputRef={passwordRef}
label={l`Password`}
placeholder={null}
autoCapitalize="none"
autoFocus={!IS_IOS && !!initialHandle}
autoCorrect={false}
autoComplete="current-password"
returnKeyType="done"
enablesReturnKeyAutomatically={true}
secureTextEntry={!revealPassword}
onChangeText={v => {
passwordValueRef.current = v
if (errorField) setErrorField('none')
setHasPassword(!!v)
}}
onSubmitEditing={() => void 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={l`Enter your password`}
onLayout={
IS_IOS
? () => {
if (hasFocusedOnce.current) return
hasFocusedOnce.current = true
// kinda dumb, but if we use `autoFocus` to focus an
// input, it happens before the password input gets
// rendered. this breaks the password autofill on iOS (it
// only does the username part). delaying it until both
// inputs are rendered fixes the autofill. when a handle is
// prefilled we focus the password field directly so the
// user can go straight to typing it -sfn
if (initialHandle) {
passwordRef.current?.focus()
} else {
identifierRef.current?.focus()
}
}
: undefined
}
hitSlop={{...HITSLOP_20, right: 0}}
/>
<RevealPasswordButton
active={revealPassword}
hasPassword={hasPassword}
onPress={() => setRevealPassword(r => !r)}
/>
</TextField.Root>
<TextField.Root isInvalid={errorField === 'password'}>
<TextField.Icon icon={Lock} />
<TextField.Input
testID="loginPasswordInput"
inputRef={passwordRef}
label={_(msg`Password`)}
autoCapitalize="none"
autoCorrect={false}
autoComplete="current-password"
returnKeyType="done"
enablesReturnKeyAutomatically={true}
secureTextEntry={true}
clearButtonMode="while-editing"
onChangeText={v => {
passwordValueRef.current = v
if (errorField) setErrorField('none')
}}
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`Enter your password`)}
onLayout={ios(() => {
if (hasFocusedOnce.current) return
hasFocusedOnce.current = true
// kinda dumb, but if we use `autoFocus` to focus
// the username input, it happens before the password
// input gets rendered. this breaks the password autofill
// on iOS (it only does the username part). delaying
// it until both inputs are rendered fixes the autofill -sfn
identifierRef.current?.focus()
})}
/>
<Button
testID="forgotPasswordButton"
onPress={onPressForgotPassword}
label={_(msg`Forgot password?`)}
accessibilityHint={_(msg`Opens password reset form`)}
variant="solid"
color="secondary"
style={[
a.rounded_sm,
// t.atoms.bg_contrast_100,
{marginLeft: 'auto', left: 6, padding: 6},
a.z_10,
]}>
<ButtonText>
<Trans>Forgot?</Trans>
</ButtonText>
</Button>
</TextField.Root>
</View>
{!isAuthFactorTokenNeeded && (
<Button
label={l`Forgot password?`}
accessibilityHint={l`Reset your password by sending a code to your email`}
style={[a.mt_md, a.self_start]}
hoverStyle={{opacity: 0.5}}
hitSlop={HITSLOP_10}
onPress={onPressForgotPassword}>
<ButtonText style={[t.atoms.text_contrast_medium]}>
<Trans>Forgot password?</Trans>
</ButtonText>
</Button>
)}
</View>
{isAuthFactorTokenNeeded && (
<View>
@@ -278,10 +436,10 @@ export const LoginForm = ({
<Trans>2FA Confirmation</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={errorField === '2fa'}>
<TextField.Icon icon={Ticket} />
<TextField.Icon icon={TicketIcon} />
<TextField.Input
testID="loginAuthFactorTokenInput"
label={_(msg`Confirmation code`)}
label={l`Confirmation code`}
autoCapitalize="none"
autoFocus
autoCorrect={false}
@@ -293,11 +451,9 @@ export const LoginForm = ({
setAuthFactorToken(text)
if (errorField) setErrorField('none')
}}
onSubmitEditing={onPressNext}
onSubmitEditing={() => 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 = ({
</Text>
</View>
)}
<FormError error={error} />
<View style={[a.pt_md, web([a.justify_between, a.flex_row])]}>
{IS_WEB && (
<Button
label={_(msg`Back`)}
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
{/*
* 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 ? (
<Admonition.Outer type="error">
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>
We couldnt verify your hosting provider. Check your
internet connection, or{' '}
<InlineLinkText
label={l`Set your hosting provider manually`}
style={[a.text_sm, a.leading_snug]}
{...createStaticClick(() => serverInputControl.open())}>
set your hosting provider manually
</InlineLinkText>
.
</Trans>
</Admonition.Text>
</Admonition.Content>
</Admonition.Row>
</Admonition.Outer>
) : (
error && (
<Admonition.Admonition type="error">{error}</Admonition.Admonition>
)
))}
<View
style={[
a.pt_md,
gtMobile && [a.justify_between, a.flex_row, a.gap_sm],
]}>
{gtMobile && (
<>
<Button
label={l`Back`}
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={[a.flex_shrink, a.justify_center, a.ml_auto]}>
<HostingProviderIndicator
state={hostingProvider.state}
onPress={() => serverInputControl.open()}
/>
</View>
</>
)}
{!serviceDescription && error ? (
<Button
testID="loginRetryButton"
label={_(msg`Retry`)}
accessibilityHint={_(msg`Retries signing in`)}
label={l`Retry`}
accessibilityHint={l`Retries signing in`}
color="primary_subtle"
size="large"
onPress={onPressRetryConnect}>
@@ -337,21 +539,23 @@ export const LoginForm = ({
</Button>
) : !serviceDescription ? (
<Button
label={_(msg`Connecting to service...`)}
label={l`Connecting to service`}
size="large"
color="secondary"
disabled>
<ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
<ButtonText>
<Trans>Connecting</Trans>
</ButtonText>
</Button>
) : (
<Button
testID="loginNextButton"
label={_(msg`Sign in`)}
accessibilityHint={_(msg`Navigates to the next screen`)}
label={l`Sign in`}
accessibilityHint={l`Navigates to the next screen`}
color="primary"
size="large"
onPress={onPressNext}>
onPress={() => void onPressNext()}>
<ButtonText>
<Trans>Sign in</Trans>
</ButtonText>
@@ -359,6 +563,102 @@ export const LoginForm = ({
</Button>
)}
</View>
{IS_NATIVE && (
<Text style={[a.text_md, native([a.text_center, a.mx_auto]), a.mt_sm]}>
<Trans>
New to Bluesky?{' '}
<InlineLinkText
label={l`Sign up`}
style={[a.text_md, native(a.text_center)]}
{...createStaticClick(() => onPressCreateAccount())}>
Sign up
</InlineLinkText>
</Trans>
</Text>
)}
{!gtMobile && (
<HostingProviderIndicator
state={hostingProvider.state}
onPress={() => serverInputControl.open()}
/>
)}
</FormContainer>
)
}
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 (
<View style={[a.z_10, a.pl_sm, {marginRight: tokens.space.xs * -1}]}>
<Button
testID="showPasswordButton"
onPress={onPress}
label={active ? l`Hide password` : l`Reveal password`}
color="secondary"
size="small"
shape="round"
style={[a.bg_transparent]}
hitSlop={tokens.space.sm}>
<Icon
size="md"
style={[
context.focused ? t.atoms.text : t.atoms.text_contrast_medium,
]}
/>
</Button>
</View>
)
}
function HostingProviderIndicator({
state,
onPress,
}: {
state: HostingProviderState
onPress: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const {gtMobile} = useBreakpoints()
return (
<Button
label={l`Change hosting provider`}
accessibilityHint={l`Opens a dialog to change the hosting provider you sign in to`}
style={[!gtMobile && [a.mt_auto, a.mb_sm, a.self_center]]}
size="small"
color="secondary"
variant="ghost"
onPress={onPress}>
<ButtonText
style={[t.atoms.text_contrast_medium, a.font_normal]}
numberOfLines={1}>
{state.status === 'detected' || state.status === 'overridden' ? (
<Trans>Hosting provider: {toNiceHostingUrl(state.pdsUrl)}</Trans>
) : state.status === 'email' ? (
<Trans>Hosting provider: Bluesky</Trans>
) : (
<Trans>Hosting provider</Trans>
)}
</ButtonText>
<TinyChevronIcon width={8} style={[t.atoms.text_contrast_medium]} />
</Button>
)
}
+4 -6
View File
@@ -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 = ({
<View style={web([a.flex_row, a.justify_center])}>
<Button
onPress={onPressNext}
label={_(msg`Close alert`)}
accessibilityHint={_(msg`Closes password update alert`)}
label={l`Close alert`}
accessibilityHint={l`Closes password update alert`}
color="primary"
size="large">
<ButtonText>
+20 -35
View File
@@ -1,16 +1,14 @@
import {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 {cleanError, isNetworkError} from '#/lib/strings/errors'
import {checkAndFormatResetCode} from '#/lib/strings/password'
import {logger} from '#/logger'
import {Agent} from '#/state/session/agent'
import {atoms as a, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import * as TextField from '#/components/forms/TextField'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
@@ -33,7 +31,7 @@ export const SetNewPasswordForm = ({
onPressBack: () => void
onPasswordSet: () => void
}) => {
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
@@ -47,9 +45,7 @@ export const SetNewPasswordForm = ({
if (!formattedCode) {
setError(
_(
msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
),
l`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
)
ax.metric('signin:passwordResetFailure', {})
return
@@ -57,7 +53,7 @@ export const SetNewPasswordForm = ({
// TODO Better password strength check
if (!password) {
setError(_(msg`Please enter a password.`))
setError(l`Please enter a password.`)
return
}
@@ -72,19 +68,16 @@ export const SetNewPasswordForm = ({
})
onPasswordSet()
ax.metric('signin:passwordResetSuccess', {})
} catch (e: any) {
const errMsg = e.toString()
logger.warn('Failed to set new password', {error: e})
} catch (err) {
logger.warn('Failed to set new password', {error: err})
ax.metric('signin:passwordResetFailure', {})
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))
}
}
}
@@ -93,9 +86,7 @@ export const SetNewPasswordForm = ({
const formattedCode = checkAndFormatResetCode(resetCode)
if (!formattedCode) {
setError(
_(
msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
),
l`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
)
return
}
@@ -112,7 +103,6 @@ export const SetNewPasswordForm = ({
then enter your new password.
</Trans>
</Text>
<View>
<TextField.LabelText>
<Trans>Reset code</Trans>
@@ -121,7 +111,7 @@ export const SetNewPasswordForm = ({
<TextField.Icon icon={Ticket} />
<TextField.Input
testID="resetCodeInput"
label={_(msg`Looks like XXXXX-XXXXX`)}
label={l`Looks like XXXXX-XXXXX`}
autoCapitalize="none"
autoFocus={true}
autoCorrect={false}
@@ -131,13 +121,10 @@ export const SetNewPasswordForm = ({
onFocus={() => setError('')}
onBlur={onBlur}
editable={!isProcessing}
accessibilityHint={_(
msg`Input code sent to your email for password reset`,
)}
accessibilityHint={l`Input code sent to your email for password reset`}
/>
</TextField.Root>
</View>
<View>
<TextField.LabelText>
<Trans>New password</Trans>
@@ -146,7 +133,7 @@ export const SetNewPasswordForm = ({
<TextField.Icon icon={Lock} />
<TextField.Input
testID="newPasswordInput"
label={_(msg`Enter a password`)}
label={l`Enter a password`}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
@@ -156,20 +143,18 @@ export const SetNewPasswordForm = ({
clearButtonMode="while-editing"
value={password}
onChangeText={setPassword}
onSubmitEditing={onPressNext}
onSubmitEditing={() => void onPressNext()}
editable={!isProcessing}
accessibilityHint={_(msg`Input new password`)}
accessibilityHint={l`Input new password`}
/>
</TextField.Root>
</View>
<FormError error={error} />
{error && <Admonition type="error">{error}</Admonition>}
<View style={[web([a.flex_row, a.align_center]), a.pt_lg]}>
{IS_WEB && (
<>
<Button
label={_(msg`Back`)}
label={l`Back`}
variant="solid"
color="secondary"
size="large"
@@ -183,10 +168,10 @@ export const SetNewPasswordForm = ({
)}
<Button
label={_(msg`Next`)}
label={l`Next`}
color="primary"
size="large"
onPress={onPressNext}
onPress={() => void onPressNext()}
disabled={isProcessing}>
<ButtonText>
<Trans>Next</Trans>
@@ -1,7 +1,6 @@
import {useContext} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {Logomark} from '#/view/icons/Logomark'
@@ -60,7 +59,7 @@ export function Logo() {
}
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const {t: l} = useLingui()
const navigation = useContext(AuthLayoutNavigationContext)
const onPressBack = (evt: GestureResponderEvent) => {
@@ -72,7 +71,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
return (
<Slot>
<Button
label={_(msg`Go back`)}
label={l`Go back`}
onPress={onPressBack}
size="small"
variant="ghost"
@@ -0,0 +1,170 @@
import {Fragment} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key'
import {Person_Stroke2_Corner0_Rounded as PersonIcon} from '#/components/icons/Person'
import {Text} from '#/components/Typography'
/**
* Confirmation gate shown before signing in to an auto-detected third-party
* hosting provider. When a typed handle resolves to a non-Bluesky PDS, this
* dialog exists to prevent typosquatting credential capture: it makes the user
* explicitly acknowledge that their password will be sent to that server before
* we send it, rather than silently trusting whatever host detection returned.
*/
export function ConfirmHostingProviderDialog({
control,
host,
identifier,
passwordLength,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
/** The display host of the resolved PDS, e.g. `example.com`. */
host: string
/** The full handle (or DID) being signed in. */
identifier: string
/**
* The length of the typed password, used to render a masked placeholder in
* the summary card. The password itself is deliberately never passed in.
*/
passwordLength: number
onConfirm: () => void
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner
host={host}
identifier={identifier}
passwordLength={passwordLength}
onConfirm={onConfirm}
/>
</Dialog.Outer>
)
}
function DialogInner({
host,
identifier,
passwordLength,
onConfirm,
}: {
host: string
identifier: string
passwordLength: number
onConfirm: () => void
}) {
const control = Dialog.useDialogContext()
const {t: l} = useLingui()
const t = useTheme()
return (
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"
style={web([{maxWidth: 400, borderRadius: 36}])}>
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text
nativeID="dialog-title"
style={[a.text_2xl, a.font_bold, a.pr_5xl]}>
<Trans>Everything look right?</Trans>
</Text>
<Text nativeID="dialog-description" style={[a.text_md, a.leading_snug]}>
<Trans>
Your username and password will be shared with{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>{host}</Text>
. If you dont recognize this provider, double-check your username.
</Trans>
</Text>
<View
style={[
a.rounded_md,
a.border,
t.atoms.border_contrast_medium,
a.overflow_hidden,
]}>
{[
{
type: 'host',
icon: GlobeIcon,
value: host,
},
{
type: 'identifier',
icon: PersonIcon,
value: identifier,
},
{
type: 'password',
icon: KeyIcon,
value: '•'.repeat(passwordLength),
},
].map((c, i) => (
<Fragment key={c.type}>
{i !== 0 && (
<View style={[a.border_t, t.atoms.border_contrast_medium]} />
)}
<View
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.px_md,
t.atoms.bg_contrast_25,
{paddingVertical: 10},
]}>
<c.icon
size="sm"
style={[t.atoms.text_contrast_low, native(a.mt_2xs)]}
/>
<Text
numberOfLines={1}
style={[t.atoms.text_contrast_high, a.flex_1, a.text_sm]}>
{c.value}
</Text>
</View>
</Fragment>
))}
</View>
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
This is just a one-time security check, since we haven't seen this
account on this device before.
</Trans>
</Text>
<Button
color="primary"
size="large"
onPress={() => control.close(() => onConfirm())}
label={l`Continue`}
accessibilityHint={l`Continue signing in`}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
</Button>
<Button
color="secondary"
size="large"
onPress={() => control.close()}
label={l`Go back`}
accessibilityHint={l`Cancels signing in so you can check your username`}>
<ButtonText>
<Trans>Go back</Trans>
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -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<DialogInnerRef>(null)
// persist these options between dialog open/close
const [fixedOption, setFixedOption] = useState<SegmentedControlOptions>(
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 (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner
formRef={formRef}
fixedOption={fixedOption}
setFixedOption={setFixedOption}
initialCustomAddress={previousCustomAddress}
isEmail={isEmail}
/>
</Dialog.Outer>
)
}
type DialogInnerRef = {getFormState: () => string | null}
function DialogInner({
formRef,
fixedOption,
setFixedOption,
initialCustomAddress,
isEmail,
}: {
formRef: React.Ref<DialogInnerRef>
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<string[]>(
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 (
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"
style={web([{maxWidth: 400, borderRadius: 36}])}>
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text
nativeID="dialog-title"
style={[a.text_2xl, a.font_bold, a.pr_5xl]}>
<Trans>Choose your hosting provider</Trans>
</Text>
<SegmentedControl.Root
type="tabs"
label={l`Hosting provider`}
value={fixedOption}
onChange={setFixedOption}>
<SegmentedControl.Item
testID="automaticSelectBtn"
value="automatic"
label={l`Automatic`}>
<SegmentedControl.ItemText>
{l`Automatic`}
</SegmentedControl.ItemText>
</SegmentedControl.Item>
<SegmentedControl.Item
testID="manualSelectBtn"
value="manual"
label={l`Manual`}>
<SegmentedControl.ItemText>{l`Manual`}</SegmentedControl.ItemText>
</SegmentedControl.Item>
</SegmentedControl.Root>
{fixedOption === 'automatic' && (
<View role="tabpanel">
<Admonition type="tip">
{isEmail ? (
<Trans>
Your hosting provider cant be detected from an email address,
so the default Bluesky service will be used. Enter your
username instead, or set your provider manually.
</Trans>
) : (
<Trans>
Your hosting provider is detected automatically from the
username you enter.
</Trans>
)}
</Admonition>
</View>
)}
{fixedOption === 'manual' && (
<View role="tabpanel">
<TextField.LabelText nativeID="address-input-label">
<Trans>Server address</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Globe} />
<Dialog.Input
testID="customServerTextInput"
value={customAddress}
onChangeText={setCustomAddress}
label="my-server.com"
accessibilityLabelledBy="address-input-label"
autoCapitalize="none"
keyboardType="url"
/>
</TextField.Root>
{pdsAddressHistory.length > 0 && (
<View style={[a.flex_row, a.flex_wrap, a.mt_xs]}>
{pdsAddressHistory.map(uri => (
<Button
key={uri}
variant="ghost"
color="primary"
label={uri}
style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]}
onPress={() => setCustomAddress(uri)}>
<ButtonText>{uri}</ButtonText>
</Button>
))}
</View>
)}
</View>
)}
<View style={[a.py_xs]}>
<Text
nativeID="dialog-description"
style={[t.atoms.text_contrast_medium, a.text_sm, a.leading_snug]}>
<Trans>
Bluesky is an open network where you can choose your hosting
provider. If you're a developer, you can host your own server.
</Trans>{' '}
<InlineLinkText
label={l`Learn more about self hosting your PDS.`}
to="https://atproto.com/guides/self-hosting">
<Trans>Learn more.</Trans>
</InlineLinkText>
</Text>
</View>
<Button
testID="doneBtn"
color="primary"
size="large"
onPress={() => control.close()}
label={l`Done`}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
+30 -24
View File
@@ -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 = (
<ChooseAccountForm
@@ -169,8 +173,8 @@ export const Login = ({onPressBack}: {onPressBack: () => 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 = (
<ForgotPasswordForm
@@ -185,8 +189,8 @@ export const Login = ({onPressBack}: {onPressBack: () => 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 = (
<SetNewPasswordForm
@@ -199,8 +203,8 @@ export const Login = ({onPressBack}: {onPressBack: () => 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 = (
<PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} />
)
@@ -215,7 +219,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
<KeyboardAvoidingView
testID="signIn"
behavior="padding"
style={a.flex_1}>
style={a.flex_1}
automaticOffset>
<AuthLayout.Header.Outer>
<AuthLayout.Header.BackButton />
<AuthLayout.Header.Content />
@@ -229,7 +234,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
<LayoutAnimationConfig skipEntering>
<ScreenTransition
key={currentForm}
direction={screenTransitionDirection}>
direction={screenTransitionDirection}
style={a.flex_1}>
{content}
</ScreenTransition>
</LayoutAnimationConfig>
+2 -2
View File
@@ -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({
<ActivityIndicator size="large" />
)}
</View>
<FormError error={state.error} />
{state.error && <Admonition type="error">{state.error}</Admonition>}
</View>
<BackNextButtons
hideNext
+5 -2
View File
@@ -14,7 +14,6 @@ import * as Dialog from '#/components/Dialog'
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
import * as DateField from '#/components/forms/DateField'
import {type DateFieldRef} from '#/components/forms/DateField/types'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
@@ -172,7 +171,11 @@ export function StepInfo({
return (
<>
<View style={[a.gap_md, a.pt_lg]}>
<FormError error={state.error} />
{state.error && (
<Admonition.Admonition type="error">
{state.error}
</Admonition.Admonition>
)}
<HostingProvider
minimal
serviceUrl={state.serviceUrl}
+115 -104
View File
@@ -1,6 +1,7 @@
import {useEffect, useReducer, useState} from 'react'
import {AppState, type AppStateStatus, View} from 'react-native'
import ReactNativeDeviceAttest from 'react-native-device-attest'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
import {AppBskyGraphStarterpack} from '@atproto/api'
import {tokens} from '@bsky.app/alf'
@@ -131,125 +132,135 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
return (
<Animated.View exiting={native(FadeIn.duration(90))} style={a.flex_1}>
<SignupContext.Provider value={{state, dispatch}}>
<LoggedOutLayout
leadin=""
title={l`Create account`}
description={l`Were so excited to have you join us!`}
scrollable>
<View testID="createAccount" style={a.flex_1}>
{showStarterPackCard &&
bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
starterPack.record,
AppBskyGraphStarterpack.isRecord,
) ? (
<Animated.View entering={!isFetchedAtMount ? FadeIn : undefined}>
<LinearGradientBackground
style={[a.mx_lg, a.p_lg, a.gap_sm, a.rounded_sm]}>
<Text style={[a.font_semi_bold, a.text_xl, {color: 'white'}]}>
{starterPack.record.name}
</Text>
<Text style={[{color: 'white'}]}>
{starterPack.feeds?.length ? (
<Trans>
You'll follow the suggested users and feeds once you
finish creating your account!
</Trans>
) : (
<Trans>
You'll follow the suggested users once you finish
creating your account!
</Trans>
)}
</Text>
</LinearGradientBackground>
</Animated.View>
) : null}
<LayoutAnimationConfig skipEntering>
<ScreenTransition
key={state.activeStep}
direction={state.screenTransitionDirection}>
<View
style={[
a.flex_1,
a.px_xl,
a.pt_2xl,
!gtMobile && {paddingBottom: 100},
]}>
<View style={[a.gap_sm, a.pb_3xl]}>
<KeyboardAvoidingView
behavior="padding"
style={a.flex_1}
automaticOffset>
<LoggedOutLayout
leadin=""
title={l`Create account`}
description={l`Were so excited to have you join us!`}
scrollable>
<View testID="createAccount" style={a.flex_1}>
{showStarterPackCard &&
bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
starterPack.record,
AppBskyGraphStarterpack.isRecord,
) ? (
<Animated.View
entering={!isFetchedAtMount ? FadeIn : undefined}>
<LinearGradientBackground
style={[a.mx_lg, a.p_lg, a.gap_sm, a.rounded_sm]}>
<Text
style={[a.font_semi_bold, t.atoms.text_contrast_medium]}>
<Trans>
Step {state.activeStep + 1} of{' '}
{state.serviceDescription &&
!state.serviceDescription.phoneVerificationRequired
? '2'
: '3'}
</Trans>
style={[a.font_semi_bold, a.text_xl, {color: 'white'}]}>
{starterPack.record.name}
</Text>
<Text style={[a.text_3xl, a.font_semi_bold]}>
{state.activeStep === SignupStep.INFO ? (
<Trans>Your account</Trans>
) : state.activeStep === SignupStep.HANDLE ? (
<Trans>Choose your username</Trans>
<Text style={[{color: 'white'}]}>
{starterPack.feeds?.length ? (
<Trans>
You'll follow the suggested users and feeds once you
finish creating your account!
</Trans>
) : (
<Trans>Complete the challenge</Trans>
<Trans>
You'll follow the suggested users once you finish
creating your account!
</Trans>
)}
</Text>
</View>
{state.activeStep === SignupStep.INFO ? (
<StepInfo
onPressBack={onPressBack}
isLoadingStarterPack={
isFetchingStarterPack && !isErrorStarterPack
}
isServerError={isError}
refetchServer={() => void refetch()}
/>
) : state.activeStep === SignupStep.HANDLE ? (
<StepHandle />
) : (
<StepCaptcha />
)}
<Divider />
</LinearGradientBackground>
</Animated.View>
) : null}
<LayoutAnimationConfig skipEntering>
<ScreenTransition
key={state.activeStep}
direction={state.screenTransitionDirection}>
<View
style={[
a.w_full,
a.py_lg,
a.flex_row,
a.gap_md,
a.align_center,
a.flex_1,
a.px_xl,
a.pt_2xl,
!gtMobile && {paddingBottom: 100},
]}>
<AppLanguageDropdown />
<View
style={
gtMobile
? [a.flex_1, a.flex, a.flex_row, a.justify_end]
: []
}>
<View style={[a.gap_sm, a.pb_3xl]}>
<Text
style={[
a.font_semi_bold,
t.atoms.text_contrast_medium,
!gtMobile && a.text_md,
{paddingInline: tokens.space.sm},
]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLinkText
label={l`Contact support`}
to={FEEDBACK_FORM_URL({email: state.email})}
style={[!gtMobile && a.text_md]}>
<Trans>Contact support</Trans>
</InlineLinkText>
<Trans>
Step {state.activeStep + 1} of{' '}
{state.serviceDescription &&
!state.serviceDescription.phoneVerificationRequired
? '2'
: '3'}
</Trans>
</Text>
<Text style={[a.text_3xl, a.font_semi_bold]}>
{state.activeStep === SignupStep.INFO ? (
<Trans>Your account</Trans>
) : state.activeStep === SignupStep.HANDLE ? (
<Trans>Choose your username</Trans>
) : (
<Trans>Complete the challenge</Trans>
)}
</Text>
</View>
{state.activeStep === SignupStep.INFO ? (
<StepInfo
onPressBack={onPressBack}
isLoadingStarterPack={
isFetchingStarterPack && !isErrorStarterPack
}
isServerError={isError}
refetchServer={() => void refetch()}
/>
) : state.activeStep === SignupStep.HANDLE ? (
<StepHandle />
) : (
<StepCaptcha />
)}
<Divider />
<View
style={[
a.w_full,
a.py_lg,
a.flex_row,
a.gap_md,
a.align_center,
]}>
<AppLanguageDropdown />
<View
style={
gtMobile
? [a.flex_1, a.flex, a.flex_row, a.justify_end]
: []
}>
<Text
style={[
t.atoms.text_contrast_medium,
!gtMobile && a.text_md,
{paddingInline: tokens.space.sm},
]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLinkText
label={l`Contact support`}
to={FEEDBACK_FORM_URL({email: state.email})}
style={[!gtMobile && a.text_md]}>
<Trans>Contact support</Trans>
</InlineLinkText>
</Text>
</View>
</View>
</View>
</View>
</ScreenTransition>
</LayoutAnimationConfig>
</View>
</LoggedOutLayout>
</ScreenTransition>
</LayoutAnimationConfig>
</View>
</LoggedOutLayout>
</KeyboardAvoidingView>
</SignupContext.Provider>
</Animated.View>
)
+330
View File
@@ -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<T>(
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
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<DidDocument | null> {
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<string | null>(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,
}
},
}
}
+6 -1
View File
@@ -160,7 +160,12 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
/>
) : undefined}
{screenState === ScreenState.S_Login ? (
<Login onPressBack={onPressBack} />
<Login
onPressBack={onPressBack}
onPressCreateAccount={() => {
setScreenState(ScreenState.S_CreateAccount)
}}
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
<Signup onPressBack={onPressBack} />
+18 -19
View File
@@ -80,7 +80,18 @@ export const SplashScreen = ({
<View
testID="signinOrCreateAccount"
style={[a.px_5xl, a.gap_md, a.pb_sm]}>
<View
<Button
testID="createAccountButton"
onPress={() => {
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 = ({
},
},
]}>
<Button
testID="createAccountButton"
onPress={() => {
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'}>
<ButtonText>
<Trans>Create account</Trans>
</ButtonText>
</Button>
</View>
<ButtonText>
<Trans>Create account</Trans>
</ButtonText>
</Button>
<Button
testID="signInButton"
@@ -119,7 +117,8 @@ export const SplashScreen = ({
accessibilityHint={_(
msg`Opens flow to sign in to your existing Bluesky account`,
)}
size="large">
size="large"
hoverStyle={{opacity: 0.5}}>
<ButtonText style={{color: 'white'}}>
<Trans>Sign in</Trans>
</ButtonText>
+11 -8
View File
@@ -1,7 +1,6 @@
import {ScrollView, StyleSheet, View} from 'react-native'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {atoms as a} from '#/alf'
@@ -29,8 +28,6 @@ export const LoggedOutLayout = ({
borderLeftWidth: 1,
})
const [isKeyboardVisible] = useIsKeyboardVisible()
if (isMobile) {
if (scrollable) {
return (
@@ -38,10 +35,8 @@ export const LoggedOutLayout = ({
style={a.flex_1}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="none"
contentContainerStyle={[
{paddingBottom: isKeyboardVisible ? 300 : 0},
]}>
<View style={a.pt_lg}>{children}</View>
contentContainerStyle={[a.flex_grow]}>
<View style={[a.flex_1, a.pt_lg]}>{children}</View>
</ScrollView>
)
} else {
@@ -77,7 +72,15 @@ export const LoggedOutLayout = ({
style={a.flex_1}
contentContainerStyle={styles.scrollViewContentContainer}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag">
/*
* RNW implements `on-drag` by blurring the focused element on ANY
* scroll event - including the one Firefox fires when swapping
* splash -> login content resizes the scroller - which kills the
* login form's autofocus. It doesn't appear to do anything anyways
* on web (judging by iOS safari, which keeps the keyboard open
* regardless of scrolling) -sfn
*/
keyboardDismissMode={IS_WEB ? 'none' : 'on-drag'}>
<View style={[styles.contentWrapper, IS_WEB && a.my_auto]}>
{children}
</View>