Resolve host on login page

This commit is contained in:
Alex Benzer
2026-05-25 10:18:17 -07:00
parent 8cea36f3c9
commit a6bef5380c
5 changed files with 464 additions and 49 deletions
+13
View File
@@ -100,6 +100,19 @@ export type Events = {
hostingProviderDidChange: boolean
}
'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': {
failedAttemptsCount: number
isUsingCustomProvider: boolean
+53 -29
View File
@@ -23,16 +23,24 @@ type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom'
export function ServerInputDialog({
control,
onSelect,
customOnly,
}: {
control: Dialog.DialogOuterProps['control']
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 formRef = useRef<DialogInnerRef>(null)
// persist these options between dialog open/close
const [fixedOption, setFixedOption] =
useState<SegmentedControlOptions>(BSKY_SERVICE)
const [fixedOption, setFixedOption] = useState<SegmentedControlOptions>(
customOnly ? 'custom' : BSKY_SERVICE,
)
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
const onClose = useCallback(() => {
@@ -42,11 +50,17 @@ export function ServerInputDialog({
if (result !== BSKY_SERVICE) {
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', {
hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
})
}, [ax, onSelect, fixedOption])
}, [ax, onSelect, fixedOption, customOnly])
return (
<Dialog.Outer
@@ -59,6 +73,7 @@ export function ServerInputDialog({
fixedOption={fixedOption}
setFixedOption={setFixedOption}
initialCustomAddress={previousCustomAddress}
customOnly={!!customOnly}
/>
</Dialog.Outer>
)
@@ -71,11 +86,13 @@ function DialogInner({
fixedOption,
setFixedOption,
initialCustomAddress,
customOnly,
}: {
formRef: React.Ref<DialogInnerRef>
fixedOption: SegmentedControlOptions
setFixedOption: (opt: SegmentedControlOptions) => void
initialCustomAddress: string
customOnly: boolean
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
@@ -131,32 +148,38 @@ 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>
{customOnly ? (
<Trans>Use a custom server</Trans>
) : (
<Trans>Choose your account provider</Trans>
)}
</Text>
<SegmentedControl.Root
type="tabs"
label={_(msg`Account provider`)}
value={fixedOption}
onChange={setFixedOption}>
<SegmentedControl.Item
testID="bskyServiceSelectBtn"
value={BSKY_SERVICE}
label={_(msg`Bluesky`)}>
<SegmentedControl.ItemText>
{_(msg`Bluesky`)}
</SegmentedControl.ItemText>
</SegmentedControl.Item>
<SegmentedControl.Item
testID="customSelectBtn"
value="custom"
label={_(msg`Custom`)}>
<SegmentedControl.ItemText>
{_(msg`Custom`)}
</SegmentedControl.ItemText>
</SegmentedControl.Item>
</SegmentedControl.Root>
{!customOnly && (
<SegmentedControl.Root
type="tabs"
label={_(msg`Account provider`)}
value={fixedOption}
onChange={setFixedOption}>
<SegmentedControl.Item
testID="bskyServiceSelectBtn"
value={BSKY_SERVICE}
label={_(msg`Bluesky`)}>
<SegmentedControl.ItemText>
{_(msg`Bluesky`)}
</SegmentedControl.ItemText>
</SegmentedControl.Item>
<SegmentedControl.Item
testID="customSelectBtn"
value="custom"
label={_(msg`Custom`)}>
<SegmentedControl.ItemText>
{_(msg`Custom`)}
</SegmentedControl.ItemText>
</SegmentedControl.Item>
</SegmentedControl.Root>
)}
{fixedOption === BSKY_SERVICE && isFirstTimeUser && (
{!customOnly && fixedOption === BSKY_SERVICE && isFirstTimeUser && (
<View role="tabpanel">
<Admonition type="tip">
<Trans>
@@ -212,8 +235,9 @@ function DialogInner({
</Trans>
) : (
<Trans>
Bluesky is an open network where you can choose your hosting
provider. If you're a developer, you can host your own server.
Bluesky is part of the Atmosphere, an open network where you can
choose your hosting provider. If you're a developer, you can
host your own server.
</Trans>
)}{' '}
<InlineLinkText
+127
View File
@@ -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
}
}
+246 -20
View File
@@ -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 Animated, {FadeIn} from 'react-native-reanimated'
import {
ComAtprotoServerCreateSession,
type ComAtprotoServerDescribeServer,
@@ -8,23 +9,30 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
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 {cleanError, isNetworkError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useResolvePdsQuery} from '#/state/queries/resolve-pds'
import {useSessionApi} from '#/state/session'
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 {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '#/components/dialogs/ServerInput'
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'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_WEB} from '#/env'
import {FormContainer} from './FormContainer'
@@ -56,12 +64,16 @@ export const LoginForm = ({
onAttemptFailed: () => void
}) => {
const t = useTheme()
const ax = useAnalytics()
const [isProcessing, setIsProcessing] = useState(false)
const [errorField, setErrorField] = useState<
'none' | 'identifier' | 'password' | '2fa'
>('none')
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const identifierValueRef = useRef<string>(initialHandle || '')
const resolveDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
)
const passwordValueRef = useRef<string>('')
const [authFactorToken, setAuthFactorToken] = useState('')
const identifierRef = useRef<TextInput>(null)
@@ -73,9 +85,72 @@ export const LoginForm = ({
const {setShowLoggedOut} = useLoggedOutViewControls()
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()
}, [])
serverInputControl.open()
}, [serverInputControl])
const onPressNext = async () => {
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
await login(
{
service: serviceUrl,
service,
identifier: fullIdent,
password,
authFactorToken: authFactorToken.trim(),
@@ -177,20 +260,12 @@ export const LoginForm = ({
return (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
<ServerInputDialog
control={serverInputControl}
onSelect={onSelectCustomServer}
customOnly
/>
<View>
<TextField.LabelText>
<Trans>Hosting provider</Trans>
</TextField.LabelText>
<HostingProvider
serviceUrl={serviceUrl}
onSelectServiceUrl={setServiceUrl}
onOpenDialog={onPressSelectService}
/>
</View>
<View>
<TextField.LabelText>
<Trans>Account</Trans>
</TextField.LabelText>
<View style={[a.gap_sm]}>
<TextField.Root isInvalid={errorField === 'identifier'}>
<TextField.Icon icon={At} />
@@ -208,8 +283,26 @@ export const LoginForm = ({
onChangeText={v => {
identifierValueRef.current = v
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={() => {
if (resolveDebounceRef.current) {
clearTimeout(resolveDebounceRef.current)
}
setHandleForResolve(identifierValueRef.current)
passwordRef.current?.focus()
}}
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
@@ -311,7 +404,7 @@ export const LoginForm = ({
</View>
)}
<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 && (
<Button
label={_(msg`Back`)}
@@ -323,6 +416,14 @@ export const LoginForm = ({
</ButtonText>
</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 ? (
<Button
testID="loginRetryButton"
@@ -362,3 +463,128 @@ export const LoginForm = ({
</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>
)
}
+25
View File
@@ -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,
})
}