about summary refs log tree commit diff
path: root/src/view/screens/Settings
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/screens/Settings')
-rw-r--r--src/view/screens/Settings/ExportCarDialog.tsx103
-rw-r--r--src/view/screens/Settings/index.tsx1032
2 files changed, 1135 insertions, 0 deletions
diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/view/screens/Settings/ExportCarDialog.tsx
new file mode 100644
index 000000000..720cd4f09
--- /dev/null
+++ b/src/view/screens/Settings/ExportCarDialog.tsx
@@ -0,0 +1,103 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
+
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {Text, P} from '#/components/Typography'
+import {Button, ButtonText} from '#/components/Button'
+import {InlineLink, Link} from '#/components/Link'
+import {getAgent, useSession} from '#/state/session'
+
+export function ExportCarDialog({
+  control,
+}: {
+  control: Dialog.DialogOuterProps['control']
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  const {currentAccount} = useSession()
+
+  const downloadUrl = React.useMemo(() => {
+    const agent = getAgent()
+    if (!currentAccount || !agent.session) {
+      return '' // shouldnt ever happen
+    }
+    // eg: https://bsky.social/xrpc/com.atproto.sync.getRepo?did=did:plc:ewvi7nxzyoun6zhxrhs64oiz
+    const url = new URL(agent.pdsUrl || agent.service)
+    url.pathname = '/xrpc/com.atproto.sync.getRepo'
+    url.searchParams.set('did', agent.session.did)
+    return url.toString()
+  }, [currentAccount])
+
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+
+      <Dialog.ScrollableInner
+        accessibilityDescribedBy="dialog-description"
+        accessibilityLabelledBy="dialog-title">
+        <View style={[a.relative, a.gap_md, a.w_full]}>
+          <Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
+            <Trans>Export My Data</Trans>
+          </Text>
+          <P nativeID="dialog-description" style={[a.text_sm]}>
+            <Trans>
+              Your account repository, containing all public data records, can
+              be downloaded as a "CAR" file. This file does not include media
+              embeds, such as images, or your private data, which must be
+              fetched separately.
+            </Trans>
+          </P>
+
+          <Link
+            variant="solid"
+            color="primary"
+            size="large"
+            label={_(msg`Download CAR file`)}
+            to={downloadUrl}
+            download="repo.car">
+            <ButtonText>
+              <Trans>Download CAR file</Trans>
+            </ButtonText>
+          </Link>
+
+          <P
+            style={[
+              a.py_xs,
+              t.atoms.text_contrast_medium,
+              a.text_sm,
+              a.leading_snug,
+              a.flex_1,
+            ]}>
+            <Trans>
+              This feature is in beta. You can read more about repository
+              exports in{' '}
+              <InlineLink
+                to="https://atproto.com/blog/repo-export"
+                style={[a.text_sm]}>
+                this blogpost.
+              </InlineLink>
+            </Trans>
+          </P>
+
+          <View style={gtMobile && [a.flex_row, a.justify_end]}>
+            <Button
+              testID="doneBtn"
+              variant="outline"
+              color="primary"
+              size={gtMobile ? 'small' : 'large'}
+              onPress={() => control.close()}
+              label={_(msg`Done`)}>
+              {_(msg`Done`)}
+            </Button>
+          </View>
+
+          {!gtMobile && <View style={{height: 40}} />}
+        </View>
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
new file mode 100644
index 000000000..458952527
--- /dev/null
+++ b/src/view/screens/Settings/index.tsx
@@ -0,0 +1,1032 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  Linking,
+  Platform,
+  StyleSheet,
+  Pressable,
+  TextStyle,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import * as AppInfo from 'lib/app-info'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useCustomPalette} from 'lib/hooks/useCustomPalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {NavigationProp} from 'lib/routes/types'
+import {HandIcon, HashtagIcon} from 'lib/icons'
+import Clipboard from '@react-native-clipboard/clipboard'
+import {makeProfileLink} from 'lib/routes/links'
+import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
+import {useModalControls} from '#/state/modals'
+import {
+  useSetMinimalShellMode,
+  useThemePrefs,
+  useSetThemePrefs,
+  useOnboardingDispatch,
+} from '#/state/shell'
+import {
+  useRequireAltTextEnabled,
+  useSetRequireAltTextEnabled,
+} from '#/state/preferences'
+import {useSession, useSessionApi, SessionAccount} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useClearPreferencesMutation} from '#/state/queries/preferences'
+import {useInviteCodesQuery} from '#/state/queries/invites'
+import {clear as clearStorage} from '#/state/persisted/store'
+import {clearLegacyStorage} from '#/state/persisted/legacy'
+import {STATUS_PAGE_URL} from 'lib/constants'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {useCloseAllActiveElements} from '#/state/util'
+import {
+  useInAppBrowser,
+  useSetInAppBrowser,
+} from '#/state/preferences/in-app-browser'
+import {isNative} from '#/platform/detection'
+import {useDialogControl} from '#/components/Dialog'
+
+import {s, colors} from 'lib/styles'
+import {ScrollView} from 'view/com/util/Views'
+import {Link, TextLink} from 'view/com/util/Link'
+import {Text} from 'view/com/util/text/Text'
+import * as Toast from 'view/com/util/Toast'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
+import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
+import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
+import {ExportCarDialog} from './ExportCarDialog'
+
+function SettingsAccountCard({account}: {account: SessionAccount}) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {isSwitchingAccounts, currentAccount} = useSession()
+  const {logout} = useSessionApi()
+  const {data: profile} = useProfileQuery({did: account.did})
+  const isCurrentAccount = account.did === currentAccount?.did
+  const {onPressSwitchAccount} = useAccountSwitcher()
+
+  const contents = (
+    <View style={[pal.view, styles.linkCard]}>
+      <View style={styles.avi}>
+        <UserAvatar size={40} avatar={profile?.avatar} />
+      </View>
+      <View style={[s.flex1]}>
+        <Text type="md-bold" style={pal.text}>
+          {profile?.displayName || account.handle}
+        </Text>
+        <Text type="sm" style={pal.textLight}>
+          {account.handle}
+        </Text>
+      </View>
+
+      {isCurrentAccount ? (
+        <TouchableOpacity
+          testID="signOutBtn"
+          onPress={logout}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Sign out`)}
+          accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}>
+          <Text type="lg" style={pal.link}>
+            <Trans>Sign out</Trans>
+          </Text>
+        </TouchableOpacity>
+      ) : (
+        <AccountDropdownBtn account={account} />
+      )}
+    </View>
+  )
+
+  return isCurrentAccount ? (
+    <Link
+      href={makeProfileLink({
+        did: currentAccount?.did,
+        handle: currentAccount?.handle,
+      })}
+      title={_(msg`Your profile`)}
+      noFeedback>
+      {contents}
+    </Link>
+  ) : (
+    <TouchableOpacity
+      testID={`switchToAccountBtn-${account.handle}`}
+      key={account.did}
+      onPress={
+        isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account)
+      }
+      accessibilityRole="button"
+      accessibilityLabel={_(msg`Switch to ${account.handle}`)}
+      accessibilityHint={_(msg`Switches the account you are logged in to`)}>
+      {contents}
+    </TouchableOpacity>
+  )
+}
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
+export function SettingsScreen({}: Props) {
+  const queryClient = useQueryClient()
+  const {colorMode, darkTheme} = useThemePrefs()
+  const {setColorMode, setDarkTheme} = useSetThemePrefs()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const requireAltTextEnabled = useRequireAltTextEnabled()
+  const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
+  const inAppBrowserPref = useInAppBrowser()
+  const setUseInAppBrowser = useSetInAppBrowser()
+  const onboardingDispatch = useOnboardingDispatch()
+  const navigation = useNavigation<NavigationProp>()
+  const {isMobile} = useWebMediaQueries()
+  const {screen, track} = useAnalytics()
+  const {openModal} = useModalControls()
+  const {isSwitchingAccounts, accounts, currentAccount} = useSession()
+  const {mutate: clearPreferences} = useClearPreferencesMutation()
+  const {data: invites} = useInviteCodesQuery()
+  const invitesAvailable = invites?.available?.length ?? 0
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const closeAllActiveElements = useCloseAllActiveElements()
+  const exportCarControl = useDialogControl()
+
+  const primaryBg = useCustomPalette<ViewStyle>({
+    light: {backgroundColor: colors.blue0},
+    dark: {backgroundColor: colors.blue6},
+  })
+  const primaryText = useCustomPalette<TextStyle>({
+    light: {color: colors.blue3},
+    dark: {color: colors.blue2},
+  })
+
+  const dangerBg = useCustomPalette<ViewStyle>({
+    light: {backgroundColor: colors.red1},
+    dark: {backgroundColor: colors.red7},
+  })
+  const dangerText = useCustomPalette<TextStyle>({
+    light: {color: colors.red4},
+    dark: {color: colors.red2},
+  })
+
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('Settings')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
+
+  const onPressAddAccount = React.useCallback(() => {
+    track('Settings:AddAccountButtonClicked')
+    setShowLoggedOut(true)
+    closeAllActiveElements()
+  }, [track, setShowLoggedOut, closeAllActiveElements])
+
+  const onPressChangeHandle = React.useCallback(() => {
+    track('Settings:ChangeHandleButtonClicked')
+    openModal({
+      name: 'change-handle',
+      onChanged() {
+        if (currentAccount) {
+          // refresh my profile
+          queryClient.invalidateQueries({
+            queryKey: RQKEY_PROFILE(currentAccount.did),
+          })
+        }
+      },
+    })
+  }, [track, queryClient, openModal, currentAccount])
+
+  const onPressExportRepository = React.useCallback(() => {
+    exportCarControl.open()
+  }, [exportCarControl])
+
+  const onPressInviteCodes = React.useCallback(() => {
+    track('Settings:InvitecodesButtonClicked')
+    openModal({name: 'invite-codes'})
+  }, [track, openModal])
+
+  const onPressLanguageSettings = React.useCallback(() => {
+    navigation.navigate('LanguageSettings')
+  }, [navigation])
+
+  const onPressDeleteAccount = React.useCallback(() => {
+    openModal({name: 'delete-account'})
+  }, [openModal])
+
+  const onPressResetPreferences = React.useCallback(async () => {
+    clearPreferences()
+  }, [clearPreferences])
+
+  const onPressResetOnboarding = React.useCallback(async () => {
+    onboardingDispatch({type: 'start'})
+    Toast.show(_(msg`Onboarding reset`))
+  }, [onboardingDispatch, _])
+
+  const onPressBuildInfo = React.useCallback(() => {
+    Clipboard.setString(
+      `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`,
+    )
+    Toast.show(_(msg`Copied build version to clipboard`))
+  }, [_])
+
+  const openHomeFeedPreferences = React.useCallback(() => {
+    navigation.navigate('PreferencesHomeFeed')
+  }, [navigation])
+
+  const openThreadsPreferences = React.useCallback(() => {
+    navigation.navigate('PreferencesThreads')
+  }, [navigation])
+
+  const onPressAppPasswords = React.useCallback(() => {
+    navigation.navigate('AppPasswords')
+  }, [navigation])
+
+  const onPressSystemLog = React.useCallback(() => {
+    navigation.navigate('Log')
+  }, [navigation])
+
+  const onPressStorybook = React.useCallback(() => {
+    navigation.navigate('Debug')
+  }, [navigation])
+
+  const onPressSavedFeeds = React.useCallback(() => {
+    navigation.navigate('SavedFeeds')
+  }, [navigation])
+
+  const onPressStatusPage = React.useCallback(() => {
+    Linking.openURL(STATUS_PAGE_URL)
+  }, [])
+
+  const clearAllStorage = React.useCallback(async () => {
+    await clearStorage()
+    Toast.show(_(msg`Storage cleared, you need to restart the app now.`))
+  }, [_])
+  const clearAllLegacyStorage = React.useCallback(async () => {
+    await clearLegacyStorage()
+    Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`))
+  }, [_])
+
+  return (
+    <View style={s.hContentRegion} testID="settingsScreen">
+      <ExportCarDialog control={exportCarControl} />
+
+      <SimpleViewHeader
+        showBackButton={isMobile}
+        style={[
+          pal.border,
+          {borderBottomWidth: 1},
+          !isMobile && {borderLeftWidth: 1, borderRightWidth: 1},
+        ]}>
+        <View style={{flex: 1}}>
+          <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
+            <Trans>Settings</Trans>
+          </Text>
+        </View>
+      </SimpleViewHeader>
+      <ScrollView
+        style={[s.hContentRegion]}
+        contentContainerStyle={isMobile && pal.viewLight}
+        scrollIndicatorInsets={{right: 1}}
+        // @ts-ignore web only -prf
+        dataSet={{'stable-gutters': 1}}>
+        <View style={styles.spacer20} />
+        {currentAccount ? (
+          <>
+            <Text type="xl-bold" style={[pal.text, styles.heading]}>
+              <Trans>Account</Trans>
+            </Text>
+            <View style={[styles.infoLine]}>
+              <Text type="lg-medium" style={pal.text}>
+                <Trans>Email:</Trans>{' '}
+              </Text>
+              {currentAccount.emailConfirmed && (
+                <>
+                  <FontAwesomeIcon
+                    icon="check"
+                    size={10}
+                    style={{color: colors.green3, marginRight: 2}}
+                  />
+                </>
+              )}
+              <Text
+                type="lg"
+                numberOfLines={1}
+                style={[
+                  pal.text,
+                  {overflow: 'hidden', marginRight: 4, flex: 1},
+                ]}>
+                {currentAccount.email || '(no email)'}
+              </Text>
+              <Link onPress={() => openModal({name: 'change-email'})}>
+                <Text type="lg" style={pal.link}>
+                  <Trans context="action">Change</Trans>
+                </Text>
+              </Link>
+            </View>
+            <View style={[styles.infoLine]}>
+              <Text type="lg-medium" style={pal.text}>
+                <Trans>Birthday:</Trans>{' '}
+              </Text>
+              <Link onPress={() => openModal({name: 'birth-date-settings'})}>
+                <Text type="lg" style={pal.link}>
+                  <Trans>Show</Trans>
+                </Text>
+              </Link>
+            </View>
+            <View style={styles.spacer20} />
+
+            {!currentAccount.emailConfirmed && <EmailConfirmationNotice />}
+          </>
+        ) : null}
+        <View style={[s.flexRow, styles.heading]}>
+          <Text type="xl-bold" style={pal.text}>
+            <Trans>Signed in as</Trans>
+          </Text>
+          <View style={s.flex1} />
+        </View>
+
+        {isSwitchingAccounts ? (
+          <View style={[pal.view, styles.linkCard]}>
+            <ActivityIndicator />
+          </View>
+        ) : (
+          <SettingsAccountCard account={currentAccount!} />
+        )}
+
+        {accounts
+          .filter(a => a.did !== currentAccount?.did)
+          .map(account => (
+            <SettingsAccountCard key={account.did} account={account} />
+          ))}
+
+        <TouchableOpacity
+          testID="switchToNewAccountBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Add account`)}
+          accessibilityHint={_(msg`Create a new Bluesky account`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="plus"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Add account</Trans>
+          </Text>
+        </TouchableOpacity>
+
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Invite a Friend</Trans>
+        </Text>
+
+        <TouchableOpacity
+          testID="inviteFriendBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={isSwitchingAccounts ? undefined : onPressInviteCodes}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Invite`)}
+          accessibilityHint={_(msg`Opens invite code list`)}
+          disabled={invites?.disabled}>
+          <View
+            style={[
+              styles.iconContainer,
+              invitesAvailable > 0 ? primaryBg : pal.btn,
+            ]}>
+            <FontAwesomeIcon
+              icon="ticket"
+              style={
+                (invitesAvailable > 0
+                  ? primaryText
+                  : pal.text) as FontAwesomeIconStyle
+              }
+            />
+          </View>
+          <Text type="lg" style={invitesAvailable > 0 ? pal.link : pal.text}>
+            {invites?.disabled ? (
+              <Trans>
+                Your invite codes are hidden when logged in using an App
+                Password
+              </Trans>
+            ) : invitesAvailable === 1 ? (
+              <Trans>{invitesAvailable} invite code available</Trans>
+            ) : (
+              <Trans>{invitesAvailable} invite codes available</Trans>
+            )}
+          </Text>
+        </TouchableOpacity>
+
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Accessibility</Trans>
+        </Text>
+        <View style={[pal.view, styles.toggleCard]}>
+          <ToggleButton
+            type="default-light"
+            label={_(msg`Require alt text before posting`)}
+            labelType="lg"
+            isSelected={requireAltTextEnabled}
+            onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)}
+          />
+        </View>
+
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Appearance</Trans>
+        </Text>
+        <View>
+          <View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
+            <SelectableBtn
+              selected={colorMode === 'system'}
+              label={_(msg`System`)}
+              left
+              onSelect={() => setColorMode('system')}
+              accessibilityHint={_(msg`Set color theme to system setting`)}
+            />
+            <SelectableBtn
+              selected={colorMode === 'light'}
+              label={_(msg`Light`)}
+              onSelect={() => setColorMode('light')}
+              accessibilityHint={_(msg`Set color theme to light`)}
+            />
+            <SelectableBtn
+              selected={colorMode === 'dark'}
+              label={_(msg`Dark`)}
+              right
+              onSelect={() => setColorMode('dark')}
+              accessibilityHint={_(msg`Set color theme to dark`)}
+            />
+          </View>
+        </View>
+
+        <View style={styles.spacer20} />
+
+        {colorMode !== 'light' && (
+          <>
+            <Text type="xl-bold" style={[pal.text, styles.heading]}>
+              <Trans>Dark Theme</Trans>
+            </Text>
+            <View>
+              <View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
+                <SelectableBtn
+                  selected={!darkTheme || darkTheme === 'dim'}
+                  label={_(msg`Dim`)}
+                  left
+                  onSelect={() => setDarkTheme('dim')}
+                  accessibilityHint={_(msg`Set dark theme to the dim theme`)}
+                />
+                <SelectableBtn
+                  selected={darkTheme === 'dark'}
+                  label={_(msg`Dark`)}
+                  right
+                  onSelect={() => setDarkTheme('dark')}
+                  accessibilityHint={_(msg`Set dark theme to the dark theme`)}
+                />
+              </View>
+            </View>
+            <View style={styles.spacer20} />
+          </>
+        )}
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Basics</Trans>
+        </Text>
+        <TouchableOpacity
+          testID="preferencesHomeFeedButton"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={openHomeFeedPreferences}
+          accessibilityRole="button"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Opens the home feed preferences`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="sliders"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Home Feed Preferences</Trans>
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="preferencesThreadsButton"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={openThreadsPreferences}
+          accessibilityRole="button"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Opens the threads preferences`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon={['far', 'comments']}
+              style={pal.text as FontAwesomeIconStyle}
+              size={18}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Thread Preferences</Trans>
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="savedFeedsBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          accessibilityHint="My Saved Feeds"
+          accessibilityLabel={_(msg`Opens screen with all saved feeds`)}
+          onPress={onPressSavedFeeds}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <HashtagIcon style={pal.text} size={18} strokeWidth={3} />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>My Saved Feeds</Trans>
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="languageSettingsBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={isSwitchingAccounts ? undefined : onPressLanguageSettings}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Language settings`)}
+          accessibilityHint={_(msg`Opens configurable language settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="language"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Languages</Trans>
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="moderationBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={
+            isSwitchingAccounts
+              ? undefined
+              : () => navigation.navigate('Moderation')
+          }
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Moderation settings`)}
+          accessibilityHint={_(msg`Opens moderation settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <HandIcon style={pal.text} size={18} strokeWidth={6} />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Moderation</Trans>
+          </Text>
+        </TouchableOpacity>
+
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Privacy</Trans>
+        </Text>
+
+        <TouchableOpacity
+          testID="externalEmbedsBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={
+            isSwitchingAccounts
+              ? undefined
+              : () => navigation.navigate('PreferencesExternalEmbeds')
+          }
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`External media settings`)}
+          accessibilityHint={_(msg`Opens external embeds settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon={['far', 'circle-play']}
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>External Media Preferences</Trans>
+          </Text>
+        </TouchableOpacity>
+
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Advanced</Trans>
+        </Text>
+        <TouchableOpacity
+          testID="appPasswordBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={onPressAppPasswords}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`App password settings`)}
+          accessibilityHint={_(msg`Opens the app password settings page`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="lock"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>App Passwords</Trans>
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="changeHandleBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={isSwitchingAccounts ? undefined : onPressChangeHandle}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Change handle`)}
+          accessibilityHint={_(msg`Choose a new Bluesky username or create`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="at"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text} numberOfLines={1}>
+            <Trans>Change Handle</Trans>
+          </Text>
+        </TouchableOpacity>
+        {isNative && (
+          <View style={[pal.view, styles.toggleCard]}>
+            <ToggleButton
+              type="default-light"
+              label={_(msg`Open links with in-app browser`)}
+              labelType="lg"
+              isSelected={inAppBrowserPref ?? false}
+              onPress={() => setUseInAppBrowser(!inAppBrowserPref)}
+            />
+          </View>
+        )}
+        <View style={styles.spacer20} />
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Account</Trans>
+        </Text>
+        <TouchableOpacity
+          testID="changePasswordBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={() => openModal({name: 'change-password'})}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Change password`)}
+          accessibilityHint={_(msg`Change your Bluesky password`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="lock"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text} numberOfLines={1}>
+            <Trans>Change Password</Trans>
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="exportRepositoryBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={isSwitchingAccounts ? undefined : onPressExportRepository}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Export my data`)}
+          accessibilityHint={_(
+            msg`Download Bluesky account data (repository)`,
+          )}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="download"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text} numberOfLines={1}>
+            <Trans>Export My Data</Trans>
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity
+          style={[pal.view, styles.linkCard]}
+          onPress={onPressDeleteAccount}
+          accessible={true}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Delete account`)}
+          accessibilityHint={_(
+            msg`Opens modal for account deletion confirmation. Requires email code.`,
+          )}>
+          <View style={[styles.iconContainer, dangerBg]}>
+            <FontAwesomeIcon
+              icon={['far', 'trash-can']}
+              style={dangerText as FontAwesomeIconStyle}
+              size={18}
+            />
+          </View>
+          <Text type="lg" style={dangerText}>
+            <Trans>Delete My Account…</Trans>
+          </Text>
+        </TouchableOpacity>
+        <View style={styles.spacer20} />
+        <TouchableOpacity
+          style={[pal.view, styles.linkCardNoIcon]}
+          onPress={onPressSystemLog}
+          accessibilityRole="button"
+          accessibilityHint="Open system log"
+          accessibilityLabel={_(msg`Opens the system log page`)}>
+          <Text type="lg" style={pal.text}>
+            <Trans>System log</Trans>
+          </Text>
+        </TouchableOpacity>
+        {__DEV__ ? (
+          <>
+            <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={onPressStorybook}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Open storybook page`)}
+              accessibilityHint={_(msg`Opens the storybook page`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>Storybook</Trans>
+              </Text>
+            </TouchableOpacity>
+            <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={onPressResetPreferences}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Reset preferences`)}
+              accessibilityHint={_(msg`Resets the preferences state`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>Reset preferences state</Trans>
+              </Text>
+            </TouchableOpacity>
+            <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={onPressResetOnboarding}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Reset onboarding`)}
+              accessibilityHint={_(msg`Resets the onboarding state`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>Reset onboarding state</Trans>
+              </Text>
+            </TouchableOpacity>
+            <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={clearAllLegacyStorage}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Clear all legacy storage data`)}
+              accessibilityHint={_(msg`Clear all legacy storage data`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>
+                  Clear all legacy storage data (restart after this)
+                </Trans>
+              </Text>
+            </TouchableOpacity>
+            <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={clearAllStorage}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Clear all storage data`)}
+              accessibilityHint={_(msg`Clear all storage data`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>Clear all storage data (restart after this)</Trans>
+              </Text>
+            </TouchableOpacity>
+          </>
+        ) : null}
+        <View style={[styles.footer]}>
+          <TouchableOpacity
+            accessibilityRole="button"
+            onPress={onPressBuildInfo}>
+            <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
+              <Trans>
+                Build version {AppInfo.appVersion} {AppInfo.updateChannel}
+              </Trans>
+            </Text>
+          </TouchableOpacity>
+          <Text type="sm" style={[pal.textLight]}>
+            &nbsp; &middot; &nbsp;
+          </Text>
+          <TouchableOpacity
+            accessibilityRole="button"
+            onPress={onPressStatusPage}>
+            <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
+              <Trans>Status page</Trans>
+            </Text>
+          </TouchableOpacity>
+        </View>
+
+        <View
+          style={[
+            {flexWrap: 'wrap', gap: 12, paddingHorizontal: 18},
+            s.flexRow,
+          ]}>
+          <TextLink
+            type="md"
+            style={pal.link}
+            href="https://bsky.social/about/support/tos"
+            text={_(msg`Terms of Service`)}
+          />
+          <TextLink
+            type="md"
+            style={pal.link}
+            href="https://bsky.social/about/support/privacy-policy"
+            text={_(msg`Privacy Policy`)}
+          />
+        </View>
+        <View style={s.footerSpacer} />
+      </ScrollView>
+    </View>
+  )
+}
+
+function EmailConfirmationNotice() {
+  const pal = usePalette('default')
+  const palInverted = usePalette('inverted')
+  const {_} = useLingui()
+  const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
+
+  return (
+    <View style={{marginBottom: 20}}>
+      <Text type="xl-bold" style={[pal.text, styles.heading]}>
+        <Trans>Verify email</Trans>
+      </Text>
+      <View
+        style={[
+          {
+            paddingVertical: isMobile ? 12 : 0,
+            paddingHorizontal: 18,
+          },
+          pal.view,
+        ]}>
+        <View style={{flexDirection: 'row', marginBottom: 8}}>
+          <Pressable
+            style={[
+              palInverted.view,
+              {
+                flexDirection: 'row',
+                gap: 6,
+                borderRadius: 6,
+                paddingHorizontal: 12,
+                paddingVertical: 10,
+                alignItems: 'center',
+              },
+              isMobile && {flex: 1},
+            ]}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Verify my email`)}
+            accessibilityHint=""
+            onPress={() => openModal({name: 'verify-email'})}>
+            <FontAwesomeIcon
+              icon="envelope"
+              color={palInverted.colors.text}
+              size={16}
+            />
+            <Text type="button" style={palInverted.text}>
+              <Trans>Verify My Email</Trans>
+            </Text>
+          </Pressable>
+        </View>
+        <Text style={pal.textLight}>
+          <Trans>Protect your account by verifying your email.</Trans>
+        </Text>
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  dimmed: {
+    opacity: 0.5,
+  },
+  spacer20: {
+    height: 20,
+  },
+  heading: {
+    paddingHorizontal: 18,
+    paddingBottom: 6,
+  },
+  infoLine: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 18,
+    paddingBottom: 6,
+  },
+  profile: {
+    flexDirection: 'row',
+    marginVertical: 6,
+    borderRadius: 4,
+    paddingVertical: 10,
+    paddingHorizontal: 10,
+  },
+  linkCard: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 12,
+    paddingHorizontal: 18,
+    marginBottom: 1,
+  },
+  linkCardNoIcon: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 20,
+    paddingHorizontal: 18,
+    marginBottom: 1,
+  },
+  toggleCard: {
+    paddingVertical: 8,
+    paddingHorizontal: 6,
+    marginBottom: 1,
+  },
+  avi: {
+    marginRight: 12,
+  },
+  iconContainer: {
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: 40,
+    height: 40,
+    borderRadius: 30,
+    marginRight: 12,
+  },
+  buildInfo: {
+    paddingVertical: 8,
+  },
+
+  colorModeText: {
+    marginLeft: 10,
+    marginBottom: 6,
+  },
+
+  selectableBtns: {
+    flexDirection: 'row',
+  },
+
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.gray1,
+  },
+  toggleBtn: {
+    paddingHorizontal: 0,
+  },
+  footer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingLeft: 18,
+  },
+})