about summary refs log tree commit diff
path: root/src/screens/Settings/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Settings/components')
-rw-r--r--src/screens/Settings/components/AddAppPasswordDialog.tsx280
-rw-r--r--src/screens/Settings/components/ChangeHandleDialog.tsx602
-rw-r--r--src/screens/Settings/components/CopyButton.tsx69
-rw-r--r--src/screens/Settings/components/SettingsList.tsx14
4 files changed, 961 insertions, 4 deletions
diff --git a/src/screens/Settings/components/AddAppPasswordDialog.tsx b/src/screens/Settings/components/AddAppPasswordDialog.tsx
new file mode 100644
index 000000000..dcb212879
--- /dev/null
+++ b/src/screens/Settings/components/AddAppPasswordDialog.tsx
@@ -0,0 +1,280 @@
+import React, {useEffect, useMemo, useState} from 'react'
+import {useWindowDimensions, View} from 'react-native'
+import Animated, {
+  FadeIn,
+  FadeOut,
+  LayoutAnimationConfig,
+  LinearTransition,
+  SlideInRight,
+  SlideOutLeft,
+} from 'react-native-reanimated'
+import {ComAtprotoServerCreateAppPassword} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation} from '@tanstack/react-query'
+
+import {isWeb} from '#/platform/detection'
+import {useAppPasswordCreateMutation} from '#/state/queries/app-passwords'
+import {atoms as a, native, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextInput from '#/components/forms/TextField'
+import * as Toggle from '#/components/forms/Toggle'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
+import {Text} from '#/components/Typography'
+import {CopyButton} from './CopyButton'
+
+export function AddAppPasswordDialog({
+  control,
+  passwords,
+}: {
+  control: Dialog.DialogControlProps
+  passwords: string[]
+}) {
+  const {height} = useWindowDimensions()
+  return (
+    <Dialog.Outer control={control} nativeOptions={{minHeight: height}}>
+      <Dialog.Handle />
+      <CreateDialogInner passwords={passwords} />
+    </Dialog.Outer>
+  )
+}
+
+function CreateDialogInner({passwords}: {passwords: string[]}) {
+  const control = Dialog.useDialogContext()
+  const t = useTheme()
+  const {_} = useLingui()
+  const autogeneratedName = useRandomName()
+  const [name, setName] = useState('')
+  const [privileged, setPrivileged] = useState(false)
+  const {
+    mutateAsync: actuallyCreateAppPassword,
+    error: apiError,
+    data,
+  } = useAppPasswordCreateMutation()
+
+  const regexFailError = useMemo(
+    () =>
+      new DisplayableError(
+        _(
+          msg`App password names can only contain letters, numbers, spaces, dashes, and underscores`,
+        ),
+      ),
+    [_],
+  )
+
+  const {
+    mutate: createAppPassword,
+    error: validationError,
+    isPending,
+  } = useMutation<
+    ComAtprotoServerCreateAppPassword.AppPassword,
+    Error | DisplayableError
+  >({
+    mutationFn: async () => {
+      const chosenName = name.trim() || autogeneratedName
+      if (chosenName.length < 4) {
+        throw new DisplayableError(
+          _(msg`App password names must be at least 4 characters long`),
+        )
+      }
+      if (passwords.find(p => p === chosenName)) {
+        throw new DisplayableError(_(msg`App password name must be unique`))
+      }
+      return await actuallyCreateAppPassword({name: chosenName, privileged})
+    },
+  })
+
+  const [hasBeenCopied, setHasBeenCopied] = useState(false)
+  useEffect(() => {
+    if (hasBeenCopied) {
+      const timeout = setTimeout(() => setHasBeenCopied(false), 100)
+      return () => clearTimeout(timeout)
+    }
+  }, [hasBeenCopied])
+
+  const error =
+    validationError || (!name.match(/^[a-zA-Z0-9-_ ]*$/) && regexFailError)
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Add app password`)}>
+      <View style={[native(a.pt_md)]}>
+        <LayoutAnimationConfig skipEntering skipExiting>
+          {!data ? (
+            <Animated.View
+              style={[a.gap_lg]}
+              exiting={native(SlideOutLeft)}
+              key={0}>
+              <Text style={[a.text_2xl, a.font_bold]}>
+                <Trans>Add App Password</Trans>
+              </Text>
+              <Text style={[a.text_md, a.leading_snug]}>
+                <Trans>
+                  Please enter a unique name for this app password or use our
+                  randomly generated one.
+                </Trans>
+              </Text>
+              <View>
+                <TextInput.Root isInvalid={!!error}>
+                  <Dialog.Input
+                    label={_(msg`App Password`)}
+                    placeholder={autogeneratedName}
+                    onChangeText={setName}
+                    returnKeyType="done"
+                    onSubmitEditing={() => createAppPassword()}
+                    blurOnSubmit
+                    autoCorrect={false}
+                    autoComplete="off"
+                    autoCapitalize="none"
+                    autoFocus
+                  />
+                </TextInput.Root>
+              </View>
+              {error instanceof DisplayableError && (
+                <Animated.View entering={FadeIn} exiting={FadeOut}>
+                  <Admonition type="error">{error.message}</Admonition>
+                </Animated.View>
+              )}
+              <Animated.View
+                style={[a.gap_lg]}
+                layout={native(LinearTransition)}>
+                <Toggle.Item
+                  name="privileged"
+                  type="checkbox"
+                  label={_(msg`Allow access to your direct messages`)}
+                  value={privileged}
+                  onChange={setPrivileged}
+                  style={[a.flex_1]}>
+                  <Toggle.Checkbox />
+                  <Toggle.LabelText
+                    style={[a.font_normal, a.text_md, a.leading_snug]}>
+                    <Trans>Allow access to your direct messages</Trans>
+                  </Toggle.LabelText>
+                </Toggle.Item>
+                <Button
+                  label={_(msg`Next`)}
+                  size="large"
+                  variant="solid"
+                  color="primary"
+                  style={[a.flex_1]}
+                  onPress={() => createAppPassword()}
+                  disabled={isPending}>
+                  <ButtonText>
+                    <Trans>Next</Trans>
+                  </ButtonText>
+                  <ButtonIcon icon={ChevronRight} />
+                </Button>
+                {!!apiError ||
+                  (error && !(error instanceof DisplayableError) && (
+                    <Animated.View entering={FadeIn} exiting={FadeOut}>
+                      <Admonition type="error">
+                        <Trans>
+                          Failed to create app password. Please try again.
+                        </Trans>
+                      </Admonition>
+                    </Animated.View>
+                  ))}
+              </Animated.View>
+            </Animated.View>
+          ) : (
+            <Animated.View
+              style={[a.gap_lg]}
+              entering={isWeb ? FadeIn.delay(200) : SlideInRight}
+              key={1}>
+              <Text style={[a.text_2xl, a.font_bold]}>
+                <Trans>Here is your app password!</Trans>
+              </Text>
+              <Text style={[a.text_md, a.leading_snug]}>
+                <Trans>
+                  Use this to sign into the other app along with your handle.
+                </Trans>
+              </Text>
+              <CopyButton
+                value={data.password}
+                label={_(msg`Copy App Password`)}
+                size="large"
+                variant="solid"
+                color="secondary">
+                <ButtonText>{data.password}</ButtonText>
+                <ButtonIcon icon={CopyIcon} />
+              </CopyButton>
+              <Text
+                style={[
+                  a.text_md,
+                  a.leading_snug,
+                  t.atoms.text_contrast_medium,
+                ]}>
+                <Trans>
+                  For security reasons, you won't be able to view this again. If
+                  you lose this app password, you'll need to generate a new one.
+                </Trans>
+              </Text>
+              <Button
+                label={_(msg`Done`)}
+                size="large"
+                variant="outline"
+                color="primary"
+                style={[a.flex_1]}
+                onPress={() => control.close()}>
+                <ButtonText>
+                  <Trans>Done</Trans>
+                </ButtonText>
+              </Button>
+            </Animated.View>
+          )}
+        </LayoutAnimationConfig>
+      </View>
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
+
+class DisplayableError extends Error {
+  constructor(message: string) {
+    super(message)
+    this.name = 'DisplayableError'
+  }
+}
+
+function useRandomName() {
+  return useState(
+    () => shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)],
+  )[0]
+}
+
+const shadesOfBlue: string[] = [
+  'AliceBlue',
+  'Aqua',
+  'Aquamarine',
+  'Azure',
+  'BabyBlue',
+  'Blue',
+  'BlueViolet',
+  'CadetBlue',
+  'CornflowerBlue',
+  'Cyan',
+  'DarkBlue',
+  'DarkCyan',
+  'DarkSlateBlue',
+  'DeepSkyBlue',
+  'DodgerBlue',
+  'ElectricBlue',
+  'LightBlue',
+  'LightCyan',
+  'LightSkyBlue',
+  'LightSteelBlue',
+  'MediumAquaMarine',
+  'MediumBlue',
+  'MediumSlateBlue',
+  'MidnightBlue',
+  'Navy',
+  'PowderBlue',
+  'RoyalBlue',
+  'SkyBlue',
+  'SlateBlue',
+  'SteelBlue',
+  'Teal',
+  'Turquoise',
+]
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>
+  )
+}
diff --git a/src/screens/Settings/components/CopyButton.tsx b/src/screens/Settings/components/CopyButton.tsx
new file mode 100644
index 000000000..eb538f5de
--- /dev/null
+++ b/src/screens/Settings/components/CopyButton.tsx
@@ -0,0 +1,69 @@
+import React, {useCallback, useEffect, useState} from 'react'
+import {GestureResponderEvent, View} from 'react-native'
+import Animated, {FadeOutUp, ZoomIn} from 'react-native-reanimated'
+import * as Clipboard from 'expo-clipboard'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonProps} from '#/components/Button'
+import {Text} from '#/components/Typography'
+
+export function CopyButton({
+  style,
+  value,
+  onPress: onPressProp,
+  ...props
+}: ButtonProps & {value: string}) {
+  const [hasBeenCopied, setHasBeenCopied] = useState(false)
+  const t = useTheme()
+
+  useEffect(() => {
+    if (hasBeenCopied) {
+      const timeout = setTimeout(() => setHasBeenCopied(false), 100)
+      return () => clearTimeout(timeout)
+    }
+  }, [hasBeenCopied])
+
+  const onPress = useCallback(
+    (evt: GestureResponderEvent) => {
+      Clipboard.setStringAsync(value)
+      setHasBeenCopied(true)
+      onPressProp?.(evt)
+    },
+    [value, onPressProp],
+  )
+
+  return (
+    <View style={[a.relative]}>
+      {hasBeenCopied && (
+        <Animated.View
+          entering={ZoomIn.duration(100)}
+          exiting={FadeOutUp.duration(2000)}
+          style={[
+            a.absolute,
+            {bottom: '100%', right: 0},
+            a.justify_center,
+            a.gap_sm,
+            a.z_10,
+            a.pb_sm,
+          ]}
+          pointerEvents="none">
+          <Text
+            style={[
+              a.font_bold,
+              a.text_right,
+              a.text_md,
+              t.atoms.text_contrast_high,
+            ]}>
+            <Trans>Copied!</Trans>
+          </Text>
+        </Animated.View>
+      )}
+      <Button
+        style={[a.flex_1, a.justify_between, style]}
+        onPress={onPress}
+        {...props}
+      />
+    </View>
+  )
+}
diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx
index 86f8040af..29ae9be6d 100644
--- a/src/screens/Settings/components/SettingsList.tsx
+++ b/src/screens/Settings/components/SettingsList.tsx
@@ -2,7 +2,7 @@ import React, {useContext, useMemo} from 'react'
 import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'
 
 import {HITSLOP_10} from '#/lib/constants'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
 import * as Button from '#/components/Button'
 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
 import {Link, LinkProps} from '#/components/Link'
@@ -17,7 +17,7 @@ const ItemContext = React.createContext({
 const Portal = createPortalGroup()
 
 export function Container({children}: {children: React.ReactNode}) {
-  return <View style={[a.flex_1, a.py_lg]}>{children}</View>
+  return <View style={[a.flex_1, a.py_md]}>{children}</View>
 }
 
 /**
@@ -241,11 +241,17 @@ export function ItemText({
   }
 }
 
-export function Divider() {
+export function Divider({style}: ViewStyleProp) {
   const t = useTheme()
   return (
     <View
-      style={[a.border_t, t.atoms.border_contrast_medium, a.w_full, a.my_sm]}
+      style={[
+        a.border_t,
+        t.atoms.border_contrast_medium,
+        a.w_full,
+        a.my_sm,
+        style,
+      ]}
     />
   )
 }