+57
-91
src/components/dialogs/ServerInput.tsx
+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
+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
+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
+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)}