Resolve host on login page
This commit is contained in:
@@ -100,6 +100,19 @@ export type Events = {
|
|||||||
hostingProviderDidChange: boolean
|
hostingProviderDidChange: boolean
|
||||||
}
|
}
|
||||||
'signin:hostingProviderFailedResolution': {}
|
'signin:hostingProviderFailedResolution': {}
|
||||||
|
'signin:pdsResolve:success': {
|
||||||
|
durationMs: number
|
||||||
|
isBlueskySocial: boolean
|
||||||
|
}
|
||||||
|
'signin:pdsResolve:failure': {
|
||||||
|
reason:
|
||||||
|
| 'invalid_handle'
|
||||||
|
| 'handle_not_found'
|
||||||
|
| 'did_doc_failed'
|
||||||
|
| 'no_pds_in_doc'
|
||||||
|
| 'network'
|
||||||
|
}
|
||||||
|
'signin:customServerUsed': {}
|
||||||
'signin:success': {
|
'signin:success': {
|
||||||
failedAttemptsCount: number
|
failedAttemptsCount: number
|
||||||
isUsingCustomProvider: boolean
|
isUsingCustomProvider: boolean
|
||||||
|
|||||||
@@ -23,16 +23,24 @@ type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom'
|
|||||||
export function ServerInputDialog({
|
export function ServerInputDialog({
|
||||||
control,
|
control,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
customOnly,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
onSelect: (url: string) => void
|
onSelect: (url: string) => void
|
||||||
|
/**
|
||||||
|
* When true, the dialog only exposes the custom-server input - no
|
||||||
|
* Bluesky/Custom tab bar. Used by the login flow, where this dialog is an
|
||||||
|
* override affordance for an already-resolved or default PDS.
|
||||||
|
*/
|
||||||
|
customOnly?: boolean
|
||||||
}) {
|
}) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const formRef = useRef<DialogInnerRef>(null)
|
const formRef = useRef<DialogInnerRef>(null)
|
||||||
|
|
||||||
// persist these options between dialog open/close
|
// persist these options between dialog open/close
|
||||||
const [fixedOption, setFixedOption] =
|
const [fixedOption, setFixedOption] = useState<SegmentedControlOptions>(
|
||||||
useState<SegmentedControlOptions>(BSKY_SERVICE)
|
customOnly ? 'custom' : BSKY_SERVICE,
|
||||||
|
)
|
||||||
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
|
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
@@ -42,11 +50,17 @@ export function ServerInputDialog({
|
|||||||
if (result !== BSKY_SERVICE) {
|
if (result !== BSKY_SERVICE) {
|
||||||
setPreviousCustomAddress(result)
|
setPreviousCustomAddress(result)
|
||||||
}
|
}
|
||||||
|
} else if (customOnly) {
|
||||||
|
// In custom-only mode, an empty form on close means the user wants to
|
||||||
|
// clear their server override. Signal this to the caller with an empty
|
||||||
|
// string; default mode preserves the legacy "no call on empty" behavior.
|
||||||
|
onSelect('')
|
||||||
|
setPreviousCustomAddress('')
|
||||||
}
|
}
|
||||||
ax.metric('signin:hostingProviderPressed', {
|
ax.metric('signin:hostingProviderPressed', {
|
||||||
hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
|
hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
|
||||||
})
|
})
|
||||||
}, [ax, onSelect, fixedOption])
|
}, [ax, onSelect, fixedOption, customOnly])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Outer
|
<Dialog.Outer
|
||||||
@@ -59,6 +73,7 @@ export function ServerInputDialog({
|
|||||||
fixedOption={fixedOption}
|
fixedOption={fixedOption}
|
||||||
setFixedOption={setFixedOption}
|
setFixedOption={setFixedOption}
|
||||||
initialCustomAddress={previousCustomAddress}
|
initialCustomAddress={previousCustomAddress}
|
||||||
|
customOnly={!!customOnly}
|
||||||
/>
|
/>
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
)
|
)
|
||||||
@@ -71,11 +86,13 @@ function DialogInner({
|
|||||||
fixedOption,
|
fixedOption,
|
||||||
setFixedOption,
|
setFixedOption,
|
||||||
initialCustomAddress,
|
initialCustomAddress,
|
||||||
|
customOnly,
|
||||||
}: {
|
}: {
|
||||||
formRef: React.Ref<DialogInnerRef>
|
formRef: React.Ref<DialogInnerRef>
|
||||||
fixedOption: SegmentedControlOptions
|
fixedOption: SegmentedControlOptions
|
||||||
setFixedOption: (opt: SegmentedControlOptions) => void
|
setFixedOption: (opt: SegmentedControlOptions) => void
|
||||||
initialCustomAddress: string
|
initialCustomAddress: string
|
||||||
|
customOnly: boolean
|
||||||
}) {
|
}) {
|
||||||
const control = Dialog.useDialogContext()
|
const control = Dialog.useDialogContext()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -131,8 +148,13 @@ function DialogInner({
|
|||||||
style={web({maxWidth: 500})}>
|
style={web({maxWidth: 500})}>
|
||||||
<View style={[a.relative, a.gap_md, a.w_full]}>
|
<View style={[a.relative, a.gap_md, a.w_full]}>
|
||||||
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
|
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
|
||||||
|
{customOnly ? (
|
||||||
|
<Trans>Use a custom server</Trans>
|
||||||
|
) : (
|
||||||
<Trans>Choose your account provider</Trans>
|
<Trans>Choose your account provider</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{!customOnly && (
|
||||||
<SegmentedControl.Root
|
<SegmentedControl.Root
|
||||||
type="tabs"
|
type="tabs"
|
||||||
label={_(msg`Account provider`)}
|
label={_(msg`Account provider`)}
|
||||||
@@ -155,8 +177,9 @@ function DialogInner({
|
|||||||
</SegmentedControl.ItemText>
|
</SegmentedControl.ItemText>
|
||||||
</SegmentedControl.Item>
|
</SegmentedControl.Item>
|
||||||
</SegmentedControl.Root>
|
</SegmentedControl.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
{fixedOption === BSKY_SERVICE && isFirstTimeUser && (
|
{!customOnly && fixedOption === BSKY_SERVICE && isFirstTimeUser && (
|
||||||
<View role="tabpanel">
|
<View role="tabpanel">
|
||||||
<Admonition type="tip">
|
<Admonition type="tip">
|
||||||
<Trans>
|
<Trans>
|
||||||
@@ -212,8 +235,9 @@ function DialogInner({
|
|||||||
</Trans>
|
</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>
|
<Trans>
|
||||||
Bluesky is an open network where you can choose your hosting
|
Bluesky is part of the Atmosphere, an open network where you can
|
||||||
provider. If you're a developer, you can host your own server.
|
choose your hosting provider. If you're a developer, you can
|
||||||
|
host your own server.
|
||||||
</Trans>
|
</Trans>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {BSKY_SERVICE} from '#/lib/constants'
|
||||||
|
import {Agent} from '#/state/session/agent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an atproto handle (or DID) to the user's PDS service URL.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* handle -> DID (via {resolverUrl}'s `com.atproto.identity.resolveHandle`)
|
||||||
|
* DID -> DID doc (via plc.directory or did:web `.well-known/did.json`)
|
||||||
|
* DID doc -> PDS (`serviceEndpoint` of the `#atproto_pds` service entry)
|
||||||
|
*
|
||||||
|
* Throws `ResolvePdsError` with a `reason` discriminator on failure.
|
||||||
|
*/
|
||||||
|
export async function resolvePdsForHandle(
|
||||||
|
handleOrDid: string,
|
||||||
|
opts: {resolverUrl?: string} = {},
|
||||||
|
): Promise<{pds: string; did: string}> {
|
||||||
|
const input = handleOrDid.trim().replace(/^@/, '').toLowerCase()
|
||||||
|
if (!input) {
|
||||||
|
throw new ResolvePdsError('invalid_handle', 'Empty handle')
|
||||||
|
}
|
||||||
|
|
||||||
|
const did = input.startsWith('did:')
|
||||||
|
? input
|
||||||
|
: await resolveHandleToDid(input, opts.resolverUrl ?? BSKY_SERVICE)
|
||||||
|
|
||||||
|
const doc = await fetchDidDoc(did)
|
||||||
|
const pds = getPdsFromDidDoc(doc)
|
||||||
|
if (!pds) {
|
||||||
|
throw new ResolvePdsError(
|
||||||
|
'no_pds_in_doc',
|
||||||
|
`No #atproto_pds service in DID doc for ${did}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {pds, did}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHandleToDid(
|
||||||
|
handle: string,
|
||||||
|
resolverUrl: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const agent = new Agent(null, {service: resolverUrl})
|
||||||
|
const res = await agent.resolveHandle({handle})
|
||||||
|
return res.data.did
|
||||||
|
} catch (e) {
|
||||||
|
throw new ResolvePdsError(
|
||||||
|
'handle_not_found',
|
||||||
|
`Could not resolve handle ${handle}`,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDidDoc(did: string): Promise<DidDoc> {
|
||||||
|
let url: string
|
||||||
|
if (did.startsWith('did:plc:')) {
|
||||||
|
url = `https://plc.directory/${did}`
|
||||||
|
} else if (did.startsWith('did:web:')) {
|
||||||
|
const domain = did.slice('did:web:'.length)
|
||||||
|
url = `https://${domain}/.well-known/did.json`
|
||||||
|
} else {
|
||||||
|
throw new ResolvePdsError(
|
||||||
|
'did_doc_failed',
|
||||||
|
`Unsupported DID method: ${did}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as DidDoc
|
||||||
|
} catch (e) {
|
||||||
|
throw new ResolvePdsError(
|
||||||
|
'did_doc_failed',
|
||||||
|
`Failed to fetch DID doc from ${url}`,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPdsFromDidDoc(doc: DidDoc): string | undefined {
|
||||||
|
const service = doc.service?.find(s => s.id?.endsWith('#atproto_pds'))
|
||||||
|
const endpoint = service?.serviceEndpoint
|
||||||
|
if (typeof endpoint === 'string') {
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type DidDoc = {
|
||||||
|
service?: {id?: string; type?: string; serviceEndpoint?: unknown}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the given PDS URL is one of Bluesky's hosted PDSes
|
||||||
|
* (either the marketing host or one of the sharded backend hosts). Used to
|
||||||
|
* decide when the auto-resolved server hint is too obvious to be worth
|
||||||
|
* showing to the user.
|
||||||
|
*/
|
||||||
|
export function isBlueskyHostedPds(pdsUrl: string): boolean {
|
||||||
|
if (pdsUrl === BSKY_SERVICE) return true
|
||||||
|
try {
|
||||||
|
const {hostname} = new URL(pdsUrl)
|
||||||
|
return hostname === 'bsky.social' || hostname.endsWith('.host.bsky.network')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvePdsErrorReason =
|
||||||
|
| 'invalid_handle'
|
||||||
|
| 'handle_not_found'
|
||||||
|
| 'did_doc_failed'
|
||||||
|
| 'no_pds_in_doc'
|
||||||
|
|
||||||
|
export class ResolvePdsError extends Error {
|
||||||
|
reason: ResolvePdsErrorReason
|
||||||
|
cause?: unknown
|
||||||
|
constructor(reason: ResolvePdsErrorReason, message: string, cause?: unknown) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ResolvePdsError'
|
||||||
|
this.reason = reason
|
||||||
|
this.cause = cause
|
||||||
|
}
|
||||||
|
}
|
||||||
+245
-19
@@ -1,5 +1,6 @@
|
|||||||
import {useCallback, useRef, useState} from 'react'
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import {Keyboard, type TextInput, View} from 'react-native'
|
import {Keyboard, type TextInput, View} from 'react-native'
|
||||||
|
import Animated, {FadeIn} from 'react-native-reanimated'
|
||||||
import {
|
import {
|
||||||
ComAtprotoServerCreateSession,
|
ComAtprotoServerCreateSession,
|
||||||
type ComAtprotoServerDescribeServer,
|
type ComAtprotoServerDescribeServer,
|
||||||
@@ -8,23 +9,30 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {isBlueskyHostedPds, ResolvePdsError} from '#/lib/api/resolve-pds'
|
||||||
|
import {BSKY_SERVICE, DEFAULT_SERVICE} from '#/lib/constants'
|
||||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||||
import {createFullHandle} from '#/lib/strings/handles'
|
import {createFullHandle} from '#/lib/strings/handles'
|
||||||
|
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
||||||
|
import {useResolvePdsQuery} from '#/state/queries/resolve-pds'
|
||||||
import {useSessionApi} from '#/state/session'
|
import {useSessionApi} from '#/state/session'
|
||||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
import {atoms as a, ios, useTheme, web} from '#/alf'
|
import {atoms as a, ios, native, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import {ServerInputDialog} from '#/components/dialogs/ServerInput'
|
||||||
import {FormError} from '#/components/forms/FormError'
|
import {FormError} from '#/components/forms/FormError'
|
||||||
import {HostingProvider} from '#/components/forms/HostingProvider'
|
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
|
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
|
||||||
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
|
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
|
||||||
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
|
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
|
||||||
|
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_IOS, IS_WEB} from '#/env'
|
import {IS_IOS, IS_WEB} from '#/env'
|
||||||
import {FormContainer} from './FormContainer'
|
import {FormContainer} from './FormContainer'
|
||||||
|
|
||||||
@@ -56,12 +64,16 @@ export const LoginForm = ({
|
|||||||
onAttemptFailed: () => void
|
onAttemptFailed: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const ax = useAnalytics()
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [errorField, setErrorField] = useState<
|
const [errorField, setErrorField] = useState<
|
||||||
'none' | 'identifier' | 'password' | '2fa'
|
'none' | 'identifier' | 'password' | '2fa'
|
||||||
>('none')
|
>('none')
|
||||||
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
|
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
|
||||||
const identifierValueRef = useRef<string>(initialHandle || '')
|
const identifierValueRef = useRef<string>(initialHandle || '')
|
||||||
|
const resolveDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
const passwordValueRef = useRef<string>('')
|
const passwordValueRef = useRef<string>('')
|
||||||
const [authFactorToken, setAuthFactorToken] = useState('')
|
const [authFactorToken, setAuthFactorToken] = useState('')
|
||||||
const identifierRef = useRef<TextInput>(null)
|
const identifierRef = useRef<TextInput>(null)
|
||||||
@@ -73,9 +85,72 @@ export const LoginForm = ({
|
|||||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||||
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
||||||
|
|
||||||
const onPressSelectService = useCallback(() => {
|
// Handle for PDS resolution. Mirrors identifierValueRef but as state so the
|
||||||
|
// query key updates. Set on blur (and on submit) to avoid a request per keystroke.
|
||||||
|
const [handleForResolve, setHandleForResolve] = useState(initialHandle || '')
|
||||||
|
// If the user explicitly picks a custom server via the fallback link, that
|
||||||
|
// overrides the resolved PDS.
|
||||||
|
const [customServerOverride, setCustomServerOverride] = useState<
|
||||||
|
string | undefined
|
||||||
|
>(undefined)
|
||||||
|
const serverInputControl = useDialogControl()
|
||||||
|
const resolveStartRef = useRef<number>(0)
|
||||||
|
|
||||||
|
const resolveQuery = useResolvePdsQuery(handleForResolve, {
|
||||||
|
enabled: !customServerOverride,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track timing for the resolve-success analytics event.
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolveQuery.isFetching) {
|
||||||
|
resolveStartRef.current = Date.now()
|
||||||
|
}
|
||||||
|
}, [resolveQuery.isFetching])
|
||||||
|
|
||||||
|
// Fire analytics on resolve success/failure.
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolveQuery.isSuccess && resolveQuery.data) {
|
||||||
|
ax.metric('signin:pdsResolve:success', {
|
||||||
|
durationMs: Date.now() - resolveStartRef.current,
|
||||||
|
isBlueskySocial: isBlueskyHostedPds(resolveQuery.data.pds),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [resolveQuery.isSuccess, resolveQuery.data, ax])
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolveQuery.isError) {
|
||||||
|
const reason =
|
||||||
|
resolveQuery.error instanceof ResolvePdsError
|
||||||
|
? resolveQuery.error.reason
|
||||||
|
: 'network'
|
||||||
|
ax.metric('signin:pdsResolve:failure', {reason})
|
||||||
|
}
|
||||||
|
}, [resolveQuery.isError, resolveQuery.error, ax])
|
||||||
|
|
||||||
|
const onSelectCustomServer = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
// Empty string = user cleared the input and tapped Done -> clear override.
|
||||||
|
// BSKY_SERVICE = user dismissed the dialog without picking a custom server
|
||||||
|
// (the dialog defaults aren't supposed to override anything).
|
||||||
|
// Both cases fall back to auto-resolve.
|
||||||
|
if (url === '' || url === BSKY_SERVICE) {
|
||||||
|
setCustomServerOverride(undefined)
|
||||||
|
setServiceUrl(DEFAULT_SERVICE)
|
||||||
|
// Make sure resolution has a chance to run with the current handle
|
||||||
|
// value, in case the user opened the dialog before the field blurred.
|
||||||
|
setHandleForResolve(identifierValueRef.current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCustomServerOverride(url)
|
||||||
|
setServiceUrl(url)
|
||||||
|
ax.metric('signin:customServerUsed', {})
|
||||||
|
},
|
||||||
|
[setServiceUrl, ax],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPressUseCustomServer = useCallback(() => {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
}, [])
|
serverInputControl.open()
|
||||||
|
}, [serverInputControl])
|
||||||
|
|
||||||
const onPressNext = async () => {
|
const onPressNext = async () => {
|
||||||
if (isProcessing) return
|
if (isProcessing) return
|
||||||
@@ -123,10 +198,18 @@ export const LoginForm = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure the resolved-PDS state is in sync with the current input
|
||||||
|
// before deciding which service to use.
|
||||||
|
if (handleForResolve !== fullIdent) {
|
||||||
|
setHandleForResolve(fullIdent)
|
||||||
|
}
|
||||||
|
const service =
|
||||||
|
customServerOverride ?? resolveQuery.data?.pds ?? serviceUrl
|
||||||
|
|
||||||
// TODO remove double login
|
// TODO remove double login
|
||||||
await login(
|
await login(
|
||||||
{
|
{
|
||||||
service: serviceUrl,
|
service,
|
||||||
identifier: fullIdent,
|
identifier: fullIdent,
|
||||||
password,
|
password,
|
||||||
authFactorToken: authFactorToken.trim(),
|
authFactorToken: authFactorToken.trim(),
|
||||||
@@ -177,20 +260,12 @@ export const LoginForm = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
|
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
|
||||||
<View>
|
<ServerInputDialog
|
||||||
<TextField.LabelText>
|
control={serverInputControl}
|
||||||
<Trans>Hosting provider</Trans>
|
onSelect={onSelectCustomServer}
|
||||||
</TextField.LabelText>
|
customOnly
|
||||||
<HostingProvider
|
|
||||||
serviceUrl={serviceUrl}
|
|
||||||
onSelectServiceUrl={setServiceUrl}
|
|
||||||
onOpenDialog={onPressSelectService}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
<View>
|
<View>
|
||||||
<TextField.LabelText>
|
|
||||||
<Trans>Account</Trans>
|
|
||||||
</TextField.LabelText>
|
|
||||||
<View style={[a.gap_sm]}>
|
<View style={[a.gap_sm]}>
|
||||||
<TextField.Root isInvalid={errorField === 'identifier'}>
|
<TextField.Root isInvalid={errorField === 'identifier'}>
|
||||||
<TextField.Icon icon={At} />
|
<TextField.Icon icon={At} />
|
||||||
@@ -208,8 +283,26 @@ export const LoginForm = ({
|
|||||||
onChangeText={v => {
|
onChangeText={v => {
|
||||||
identifierValueRef.current = v
|
identifierValueRef.current = v
|
||||||
if (errorField) setErrorField('none')
|
if (errorField) setErrorField('none')
|
||||||
|
// Debounce the resolution trigger so it fires shortly after
|
||||||
|
// the user stops typing, not on every keystroke.
|
||||||
|
if (resolveDebounceRef.current) {
|
||||||
|
clearTimeout(resolveDebounceRef.current)
|
||||||
|
}
|
||||||
|
resolveDebounceRef.current = setTimeout(() => {
|
||||||
|
setHandleForResolve(v)
|
||||||
|
}, 400)
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (resolveDebounceRef.current) {
|
||||||
|
clearTimeout(resolveDebounceRef.current)
|
||||||
|
}
|
||||||
|
setHandleForResolve(identifierValueRef.current)
|
||||||
}}
|
}}
|
||||||
onSubmitEditing={() => {
|
onSubmitEditing={() => {
|
||||||
|
if (resolveDebounceRef.current) {
|
||||||
|
clearTimeout(resolveDebounceRef.current)
|
||||||
|
}
|
||||||
|
setHandleForResolve(identifierValueRef.current)
|
||||||
passwordRef.current?.focus()
|
passwordRef.current?.focus()
|
||||||
}}
|
}}
|
||||||
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
|
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
|
||||||
@@ -311,7 +404,7 @@ export const LoginForm = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<FormError error={error} />
|
<FormError error={error} />
|
||||||
<View style={[a.pt_md, web([a.justify_between, a.flex_row])]}>
|
<View style={[a.pt_md, web([a.flex_row, a.align_center, a.gap_md])]}>
|
||||||
{IS_WEB && (
|
{IS_WEB && (
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Back`)}
|
label={_(msg`Back`)}
|
||||||
@@ -323,6 +416,14 @@ export const LoginForm = ({
|
|||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<View style={[web([a.flex_1, {minWidth: 0}]), native([a.pb_md])]}>
|
||||||
|
<PdsResolveStatus
|
||||||
|
query={resolveQuery}
|
||||||
|
override={customServerOverride}
|
||||||
|
handle={handleForResolve}
|
||||||
|
onPressUseCustomServer={onPressUseCustomServer}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
{!serviceDescription && error ? (
|
{!serviceDescription && error ? (
|
||||||
<Button
|
<Button
|
||||||
testID="loginRetryButton"
|
testID="loginRetryButton"
|
||||||
@@ -362,3 +463,128 @@ export const LoginForm = ({
|
|||||||
</FormContainer>
|
</FormContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PdsResolveStatus({
|
||||||
|
query,
|
||||||
|
override,
|
||||||
|
handle,
|
||||||
|
onPressUseCustomServer,
|
||||||
|
}: {
|
||||||
|
query: ReturnType<typeof useResolvePdsQuery>
|
||||||
|
override: string | undefined
|
||||||
|
handle: string
|
||||||
|
onPressUseCustomServer: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
// Only surface a "Resolving..." state if the fetch is actually slow enough
|
||||||
|
// to be perceptible. Most resolutions complete in <300ms, in which case
|
||||||
|
// flashing a loading message just makes the form look noisy. After 600ms,
|
||||||
|
// we assume the user is genuinely waiting and could use feedback.
|
||||||
|
const [showLoading, setShowLoading] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.isFetching) {
|
||||||
|
const raf = requestAnimationFrame(() => setShowLoading(false))
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => setShowLoading(true), 600)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [query.isFetching])
|
||||||
|
|
||||||
|
let content: React.ReactNode = null
|
||||||
|
let contentKey = 'empty'
|
||||||
|
if (override) {
|
||||||
|
contentKey = 'override:' + override
|
||||||
|
content = (
|
||||||
|
<Text
|
||||||
|
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}>
|
||||||
|
<Trans>You're signing in to {toNiceDomain(override)}.</Trans>{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
label={_(msg`Change server`)}
|
||||||
|
{...createStaticClick(onPressUseCustomServer)}
|
||||||
|
style={[a.text_sm]}>
|
||||||
|
<Trans>Change</Trans>
|
||||||
|
</InlineLinkText>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
} else if (!handle || !handle.includes('.') || handle.includes('@')) {
|
||||||
|
// Nothing typed yet, partial handle, or an email (legacy login flow).
|
||||||
|
content = null
|
||||||
|
} else if (query.isFetching) {
|
||||||
|
if (!showLoading) {
|
||||||
|
// Fetch is still in flight but hasn't been slow enough to surface yet.
|
||||||
|
// Render nothing instead of a transient flash.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
contentKey = 'loading'
|
||||||
|
content = (
|
||||||
|
<Text
|
||||||
|
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}>
|
||||||
|
<Trans>Resolving your server…</Trans>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
} else if (query.isError) {
|
||||||
|
contentKey = 'error'
|
||||||
|
content = (
|
||||||
|
<View style={[a.gap_2xs]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
web(a.text_right),
|
||||||
|
]}>
|
||||||
|
<Trans>Couldn't find your server.</Trans>
|
||||||
|
</Text>
|
||||||
|
<InlineLinkText
|
||||||
|
label={_(msg`Use a custom server`)}
|
||||||
|
{...createStaticClick(onPressUseCustomServer)}
|
||||||
|
style={[a.text_sm, a.leading_snug, web(a.text_right)]}>
|
||||||
|
<Trans>Use a custom server</Trans>
|
||||||
|
</InlineLinkText>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
} else if (query.data) {
|
||||||
|
// For the default case (account hosted on any Bluesky-operated PDS, which
|
||||||
|
// includes the *.host.bsky.network shards), hide the subtitle - the user
|
||||||
|
// already expects that and the confirmation just adds noise. Only show
|
||||||
|
// when the resolved PDS is something else worth confirming.
|
||||||
|
if (isBlueskyHostedPds(query.data.pds)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
contentKey = 'resolved:' + query.data.pds
|
||||||
|
content = (
|
||||||
|
<Text
|
||||||
|
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}>
|
||||||
|
<Trans>You're signing in to {toNiceDomain(query.data.pds)}</Trans>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content) return null
|
||||||
|
return <FadeInWrapper key={contentKey}>{content}</FadeInWrapper>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fades in its children on mount. Uses `react-native-reanimated`'s FadeIn on
|
||||||
|
* native and a CSS opacity transition on web - the latter because reanimated
|
||||||
|
* layout animations don't reliably trigger entering animations on web.
|
||||||
|
*/
|
||||||
|
function FadeInWrapper({children}: {children: React.ReactNode}) {
|
||||||
|
const [opacity, setOpacity] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const raf = requestAnimationFrame(() => setOpacity(1))
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={native(FadeIn.duration(200))}
|
||||||
|
style={web([
|
||||||
|
a.transition_opacity,
|
||||||
|
{transitionDuration: '200ms', opacity},
|
||||||
|
])}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {useQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {resolvePdsForHandle} from '#/lib/api/resolve-pds'
|
||||||
|
import {STALE} from '#/state/queries'
|
||||||
|
|
||||||
|
const RQKEY_ROOT = 'resolve-pds'
|
||||||
|
export const RQKEY = (handle: string) => [RQKEY_ROOT, handle]
|
||||||
|
|
||||||
|
export function useResolvePdsQuery(handle: string, opts?: {enabled?: boolean}) {
|
||||||
|
const normalized = handle.trim().replace(/^@/, '').toLowerCase()
|
||||||
|
// Only auto-resolve when the input looks like a full handle or a DID.
|
||||||
|
// Skip emails (contain `@`) so legacy email-login users keep going to the
|
||||||
|
// default service.
|
||||||
|
const looksResolvable =
|
||||||
|
!normalized.includes('@') &&
|
||||||
|
(normalized.startsWith('did:') || normalized.includes('.'))
|
||||||
|
return useQuery({
|
||||||
|
enabled: (opts?.enabled ?? true) && looksResolvable,
|
||||||
|
queryKey: RQKEY(normalized),
|
||||||
|
queryFn: () => resolvePdsForHandle(normalized),
|
||||||
|
staleTime: STALE.MINUTES.FIVE,
|
||||||
|
// Don't retry — failures fall back to manual server entry, no point hammering.
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user