mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useState} from 'react'
2import {
3 ActivityIndicator,
4 StyleSheet,
5 TouchableOpacity,
6 View,
7} from 'react-native'
8import {setStringAsync} from 'expo-clipboard'
9import {ComAtprotoServerDescribeServer} from '@atproto/api'
10import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
11import {msg, Trans} from '@lingui/macro'
12import {useLingui} from '@lingui/react'
13
14import {logger} from '#/logger'
15import {useModalControls} from '#/state/modals'
16import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
17import {useServiceQuery} from '#/state/queries/service'
18import {SessionAccount, useAgent, useSession} from '#/state/session'
19import {useAnalytics} from 'lib/analytics/analytics'
20import {usePalette} from 'lib/hooks/usePalette'
21import {cleanError} from 'lib/strings/errors'
22import {createFullHandle, makeValidHandle} from 'lib/strings/handles'
23import {s} from 'lib/styles'
24import {useTheme} from 'lib/ThemeContext'
25import {ErrorMessage} from '../util/error/ErrorMessage'
26import {Button} from '../util/forms/Button'
27import {SelectableBtn} from '../util/forms/SelectableBtn'
28import {Text} from '../util/text/Text'
29import * as Toast from '../util/Toast'
30import {ScrollView, TextInput} from './util'
31
32export const snapPoints = ['100%']
33
34export type Props = {onChanged: () => void}
35
36export function Component(props: Props) {
37 const {currentAccount} = useSession()
38 const agent = useAgent()
39 const {
40 isLoading,
41 data: serviceInfo,
42 error: serviceInfoError,
43 } = useServiceQuery(agent.service.toString())
44
45 return isLoading || !currentAccount ? (
46 <View style={{padding: 18}}>
47 <ActivityIndicator />
48 </View>
49 ) : serviceInfoError || !serviceInfo ? (
50 <ErrorMessage message={cleanError(serviceInfoError)} />
51 ) : (
52 <Inner
53 {...props}
54 currentAccount={currentAccount}
55 serviceInfo={serviceInfo}
56 />
57 )
58}
59
60export function Inner({
61 currentAccount,
62 serviceInfo,
63 onChanged,
64}: Props & {
65 currentAccount: SessionAccount
66 serviceInfo: ComAtprotoServerDescribeServer.OutputSchema
67}) {
68 const {_} = useLingui()
69 const pal = usePalette('default')
70 const {track} = useAnalytics()
71 const {closeModal} = useModalControls()
72 const {mutateAsync: updateHandle, isPending: isUpdateHandlePending} =
73 useUpdateHandleMutation()
74 const agent = useAgent()
75
76 const [error, setError] = useState<string>('')
77
78 const [isCustom, setCustom] = React.useState<boolean>(false)
79 const [handle, setHandle] = React.useState<string>('')
80 const [canSave, setCanSave] = React.useState<boolean>(false)
81
82 const userDomain = serviceInfo.availableUserDomains?.[0]
83
84 // events
85 // =
86 const onPressCancel = React.useCallback(() => {
87 closeModal()
88 }, [closeModal])
89 const onToggleCustom = React.useCallback(() => {
90 // toggle between a provided domain vs a custom one
91 setHandle('')
92 setCanSave(false)
93 setCustom(!isCustom)
94 track(
95 isCustom ? 'EditHandle:ViewCustomForm' : 'EditHandle:ViewProvidedForm',
96 )
97 }, [setCustom, isCustom, track])
98 const onPressSave = React.useCallback(async () => {
99 if (!userDomain) {
100 logger.error(`ChangeHandle: userDomain is undefined`, {
101 service: serviceInfo,
102 })
103 setError(`The service you've selected has no domains configured.`)
104 return
105 }
106
107 try {
108 track('EditHandle:SetNewHandle')
109 const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
110 logger.debug(`Updating handle to ${newHandle}`)
111 await updateHandle({
112 handle: newHandle,
113 })
114 await agent.resumeSession(agent.session!)
115 closeModal()
116 onChanged()
117 } catch (err: any) {
118 setError(cleanError(err))
119 logger.error('Failed to update handle', {handle, message: err})
120 } finally {
121 }
122 }, [
123 setError,
124 handle,
125 userDomain,
126 isCustom,
127 onChanged,
128 track,
129 closeModal,
130 updateHandle,
131 serviceInfo,
132 agent,
133 ])
134
135 // rendering
136 // =
137 return (
138 <View style={[s.flex1, pal.view]}>
139 <View style={[styles.title, pal.border]}>
140 <View style={styles.titleLeft}>
141 <TouchableOpacity
142 onPress={onPressCancel}
143 accessibilityRole="button"
144 accessibilityLabel={_(msg`Cancel change handle`)}
145 accessibilityHint={_(msg`Exits handle change process`)}
146 onAccessibilityEscape={onPressCancel}>
147 <Text type="lg" style={pal.textLight}>
148 <Trans>Cancel</Trans>
149 </Text>
150 </TouchableOpacity>
151 </View>
152 <Text
153 type="2xl-bold"
154 style={[styles.titleMiddle, pal.text]}
155 numberOfLines={1}>
156 <Trans>Change Handle</Trans>
157 </Text>
158 <View style={styles.titleRight}>
159 {isUpdateHandlePending ? (
160 <ActivityIndicator />
161 ) : canSave ? (
162 <TouchableOpacity
163 onPress={onPressSave}
164 accessibilityRole="button"
165 accessibilityLabel={_(msg`Save handle change`)}
166 accessibilityHint={_(msg`Saves handle change to ${handle}`)}>
167 <Text type="2xl-medium" style={pal.link}>
168 <Trans>Save</Trans>
169 </Text>
170 </TouchableOpacity>
171 ) : undefined}
172 </View>
173 </View>
174 <ScrollView style={styles.inner}>
175 {error !== '' && (
176 <View style={styles.errorContainer}>
177 <ErrorMessage message={error} />
178 </View>
179 )}
180
181 {isCustom ? (
182 <CustomHandleForm
183 currentAccount={currentAccount}
184 handle={handle}
185 isProcessing={isUpdateHandlePending}
186 canSave={canSave}
187 onToggleCustom={onToggleCustom}
188 setHandle={setHandle}
189 setCanSave={setCanSave}
190 onPressSave={onPressSave}
191 />
192 ) : (
193 <ProvidedHandleForm
194 handle={handle}
195 userDomain={userDomain}
196 isProcessing={isUpdateHandlePending}
197 onToggleCustom={onToggleCustom}
198 setHandle={setHandle}
199 setCanSave={setCanSave}
200 />
201 )}
202 </ScrollView>
203 </View>
204 )
205}
206
207/**
208 * The form for using a domain allocated by the PDS
209 */
210function ProvidedHandleForm({
211 userDomain,
212 handle,
213 isProcessing,
214 setHandle,
215 onToggleCustom,
216 setCanSave,
217}: {
218 userDomain: string
219 handle: string
220 isProcessing: boolean
221 setHandle: (v: string) => void
222 onToggleCustom: () => void
223 setCanSave: (v: boolean) => void
224}) {
225 const pal = usePalette('default')
226 const theme = useTheme()
227 const {_} = useLingui()
228
229 // events
230 // =
231 const onChangeHandle = React.useCallback(
232 (v: string) => {
233 const newHandle = makeValidHandle(v)
234 setHandle(newHandle)
235 setCanSave(newHandle.length > 0)
236 },
237 [setHandle, setCanSave],
238 )
239
240 // rendering
241 // =
242 return (
243 <>
244 <View style={[pal.btn, styles.textInputWrapper]}>
245 <FontAwesomeIcon
246 icon="at"
247 style={[pal.textLight, styles.textInputIcon]}
248 />
249 <TextInput
250 testID="setHandleInput"
251 style={[pal.text, styles.textInput]}
252 placeholder={_(msg`e.g. alice`)}
253 placeholderTextColor={pal.colors.textLight}
254 autoCapitalize="none"
255 keyboardAppearance={theme.colorScheme}
256 value={handle}
257 onChangeText={onChangeHandle}
258 editable={!isProcessing}
259 accessible={true}
260 accessibilityLabel={_(msg`Handle`)}
261 accessibilityHint={_(msg`Sets Bluesky username`)}
262 />
263 </View>
264 <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}>
265 <Trans>
266 Your full handle will be{' '}
267 <Text type="md-bold" style={pal.textLight}>
268 @{createFullHandle(handle, userDomain)}
269 </Text>
270 </Trans>
271 </Text>
272 <TouchableOpacity
273 onPress={onToggleCustom}
274 accessibilityRole="button"
275 accessibilityLabel={_(msg`Hosting provider`)}
276 accessibilityHint={_(msg`Opens modal for using custom domain`)}>
277 <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
278 <Trans>I have my own domain</Trans>
279 </Text>
280 </TouchableOpacity>
281 </>
282 )
283}
284
285/**
286 * The form for using a custom domain
287 */
288function CustomHandleForm({
289 currentAccount,
290 handle,
291 canSave,
292 isProcessing,
293 setHandle,
294 onToggleCustom,
295 onPressSave,
296 setCanSave,
297}: {
298 currentAccount: SessionAccount
299 handle: string
300 canSave: boolean
301 isProcessing: boolean
302 setHandle: (v: string) => void
303 onToggleCustom: () => void
304 onPressSave: () => void
305 setCanSave: (v: boolean) => void
306}) {
307 const pal = usePalette('default')
308 const palSecondary = usePalette('secondary')
309 const palError = usePalette('error')
310 const theme = useTheme()
311 const {_} = useLingui()
312 const [isVerifying, setIsVerifying] = React.useState(false)
313 const [error, setError] = React.useState<string>('')
314 const [isDNSForm, setDNSForm] = React.useState<boolean>(true)
315 const fetchDid = useFetchDid()
316 // events
317 // =
318 const onPressCopy = React.useCallback(() => {
319 setStringAsync(isDNSForm ? `did=${currentAccount.did}` : currentAccount.did)
320 Toast.show(_(msg`Copied to clipboard`))
321 }, [currentAccount, isDNSForm, _])
322 const onChangeHandle = React.useCallback(
323 (v: string) => {
324 setHandle(v)
325 setCanSave(false)
326 },
327 [setHandle, setCanSave],
328 )
329 const onPressVerify = React.useCallback(async () => {
330 if (canSave) {
331 onPressSave()
332 }
333 try {
334 setIsVerifying(true)
335 setError('')
336 const did = await fetchDid(handle)
337 if (did === currentAccount.did) {
338 setCanSave(true)
339 } else {
340 setError(`Incorrect DID returned (got ${did})`)
341 }
342 } catch (err: any) {
343 setError(cleanError(err))
344 logger.error('Failed to verify domain', {handle, error: err})
345 } finally {
346 setIsVerifying(false)
347 }
348 }, [
349 handle,
350 currentAccount,
351 setIsVerifying,
352 setCanSave,
353 setError,
354 canSave,
355 onPressSave,
356 fetchDid,
357 ])
358
359 // rendering
360 // =
361 return (
362 <>
363 <Text type="md" style={[pal.text, s.pb5, s.pl5]} nativeID="customDomain">
364 <Trans>Enter the domain you want to use</Trans>
365 </Text>
366 <View style={[pal.btn, styles.textInputWrapper]}>
367 <FontAwesomeIcon
368 icon="at"
369 style={[pal.textLight, styles.textInputIcon]}
370 />
371 <TextInput
372 testID="setHandleInput"
373 style={[pal.text, styles.textInput]}
374 placeholder={_(msg`e.g. alice.com`)}
375 placeholderTextColor={pal.colors.textLight}
376 autoCapitalize="none"
377 keyboardAppearance={theme.colorScheme}
378 value={handle}
379 onChangeText={onChangeHandle}
380 editable={!isProcessing}
381 accessibilityLabelledBy="customDomain"
382 accessibilityLabel={_(msg`Custom domain`)}
383 accessibilityHint={_(msg`Input your preferred hosting provider`)}
384 />
385 </View>
386 <View style={styles.spacer} />
387
388 <View style={[styles.selectableBtns]}>
389 <SelectableBtn
390 selected={isDNSForm}
391 label={_(msg`DNS Panel`)}
392 left
393 onSelect={() => setDNSForm(true)}
394 accessibilityHint={_(msg`Use the DNS panel`)}
395 style={s.flex1}
396 />
397 <SelectableBtn
398 selected={!isDNSForm}
399 label={_(msg`No DNS Panel`)}
400 right
401 onSelect={() => setDNSForm(false)}
402 accessibilityHint={_(msg`Use a file on your server`)}
403 style={s.flex1}
404 />
405 </View>
406 <View style={styles.spacer} />
407 {isDNSForm ? (
408 <>
409 <Text type="md" style={[pal.text, s.pb5, s.pl5]}>
410 <Trans>Add the following DNS record to your domain:</Trans>
411 </Text>
412 <View style={[styles.dnsTable, pal.btn]}>
413 <Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
414 <Trans>Host:</Trans>
415 </Text>
416 <View style={[styles.dnsValue]}>
417 <Text type="mono" style={[styles.monoText, pal.text]}>
418 _atproto
419 </Text>
420 </View>
421 <Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
422 <Trans>Type:</Trans>
423 </Text>
424 <View style={[styles.dnsValue]}>
425 <Text type="mono" style={[styles.monoText, pal.text]}>
426 TXT
427 </Text>
428 </View>
429 <Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
430 <Trans>Value:</Trans>
431 </Text>
432 <View style={[styles.dnsValue]}>
433 <Text type="mono" style={[styles.monoText, pal.text]}>
434 did={currentAccount.did}
435 </Text>
436 </View>
437 </View>
438 <Text type="md" style={[pal.text, s.pt20, s.pl5]}>
439 <Trans>This should create a domain record at:</Trans>
440 </Text>
441 <Text type="mono" style={[styles.monoText, pal.text, s.pt5, s.pl5]}>
442 _atproto.{handle}
443 </Text>
444 </>
445 ) : (
446 <>
447 <Text type="md" style={[pal.text, s.pb5, s.pl5]}>
448 <Trans>Upload a text file to:</Trans>
449 </Text>
450 <View style={[styles.valueContainer, pal.btn]}>
451 <View style={[styles.dnsValue]}>
452 <Text type="mono" style={[styles.monoText, pal.text]}>
453 https://{handle}/.well-known/atproto-did
454 </Text>
455 </View>
456 </View>
457 <View style={styles.spacer} />
458 <Text type="md" style={[pal.text, s.pb5, s.pl5]}>
459 <Trans>That contains the following:</Trans>
460 </Text>
461 <View style={[styles.valueContainer, pal.btn]}>
462 <View style={[styles.dnsValue]}>
463 <Text type="mono" style={[styles.monoText, pal.text]}>
464 {currentAccount.did}
465 </Text>
466 </View>
467 </View>
468 </>
469 )}
470
471 <View style={styles.spacer} />
472 <Button type="default" style={[s.p20, s.mb10]} onPress={onPressCopy}>
473 <Text type="xl" style={[pal.link, s.textCenter]}>
474 <Trans>
475 Copy {isDNSForm ? _(msg`Domain Value`) : _(msg`File Contents`)}
476 </Trans>
477 </Text>
478 </Button>
479 {canSave === true && (
480 <View style={[styles.message, palSecondary.view]}>
481 <Text type="md-medium" style={palSecondary.text}>
482 <Trans>Domain verified!</Trans>
483 </Text>
484 </View>
485 )}
486 {error ? (
487 <View style={[styles.message, palError.view]}>
488 <Text type="md-medium" style={palError.text}>
489 {error}
490 </Text>
491 </View>
492 ) : null}
493 <Button
494 type="primary"
495 style={[s.p20, isVerifying && styles.dimmed]}
496 onPress={onPressVerify}>
497 {isVerifying ? (
498 <ActivityIndicator color="white" />
499 ) : (
500 <Text type="xl-medium" style={[s.white, s.textCenter]}>
501 {canSave
502 ? _(msg`Update to ${handle}`)
503 : isDNSForm
504 ? _(msg`Verify DNS Record`)
505 : _(msg`Verify Text File`)}
506 </Text>
507 )}
508 </Button>
509 <View style={styles.spacer} />
510 <TouchableOpacity
511 onPress={onToggleCustom}
512 accessibilityLabel={_(msg`Use default provider`)}
513 accessibilityHint={_(msg`Use bsky.social as hosting provider`)}>
514 <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
515 <Trans>Nevermind, create a handle for me</Trans>
516 </Text>
517 </TouchableOpacity>
518 </>
519 )
520}
521
522const styles = StyleSheet.create({
523 inner: {
524 padding: 14,
525 },
526 footer: {
527 padding: 14,
528 },
529 spacer: {
530 height: 20,
531 },
532 dimmed: {
533 opacity: 0.7,
534 },
535
536 selectableBtns: {
537 flexDirection: 'row',
538 },
539
540 title: {
541 flexDirection: 'row',
542 alignItems: 'center',
543 paddingTop: 25,
544 paddingHorizontal: 20,
545 paddingBottom: 15,
546 borderBottomWidth: 1,
547 },
548 titleLeft: {
549 width: 80,
550 },
551 titleRight: {
552 width: 80,
553 flexDirection: 'row',
554 justifyContent: 'flex-end',
555 },
556 titleMiddle: {
557 flex: 1,
558 textAlign: 'center',
559 fontSize: 21,
560 },
561
562 textInputWrapper: {
563 borderRadius: 8,
564 flexDirection: 'row',
565 alignItems: 'center',
566 },
567 textInputIcon: {
568 marginLeft: 12,
569 },
570 textInput: {
571 flex: 1,
572 width: '100%',
573 paddingVertical: 10,
574 paddingHorizontal: 8,
575 fontSize: 17,
576 letterSpacing: 0.25,
577 fontWeight: '400',
578 borderRadius: 10,
579 },
580
581 valueContainer: {
582 borderRadius: 4,
583 paddingVertical: 16,
584 },
585
586 dnsTable: {
587 borderRadius: 4,
588 paddingTop: 2,
589 paddingBottom: 16,
590 },
591 dnsLabel: {
592 paddingHorizontal: 14,
593 paddingTop: 10,
594 },
595 dnsValue: {
596 paddingHorizontal: 14,
597 borderRadius: 4,
598 },
599 monoText: {
600 fontSize: 18,
601 lineHeight: 20,
602 },
603
604 message: {
605 paddingHorizontal: 12,
606 paddingVertical: 10,
607 borderRadius: 8,
608 marginBottom: 10,
609 },
610
611 btn: {
612 flexDirection: 'row',
613 alignItems: 'center',
614 justifyContent: 'center',
615 width: '100%',
616 borderRadius: 32,
617 padding: 10,
618 marginBottom: 10,
619 },
620 errorContainer: {marginBottom: 10},
621})