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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,10 @@ const Context = createContext<{
|
||||
})
|
||||
Context.displayName = 'TextFieldContext'
|
||||
|
||||
export function useTextFieldContext() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export type RootProps = React.PropsWithChildren<
|
||||
{isInvalid?: boolean} & TextStyleProp
|
||||
>
|
||||
|
||||
@@ -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/')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 couldn’t 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 don’t 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 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.
|
||||
</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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`We’re 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`We’re 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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user