Bluesky app fork with some witchin' additions 💫

feat: add pds auto resolving from catsky

authored by uwx and committed by Tangled 9c43f94f 38d2beb6

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