about summary refs log tree commit diff
path: root/src/screens/Settings
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-10-31 20:45:34 +0000
committerGitHub <noreply@github.com>2024-10-31 20:45:34 +0000
commitaa6aad652e8091ea6039af82f41d4de3669a5944 (patch)
treebb9b51ebc38728aaf38b3f0c4318ff702bcebd5d /src/screens/Settings
parentd85dcc3dd06b49fccee66bc9e16cd8d0938f5c82 (diff)
downloadvoidsky-aa6aad652e8091ea6039af82f41d4de3669a5944.tar.zst
[Settings] Thread prefs revamp (#5772)
* thread preferences screen

* minor tweaks

* more spacing

* replace gate with IS_INTERNAL

* [Settings] Following feed prefs revamp (#5773)

* gated new settings screen

* Following feed prefs

* Update src/screens/Settings/FollowingFeedPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Settings/FollowingFeedPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* replace pref following feed gate

* Update src/screens/Settings/FollowingFeedPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* use "Experimental" as the header

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] External media prefs revamp (#5774)

* gated new settings screen

* external media prefs revamp

* replace gate ext media embeds

* Update src/screens/Settings/ExternalMediaPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add imports for translation

* alternate list style on native

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] Languages revamp (partial) (#5775)

* language settings (lazy restyle)

* replace gate

* fix text determining flex space

* [Settings] App passwords revamp (#5777)

* rework app passwords screen

* Apply surfdude's copy changes

Thanks @surfdude29!

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* format

* replace gate

* use admonition for input error and animate

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] Change handle dialog (#5781)

* new change handle dialog

* animations native only

* overflow hidden on togglebutton animation

* add a low-contrast border

* extract out copybutton

* finish change handle dialog

* invalidate query on success

* web fixes

* error message for rate limit exceeded

* typo

* em dash!

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* another em dash

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* set maxwidth of suffixtext

* Copy tweak

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] Notifs settings revamp (#5884)

* rename, move, and restyle notif settings

* bold "experimental:"

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Diffstat (limited to 'src/screens/Settings')
-rw-r--r--src/screens/Settings/AccountSettings.tsx26
-rw-r--r--src/screens/Settings/AppPasswords.tsx209
-rw-r--r--src/screens/Settings/ExternalMediaPreferences.tsx99
-rw-r--r--src/screens/Settings/FollowingFeedPreferences.tsx143
-rw-r--r--src/screens/Settings/LanguageSettings.tsx275
-rw-r--r--src/screens/Settings/NotificationSettings.tsx85
-rw-r--r--src/screens/Settings/Settings.tsx1
-rw-r--r--src/screens/Settings/ThreadPreferences.tsx145
-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
12 files changed, 1923 insertions, 25 deletions
diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx
index 19101d2f4..6a72cdcc3 100644
--- a/src/screens/Settings/AccountSettings.tsx
+++ b/src/screens/Settings/AccountSettings.tsx
@@ -2,12 +2,9 @@ import React from 'react'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
-import {useQueryClient} from '@tanstack/react-query'
 
 import {CommonNavigatorParams} from '#/lib/routes/types'
 import {useModalControls} from '#/state/modals'
-import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
-import {useProfileQuery} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
 import {ExportCarDialog} from '#/view/screens/Settings/ExportCarDialog'
 import * as SettingsList from '#/screens/Settings/components/SettingsList'
@@ -25,6 +22,7 @@ import {PencilLine_Stroke2_Corner2_Rounded as PencilIcon} from '#/components/ico
 import {Trash_Stroke2_Corner2_Rounded} from '#/components/icons/Trash'
 import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified'
 import * as Layout from '#/components/Layout'
+import {ChangeHandleDialog} from './components/ChangeHandleDialog'
 import {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
@@ -32,10 +30,9 @@ export function AccountSettingsScreen({}: Props) {
   const t = useTheme()
   const {_} = useLingui()
   const {currentAccount} = useSession()
-  const queryClient = useQueryClient()
-  const {data: profile} = useProfileQuery({did: currentAccount?.did})
   const {openModal} = useModalControls()
   const birthdayControl = useDialogControl()
+  const changeHandleControl = useDialogControl()
   const exportCarControl = useDialogControl()
   const deactivateAccountControl = useDialogControl()
 
@@ -117,26 +114,12 @@ export function AccountSettingsScreen({}: Props) {
           </SettingsList.PressableItem>
           <SettingsList.PressableItem
             label={_(msg`Handle`)}
-            onPress={() =>
-              openModal({
-                name: 'change-handle',
-                onChanged() {
-                  if (currentAccount) {
-                    // refresh my profile
-                    queryClient.invalidateQueries({
-                      queryKey: RQKEY_PROFILE(currentAccount.did),
-                    })
-                  }
-                },
-              })
-            }>
+            accessibilityHint={_(msg`Open change handle dialog`)}
+            onPress={() => changeHandleControl.open()}>
             <SettingsList.ItemIcon icon={AtIcon} />
             <SettingsList.ItemText>
               <Trans>Handle</Trans>
             </SettingsList.ItemText>
-            {profile && (
-              <SettingsList.BadgeText>@{profile.handle}</SettingsList.BadgeText>
-            )}
             <SettingsList.Chevron />
           </SettingsList.PressableItem>
           <SettingsList.Divider />
@@ -173,6 +156,7 @@ export function AccountSettingsScreen({}: Props) {
       </Layout.Content>
 
       <BirthDateSettingsDialog control={birthdayControl} />
+      <ChangeHandleDialog control={changeHandleControl} />
       <ExportCarDialog control={exportCarControl} />
       <DeactivateAccountDialog control={deactivateAccountControl} />
     </Layout.Screen>
diff --git a/src/screens/Settings/AppPasswords.tsx b/src/screens/Settings/AppPasswords.tsx
new file mode 100644
index 000000000..8cebf97ce
--- /dev/null
+++ b/src/screens/Settings/AppPasswords.tsx
@@ -0,0 +1,209 @@
+import React, {useCallback} from 'react'
+import {View} from 'react-native'
+import Animated, {
+  FadeIn,
+  FadeOut,
+  LayoutAnimationConfig,
+  LinearTransition,
+  StretchOutY,
+} from 'react-native-reanimated'
+import {ComAtprotoServerListAppPasswords} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+
+import {CommonNavigatorParams} from '#/lib/routes/types'
+import {cleanError} from '#/lib/strings/errors'
+import {isWeb} from '#/platform/detection'
+import {
+  useAppPasswordDeleteMutation,
+  useAppPasswordsQuery,
+} from '#/state/queries/app-passwords'
+import {EmptyState} from '#/view/com/util/EmptyState'
+import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Admonition, colors} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import * as Layout from '#/components/Layout'
+import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
+import {AddAppPasswordDialog} from './components/AddAppPasswordDialog'
+import * as SettingsList from './components/SettingsList'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
+export function AppPasswordsScreen({}: Props) {
+  const {_} = useLingui()
+  const {data: appPasswords, error} = useAppPasswordsQuery()
+  const createAppPasswordControl = useDialogControl()
+
+  return (
+    <Layout.Screen testID="AppPasswordsScreen">
+      <Layout.Header title={_(msg`App Passwords`)} />
+      <Layout.Content>
+        {error ? (
+          <ErrorScreen
+            title={_(msg`Oops!`)}
+            message={_(msg`There was an issue fetching your app passwords`)}
+            details={cleanError(error)}
+          />
+        ) : (
+          <SettingsList.Container>
+            <SettingsList.Item>
+              <Admonition type="tip" style={[a.flex_1]}>
+                <Trans>
+                  Use app passwords to sign in to other Bluesky clients without
+                  giving full access to your account or password.
+                </Trans>
+              </Admonition>
+            </SettingsList.Item>
+            <SettingsList.Item>
+              <Button
+                label={_(msg`Add App Password`)}
+                size="large"
+                color="primary"
+                variant="solid"
+                onPress={() => createAppPasswordControl.open()}
+                style={[a.flex_1]}>
+                <ButtonIcon icon={PlusIcon} />
+                <ButtonText>
+                  <Trans>Add App Password</Trans>
+                </ButtonText>
+              </Button>
+            </SettingsList.Item>
+            <SettingsList.Divider />
+            <LayoutAnimationConfig skipEntering skipExiting>
+              {appPasswords ? (
+                appPasswords.length > 0 ? (
+                  <View style={[a.overflow_hidden]}>
+                    {appPasswords.map(appPassword => (
+                      <Animated.View
+                        key={appPassword.name}
+                        style={a.w_full}
+                        entering={FadeIn}
+                        exiting={isWeb ? FadeOut : StretchOutY}
+                        layout={LinearTransition.delay(150)}>
+                        <SettingsList.Item>
+                          <AppPasswordCard appPassword={appPassword} />
+                        </SettingsList.Item>
+                      </Animated.View>
+                    ))}
+                  </View>
+                ) : (
+                  <EmptyState
+                    icon="growth"
+                    message={_(msg`No app passwords yet`)}
+                  />
+                )
+              ) : (
+                <View
+                  style={[
+                    a.flex_1,
+                    a.justify_center,
+                    a.align_center,
+                    a.py_4xl,
+                  ]}>
+                  <Loader size="xl" />
+                </View>
+              )}
+            </LayoutAnimationConfig>
+          </SettingsList.Container>
+        )}
+      </Layout.Content>
+
+      <AddAppPasswordDialog
+        control={createAppPasswordControl}
+        passwords={appPasswords?.map(p => p.name) || []}
+      />
+    </Layout.Screen>
+  )
+}
+
+function AppPasswordCard({
+  appPassword,
+}: {
+  appPassword: ComAtprotoServerListAppPasswords.AppPassword
+}) {
+  const t = useTheme()
+  const {i18n, _} = useLingui()
+  const deleteControl = Prompt.usePromptControl()
+  const {mutateAsync: deleteMutation} = useAppPasswordDeleteMutation()
+
+  const onDelete = useCallback(async () => {
+    await deleteMutation({name: appPassword.name})
+    Toast.show(_(msg`App password deleted`))
+  }, [deleteMutation, appPassword.name, _])
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.border,
+        a.rounded_sm,
+        a.px_md,
+        a.py_sm,
+        t.atoms.bg_contrast_25,
+        t.atoms.border_contrast_low,
+      ]}>
+      <View
+        style={[
+          a.flex_row,
+          a.justify_between,
+          a.align_start,
+          a.w_full,
+          a.gap_sm,
+        ]}>
+        <View style={[a.gap_xs]}>
+          <Text style={[t.atoms.text, a.text_md, a.font_bold]}>
+            {appPassword.name}
+          </Text>
+          <Text style={[t.atoms.text_contrast_medium]}>
+            <Trans>
+              Created{' '}
+              {i18n.date(appPassword.createdAt, {
+                year: 'numeric',
+                month: 'numeric',
+                day: 'numeric',
+                hour: '2-digit',
+                minute: '2-digit',
+              })}
+            </Trans>
+          </Text>
+        </View>
+        <Button
+          label={_(msg`Delete app password`)}
+          variant="ghost"
+          color="negative"
+          size="small"
+          style={[a.bg_transparent]}
+          onPress={() => deleteControl.open()}>
+          <ButtonIcon icon={TrashIcon} />
+        </Button>
+      </View>
+      {appPassword.privileged && (
+        <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
+          <WarningIcon style={[{color: colors.warning[t.scheme]}]} />
+          <Text style={t.atoms.text_contrast_high}>
+            <Trans>Allows access to direct messages</Trans>
+          </Text>
+        </View>
+      )}
+
+      <Prompt.Basic
+        control={deleteControl}
+        title={_(msg`Delete app password?`)}
+        description={_(
+          msg`Are you sure you want to delete the app password "${appPassword.name}"?`,
+        )}
+        onConfirm={onDelete}
+        confirmButtonCta={_(msg`Delete`)}
+        confirmButtonColor="negative"
+      />
+    </View>
+  )
+}
diff --git a/src/screens/Settings/ExternalMediaPreferences.tsx b/src/screens/Settings/ExternalMediaPreferences.tsx
new file mode 100644
index 000000000..91c7ea7fc
--- /dev/null
+++ b/src/screens/Settings/ExternalMediaPreferences.tsx
@@ -0,0 +1,99 @@
+import React, {Fragment} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  EmbedPlayerSource,
+  externalEmbedLabels,
+} from '#/lib/strings/embed-player'
+import {
+  useExternalEmbedsPrefs,
+  useSetExternalEmbedPref,
+} from '#/state/preferences'
+import {atoms as a, native} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import * as Toggle from '#/components/forms/Toggle'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from './components/SettingsList'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'PreferencesExternalEmbeds'
+>
+export function ExternalMediaPreferencesScreen({}: Props) {
+  const {_} = useLingui()
+  return (
+    <Layout.Screen testID="externalMediaPreferencesScreen">
+      <Layout.Header title={_(msg`External Media Preferences`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item>
+            <Admonition type="info" style={[a.flex_1]}>
+              <Trans>
+                External media may allow websites to collect information about
+                you and your device. No information is sent or requested until
+                you press the "play" button.
+              </Trans>
+            </Admonition>
+          </SettingsList.Item>
+          <SettingsList.Group iconInset={false}>
+            <SettingsList.ItemText>
+              <Trans>Enable media players for</Trans>
+            </SettingsList.ItemText>
+            <View style={[a.mt_sm, a.w_full]}>
+              {native(<SettingsList.Divider style={[a.my_0]} />)}
+              {Object.entries(externalEmbedLabels)
+                // TODO: Remove special case when we disable the old integration.
+                .filter(([key]) => key !== 'tenor')
+                .map(([key, label]) => (
+                  <Fragment key={key}>
+                    <PrefSelector
+                      source={key as EmbedPlayerSource}
+                      label={label}
+                      key={key}
+                    />
+                    {native(<SettingsList.Divider style={[a.my_0]} />)}
+                  </Fragment>
+                ))}
+            </View>
+          </SettingsList.Group>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
+
+function PrefSelector({
+  source,
+  label,
+}: {
+  source: EmbedPlayerSource
+  label: string
+}) {
+  const setExternalEmbedPref = useSetExternalEmbedPref()
+  const sources = useExternalEmbedsPrefs()
+
+  return (
+    <Toggle.Item
+      name={label}
+      label={label}
+      type="checkbox"
+      value={sources?.[source] === 'show'}
+      onChange={() =>
+        setExternalEmbedPref(
+          source,
+          sources?.[source] === 'show' ? 'hide' : 'show',
+        )
+      }
+      style={[
+        a.flex_1,
+        a.py_md,
+        native([a.justify_between, a.flex_row_reverse]),
+      ]}>
+      <Toggle.Platform />
+      <Toggle.LabelText style={[a.text_md]}>{label}</Toggle.LabelText>
+    </Toggle.Item>
+  )
+}
diff --git a/src/screens/Settings/FollowingFeedPreferences.tsx b/src/screens/Settings/FollowingFeedPreferences.tsx
new file mode 100644
index 000000000..12de2a31a
--- /dev/null
+++ b/src/screens/Settings/FollowingFeedPreferences.tsx
@@ -0,0 +1,143 @@
+import React from 'react'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  usePreferencesQuery,
+  useSetFeedViewPreferencesMutation,
+} from '#/state/queries/preferences'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import * as Toggle from '#/components/forms/Toggle'
+import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
+import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
+import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
+import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from './components/SettingsList'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'PreferencesFollowingFeed'
+>
+export function FollowingFeedPreferencesScreen({}: Props) {
+  const {_} = useLingui()
+
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate: setFeedViewPref, variables} =
+    useSetFeedViewPreferencesMutation()
+
+  const showReplies = !(
+    variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies
+  )
+
+  const showReposts = !(
+    variables?.hideReposts ?? preferences?.feedViewPrefs?.hideReposts
+  )
+
+  const showQuotePosts = !(
+    variables?.hideQuotePosts ?? preferences?.feedViewPrefs?.hideQuotePosts
+  )
+
+  const mergeFeedEnabled = Boolean(
+    variables?.lab_mergeFeedEnabled ??
+      preferences?.feedViewPrefs?.lab_mergeFeedEnabled,
+  )
+
+  return (
+    <Layout.Screen testID="followingFeedPreferencesScreen">
+      <Layout.Header title={_(msg`Following Feed Preferences`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item>
+            <Admonition type="tip" style={[a.flex_1]}>
+              <Trans>These settings only apply to the Following feed.</Trans>
+            </Admonition>
+          </SettingsList.Item>
+          <Toggle.Item
+            type="checkbox"
+            name="show-replies"
+            label={_(msg`Show replies`)}
+            value={showReplies}
+            onChange={value =>
+              setFeedViewPref({
+                hideReplies: !value,
+              })
+            }>
+            <SettingsList.Item>
+              <SettingsList.ItemIcon icon={BubblesIcon} />
+              <SettingsList.ItemText>
+                <Trans>Show replies</Trans>
+              </SettingsList.ItemText>
+              <Toggle.Platform />
+            </SettingsList.Item>
+          </Toggle.Item>
+          <Toggle.Item
+            type="checkbox"
+            name="show-reposts"
+            label={_(msg`Show reposts`)}
+            value={showReposts}
+            onChange={value =>
+              setFeedViewPref({
+                hideReposts: !value,
+              })
+            }>
+            <SettingsList.Item>
+              <SettingsList.ItemIcon icon={RepostIcon} />
+              <SettingsList.ItemText>
+                <Trans>Show reposts</Trans>
+              </SettingsList.ItemText>
+              <Toggle.Platform />
+            </SettingsList.Item>
+          </Toggle.Item>
+          <Toggle.Item
+            type="checkbox"
+            name="show-quotes"
+            label={_(msg`Show quote posts`)}
+            value={showQuotePosts}
+            onChange={value =>
+              setFeedViewPref({
+                hideQuotePosts: !value,
+              })
+            }>
+            <SettingsList.Item>
+              <SettingsList.ItemIcon icon={QuoteIcon} />
+              <SettingsList.ItemText>
+                <Trans>Show quote posts</Trans>
+              </SettingsList.ItemText>
+              <Toggle.Platform />
+            </SettingsList.Item>
+          </Toggle.Item>
+          <SettingsList.Divider />
+          <SettingsList.Group>
+            <SettingsList.ItemIcon icon={BeakerIcon} />
+            <SettingsList.ItemText>
+              <Trans>Experimental</Trans>
+            </SettingsList.ItemText>
+            <Toggle.Item
+              type="checkbox"
+              name="merge-feed"
+              label={_(
+                msg`Show samples of your saved feeds in your Following feed`,
+              )}
+              value={mergeFeedEnabled}
+              onChange={value =>
+                setFeedViewPref({
+                  lab_mergeFeedEnabled: value,
+                })
+              }
+              style={[a.w_full, a.gap_md]}>
+              <Toggle.LabelText style={[a.flex_1]}>
+                <Trans>
+                  Show samples of your saved feeds in your Following feed
+                </Trans>
+              </Toggle.LabelText>
+              <Toggle.Platform />
+            </Toggle.Item>
+          </SettingsList.Group>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/LanguageSettings.tsx b/src/screens/Settings/LanguageSettings.tsx
new file mode 100644
index 000000000..c6cd8bb5a
--- /dev/null
+++ b/src/screens/Settings/LanguageSettings.tsx
@@ -0,0 +1,275 @@
+import React, {useCallback, useMemo} from 'react'
+import {View} from 'react-native'
+import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages'
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {sanitizeAppLanguageSetting} from '#/locale/helpers'
+import {useModalControls} from '#/state/modals'
+import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
+import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+import * as SettingsList from './components/SettingsList'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'>
+export function LanguageSettingsScreen({}: Props) {
+  const {_} = useLingui()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
+  const t = useTheme()
+
+  const {openModal} = useModalControls()
+
+  const onPressContentLanguages = useCallback(() => {
+    openModal({name: 'content-languages-settings'})
+  }, [openModal])
+
+  const onChangePrimaryLanguage = useCallback(
+    (value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
+      if (!value) return
+      if (langPrefs.primaryLanguage !== value) {
+        setLangPrefs.setPrimaryLanguage(value)
+      }
+    },
+    [langPrefs, setLangPrefs],
+  )
+
+  const onChangeAppLanguage = useCallback(
+    (value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
+      if (!value) return
+      if (langPrefs.appLanguage !== value) {
+        setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
+      }
+    },
+    [langPrefs, setLangPrefs],
+  )
+
+  const myLanguages = useMemo(() => {
+    return (
+      langPrefs.contentLanguages
+        .map(lang => LANGUAGES.find(l => l.code2 === lang))
+        .filter(Boolean)
+        // @ts-ignore
+        .map(l => l.name)
+        .join(', ')
+    )
+  }, [langPrefs.contentLanguages])
+
+  return (
+    <Layout.Screen testID="PreferencesLanguagesScreen">
+      <Layout.Header title={_(msg`Languages`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Group iconInset={false}>
+            <SettingsList.ItemText>
+              <Trans>App Language</Trans>
+            </SettingsList.ItemText>
+            <View style={[a.gap_md, a.w_full]}>
+              <Text style={[a.leading_snug]}>
+                <Trans>
+                  Select your app language for the default text to display in
+                  the app.
+                </Trans>
+              </Text>
+              <View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}>
+                <RNPickerSelect
+                  placeholder={{}}
+                  value={sanitizeAppLanguageSetting(langPrefs.appLanguage)}
+                  onValueChange={onChangeAppLanguage}
+                  items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
+                    label: l.name,
+                    value: l.code2,
+                    key: l.code2,
+                  }))}
+                  style={{
+                    inputAndroid: {
+                      backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+                      color: t.atoms.text.color,
+                      fontSize: 14,
+                      letterSpacing: 0.5,
+                      fontWeight: a.font_bold.fontWeight,
+                      paddingHorizontal: 14,
+                      paddingVertical: 8,
+                      borderRadius: a.rounded_xs.borderRadius,
+                    },
+                    inputIOS: {
+                      backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+                      color: t.atoms.text.color,
+                      fontSize: 14,
+                      letterSpacing: 0.5,
+                      fontWeight: a.font_bold.fontWeight,
+                      paddingHorizontal: 14,
+                      paddingVertical: 8,
+                      borderRadius: a.rounded_xs.borderRadius,
+                    },
+                    inputWeb: {
+                      flex: 1,
+                      width: '100%',
+                      cursor: 'pointer',
+                      // @ts-ignore web only
+                      '-moz-appearance': 'none',
+                      '-webkit-appearance': 'none',
+                      appearance: 'none',
+                      outline: 0,
+                      borderWidth: 0,
+                      backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+                      color: t.atoms.text.color,
+                      fontSize: 14,
+                      fontFamily: 'inherit',
+                      letterSpacing: 0.5,
+                      fontWeight: a.font_bold.fontWeight,
+                      paddingHorizontal: 14,
+                      paddingVertical: 8,
+                      borderRadius: a.rounded_xs.borderRadius,
+                    },
+                  }}
+                />
+
+                <View
+                  style={[
+                    a.absolute,
+                    t.atoms.bg_contrast_25,
+                    a.rounded_xs,
+                    a.pointer_events_none,
+                    a.align_center,
+                    a.justify_center,
+                    {
+                      top: 1,
+                      right: 1,
+                      bottom: 1,
+                      width: 40,
+                    },
+                  ]}>
+                  <ChevronDownIcon style={[t.atoms.text]} />
+                </View>
+              </View>
+            </View>
+          </SettingsList.Group>
+          <SettingsList.Divider />
+          <SettingsList.Group iconInset={false}>
+            <SettingsList.ItemText>
+              <Trans>Primary Language</Trans>
+            </SettingsList.ItemText>
+            <View style={[a.gap_md, a.w_full]}>
+              <Text style={[a.leading_snug]}>
+                <Trans>
+                  Select your preferred language for translations in your feed.
+                </Trans>
+              </Text>
+              <View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}>
+                <RNPickerSelect
+                  placeholder={{}}
+                  value={langPrefs.primaryLanguage}
+                  onValueChange={onChangePrimaryLanguage}
+                  items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
+                    label: l.name,
+                    value: l.code2,
+                    key: l.code2 + l.code3,
+                  }))}
+                  style={{
+                    inputAndroid: {
+                      backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+                      color: t.atoms.text.color,
+                      fontSize: 14,
+                      letterSpacing: 0.5,
+                      fontWeight: a.font_bold.fontWeight,
+                      paddingHorizontal: 14,
+                      paddingVertical: 8,
+                      borderRadius: a.rounded_xs.borderRadius,
+                    },
+                    inputIOS: {
+                      backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+                      color: t.atoms.text.color,
+                      fontSize: 14,
+                      letterSpacing: 0.5,
+                      fontWeight: a.font_bold.fontWeight,
+                      paddingHorizontal: 14,
+                      paddingVertical: 8,
+                      borderRadius: a.rounded_xs.borderRadius,
+                    },
+                    inputWeb: {
+                      flex: 1,
+                      width: '100%',
+                      cursor: 'pointer',
+                      // @ts-ignore web only
+                      '-moz-appearance': 'none',
+                      '-webkit-appearance': 'none',
+                      appearance: 'none',
+                      outline: 0,
+                      borderWidth: 0,
+                      backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+                      color: t.atoms.text.color,
+                      fontSize: 14,
+                      fontFamily: 'inherit',
+                      letterSpacing: 0.5,
+                      fontWeight: a.font_bold.fontWeight,
+                      paddingHorizontal: 14,
+                      paddingVertical: 8,
+                      borderRadius: a.rounded_xs.borderRadius,
+                    },
+                  }}
+                />
+
+                <View
+                  style={{
+                    position: 'absolute',
+                    top: 1,
+                    right: 1,
+                    bottom: 1,
+                    width: 40,
+                    backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+                    borderRadius: a.rounded_xs.borderRadius,
+                    pointerEvents: 'none',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                  }}>
+                  <ChevronDownIcon style={t.atoms.text} />
+                </View>
+              </View>
+            </View>
+          </SettingsList.Group>
+          <SettingsList.Divider />
+          <SettingsList.Group iconInset={false}>
+            <SettingsList.ItemText>
+              <Trans>Content Languages</Trans>
+            </SettingsList.ItemText>
+            <View style={[a.gap_md]}>
+              <Text style={[a.leading_snug]}>
+                <Trans>
+                  Select which languages you want your subscribed feeds to
+                  include. If none are selected, all languages will be shown.
+                </Trans>
+              </Text>
+
+              <Button
+                label={_(msg`Select content languages`)}
+                size="small"
+                color="secondary"
+                variant="solid"
+                onPress={onPressContentLanguages}
+                style={[a.justify_start, web({maxWidth: 400})]}>
+                <ButtonIcon
+                  icon={myLanguages.length > 0 ? CheckIcon : PlusIcon}
+                />
+                <ButtonText
+                  style={[t.atoms.text, a.text_md, a.flex_1, a.text_left]}
+                  numberOfLines={1}>
+                  {myLanguages.length > 0
+                    ? myLanguages
+                    : _(msg`Select languages`)}
+                </ButtonText>
+              </Button>
+            </View>
+          </SettingsList.Group>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings.tsx b/src/screens/Settings/NotificationSettings.tsx
new file mode 100644
index 000000000..8e1033742
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings.tsx
@@ -0,0 +1,85 @@
+import React from 'react'
+import {Text} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {AllNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
+import {useNotificationSettingsMutation} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Error} from '#/components/Error'
+import * as Toggle from '#/components/forms/Toggle'
+import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
+import * as Layout from '#/components/Layout'
+import {Loader} from '#/components/Loader'
+import * as SettingsList from './components/SettingsList'
+
+type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationSettings'>
+export function NotificationSettingsScreen({}: Props) {
+  const {_} = useLingui()
+
+  const {data, isError: isQueryError, refetch} = useNotificationFeedQuery()
+  const serverPriority = data?.pages.at(0)?.priority
+
+  const {
+    mutate: onChangePriority,
+    isPending: isMutationPending,
+    variables,
+  } = useNotificationSettingsMutation()
+
+  const priority = isMutationPending
+    ? variables[0] === 'enabled'
+    : serverPriority
+
+  return (
+    <Layout.Screen>
+      <Layout.Header title={_(msg`Notification Settings`)} />
+      <Layout.Content>
+        {isQueryError ? (
+          <Error
+            title={_(msg`Oops!`)}
+            message={_(msg`Something went wrong!`)}
+            onRetry={refetch}
+            sideBorders={false}
+          />
+        ) : (
+          <SettingsList.Container>
+            <SettingsList.Group>
+              <SettingsList.ItemIcon icon={BeakerIcon} />
+              <SettingsList.ItemText>
+                <Trans>Notification filters</Trans>
+              </SettingsList.ItemText>
+              <Toggle.Group
+                label={_(msg`Priority notifications`)}
+                type="checkbox"
+                values={priority ? ['enabled'] : []}
+                onChange={onChangePriority}
+                disabled={typeof priority !== 'boolean' || isMutationPending}>
+                <Toggle.Item
+                  name="enabled"
+                  label={_(msg`Enable priority notifications`)}
+                  style={[a.flex_1, a.justify_between]}>
+                  <Toggle.LabelText>
+                    <Trans>Enable priority notifications</Trans>
+                  </Toggle.LabelText>
+                  {!data ? <Loader size="md" /> : <Toggle.Platform />}
+                </Toggle.Item>
+              </Toggle.Group>
+            </SettingsList.Group>
+            <SettingsList.Item>
+              <Admonition type="warning" style={[a.flex_1]}>
+                <Trans>
+                  <Text style={[a.font_bold]}>Experimental:</Text> When this
+                  preference is enabled, you'll only receive reply and quote
+                  notifications from users you follow. We'll continue to add
+                  more controls here over time.
+                </Trans>
+              </Admonition>
+            </SettingsList.Item>
+          </SettingsList.Container>
+        )}
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index 789ffb56f..bfdb79135 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -60,6 +60,7 @@ export function SettingsScreen({}: Props) {
           <View
             style={[
               a.px_xl,
+              a.pt_md,
               a.pb_md,
               a.w_full,
               a.gap_2xs,
diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx
new file mode 100644
index 000000000..bae281fdc
--- /dev/null
+++ b/src/screens/Settings/ThreadPreferences.tsx
@@ -0,0 +1,145 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  usePreferencesQuery,
+  useSetThreadViewPreferencesMutation,
+} from '#/state/queries/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
+import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
+import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+import * as SettingsList from './components/SettingsList'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
+export function ThreadPreferencesScreen({}: Props) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate: setThreadViewPrefs, variables} =
+    useSetThreadViewPreferencesMutation()
+
+  const sortReplies = variables?.sort ?? preferences?.threadViewPrefs?.sort
+
+  const prioritizeFollowedUsers = Boolean(
+    variables?.prioritizeFollowedUsers ??
+      preferences?.threadViewPrefs?.prioritizeFollowedUsers,
+  )
+  const treeViewEnabled = Boolean(
+    variables?.lab_treeViewEnabled ??
+      preferences?.threadViewPrefs?.lab_treeViewEnabled,
+  )
+
+  return (
+    <Layout.Screen testID="threadPreferencesScreen">
+      <Layout.Header title={_(msg`Thread Preferences`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Group>
+            <SettingsList.ItemIcon icon={BubblesIcon} />
+            <SettingsList.ItemText>Sort replies</SettingsList.ItemText>
+            <View style={[a.w_full, a.gap_md]}>
+              <Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
+                <Trans>Sort replies to the same post by:</Trans>
+              </Text>
+              <Toggle.Group
+                label={_(msg`Sort replies by`)}
+                type="radio"
+                values={sortReplies ? [sortReplies] : []}
+                onChange={values => setThreadViewPrefs({sort: values[0]})}>
+                <View style={[a.gap_sm, a.flex_1]}>
+                  <Toggle.Item
+                    name="oldest"
+                    label={_(msg`Oldest replies first`)}>
+                    <Toggle.Radio />
+                    <Toggle.LabelText>
+                      <Trans>Oldest replies first</Trans>
+                    </Toggle.LabelText>
+                  </Toggle.Item>
+                  <Toggle.Item
+                    name="newest"
+                    label={_(msg`Newest replies first`)}>
+                    <Toggle.Radio />
+                    <Toggle.LabelText>
+                      <Trans>Newest replies first</Trans>
+                    </Toggle.LabelText>
+                  </Toggle.Item>
+                  <Toggle.Item
+                    name="most-likes"
+                    label={_(msg`Most-liked replies first`)}>
+                    <Toggle.Radio />
+                    <Toggle.LabelText>
+                      <Trans>Most-liked first</Trans>
+                    </Toggle.LabelText>
+                  </Toggle.Item>
+                  <Toggle.Item
+                    name="random"
+                    label={_(msg`Random (aka "Poster's Roulette")`)}>
+                    <Toggle.Radio />
+                    <Toggle.LabelText>
+                      <Trans>Random (aka "Poster's Roulette")</Trans>
+                    </Toggle.LabelText>
+                  </Toggle.Item>
+                </View>
+              </Toggle.Group>
+            </View>
+          </SettingsList.Group>
+          <SettingsList.Group>
+            <SettingsList.ItemIcon icon={PersonGroupIcon} />
+            <SettingsList.ItemText>
+              <Trans>Prioritize your Follows</Trans>
+            </SettingsList.ItemText>
+            <Toggle.Item
+              type="checkbox"
+              name="prioritize-follows"
+              label={_(msg`Prioritize your Follows`)}
+              value={prioritizeFollowedUsers}
+              onChange={value =>
+                setThreadViewPrefs({
+                  prioritizeFollowedUsers: value,
+                })
+              }
+              style={[a.w_full, a.gap_md]}>
+              <Toggle.LabelText style={[a.flex_1]}>
+                <Trans>
+                  Show replies by people you follow before all other replies.
+                </Trans>
+              </Toggle.LabelText>
+              <Toggle.Platform />
+            </Toggle.Item>
+          </SettingsList.Group>
+          <SettingsList.Divider />
+          <SettingsList.Group>
+            <SettingsList.ItemIcon icon={BeakerIcon} />
+            <SettingsList.ItemText>
+              <Trans>Experimental</Trans>
+            </SettingsList.ItemText>
+            <Toggle.Item
+              type="checkbox"
+              name="threaded-mode"
+              label={_(msg`Threaded mode`)}
+              value={treeViewEnabled}
+              onChange={value =>
+                setThreadViewPrefs({
+                  lab_treeViewEnabled: value,
+                })
+              }
+              style={[a.w_full, a.gap_md]}>
+              <Toggle.LabelText style={[a.flex_1]}>
+                <Trans>Show replies in a threaded view</Trans>
+              </Toggle.LabelText>
+              <Toggle.Platform />
+            </Toggle.Item>
+          </SettingsList.Group>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
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,
+      ]}
     />
   )
 }