From a6bef5380cc65266cbdcb84699acdbae0109a237 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Mon, 25 May 2026 10:18:17 -0700 Subject: [PATCH] Resolve host on login page --- src/analytics/metrics/types.ts | 13 ++ src/components/dialogs/ServerInput.tsx | 82 +++++--- src/lib/api/resolve-pds.ts | 127 ++++++++++++ src/screens/Login/LoginForm.tsx | 266 +++++++++++++++++++++++-- src/state/queries/resolve-pds.ts | 25 +++ 5 files changed, 464 insertions(+), 49 deletions(-) create mode 100644 src/lib/api/resolve-pds.ts create mode 100644 src/state/queries/resolve-pds.ts diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 91bbefd13a..df292ff03a 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -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 diff --git a/src/components/dialogs/ServerInput.tsx b/src/components/dialogs/ServerInput.tsx index 2a8c263aa6..0b769ae31e 100644 --- a/src/components/dialogs/ServerInput.tsx +++ b/src/components/dialogs/ServerInput.tsx @@ -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(null) // persist these options between dialog open/close - const [fixedOption, setFixedOption] = - useState(BSKY_SERVICE) + const [fixedOption, setFixedOption] = useState( + 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 ( ) @@ -71,11 +86,13 @@ function DialogInner({ fixedOption, setFixedOption, initialCustomAddress, + customOnly, }: { formRef: React.Ref 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})}> - Choose your account provider + {customOnly ? ( + Use a custom server + ) : ( + Choose your account provider + )} - - - - {_(msg`Bluesky`)} - - - - - {_(msg`Custom`)} - - - + {!customOnly && ( + + + + {_(msg`Bluesky`)} + + + + + {_(msg`Custom`)} + + + + )} - {fixedOption === BSKY_SERVICE && isFirstTimeUser && ( + {!customOnly && fixedOption === BSKY_SERVICE && isFirstTimeUser && ( @@ -212,8 +235,9 @@ function DialogInner({ ) : ( - 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. )}{' '} 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 { + 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 { + 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 + } +} diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 2689400426..abdac82746 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -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(initialHandle || '') + const resolveDebounceRef = useRef | undefined>( + undefined, + ) const passwordValueRef = useRef('') const [authFactorToken, setAuthFactorToken] = useState('') const identifierRef = useRef(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(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 ( Sign in}> + - - Hosting provider - - - - - - Account - @@ -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 = ({ )} - + {IS_WEB && ( )} + + + {!serviceDescription && error ? (