Automatic PDS resolution (from Catsky) (from Deer) #27

open
opened by maxine.puppykitty.racing targeting main
Changed files
+148 -109
src
components
screens
+58 -92
src/components/dialogs/ServerInput.tsx
···
const formRef = useRef<DialogInnerRef>(null)
// persist these options between dialog open/close
-
const [fixedOption, setFixedOption] =
-
useState<SegmentedControlOptions>(BSKY_SERVICE)
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
const onClose = useCallback(() => {
···
}
}
logger.metric('signin:hostingProviderPressed', {
-
hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
})
-
}, [onSelect, fixedOption])
return (
<Dialog.Outer
···
<Dialog.Handle />
<DialogInner
formRef={formRef}
-
fixedOption={fixedOption}
-
setFixedOption={setFixedOption}
initialCustomAddress={previousCustomAddress}
/>
</Dialog.Outer>
···
function DialogInner({
formRef,
-
fixedOption,
-
setFixedOption,
initialCustomAddress,
}: {
formRef: React.Ref<DialogInnerRef>
-
fixedOption: SegmentedControlOptions
-
setFixedOption: (opt: SegmentedControlOptions) => void
initialCustomAddress: string
}) {
const control = Dialog.useDialogContext()
···
formRef,
() => ({
getFormState: () => {
-
let url
-
if (fixedOption === 'custom') {
-
url = customAddress.trim().toLowerCase()
-
if (!url) {
-
return null
-
}
-
} else {
-
url = fixedOption
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (url === 'localhost' || url.startsWith('localhost:')) {
···
}
}
-
if (fixedOption === 'custom') {
-
if (!pdsAddressHistory.includes(url)) {
-
const newHistory = [url, ...pdsAddressHistory.slice(0, 4)]
-
setPdsAddressHistory(newHistory)
-
persisted.write('pdsAddressHistory', newHistory)
-
}
}
return url
},
}),
-
[customAddress, fixedOption, pdsAddressHistory],
)
const isFirstTimeUser = accounts.length === 0
···
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<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>
-
-
{fixedOption === BSKY_SERVICE && isFirstTimeUser && (
-
<View role="tabpanel">
-
<Admonition type="tip">
-
<Trans>
-
Bluesky is an open network where you can choose your own
-
provider. If you're new here, we recommend sticking with the
-
default Bluesky Social option.
-
</Trans>
-
</Admonition>
-
</View>
-
)}
-
{fixedOption === 'custom' && (
-
<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
style={[t.atoms.text_contrast_medium, a.text_sm, a.leading_snug]}>
···
const formRef = useRef<DialogInnerRef>(null)
// persist these options between dialog open/close
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
const onClose = useCallback(() => {
···
}
}
logger.metric('signin:hostingProviderPressed', {
+
hostingProviderDidChange: false, // stubbed for PDS auto-resolution
})
+
}, [onSelect])
return (
<Dialog.Outer
···
<Dialog.Handle />
<DialogInner
formRef={formRef}
initialCustomAddress={previousCustomAddress}
/>
</Dialog.Outer>
···
function DialogInner({
formRef,
initialCustomAddress,
}: {
formRef: React.Ref<DialogInnerRef>
initialCustomAddress: string
}) {
const control = Dialog.useDialogContext()
···
formRef,
() => ({
getFormState: () => {
+
let url = customAddress.trim().toLowerCase()
+
if (!url) {
+
return null
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (url === 'localhost' || url.startsWith('localhost:')) {
···
}
}
+
if (!pdsAddressHistory.includes(url)) {
+
const newHistory = [url, ...pdsAddressHistory.slice(0, 4)]
+
setPdsAddressHistory(newHistory)
+
persisted.write('pdsAddressHistory', newHistory)
}
return url
},
}),
+
[customAddress, pdsAddressHistory],
)
const isFirstTimeUser = accounts.length === 0
···
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Choose your account provider</Trans>
</Text>
+
{isFirstTimeUser && (
+
<Admonition type="tip">
+
<Trans>
+
Bluesky is an open network where you can choose your own provider.
+
If you're new here, we recommend sticking with the default Bluesky
+
Social option.
+
</Trans>
+
</Admonition>
)}
+
<View
+
style={[
+
a.border,
+
t.atoms.border_contrast_low,
+
a.rounded_sm,
+
a.px_md,
+
a.py_md,
+
]}>
+
<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
style={[t.atoms.text_contrast_medium, a.text_sm, a.leading_snug]}>
+7 -7
src/components/forms/HostingProvider.tsx
···
onOpenDialog,
minimal,
}: {
-
serviceUrl: string
onSelectServiceUrl: (provider: string) => void
onOpenDialog?: () => void
minimal?: boolean
···
const serverInputControl = useDialogControl()
const t = useTheme()
const {_} = useLingui()
const onPressSelectService = React.useCallback(() => {
Keyboard.dismiss()
···
<Trans>You are creating an account on</Trans>
</Text>
<Button
-
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
···
{marginHorizontal: tokens.space.xs * -1},
{paddingVertical: 0},
]}>
-
<ButtonText style={[a.text_sm]}>
-
{toNiceDomain(serviceUrl)}
-
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</View>
) : (
<Button
testID="selectServiceButton"
-
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
variant="solid"
color="secondary"
···
}
/>
</View>
-
<Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
<View
style={[
a.rounded_sm,
···
onOpenDialog,
minimal,
}: {
+
serviceUrl?: string | undefined
onSelectServiceUrl: (provider: string) => void
onOpenDialog?: () => void
minimal?: boolean
···
const serverInputControl = useDialogControl()
const t = useTheme()
const {_} = useLingui()
+
const serviceProviderLabel =
+
serviceUrl === undefined ? _(msg`Automatic`) : toNiceDomain(serviceUrl)
const onPressSelectService = React.useCallback(() => {
Keyboard.dismiss()
···
<Trans>You are creating an account on</Trans>
</Text>
<Button
+
label={serviceProviderLabel}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
···
{marginHorizontal: tokens.space.xs * -1},
{paddingVertical: 0},
]}>
+
<ButtonText style={[a.text_sm]}>{serviceProviderLabel}</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</View>
) : (
<Button
testID="selectServiceButton"
+
label={serviceProviderLabel}
accessibilityHint={_(msg`Changes hosting provider`)}
variant="solid"
color="secondary"
···
}
/>
</View>
+
<Text style={[a.text_md]}>{serviceProviderLabel}</Text>
<View
style={[
a.rounded_sm,
+27 -2
src/screens/Login/LoginForm.tsx
···
onPressForgotPassword,
onAttemptSuccess,
onAttemptFailed,
}: {
error: string
-
serviceUrl: string
serviceDescription: ServiceDescription | undefined
initialHandle: string
setError: (v: string) => void
···
onPressForgotPassword: () => void
onAttemptSuccess: () => void
onAttemptFailed: () => void
}) => {
const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
···
return
}
setIsProcessing(true)
try {
···
<View>
<TextField.LabelText>
<Trans>Hosting provider</Trans>
</TextField.LabelText>
<HostingProvider
serviceUrl={serviceUrl}
···
defaultValue={initialHandle || ''}
onChangeText={v => {
identifierValueRef.current = v
}}
onSubmitEditing={() => {
passwordRef.current?.focus()
···
<Trans>Retry</Trans>
</ButtonText>
</Button>
-
) : !serviceDescription ? (
<>
<ActivityIndicator color={t.palette.primary_500} />
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
···
onPressForgotPassword,
onAttemptSuccess,
onAttemptFailed,
+
debouncedResolveService,
+
isResolvingService,
}: {
error: string
+
serviceUrl?: string | undefined
serviceDescription: ServiceDescription | undefined
initialHandle: string
setError: (v: string) => void
···
onPressForgotPassword: () => void
onAttemptSuccess: () => void
onAttemptFailed: () => void
+
debouncedResolveService: (identifier: string) => void
+
isResolvingService: boolean
}) => {
const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
···
return
}
+
if (!serviceUrl) {
+
setError(_(msg`Please enter hosting provider URL`))
+
return
+
}
+
setIsProcessing(true)
try {
···
<View>
<TextField.LabelText>
<Trans>Hosting provider</Trans>
+
{isResolvingService && (
+
<ActivityIndicator
+
size={12}
+
color={t.palette.contrast_500}
+
style={a.ml_sm}
+
/>
+
)}
</TextField.LabelText>
<HostingProvider
serviceUrl={serviceUrl}
···
defaultValue={initialHandle || ''}
onChangeText={v => {
identifierValueRef.current = v
+
// Trigger PDS auto-resolution for handles/DIDs
+
const id = v.trim()
+
if (!id) return
+
if (
+
id.startsWith('did:') ||
+
(id.includes('.') && !id.includes('@'))
+
) {
+
debouncedResolveService(id)
+
}
}}
onSubmitEditing={() => {
passwordRef.current?.focus()
···
<Trans>Retry</Trans>
</ButtonText>
</Button>
+
) : !serviceDescription && serviceUrl !== undefined ? (
<>
<ActivityIndicator color={t.palette.primary_500} />
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
+56 -8
src/screens/Login/index.tsx
···
-
import {useEffect, useRef, useState} from 'react'
import {KeyboardAvoidingView} from 'react-native'
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DEFAULT_SERVICE} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useServiceQuery} from '#/state/queries/service'
-
import {type SessionAccount, useSession} from '#/state/session'
import {useLoggedOutView} from '#/state/shell/logged-out'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm'
···
import {atoms as a, native} from '#/alf'
import {ScreenTransition} from '#/components/ScreenTransition'
import {ChooseAccountForm} from './ChooseAccountForm'
enum Forms {
Login,
···
const failedAttemptCountRef = useRef(0)
const startTimeRef = useRef(Date.now())
const {accounts} = useSession()
const {requestedAccountSwitchTo} = useLoggedOutView()
const requestedAccount = accounts.find(
acc => acc.did === requestedAccountSwitchTo,
)
-
const [error, setError] = useState('')
-
const [serviceUrl, setServiceUrl] = useState(
-
requestedAccount?.service || DEFAULT_SERVICE,
)
const [initialHandle, setInitialHandle] = useState(
requestedAccount?.handle || '',
···
data: serviceDescription,
error: serviceError,
refetch: refetchService,
-
} = useServiceQuery(serviceUrl)
const onSelectAccount = (account?: SessionAccount) => {
if (account?.service) {
···
}
}, [serviceError, serviceUrl, _])
const onPressForgotPassword = () => {
gotoForm(Forms.ForgotPassword)
logEvent('signin:forgotPasswordPressed', {})
···
}
onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={refetchService}
/>
)
break
···
content = (
<ForgotPasswordForm
error={error}
-
serviceUrl={serviceUrl}
serviceDescription={serviceDescription}
setError={setError}
setServiceUrl={setServiceUrl}
···
content = (
<SetNewPasswordForm
error={error}
-
serviceUrl={serviceUrl}
setError={setError}
onPressBack={() => gotoForm(Forms.ForgotPassword)}
onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
···
+
import {useCallback,useEffect, useMemo, useRef, useState} from 'react'
import {KeyboardAvoidingView} from 'react-native'
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+
import debounce from 'lodash.debounce'
import {DEFAULT_SERVICE} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
+
import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity'
import {useServiceQuery} from '#/state/queries/service'
+
import {type SessionAccount, useAgent, useSession} from '#/state/session'
import {useLoggedOutView} from '#/state/shell/logged-out'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm'
···
import {atoms as a, native} from '#/alf'
import {ScreenTransition} from '#/components/ScreenTransition'
import {ChooseAccountForm} from './ChooseAccountForm'
+
import { Did } from '@atproto/api'
enum Forms {
Login,
···
const failedAttemptCountRef = useRef(0)
const startTimeRef = useRef(Date.now())
+
const agent = useAgent()
const {accounts} = useSession()
const {requestedAccountSwitchTo} = useLoggedOutView()
const requestedAccount = accounts.find(
acc => acc.did === requestedAccountSwitchTo,
)
+
const [isResolvingService, setIsResolvingService] = useState(false)
+
const [error, setError] = useState<string>('')
+
const [serviceUrl, setServiceUrl] = useState<string | undefined>(
+
requestedAccount?.service,
)
const [initialHandle, setInitialHandle] = useState(
requestedAccount?.handle || '',
···
data: serviceDescription,
error: serviceError,
refetch: refetchService,
+
} = useServiceQuery(serviceUrl ?? '')
const onSelectAccount = (account?: SessionAccount) => {
if (account?.service) {
···
}
}, [serviceError, serviceUrl, _])
+
const resolveIdentity = useCallback(
+
async (identifier: string) => {
+
setIsResolvingService(true)
+
+
try {
+
const getDid = async () => {
+
if (identifier.startsWith('did:')) return identifier
+
else
+
return (
+
await agent.resolveHandle({
+
handle: identifier,
+
})
+
).data.did
+
}
+
+
const did = (await getDid()) as Did
+
const pdsUrl = await resolvePdsServiceUrl(did)
+
+
if (!pdsUrl) {
+
throw new Error(`No PDS service found in DID document for ${did}`)
+
}
+
+
if (pdsUrl.endsWith('.bsky.network')) {
+
setServiceUrl('https://bsky.social')
+
} else {
+
setServiceUrl(pdsUrl)
+
}
+
} catch (err) {
+
logger.error(`Service auto-resolution failed: ${err}`)
+
} finally {
+
setIsResolvingService(false)
+
}
+
},
+
[agent],
+
)
+
+
const debouncedResolveService = useMemo(
+
() => debounce(resolveIdentity, 800),
+
[resolveIdentity],
+
)
+
const onPressForgotPassword = () => {
gotoForm(Forms.ForgotPassword)
logEvent('signin:forgotPasswordPressed', {})
···
}
onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={refetchService}
+
debouncedResolveService={debouncedResolveService}
+
isResolvingService={isResolvingService}
/>
)
break
···
content = (
<ForgotPasswordForm
error={error}
+
serviceUrl={serviceUrl ?? DEFAULT_SERVICE}
serviceDescription={serviceDescription}
setError={setError}
setServiceUrl={setServiceUrl}
···
content = (
<SetNewPasswordForm
error={error}
+
serviceUrl={serviceUrl ?? DEFAULT_SERVICE}
setError={setError}
onPressBack={() => gotoForm(Forms.ForgotPassword)}
onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}