forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useCallback, useMemo, useState} from 'react'
2import {View} from 'react-native'
3import Animated, {
4 FadeIn,
5 FadeOut,
6 LayoutAnimationConfig,
7 LinearTransition,
8 SlideInLeft,
9 SlideInRight,
10 SlideOutLeft,
11 SlideOutRight,
12} from 'react-native-reanimated'
13import {type ComAtprotoServerDescribeServer} from '@atproto/api'
14import {msg} from '@lingui/core/macro'
15import {useLingui} from '@lingui/react'
16import {Trans} from '@lingui/react/macro'
17import {useMutation, useQueryClient} from '@tanstack/react-query'
18
19import {HITSLOP_10, urls} from '#/lib/constants'
20import {cleanError} from '#/lib/strings/errors'
21import {
22 createFullHandle,
23 sanitizeHandle,
24 validateServiceHandle,
25} from '#/lib/strings/handles'
26import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
27import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
28import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
29import {useServiceQuery} from '#/state/queries/service'
30import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
31import {useAgent, useSession} from '#/state/session'
32import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
33import {atoms as a, native, useBreakpoints, useTheme} from '#/alf'
34import {Admonition} from '#/components/Admonition'
35import {Button, ButtonIcon, ButtonText} from '#/components/Button'
36import * as Dialog from '#/components/Dialog'
37import * as SegmentedControl from '#/components/forms/SegmentedControl'
38import * as TextField from '#/components/forms/TextField'
39import {
40 ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon,
41 ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon,
42} from '#/components/icons/Arrow'
43import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
44import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
45import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
46import {InlineLinkText} from '#/components/Link'
47import {Loader} from '#/components/Loader'
48import {Text} from '#/components/Typography'
49import {useSimpleVerificationState} from '#/components/verification'
50import {CopyButton} from './CopyButton'
51
52export function ChangeHandleDialog({
53 control,
54}: {
55 control: Dialog.DialogControlProps
56}) {
57 return (
58 <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
59 <ChangeHandleDialogInner />
60 </Dialog.Outer>
61 )
62}
63
64function ChangeHandleDialogInner() {
65 const control = Dialog.useDialogContext()
66 const {_} = useLingui()
67 const {currentAccount} = useSession()
68 const enableSquareButtons = useEnableSquareButtons()
69 const {
70 data: serviceInfo,
71 error: serviceInfoError,
72 refetch,
73 } = useServiceQuery(currentAccount!.service)
74
75 const [page, setPage] = useState<'provided-handle' | 'own-handle'>(
76 'provided-handle',
77 )
78
79 const cancelButton = useCallback(
80 () => (
81 <Button
82 label={_(msg`Cancel`)}
83 onPress={() => control.close()}
84 size="small"
85 color="primary"
86 variant="ghost"
87 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]}>
88 <ButtonText style={[a.text_md]}>
89 <Trans>Cancel</Trans>
90 </ButtonText>
91 </Button>
92 ),
93 [control, _, enableSquareButtons],
94 )
95
96 return (
97 <Dialog.ScrollableInner
98 label={_(msg`Change Handle`)}
99 header={
100 <Dialog.Header renderLeft={cancelButton}>
101 <Dialog.HeaderText>
102 <Trans>Change Handle</Trans>
103 </Dialog.HeaderText>
104 </Dialog.Header>
105 }
106 contentContainerStyle={[a.pt_0, a.px_0]}>
107 <View style={[a.flex_1, a.pt_lg, a.px_xl]}>
108 {serviceInfoError ? (
109 <ErrorScreen
110 title={_(msg`Oops!`)}
111 message={_(msg`There was an issue fetching your service info`)}
112 details={cleanError(serviceInfoError)}
113 onPressTryAgain={refetch}
114 />
115 ) : serviceInfo ? (
116 <LayoutAnimationConfig skipEntering skipExiting>
117 {page === 'provided-handle' ? (
118 <Animated.View
119 key={page}
120 entering={native(SlideInLeft)}
121 exiting={native(SlideOutLeft)}>
122 <ProvidedHandlePage
123 serviceInfo={serviceInfo}
124 goToOwnHandle={() => setPage('own-handle')}
125 />
126 </Animated.View>
127 ) : (
128 <Animated.View
129 key={page}
130 entering={native(SlideInRight)}
131 exiting={native(SlideOutRight)}>
132 <OwnHandlePage
133 goToServiceHandle={() => setPage('provided-handle')}
134 />
135 </Animated.View>
136 )}
137 </LayoutAnimationConfig>
138 ) : (
139 <View style={[a.flex_1, a.justify_center, a.align_center, a.py_4xl]}>
140 <Loader size="xl" />
141 </View>
142 )}
143 </View>
144 </Dialog.ScrollableInner>
145 )
146}
147
148function ProvidedHandlePage({
149 serviceInfo,
150 goToOwnHandle,
151}: {
152 serviceInfo: ComAtprotoServerDescribeServer.OutputSchema
153 goToOwnHandle: () => void
154}) {
155 const {_} = useLingui()
156 const [subdomain, setSubdomain] = useState('')
157 const agent = useAgent()
158 const control = Dialog.useDialogContext()
159 const {currentAccount} = useSession()
160 const queryClient = useQueryClient()
161 const profile = useCurrentAccountProfile()
162 const verification = useSimpleVerificationState({
163 profile,
164 })
165
166 const {
167 mutate: changeHandle,
168 isPending,
169 error,
170 isSuccess,
171 } = useUpdateHandleMutation({
172 onSuccess: () => {
173 if (currentAccount) {
174 queryClient.invalidateQueries({
175 queryKey: RQKEY_PROFILE(currentAccount.did),
176 })
177 }
178 if ('resumeSession' in agent && agent.session) {
179 agent.resumeSession(agent.session).then(() => control.close())
180 } else {
181 control.close()
182 }
183 },
184 })
185
186 const host = serviceInfo.availableUserDomains[0]
187
188 const validation = useMemo(
189 () => validateServiceHandle(subdomain, host),
190 [subdomain, host],
191 )
192
193 const isInvalid =
194 !validation.handleChars ||
195 !validation.hyphenStartOrEnd ||
196 !validation.totalLength
197
198 return (
199 <LayoutAnimationConfig skipEntering>
200 <View style={[a.flex_1, a.gap_md]}>
201 {isSuccess && (
202 <Animated.View entering={FadeIn} exiting={FadeOut}>
203 <SuccessMessage text={_(msg`Handle changed!`)} />
204 </Animated.View>
205 )}
206 {error && (
207 <Animated.View entering={FadeIn} exiting={FadeOut}>
208 <ChangeHandleError error={error} />
209 </Animated.View>
210 )}
211 <Animated.View
212 layout={native(LinearTransition)}
213 style={[a.flex_1, a.gap_md]}>
214 {verification.isVerified && verification.role === 'default' && (
215 <Admonition type="error">
216 <Trans>
217 You are verified. You will lose your verification status if you
218 change your handle.{' '}
219 <InlineLinkText
220 label={_(
221 msg({
222 message: `Learn more`,
223 context: `english-only-resource`,
224 }),
225 )}
226 to={urls.website.blog.initialVerificationAnnouncement}>
227 <Trans context="english-only-resource">Learn more.</Trans>
228 </InlineLinkText>
229 </Trans>
230 </Admonition>
231 )}
232 <View>
233 <TextField.LabelText>
234 <Trans>New handle</Trans>
235 </TextField.LabelText>
236 <TextField.Root isInvalid={isInvalid}>
237 <TextField.Icon icon={AtIcon} />
238 <Dialog.Input
239 editable={!isPending}
240 defaultValue={subdomain}
241 onChangeText={text => setSubdomain(text)}
242 label={_(msg`New handle`)}
243 placeholder={_(msg`e.g. alice`)}
244 autoCapitalize="none"
245 autoCorrect={false}
246 />
247 <TextField.SuffixText label={host} style={[{maxWidth: '40%'}]}>
248 {host}
249 </TextField.SuffixText>
250 </TextField.Root>
251 </View>
252 <Text>
253 <Trans>
254 Your full handle will be{' '}
255 <Text style={[a.font_semi_bold]}>
256 @{createFullHandle(subdomain, host)}
257 </Text>
258 </Trans>
259 </Text>
260 <Button
261 label={_(msg`Save new handle`)}
262 variant="solid"
263 size="large"
264 color={validation.overall ? 'primary' : 'secondary'}
265 disabled={!validation.overall}
266 onPress={() => {
267 if (validation.overall) {
268 changeHandle({handle: createFullHandle(subdomain, host)})
269 }
270 }}>
271 {isPending ? (
272 <ButtonIcon icon={Loader} />
273 ) : (
274 <ButtonText>
275 <Trans>Save</Trans>
276 </ButtonText>
277 )}
278 </Button>
279 <Text style={[a.leading_snug]}>
280 <Trans>
281 If you have your own domain, you can use that as your handle. This
282 lets you self-verify your identity.{' '}
283 <InlineLinkText
284 label={_(
285 msg({
286 message: `Learn more`,
287 context: `english-only-resource`,
288 }),
289 )}
290 to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial"
291 style={[a.font_semi_bold]}
292 disableMismatchWarning>
293 Learn more here.
294 </InlineLinkText>
295 </Trans>
296 </Text>
297 <Button
298 label={_(msg`I have my own domain`)}
299 variant="outline"
300 color="primary"
301 size="large"
302 onPress={goToOwnHandle}>
303 <ButtonText>
304 <Trans>I have my own domain</Trans>
305 </ButtonText>
306 <ButtonIcon icon={ArrowRightIcon} position="right" />
307 </Button>
308 </Animated.View>
309 </View>
310 </LayoutAnimationConfig>
311 )
312}
313
314function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
315 const {_} = useLingui()
316 const t = useTheme()
317 const {currentAccount} = useSession()
318 const [dnsPanel, setDNSPanel] = useState(true)
319 const [domain, setDomain] = useState('')
320 const agent = useAgent()
321 const control = Dialog.useDialogContext()
322 const fetchDid = useFetchDid()
323 const queryClient = useQueryClient()
324
325 const {
326 mutate: changeHandle,
327 isPending,
328 error,
329 isSuccess,
330 } = useUpdateHandleMutation({
331 onSuccess: () => {
332 if (currentAccount) {
333 queryClient.invalidateQueries({
334 queryKey: RQKEY_PROFILE(currentAccount.did),
335 })
336 }
337 if ('resumeSession' in agent && agent.session) {
338 agent.resumeSession(agent.session).then(() => control.close())
339 } else {
340 control.close()
341 }
342 },
343 })
344
345 const {
346 mutate: verify,
347 isPending: isVerifyPending,
348 isSuccess: isVerified,
349 error: verifyError,
350 reset: resetVerification,
351 } = useMutation<true, Error | DidMismatchError>({
352 mutationKey: ['verify-handle', domain],
353 mutationFn: async () => {
354 const did = await fetchDid(domain)
355 if (did !== currentAccount?.did) {
356 throw new DidMismatchError(did)
357 }
358 return true
359 },
360 })
361
362 return (
363 <View style={[a.flex_1, a.gap_lg]}>
364 {isSuccess && (
365 <Animated.View entering={FadeIn} exiting={FadeOut}>
366 <SuccessMessage text={_(msg`Handle changed!`)} />
367 </Animated.View>
368 )}
369 {error && (
370 <Animated.View entering={FadeIn} exiting={FadeOut}>
371 <ChangeHandleError error={error} />
372 </Animated.View>
373 )}
374 {verifyError && (
375 <Animated.View entering={FadeIn} exiting={FadeOut}>
376 <Admonition type="error">
377 {verifyError instanceof DidMismatchError ? (
378 <Trans>
379 Wrong DID returned from server. Received: {verifyError.did}
380 </Trans>
381 ) : (
382 <Trans>Failed to verify handle. Please try again.</Trans>
383 )}
384 </Admonition>
385 </Animated.View>
386 )}
387 <Animated.View
388 layout={native(LinearTransition)}
389 style={[a.flex_1, a.gap_md, a.overflow_hidden]}>
390 <View>
391 <TextField.LabelText>
392 <Trans>Enter the domain you want to use</Trans>
393 </TextField.LabelText>
394 <TextField.Root>
395 <TextField.Icon icon={AtIcon} />
396 <Dialog.Input
397 label={_(msg`New handle`)}
398 placeholder={_(msg`e.g. alice.com`)}
399 editable={!isPending}
400 defaultValue={domain}
401 onChangeText={text => {
402 setDomain(text)
403 resetVerification()
404 }}
405 autoCapitalize="none"
406 autoCorrect={false}
407 />
408 </TextField.Root>
409 </View>
410 <SegmentedControl.Root
411 label={_(msg`Choose domain verification method`)}
412 type="tabs"
413 value={dnsPanel ? 'dns' : 'file'}
414 onChange={values => setDNSPanel(values === 'dns')}>
415 <SegmentedControl.Item value="dns" label={_(msg`DNS Panel`)}>
416 <SegmentedControl.ItemText>
417 <Trans>DNS Panel</Trans>
418 </SegmentedControl.ItemText>
419 </SegmentedControl.Item>
420 <SegmentedControl.Item value="file" label={_(msg`No DNS Panel`)}>
421 <SegmentedControl.ItemText>
422 <Trans>No DNS Panel</Trans>
423 </SegmentedControl.ItemText>
424 </SegmentedControl.Item>
425 </SegmentedControl.Root>
426 {dnsPanel ? (
427 <>
428 <Text>
429 <Trans>Add the following DNS record to your domain:</Trans>
430 </Text>
431 <View
432 style={[
433 t.atoms.bg_contrast_25,
434 a.rounded_sm,
435 a.p_md,
436 a.border,
437 t.atoms.border_contrast_low,
438 ]}>
439 <Text style={[t.atoms.text_contrast_medium]}>
440 <Trans>Host:</Trans>
441 </Text>
442 <View style={[a.py_xs]}>
443 <CopyButton
444 color="secondary"
445 value="_atproto"
446 label={_(msg`Copy host`)}
447 style={[a.bg_transparent]}
448 hoverStyle={[a.bg_transparent]}
449 hitSlop={HITSLOP_10}>
450 <Text style={[a.text_md, a.flex_1]}>_atproto</Text>
451 <ButtonIcon icon={CopyIcon} />
452 </CopyButton>
453 </View>
454 <Text style={[a.mt_xs, t.atoms.text_contrast_medium]}>
455 <Trans>Type:</Trans>
456 </Text>
457 <View style={[a.py_xs]}>
458 <Text style={[a.text_md]}>TXT</Text>
459 </View>
460 <Text style={[a.mt_xs, t.atoms.text_contrast_medium]}>
461 <Trans>Value:</Trans>
462 </Text>
463 <View style={[a.py_xs]}>
464 <CopyButton
465 color="secondary"
466 value={'did=' + currentAccount?.did}
467 label={_(msg`Copy TXT record value`)}
468 style={[a.bg_transparent]}
469 hoverStyle={[a.bg_transparent]}
470 hitSlop={HITSLOP_10}>
471 <Text style={[a.text_md, a.flex_1]}>
472 did={currentAccount?.did}
473 </Text>
474 <ButtonIcon icon={CopyIcon} />
475 </CopyButton>
476 </View>
477 </View>
478 <Text>
479 <Trans>This should create a domain record at:</Trans>
480 </Text>
481 <View
482 style={[
483 t.atoms.bg_contrast_25,
484 a.rounded_sm,
485 a.p_md,
486 a.border,
487 t.atoms.border_contrast_low,
488 ]}>
489 <Text style={[a.text_md]}>_atproto.{domain}</Text>
490 </View>
491 </>
492 ) : (
493 <>
494 <Text>
495 <Trans>Upload a text file to:</Trans>
496 </Text>
497 <View
498 style={[
499 t.atoms.bg_contrast_25,
500 a.rounded_sm,
501 a.p_md,
502 a.border,
503 t.atoms.border_contrast_low,
504 ]}>
505 <Text style={[a.text_md]}>
506 https://{domain}/.well-known/atproto-did
507 </Text>
508 </View>
509 <Text>
510 <Trans>That contains the following:</Trans>
511 </Text>
512 <CopyButton
513 value={currentAccount?.did ?? ''}
514 label={_(msg`Copy DID`)}
515 size="large"
516 shape="rectangular"
517 color="secondary"
518 style={[
519 a.px_md,
520 a.border,
521 t.atoms.border_contrast_low,
522 t.atoms.bg_contrast_25,
523 ]}>
524 <Text style={[a.text_md, a.flex_1]}>{currentAccount?.did}</Text>
525 <ButtonIcon icon={CopyIcon} />
526 </CopyButton>
527 </>
528 )}
529 </Animated.View>
530 {isVerified && (
531 <Animated.View
532 entering={FadeIn}
533 exiting={FadeOut}
534 layout={native(LinearTransition)}>
535 <SuccessMessage text={_(msg`Domain verified!`)} />
536 </Animated.View>
537 )}
538 <Animated.View layout={native(LinearTransition)}>
539 {currentAccount?.handle?.endsWith('.bsky.social') && (
540 <Admonition type="info" style={[a.mb_md]}>
541 <Trans>
542 Your current handle{' '}
543 <Text style={[a.font_semi_bold]}>
544 {sanitizeHandle(currentAccount?.handle || '', '@')}
545 </Text>{' '}
546 will automatically remain reserved for you. You can switch back to
547 it at any time from this account.
548 </Trans>
549 </Admonition>
550 )}
551 <Button
552 label={
553 isVerified
554 ? _(msg`Update to ${domain}`)
555 : dnsPanel
556 ? _(msg`Verify DNS Record`)
557 : _(msg`Verify Text File`)
558 }
559 variant="solid"
560 size="large"
561 color="primary"
562 disabled={domain.trim().length === 0}
563 onPress={() => {
564 if (isVerified) {
565 changeHandle({handle: domain})
566 } else {
567 verify()
568 }
569 }}>
570 {isPending || isVerifyPending ? (
571 <ButtonIcon icon={Loader} />
572 ) : (
573 <ButtonText>
574 {isVerified ? (
575 <Trans>Update to {domain}</Trans>
576 ) : dnsPanel ? (
577 <Trans>Verify DNS Record</Trans>
578 ) : (
579 <Trans>Verify Text File</Trans>
580 )}
581 </ButtonText>
582 )}
583 </Button>
584
585 <Button
586 label={_(msg`Use default provider`)}
587 accessibilityHint={_(msg`Returns to previous page`)}
588 onPress={goToServiceHandle}
589 variant="outline"
590 color="secondary"
591 size="large"
592 style={[a.mt_sm]}>
593 <ButtonIcon icon={ArrowLeftIcon} position="left" />
594 <ButtonText>
595 <Trans>Nevermind, create a handle for me</Trans>
596 </ButtonText>
597 </Button>
598 </Animated.View>
599 </View>
600 )
601}
602
603class DidMismatchError extends Error {
604 did: string
605 constructor(did: string) {
606 super('DID mismatch')
607 this.name = 'DidMismatchError'
608 this.did = did
609 }
610}
611
612function ChangeHandleError({error}: {error: unknown}) {
613 const {_} = useLingui()
614
615 let message = _(msg`Failed to change handle. Please try again.`)
616
617 if (error instanceof Error) {
618 if (error.message.startsWith('Handle already taken')) {
619 message = _(msg`Handle already taken. Please try a different one.`)
620 } else if (error.message === 'Reserved handle') {
621 message = _(msg`This handle is reserved. Please try a different one.`)
622 } else if (error.message === 'Handle too long') {
623 message = _(msg`Handle too long. Please try a shorter one.`)
624 } else if (error.message === 'Input/handle must be a valid handle') {
625 message = _(msg`Invalid handle. Please try a different one.`)
626 } else if (error.message === 'Rate Limit Exceeded') {
627 message = _(
628 msg`Rate limit exceeded – you've tried to change your handle too many times in a short period. Please wait a minute before trying again.`,
629 )
630 }
631 }
632
633 return <Admonition type="error">{message}</Admonition>
634}
635
636function SuccessMessage({text}: {text: string}) {
637 const {gtMobile} = useBreakpoints()
638 const t = useTheme()
639 const enableSquareButtons = useEnableSquareButtons()
640 return (
641 <View
642 style={[
643 a.flex_1,
644 a.gap_md,
645 a.flex_row,
646 a.justify_center,
647 a.align_center,
648 gtMobile ? a.px_md : a.px_sm,
649 a.py_xs,
650 t.atoms.border_contrast_low,
651 ]}>
652 <View
653 style={[
654 {height: 20, width: 20},
655 enableSquareButtons ? a.rounded_sm : a.rounded_full,
656 a.align_center,
657 a.justify_center,
658 {backgroundColor: t.palette.positive_500},
659 ]}>
660 <CheckIcon fill={t.palette.white} size="xs" />
661 </View>
662 <Text style={[a.text_md]}>{text}</Text>
663 </View>
664 )
665}