Resolve host on login page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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