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