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 31 const formRef = useRef<DialogInnerRef>(null) 32 32 33 33 // persist these options between dialog open/close 34 - const [fixedOption, setFixedOption] = 35 - useState<SegmentedControlOptions>(BSKY_SERVICE) 36 34 const [previousCustomAddress, setPreviousCustomAddress] = useState('') 37 35 38 36 const onClose = useCallback(() => { ··· 44 42 } 45 43 } 46 44 logger.metric('signin:hostingProviderPressed', { 47 - hostingProviderDidChange: fixedOption !== BSKY_SERVICE, 45 + hostingProviderDidChange: false, // stubbed for PDS auto-resolution 48 46 }) 49 - }, [onSelect, fixedOption]) 47 + }, [onSelect]) 50 48 51 49 return ( 52 50 <Dialog.Outer ··· 59 57 <Dialog.Handle /> 60 58 <DialogInner 61 59 formRef={formRef} 62 - fixedOption={fixedOption} 63 - setFixedOption={setFixedOption} 64 60 initialCustomAddress={previousCustomAddress} 65 61 /> 66 62 </Dialog.Outer> ··· 71 67 72 68 function DialogInner({ 73 69 formRef, 74 - fixedOption, 75 - setFixedOption, 76 70 initialCustomAddress, 77 71 }: { 78 72 formRef: React.Ref<DialogInnerRef> 79 - fixedOption: SegmentedControlOptions 80 - setFixedOption: (opt: SegmentedControlOptions) => void 81 73 initialCustomAddress: string 82 74 }) { 83 75 const control = Dialog.useDialogContext() ··· 94 86 formRef, 95 87 () => ({ 96 88 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 89 + let url = customAddress.trim().toLowerCase() 90 + if (!url) { 91 + return null 105 92 } 106 93 if (!url.startsWith('http://') && !url.startsWith('https://')) { 107 94 if (url === 'localhost' || url.startsWith('localhost:')) { ··· 111 98 } 112 99 } 113 100 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 - } 101 + if (!pdsAddressHistory.includes(url)) { 102 + const newHistory = [url, ...pdsAddressHistory.slice(0, 4)] 103 + setPdsAddressHistory(newHistory) 104 + persisted.write('pdsAddressHistory', newHistory) 120 105 } 121 106 122 107 return url 123 108 }, 124 109 }), 125 - [customAddress, fixedOption, pdsAddressHistory], 110 + [customAddress, pdsAddressHistory], 126 111 ) 127 112 128 113 const isFirstTimeUser = accounts.length === 0 ··· 136 121 <Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}> 137 122 <Trans>Choose your account provider</Trans> 138 123 </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 124 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> 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> 172 133 )} 173 134 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 - )} 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> 208 174 209 175 <View style={[a.py_xs]}> 210 176 <Text
+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,
+27 -2
src/screens/Login/LoginForm.tsx
··· 48 48 onPressForgotPassword, 49 49 onAttemptSuccess, 50 50 onAttemptFailed, 51 + debouncedResolveService, 52 + isResolvingService, 51 53 }: { 52 54 error: string 53 - serviceUrl: string 55 + serviceUrl?: string | undefined 54 56 serviceDescription: ServiceDescription | undefined 55 57 initialHandle: string 56 58 setError: (v: string) => void ··· 60 62 onPressForgotPassword: () => void 61 63 onAttemptSuccess: () => void 62 64 onAttemptFailed: () => void 65 + debouncedResolveService: (identifier: string) => void 66 + isResolvingService: boolean 63 67 }) => { 64 68 const t = useTheme() 65 69 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} ··· 209 225 defaultValue={initialHandle || ''} 210 226 onChangeText={v => { 211 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 + } 212 237 }} 213 238 onSubmitEditing={() => { 214 239 passwordRef.current?.focus() ··· 333 358 <Trans>Retry</Trans> 334 359 </ButtonText> 335 360 </Button> 336 - ) : !serviceDescription ? ( 361 + ) : !serviceDescription && serviceUrl !== undefined ? ( 337 362 <> 338 363 <ActivityIndicator color={t.palette.primary_500} /> 339 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' 1 + import {useCallback,useEffect, useMemo, useRef, useState} from 'react' 2 2 import {KeyboardAvoidingView} from 'react-native' 3 3 import Animated, {FadeIn, 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' ··· 18 20 import {atoms as a, native} from '#/alf' 19 21 import {ScreenTransition} from '#/components/ScreenTransition' 20 22 import {ChooseAccountForm} from './ChooseAccountForm' 23 + import { Did } from '@atproto/api' 21 24 22 25 enum Forms { 23 26 Login, ··· 40 43 const failedAttemptCountRef = useRef(0) 41 44 const startTimeRef = useRef(Date.now()) 42 45 46 + const agent = useAgent() 43 47 const {accounts} = useSession() 44 48 const {requestedAccountSwitchTo} = useLoggedOutView() 45 49 const requestedAccount = accounts.find( 46 50 acc => acc.did === requestedAccountSwitchTo, 47 51 ) 48 52 49 - const [error, setError] = useState('') 50 - const [serviceUrl, setServiceUrl] = useState( 51 - requestedAccount?.service || DEFAULT_SERVICE, 53 + const [isResolvingService, setIsResolvingService] = useState(false) 54 + const [error, setError] = useState<string>('') 55 + const [serviceUrl, setServiceUrl] = useState<string | undefined>( 56 + requestedAccount?.service, 52 57 ) 53 58 const [initialHandle, setInitialHandle] = useState( 54 59 requestedAccount?.handle || '', ··· 68 73 data: serviceDescription, 69 74 error: serviceError, 70 75 refetch: refetchService, 71 - } = useServiceQuery(serviceUrl) 76 + } = useServiceQuery(serviceUrl ?? '') 72 77 73 78 const onSelectAccount = (account?: SessionAccount) => { 74 79 if (account?.service) { ··· 102 107 } 103 108 }, [serviceError, serviceUrl, _]) 104 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 + 105 151 const onPressForgotPassword = () => { 106 152 gotoForm(Forms.ForgotPassword) 107 153 logEvent('signin:forgotPasswordPressed', {}) ··· 150 196 } 151 197 onPressForgotPassword={onPressForgotPassword} 152 198 onPressRetryConnect={refetchService} 199 + debouncedResolveService={debouncedResolveService} 200 + isResolvingService={isResolvingService} 153 201 /> 154 202 ) 155 203 break ··· 169 217 content = ( 170 218 <ForgotPasswordForm 171 219 error={error} 172 - serviceUrl={serviceUrl} 220 + serviceUrl={serviceUrl ?? DEFAULT_SERVICE} 173 221 serviceDescription={serviceDescription} 174 222 setError={setError} 175 223 setServiceUrl={setServiceUrl} ··· 184 232 content = ( 185 233 <SetNewPasswordForm 186 234 error={error} 187 - serviceUrl={serviceUrl} 235 + serviceUrl={serviceUrl ?? DEFAULT_SERVICE} 188 236 setError={setError} 189 237 onPressBack={() => gotoForm(Forms.ForgotPassword)} 190 238 onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}