about summary refs log tree commit diff
path: root/src/screens/Settings
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Settings')
-rw-r--r--src/screens/Settings/AboutSettings.tsx78
-rw-r--r--src/screens/Settings/AccessibilitySettings.tsx113
-rw-r--r--src/screens/Settings/AccountSettings.tsx180
-rw-r--r--src/screens/Settings/AppearanceSettings.tsx165
-rw-r--r--src/screens/Settings/ContentAndMediaSettings.tsx104
-rw-r--r--src/screens/Settings/PrivacyAndSecuritySettings.tsx91
-rw-r--r--src/screens/Settings/Settings.tsx282
-rw-r--r--src/screens/Settings/components/Email2FAToggle.tsx66
-rw-r--r--src/screens/Settings/components/PwiOptOut.tsx100
-rw-r--r--src/screens/Settings/components/SettingsList.tsx300
10 files changed, 1390 insertions, 89 deletions
diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx
new file mode 100644
index 000000000..3c445b966
--- /dev/null
+++ b/src/screens/Settings/AboutSettings.tsx
@@ -0,0 +1,78 @@
+import React from 'react'
+import {Platform} from 'react-native'
+import {setStringAsync} from 'expo-clipboard'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+
+import {appVersion, BUNDLE_DATE, bundleInfo} from '#/lib/app-info'
+import {STATUS_PAGE_URL} from '#/lib/constants'
+import {CommonNavigatorParams} from '#/lib/routes/types'
+import * as Toast from '#/view/com/util/Toast'
+import * as SettingsList from '#/screens/Settings/components/SettingsList'
+import {CodeLines_Stroke2_Corner2_Rounded as CodeLinesIcon} from '#/components/icons/CodeLines'
+import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
+import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/icons/Newspaper'
+import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench'
+import * as Layout from '#/components/Layout'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'AboutSettings'>
+export function AboutSettingsScreen({}: Props) {
+  const {_} = useLingui()
+
+  return (
+    <Layout.Screen>
+      <Layout.Header title={_(msg`About`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.LinkItem
+            to="https://bsky.social/about/support/tos"
+            label={_(msg`Terms of Service`)}>
+            <SettingsList.ItemIcon icon={NewspaperIcon} />
+            <SettingsList.ItemText>
+              <Trans>Terms of Service</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="https://bsky.social/about/support/privacy-policy"
+            label={_(msg`Privacy Policy`)}>
+            <SettingsList.ItemIcon icon={NewspaperIcon} />
+            <SettingsList.ItemText>
+              <Trans>Privacy Policy</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to={STATUS_PAGE_URL}
+            label={_(msg`Status Page`)}>
+            <SettingsList.ItemIcon icon={GlobeIcon} />
+            <SettingsList.ItemText>
+              <Trans>Status Page</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.Divider />
+          <SettingsList.LinkItem to="/sys/log" label={_(msg`System log`)}>
+            <SettingsList.ItemIcon icon={CodeLinesIcon} />
+            <SettingsList.ItemText>
+              <Trans>System log</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.PressableItem
+            label={_(msg`Version ${appVersion}`)}
+            accessibilityHint={_(msg`Copy build version to clipboard`)}
+            onPress={() => {
+              setStringAsync(
+                `Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}; Platform version: ${Platform.Version}`,
+              )
+              Toast.show(_(msg`Copied build version to clipboard`))
+            }}>
+            <SettingsList.ItemIcon icon={WrenchIcon} />
+            <SettingsList.ItemText>
+              <Trans>Version {appVersion}</Trans>
+            </SettingsList.ItemText>
+            <SettingsList.BadgeText>{bundleInfo}</SettingsList.BadgeText>
+          </SettingsList.PressableItem>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/AccessibilitySettings.tsx b/src/screens/Settings/AccessibilitySettings.tsx
new file mode 100644
index 000000000..dfe2c14a5
--- /dev/null
+++ b/src/screens/Settings/AccessibilitySettings.tsx
@@ -0,0 +1,113 @@
+import React from 'react'
+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 {isNative} from '#/platform/detection'
+import {
+  useHapticsDisabled,
+  useRequireAltTextEnabled,
+  useSetHapticsDisabled,
+  useSetRequireAltTextEnabled,
+} from '#/state/preferences'
+import {
+  useLargeAltBadgeEnabled,
+  useSetLargeAltBadgeEnabled,
+} from '#/state/preferences/large-alt-badge'
+import * as SettingsList from '#/screens/Settings/components/SettingsList'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import * as Toggle from '#/components/forms/Toggle'
+import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
+import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
+import * as Layout from '#/components/Layout'
+import {InlineLinkText} from '#/components/Link'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'AccessibilitySettings'
+>
+export function AccessibilitySettingsScreen({}: Props) {
+  const {_} = useLingui()
+
+  const requireAltTextEnabled = useRequireAltTextEnabled()
+  const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
+  const hapticsDisabled = useHapticsDisabled()
+  const setHapticsDisabled = useSetHapticsDisabled()
+  const largeAltBadgeEnabled = useLargeAltBadgeEnabled()
+  const setLargeAltBadgeEnabled = useSetLargeAltBadgeEnabled()
+
+  return (
+    <Layout.Screen>
+      <Layout.Header title={_(msg`Accessibility`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Group contentContainerStyle={[a.gap_sm]}>
+            <SettingsList.ItemIcon icon={AccessibilityIcon} />
+            <SettingsList.ItemText>
+              <Trans>Alt text</Trans>
+            </SettingsList.ItemText>
+            <Toggle.Item
+              name="require_alt_text"
+              label={_(msg`Require alt text before posting`)}
+              value={requireAltTextEnabled ?? false}
+              onChange={value => setRequireAltTextEnabled(value)}
+              style={[a.w_full]}>
+              <Toggle.LabelText style={[a.flex_1]}>
+                <Trans>Require alt text before posting</Trans>
+              </Toggle.LabelText>
+              <Toggle.Platform />
+            </Toggle.Item>
+            <Toggle.Item
+              name="large_alt_badge"
+              label={_(msg`Display larger alt text badges`)}
+              value={!!largeAltBadgeEnabled}
+              onChange={value => setLargeAltBadgeEnabled(value)}
+              style={[a.w_full]}>
+              <Toggle.LabelText style={[a.flex_1]}>
+                <Trans>Display larger alt text badges</Trans>
+              </Toggle.LabelText>
+              <Toggle.Platform />
+            </Toggle.Item>
+          </SettingsList.Group>
+          {isNative && (
+            <>
+              <SettingsList.Divider />
+              <SettingsList.Group contentContainerStyle={[a.gap_sm]}>
+                <SettingsList.ItemIcon icon={HapticIcon} />
+                <SettingsList.ItemText>
+                  <Trans>Haptics</Trans>
+                </SettingsList.ItemText>
+                <Toggle.Item
+                  name="haptics"
+                  label={_(msg`Disable haptic feedback`)}
+                  value={hapticsDisabled ?? false}
+                  onChange={value => setHapticsDisabled(value)}
+                  style={[a.w_full]}>
+                  <Toggle.LabelText style={[a.flex_1]}>
+                    <Trans>Disable haptic feedback</Trans>
+                  </Toggle.LabelText>
+                  <Toggle.Platform />
+                </Toggle.Item>
+              </SettingsList.Group>
+            </>
+          )}
+          <SettingsList.Item>
+            <Admonition type="info" style={[a.flex_1]}>
+              <Trans>
+                Autoplay options have moved to the{' '}
+                <InlineLinkText
+                  to="/settings/content-and-media"
+                  label={_(msg`Content and media`)}>
+                  Content and Media settings
+                </InlineLinkText>
+                .
+              </Trans>
+            </Admonition>
+          </SettingsList.Item>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx
new file mode 100644
index 000000000..19101d2f4
--- /dev/null
+++ b/src/screens/Settings/AccountSettings.tsx
@@ -0,0 +1,180 @@
+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'
+import {atoms as a, useTheme} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
+import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
+import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
+import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake'
+import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
+import {Freeze_Stroke2_Corner2_Rounded as FreezeIcon} from '#/components/icons/Freeze'
+import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
+import {PencilLine_Stroke2_Corner2_Rounded as PencilIcon} from '#/components/icons/Pencil'
+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 {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
+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 exportCarControl = useDialogControl()
+  const deactivateAccountControl = useDialogControl()
+
+  return (
+    <Layout.Screen>
+      <Layout.Header title={_(msg`Account`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item>
+            <SettingsList.ItemIcon icon={EnvelopeIcon} />
+            <SettingsList.ItemText>
+              <Trans>Email</Trans>
+            </SettingsList.ItemText>
+            {currentAccount && (
+              <>
+                <SettingsList.BadgeText>
+                  {currentAccount.email || <Trans>(no email)</Trans>}
+                </SettingsList.BadgeText>
+                {currentAccount.emailConfirmed ? (
+                  <CheckIcon color={t.palette.positive_500} size="sm" />
+                ) : (
+                  <SettingsList.BadgeButton
+                    label={_(msg`Verify`)}
+                    onPress={() => {}}
+                  />
+                )}
+              </>
+            )}
+          </SettingsList.Item>
+          <SettingsList.PressableItem
+            label={_(msg`Change email`)}
+            onPress={() => openModal({name: 'change-email'})}>
+            <SettingsList.ItemIcon icon={PencilIcon} />
+            <SettingsList.ItemText>
+              <Trans>Change email</Trans>
+            </SettingsList.ItemText>
+            <SettingsList.Chevron />
+          </SettingsList.PressableItem>
+          <SettingsList.LinkItem
+            to="/settings/privacy-and-security"
+            label={_(msg`Protect your account`)}
+            style={[
+              a.my_xs,
+              a.mx_lg,
+              a.rounded_md,
+              {backgroundColor: t.palette.primary_50},
+            ]}
+            chevronColor={t.palette.primary_500}
+            hoverStyle={[{backgroundColor: t.palette.primary_100}]}
+            contentContainerStyle={[a.rounded_md, a.px_lg]}>
+            <SettingsList.ItemIcon
+              icon={VerifiedIcon}
+              color={t.palette.primary_500}
+            />
+            <SettingsList.ItemText
+              style={[{color: t.palette.primary_500}, a.font_bold]}>
+              <Trans>Protect your account</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.Divider />
+          <SettingsList.Item>
+            <SettingsList.ItemIcon icon={BirthdayCakeIcon} />
+            <SettingsList.ItemText>
+              <Trans>Birthday</Trans>
+            </SettingsList.ItemText>
+            <SettingsList.BadgeButton
+              label={_(msg`Edit`)}
+              onPress={() => birthdayControl.open()}
+            />
+          </SettingsList.Item>
+          <SettingsList.PressableItem
+            label={_(msg`Password`)}
+            onPress={() => openModal({name: 'change-password'})}>
+            <SettingsList.ItemIcon icon={LockIcon} />
+            <SettingsList.ItemText>
+              <Trans>Password</Trans>
+            </SettingsList.ItemText>
+            <SettingsList.Chevron />
+          </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),
+                    })
+                  }
+                },
+              })
+            }>
+            <SettingsList.ItemIcon icon={AtIcon} />
+            <SettingsList.ItemText>
+              <Trans>Handle</Trans>
+            </SettingsList.ItemText>
+            {profile && (
+              <SettingsList.BadgeText>@{profile.handle}</SettingsList.BadgeText>
+            )}
+            <SettingsList.Chevron />
+          </SettingsList.PressableItem>
+          <SettingsList.Divider />
+          <SettingsList.PressableItem
+            label={_(msg`Export my data`)}
+            onPress={() => exportCarControl.open()}>
+            <SettingsList.ItemIcon icon={CarIcon} />
+            <SettingsList.ItemText>
+              <Trans>Export my data</Trans>
+            </SettingsList.ItemText>
+            <SettingsList.Chevron />
+          </SettingsList.PressableItem>
+          <SettingsList.PressableItem
+            label={_(msg`Deactivate account`)}
+            onPress={() => deactivateAccountControl.open()}
+            destructive>
+            <SettingsList.ItemIcon icon={FreezeIcon} />
+            <SettingsList.ItemText>
+              <Trans>Deactivate account</Trans>
+            </SettingsList.ItemText>
+            <SettingsList.Chevron />
+          </SettingsList.PressableItem>
+          <SettingsList.PressableItem
+            label={_(msg`Delete account`)}
+            onPress={() => openModal({name: 'delete-account'})}
+            destructive>
+            <SettingsList.ItemIcon icon={Trash_Stroke2_Corner2_Rounded} />
+            <SettingsList.ItemText>
+              <Trans>Delete account</Trans>
+            </SettingsList.ItemText>
+            <SettingsList.Chevron />
+          </SettingsList.PressableItem>
+        </SettingsList.Container>
+      </Layout.Content>
+
+      <BirthDateSettingsDialog control={birthdayControl} />
+      <ExportCarDialog control={exportCarControl} />
+      <DeactivateAccountDialog control={deactivateAccountControl} />
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx
index c317c930f..d0beb7d50 100644
--- a/src/screens/Settings/AppearanceSettings.tsx
+++ b/src/screens/Settings/AppearanceSettings.tsx
@@ -1,18 +1,15 @@
 import React, {useCallback} from 'react'
-import {View} from 'react-native'
 import Animated, {
-  FadeInDown,
-  FadeOutDown,
+  FadeInUp,
+  FadeOutUp,
   LayoutAnimationConfig,
+  LinearTransition,
 } from 'react-native-reanimated'
-import {msg, Trans} from '@lingui/macro'
+import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
 import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
-import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
-import {ScrollView} from '#/view/com/util/Views'
 import {atoms as a, native, useAlf, useTheme} from '#/alf'
 import * as ToggleButton from '#/components/forms/ToggleButton'
 import {Props as SVGIconProps} from '#/components/icons/common'
@@ -22,12 +19,11 @@ import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/T
 import {TitleCase_Stroke2_Corner0_Rounded as Aa} from '#/components/icons/TitleCase'
 import * as Layout from '#/components/Layout'
 import {Text} from '#/components/Typography'
+import * as SettingsList from './components/SettingsList'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
 export function AppearanceSettingsScreen({}: Props) {
-  const t = useTheme()
   const {_} = useLingui()
-  const {isTabletOrMobile} = useWebMediaQueries()
   const {fonts} = useAlf()
 
   const {colorMode, darkTheme} = useThemePrefs()
@@ -77,66 +73,54 @@ export function AppearanceSettingsScreen({}: Props) {
   return (
     <LayoutAnimationConfig skipExiting skipEntering>
       <Layout.Screen testID="preferencesThreadsScreen">
-        <ScrollView
-          // @ts-ignore web only -prf
-          dataSet={{'stable-gutters': 1}}
-          contentContainerStyle={{paddingBottom: 75}}>
-          <SimpleViewHeader
-            showBackButton={isTabletOrMobile}
-            style={[t.atoms.border_contrast_medium, a.border_b]}>
-            <View style={a.flex_1}>
-              <Text style={[a.text_2xl, a.font_bold]}>
-                <Trans>Appearance</Trans>
-              </Text>
-            </View>
-          </SimpleViewHeader>
-
-          <View style={[a.gap_3xl, a.pt_xl, a.px_xl]}>
-            <View style={[a.gap_lg]}>
-              <AppearanceToggleButtonGroup
-                title={_(msg`Color mode`)}
-                icon={PhoneIcon}
-                items={[
-                  {
-                    label: _(msg`System`),
-                    name: 'system',
-                  },
-                  {
-                    label: _(msg`Light`),
-                    name: 'light',
-                  },
-                  {
-                    label: _(msg`Dark`),
-                    name: 'dark',
-                  },
-                ]}
-                values={[colorMode]}
-                onChange={onChangeAppearance}
-              />
+        <Layout.Header title={_(msg`Appearance`)} />
+        <Layout.Content>
+          <SettingsList.Container>
+            <AppearanceToggleButtonGroup
+              title={_(msg`Color mode`)}
+              icon={PhoneIcon}
+              items={[
+                {
+                  label: _(msg`System`),
+                  name: 'system',
+                },
+                {
+                  label: _(msg`Light`),
+                  name: 'light',
+                },
+                {
+                  label: _(msg`Dark`),
+                  name: 'dark',
+                },
+              ]}
+              values={[colorMode]}
+              onChange={onChangeAppearance}
+            />
 
-              {colorMode !== 'light' && (
-                <Animated.View
-                  entering={native(FadeInDown)}
-                  exiting={native(FadeOutDown)}>
-                  <AppearanceToggleButtonGroup
-                    title={_(msg`Dark theme`)}
-                    icon={MoonIcon}
-                    items={[
-                      {
-                        label: _(msg`Dim`),
-                        name: 'dim',
-                      },
-                      {
-                        label: _(msg`Dark`),
-                        name: 'dark',
-                      },
-                    ]}
-                    values={[darkTheme ?? 'dim']}
-                    onChange={onChangeDarkTheme}
-                  />
-                </Animated.View>
-              )}
+            {colorMode !== 'light' && (
+              <Animated.View
+                entering={native(FadeInUp)}
+                exiting={native(FadeOutUp)}>
+                <AppearanceToggleButtonGroup
+                  title={_(msg`Dark theme`)}
+                  icon={MoonIcon}
+                  items={[
+                    {
+                      label: _(msg`Dim`),
+                      name: 'dim',
+                    },
+                    {
+                      label: _(msg`Dark`),
+                      name: 'dark',
+                    },
+                  ]}
+                  values={[darkTheme ?? 'dim']}
+                  onChange={onChangeDarkTheme}
+                />
+              </Animated.View>
+            )}
 
+            <Animated.View layout={native(LinearTransition)}>
               <AppearanceToggleButtonGroup
                 title={_(msg`Font`)}
                 description={_(
@@ -177,9 +161,9 @@ export function AppearanceSettingsScreen({}: Props) {
                 values={[fonts.scale]}
                 onChange={onChangeFontScale}
               />
-            </View>
-          </View>
-        </ScrollView>
+            </Animated.View>
+          </SettingsList.Container>
+        </Layout.Content>
       </Layout.Screen>
     </LayoutAnimationConfig>
   )
@@ -205,29 +189,32 @@ export function AppearanceToggleButtonGroup({
 }) {
   const t = useTheme()
   return (
-    <View style={[a.gap_sm]}>
-      <View style={[a.gap_xs]}>
-        <View style={[a.flex_row, a.align_center, a.gap_md]}>
-          <Icon style={t.atoms.text} />
-          <Text style={[a.text_md, a.font_bold]}>{title}</Text>
-        </View>
+    <>
+      <SettingsList.Group contentContainerStyle={[a.gap_sm]} iconInset={false}>
+        <SettingsList.ItemIcon icon={Icon} />
+        <SettingsList.ItemText>{title}</SettingsList.ItemText>
         {description && (
           <Text
-            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            style={[
+              a.text_sm,
+              a.leading_snug,
+              t.atoms.text_contrast_medium,
+              a.w_full,
+            ]}>
             {description}
           </Text>
         )}
-      </View>
-      <ToggleButton.Group label={title} values={values} onChange={onChange}>
-        {items.map(item => (
-          <ToggleButton.Button
-            key={item.name}
-            label={item.label}
-            name={item.name}>
-            <ToggleButton.ButtonText>{item.label}</ToggleButton.ButtonText>
-          </ToggleButton.Button>
-        ))}
-      </ToggleButton.Group>
-    </View>
+        <ToggleButton.Group label={title} values={values} onChange={onChange}>
+          {items.map(item => (
+            <ToggleButton.Button
+              key={item.name}
+              label={item.label}
+              name={item.name}>
+              <ToggleButton.ButtonText>{item.label}</ToggleButton.ButtonText>
+            </ToggleButton.Button>
+          ))}
+        </ToggleButton.Group>
+      </SettingsList.Group>
+    </>
   )
 }
diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx
new file mode 100644
index 000000000..79c8a48f3
--- /dev/null
+++ b/src/screens/Settings/ContentAndMediaSettings.tsx
@@ -0,0 +1,104 @@
+import React from 'react'
+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 {isNative} from '#/platform/detection'
+import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
+import {
+  useInAppBrowser,
+  useSetInAppBrowser,
+} from '#/state/preferences/in-app-browser'
+import * as SettingsList from '#/screens/Settings/components/SettingsList'
+import * as Toggle from '#/components/forms/Toggle'
+import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
+import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons/Hashtag'
+import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home'
+import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh'
+import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
+import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
+import * as Layout from '#/components/Layout'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'ContentAndMediaSettings'
+>
+export function ContentAndMediaSettingsScreen({}: Props) {
+  const {_} = useLingui()
+  const autoplayDisabledPref = useAutoplayDisabled()
+  const setAutoplayDisabledPref = useSetAutoplayDisabled()
+  const inAppBrowserPref = useInAppBrowser()
+  const setUseInAppBrowser = useSetInAppBrowser()
+
+  return (
+    <Layout.Screen>
+      <Layout.Header title={_(msg`Content and Media`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.LinkItem
+            to="/settings/saved-feeds"
+            label={_(msg`Manage saved feeds`)}>
+            <SettingsList.ItemIcon icon={HashtagIcon} />
+            <SettingsList.ItemText>
+              <Trans>Manage saved feeds</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="/settings/threads"
+            label={_(msg`Thread preferences`)}>
+            <SettingsList.ItemIcon icon={BubblesIcon} />
+            <SettingsList.ItemText>
+              <Trans>Thread preferences</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="/settings/following-feed"
+            label={_(msg`Following feed preferences`)}>
+            <SettingsList.ItemIcon icon={HomeIcon} />
+            <SettingsList.ItemText>
+              <Trans>Following feed preferences</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="/settings/external-embeds"
+            label={_(msg`External media`)}>
+            <SettingsList.ItemIcon icon={MacintoshIcon} />
+            <SettingsList.ItemText>
+              <Trans>External media</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.Divider />
+          {isNative && (
+            <Toggle.Item
+              name="use_in_app_browser"
+              label={_(msg`Use in-app browser to open links`)}
+              value={inAppBrowserPref ?? false}
+              onChange={value => setUseInAppBrowser(value)}>
+              <SettingsList.Item>
+                <SettingsList.ItemIcon icon={WindowIcon} />
+                <SettingsList.ItemText>
+                  <Trans>Use in-app browser to open links</Trans>
+                </SettingsList.ItemText>
+                <Toggle.Platform />
+              </SettingsList.Item>
+            </Toggle.Item>
+          )}
+          <Toggle.Item
+            name="disable_autoplay"
+            label={_(msg`Disable autoplay for videos and GIFs`)}
+            value={autoplayDisabledPref}
+            onChange={value => setAutoplayDisabledPref(value)}>
+            <SettingsList.Item>
+              <SettingsList.ItemIcon icon={PlayIcon} />
+              <SettingsList.ItemText>
+                <Trans>Disable autoplay for videos and GIFs</Trans>
+              </SettingsList.ItemText>
+              <Toggle.Platform />
+            </SettingsList.Item>
+          </Toggle.Item>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/PrivacyAndSecuritySettings.tsx b/src/screens/Settings/PrivacyAndSecuritySettings.tsx
new file mode 100644
index 000000000..da462c90d
--- /dev/null
+++ b/src/screens/Settings/PrivacyAndSecuritySettings.tsx
@@ -0,0 +1,91 @@
+import React from 'react'
+import {View} from 'react-native'
+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 {useAppPasswordsQuery} from '#/state/queries/app-passwords'
+import * as SettingsList from '#/screens/Settings/components/SettingsList'
+import {atoms as a} from '#/alf'
+import * as Admonition from '#/components/Admonition'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash'
+import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key'
+import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified'
+import * as Layout from '#/components/Layout'
+import {InlineLinkText} from '#/components/Link'
+import {Email2FAToggle} from './components/Email2FAToggle'
+import {PwiOptOut} from './components/PwiOptOut'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'PrivacyAndSecuritySettings'
+>
+export function PrivacyAndSecuritySettingsScreen({}: Props) {
+  const {_} = useLingui()
+  const {data: appPasswords} = useAppPasswordsQuery()
+  return (
+    <Layout.Screen>
+      <Layout.Header title={_(msg`Privacy and Security`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item>
+            <SettingsList.ItemIcon icon={VerifiedIcon} />
+            <SettingsList.ItemText>
+              <Trans>Two-factor authentication (2FA)</Trans>
+            </SettingsList.ItemText>
+            <Email2FAToggle />
+          </SettingsList.Item>
+          <SettingsList.LinkItem
+            to="/settings/app-passwords"
+            label={_(msg`App passwords`)}>
+            <SettingsList.ItemIcon icon={KeyIcon} />
+            <SettingsList.ItemText>
+              <Trans>App passwords</Trans>
+            </SettingsList.ItemText>
+            {appPasswords && appPasswords.length > 0 && (
+              <SettingsList.BadgeText>
+                {appPasswords.length}
+              </SettingsList.BadgeText>
+            )}
+          </SettingsList.LinkItem>
+          <SettingsList.Divider />
+          <SettingsList.Group>
+            <SettingsList.ItemIcon icon={EyeSlashIcon} />
+            <SettingsList.ItemText>
+              <Trans>Logged-out visibility</Trans>
+            </SettingsList.ItemText>
+            <PwiOptOut />
+          </SettingsList.Group>
+          <SettingsList.Item>
+            <Admonition.Outer type="tip" style={[a.flex_1]}>
+              <Admonition.Row>
+                <Admonition.Icon />
+                <View style={[a.flex_1, a.gap_sm]}>
+                  <Admonition.Text>
+                    <Trans>
+                      Note: Bluesky is an open and public network. This setting
+                      only limits the visibility of your content on the Bluesky
+                      app and website, and other apps may not respect this
+                      setting. Your content may still be shown to logged-out
+                      users by other apps and websites.
+                    </Trans>
+                  </Admonition.Text>
+                  <Admonition.Text>
+                    <InlineLinkText
+                      label={_(
+                        msg`Learn more about what is public on Bluesky.`,
+                      )}
+                      to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
+                      <Trans>Learn more about what is public on Bluesky.</Trans>
+                    </InlineLinkText>
+                  </Admonition.Text>
+                </View>
+              </Admonition.Row>
+            </Admonition.Outer>
+          </SettingsList.Item>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
new file mode 100644
index 000000000..789ffb56f
--- /dev/null
+++ b/src/screens/Settings/Settings.tsx
@@ -0,0 +1,282 @@
+import React from 'react'
+import {View} from 'react-native'
+import {Linking} from 'react-native'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+
+import {HELP_DESK_URL} from '#/lib/constants'
+import {CommonNavigatorParams} from '#/lib/routes/types'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
+import {useSession, useSessionApi} from '#/state/session'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {useCloseAllActiveElements} from '#/state/util'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName'
+import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
+import * as SettingsList from '#/screens/Settings/components/SettingsList'
+import {atoms as a, useTheme} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
+import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
+import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
+import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo'
+import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion'
+import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
+import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
+import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller'
+import {
+  Person_Stroke2_Corner2_Rounded as PersonIcon,
+  PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon,
+} from '#/components/icons/Person'
+import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
+import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
+import * as Layout from '#/components/Layout'
+import * as Prompt from '#/components/Prompt'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
+export function SettingsScreen({}: Props) {
+  const {_} = useLingui()
+  const {logoutEveryAccount} = useSessionApi()
+  const {accounts, currentAccount} = useSession()
+  const switchAccountControl = useDialogControl()
+  const signOutPromptControl = Prompt.usePromptControl()
+  const {data: profile} = useProfileQuery({did: currentAccount?.did})
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const closeEverything = useCloseAllActiveElements()
+
+  const onAddAnotherAccount = () => {
+    setShowLoggedOut(true)
+    closeEverything()
+  }
+
+  return (
+    <Layout.Screen>
+      <Layout.Header title={_(msg`Settings`)} />
+      <Layout.Content>
+        <SettingsList.Container>
+          <View
+            style={[
+              a.px_xl,
+              a.pb_md,
+              a.w_full,
+              a.gap_2xs,
+              a.align_center,
+              {minHeight: 160},
+            ]}>
+            {profile && <ProfilePreview profile={profile} />}
+          </View>
+          <SettingsList.PressableItem
+            label={
+              accounts.length > 1
+                ? _(msg`Switch account`)
+                : _(msg`Add another account`)
+            }
+            onPress={() =>
+              accounts.length > 1
+                ? switchAccountControl.open()
+                : onAddAnotherAccount()
+            }>
+            <SettingsList.ItemIcon icon={PersonGroupIcon} />
+            <SettingsList.ItemText>
+              {accounts.length > 1 ? (
+                <Trans>Switch account</Trans>
+              ) : (
+                <Trans>Add another account</Trans>
+              )}
+            </SettingsList.ItemText>
+            {accounts.length > 1 && (
+              <AvatarStack
+                profiles={accounts
+                  .map(acc => acc.did)
+                  .filter(did => did !== currentAccount?.did)
+                  .slice(0, 5)}
+              />
+            )}
+          </SettingsList.PressableItem>
+          <SettingsList.Divider />
+          <SettingsList.LinkItem to="/settings/account" label={_(msg`Account`)}>
+            <SettingsList.ItemIcon icon={PersonIcon} />
+            <SettingsList.ItemText>
+              <Trans>Account</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="/settings/privacy-and-security"
+            label={_(msg`Privacy and security`)}>
+            <SettingsList.ItemIcon icon={LockIcon} />
+            <SettingsList.ItemText>
+              <Trans>Privacy and security</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem to="/moderation" label={_(msg`Moderation`)}>
+            <SettingsList.ItemIcon icon={HandIcon} />
+            <SettingsList.ItemText>
+              <Trans>Moderation</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="/settings/content-and-media"
+            label={_(msg`Content and media`)}>
+            <SettingsList.ItemIcon icon={WindowIcon} />
+            <SettingsList.ItemText>
+              <Trans>Content and media</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="/settings/appearance"
+            label={_(msg`Appearance`)}>
+            <SettingsList.ItemIcon icon={PaintRollerIcon} />
+            <SettingsList.ItemText>
+              <Trans>Appearance</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="/settings/accessibility"
+            label={_(msg`Accessibility`)}>
+            <SettingsList.ItemIcon icon={AccessibilityIcon} />
+            <SettingsList.ItemText>
+              <Trans>Accessibility</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="/settings/language"
+            label={_(msg`Languages`)}>
+            <SettingsList.ItemIcon icon={EarthIcon} />
+            <SettingsList.ItemText>
+              <Trans>Languages</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.PressableItem
+            onPress={() => Linking.openURL(HELP_DESK_URL)}
+            label={_(msg`Help`)}
+            accessibilityHint={_(msg`Open helpdesk in browser`)}>
+            <SettingsList.ItemIcon icon={CircleQuestionIcon} />
+            <SettingsList.ItemText>
+              <Trans>Help</Trans>
+            </SettingsList.ItemText>
+            <SettingsList.Chevron />
+          </SettingsList.PressableItem>
+          <SettingsList.LinkItem to="/settings/about" label={_(msg`About`)}>
+            <SettingsList.ItemIcon icon={BubbleInfoIcon} />
+            <SettingsList.ItemText>
+              <Trans>About</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
+          <SettingsList.Divider />
+          <SettingsList.PressableItem
+            destructive
+            onPress={() => signOutPromptControl.open()}
+            label={_(msg`Sign out`)}>
+            <SettingsList.ItemText>
+              <Trans>Sign out</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.PressableItem>
+        </SettingsList.Container>
+      </Layout.Content>
+
+      <Prompt.Basic
+        control={signOutPromptControl}
+        title={_(msg`Sign out?`)}
+        description={_(msg`You will be signed out of all your accounts.`)}
+        onConfirm={() => logoutEveryAccount('Settings')}
+        confirmButtonCta={_(msg`Sign out`)}
+        cancelButtonCta={_(msg`Cancel`)}
+        confirmButtonColor="negative"
+      />
+
+      <SwitchAccountDialog control={switchAccountControl} />
+    </Layout.Screen>
+  )
+}
+
+function ProfilePreview({
+  profile,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+}) {
+  const shadow = useProfileShadow(profile)
+  const moderationOpts = useModerationOpts()
+
+  if (!moderationOpts) return null
+
+  const moderation = moderateProfile(profile, moderationOpts)
+
+  return (
+    <>
+      <UserAvatar
+        size={80}
+        avatar={shadow.avatar}
+        moderation={moderation.ui('avatar')}
+      />
+      <ProfileHeaderDisplayName profile={shadow} moderation={moderation} />
+      <ProfileHeaderHandle profile={shadow} />
+    </>
+  )
+}
+
+const AVI_SIZE = 26
+const HALF_AVI_SIZE = AVI_SIZE / 2
+
+function AvatarStack({profiles}: {profiles: string[]}) {
+  const {data, error} = useProfilesQuery({handles: profiles})
+  const t = useTheme()
+  const moderationOpts = useModerationOpts()
+
+  if (error) {
+    console.error(error)
+    return null
+  }
+
+  const isPending = !data || !moderationOpts
+
+  const items = isPending
+    ? Array.from({length: profiles.length}).map((_, i) => ({
+        key: i,
+        profile: null,
+        moderation: null,
+      }))
+    : data.profiles.map(item => ({
+        key: item.did,
+        profile: item,
+        moderation: moderateProfile(item, moderationOpts),
+      }))
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.relative,
+        {width: AVI_SIZE + (items.length - 1) * HALF_AVI_SIZE},
+      ]}>
+      {items.map((item, i) => (
+        <View
+          key={item.key}
+          style={[
+            t.atoms.bg_contrast_25,
+            a.relative,
+            {
+              width: AVI_SIZE,
+              height: AVI_SIZE,
+              left: i * -HALF_AVI_SIZE,
+              borderWidth: 1,
+              borderColor: t.atoms.bg.backgroundColor,
+              borderRadius: 999,
+              zIndex: 3 - i,
+            },
+          ]}>
+          {item.profile && (
+            <UserAvatar
+              size={AVI_SIZE - 2}
+              avatar={item.profile.avatar}
+              moderation={item.moderation.ui('avatar')}
+            />
+          )}
+        </View>
+      ))}
+    </View>
+  )
+}
diff --git a/src/screens/Settings/components/Email2FAToggle.tsx b/src/screens/Settings/components/Email2FAToggle.tsx
new file mode 100644
index 000000000..d89e5f18e
--- /dev/null
+++ b/src/screens/Settings/components/Email2FAToggle.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useModalControls} from '#/state/modals'
+import {useAgent, useSession} from '#/state/session'
+import {DisableEmail2FADialog} from '#/view/screens/Settings/DisableEmail2FADialog'
+import {useDialogControl} from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+import * as SettingsList from './SettingsList'
+
+export function Email2FAToggle() {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const {openModal} = useModalControls()
+  const disableDialogControl = useDialogControl()
+  const enableDialogControl = useDialogControl()
+  const agent = useAgent()
+
+  const enableEmailAuthFactor = React.useCallback(async () => {
+    if (currentAccount?.email) {
+      await agent.com.atproto.server.updateEmail({
+        email: currentAccount.email,
+        emailAuthFactor: true,
+      })
+      await agent.resumeSession(agent.session!)
+    }
+  }, [currentAccount, agent])
+
+  const onToggle = React.useCallback(() => {
+    if (!currentAccount) {
+      return
+    }
+    if (currentAccount.emailAuthFactor) {
+      disableDialogControl.open()
+    } else {
+      if (!currentAccount.emailConfirmed) {
+        openModal({
+          name: 'verify-email',
+          onSuccess: enableDialogControl.open,
+        })
+        return
+      }
+      enableDialogControl.open()
+    }
+  }, [currentAccount, enableDialogControl, openModal, disableDialogControl])
+
+  return (
+    <>
+      <DisableEmail2FADialog control={disableDialogControl} />
+      <Prompt.Basic
+        control={enableDialogControl}
+        title={_(msg`Enable Email 2FA`)}
+        description={_(msg`Require an email code to log in to your account.`)}
+        onConfirm={enableEmailAuthFactor}
+        confirmButtonCta={_(msg`Enable`)}
+      />
+      <SettingsList.BadgeButton
+        label={
+          currentAccount?.emailAuthFactor ? _(msg`Disable`) : _(msg`Enable`)
+        }
+        onPress={onToggle}
+      />
+    </>
+  )
+}
diff --git a/src/screens/Settings/components/PwiOptOut.tsx b/src/screens/Settings/components/PwiOptOut.tsx
new file mode 100644
index 000000000..4339ade9b
--- /dev/null
+++ b/src/screens/Settings/components/PwiOptOut.tsx
@@ -0,0 +1,100 @@
+import React from 'react'
+import {View} from 'react-native'
+import {ComAtprotoLabelDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {
+  useProfileQuery,
+  useProfileUpdateMutation,
+} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+import {Text} from '#/components/Typography'
+
+export function PwiOptOut() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const {data: profile} = useProfileQuery({did: currentAccount?.did})
+  const updateProfile = useProfileUpdateMutation()
+
+  const isOptedOut =
+    profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
+  const canToggle = profile && !updateProfile.isPending
+
+  const onToggleOptOut = React.useCallback(() => {
+    if (!profile) {
+      return
+    }
+    let wasAdded = false
+    updateProfile.mutate({
+      profile,
+      updates: existing => {
+        // create labels attr if needed
+        existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
+          ? existing.labels
+          : {
+              $type: 'com.atproto.label.defs#selfLabels',
+              values: [],
+            }
+
+        // toggle the label
+        const hasLabel = existing.labels.values.some(
+          l => l.val === '!no-unauthenticated',
+        )
+        if (hasLabel) {
+          wasAdded = false
+          existing.labels.values = existing.labels.values.filter(
+            l => l.val !== '!no-unauthenticated',
+          )
+        } else {
+          wasAdded = true
+          existing.labels.values.push({val: '!no-unauthenticated'})
+        }
+
+        // delete if no longer needed
+        if (existing.labels.values.length === 0) {
+          delete existing.labels
+        }
+        return existing
+      },
+      checkCommitted: res => {
+        const exists = !!res.data.labels?.some(
+          l => l.val === '!no-unauthenticated',
+        )
+        return exists === wasAdded
+      },
+    })
+  }, [updateProfile, profile])
+
+  return (
+    <View style={[a.flex_1, a.gap_sm]}>
+      <Toggle.Item
+        name="logged_out_visibility"
+        disabled={!canToggle || updateProfile.isPending}
+        value={isOptedOut}
+        onChange={onToggleOptOut}
+        label={_(
+          msg`Discourage apps from showing my account to logged-out users`,
+        )}
+        style={[a.w_full]}>
+        <Toggle.LabelText style={[a.flex_1]}>
+          <Trans>
+            Discourage apps from showing my account to logged-out users
+          </Trans>
+        </Toggle.LabelText>
+        <Toggle.Platform />
+      </Toggle.Item>
+
+      <Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
+        <Trans>
+          Bluesky will not show your profile and posts to logged-out users.
+          Other apps may not honor this request. This does not make your account
+          private.
+        </Trans>
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx
new file mode 100644
index 000000000..86f8040af
--- /dev/null
+++ b/src/screens/Settings/components/SettingsList.tsx
@@ -0,0 +1,300 @@
+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 * as Button from '#/components/Button'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
+import {Link, LinkProps} from '#/components/Link'
+import {createPortalGroup} from '#/components/Portal'
+import {Text} from '#/components/Typography'
+
+const ItemContext = React.createContext({
+  destructive: false,
+  withinGroup: false,
+})
+
+const Portal = createPortalGroup()
+
+export function Container({children}: {children: React.ReactNode}) {
+  return <View style={[a.flex_1, a.py_lg]}>{children}</View>
+}
+
+/**
+ * This uses `Portal` magic ✨ to render the icons and title correctly. ItemIcon and ItemText components
+ * get teleported to the top row, leaving the rest of the children in the bottom row.
+ */
+export function Group({
+  children,
+  destructive = false,
+  iconInset = true,
+  style,
+  contentContainerStyle,
+}: {
+  children: React.ReactNode
+  destructive?: boolean
+  iconInset?: boolean
+  style?: StyleProp<ViewStyle>
+  contentContainerStyle?: StyleProp<ViewStyle>
+}) {
+  const context = useMemo(
+    () => ({destructive, withinGroup: true}),
+    [destructive],
+  )
+  return (
+    <View style={[a.w_full, style]}>
+      <Portal.Provider>
+        <ItemContext.Provider value={context}>
+          <Item style={[a.pb_2xs, {minHeight: 42}]}>
+            <Portal.Outlet />
+          </Item>
+          <Item
+            style={[
+              a.flex_col,
+              a.pt_2xs,
+              a.align_start,
+              a.gap_0,
+              contentContainerStyle,
+            ]}
+            iconInset={iconInset}>
+            {children}
+          </Item>
+        </ItemContext.Provider>
+      </Portal.Provider>
+    </View>
+  )
+}
+
+export function Item({
+  children,
+  destructive,
+  iconInset = false,
+  style,
+}: {
+  children?: React.ReactNode
+  destructive?: boolean
+  /**
+   * Adds left padding so that the content will be aligned with other Items that contain icons
+   * @default false
+   */
+  iconInset?: boolean
+  style?: StyleProp<ViewStyle>
+}) {
+  const context = useContext(ItemContext)
+  const childContext = useMemo(() => {
+    if (typeof destructive !== 'boolean') return context
+    return {...context, destructive}
+  }, [context, destructive])
+  return (
+    <View
+      style={[
+        a.px_xl,
+        a.py_sm,
+        a.align_center,
+        a.gap_md,
+        a.w_full,
+        a.flex_row,
+        {minHeight: 48},
+        iconInset && {
+          paddingLeft:
+            // existing padding
+            a.pl_xl.paddingLeft +
+            // icon
+            28 +
+            // gap
+            a.gap_md.gap,
+        },
+        style,
+      ]}>
+      <ItemContext.Provider value={childContext}>
+        {children}
+      </ItemContext.Provider>
+    </View>
+  )
+}
+
+export function LinkItem({
+  children,
+  destructive = false,
+  contentContainerStyle,
+  chevronColor,
+  ...props
+}: LinkProps & {
+  contentContainerStyle?: StyleProp<ViewStyle>
+  destructive?: boolean
+  chevronColor?: string
+}) {
+  const t = useTheme()
+
+  return (
+    <Link color="secondary" {...props}>
+      {args => (
+        <Item
+          destructive={destructive}
+          style={[
+            (args.hovered || args.pressed) && [t.atoms.bg_contrast_25],
+            contentContainerStyle,
+          ]}>
+          {typeof children === 'function' ? children(args) : children}
+          <Chevron color={chevronColor} />
+        </Item>
+      )}
+    </Link>
+  )
+}
+
+export function PressableItem({
+  children,
+  destructive = false,
+  contentContainerStyle,
+  hoverStyle,
+  ...props
+}: Button.ButtonProps & {
+  contentContainerStyle?: StyleProp<ViewStyle>
+  destructive?: boolean
+}) {
+  const t = useTheme()
+  return (
+    <Button.Button {...props}>
+      {args => (
+        <Item
+          destructive={destructive}
+          style={[
+            (args.hovered || args.pressed) && [
+              t.atoms.bg_contrast_25,
+              hoverStyle,
+            ],
+            contentContainerStyle,
+          ]}>
+          {typeof children === 'function' ? children(args) : children}
+        </Item>
+      )}
+    </Button.Button>
+  )
+}
+
+export function ItemIcon({
+  icon: Comp,
+  size = 'xl',
+  color: colorProp,
+}: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & {
+  color?: string
+}) {
+  const t = useTheme()
+  const {destructive, withinGroup} = useContext(ItemContext)
+
+  /*
+   * Copied here from icons/common.tsx so we can tweak if we need to, but
+   * also so that we can calculate transforms.
+   */
+  const iconSize = {
+    xs: 12,
+    sm: 16,
+    md: 20,
+    lg: 24,
+    xl: 28,
+    '2xl': 32,
+  }[size]
+
+  const color =
+    colorProp ?? (destructive ? t.palette.negative_500 : t.atoms.text.color)
+
+  const content = (
+    <View style={[a.z_20, {width: iconSize, height: iconSize}]}>
+      <Comp width={iconSize} style={[{color}]} />
+    </View>
+  )
+
+  if (withinGroup) {
+    return <Portal.Portal>{content}</Portal.Portal>
+  } else {
+    return content
+  }
+}
+
+export function ItemText({
+  // eslint-disable-next-line react/prop-types
+  style,
+  ...props
+}: React.ComponentProps<typeof Button.ButtonText>) {
+  const t = useTheme()
+  const {destructive, withinGroup} = useContext(ItemContext)
+
+  const content = (
+    <Button.ButtonText
+      style={[
+        a.text_md,
+        a.font_normal,
+        a.text_left,
+        a.flex_1,
+        destructive ? {color: t.palette.negative_500} : t.atoms.text,
+        style,
+      ]}
+      {...props}
+    />
+  )
+
+  if (withinGroup) {
+    return <Portal.Portal>{content}</Portal.Portal>
+  } else {
+    return content
+  }
+}
+
+export function Divider() {
+  const t = useTheme()
+  return (
+    <View
+      style={[a.border_t, t.atoms.border_contrast_medium, a.w_full, a.my_sm]}
+    />
+  )
+}
+
+export function Chevron({color: colorProp}: {color?: string}) {
+  const {destructive} = useContext(ItemContext)
+  const t = useTheme()
+  const color =
+    colorProp ?? (destructive ? t.palette.negative_500 : t.palette.contrast_500)
+  return <ItemIcon icon={ChevronRightIcon} size="md" color={color} />
+}
+
+export function BadgeText({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  return (
+    <Text
+      style={[
+        t.atoms.text_contrast_low,
+        a.text_md,
+        a.text_right,
+        a.leading_snug,
+      ]}
+      numberOfLines={1}>
+      {children}
+    </Text>
+  )
+}
+
+export function BadgeButton({
+  label,
+  onPress,
+}: {
+  label: string
+  onPress: (evt: GestureResponderEvent) => void
+}) {
+  const t = useTheme()
+  return (
+    <Button.Button label={label} onPress={onPress} hitSlop={HITSLOP_10}>
+      {({pressed}) => (
+        <Button.ButtonText
+          style={[
+            a.text_md,
+            a.font_normal,
+            a.text_right,
+            {color: pressed ? t.palette.contrast_300 : t.palette.primary_500},
+          ]}>
+          {label}
+        </Button.ButtonText>
+      )}
+    </Button.Button>
+  )
+}