about summary refs log tree commit diff
path: root/src/screens/Settings/components/ChangeHandleDialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Settings/components/ChangeHandleDialog.tsx')
-rw-r--r--src/screens/Settings/components/ChangeHandleDialog.tsx602
1 files changed, 602 insertions, 0 deletions
diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx
new file mode 100644
index 000000000..e76d6257f
--- /dev/null
+++ b/src/screens/Settings/components/ChangeHandleDialog.tsx
@@ -0,0 +1,602 @@
+import React, {useCallback, useMemo, useState} from 'react'
+import {useWindowDimensions, View} from 'react-native'
+import Animated, {
+  FadeIn,
+  FadeOut,
+  LayoutAnimationConfig,
+  LinearTransition,
+  SlideInLeft,
+  SlideInRight,
+  SlideOutLeft,
+  SlideOutRight,
+} from 'react-native-reanimated'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {HITSLOP_10} from '#/lib/constants'
+import {cleanError} from '#/lib/strings/errors'
+import {createFullHandle, validateHandle} from '#/lib/strings/handles'
+import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
+import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
+import {useServiceQuery} from '#/state/queries/service'
+import {useAgent, useSession} from '#/state/session'
+import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
+import {atoms as a, native, useBreakpoints, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
+import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
+import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
+import {InlineLinkText} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {CopyButton} from './CopyButton'
+
+export function ChangeHandleDialog({
+  control,
+}: {
+  control: Dialog.DialogControlProps
+}) {
+  const {height} = useWindowDimensions()
+
+  return (
+    <Dialog.Outer control={control} nativeOptions={{minHeight: height}}>
+      <ChangeHandleDialogInner />
+    </Dialog.Outer>
+  )
+}
+
+function ChangeHandleDialogInner() {
+  const control = Dialog.useDialogContext()
+  const {_} = useLingui()
+  const agent = useAgent()
+  const {
+    data: serviceInfo,
+    error: serviceInfoError,
+    refetch,
+  } = useServiceQuery(agent.serviceUrl.toString())
+
+  const [page, setPage] = useState<'provided-handle' | 'own-handle'>(
+    'provided-handle',
+  )
+
+  const cancelButton = useCallback(
+    () => (
+      <Button
+        label={_(msg`Cancel`)}
+        onPress={() => control.close()}
+        size="small"
+        color="primary"
+        variant="ghost"
+        style={[a.rounded_full]}>
+        <ButtonText style={[a.text_md]}>
+          <Trans>Cancel</Trans>
+        </ButtonText>
+      </Button>
+    ),
+    [control, _],
+  )
+
+  return (
+    <Dialog.ScrollableInner
+      label={_(msg`Change Handle`)}
+      style={[a.overflow_hidden]}
+      header={
+        <Dialog.Header renderLeft={cancelButton}>
+          <Dialog.HeaderText>
+            <Trans>Change Handle</Trans>
+          </Dialog.HeaderText>
+        </Dialog.Header>
+      }
+      contentContainerStyle={[a.pt_0, a.px_0]}>
+      <View style={[a.flex_1, a.pt_lg, a.px_xl]}>
+        {serviceInfoError ? (
+          <ErrorScreen
+            title={_(msg`Oops!`)}
+            message={_(msg`There was an issue fetching your service info`)}
+            details={cleanError(serviceInfoError)}
+            onPressTryAgain={refetch}
+          />
+        ) : serviceInfo ? (
+          <LayoutAnimationConfig skipEntering skipExiting>
+            {page === 'provided-handle' ? (
+              <Animated.View
+                key={page}
+                entering={native(SlideInLeft)}
+                exiting={native(SlideOutLeft)}>
+                <ProvidedHandlePage
+                  serviceInfo={serviceInfo}
+                  goToOwnHandle={() => setPage('own-handle')}
+                />
+              </Animated.View>
+            ) : (
+              <Animated.View
+                key={page}
+                entering={native(SlideInRight)}
+                exiting={native(SlideOutRight)}>
+                <OwnHandlePage
+                  goToServiceHandle={() => setPage('provided-handle')}
+                />
+              </Animated.View>
+            )}
+          </LayoutAnimationConfig>
+        ) : (
+          <View style={[a.flex_1, a.justify_center, a.align_center, a.py_4xl]}>
+            <Loader size="xl" />
+          </View>
+        )}
+      </View>
+    </Dialog.ScrollableInner>
+  )
+}
+
+function ProvidedHandlePage({
+  serviceInfo,
+  goToOwnHandle,
+}: {
+  serviceInfo: ComAtprotoServerDescribeServer.OutputSchema
+  goToOwnHandle: () => void
+}) {
+  const {_} = useLingui()
+  const [subdomain, setSubdomain] = useState('')
+  const agent = useAgent()
+  const control = Dialog.useDialogContext()
+  const {currentAccount} = useSession()
+  const queryClient = useQueryClient()
+
+  const {
+    mutate: changeHandle,
+    isPending,
+    error,
+    isSuccess,
+  } = useUpdateHandleMutation({
+    onSuccess: () => {
+      if (currentAccount) {
+        queryClient.invalidateQueries({
+          queryKey: RQKEY_PROFILE(currentAccount.did),
+        })
+      }
+      agent.resumeSession(agent.session!).then(() => control.close())
+    },
+  })
+
+  const host = serviceInfo.availableUserDomains[0]
+
+  const validation = useMemo(
+    () => validateHandle(subdomain, host),
+    [subdomain, host],
+  )
+
+  const isTooLong = subdomain.length > 18
+  const isInvalid =
+    isTooLong ||
+    !validation.handleChars ||
+    !validation.hyphenStartOrEnd ||
+    !validation.totalLength
+
+  return (
+    <LayoutAnimationConfig skipEntering>
+      <View style={[a.flex_1, a.gap_md]}>
+        {isSuccess && (
+          <Animated.View entering={FadeIn} exiting={FadeOut}>
+            <SuccessMessage text={_(msg`Handle changed!`)} />
+          </Animated.View>
+        )}
+        {error && (
+          <Animated.View entering={FadeIn} exiting={FadeOut}>
+            <ChangeHandleError error={error} />
+          </Animated.View>
+        )}
+        <Animated.View
+          layout={native(LinearTransition)}
+          style={[a.flex_1, a.gap_md]}>
+          <View>
+            <TextField.LabelText>
+              <Trans>New handle</Trans>
+            </TextField.LabelText>
+            <TextField.Root isInvalid={isInvalid}>
+              <TextField.Icon icon={AtIcon} />
+              <Dialog.Input
+                editable={!isPending}
+                defaultValue={subdomain}
+                onChangeText={text => setSubdomain(text)}
+                label={_(msg`New handle`)}
+                placeholder={_(msg`e.g. alice`)}
+                autoCapitalize="none"
+                autoCorrect={false}
+              />
+              <TextField.SuffixText label={host} style={[{maxWidth: '40%'}]}>
+                {host}
+              </TextField.SuffixText>
+            </TextField.Root>
+          </View>
+          <Text>
+            <Trans>
+              Your full handle will be{' '}
+              <Text style={[a.font_bold]}>
+                @{createFullHandle(subdomain, host)}
+              </Text>
+            </Trans>
+          </Text>
+          <Button
+            label={_(msg`Save new handle`)}
+            variant="solid"
+            size="large"
+            color={validation.overall && !isTooLong ? 'primary' : 'secondary'}
+            disabled={!validation.overall && !isTooLong}
+            onPress={() => {
+              if (validation.overall && !isTooLong) {
+                changeHandle({handle: createFullHandle(subdomain, host)})
+              }
+            }}>
+            {isPending ? (
+              <ButtonIcon icon={Loader} />
+            ) : (
+              <ButtonText>
+                <Trans>Save</Trans>
+              </ButtonText>
+            )}
+          </Button>
+          <Text style={[a.leading_snug]}>
+            <Trans>
+              If you have your own domain, you can use that as your handle. This
+              lets you self-verify your identity –{' '}
+              <InlineLinkText
+                label={_(msg`learn more`)}
+                to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial"
+                style={[a.font_bold]}
+                disableMismatchWarning>
+                learn more
+              </InlineLinkText>
+              .
+            </Trans>
+          </Text>
+          <Button
+            label={_(msg`I have my own domain`)}
+            variant="outline"
+            color="primary"
+            size="large"
+            onPress={goToOwnHandle}>
+            <ButtonText>
+              <Trans>I have my own domain</Trans>
+            </ButtonText>
+            <ButtonIcon icon={ArrowRightIcon} />
+          </Button>
+        </Animated.View>
+      </View>
+    </LayoutAnimationConfig>
+  )
+}
+
+function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+  const [dnsPanel, setDNSPanel] = useState(true)
+  const [domain, setDomain] = useState('')
+  const agent = useAgent()
+  const control = Dialog.useDialogContext()
+  const fetchDid = useFetchDid()
+  const queryClient = useQueryClient()
+
+  const {
+    mutate: changeHandle,
+    isPending,
+    error,
+    isSuccess,
+  } = useUpdateHandleMutation({
+    onSuccess: () => {
+      if (currentAccount) {
+        queryClient.invalidateQueries({
+          queryKey: RQKEY_PROFILE(currentAccount.did),
+        })
+      }
+      agent.resumeSession(agent.session!).then(() => control.close())
+    },
+  })
+
+  const {
+    mutate: verify,
+    isPending: isVerifyPending,
+    isSuccess: isVerified,
+    error: verifyError,
+    reset: resetVerification,
+  } = useMutation<true, Error | DidMismatchError>({
+    mutationKey: ['verify-handle', domain],
+    mutationFn: async () => {
+      const did = await fetchDid(domain)
+      if (did !== currentAccount?.did) {
+        throw new DidMismatchError(did)
+      }
+      return true
+    },
+  })
+
+  return (
+    <View style={[a.flex_1, a.gap_lg]}>
+      {isSuccess && (
+        <Animated.View entering={FadeIn} exiting={FadeOut}>
+          <SuccessMessage text={_(msg`Handle changed!`)} />
+        </Animated.View>
+      )}
+      {error && (
+        <Animated.View entering={FadeIn} exiting={FadeOut}>
+          <ChangeHandleError error={error} />
+        </Animated.View>
+      )}
+      {verifyError && (
+        <Animated.View entering={FadeIn} exiting={FadeOut}>
+          <Admonition type="error">
+            {verifyError instanceof DidMismatchError ? (
+              <Trans>
+                Wrong DID returned from server. Received: {verifyError.did}
+              </Trans>
+            ) : (
+              <Trans>Failed to verify handle. Please try again.</Trans>
+            )}
+          </Admonition>
+        </Animated.View>
+      )}
+      <Animated.View
+        layout={native(LinearTransition)}
+        style={[a.flex_1, a.gap_md, a.overflow_hidden]}>
+        <View>
+          <TextField.LabelText>
+            <Trans>Enter the domain you want to use</Trans>
+          </TextField.LabelText>
+          <TextField.Root>
+            <TextField.Icon icon={AtIcon} />
+            <Dialog.Input
+              label={_(msg`New handle`)}
+              placeholder={_(msg`e.g. alice.com`)}
+              editable={!isPending}
+              defaultValue={domain}
+              onChangeText={text => {
+                setDomain(text)
+                resetVerification()
+              }}
+              autoCapitalize="none"
+              autoCorrect={false}
+            />
+          </TextField.Root>
+        </View>
+        <ToggleButton.Group
+          label={_(msg`Choose domain verification method`)}
+          values={[dnsPanel ? 'dns' : 'file']}
+          onChange={values => setDNSPanel(values[0] === 'dns')}>
+          <ToggleButton.Button name="dns" label={_(msg`DNS Panel`)}>
+            <ToggleButton.ButtonText>
+              <Trans>DNS Panel</Trans>
+            </ToggleButton.ButtonText>
+          </ToggleButton.Button>
+          <ToggleButton.Button name="file" label={_(msg`No DNS Panel`)}>
+            <ToggleButton.ButtonText>
+              <Trans>No DNS Panel</Trans>
+            </ToggleButton.ButtonText>
+          </ToggleButton.Button>
+        </ToggleButton.Group>
+        {dnsPanel ? (
+          <>
+            <Text>
+              <Trans>Add the following DNS record to your domain:</Trans>
+            </Text>
+            <View
+              style={[
+                t.atoms.bg_contrast_25,
+                a.rounded_sm,
+                a.p_md,
+                a.border,
+                t.atoms.border_contrast_low,
+              ]}>
+              <Text style={[t.atoms.text_contrast_medium]}>
+                <Trans>Host:</Trans>
+              </Text>
+              <View style={[a.py_xs]}>
+                <CopyButton
+                  variant="solid"
+                  color="secondary"
+                  value="_atproto"
+                  label={_(msg`Copy host`)}
+                  hoverStyle={[a.bg_transparent]}
+                  hitSlop={HITSLOP_10}>
+                  <Text style={[a.text_md, a.flex_1]}>_atproto</Text>
+                  <ButtonIcon icon={CopyIcon} />
+                </CopyButton>
+              </View>
+              <Text style={[a.mt_xs, t.atoms.text_contrast_medium]}>
+                <Trans>Type:</Trans>
+              </Text>
+              <View style={[a.py_xs]}>
+                <Text style={[a.text_md]}>TXT</Text>
+              </View>
+              <Text style={[a.mt_xs, t.atoms.text_contrast_medium]}>
+                <Trans>Value:</Trans>
+              </Text>
+              <View style={[a.py_xs]}>
+                <CopyButton
+                  variant="solid"
+                  color="secondary"
+                  value={'did=' + currentAccount?.did}
+                  label={_(msg`Copy TXT record value`)}
+                  hoverStyle={[a.bg_transparent]}
+                  hitSlop={HITSLOP_10}>
+                  <Text style={[a.text_md, a.flex_1]}>
+                    did={currentAccount?.did}
+                  </Text>
+                  <ButtonIcon icon={CopyIcon} />
+                </CopyButton>
+              </View>
+            </View>
+            <Text>
+              <Trans>This should create a domain record at:</Trans>
+            </Text>
+            <View
+              style={[
+                t.atoms.bg_contrast_25,
+                a.rounded_sm,
+                a.p_md,
+                a.border,
+                t.atoms.border_contrast_low,
+              ]}>
+              <Text style={[a.text_md]}>_atproto.{domain}</Text>
+            </View>
+          </>
+        ) : (
+          <>
+            <Text>
+              <Trans>Upload a text file to:</Trans>
+            </Text>
+            <View
+              style={[
+                t.atoms.bg_contrast_25,
+                a.rounded_sm,
+                a.p_md,
+                a.border,
+                t.atoms.border_contrast_low,
+              ]}>
+              <Text style={[a.text_md]}>
+                https://{domain}/.well-known/atproto-did
+              </Text>
+            </View>
+            <Text>
+              <Trans>That contains the following:</Trans>
+            </Text>
+            <CopyButton
+              value={currentAccount?.did ?? ''}
+              label={_(msg`Copy DID`)}
+              size="large"
+              variant="solid"
+              color="secondary"
+              style={[a.px_md, a.border, t.atoms.border_contrast_low]}>
+              <Text style={[a.text_md, a.flex_1]}>{currentAccount?.did}</Text>
+              <ButtonIcon icon={CopyIcon} />
+            </CopyButton>
+          </>
+        )}
+      </Animated.View>
+      {isVerified && (
+        <Animated.View
+          entering={FadeIn}
+          exiting={FadeOut}
+          layout={native(LinearTransition)}>
+          <SuccessMessage text={_(msg`Domain verified!`)} />
+        </Animated.View>
+      )}
+      <Animated.View layout={native(LinearTransition)}>
+        <Button
+          label={
+            isVerified
+              ? _(msg`Update to ${domain}`)
+              : dnsPanel
+              ? _(msg`Verify DNS Record`)
+              : _(msg`Verify Text File`)
+          }
+          variant="solid"
+          size="large"
+          color={domain.trim().length > 0 ? 'primary' : 'secondary'}
+          disabled={domain.trim().length === 0}
+          onPress={() => {
+            if (isVerified) {
+              changeHandle({handle: domain})
+            } else {
+              verify()
+            }
+          }}>
+          {isPending || isVerifyPending ? (
+            <ButtonIcon icon={Loader} />
+          ) : (
+            <ButtonText>
+              {isVerified ? (
+                <Trans>Update to {domain}</Trans>
+              ) : dnsPanel ? (
+                <Trans>Verify DNS Record</Trans>
+              ) : (
+                <Trans>Verify Text File</Trans>
+              )}
+            </ButtonText>
+          )}
+        </Button>
+      </Animated.View>
+      <Animated.View layout={native(LinearTransition)}>
+        <Button
+          label={_(msg`Use default provider`)}
+          accessibilityHint={_(msg`Go back to previous page`)}
+          onPress={goToServiceHandle}
+          style={[a.p_0, a.justify_start]}>
+          <ButtonText style={[{color: t.palette.primary_500}, a.text_left]}>
+            <Trans>Nevermind, create a handle for me</Trans>
+          </ButtonText>
+        </Button>
+      </Animated.View>
+    </View>
+  )
+}
+
+class DidMismatchError extends Error {
+  did: string
+  constructor(did: string) {
+    super('DID mismatch')
+    this.name = 'DidMismatchError'
+    this.did = did
+  }
+}
+
+function ChangeHandleError({error}: {error: unknown}) {
+  const {_} = useLingui()
+
+  let message = _(msg`Failed to change handle. Please try again.`)
+
+  if (error instanceof Error) {
+    if (error.message.startsWith('Handle already taken')) {
+      message = _(msg`Handle already taken. Please try a different one.`)
+    } else if (error.message === 'Reserved handle') {
+      message = _(msg`This handle is reserved. Please try a different one.`)
+    } else if (error.message === 'Handle too long') {
+      message = _(msg`Handle too long. Please try a shorter one.`)
+    } else if (error.message === 'Input/handle must be a valid handle') {
+      message = _(msg`Invalid handle. Please try a different one.`)
+    } else if (error.message === 'Rate Limit Exceeded') {
+      message = _(
+        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.`,
+      )
+    }
+  }
+
+  return <Admonition type="error">{message}</Admonition>
+}
+
+function SuccessMessage({text}: {text: string}) {
+  const {gtMobile} = useBreakpoints()
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.gap_md,
+        a.flex_row,
+        a.justify_center,
+        a.align_center,
+        gtMobile ? a.px_md : a.px_sm,
+        a.py_xs,
+        t.atoms.border_contrast_low,
+      ]}>
+      <View
+        style={[
+          {height: 20, width: 20},
+          a.rounded_full,
+          a.align_center,
+          a.justify_center,
+          {backgroundColor: t.palette.positive_600},
+        ]}>
+        <CheckIcon fill={t.palette.white} size="xs" />
+      </View>
+      <Text style={[a.text_md]}>{text}</Text>
+    </View>
+  )
+}