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