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