An ATproto social media client -- with an independent Appview.

feat: add pds auto resolving

authored by

Turtlepaw and committed by bunware.org c8af1a7a 84023eb5

+135 -98
+7 -7
src/components/forms/HostingProvider.tsx
··· 18 18 onOpenDialog, 19 19 minimal, 20 20 }: { 21 - serviceUrl: string 21 + serviceUrl?: string | undefined 22 22 onSelectServiceUrl: (provider: string) => void 23 23 onOpenDialog?: () => void 24 24 minimal?: boolean ··· 26 26 const serverInputControl = useDialogControl() 27 27 const t = useTheme() 28 28 const {_} = useLingui() 29 + const serviceProviderLabel = 30 + serviceUrl === undefined ? _(msg`Automatic`) : toNiceDomain(serviceUrl) 29 31 30 32 const onPressSelectService = React.useCallback(() => { 31 33 Keyboard.dismiss() ··· 45 47 <Trans>You are creating an account on</Trans> 46 48 </Text> 47 49 <Button 48 - label={toNiceDomain(serviceUrl)} 50 + label={serviceProviderLabel} 49 51 accessibilityHint={_(msg`Changes hosting provider`)} 50 52 onPress={onPressSelectService} 51 53 variant="ghost" ··· 56 58 {marginHorizontal: tokens.space.xs * -1}, 57 59 {paddingVertical: 0}, 58 60 ]}> 59 - <ButtonText style={[a.text_sm]}> 60 - {toNiceDomain(serviceUrl)} 61 - </ButtonText> 61 + <ButtonText style={[a.text_sm]}>{serviceProviderLabel}</ButtonText> 62 62 <ButtonIcon icon={PencilIcon} /> 63 63 </Button> 64 64 </View> 65 65 ) : ( 66 66 <Button 67 67 testID="selectServiceButton" 68 - label={toNiceDomain(serviceUrl)} 68 + label={serviceProviderLabel} 69 69 accessibilityHint={_(msg`Changes hosting provider`)} 70 70 variant="solid" 71 71 color="secondary" ··· 94 94 } 95 95 /> 96 96 </View> 97 - <Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text> 97 + <Text style={[a.text_md]}>{serviceProviderLabel}</Text> 98 98 <View 99 99 style={[ 100 100 a.rounded_sm,
+28 -3
src/screens/Login/LoginForm.tsx
··· 47 47 onPressForgotPassword, 48 48 onAttemptSuccess, 49 49 onAttemptFailed, 50 + debouncedResolveService, 51 + isResolvingService, 50 52 }: { 51 53 error: string 52 - serviceUrl: string 54 + serviceUrl?: string | undefined 53 55 serviceDescription: ServiceDescription | undefined 54 56 initialHandle: string 55 57 setError: (v: string) => void ··· 59 61 onPressForgotPassword: () => void 60 62 onAttemptSuccess: () => void 61 63 onAttemptFailed: () => void 64 + debouncedResolveService: (identifier: string) => void 65 + isResolvingService: boolean 62 66 }) => { 63 67 const t = useTheme() 64 68 const [isProcessing, setIsProcessing] = useState<boolean>(false) ··· 97 101 98 102 if (!password) { 99 103 setError(_(msg`Please enter your password`)) 104 + return 105 + } 106 + 107 + if (!serviceUrl) { 108 + setError(_(msg`Please enter hosting provider URL`)) 100 109 return 101 110 } 102 111 ··· 182 191 <View> 183 192 <TextField.LabelText> 184 193 <Trans>Hosting provider</Trans> 194 + {isResolvingService && ( 195 + <ActivityIndicator 196 + size={12} 197 + color={t.palette.contrast_500} 198 + style={a.ml_sm} 199 + /> 200 + )} 185 201 </TextField.LabelText> 186 202 <HostingProvider 187 203 serviceUrl={serviceUrl} ··· 208 224 defaultValue={initialHandle || ''} 209 225 onChangeText={v => { 210 226 identifierValueRef.current = v 227 + // Trigger PDS auto-resolution for handles/DIDs 228 + const id = v.trim() 229 + if (!id) return 230 + if ( 231 + id.startsWith('did:') || 232 + (id.includes('.') && !id.includes('@')) 233 + ) { 234 + debouncedResolveService(id) 235 + } 211 236 }} 212 237 onSubmitEditing={() => { 213 238 passwordRef.current?.focus() ··· 330 355 <Trans>Retry</Trans> 331 356 </ButtonText> 332 357 </Button> 333 - ) : !serviceDescription ? ( 358 + ) : !serviceDescription && serviceUrl !== undefined ? ( 334 359 <> 335 - <ActivityIndicator /> 360 + <ActivityIndicator color={t.palette.contrast_500} /> 336 361 <Text style={[t.atoms.text_contrast_high, a.pl_md]}> 337 362 <Trans>Connecting...</Trans> 338 363 </Text>
+50 -7
src/screens/Login/index.tsx
··· 1 - import React, {useRef} from 'react' 1 + import React, {useCallback, useMemo, useRef} from 'react' 2 2 import {KeyboardAvoidingView} from 'react-native' 3 3 import {LayoutAnimationConfig} from 'react-native-reanimated' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 + import debounce from 'lodash.debounce' 6 7 7 8 import {DEFAULT_SERVICE} from '#/lib/constants' 8 9 import {logEvent} from '#/lib/statsig/statsig' 9 10 import {logger} from '#/logger' 11 + import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' 10 12 import {useServiceQuery} from '#/state/queries/service' 11 - import {type SessionAccount, useSession} from '#/state/session' 13 + import {type SessionAccount, useAgent, useSession} from '#/state/session' 12 14 import {useLoggedOutView} from '#/state/shell/logged-out' 13 15 import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' 14 16 import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm' ··· 32 34 const failedAttemptCountRef = useRef(0) 33 35 const startTimeRef = useRef(Date.now()) 34 36 37 + const agent = useAgent() 35 38 const {accounts} = useSession() 36 39 const {requestedAccountSwitchTo} = useLoggedOutView() 37 40 const requestedAccount = accounts.find( 38 41 acc => acc.did === requestedAccountSwitchTo, 39 42 ) 40 43 44 + const [isResolvingService, setIsResolvingService] = React.useState(false) 41 45 const [error, setError] = React.useState<string>('') 42 - const [serviceUrl, setServiceUrl] = React.useState<string>( 43 - requestedAccount?.service || DEFAULT_SERVICE, 46 + const [serviceUrl, setServiceUrl] = React.useState<string | undefined>( 47 + requestedAccount?.service, 44 48 ) 45 49 const [initialHandle, setInitialHandle] = React.useState<string>( 46 50 requestedAccount?.handle || '', ··· 57 61 data: serviceDescription, 58 62 error: serviceError, 59 63 refetch: refetchService, 60 - } = useServiceQuery(serviceUrl) 64 + } = useServiceQuery(serviceUrl ?? '') 61 65 62 66 const onSelectAccount = (account?: SessionAccount) => { 63 67 if (account?.service) { ··· 88 92 } 89 93 }, [serviceError, serviceUrl, _]) 90 94 95 + const resolveIdentity = useCallback( 96 + async (identifier: string) => { 97 + setIsResolvingService(true) 98 + 99 + try { 100 + const getDid = async () => { 101 + if (identifier.startsWith('did:')) return identifier 102 + else 103 + return ( 104 + await agent.resolveHandle({ 105 + handle: identifier, 106 + }) 107 + ).data.did 108 + } 109 + 110 + const did = (await getDid()) as `did:${string}` 111 + const pdsUrl = await resolvePdsServiceUrl(did) 112 + 113 + if (pdsUrl.endsWith('.bsky.network')) { 114 + setServiceUrl('https://bsky.social') 115 + } else { 116 + setServiceUrl(pdsUrl) 117 + } 118 + } catch (err) { 119 + logger.error(`Service auto-resolution failed: ${err}`) 120 + } finally { 121 + setIsResolvingService(false) 122 + } 123 + }, 124 + [agent], 125 + ) 126 + 127 + const debouncedResolveService = useMemo( 128 + () => debounce(resolveIdentity, 800), 129 + [resolveIdentity], 130 + ) 131 + 91 132 const onPressForgotPassword = () => { 92 133 setCurrentForm(Forms.ForgotPassword) 93 134 logEvent('signin:forgotPasswordPressed', {}) ··· 136 177 } 137 178 onPressForgotPassword={onPressForgotPassword} 138 179 onPressRetryConnect={refetchService} 180 + debouncedResolveService={debouncedResolveService} 181 + isResolvingService={isResolvingService} 139 182 /> 140 183 ) 141 184 break ··· 155 198 content = ( 156 199 <ForgotPasswordForm 157 200 error={error} 158 - serviceUrl={serviceUrl} 201 + serviceUrl={serviceUrl ?? DEFAULT_SERVICE} 159 202 serviceDescription={serviceDescription} 160 203 setError={setError} 161 204 setServiceUrl={setServiceUrl} ··· 170 213 content = ( 171 214 <SetNewPasswordForm 172 215 error={error} 173 - serviceUrl={serviceUrl} 216 + serviceUrl={serviceUrl ?? DEFAULT_SERVICE} 174 217 setError={setError} 175 218 onPressBack={() => gotoForm(Forms.ForgotPassword)} 176 219 onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
+50 -81
src/view/com/auth/server-input/index.tsx
··· 13 13 import {Button, ButtonText} from '#/components/Button' 14 14 import * as Dialog from '#/components/Dialog' 15 15 import * as TextField from '#/components/forms/TextField' 16 - import * as ToggleButton from '#/components/forms/ToggleButton' 17 16 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 18 17 import {InlineLinkText} from '#/components/Link' 19 18 import {P, Text} from '#/components/Typography' ··· 29 28 const formRef = useRef<DialogInnerRef>(null) 30 29 31 30 // persist these options between dialog open/close 32 - const [fixedOption, setFixedOption] = useState(BSKY_SERVICE) 33 31 const [previousCustomAddress, setPreviousCustomAddress] = useState('') 34 32 35 33 const onClose = useCallback(() => { ··· 41 39 } 42 40 } 43 41 logEvent('signin:hostingProviderPressed', { 44 - hostingProviderDidChange: fixedOption !== BSKY_SERVICE, 42 + hostingProviderDidChange: false, // stubbed for PDS auto-resolution 45 43 }) 46 - }, [onSelect, fixedOption]) 44 + }, [onSelect]) 47 45 48 46 return ( 49 47 <Dialog.Outer ··· 53 51 <Dialog.Handle /> 54 52 <DialogInner 55 53 formRef={formRef} 56 - fixedOption={fixedOption} 57 - setFixedOption={setFixedOption} 58 54 initialCustomAddress={previousCustomAddress} 59 55 /> 60 56 </Dialog.Outer> ··· 65 61 66 62 function DialogInner({ 67 63 formRef, 68 - fixedOption, 69 - setFixedOption, 70 64 initialCustomAddress, 71 65 }: { 72 66 formRef: React.Ref<DialogInnerRef> 73 - fixedOption: string 74 - setFixedOption: (opt: string) => void 75 67 initialCustomAddress: string 76 68 }) { 77 69 const control = Dialog.useDialogContext() ··· 88 80 formRef, 89 81 () => ({ 90 82 getFormState: () => { 91 - let url 92 - if (fixedOption === 'custom') { 93 - url = customAddress.trim().toLowerCase() 94 - if (!url) { 95 - return null 96 - } 97 - } else { 98 - url = fixedOption 83 + let url = customAddress.trim().toLowerCase() 84 + if (!url) { 85 + return null 99 86 } 100 87 if (!url.startsWith('http://') && !url.startsWith('https://')) { 101 88 if (url === 'localhost' || url.startsWith('localhost:')) { ··· 105 92 } 106 93 } 107 94 108 - if (fixedOption === 'custom') { 109 - if (!pdsAddressHistory.includes(url)) { 110 - const newHistory = [url, ...pdsAddressHistory.slice(0, 4)] 111 - setPdsAddressHistory(newHistory) 112 - persisted.write('pdsAddressHistory', newHistory) 113 - } 95 + if (!pdsAddressHistory.includes(url)) { 96 + const newHistory = [url, ...pdsAddressHistory.slice(0, 4)] 97 + setPdsAddressHistory(newHistory) 98 + persisted.write('pdsAddressHistory', newHistory) 114 99 } 115 100 116 101 return url 117 102 }, 118 103 }), 119 - [customAddress, fixedOption, pdsAddressHistory], 104 + [customAddress, pdsAddressHistory], 120 105 ) 121 106 122 107 const isFirstTimeUser = accounts.length === 0 ··· 129 114 <Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}> 130 115 <Trans>Choose your account provider</Trans> 131 116 </Text> 132 - <ToggleButton.Group 133 - label="Preferences" 134 - values={[fixedOption]} 135 - onChange={values => setFixedOption(values[0])}> 136 - <ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}> 137 - <ToggleButton.ButtonText>{_(msg`Bluesky`)}</ToggleButton.ButtonText> 138 - </ToggleButton.Button> 139 - <ToggleButton.Button 140 - testID="customSelectBtn" 141 - name="custom" 142 - label={_(msg`Custom`)}> 143 - <ToggleButton.ButtonText>{_(msg`Custom`)}</ToggleButton.ButtonText> 144 - </ToggleButton.Button> 145 - </ToggleButton.Group> 146 117 147 - {fixedOption === BSKY_SERVICE && isFirstTimeUser && ( 118 + {isFirstTimeUser && ( 148 119 <Admonition type="tip"> 149 120 <Trans> 150 121 Bluesky is an open network where you can choose your own provider. ··· 154 125 </Admonition> 155 126 )} 156 127 157 - {fixedOption === 'custom' && ( 158 - <View 159 - style={[ 160 - a.border, 161 - t.atoms.border_contrast_low, 162 - a.rounded_sm, 163 - a.px_md, 164 - a.py_md, 165 - ]}> 166 - <TextField.LabelText nativeID="address-input-label"> 167 - <Trans>Server address</Trans> 168 - </TextField.LabelText> 169 - <TextField.Root> 170 - <TextField.Icon icon={Globe} /> 171 - <Dialog.Input 172 - testID="customServerTextInput" 173 - value={customAddress} 174 - onChangeText={setCustomAddress} 175 - label="my-server.com" 176 - accessibilityLabelledBy="address-input-label" 177 - autoCapitalize="none" 178 - keyboardType="url" 179 - /> 180 - </TextField.Root> 181 - {pdsAddressHistory.length > 0 && ( 182 - <View style={[a.flex_row, a.flex_wrap, a.mt_xs]}> 183 - {pdsAddressHistory.map(uri => ( 184 - <Button 185 - key={uri} 186 - variant="ghost" 187 - color="primary" 188 - label={uri} 189 - style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]} 190 - onPress={() => setCustomAddress(uri)}> 191 - <ButtonText>{uri}</ButtonText> 192 - </Button> 193 - ))} 194 - </View> 195 - )} 196 - </View> 197 - )} 128 + <View 129 + style={[ 130 + a.border, 131 + t.atoms.border_contrast_low, 132 + a.rounded_sm, 133 + a.px_md, 134 + a.py_md, 135 + ]}> 136 + <TextField.LabelText nativeID="address-input-label"> 137 + <Trans>Server address</Trans> 138 + </TextField.LabelText> 139 + <TextField.Root> 140 + <TextField.Icon icon={Globe} /> 141 + <Dialog.Input 142 + testID="customServerTextInput" 143 + value={customAddress} 144 + onChangeText={setCustomAddress} 145 + label="my-server.com" 146 + accessibilityLabelledBy="address-input-label" 147 + autoCapitalize="none" 148 + keyboardType="url" 149 + /> 150 + </TextField.Root> 151 + {pdsAddressHistory.length > 0 && ( 152 + <View style={[a.flex_row, a.flex_wrap, a.mt_xs]}> 153 + {pdsAddressHistory.map(uri => ( 154 + <Button 155 + key={uri} 156 + variant="ghost" 157 + color="primary" 158 + label={uri} 159 + style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]} 160 + onPress={() => setCustomAddress(uri)}> 161 + <ButtonText>{uri}</ButtonText> 162 + </Button> 163 + ))} 164 + </View> 165 + )} 166 + </View> 198 167 199 168 <View style={[a.py_xs]}> 200 169 <P