about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/auth/LoggedOut.tsx4
-rw-r--r--src/view/com/auth/SplashScreen.web.tsx2
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx133
-rw-r--r--src/view/com/auth/create/Step1.tsx344
-rw-r--r--src/view/com/auth/create/Step2.tsx350
-rw-r--r--src/view/com/auth/create/Step3.tsx4
-rw-r--r--src/view/com/auth/create/StepHeader.tsx34
-rw-r--r--src/view/com/auth/create/state.ts124
-rw-r--r--src/view/com/auth/login/ChooseAccountForm.tsx8
-rw-r--r--src/view/com/auth/login/ForgotPasswordForm.tsx16
-rw-r--r--src/view/com/auth/login/LoginForm.tsx23
-rw-r--r--src/view/com/auth/login/PasswordUpdatedForm.tsx2
-rw-r--r--src/view/com/auth/login/SetNewPasswordForm.tsx12
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeedsItem.tsx13
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollows.tsx2
-rw-r--r--src/view/com/auth/onboarding/WelcomeDesktop.tsx24
-rw-r--r--src/view/com/composer/Composer.tsx92
-rw-r--r--src/view/com/composer/ComposerReplyTo.tsx254
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx2
-rw-r--r--src/view/com/composer/Prompt.tsx2
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx2
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx2
-rw-r--r--src/view/com/composer/select-language/SuggestedLanguage.tsx101
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx3
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx128
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx3
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx143
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts8
-rw-r--r--src/view/com/feeds/FeedPage.tsx14
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx35
-rw-r--r--src/view/com/feeds/ProfileFeedgens.tsx10
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx2
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx1
-rw-r--r--src/view/com/lightbox/Lightbox.tsx35
-rw-r--r--src/view/com/lightbox/Lightbox.web.tsx26
-rw-r--r--src/view/com/lists/ListCard.tsx40
-rw-r--r--src/view/com/lists/ListMembers.tsx12
-rw-r--r--src/view/com/lists/ProfileLists.tsx10
-rw-r--r--src/view/com/modals/AddAppPasswords.tsx28
-rw-r--r--src/view/com/modals/AltImage.tsx147
-rw-r--r--src/view/com/modals/AppealLabel.tsx4
-rw-r--r--src/view/com/modals/BirthDateSettings.tsx5
-rw-r--r--src/view/com/modals/ChangeEmail.tsx6
-rw-r--r--src/view/com/modals/ChangeHandle.tsx16
-rw-r--r--src/view/com/modals/Confirm.tsx10
-rw-r--r--src/view/com/modals/ContentFilteringSettings.tsx57
-rw-r--r--src/view/com/modals/CreateOrEditList.tsx160
-rw-r--r--src/view/com/modals/DeleteAccount.tsx25
-rw-r--r--src/view/com/modals/EditImage.tsx10
-rw-r--r--src/view/com/modals/EditProfile.tsx13
-rw-r--r--src/view/com/modals/EmbedConsent.tsx153
-rw-r--r--src/view/com/modals/InAppBrowserConsent.tsx102
-rw-r--r--src/view/com/modals/InviteCodes.tsx19
-rw-r--r--src/view/com/modals/LinkWarning.tsx6
-rw-r--r--src/view/com/modals/ListAddRemoveUsers.tsx6
-rw-r--r--src/view/com/modals/Modal.tsx8
-rw-r--r--src/view/com/modals/Modal.web.tsx14
-rw-r--r--src/view/com/modals/ModerationDetails.tsx45
-rw-r--r--src/view/com/modals/ProfilePreview.tsx11
-rw-r--r--src/view/com/modals/Repost.tsx24
-rw-r--r--src/view/com/modals/SelfLabel.tsx8
-rw-r--r--src/view/com/modals/ServerInput.tsx6
-rw-r--r--src/view/com/modals/SwitchAccount.tsx8
-rw-r--r--src/view/com/modals/Threadgate.tsx4
-rw-r--r--src/view/com/modals/UserAddRemoveLists.tsx26
-rw-r--r--src/view/com/modals/VerifyEmail.tsx34
-rw-r--r--src/view/com/modals/Waitlist.tsx16
-rw-r--r--src/view/com/modals/report/InputIssueDetails.tsx3
-rw-r--r--src/view/com/modals/report/Modal.tsx6
-rw-r--r--src/view/com/notifications/Feed.tsx11
-rw-r--r--src/view/com/notifications/FeedItem.tsx23
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx11
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx30
-rw-r--r--src/view/com/pager/Pager.tsx1
-rw-r--r--src/view/com/pager/Pager.web.tsx51
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx15
-rw-r--r--src/view/com/pager/PagerWithHeader.web.tsx194
-rw-r--r--src/view/com/post-thread/PostThread.tsx95
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx23
-rw-r--r--src/view/com/post/Post.tsx29
-rw-r--r--src/view/com/posts/CustomFeedEmptyState.tsx9
-rw-r--r--src/view/com/posts/DiscoverFallbackHeader.tsx43
-rw-r--r--src/view/com/posts/Feed.tsx31
-rw-r--r--src/view/com/posts/FeedErrorMessage.tsx15
-rw-r--r--src/view/com/posts/FeedItem.tsx76
-rw-r--r--src/view/com/posts/FeedSlice.tsx3
-rw-r--r--src/view/com/posts/FollowingEmptyState.tsx13
-rw-r--r--src/view/com/posts/FollowingEndOfFeed.tsx13
-rw-r--r--src/view/com/profile/FollowButton.tsx11
-rw-r--r--src/view/com/profile/ProfileCard.tsx9
-rw-r--r--src/view/com/profile/ProfileHeader.tsx90
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx3
-rw-r--r--src/view/com/profile/ProfileSubpageHeader.tsx20
-rw-r--r--src/view/com/util/AccountDropdownBtn.tsx2
-rw-r--r--src/view/com/util/BlurView.android.tsx30
-rw-r--r--src/view/com/util/ErrorBoundary.tsx3
-rw-r--r--src/view/com/util/Link.tsx21
-rw-r--r--src/view/com/util/List.tsx6
-rw-r--r--src/view/com/util/List.web.tsx341
-rw-r--r--src/view/com/util/MainScrollProvider.tsx124
-rw-r--r--src/view/com/util/Selector.tsx7
-rw-r--r--src/view/com/util/SimpleViewHeader.tsx16
-rw-r--r--src/view/com/util/Toast.web.tsx3
-rw-r--r--src/view/com/util/ViewHeader.tsx7
-rw-r--r--src/view/com/util/error/ErrorMessage.tsx4
-rw-r--r--src/view/com/util/error/ErrorScreen.tsx6
-rw-r--r--src/view/com/util/fab/FABInner.tsx4
-rw-r--r--src/view/com/util/forms/DateInput.tsx13
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx8
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx241
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx34
-rw-r--r--src/view/com/util/forms/SearchInput.tsx6
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx5
-rw-r--r--src/view/com/util/images/Gallery.tsx5
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx8
-rw-r--r--src/view/com/util/moderation/PostHider.tsx8
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx18
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx7
-rw-r--r--src/view/com/util/post-embeds/ExternalGifEmbed.tsx170
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx91
-rw-r--r--src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx148
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx58
-rw-r--r--src/view/com/util/post-embeds/index.tsx35
-rw-r--r--src/view/com/util/text/RichText.tsx50
-rw-r--r--src/view/com/util/text/Text.tsx16
-rw-r--r--src/view/icons/Logo.tsx9
-rw-r--r--src/view/icons/index.tsx12
-rw-r--r--src/view/screens/AppPasswords.tsx13
-rw-r--r--src/view/screens/Debug.tsx7
-rw-r--r--src/view/screens/Feeds.tsx12
-rw-r--r--src/view/screens/Home.tsx4
-rw-r--r--src/view/screens/Lists.tsx4
-rw-r--r--src/view/screens/Log.tsx4
-rw-r--r--src/view/screens/Moderation.tsx8
-rw-r--r--src/view/screens/ModerationModlists.tsx2
-rw-r--r--src/view/screens/PostThread.tsx9
-rw-r--r--src/view/screens/PreferencesExternalEmbeds.tsx138
-rw-r--r--src/view/screens/PreferencesHomeFeed.tsx19
-rw-r--r--src/view/screens/PreferencesThreads.tsx20
-rw-r--r--src/view/screens/Profile.tsx7
-rw-r--r--src/view/screens/ProfileFeed.tsx62
-rw-r--r--src/view/screens/ProfileList.tsx96
-rw-r--r--src/view/screens/SavedFeeds.tsx27
-rw-r--r--src/view/screens/Search/Search.tsx315
-rw-r--r--src/view/screens/Search/index.tsx4
-rw-r--r--src/view/screens/Search/index.web.tsx3
-rw-r--r--src/view/screens/Settings.tsx168
-rw-r--r--src/view/screens/Storybook/Breakpoints.tsx25
-rw-r--r--src/view/screens/Storybook/Buttons.tsx124
-rw-r--r--src/view/screens/Storybook/Dialogs.tsx90
-rw-r--r--src/view/screens/Storybook/Forms.tsx215
-rw-r--r--src/view/screens/Storybook/Icons.tsx41
-rw-r--r--src/view/screens/Storybook/Links.tsx48
-rw-r--r--src/view/screens/Storybook/Palette.tsx336
-rw-r--r--src/view/screens/Storybook/Shadows.tsx53
-rw-r--r--src/view/screens/Storybook/Spacing.tsx64
-rw-r--r--src/view/screens/Storybook/Theming.tsx56
-rw-r--r--src/view/screens/Storybook/Typography.tsx30
-rw-r--r--src/view/screens/Storybook/index.tsx78
-rw-r--r--src/view/screens/Support.tsx4
-rw-r--r--src/view/shell/Composer.web.tsx34
-rw-r--r--src/view/shell/Drawer.tsx11
-rw-r--r--src/view/shell/bottom-bar/BottomBarStyles.tsx4
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx1
-rw-r--r--src/view/shell/createNativeStackNavigatorWithAuth.tsx3
-rw-r--r--src/view/shell/desktop/Feeds.tsx4
-rw-r--r--src/view/shell/desktop/LeftNav.tsx46
-rw-r--r--src/view/shell/desktop/RightNav.tsx37
-rw-r--r--src/view/shell/desktop/Search.tsx104
-rw-r--r--src/view/shell/index.tsx2
-rw-r--r--src/view/shell/index.web.tsx24
171 files changed, 6107 insertions, 1584 deletions
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx
index c0427ff54..603abbab2 100644
--- a/src/view/com/auth/LoggedOut.tsx
+++ b/src/view/com/auth/LoggedOut.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {View, Pressable} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
+import {Trans, msg} from '@lingui/macro'
 import {useNavigation} from '@react-navigation/native'
 
 import {isIOS, isNative} from 'platform/detection'
@@ -119,7 +119,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
             }}
             onPress={onPressSearch}>
             <Text type="lg-bold" style={[pal.text]}>
-              Search{' '}
+              <Trans>Search</Trans>{' '}
             </Text>
             <FontAwesomeIcon
               icon="search"
diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx
index 1cc7b9146..d2b1a47e3 100644
--- a/src/view/com/auth/SplashScreen.web.tsx
+++ b/src/view/com/auth/SplashScreen.web.tsx
@@ -74,7 +74,7 @@ export const SplashScreen = ({
                 // TODO: web accessibility
                 accessibilityRole="button">
                 <Text style={[s.white, styles.btnLabel]}>
-                  Create a new account
+                  <Trans>Create a new account</Trans>
                 </Text>
               </TouchableOpacity>
               <TouchableOpacity
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
index a89e6fb34..449afb0d3 100644
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ b/src/view/com/auth/create/CreateAccount.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {
   ActivityIndicator,
-  KeyboardAvoidingView,
   ScrollView,
   StyleSheet,
   TouchableOpacity,
@@ -23,11 +22,13 @@ import {
   useSetSaveFeedsMutation,
   DEFAULT_PROD_FEEDS,
 } from '#/state/queries/preferences'
-import {IS_PROD} from '#/lib/constants'
+import {FEEDBACK_FORM_URL, IS_PROD} from '#/lib/constants'
 
 import {Step1} from './Step1'
 import {Step2} from './Step2'
 import {Step3} from './Step3'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {TextLink} from '../../util/Link'
 
 export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
   const {screen} = useAnalytics()
@@ -38,6 +39,7 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
   const {createAccount} = useSessionApi()
   const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
   const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
+  const {isTabletOrDesktop} = useWebMediaQueries()
 
   React.useEffect(() => {
     screen('CreateAccount')
@@ -116,68 +118,87 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
 
   return (
     <LoggedOutLayout
-      leadin={`Step ${uiState.step}`}
+      leadin=""
       title={_(msg`Create Account`)}
       description={_(msg`We're so excited to have you join us!`)}>
       <ScrollView testID="createAccount" style={pal.view}>
-        <KeyboardAvoidingView behavior="padding">
-          <View style={styles.stepContainer}>
-            {uiState.step === 1 && (
-              <Step1 uiState={uiState} uiDispatch={uiDispatch} />
-            )}
-            {uiState.step === 2 && (
-              <Step2 uiState={uiState} uiDispatch={uiDispatch} />
-            )}
-            {uiState.step === 3 && (
-              <Step3 uiState={uiState} uiDispatch={uiDispatch} />
-            )}
-          </View>
-          <View style={[s.flexRow, s.pl20, s.pr20]}>
+        <View style={styles.stepContainer}>
+          {uiState.step === 1 && (
+            <Step1 uiState={uiState} uiDispatch={uiDispatch} />
+          )}
+          {uiState.step === 2 && (
+            <Step2 uiState={uiState} uiDispatch={uiDispatch} />
+          )}
+          {uiState.step === 3 && (
+            <Step3 uiState={uiState} uiDispatch={uiDispatch} />
+          )}
+        </View>
+        <View style={[s.flexRow, s.pl20, s.pr20]}>
+          <TouchableOpacity
+            onPress={onPressBackInner}
+            testID="backBtn"
+            accessibilityRole="button">
+            <Text type="xl" style={pal.link}>
+              <Trans>Back</Trans>
+            </Text>
+          </TouchableOpacity>
+          <View style={s.flex1} />
+          {uiState.canNext ? (
             <TouchableOpacity
-              onPress={onPressBackInner}
-              testID="backBtn"
+              testID="nextBtn"
+              onPress={onPressNext}
               accessibilityRole="button">
-              <Text type="xl" style={pal.link}>
-                <Trans>Back</Trans>
-              </Text>
-            </TouchableOpacity>
-            <View style={s.flex1} />
-            {uiState.canNext ? (
-              <TouchableOpacity
-                testID="nextBtn"
-                onPress={onPressNext}
-                accessibilityRole="button">
-                {uiState.isProcessing ? (
-                  <ActivityIndicator />
-                ) : (
-                  <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                    <Trans>Next</Trans>
-                  </Text>
-                )}
-              </TouchableOpacity>
-            ) : serviceInfoError ? (
-              <TouchableOpacity
-                testID="retryConnectBtn"
-                onPress={() => refetchServiceInfo()}
-                accessibilityRole="button"
-                accessibilityLabel={_(msg`Retry`)}
-                accessibilityHint=""
-                accessibilityLiveRegion="polite">
+              {uiState.isProcessing ? (
+                <ActivityIndicator />
+              ) : (
                 <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                  <Trans>Retry</Trans>
+                  <Trans>Next</Trans>
                 </Text>
-              </TouchableOpacity>
-            ) : serviceInfoIsFetching ? (
-              <>
-                <ActivityIndicator color="#fff" />
-                <Text type="xl" style={[pal.text, s.pr5]}>
-                  <Trans>Connecting...</Trans>
-                </Text>
-              </>
-            ) : undefined}
+              )}
+            </TouchableOpacity>
+          ) : serviceInfoError ? (
+            <TouchableOpacity
+              testID="retryConnectBtn"
+              onPress={() => refetchServiceInfo()}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Retry`)}
+              accessibilityHint=""
+              accessibilityLiveRegion="polite">
+              <Text type="xl-bold" style={[pal.link, s.pr5]}>
+                <Trans>Retry</Trans>
+              </Text>
+            </TouchableOpacity>
+          ) : serviceInfoIsFetching ? (
+            <>
+              <ActivityIndicator color="#fff" />
+              <Text type="xl" style={[pal.text, s.pr5]}>
+                <Trans>Connecting...</Trans>
+              </Text>
+            </>
+          ) : undefined}
+        </View>
+
+        <View style={styles.stepContainer}>
+          <View
+            style={[
+              s.flexRow,
+              s.alignCenter,
+              pal.viewLight,
+              {borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12},
+            ]}>
+            <Text type="md" style={pal.textLight}>
+              <Trans>Having trouble?</Trans>{' '}
+            </Text>
+            <TextLink
+              type="md"
+              style={pal.link}
+              text={_(msg`Contact support`)}
+              href={FEEDBACK_FORM_URL({email: uiState.email})}
+            />
           </View>
-          <View style={s.footerSpacer} />
-        </KeyboardAvoidingView>
+        </View>
+
+        <View style={{height: isTabletOrDesktop ? 50 : 400}} />
       </ScrollView>
     </LoggedOutLayout>
   )
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index c9d19e868..2ce77cf53 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -1,25 +1,38 @@
 import React from 'react'
-import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
+import {
+  ActivityIndicator,
+  Keyboard,
+  StyleSheet,
+  TouchableWithoutFeedback,
+  View,
+} from 'react-native'
+import {CreateAccountState, CreateAccountDispatch, is18} from './state'
 import {Text} from 'view/com/util/text/Text'
+import {DateInput} from 'view/com/util/forms/DateInput'
 import {StepHeader} from './StepHeader'
-import {CreateAccountState, CreateAccountDispatch} from './state'
-import {useTheme} from 'lib/ThemeContext'
-import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
-import {HelpTip} from '../util/HelpTip'
+import {usePalette} from 'lib/hooks/usePalette'
 import {TextInput} from '../util/TextInput'
-import {Button} from 'view/com/util/forms/Button'
+import {Button} from '../../util/forms/Button'
+import {Policies} from './Policies'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {msg, Trans} from '@lingui/macro'
+import {isWeb} from 'platform/detection'
+import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {logger} from '#/logger'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 
-import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
-import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
+function sanitizeDate(date: Date): Date {
+  if (!date || date.toString() === 'Invalid Date') {
+    logger.error(`Create account: handled invalid date for birthDate`, {
+      hasDate: !!date,
+    })
+    return new Date()
+  }
+  return date
+}
 
-/** STEP 1: Your hosting provider
- * @field Bluesky (default)
- * @field Other (staging, local dev, your own PDS, etc.)
- */
 export function Step1({
   uiState,
   uiDispatch,
@@ -28,135 +41,175 @@ export function Step1({
   uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
-  const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
   const {_} = useLingui()
+  const {openModal} = useModalControls()
 
-  const onPressDefault = React.useCallback(() => {
-    setIsDefaultSelected(true)
-    uiDispatch({type: 'set-service-url', value: PROD_SERVICE})
-  }, [setIsDefaultSelected, uiDispatch])
+  const onPressSelectService = React.useCallback(() => {
+    openModal({
+      name: 'server-input',
+      initialService: uiState.serviceUrl,
+      onSelect: (url: string) =>
+        uiDispatch({type: 'set-service-url', value: url}),
+    })
+    Keyboard.dismiss()
+  }, [uiDispatch, uiState.serviceUrl, openModal])
 
-  const onPressOther = React.useCallback(() => {
-    setIsDefaultSelected(false)
-    uiDispatch({type: 'set-service-url', value: 'https://'})
-  }, [setIsDefaultSelected, uiDispatch])
+  const onPressWaitlist = React.useCallback(() => {
+    openModal({name: 'waitlist'})
+  }, [openModal])
 
-  const onChangeServiceUrl = React.useCallback(
-    (v: string) => {
-      uiDispatch({type: 'set-service-url', value: v})
-    },
-    [uiDispatch],
-  )
+  const birthDate = React.useMemo(() => {
+    return sanitizeDate(uiState.birthDate)
+  }, [uiState.birthDate])
 
   return (
     <View>
-      <StepHeader step="1" title={_(msg`Your hosting provider`)} />
-      <Text style={[pal.text, s.mb10]}>
-        <Trans>This is the service that keeps you online.</Trans>
-      </Text>
-      <Option
-        testID="blueskyServerBtn"
-        isSelected={isDefaultSelected}
-        label="Bluesky"
-        help="&nbsp;(default)"
-        onPress={onPressDefault}
-      />
-      <Option
-        testID="otherServerBtn"
-        isSelected={!isDefaultSelected}
-        label="Other"
-        onPress={onPressOther}>
-        <View style={styles.otherForm}>
-          <Text nativeID="addressProvider" style={[pal.text, s.mb5]}>
-            <Trans>Enter the address of your provider:</Trans>
-          </Text>
-          <TextInput
-            testID="customServerInput"
-            icon="globe"
-            placeholder={_(msg`Hosting provider address`)}
-            value={uiState.serviceUrl}
-            editable
-            onChange={onChangeServiceUrl}
-            accessibilityHint="Input hosting provider address"
-            accessibilityLabel={_(msg`Hosting provider address`)}
-            accessibilityLabelledBy="addressProvider"
-          />
-          {LOGIN_INCLUDE_DEV_SERVERS && (
-            <View style={[s.flexRow, s.mt10]}>
-              <Button
-                testID="stagingServerBtn"
-                type="default"
-                style={s.mr5}
-                label={_(msg`Staging`)}
-                onPress={() => onChangeServiceUrl(STAGING_SERVICE)}
-              />
-              <Button
-                testID="localDevServerBtn"
-                type="default"
-                label={_(msg`Dev Server`)}
-                onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)}
+      <StepHeader uiState={uiState} title={_(msg`Your account`)}>
+        <View>
+          <Button
+            testID="selectServiceButton"
+            type="default"
+            style={{
+              aspectRatio: 1,
+              justifyContent: 'center',
+              alignItems: 'center',
+            }}
+            accessibilityLabel={_(msg`Select service`)}
+            accessibilityHint={_(msg`Sets server for the Bluesky client`)}
+            onPress={onPressSelectService}>
+            <FontAwesomeIcon icon="server" size={21} />
+          </Button>
+        </View>
+      </StepHeader>
+
+      {!uiState.serviceDescription ? (
+        <ActivityIndicator />
+      ) : (
+        <>
+          {uiState.isInviteCodeRequired && (
+            <View style={s.pb20}>
+              <Text type="md-medium" style={[pal.text, s.mb2]}>
+                <Trans>Invite code</Trans>
+              </Text>
+              <TextInput
+                testID="inviteCodeInput"
+                icon="ticket"
+                placeholder={_(msg`Required for this provider`)}
+                value={uiState.inviteCode}
+                editable
+                onChange={value => uiDispatch({type: 'set-invite-code', value})}
+                accessibilityLabel={_(msg`Invite code`)}
+                accessibilityHint={_(msg`Input invite code to proceed`)}
+                autoCapitalize="none"
+                autoComplete="off"
+                autoCorrect={false}
+                autoFocus={true}
               />
             </View>
           )}
-        </View>
-      </Option>
-      {uiState.error ? (
-        <ErrorMessage message={uiState.error} style={styles.error} />
-      ) : (
-        <HelpTip text={_(msg`You can change hosting providers at any time.`)} />
-      )}
-    </View>
-  )
-}
-
-function Option({
-  children,
-  isSelected,
-  label,
-  help,
-  onPress,
-  testID,
-}: React.PropsWithChildren<{
-  isSelected: boolean
-  label: string
-  help?: string
-  onPress: () => void
-  testID?: string
-}>) {
-  const theme = useTheme()
-  const pal = usePalette('default')
-  const circleFillStyle = React.useMemo(
-    () => ({
-      backgroundColor: theme.palette.primary.background,
-    }),
-    [theme],
-  )
 
-  return (
-    <View style={[styles.option, pal.border]}>
-      <TouchableWithoutFeedback
-        onPress={onPress}
-        testID={testID}
-        accessibilityRole="button"
-        accessibilityLabel={label}
-        accessibilityHint={`Sets hosting provider to ${label}`}>
-        <View style={styles.optionHeading}>
-          <View style={[styles.circle, pal.border]}>
-            {isSelected ? (
-              <View style={[circleFillStyle, styles.circleFill]} />
-            ) : undefined}
-          </View>
-          <Text type="xl" style={pal.text}>
-            {label}
-            {help ? (
-              <Text type="xl" style={pal.textLight}>
-                {help}
+          {!uiState.inviteCode && uiState.isInviteCodeRequired ? (
+            <View style={[s.flexRow, s.alignCenter]}>
+              <Text style={pal.text}>
+                <Trans>Don't have an invite code?</Trans>{' '}
               </Text>
-            ) : undefined}
-          </Text>
-        </View>
-      </TouchableWithoutFeedback>
-      {isSelected && children}
+              <TouchableWithoutFeedback
+                onPress={onPressWaitlist}
+                accessibilityLabel={_(msg`Join the waitlist.`)}
+                accessibilityHint="">
+                <View style={styles.touchable}>
+                  <Text style={pal.link}>
+                    <Trans>Join the waitlist.</Trans>
+                  </Text>
+                </View>
+              </TouchableWithoutFeedback>
+            </View>
+          ) : (
+            <>
+              <View style={s.pb20}>
+                <Text
+                  type="md-medium"
+                  style={[pal.text, s.mb2]}
+                  nativeID="email">
+                  <Trans>Email address</Trans>
+                </Text>
+                <TextInput
+                  testID="emailInput"
+                  icon="envelope"
+                  placeholder={_(msg`Enter your email address`)}
+                  value={uiState.email}
+                  editable
+                  onChange={value => uiDispatch({type: 'set-email', value})}
+                  accessibilityLabel={_(msg`Email`)}
+                  accessibilityHint={_(msg`Input email for Bluesky account`)}
+                  accessibilityLabelledBy="email"
+                  autoCapitalize="none"
+                  autoComplete="off"
+                  autoCorrect={false}
+                  autoFocus={!uiState.isInviteCodeRequired}
+                />
+              </View>
+
+              <View style={s.pb20}>
+                <Text
+                  type="md-medium"
+                  style={[pal.text, s.mb2]}
+                  nativeID="password">
+                  <Trans>Password</Trans>
+                </Text>
+                <TextInput
+                  testID="passwordInput"
+                  icon="lock"
+                  placeholder={_(msg`Choose your password`)}
+                  value={uiState.password}
+                  editable
+                  secureTextEntry
+                  onChange={value => uiDispatch({type: 'set-password', value})}
+                  accessibilityLabel={_(msg`Password`)}
+                  accessibilityHint={_(msg`Set password`)}
+                  accessibilityLabelledBy="password"
+                  autoCapitalize="none"
+                  autoComplete="off"
+                  autoCorrect={false}
+                />
+              </View>
+
+              <View style={s.pb20}>
+                <Text
+                  type="md-medium"
+                  style={[pal.text, s.mb2]}
+                  nativeID="birthDate">
+                  <Trans>Your birth date</Trans>
+                </Text>
+                <DateInput
+                  handleAsUTC
+                  testID="birthdayInput"
+                  value={birthDate}
+                  onChange={value =>
+                    uiDispatch({type: 'set-birth-date', value})
+                  }
+                  buttonType="default-light"
+                  buttonStyle={[pal.border, styles.dateInputButton]}
+                  buttonLabelType="lg"
+                  accessibilityLabel={_(msg`Birthday`)}
+                  accessibilityHint={_(msg`Enter your birth date`)}
+                  accessibilityLabelledBy="birthDate"
+                />
+              </View>
+
+              {uiState.serviceDescription && (
+                <Policies
+                  serviceDescription={uiState.serviceDescription}
+                  needsGuardian={!is18(uiState)}
+                />
+              )}
+            </>
+          )}
+        </>
+      )}
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
+      ) : undefined}
     </View>
   )
 }
@@ -164,34 +217,15 @@ function Option({
 const styles = StyleSheet.create({
   error: {
     borderRadius: 6,
+    marginTop: 10,
   },
-
-  option: {
+  dateInputButton: {
     borderWidth: 1,
     borderRadius: 6,
-    marginBottom: 10,
-  },
-  optionHeading: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    padding: 10,
+    paddingVertical: 14,
   },
-  circle: {
-    width: 26,
-    height: 26,
-    borderRadius: 15,
-    padding: 4,
-    borderWidth: 1,
-    marginRight: 10,
-  },
-  circleFill: {
-    width: 16,
-    height: 16,
-    borderRadius: 10,
-  },
-
-  otherForm: {
-    paddingBottom: 10,
-    paddingHorizontal: 12,
+  // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
+  touchable: {
+    ...(isWeb && {cursor: 'pointer'}),
   },
 })
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index 89fd070ad..f6eedc2eb 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -1,28 +1,34 @@
 import React from 'react'
-import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {CreateAccountState, CreateAccountDispatch, is18} from './state'
+import {
+  ActivityIndicator,
+  StyleSheet,
+  TouchableWithoutFeedback,
+  View,
+} from 'react-native'
+import RNPickerSelect from 'react-native-picker-select'
+import {
+  CreateAccountState,
+  CreateAccountDispatch,
+  requestVerificationCode,
+} from './state'
 import {Text} from 'view/com/util/text/Text'
-import {DateInput} from 'view/com/util/forms/DateInput'
 import {StepHeader} from './StepHeader'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {TextInput} from '../util/TextInput'
-import {Policies} from './Policies'
+import {Button} from '../../util/forms/Button'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {isWeb} from 'platform/detection'
+import {isAndroid, isWeb} from 'platform/detection'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import parsePhoneNumber from 'libphonenumber-js'
+import {COUNTRY_CODES} from '#/lib/country-codes'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
 
-/** STEP 2: Your account
- * @field Invite code or waitlist
- * @field Email address
- * @field Email address
- * @field Email address
- * @field Password
- * @field Birth date
- * @readonly Terms of service & privacy policy
- */
 export function Step2({
   uiState,
   uiDispatch,
@@ -32,116 +38,253 @@ export function Step2({
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {openModal} = useModalControls()
+  const {isMobile} = useWebMediaQueries()
 
-  const onPressWaitlist = React.useCallback(() => {
-    openModal({name: 'waitlist'})
-  }, [openModal])
+  const onPressRequest = React.useCallback(() => {
+    if (
+      uiState.verificationPhone.length >= 9 &&
+      parsePhoneNumber(uiState.verificationPhone, uiState.phoneCountry)
+    ) {
+      requestVerificationCode({uiState, uiDispatch, _})
+    } else {
+      uiDispatch({
+        type: 'set-error',
+        value: _(
+          msg`There's something wrong with this number. Please choose your country and enter your full phone number!`,
+        ),
+      })
+    }
+  }, [uiState, uiDispatch, _])
+
+  const onPressRetry = React.useCallback(() => {
+    uiDispatch({type: 'set-has-requested-verification-code', value: false})
+  }, [uiDispatch])
+
+  const phoneNumberFormatted = React.useMemo(
+    () =>
+      uiState.hasRequestedVerificationCode
+        ? parsePhoneNumber(
+            uiState.verificationPhone,
+            uiState.phoneCountry,
+          )?.formatInternational()
+        : '',
+    [
+      uiState.hasRequestedVerificationCode,
+      uiState.verificationPhone,
+      uiState.phoneCountry,
+    ],
+  )
 
   return (
     <View>
-      <StepHeader step="2" title={_(msg`Your account`)} />
-
-      {uiState.isInviteCodeRequired && (
-        <View style={s.pb20}>
-          <Text type="md-medium" style={[pal.text, s.mb2]}>
-            Invite code
-          </Text>
-          <TextInput
-            testID="inviteCodeInput"
-            icon="ticket"
-            placeholder={_(msg`Required for this provider`)}
-            value={uiState.inviteCode}
-            editable
-            onChange={value => uiDispatch({type: 'set-invite-code', value})}
-            accessibilityLabel={_(msg`Invite code`)}
-            accessibilityHint="Input invite code to proceed"
-          />
-        </View>
-      )}
+      <StepHeader uiState={uiState} title={_(msg`SMS verification`)} />
 
-      {!uiState.inviteCode && uiState.isInviteCodeRequired ? (
-        <Text style={[s.alignBaseline, pal.text]}>
-          Don't have an invite code?{' '}
-          <TouchableWithoutFeedback
-            onPress={onPressWaitlist}
-            accessibilityLabel={_(msg`Join the waitlist.`)}
-            accessibilityHint="">
-            <View style={styles.touchable}>
-              <Text style={pal.link}>
-                <Trans>Join the waitlist.</Trans>
-              </Text>
-            </View>
-          </TouchableWithoutFeedback>
-        </Text>
-      ) : (
+      {!uiState.hasRequestedVerificationCode ? (
         <>
-          <View style={s.pb20}>
-            <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email">
-              <Trans>Email address</Trans>
+          <View style={s.pb10}>
+            <Text
+              type="md-medium"
+              style={[pal.text, s.mb2]}
+              nativeID="phoneCountry">
+              <Trans>Country</Trans>
             </Text>
-            <TextInput
-              testID="emailInput"
-              icon="envelope"
-              placeholder={_(msg`Enter your email address`)}
-              value={uiState.email}
-              editable
-              onChange={value => uiDispatch({type: 'set-email', value})}
-              accessibilityLabel={_(msg`Email`)}
-              accessibilityHint="Input email for Bluesky waitlist"
-              accessibilityLabelledBy="email"
-            />
+            <View
+              style={[
+                {position: 'relative'},
+                isAndroid && {
+                  borderWidth: 1,
+                  borderColor: pal.border.borderColor,
+                  borderRadius: 4,
+                },
+              ]}>
+              <RNPickerSelect
+                placeholder={{}}
+                value={uiState.phoneCountry}
+                onValueChange={value =>
+                  uiDispatch({type: 'set-phone-country', value})
+                }
+                items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({
+                  label: l.name,
+                  value: l.code2,
+                  key: l.code2,
+                }))}
+                style={{
+                  inputAndroid: {
+                    backgroundColor: pal.view.backgroundColor,
+                    color: pal.text.color,
+                    fontSize: 21,
+                    letterSpacing: 0.5,
+                    fontWeight: '500',
+                    paddingHorizontal: 14,
+                    paddingVertical: 8,
+                    borderRadius: 4,
+                  },
+                  inputIOS: {
+                    backgroundColor: pal.view.backgroundColor,
+                    color: pal.text.color,
+                    fontSize: 14,
+                    letterSpacing: 0.5,
+                    fontWeight: '500',
+                    paddingHorizontal: 14,
+                    paddingVertical: 8,
+                    borderWidth: 1,
+                    borderColor: pal.border.borderColor,
+                    borderRadius: 4,
+                  },
+                  inputWeb: {
+                    // @ts-ignore web only
+                    cursor: 'pointer',
+                    '-moz-appearance': 'none',
+                    '-webkit-appearance': 'none',
+                    appearance: 'none',
+                    outline: 0,
+                    borderWidth: 1,
+                    borderColor: pal.border.borderColor,
+                    backgroundColor: pal.view.backgroundColor,
+                    color: pal.text.color,
+                    fontSize: 14,
+                    letterSpacing: 0.5,
+                    fontWeight: '500',
+                    paddingHorizontal: 14,
+                    paddingVertical: 8,
+                    borderRadius: 4,
+                  },
+                }}
+                accessibilityLabel={_(msg`Select your phone's country`)}
+                accessibilityHint=""
+                accessibilityLabelledBy="phoneCountry"
+              />
+              <View
+                style={{
+                  position: 'absolute',
+                  top: 1,
+                  right: 1,
+                  bottom: 1,
+                  width: 40,
+                  pointerEvents: 'none',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                }}>
+                <FontAwesomeIcon
+                  icon="chevron-down"
+                  style={pal.text as FontAwesomeIconStyle}
+                />
+              </View>
+            </View>
           </View>
 
           <View style={s.pb20}>
             <Text
               type="md-medium"
               style={[pal.text, s.mb2]}
-              nativeID="password">
-              <Trans>Password</Trans>
+              nativeID="phoneNumber">
+              <Trans>Phone number</Trans>
             </Text>
             <TextInput
-              testID="passwordInput"
-              icon="lock"
-              placeholder={_(msg`Choose your password`)}
-              value={uiState.password}
+              testID="phoneInput"
+              icon="phone"
+              placeholder={_(msg`Enter your phone number`)}
+              value={uiState.verificationPhone}
               editable
-              secureTextEntry
-              onChange={value => uiDispatch({type: 'set-password', value})}
-              accessibilityLabel={_(msg`Password`)}
-              accessibilityHint="Set password"
-              accessibilityLabelledBy="password"
+              onChange={value =>
+                uiDispatch({type: 'set-verification-phone', value})
+              }
+              accessibilityLabel={_(msg`Email`)}
+              accessibilityHint={_(
+                msg`Input phone number for SMS verification`,
+              )}
+              accessibilityLabelledBy="phoneNumber"
+              keyboardType="phone-pad"
+              autoCapitalize="none"
+              autoComplete="tel"
+              autoCorrect={false}
+              autoFocus={true}
             />
+            <Text type="sm" style={[pal.textLight, s.mt5]}>
+              <Trans>
+                Please enter a phone number that can receive SMS text messages.
+              </Trans>
+            </Text>
           </View>
 
+          <View style={isMobile ? {} : {flexDirection: 'row'}}>
+            {uiState.isProcessing ? (
+              <ActivityIndicator />
+            ) : (
+              <Button
+                testID="requestCodeBtn"
+                type="primary"
+                label={_(msg`Request code`)}
+                labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
+                style={
+                  isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
+                }
+                onPress={onPressRequest}
+              />
+            )}
+          </View>
+        </>
+      ) : (
+        <>
           <View style={s.pb20}>
-            <Text
-              type="md-medium"
-              style={[pal.text, s.mb2]}
-              nativeID="birthDate">
-              <Trans>Your birth date</Trans>
-            </Text>
-            <DateInput
-              testID="birthdayInput"
-              value={uiState.birthDate}
-              onChange={value => uiDispatch({type: 'set-birth-date', value})}
-              buttonType="default-light"
-              buttonStyle={[pal.border, styles.dateInputButton]}
-              buttonLabelType="lg"
-              accessibilityLabel={_(msg`Birthday`)}
-              accessibilityHint="Enter your birth date"
-              accessibilityLabelledBy="birthDate"
+            <View
+              style={[
+                s.flexRow,
+                s.mb5,
+                s.alignCenter,
+                {justifyContent: 'space-between'},
+              ]}>
+              <Text
+                type="md-medium"
+                style={pal.text}
+                nativeID="verificationCode">
+                <Trans>Verification code</Trans>{' '}
+              </Text>
+              <TouchableWithoutFeedback
+                onPress={onPressRetry}
+                accessibilityLabel={_(msg`Retry.`)}
+                accessibilityHint="">
+                <View style={styles.touchable}>
+                  <Text
+                    type="md-medium"
+                    style={pal.link}
+                    nativeID="verificationCode">
+                    <Trans>Retry</Trans>
+                  </Text>
+                </View>
+              </TouchableWithoutFeedback>
+            </View>
+            <TextInput
+              testID="codeInput"
+              icon="hashtag"
+              placeholder={_(msg`XXXXXX`)}
+              value={uiState.verificationCode}
+              editable
+              onChange={value =>
+                uiDispatch({type: 'set-verification-code', value})
+              }
+              accessibilityLabel={_(msg`Email`)}
+              accessibilityHint={_(
+                msg`Input the verification code we have texted to you`,
+              )}
+              accessibilityLabelledBy="verificationCode"
+              keyboardType="phone-pad"
+              autoCapitalize="none"
+              autoComplete="one-time-code"
+              textContentType="oneTimeCode"
+              autoCorrect={false}
+              autoFocus={true}
             />
+            <Text type="sm" style={[pal.textLight, s.mt5]}>
+              <Trans>
+                Please enter the verification code sent to{' '}
+                {phoneNumberFormatted}.
+              </Trans>
+            </Text>
           </View>
-
-          {uiState.serviceDescription && (
-            <Policies
-              serviceDescription={uiState.serviceDescription}
-              needsGuardian={!is18(uiState)}
-            />
-          )}
         </>
       )}
+
       {uiState.error ? (
         <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
@@ -154,11 +297,6 @@ const styles = StyleSheet.create({
     borderRadius: 6,
     marginTop: 10,
   },
-  dateInputButton: {
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingVertical: 14,
-  },
   // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
   touchable: {
     ...(isWeb && {cursor: 'pointer'}),
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
index 4c8a58519..bc7956da4 100644
--- a/src/view/com/auth/create/Step3.tsx
+++ b/src/view/com/auth/create/Step3.tsx
@@ -25,7 +25,7 @@ export function Step3({
   const {_} = useLingui()
   return (
     <View>
-      <StepHeader step="3" title={_(msg`Your user handle`)} />
+      <StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
       <View style={s.pb10}>
         <TextInput
           testID="handleInput"
@@ -36,7 +36,7 @@ export function Step3({
           onChange={value => uiDispatch({type: 'set-handle', value})}
           // TODO: Add explicit text label
           accessibilityLabel={_(msg`User handle`)}
-          accessibilityHint="Input your user handle"
+          accessibilityHint={_(msg`Input your user handle`)}
         />
         <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
           <Trans>Your full handle will be</Trans>{' '}
diff --git a/src/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx
index 4b4eb5d23..af6bf5478 100644
--- a/src/view/com/auth/create/StepHeader.tsx
+++ b/src/view/com/auth/create/StepHeader.tsx
@@ -2,23 +2,43 @@ import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {Text} from 'view/com/util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
+import {Trans} from '@lingui/macro'
+import {CreateAccountState} from './state'
 
-export function StepHeader({step, title}: {step: string; title: string}) {
+export function StepHeader({
+  uiState,
+  title,
+  children,
+}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
   const pal = usePalette('default')
+  const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2
   return (
     <View style={styles.container}>
-      <Text type="lg" style={[pal.textLight]}>
-        {step === '3' ? 'Last step!' : <>Step {step} of 3</>}
-      </Text>
-      <Text style={[pal.text]} type="title-xl">
-        {title}
-      </Text>
+      <View>
+        <Text type="lg" style={[pal.textLight]}>
+          {uiState.step === 3 ? (
+            <Trans>Last step!</Trans>
+          ) : (
+            <Trans>
+              Step {uiState.step} of {numSteps}
+            </Trans>
+          )}
+        </Text>
+
+        <Text style={[pal.text]} type="title-xl">
+          {title}
+        </Text>
+      </View>
+      {children}
     </View>
   )
 }
 
 const styles = StyleSheet.create({
   container: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
     marginBottom: 20,
   },
 })
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
index a77d2a44f..81cebc118 100644
--- a/src/view/com/auth/create/state.ts
+++ b/src/view/com/auth/create/state.ts
@@ -2,6 +2,7 @@ import {useReducer} from 'react'
 import {
   ComAtprotoServerDescribeServer,
   ComAtprotoServerCreateAccount,
+  BskyAgent,
 } from '@atproto/api'
 import {I18nContext, useLingui} from '@lingui/react'
 import {msg} from '@lingui/macro'
@@ -13,6 +14,7 @@ import {cleanError} from '#/lib/strings/errors'
 import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
 import {ApiContext as SessionApiContext} from '#/state/session'
 import {DEFAULT_SERVICE} from '#/lib/constants'
+import parsePhoneNumber, {CountryCode} from 'libphonenumber-js'
 
 export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
 const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
@@ -27,6 +29,10 @@ export type CreateAccountAction =
   | {type: 'set-invite-code'; value: string}
   | {type: 'set-email'; value: string}
   | {type: 'set-password'; value: string}
+  | {type: 'set-phone-country'; value: CountryCode}
+  | {type: 'set-verification-phone'; value: string}
+  | {type: 'set-verification-code'; value: string}
+  | {type: 'set-has-requested-verification-code'; value: boolean}
   | {type: 'set-handle'; value: string}
   | {type: 'set-birth-date'; value: Date}
   | {type: 'next'}
@@ -43,6 +49,10 @@ export interface CreateAccountState {
   inviteCode: string
   email: string
   password: string
+  phoneCountry: CountryCode
+  verificationPhone: string
+  verificationCode: string
+  hasRequestedVerificationCode: boolean
   handle: string
   birthDate: Date
 
@@ -50,6 +60,7 @@ export interface CreateAccountState {
   canBack: boolean
   canNext: boolean
   isInviteCodeRequired: boolean
+  isPhoneVerificationRequired: boolean
 }
 
 export type CreateAccountDispatch = (action: CreateAccountAction) => void
@@ -66,15 +77,55 @@ export function useCreateAccount() {
     inviteCode: '',
     email: '',
     password: '',
+    phoneCountry: 'US',
+    verificationPhone: '',
+    verificationCode: '',
+    hasRequestedVerificationCode: false,
     handle: '',
     birthDate: DEFAULT_DATE,
 
     canBack: false,
     canNext: false,
     isInviteCodeRequired: false,
+    isPhoneVerificationRequired: false,
   })
 }
 
+export async function requestVerificationCode({
+  uiState,
+  uiDispatch,
+  _,
+}: {
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
+  _: I18nContext['_']
+}) {
+  const phoneNumber = parsePhoneNumber(
+    uiState.verificationPhone,
+    uiState.phoneCountry,
+  )?.number
+  if (!phoneNumber) {
+    return
+  }
+  uiDispatch({type: 'set-error', value: ''})
+  uiDispatch({type: 'set-processing', value: true})
+  uiDispatch({type: 'set-verification-phone', value: phoneNumber})
+  try {
+    const agent = new BskyAgent({service: uiState.serviceUrl})
+    await agent.com.atproto.temp.requestPhoneVerification({
+      phoneNumber,
+    })
+    uiDispatch({type: 'set-has-requested-verification-code', value: true})
+  } catch (e: any) {
+    logger.error(
+      `Failed to request sms verification code (${e.status} status)`,
+      {error: e},
+    )
+    uiDispatch({type: 'set-error', value: cleanError(e.toString())})
+  }
+  uiDispatch({type: 'set-processing', value: false})
+}
+
 export async function submit({
   createAccount,
   onboardingDispatch,
@@ -89,26 +140,36 @@ export async function submit({
   _: I18nContext['_']
 }) {
   if (!uiState.email) {
-    uiDispatch({type: 'set-step', value: 2})
+    uiDispatch({type: 'set-step', value: 1})
     return uiDispatch({
       type: 'set-error',
       value: _(msg`Please enter your email.`),
     })
   }
   if (!EmailValidator.validate(uiState.email)) {
-    uiDispatch({type: 'set-step', value: 2})
+    uiDispatch({type: 'set-step', value: 1})
     return uiDispatch({
       type: 'set-error',
       value: _(msg`Your email appears to be invalid.`),
     })
   }
   if (!uiState.password) {
-    uiDispatch({type: 'set-step', value: 2})
+    uiDispatch({type: 'set-step', value: 1})
     return uiDispatch({
       type: 'set-error',
       value: _(msg`Please choose your password.`),
     })
   }
+  if (
+    uiState.isPhoneVerificationRequired &&
+    (!uiState.verificationPhone || !uiState.verificationCode)
+  ) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please enter the code you received by SMS.`),
+    })
+  }
   if (!uiState.handle) {
     uiDispatch({type: 'set-step', value: 3})
     return uiDispatch({
@@ -127,6 +188,8 @@ export async function submit({
       handle: createFullHandle(uiState.handle, uiState.userDomain),
       password: uiState.password,
       inviteCode: uiState.inviteCode.trim(),
+      verificationPhone: uiState.verificationPhone.trim(),
+      verificationCode: uiState.verificationCode.trim(),
     })
   } catch (e: any) {
     onboardingDispatch({type: 'skip'}) // undo starting the onboard
@@ -135,8 +198,17 @@ export async function submit({
       errMsg = _(
         msg`Invite code not accepted. Check that you input it correctly and try again.`,
       )
+      uiDispatch({type: 'set-step', value: 1})
+    } else if (e.error === 'InvalidPhoneVerification') {
+      uiDispatch({type: 'set-step', value: 2})
+    }
+
+    if ([400, 429].includes(e.status)) {
+      logger.warn('Failed to create account', {error: e})
+    } else {
+      logger.error(`Failed to create account (${e.status} status)`, {error: e})
     }
-    logger.error('Failed to create account', {error: e})
+
     uiDispatch({type: 'set-processing', value: false})
     uiDispatch({type: 'set-error', value: cleanError(errMsg)})
     throw e
@@ -195,6 +267,22 @@ function createReducer({_}: {_: I18nContext['_']}) {
       case 'set-password': {
         return compute({...state, password: action.value})
       }
+      case 'set-phone-country': {
+        return compute({...state, phoneCountry: action.value})
+      }
+      case 'set-verification-phone': {
+        return compute({
+          ...state,
+          verificationPhone: action.value,
+          hasRequestedVerificationCode: false,
+        })
+      }
+      case 'set-verification-code': {
+        return compute({...state, verificationCode: action.value.trim()})
+      }
+      case 'set-has-requested-verification-code': {
+        return compute({...state, hasRequestedVerificationCode: action.value})
+      }
       case 'set-handle': {
         return compute({...state, handle: action.value})
       }
@@ -202,7 +290,7 @@ function createReducer({_}: {_: I18nContext['_']}) {
         return compute({...state, birthDate: action.value})
       }
       case 'next': {
-        if (state.step === 2) {
+        if (state.step === 1) {
           if (!is13(state)) {
             return compute({
               ...state,
@@ -212,10 +300,18 @@ function createReducer({_}: {_: I18nContext['_']}) {
             })
           }
         }
-        return compute({...state, error: '', step: state.step + 1})
+        let increment = 1
+        if (state.step === 1 && !state.isPhoneVerificationRequired) {
+          increment = 2
+        }
+        return compute({...state, error: '', step: state.step + increment})
       }
       case 'back': {
-        return compute({...state, error: '', step: state.step - 1})
+        let decrement = 1
+        if (state.step === 3 && !state.isPhoneVerificationRequired) {
+          decrement = 2
+        }
+        return compute({...state, error: '', step: state.step - decrement})
       }
     }
   }
@@ -224,12 +320,16 @@ function createReducer({_}: {_: I18nContext['_']}) {
 function compute(state: CreateAccountState): CreateAccountState {
   let canNext = true
   if (state.step === 1) {
-    canNext = !!state.serviceDescription
-  } else if (state.step === 2) {
     canNext =
+      !!state.serviceDescription &&
       (!state.isInviteCodeRequired || !!state.inviteCode) &&
       !!state.email &&
       !!state.password
+  } else if (state.step === 2) {
+    canNext =
+      !state.isPhoneVerificationRequired ||
+      (!!state.verificationPhone &&
+        isValidVerificationCode(state.verificationCode))
   } else if (state.step === 3) {
     canNext = !!state.handle
   }
@@ -238,5 +338,11 @@ function compute(state: CreateAccountState): CreateAccountState {
     canBack: state.step > 1,
     canNext,
     isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
+    isPhoneVerificationRequired:
+      !!state.serviceDescription?.phoneVerificationRequired,
   }
 }
+
+function isValidVerificationCode(str: string): boolean {
+  return /[0-9]{6}/.test(str)
+}
diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx
index 73ddfc9d6..32cd8315d 100644
--- a/src/view/com/auth/login/ChooseAccountForm.tsx
+++ b/src/view/com/auth/login/ChooseAccountForm.tsx
@@ -42,7 +42,7 @@ function AccountItem({
       onPress={onPress}
       accessibilityRole="button"
       accessibilityLabel={_(msg`Sign in as ${account.handle}`)}
-      accessibilityHint="Double tap to sign in">
+      accessibilityHint={_(msg`Double tap to sign in`)}>
       <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
         <View style={s.p10}>
           <UserAvatar avatar={profile?.avatar} size={30} />
@@ -95,19 +95,19 @@ export const ChooseAccountForm = ({
       if (account.accessJwt) {
         if (account.did === currentAccount?.did) {
           setShowLoggedOut(false)
-          Toast.show(`Already signed in as @${account.handle}`)
+          Toast.show(_(msg`Already signed in as @${account.handle}`))
         } else {
           await initSession(account)
           track('Sign In', {resumedSession: true})
           setTimeout(() => {
-            Toast.show(`Signed in as @${account.handle}`)
+            Toast.show(_(msg`Signed in as @${account.handle}`))
           }, 100)
         }
       } else {
         onSelectAccount(account)
       }
     },
-    [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut],
+    [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
   )
 
   return (
diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx
index 215c393d9..f9bb64f98 100644
--- a/src/view/com/auth/login/ForgotPasswordForm.tsx
+++ b/src/view/com/auth/login/ForgotPasswordForm.tsx
@@ -67,7 +67,7 @@ export const ForgotPasswordForm = ({
 
   const onPressNext = async () => {
     if (!EmailValidator.validate(email)) {
-      return setError('Your email appears to be invalid.')
+      return setError(_(msg`Your email appears to be invalid.`))
     }
 
     setError('')
@@ -83,7 +83,9 @@ export const ForgotPasswordForm = ({
       setIsProcessing(false)
       if (isNetworkError(e)) {
         setError(
-          'Unable to contact your service. Please check your Internet connection.',
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
         )
       } else {
         setError(cleanError(errMsg))
@@ -112,7 +114,9 @@ export const ForgotPasswordForm = ({
             onPress={onPressSelectService}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Hosting provider`)}
-            accessibilityHint="Sets hosting provider for password reset">
+            accessibilityHint={_(
+              msg`Sets hosting provider for password reset`,
+            )}>
             <FontAwesomeIcon
               icon="globe"
               style={[pal.textLight, styles.groupContentIcon]}
@@ -136,7 +140,7 @@ export const ForgotPasswordForm = ({
             <TextInput
               testID="forgotPasswordEmail"
               style={[pal.text, styles.textInput]}
-              placeholder="Email address"
+              placeholder={_(msg`Email address`)}
               placeholderTextColor={pal.colors.textLight}
               autoCapitalize="none"
               autoFocus
@@ -146,7 +150,7 @@ export const ForgotPasswordForm = ({
               onChangeText={setEmail}
               editable={!isProcessing}
               accessibilityLabel={_(msg`Email`)}
-              accessibilityHint="Sets email for password reset"
+              accessibilityHint={_(msg`Sets email for password reset`)}
             />
           </View>
         </View>
@@ -179,7 +183,7 @@ export const ForgotPasswordForm = ({
               onPress={onPressNext}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Go to next`)}
-              accessibilityHint="Navigates to the next screen">
+              accessibilityHint={_(msg`Navigates to the next screen`)}>
               <Text type="xl-bold" style={[pal.link, s.pr5]}>
                 <Trans>Next</Trans>
               </Text>
diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx
index a39d0d9bf..10608a54b 100644
--- a/src/view/com/auth/login/LoginForm.tsx
+++ b/src/view/com/auth/login/LoginForm.tsx
@@ -107,17 +107,21 @@ export const LoginForm = ({
       })
     } catch (e: any) {
       const errMsg = e.toString()
-      logger.warn('Failed to login', {error: e})
       setIsProcessing(false)
       if (errMsg.includes('Authentication Required')) {
+        logger.info('Failed to login due to invalid credentials', {
+          error: errMsg,
+        })
         setError(_(msg`Invalid username or password`))
       } else if (isNetworkError(e)) {
+        logger.warn('Failed to login due to network error', {error: errMsg})
         setError(
           _(
             msg`Unable to contact your service. Please check your Internet connection.`,
           ),
         )
       } else {
+        logger.warn('Failed to login', {error: errMsg})
         setError(cleanError(errMsg))
       }
     }
@@ -141,7 +145,7 @@ export const LoginForm = ({
             onPress={onPressSelectService}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Select service`)}
-            accessibilityHint="Sets server for the Bluesky client">
+            accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
             <Text type="xl" style={[pal.text, styles.textBtnLabel]}>
               {toNiceDomain(serviceUrl)}
             </Text>
@@ -174,6 +178,7 @@ export const LoginForm = ({
             autoCorrect={false}
             autoComplete="username"
             returnKeyType="next"
+            textContentType="username"
             onSubmitEditing={() => {
               passwordInputRef.current?.focus()
             }}
@@ -185,7 +190,9 @@ export const LoginForm = ({
             }
             editable={!isProcessing}
             accessibilityLabel={_(msg`Username or email address`)}
-            accessibilityHint="Input the username or email address you used at signup"
+            accessibilityHint={_(
+              msg`Input the username or email address you used at signup`,
+            )}
           />
         </View>
         <View style={[pal.borderDark, styles.groupContent]}>
@@ -216,8 +223,8 @@ export const LoginForm = ({
             accessibilityLabel={_(msg`Password`)}
             accessibilityHint={
               identifier === ''
-                ? 'Input your password'
-                : `Input the password tied to ${identifier}`
+                ? _(msg`Input your password`)
+                : _(msg`Input the password tied to ${identifier}`)
             }
           />
           <TouchableOpacity
@@ -226,7 +233,7 @@ export const LoginForm = ({
             onPress={onPressForgotPassword}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Forgot password`)}
-            accessibilityHint="Opens password reset form">
+            accessibilityHint={_(msg`Opens password reset form`)}>
             <Text style={pal.link}>
               <Trans>Forgot</Trans>
             </Text>
@@ -256,7 +263,7 @@ export const LoginForm = ({
             onPress={onPressRetryConnect}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Retry`)}
-            accessibilityHint="Retries login">
+            accessibilityHint={_(msg`Retries login`)}>
             <Text type="xl-bold" style={[pal.link, s.pr5]}>
               <Trans>Retry</Trans>
             </Text>
@@ -276,7 +283,7 @@ export const LoginForm = ({
             onPress={onPressNext}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Go to next`)}
-            accessibilityHint="Navigates to the next screen">
+            accessibilityHint={_(msg`Navigates to the next screen`)}>
             <Text type="xl-bold" style={[pal.link, s.pr5]}>
               <Trans>Next</Trans>
             </Text>
diff --git a/src/view/com/auth/login/PasswordUpdatedForm.tsx b/src/view/com/auth/login/PasswordUpdatedForm.tsx
index 1e07588a9..71f750b14 100644
--- a/src/view/com/auth/login/PasswordUpdatedForm.tsx
+++ b/src/view/com/auth/login/PasswordUpdatedForm.tsx
@@ -36,7 +36,7 @@ export const PasswordUpdatedForm = ({
             onPress={onPressNext}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Close alert`)}
-            accessibilityHint="Closes password update alert">
+            accessibilityHint={_(msg`Closes password update alert`)}>
             <Text type="xl-bold" style={[pal.link, s.pr5]}>
               <Trans>Okay</Trans>
             </Text>
diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx
index 2bb614df2..630c6afde 100644
--- a/src/view/com/auth/login/SetNewPasswordForm.tsx
+++ b/src/view/com/auth/login/SetNewPasswordForm.tsx
@@ -95,7 +95,7 @@ export const SetNewPasswordForm = ({
             <TextInput
               testID="resetCodeInput"
               style={[pal.text, styles.textInput]}
-              placeholder="Reset code"
+              placeholder={_(msg`Reset code`)}
               placeholderTextColor={pal.colors.textLight}
               autoCapitalize="none"
               autoCorrect={false}
@@ -106,7 +106,9 @@ export const SetNewPasswordForm = ({
               editable={!isProcessing}
               accessible={true}
               accessibilityLabel={_(msg`Reset code`)}
-              accessibilityHint="Input code sent to your email for password reset"
+              accessibilityHint={_(
+                msg`Input code sent to your email for password reset`,
+              )}
             />
           </View>
           <View style={[pal.borderDark, styles.groupContent]}>
@@ -117,7 +119,7 @@ export const SetNewPasswordForm = ({
             <TextInput
               testID="newPasswordInput"
               style={[pal.text, styles.textInput]}
-              placeholder="New password"
+              placeholder={_(msg`New password`)}
               placeholderTextColor={pal.colors.textLight}
               autoCapitalize="none"
               autoCorrect={false}
@@ -128,7 +130,7 @@ export const SetNewPasswordForm = ({
               editable={!isProcessing}
               accessible={true}
               accessibilityLabel={_(msg`Password`)}
-              accessibilityHint="Input new password"
+              accessibilityHint={_(msg`Input new password`)}
             />
           </View>
         </View>
@@ -161,7 +163,7 @@ export const SetNewPasswordForm = ({
               onPress={onPressNext}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Go to next`)}
-              accessibilityHint="Navigates to the next screen">
+              accessibilityHint={_(msg`Navigates to the next screen`)}>
               <Text type="xl-bold" style={[pal.link, s.pr5]}>
                 <Trans>Next</Trans>
               </Text>
diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
index fcc4572af..63fb0ec15 100644
--- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
@@ -18,6 +18,8 @@ import {
 } from '#/state/queries/preferences'
 import {logger} from '#/logger'
 import {useAnalytics} from '#/lib/analytics/analytics'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export function RecommendedFeedsItem({
   item,
@@ -26,6 +28,7 @@ export function RecommendedFeedsItem({
 }) {
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {data: preferences} = usePreferencesQuery()
   const {
     mutateAsync: pinFeed,
@@ -51,7 +54,7 @@ export function RecommendedFeedsItem({
         await removeFeed({uri: item.uri})
         resetRemoveFeed()
       } catch (e) {
-        Toast.show('There was an issue contacting your server')
+        Toast.show(_(msg`There was an issue contacting your server`))
         logger.error('Failed to unsave feed', {error: e})
       }
     } else {
@@ -60,7 +63,7 @@ export function RecommendedFeedsItem({
         resetPinFeed()
         track('Onboarding:CustomFeedAdded')
       } catch (e) {
-        Toast.show('There was an issue contacting your server')
+        Toast.show(_(msg`There was an issue contacting your server`))
         logger.error('Failed to pin feed', {error: e})
       }
     }
@@ -94,7 +97,7 @@ export function RecommendedFeedsItem({
           </Text>
 
           <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
-            by {sanitizeHandle(item.creator.handle, '@')}
+            <Trans>by {sanitizeHandle(item.creator.handle, '@')}</Trans>
           </Text>
 
           {item.description ? (
@@ -133,7 +136,7 @@ export function RecommendedFeedsItem({
                       color={pal.colors.textInverted}
                     />
                     <Text type="lg-medium" style={pal.textInverted}>
-                      Added
+                      <Trans>Added</Trans>
                     </Text>
                   </>
                 ) : (
@@ -144,7 +147,7 @@ export function RecommendedFeedsItem({
                       color={pal.colors.textInverted}
                     />
                     <Text type="lg-medium" style={pal.textInverted}>
-                      Add
+                      <Trans>Add</Trans>
                     </Text>
                   </>
                 )}
diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx
index 372bbec6a..93cfb7386 100644
--- a/src/view/com/auth/onboarding/RecommendedFollows.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx
@@ -83,7 +83,7 @@ export function RecommendedFollows({next}: Props) {
             <Text
               type="2xl-medium"
               style={{color: '#fff', position: 'relative', top: -1}}>
-              <Trans>Done</Trans>
+              <Trans context="action">Done</Trans>
             </Text>
             <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
           </View>
diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx
index 1a30c17f9..fdb31197c 100644
--- a/src/view/com/auth/onboarding/WelcomeDesktop.tsx
+++ b/src/view/com/auth/onboarding/WelcomeDesktop.tsx
@@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
 import {Button} from 'view/com/util/forms/Button'
+import {Trans} from '@lingui/macro'
 
 type Props = {
   next: () => void
@@ -17,7 +18,7 @@ export function WelcomeDesktop({next}: Props) {
   const pal = usePalette('default')
   const horizontal = useMediaQuery({minWidth: 1300})
   const title = (
-    <>
+    <Trans>
       <Text
         style={[
           pal.textLight,
@@ -40,7 +41,7 @@ export function WelcomeDesktop({next}: Props) {
         ]}>
         Bluesky
       </Text>
-    </>
+    </Trans>
   )
   return (
     <TitleColumnLayout
@@ -52,10 +53,12 @@ export function WelcomeDesktop({next}: Props) {
         <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
         <View style={[styles.rowText]}>
           <Text type="xl-bold" style={[pal.text]}>
-            Bluesky is public.
+            <Trans>Bluesky is public.</Trans>
           </Text>
           <Text type="xl" style={[pal.text, s.pt2]}>
-            Your posts, likes, and blocks are public. Mutes are private.
+            <Trans>
+              Your posts, likes, and blocks are public. Mutes are private.
+            </Trans>
           </Text>
         </View>
       </View>
@@ -63,10 +66,10 @@ export function WelcomeDesktop({next}: Props) {
         <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
         <View style={[styles.rowText]}>
           <Text type="xl-bold" style={[pal.text]}>
-            Bluesky is open.
+            <Trans>Bluesky is open.</Trans>
           </Text>
           <Text type="xl" style={[pal.text, s.pt2]}>
-            Never lose access to your followers and data.
+            <Trans>Never lose access to your followers and data.</Trans>
           </Text>
         </View>
       </View>
@@ -74,10 +77,13 @@ export function WelcomeDesktop({next}: Props) {
         <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
         <View style={[styles.rowText]}>
           <Text type="xl-bold" style={[pal.text]}>
-            Bluesky is flexible.
+            <Trans>Bluesky is flexible.</Trans>
           </Text>
           <Text type="xl" style={[pal.text, s.pt2]}>
-            Choose the algorithms that power your experience with custom feeds.
+            <Trans>
+              Choose the algorithms that power your experience with custom
+              feeds.
+            </Trans>
           </Text>
         </View>
       </View>
@@ -94,7 +100,7 @@ export function WelcomeDesktop({next}: Props) {
             <Text
               type="2xl-medium"
               style={{color: '#fff', position: 'relative', top: -1}}>
-              Next
+              <Trans context="action">Next</Trans>
             </Text>
             <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
           </View>
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 9f60923d6..1ed6b98a5 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -6,6 +6,7 @@ import {
   Keyboard,
   KeyboardAvoidingView,
   Platform,
+  Pressable,
   ScrollView,
   StyleSheet,
   TouchableOpacity,
@@ -28,8 +29,6 @@ import {UserAvatar} from '../util/UserAvatar'
 import * as apilib from 'lib/api/index'
 import {ComposerOpts} from 'state/shell/composer'
 import {s, colors, gradients} from 'lib/styles'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
 import {cleanError} from 'lib/strings/errors'
 import {shortenLinks} from 'lib/strings/rich-text-manip'
 import {toShortUrl} from 'lib/strings/url-helpers'
@@ -46,7 +45,7 @@ import {Gallery} from './photos/Gallery'
 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
 import {LabelsBtn} from './labels/LabelsBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
-import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
+import {SuggestedLanguage} from './select-language/SuggestedLanguage'
 import {insertMentionAt} from 'lib/strings/mention-manip'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -63,6 +62,7 @@ import {useComposerControls} from '#/state/shell/composer'
 import {emitPostCreated} from '#/state/events'
 import {ThreadgateSetting} from '#/state/queries/threadgate'
 import {logger} from '#/logger'
+import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
 
 type Props = ComposerOpts
 export const ComposePost = observer(function ComposePost({
@@ -70,10 +70,11 @@ export const ComposePost = observer(function ComposePost({
   onPost,
   quote: initQuote,
   mention: initMention,
+  openPicker,
 }: Props) {
   const {currentAccount} = useSession()
   const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
-  const {activeModals} = useModals()
+  const {isModalActive, activeModals} = useModals()
   const {openModal, closeModal} = useModalControls()
   const {closeComposer} = useComposerControls()
   const {track} = useAnalytics()
@@ -175,11 +176,11 @@ export const ComposePost = observer(function ComposePost({
     [onPressCancel],
   )
   useEffect(() => {
-    if (isWeb) {
+    if (isWeb && !isModalActive) {
       window.addEventListener('keydown', onEscape)
       return () => window.removeEventListener('keydown', onEscape)
     }
-  }, [onEscape])
+  }, [onEscape, isModalActive])
 
   const onPressAddLinkCard = useCallback(
     (uri: string) => {
@@ -260,7 +261,11 @@ export const ComposePost = observer(function ComposePost({
     setLangPrefs.savePostLanguageToHistory()
     onPost?.()
     onClose()
-    Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
+    Toast.show(
+      replyTo
+        ? _(msg`Your reply has been published`)
+        : _(msg`Your post has been published`),
+    )
   }
 
   const canPost = useMemo(
@@ -269,11 +274,17 @@ export const ComposePost = observer(function ComposePost({
       (!requireAltTextEnabled || !gallery.needsAltText),
     [graphemeLength, requireAltTextEnabled, gallery.needsAltText],
   )
-  const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
+  const selectTextInputPlaceholder = replyTo
+    ? _(msg`Write your reply`)
+    : _(msg`What's up?`)
 
   const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
   const hasMedia = gallery.size > 0 || Boolean(extLink)
 
+  const onEmojiButtonPress = useCallback(() => {
+    openPicker?.(textInput.current?.getCursorPosition())
+  }, [openPicker])
+
   return (
     <KeyboardAvoidingView
       testID="composePostView"
@@ -287,7 +298,9 @@ export const ComposePost = observer(function ComposePost({
             onAccessibilityEscape={onPressCancel}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Cancel`)}
-            accessibilityHint="Closes post composer and discards post draft">
+            accessibilityHint={_(
+              msg`Closes post composer and discards post draft`,
+            )}>
             <Text style={[pal.link, s.f18]}>
               <Trans>Cancel</Trans>
             </Text>
@@ -319,7 +332,7 @@ export const ComposePost = observer(function ComposePost({
                   onPress={onPressPublish}
                   accessibilityRole="button"
                   accessibilityLabel={
-                    replyTo ? 'Publish reply' : 'Publish post'
+                    replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
                   }
                   accessibilityHint="">
                   <LinearGradient
@@ -331,14 +344,18 @@ export const ComposePost = observer(function ComposePost({
                     end={{x: 1, y: 1}}
                     style={styles.postBtn}>
                     <Text style={[s.white, s.f16, s.bold]}>
-                      {replyTo ? 'Reply' : 'Post'}
+                      {replyTo ? (
+                        <Trans context="action">Reply</Trans>
+                      ) : (
+                        <Trans context="action">Post</Trans>
+                      )}
                     </Text>
                   </LinearGradient>
                 </TouchableOpacity>
               ) : (
                 <View style={[styles.postBtn, pal.btn]}>
                   <Text style={[pal.textLight, s.f16, s.bold]}>
-                    <Trans>Post</Trans>
+                    <Trans context="action">Post</Trans>
                   </Text>
                 </View>
               )}
@@ -374,22 +391,7 @@ export const ComposePost = observer(function ComposePost({
         <ScrollView
           style={styles.scrollView}
           keyboardShouldPersistTaps="always">
-          {replyTo ? (
-            <View style={[pal.border, styles.replyToLayout]}>
-              <UserAvatar avatar={replyTo.author.avatar} size={50} />
-              <View style={styles.replyToPost}>
-                <Text type="xl-medium" style={[pal.text]}>
-                  {sanitizeDisplayName(
-                    replyTo.author.displayName ||
-                      sanitizeHandle(replyTo.author.handle),
-                  )}
-                </Text>
-                <Text type="post-text" style={pal.text} numberOfLines={6}>
-                  {replyTo.text}
-                </Text>
-              </View>
-            </View>
-          ) : undefined}
+          {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
 
           <View
             style={[
@@ -411,7 +413,9 @@ export const ComposePost = observer(function ComposePost({
               onError={setError}
               accessible={true}
               accessibilityLabel={_(msg`Write post`)}
-              accessibilityHint={`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`}
+              accessibilityHint={_(
+                msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
+              )}
             />
           </View>
 
@@ -440,7 +444,9 @@ export const ComposePost = observer(function ComposePost({
                   onPress={() => onPressAddLinkCard(url)}
                   accessibilityRole="button"
                   accessibilityLabel={_(msg`Add link card`)}
-                  accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
+                  accessibilityHint={_(
+                    msg`Creates a card with a thumbnail. The card links to ${url}`,
+                  )}>
                   <Text style={pal.text}>
                     <Trans>Add link card:</Trans>{' '}
                     <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text>
@@ -449,6 +455,7 @@ export const ComposePost = observer(function ComposePost({
               ))}
           </View>
         ) : null}
+        <SuggestedLanguage text={richtext.text} />
         <View style={[pal.border, styles.bottomBar]}>
           {canSelectImages ? (
             <>
@@ -456,7 +463,19 @@ export const ComposePost = observer(function ComposePost({
               <OpenCameraBtn gallery={gallery} />
             </>
           ) : null}
-          {!isMobile ? <EmojiPickerButton /> : null}
+          {!isMobile ? (
+            <Pressable
+              onPress={onEmojiButtonPress}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Open emoji picker`)}
+              accessibilityHint={_(msg`Open emoji picker`)}>
+              <FontAwesomeIcon
+                icon={['far', 'face-smile']}
+                color={pal.colors.link}
+                size={22}
+              />
+            </Pressable>
+          ) : null}
           <View style={s.flex1} />
           <SelectLangBtn />
           <CharProgress count={graphemeLength} />
@@ -532,17 +551,6 @@ const styles = StyleSheet.create({
   textInputLayoutMobile: {
     flex: 1,
   },
-  replyToLayout: {
-    flexDirection: 'row',
-    borderTopWidth: 1,
-    paddingTop: 16,
-    paddingBottom: 16,
-  },
-  replyToPost: {
-    flex: 1,
-    paddingLeft: 13,
-    paddingRight: 8,
-  },
   addExtLinkBtn: {
     borderWidth: 1,
     borderRadius: 24,
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
new file mode 100644
index 000000000..678c8581f
--- /dev/null
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -0,0 +1,254 @@
+import React from 'react'
+import {LayoutAnimation, Pressable, StyleSheet, View} from 'react-native'
+import {Image} from 'expo-image'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {
+  AppBskyEmbedImages,
+  AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
+  AppBskyFeedPost,
+} from '@atproto/api'
+import {ComposerOptsPostRef} from 'state/shell/composer'
+import {usePalette} from 'lib/hooks/usePalette'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {Text} from 'view/com/util/text/Text'
+import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed'
+
+export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {embed} = replyTo
+
+  const [showFull, setShowFull] = React.useState(false)
+
+  const onPress = React.useCallback(() => {
+    setShowFull(prev => !prev)
+    LayoutAnimation.configureNext({
+      duration: 350,
+      update: {type: 'spring', springDamping: 0.7},
+    })
+  }, [])
+
+  const quote = React.useMemo(() => {
+    if (
+      AppBskyEmbedRecord.isView(embed) &&
+      AppBskyEmbedRecord.isViewRecord(embed.record) &&
+      AppBskyFeedPost.isRecord(embed.record.value)
+    ) {
+      // Not going to include the images right now
+      return {
+        author: embed.record.author,
+        cid: embed.record.cid,
+        uri: embed.record.uri,
+        indexedAt: embed.record.indexedAt,
+        text: embed.record.value.text,
+      }
+    } else if (
+      AppBskyEmbedRecordWithMedia.isView(embed) &&
+      AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
+      AppBskyFeedPost.isRecord(embed.record.record.value)
+    ) {
+      return {
+        author: embed.record.record.author,
+        cid: embed.record.record.cid,
+        uri: embed.record.record.uri,
+        indexedAt: embed.record.record.indexedAt,
+        text: embed.record.record.value.text,
+      }
+    }
+  }, [embed])
+
+  const images = React.useMemo(() => {
+    if (AppBskyEmbedImages.isView(embed)) {
+      return embed.images
+    } else if (
+      AppBskyEmbedRecordWithMedia.isView(embed) &&
+      AppBskyEmbedImages.isView(embed.media)
+    ) {
+      return embed.media.images
+    }
+  }, [embed])
+
+  return (
+    <Pressable
+      style={[pal.border, styles.replyToLayout]}
+      onPress={onPress}
+      accessibilityRole="button"
+      accessibilityLabel={_(
+        msg`Expand or collapse the full post you are replying to`,
+      )}
+      accessibilityHint={_(
+        msg`Expand or collapse the full post you are replying to`,
+      )}>
+      <UserAvatar avatar={replyTo.author.avatar} size={50} />
+      <View style={styles.replyToPost}>
+        <Text type="xl-medium" style={[pal.text]}>
+          {sanitizeDisplayName(
+            replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
+          )}
+        </Text>
+        <View style={styles.replyToBody}>
+          <View style={styles.replyToText}>
+            <Text
+              type="post-text"
+              style={pal.text}
+              numberOfLines={!showFull ? 6 : undefined}>
+              {replyTo.text}
+            </Text>
+          </View>
+          {images && (
+            <ComposerReplyToImages images={images} showFull={showFull} />
+          )}
+        </View>
+        {showFull && quote && <QuoteEmbed quote={quote} />}
+      </View>
+    </Pressable>
+  )
+}
+
+function ComposerReplyToImages({
+  images,
+}: {
+  images: AppBskyEmbedImages.ViewImage[]
+  showFull: boolean
+}) {
+  return (
+    <View
+      style={{
+        width: 65,
+        flexDirection: 'column',
+        alignItems: 'center',
+      }}>
+      <View style={styles.imagesContainer}>
+        {(images.length === 1 && (
+          <Image
+            source={{uri: images[0].thumb}}
+            style={styles.singleImage}
+            cachePolicy="memory-disk"
+            accessibilityIgnoresInvertColors
+          />
+        )) ||
+          (images.length === 2 && (
+            <View style={[styles.imagesInner, styles.imagesRow]}>
+              <Image
+                source={{uri: images[0].thumb}}
+                style={styles.doubleImageTall}
+                cachePolicy="memory-disk"
+                accessibilityIgnoresInvertColors
+              />
+              <Image
+                source={{uri: images[1].thumb}}
+                style={styles.doubleImageTall}
+                cachePolicy="memory-disk"
+                accessibilityIgnoresInvertColors
+              />
+            </View>
+          )) ||
+          (images.length === 3 && (
+            <View style={[styles.imagesInner, styles.imagesRow]}>
+              <Image
+                source={{uri: images[0].thumb}}
+                style={styles.doubleImageTall}
+                cachePolicy="memory-disk"
+                accessibilityIgnoresInvertColors
+              />
+              <View style={styles.imagesInner}>
+                <Image
+                  source={{uri: images[1].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+                <Image
+                  source={{uri: images[2].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+              </View>
+            </View>
+          )) ||
+          (images.length === 4 && (
+            <View style={styles.imagesInner}>
+              <View style={[styles.imagesInner, styles.imagesRow]}>
+                <Image
+                  source={{uri: images[0].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+                <Image
+                  source={{uri: images[1].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+              </View>
+              <View style={[styles.imagesInner, styles.imagesRow]}>
+                <Image
+                  source={{uri: images[2].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+                <Image
+                  source={{uri: images[3].thumb}}
+                  style={styles.doubleImage}
+                  cachePolicy="memory-disk"
+                  accessibilityIgnoresInvertColors
+                />
+              </View>
+            </View>
+          ))}
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  replyToLayout: {
+    flexDirection: 'row',
+    borderTopWidth: 1,
+    paddingTop: 16,
+    paddingBottom: 16,
+  },
+  replyToPost: {
+    flex: 1,
+    paddingLeft: 13,
+    paddingRight: 8,
+  },
+  replyToBody: {
+    flexDirection: 'row',
+    gap: 10,
+  },
+  replyToText: {
+    flex: 1,
+    flexGrow: 1,
+  },
+  imagesContainer: {
+    borderRadius: 6,
+    overflow: 'hidden',
+    marginTop: 2,
+  },
+  imagesInner: {
+    gap: 2,
+  },
+  imagesRow: {
+    flexDirection: 'row',
+  },
+  singleImage: {
+    width: 65,
+    height: 65,
+  },
+  doubleImageTall: {
+    width: 32.5,
+    height: 65,
+  },
+  doubleImage: {
+    width: 32.5,
+    height: 32.5,
+  },
+})
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 502e4b4d2..02dd1bbd7 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -68,7 +68,7 @@ export const ExternalEmbed = ({
         onPress={onRemove}
         accessibilityRole="button"
         accessibilityLabel={_(msg`Remove image preview`)}
-        accessibilityHint={`Removes default thumbnail from ${link.uri}`}
+        accessibilityHint={_(msg`Removes default thumbnail from ${link.uri}`)}
         onAccessibilityEscape={onRemove}>
         <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
       </TouchableOpacity>
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx
index 9964359ac..632bb2634 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/composer/Prompt.tsx
@@ -22,7 +22,7 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
       onPress={() => onPressCompose()}
       accessibilityRole="button"
       accessibilityLabel={_(msg`Compose reply`)}
-      accessibilityHint="Opens composer">
+      accessibilityHint={_(msg`Opens composer`)}>
       <UserAvatar avatar={profile?.avatar} size={38} />
       <Text
         type="xl"
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 69f63c55f..a288e7310 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -58,7 +58,7 @@ export function OpenCameraBtn({gallery}: Props) {
       hitSlop={HITSLOP_10}
       accessibilityRole="button"
       accessibilityLabel={_(msg`Camera`)}
-      accessibilityHint="Opens camera on device">
+      accessibilityHint={_(msg`Opens camera on device`)}>
       <FontAwesomeIcon
         icon="camera"
         style={pal.link as FontAwesomeIconStyle}
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index af0a22b01..f7fa9502d 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -41,7 +41,7 @@ export function SelectPhotoBtn({gallery}: Props) {
       hitSlop={HITSLOP_10}
       accessibilityRole="button"
       accessibilityLabel={_(msg`Gallery`)}
-      accessibilityHint="Opens device photo gallery">
+      accessibilityHint={_(msg`Opens device photo gallery`)}>
       <FontAwesomeIcon
         icon={['far', 'image']}
         style={pal.link as FontAwesomeIconStyle}
diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx
new file mode 100644
index 000000000..987d89d36
--- /dev/null
+++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx
@@ -0,0 +1,101 @@
+import React, {useEffect, useState} from 'react'
+import {StyleSheet, View} from 'react-native'
+import lande from 'lande'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {Text} from '../../util/text/Text'
+import {Button} from '../../util/forms/Button'
+import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers'
+import {
+  toPostLanguages,
+  useLanguagePrefs,
+  useLanguagePrefsApi,
+} from '#/state/preferences/languages'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {s} from '#/lib/styles'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+
+// fallbacks for safari
+const onIdle = globalThis.requestIdleCallback || (cb => setTimeout(cb, 1))
+const cancelIdle = globalThis.cancelIdleCallback || clearTimeout
+
+export function SuggestedLanguage({text}: {text: string}) {
+  const [suggestedLanguage, setSuggestedLanguage] = useState<string>()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+
+  useEffect(() => {
+    const textTrimmed = text.trim()
+
+    // Don't run the language model on small posts, the results are likely
+    // to be inaccurate anyway.
+    if (textTrimmed.length < 40) {
+      setSuggestedLanguage(undefined)
+      return
+    }
+
+    const idle = onIdle(() => {
+      // Only select languages that have a high confidence and convert to code2
+      const result = lande(textTrimmed).filter(
+        ([lang, value]) => value >= 0.97 && code3ToCode2Strict(lang),
+      )
+
+      setSuggestedLanguage(
+        result.length > 0 ? code3ToCode2Strict(result[0][0]) : undefined,
+      )
+    })
+
+    return () => cancelIdle(idle)
+  }, [text])
+
+  return suggestedLanguage &&
+    !toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage) ? (
+    <View style={[pal.border, styles.infoBar]}>
+      <FontAwesomeIcon
+        icon="language"
+        style={pal.text as FontAwesomeIconStyle}
+        size={24}
+      />
+      <Text style={[pal.text, s.flex1]}>
+        <Trans>
+          Are you writing in{' '}
+          <Text type="sm-bold" style={pal.text}>
+            {codeToLanguageName(suggestedLanguage)}
+          </Text>
+          ?
+        </Trans>
+      </Text>
+
+      <Button
+        type="default"
+        onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)}
+        accessibilityLabel={_(
+          msg`Change post language to ${codeToLanguageName(suggestedLanguage)}`,
+        )}
+        accessibilityHint="">
+        <Text type="button" style={[pal.link, s.fw600]}>
+          <Trans>Yes</Trans>
+        </Text>
+      </Button>
+    </View>
+  ) : null
+}
+
+const styles = StyleSheet.create({
+  infoBar: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 10,
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 16,
+    paddingVertical: 12,
+    marginHorizontal: 10,
+    marginBottom: 10,
+  },
+})
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 7e39f6aed..3d0d5ab8d 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -32,6 +32,7 @@ import {POST_IMG_MAX} from 'lib/constants'
 export interface TextInputRef {
   focus: () => void
   blur: () => void
+  getCursorPosition: () => DOMRect | undefined
 }
 
 interface TextInputProps extends ComponentProps<typeof RNTextInput> {
@@ -74,6 +75,7 @@ export const TextInput = forwardRef(function TextInputImpl(
     blur: () => {
       textInput.current?.blur()
     },
+    getCursorPosition: () => undefined, // Not implemented on native
   }))
 
   const onChangeText = useCallback(
@@ -215,6 +217,7 @@ export const TextInput = forwardRef(function TextInputImpl(
         autoFocus={true}
         allowFontScaling
         multiline
+        scrollEnabled={false}
         numberOfLines={4}
         style={[
           pal.text,
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 206a3205b..f2012a630 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -9,7 +9,7 @@ import Hardbreak from '@tiptap/extension-hard-break'
 import {Mention} from '@tiptap/extension-mention'
 import {Paragraph} from '@tiptap/extension-paragraph'
 import {Placeholder} from '@tiptap/extension-placeholder'
-import {Text} from '@tiptap/extension-text'
+import {Text as TiptapText} from '@tiptap/extension-text'
 import isEqual from 'lodash.isequal'
 import {createSuggestion} from './web/Autocomplete'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
@@ -18,10 +18,16 @@ import {Emoji} from './web/EmojiPicker.web'
 import {LinkDecorator} from './web/LinkDecorator'
 import {generateJSON} from '@tiptap/html'
 import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {Portal} from '#/components/Portal'
+import {Text} from '../../util/text/Text'
+import {Trans} from '@lingui/macro'
+import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
 
 export interface TextInputRef {
   focus: () => void
   blur: () => void
+  getCursorPosition: () => DOMRect | undefined
 }
 
 interface TextInputProps {
@@ -52,7 +58,11 @@ export const TextInput = React.forwardRef(function TextInputImpl(
 ) {
   const autocomplete = useActorAutocompleteFn()
 
+  const pal = usePalette('default')
   const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
+
+  const [isDropping, setIsDropping] = React.useState(false)
+
   const extensions = React.useMemo(
     () => [
       Document,
@@ -67,7 +77,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       Placeholder.configure({
         placeholder,
       }),
-      Text,
+      TiptapText,
       History,
       Hardbreak,
     ],
@@ -87,6 +97,46 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     }
   }, [onPhotoPasted])
 
+  React.useEffect(() => {
+    const handleDrop = (event: DragEvent) => {
+      const transfer = event.dataTransfer
+      if (transfer) {
+        const items = transfer.items
+
+        getImageFromUri(items, (uri: string) => {
+          textInputWebEmitter.emit('photo-pasted', uri)
+        })
+      }
+
+      event.preventDefault()
+      setIsDropping(false)
+    }
+    const handleDragEnter = (event: DragEvent) => {
+      const transfer = event.dataTransfer
+
+      event.preventDefault()
+      if (transfer && transfer.types.includes('Files')) {
+        setIsDropping(true)
+      }
+    }
+    const handleDragLeave = (event: DragEvent) => {
+      event.preventDefault()
+      setIsDropping(false)
+    }
+
+    document.body.addEventListener('drop', handleDrop)
+    document.body.addEventListener('dragenter', handleDragEnter)
+    document.body.addEventListener('dragover', handleDragEnter)
+    document.body.addEventListener('dragleave', handleDragLeave)
+
+    return () => {
+      document.body.removeEventListener('drop', handleDrop)
+      document.body.removeEventListener('dragenter', handleDragEnter)
+      document.body.removeEventListener('dragover', handleDragEnter)
+      document.body.removeEventListener('dragleave', handleDragLeave)
+    }
+  }, [setIsDropping])
+
   const editor = useEditor(
     {
       extensions,
@@ -169,12 +219,35 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   React.useImperativeHandle(ref, () => ({
     focus: () => {}, // TODO
     blur: () => {}, // TODO
+    getCursorPosition: () => {
+      const pos = editor?.state.selection.$anchor.pos
+      return pos ? editor?.view.coordsAtPos(pos) : undefined
+    },
   }))
 
   return (
-    <View style={styles.container}>
-      <EditorContent editor={editor} />
-    </View>
+    <>
+      <View style={styles.container}>
+        <EditorContent editor={editor} />
+      </View>
+
+      {isDropping && (
+        <Portal>
+          <Animated.View
+            style={styles.dropContainer}
+            entering={FadeIn.duration(80)}
+            exiting={FadeOut.duration(80)}>
+            <View style={[pal.view, pal.border, styles.dropModal]}>
+              <Text
+                type="lg"
+                style={[pal.text, pal.borderDark, styles.dropText]}>
+                <Trans>Drop to add images</Trans>
+              </Text>
+            </View>
+          </Animated.View>
+        </Portal>
+      )}
+    </>
   )
 })
 
@@ -205,6 +278,33 @@ const styles = StyleSheet.create({
     marginLeft: 8,
     marginBottom: 10,
   },
+  dropContainer: {
+    backgroundColor: '#0007',
+    pointerEvents: 'none',
+    alignItems: 'center',
+    justifyContent: 'center',
+    // @ts-ignore web only -prf
+    position: 'fixed',
+    padding: 16,
+    top: 0,
+    bottom: 0,
+    left: 0,
+    right: 0,
+  },
+  dropModal: {
+    // @ts-ignore web only
+    boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
+    padding: 8,
+    borderWidth: 1,
+    borderRadius: 16,
+  },
+  dropText: {
+    paddingVertical: 44,
+    paddingHorizontal: 36,
+    borderStyle: 'dashed',
+    borderRadius: 8,
+    borderWidth: 2,
+  },
 })
 
 function getImageFromUri(
@@ -213,25 +313,25 @@ function getImageFromUri(
 ) {
   for (let index = 0; index < items.length; index++) {
     const item = items[index]
-    const {kind, type} = item
+    const type = item.type
 
     if (type === 'text/plain') {
+      console.log('hit')
       item.getAsString(async itemString => {
         if (isUriImage(itemString)) {
           const response = await fetch(itemString)
           const blob = await response.blob()
-          blobToDataUri(blob).then(callback, err => console.error(err))
+
+          if (blob.type.startsWith('image/')) {
+            blobToDataUri(blob).then(callback, err => console.error(err))
+          }
         }
       })
-    }
-
-    if (kind === 'file') {
+    } else if (type.startsWith('image/')) {
       const file = item.getAsFile()
 
-      if (file instanceof Blob) {
-        blobToDataUri(new Blob([file], {type: item.type})).then(callback, err =>
-          console.error(err),
-        )
+      if (file) {
+        blobToDataUri(file).then(callback, err => console.error(err))
       }
     }
   }
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index 51197b8e4..76058fed3 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -17,6 +17,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {useGrapheme} from '../hooks/useGrapheme'
+import {Trans} from '@lingui/macro'
 
 interface MentionListRef {
   onKeyDown: (props: SuggestionKeyDownProps) => boolean
@@ -187,7 +188,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
             })
           ) : (
             <Text type="sm" style={[pal.text, styles.noResult]}>
-              No result
+              <Trans>No result</Trans>
             </Text>
           )}
         </View>
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index f4b2d99b0..149362116 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -1,11 +1,17 @@
 import React from 'react'
 import Picker from '@emoji-mart/react'
-import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {
+  StyleSheet,
+  TouchableWithoutFeedback,
+  useWindowDimensions,
+  View,
+} from 'react-native'
 import {textInputWebEmitter} from '../TextInput.web'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useMediaQuery} from 'react-responsive'
+
+const HEIGHT_OFFSET = 40
+const WIDTH_OFFSET = 100
+const PICKER_HEIGHT = 435 + HEIGHT_OFFSET
+const PICKER_WIDTH = 350 + WIDTH_OFFSET
 
 export type Emoji = {
   aliases?: string[]
@@ -18,59 +24,87 @@ export type Emoji = {
   unified: string
 }
 
-export function EmojiPickerButton() {
-  const pal = usePalette('default')
-  const [open, setOpen] = React.useState(false)
-  const onOpenChange = (o: boolean) => {
-    setOpen(o)
-  }
-  const close = () => {
-    setOpen(false)
-  }
+export interface EmojiPickerState {
+  isOpen: boolean
+  pos: {top: number; left: number; right: number; bottom: number}
+}
 
-  return (
-    <DropdownMenu.Root open={open} onOpenChange={onOpenChange}>
-      <DropdownMenu.Trigger style={styles.trigger}>
-        <FontAwesomeIcon
-          icon={['far', 'face-smile']}
-          color={pal.colors.link}
-          size={22}
-        />
-      </DropdownMenu.Trigger>
-
-      <DropdownMenu.Portal>
-        <EmojiPicker close={close} />
-      </DropdownMenu.Portal>
-    </DropdownMenu.Root>
-  )
+interface IProps {
+  state: EmojiPickerState
+  close: () => void
 }
 
-export function EmojiPicker({close}: {close: () => void}) {
+export function EmojiPicker({state, close}: IProps) {
+  const {height, width} = useWindowDimensions()
+
+  const isShiftDown = React.useRef(false)
+
+  const position = React.useMemo(() => {
+    const fitsBelow = state.pos.top + PICKER_HEIGHT < height
+    const fitsAbove = PICKER_HEIGHT < state.pos.top
+    const placeOnLeft = PICKER_WIDTH < state.pos.left
+    const screenYMiddle = height / 2 - PICKER_HEIGHT / 2
+
+    if (fitsBelow) {
+      return {
+        top: state.pos.top + HEIGHT_OFFSET,
+      }
+    } else if (fitsAbove) {
+      return {
+        bottom: height - state.pos.bottom + HEIGHT_OFFSET,
+      }
+    } else {
+      return {
+        top: screenYMiddle,
+        left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined,
+        right: !placeOnLeft
+          ? width - state.pos.right - PICKER_WIDTH
+          : undefined,
+      }
+    }
+  }, [state.pos, height, width])
+
+  React.useEffect(() => {
+    if (!state.isOpen) return
+
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Shift') {
+        isShiftDown.current = true
+      }
+    }
+    const onKeyUp = (e: KeyboardEvent) => {
+      if (e.key === 'Shift') {
+        isShiftDown.current = false
+      }
+    }
+    window.addEventListener('keydown', onKeyDown, true)
+    window.addEventListener('keyup', onKeyUp, true)
+
+    return () => {
+      window.removeEventListener('keydown', onKeyDown, true)
+      window.removeEventListener('keyup', onKeyUp, true)
+    }
+  }, [state.isOpen])
+
   const onInsert = (emoji: Emoji) => {
     textInputWebEmitter.emit('emoji-inserted', emoji)
-    close()
+
+    if (!isShiftDown.current) {
+      close()
+    }
   }
-  const reducedPadding = useMediaQuery({query: '(max-height: 750px)'})
-  const noPadding = useMediaQuery({query: '(max-height: 550px)'})
-  const noPicker = useMediaQuery({query: '(max-height: 350px)'})
+
+  if (!state.isOpen) return null
 
   return (
-    // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
-    <TouchableWithoutFeedback onPress={close} accessibilityViewIsModal>
+    <TouchableWithoutFeedback
+      accessibilityRole="button"
+      onPress={close}
+      accessibilityViewIsModal>
       <View style={styles.mask}>
         {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
-        <TouchableWithoutFeedback
-          onPress={e => {
-            e.stopPropagation() // prevent event from bubbling up to the mask
-          }}>
-          <View
-            style={[
-              styles.picker,
-              {
-                paddingTop: noPadding ? 0 : reducedPadding ? 150 : 325,
-                display: noPicker ? 'none' : 'flex',
-              },
-            ]}>
+        <TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
+          <View style={[{position: 'absolute'}, position]}>
             <Picker
               data={async () => {
                 return (await import('./EmojiPickerData.json')).default
@@ -87,21 +121,14 @@ export function EmojiPicker({close}: {close: () => void}) {
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore web ony
+    position: 'fixed',
     top: 0,
     left: 0,
     right: 0,
     width: '100%',
     height: '100%',
-  },
-  trigger: {
-    backgroundColor: 'transparent',
-    // @ts-ignore web only -prf
-    border: 'none',
-    paddingTop: 4,
-    paddingLeft: 12,
-    paddingRight: 12,
-    cursor: 'pointer',
+    alignItems: 'center',
   },
   picker: {
     marginHorizontal: 'auto',
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index ef3958c9d..fc7218d5d 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -18,6 +18,7 @@ import {POST_IMG_MAX} from 'lib/constants'
 import {logger} from '#/logger'
 import {getAgent} from '#/state/session'
 import {useGetPost} from '#/state/queries/post'
+import {useFetchDid} from '#/state/queries/handle'
 
 export function useExternalLinkFetch({
   setQuote,
@@ -28,6 +29,7 @@ export function useExternalLinkFetch({
     undefined,
   )
   const getPost = useGetPost()
+  const fetchDid = useFetchDid()
 
   useEffect(() => {
     let aborted = false
@@ -55,7 +57,7 @@ export function useExternalLinkFetch({
           },
         )
       } else if (isBskyCustomFeedUrl(extLink.uri)) {
-        getFeedAsEmbed(getAgent(), extLink.uri).then(
+        getFeedAsEmbed(getAgent(), fetchDid, extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -73,7 +75,7 @@ export function useExternalLinkFetch({
           },
         )
       } else if (isBskyListUrl(extLink.uri)) {
-        getListAsEmbed(getAgent(), extLink.uri).then(
+        getListAsEmbed(getAgent(), fetchDid, extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -133,7 +135,7 @@ export function useExternalLinkFetch({
       })
     }
     return cleanup
-  }, [extLink, setQuote, getPost])
+  }, [extLink, setQuote, getPost, fetchDid])
 
   return {extLink, setExtLink}
 }
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 84d49e3b0..9595e77e5 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -174,6 +174,7 @@ export function FeedPage({
           feed={feed}
           feedParams={feedParams}
           pollInterval={POLL_FREQ}
+          disablePoll={hasNew}
           scrollElRef={scrollElRef}
           onScrolledDownChange={setIsScrolledDown}
           onHasNew={setHasNew}
@@ -197,7 +198,7 @@ export function FeedPage({
           onPress={onPressCompose}
           icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
           accessibilityRole="button"
-          accessibilityLabel={_(msg`New post`)}
+          accessibilityLabel={_(msg({message: `New post`, context: 'action'}))}
           accessibilityHint=""
         />
       )}
@@ -209,18 +210,9 @@ function useHeaderOffset() {
   const {isDesktop, isTablet} = useWebMediaQueries()
   const {fontScale} = useWindowDimensions()
   const {hasSession} = useSession()
-
-  if (isDesktop) {
+  if (isDesktop || isTablet) {
     return 0
   }
-  if (isTablet) {
-    if (hasSession) {
-      return 50
-    } else {
-      return 0
-    }
-  }
-
   if (hasSession) {
     const navBarPad = 16
     const navBarText = 21 * fontScale
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 99e2b474f..487163840 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -14,7 +14,7 @@ import * as Toast from 'view/com/util/Toast'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {logger} from '#/logger'
 import {useModalControls} from '#/state/modals'
-import {msg} from '@lingui/macro'
+import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {
   usePinFeedMutation,
@@ -108,9 +108,9 @@ export function FeedSourceCardLoaded({
           try {
             await removeFeed({uri: feed.uri})
             // await item.unsave()
-            Toast.show('Removed from my feeds')
+            Toast.show(_(msg`Removed from my feeds`))
           } catch (e) {
-            Toast.show('There was an issue contacting your server')
+            Toast.show(_(msg`There was an issue contacting your server`))
             logger.error('Failed to unsave feed', {error: e})
           }
         },
@@ -122,9 +122,9 @@ export function FeedSourceCardLoaded({
         } else {
           await saveFeed({uri: feed.uri})
         }
-        Toast.show('Added to my feeds')
+        Toast.show(_(msg`Added to my feeds`))
       } catch (e) {
-        Toast.show('There was an issue contacting your server')
+        Toast.show(_(msg`There was an issue contacting your server`))
         logger.error('Failed to save feed', {error: e})
       }
     }
@@ -164,7 +164,7 @@ export function FeedSourceCardLoaded({
             testID={`feed-${feedUri}-toggleSave`}
             disabled={isRemovePending}
             accessibilityRole="button"
-            accessibilityLabel={'Remove from my feeds'}
+            accessibilityLabel={_(msg`Remove from my feeds`)}
             accessibilityHint=""
             onPress={() => {
               openModal({
@@ -175,9 +175,11 @@ export function FeedSourceCardLoaded({
                   try {
                     await removeFeed({uri: feedUri})
                     // await item.unsave()
-                    Toast.show('Removed from my feeds')
+                    Toast.show(_(msg`Removed from my feeds`))
                   } catch (e) {
-                    Toast.show('There was an issue contacting your server')
+                    Toast.show(
+                      _(msg`There was an issue contacting your server`),
+                    )
                     logger.error('Failed to unsave feed', {error: e})
                   }
                 },
@@ -223,19 +225,22 @@ export function FeedSourceCardLoaded({
             {feed.displayName}
           </Text>
           <Text style={[pal.textLight]} numberOfLines={3}>
-            {feed.type === 'feed' ? 'Feed' : 'List'} by{' '}
-            {sanitizeHandle(feed.creatorHandle, '@')}
+            {feed.type === 'feed' ? (
+              <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
+            ) : (
+              <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
+            )}
           </Text>
         </View>
 
         {showSaveBtn && feed.type === 'feed' && (
-          <View>
+          <View style={[s.justifyCenter]}>
             <Pressable
               testID={`feed-${feed.displayName}-toggleSave`}
               disabled={isSavePending || isPinPending || isRemovePending}
               accessibilityRole="button"
               accessibilityLabel={
-                isSaved ? 'Remove from my feeds' : 'Add to my feeds'
+                isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`)
               }
               accessibilityHint=""
               onPress={onToggleSaved}
@@ -269,8 +274,10 @@ export function FeedSourceCardLoaded({
 
       {showLikes && feed.type === 'feed' ? (
         <Text type="sm-medium" style={[pal.text, pal.textLight]}>
-          Liked by {feed.likeCount || 0}{' '}
-          {pluralize(feed.likeCount || 0, 'user')}
+          <Trans>
+            Liked by {feed.likeCount || 0}{' '}
+            {pluralize(feed.likeCount || 0, 'user')}
+          </Trans>
         </Text>
       ) : null}
     </Pressable>
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index 8665fbfac..f558eb18c 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -9,13 +9,14 @@ import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
 import {logger} from '#/logger'
-import {Trans} from '@lingui/macro'
+import {Trans, msg} from '@lingui/macro'
 import {cleanError} from '#/lib/strings/errors'
 import {useTheme} from '#/lib/ThemeContext'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 import {hydrateFeedGenerator} from '#/state/queries/feed'
 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {isNative} from '#/platform/detection'
+import {useLingui} from '@lingui/react'
 
 const LOADING = {_reactKey: '__loading__'}
 const EMPTY = {_reactKey: '__empty__'}
@@ -43,6 +44,7 @@ export const ProfileFeedgens = React.forwardRef<
   ref,
 ) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const theme = useTheme()
   const [isPTRing, setIsPTRing] = React.useState(false)
   const opts = React.useMemo(() => ({enabled}), [enabled])
@@ -142,7 +144,9 @@ export const ProfileFeedgens = React.forwardRef<
       } else if (item === LOAD_MORE_ERROR_ITEM) {
         return (
           <LoadMoreRetryBtn
-            label="There was an issue fetching your lists. Tap here to try again."
+            label={_(
+              msg`There was an issue fetching your lists. Tap here to try again.`,
+            )}
             onPress={onPressRetryLoadMore}
           />
         )
@@ -162,7 +166,7 @@ export const ProfileFeedgens = React.forwardRef<
       }
       return null
     },
-    [error, refetch, onPressRetryLoadMore, pal, preferences],
+    [error, refetch, onPressRetryLoadMore, pal, preferences, _],
   )
 
   return (
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
index c806bc6a6..3401adaff 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
@@ -24,7 +24,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => (
       hitSlop={HIT_SLOP}
       accessibilityRole="button"
       accessibilityLabel={t`Close image`}
-      accessibilityHint="Closes viewer for header image"
+      accessibilityHint={t`Closes viewer for header image`}
       onAccessibilityEscape={onRequestClose}>
       <Text style={styles.closeText}>✕</Text>
     </TouchableOpacity>
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
index ea740ec91..003ad61ba 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -320,6 +320,7 @@ const ImageItem = ({
           accessibilityLabel={imageSrc.alt}
           accessibilityHint=""
           onLoad={() => setIsLoaded(true)}
+          cachePolicy="memory"
         />
       </GestureDetector>
     </Animated.View>
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index 8a18df33f..38f2c89c9 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {Pressable, StyleSheet, View} from 'react-native'
+import {LayoutAnimation, StyleSheet, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import ImageView from './ImageViewing'
 import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip'
@@ -15,6 +15,8 @@ import {
   ProfileImageLightbox,
   ImagesLightbox,
 } from '#/state/lightbox'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export function Lightbox() {
   const {activeLightbox} = useLightbox()
@@ -53,6 +55,7 @@ export function Lightbox() {
 }
 
 function LightboxFooter({imageIndex}: {imageIndex: number}) {
+  const {_} = useLingui()
   const {activeLightbox} = useLightbox()
   const [isAltExpanded, setAltExpanded] = React.useState(false)
   const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
@@ -60,12 +63,14 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
   const saveImageToAlbumWithToasts = React.useCallback(
     async (uri: string) => {
       if (!permissionResponse || permissionResponse.granted === false) {
-        Toast.show('Permission to access camera roll is required.')
+        Toast.show(_(msg`Permission to access camera roll is required.`))
         if (permissionResponse?.canAskAgain) {
           requestPermission()
         } else {
           Toast.show(
-            'Permission to access camera roll was denied. Please enable it in your system settings.',
+            _(
+              msg`Permission to access camera roll was denied. Please enable it in your system settings.`,
+            ),
           )
         }
         return
@@ -78,7 +83,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
         Toast.show(`Failed to save image: ${String(e)}`)
       }
     },
-    [permissionResponse, requestPermission],
+    [permissionResponse, requestPermission, _],
   )
 
   const lightbox = activeLightbox
@@ -100,15 +105,21 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
   return (
     <View style={[styles.footer]}>
       {altText ? (
-        <Pressable
-          onPress={() => setAltExpanded(!isAltExpanded)}
-          accessibilityRole="button">
+        <View accessibilityRole="button" style={styles.footerText}>
           <Text
-            style={[s.gray3, styles.footerText]}
-            numberOfLines={isAltExpanded ? undefined : 3}>
+            style={[s.gray3]}
+            numberOfLines={isAltExpanded ? undefined : 3}
+            selectable
+            onPress={() => {
+              LayoutAnimation.configureNext({
+                duration: 300,
+                update: {type: 'spring', springDamping: 0.7},
+              })
+              setAltExpanded(prev => !prev)
+            }}>
             {altText}
           </Text>
-        </Pressable>
+        </View>
       ) : null}
       <View style={styles.footerBtns}>
         <Button
@@ -117,7 +128,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
           onPress={() => saveImageToAlbumWithToasts(uri)}>
           <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
           <Text type="xl" style={s.white}>
-            Save
+            <Trans context="action">Save</Trans>
           </Text>
         </Button>
         <Button
@@ -126,7 +137,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
           onPress={() => shareImageModal({uri})}>
           <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
           <Text type="xl" style={s.white}>
-            Share
+            <Trans context="action">Share</Trans>
           </Text>
         </Button>
       </View>
diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx
index 45e1fa5a3..fb97c30a4 100644
--- a/src/view/com/lightbox/Lightbox.web.tsx
+++ b/src/view/com/lightbox/Lightbox.web.tsx
@@ -1,13 +1,17 @@
 import React, {useCallback, useEffect, useState} from 'react'
 import {
   Image,
+  ImageStyle,
   TouchableOpacity,
   TouchableWithoutFeedback,
   StyleSheet,
   View,
   Pressable,
 } from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
 import {colors, s} from 'lib/styles'
 import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
 import {Text} from '../util/text/Text'
@@ -19,6 +23,7 @@ import {
   ImagesLightbox,
   ProfileImageLightbox,
 } from '#/state/lightbox'
+import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 
 interface Img {
   uri: string
@@ -28,8 +33,10 @@ interface Img {
 export function Lightbox() {
   const {activeLightbox} = useLightbox()
   const {closeLightbox} = useLightboxControls()
+  const isActive = !!activeLightbox
+  useWebBodyScrollLock(isActive)
 
-  if (!activeLightbox) {
+  if (!isActive) {
     return null
   }
 
@@ -110,13 +117,13 @@ function LightboxInner({
         onPress={onClose}
         accessibilityRole="button"
         accessibilityLabel={_(msg`Close image viewer`)}
-        accessibilityHint="Exits image view"
+        accessibilityHint={_(msg`Exits image view`)}
         onAccessibilityEscape={onClose}>
         <View style={styles.imageCenterer}>
           <Image
             accessibilityIgnoresInvertColors
             source={imgs[index]}
-            style={styles.image}
+            style={styles.image as ImageStyle}
             accessibilityLabel={imgs[index].alt}
             accessibilityHint=""
           />
@@ -129,7 +136,7 @@ function LightboxInner({
               accessibilityHint="">
               <FontAwesomeIcon
                 icon="angle-left"
-                style={styles.icon}
+                style={styles.icon as FontAwesomeIconStyle}
                 size={40}
               />
             </TouchableOpacity>
@@ -143,7 +150,7 @@ function LightboxInner({
               accessibilityHint="">
               <FontAwesomeIcon
                 icon="angle-right"
-                style={styles.icon}
+                style={styles.icon as FontAwesomeIconStyle}
                 size={40}
               />
             </TouchableOpacity>
@@ -154,7 +161,9 @@ function LightboxInner({
         <View style={styles.footer}>
           <Pressable
             accessibilityLabel={_(msg`Expand alt text`)}
-            accessibilityHint="If alt text is long, toggles alt text expanded state"
+            accessibilityHint={_(
+              msg`If alt text is long, toggles alt text expanded state`,
+            )}
             onPress={() => {
               setAltExpanded(!isAltExpanded)
             }}>
@@ -176,7 +185,8 @@ function LightboxInner({
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore
+    position: 'fixed',
     top: 0,
     left: 0,
     width: '100%',
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
index 774e9e916..5750faec1 100644
--- a/src/view/com/lists/ListCard.tsx
+++ b/src/view/com/lists/ListCard.tsx
@@ -11,6 +11,7 @@ import {useSession} from '#/state/session'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
+import {Trans} from '@lingui/macro'
 
 export const ListCard = ({
   testID,
@@ -76,23 +77,40 @@ export const ListCard = ({
             {sanitizeDisplayName(list.name)}
           </Text>
           <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-            {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '}
+            {list.purpose === 'app.bsky.graph.defs#curatelist' &&
+              (list.creator.did === currentAccount?.did ? (
+                <Trans>User list by you</Trans>
+              ) : (
+                <Trans>
+                  User list by {sanitizeHandle(list.creator.handle, '@')}
+                </Trans>
+              ))}
             {list.purpose === 'app.bsky.graph.defs#modlist' &&
-              'Moderation list '}
-            by{' '}
-            {list.creator.did === currentAccount?.did
-              ? 'you'
-              : sanitizeHandle(list.creator.handle, '@')}
+              (list.creator.did === currentAccount?.did ? (
+                <Trans>Moderation list by you</Trans>
+              ) : (
+                <Trans>
+                  Moderation list by {sanitizeHandle(list.creator.handle, '@')}
+                </Trans>
+              ))}
           </Text>
-          {!!list.viewer?.muted && (
-            <View style={s.flexRow}>
+          <View style={s.flexRow}>
+            {list.viewer?.muted ? (
               <View style={[s.mt5, pal.btn, styles.pill]}>
                 <Text type="xs" style={pal.text}>
-                  Subscribed
+                  <Trans>Muted</Trans>
                 </Text>
               </View>
-            </View>
-          )}
+            ) : null}
+
+            {list.viewer?.blocked ? (
+              <View style={[s.mt5, pal.btn, styles.pill]}>
+                <Text type="xs" style={pal.text}>
+                  <Trans>Blocked</Trans>
+                </Text>
+              </View>
+            ) : null}
+          </View>
         </View>
         {renderButton ? (
           <View style={styles.layoutButton}>{renderButton()}</View>
diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx
index 932f4b512..212244cd8 100644
--- a/src/view/com/lists/ListMembers.tsx
+++ b/src/view/com/lists/ListMembers.tsx
@@ -20,6 +20,8 @@ import {logger} from '#/logger'
 import {useModalControls} from '#/state/modals'
 import {useSession} from '#/state/session'
 import {cleanError} from '#/lib/strings/errors'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const EMPTY_ITEM = {_reactKey: '__empty__'}
@@ -50,6 +52,7 @@ export function ListMembers({
   desktopFixedHeightOffset?: number
 }) {
   const {track} = useAnalytics()
+  const {_} = useLingui()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
   const {isMobile} = useWebMediaQueries()
   const {openModal} = useModalControls()
@@ -143,12 +146,12 @@ export function ListMembers({
         <Button
           testID={`user-${profile.handle}-editBtn`}
           type="default"
-          label="Edit"
+          label={_(msg({message: 'Edit', context: 'action'}))}
           onPress={() => onPressEditMembership(profile)}
         />
       )
     },
-    [isOwner, onPressEditMembership],
+    [isOwner, onPressEditMembership, _],
   )
 
   const renderItem = React.useCallback(
@@ -165,7 +168,9 @@ export function ListMembers({
       } else if (item === LOAD_MORE_ERROR_ITEM) {
         return (
           <LoadMoreRetryBtn
-            label="There was an issue fetching the list. Tap here to try again."
+            label={_(
+              msg`There was an issue fetching the list. Tap here to try again.`,
+            )}
             onPress={onPressRetryLoadMore}
           />
         )
@@ -191,6 +196,7 @@ export function ListMembers({
       onPressTryAgain,
       onPressRetryLoadMore,
       isMobile,
+      _,
     ],
   )
 
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index db981717f..89d6ab480 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -10,11 +10,12 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
 import {logger} from '#/logger'
-import {Trans} from '@lingui/macro'
+import {Trans, msg} from '@lingui/macro'
 import {cleanError} from '#/lib/strings/errors'
 import {useTheme} from '#/lib/ThemeContext'
 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {isNative} from '#/platform/detection'
+import {useLingui} from '@lingui/react'
 
 const LOADING = {_reactKey: '__loading__'}
 const EMPTY = {_reactKey: '__empty__'}
@@ -42,6 +43,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
     const pal = usePalette('default')
     const theme = useTheme()
     const {track} = useAnalytics()
+    const {_} = useLingui()
     const [isPTRing, setIsPTRing] = React.useState(false)
     const opts = React.useMemo(() => ({enabled}), [enabled])
     const {
@@ -149,7 +151,9 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
         } else if (item === LOAD_MORE_ERROR_ITEM) {
           return (
             <LoadMoreRetryBtn
-              label="There was an issue fetching your lists. Tap here to try again."
+              label={_(
+                msg`There was an issue fetching your lists. Tap here to try again.`,
+              )}
               onPress={onPressRetryLoadMore}
             />
           )
@@ -164,7 +168,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
           />
         )
       },
-      [error, refetch, onPressRetryLoadMore, pal],
+      [error, refetch, onPressRetryLoadMore, pal, _],
     )
 
     return (
diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx
index 812a36f45..7ec8268be 100644
--- a/src/view/com/modals/AddAppPasswords.tsx
+++ b/src/view/com/modals/AddAppPasswords.tsx
@@ -72,10 +72,10 @@ export function Component({}: {}) {
   const onCopy = React.useCallback(() => {
     if (appPassword) {
       Clipboard.setString(appPassword)
-      Toast.show('Copied to clipboard')
+      Toast.show(_(msg`Copied to clipboard`))
       setWasCopied(true)
     }
-  }, [appPassword])
+  }, [appPassword, _])
 
   const onDone = React.useCallback(() => {
     closeModal()
@@ -85,7 +85,9 @@ export function Component({}: {}) {
     // if name is all whitespace, we don't allow it
     if (!name || !name.trim()) {
       Toast.show(
-        'Please enter a name for your app password. All spaces is not allowed.',
+        _(
+          msg`Please enter a name for your app password. All spaces is not allowed.`,
+        ),
         'times',
       )
       return
@@ -93,14 +95,14 @@ export function Component({}: {}) {
     // if name is too short (under 4 chars), we don't allow it
     if (name.length < 4) {
       Toast.show(
-        'App Password names must be at least 4 characters long.',
+        _(msg`App Password names must be at least 4 characters long.`),
         'times',
       )
       return
     }
 
     if (passwords?.find(p => p.name === name)) {
-      Toast.show('This name is already in use', 'times')
+      Toast.show(_(msg`This name is already in use`), 'times')
       return
     }
 
@@ -109,11 +111,11 @@ export function Component({}: {}) {
       if (newPassword) {
         setAppPassword(newPassword.password)
       } else {
-        Toast.show('Failed to create app password.', 'times')
+        Toast.show(_(msg`Failed to create app password.`), 'times')
         // TODO: better error handling (?)
       }
     } catch (e) {
-      Toast.show('Failed to create app password.', 'times')
+      Toast.show(_(msg`Failed to create app password.`), 'times')
       logger.error('Failed to create app password', {error: e})
     }
   }
@@ -127,7 +129,9 @@ export function Component({}: {}) {
       setName(text)
     } else {
       Toast.show(
-        'App Password names can only contain letters, numbers, spaces, dashes, and underscores.',
+        _(
+          msg`App Password names can only contain letters, numbers, spaces, dashes, and underscores.`,
+        ),
       )
     }
   }
@@ -158,7 +162,7 @@ export function Component({}: {}) {
               style={[styles.input, pal.text]}
               onChangeText={_onChangeText}
               value={name}
-              placeholder="Enter a name for this App Password"
+              placeholder={_(msg`Enter a name for this App Password`)}
               placeholderTextColor={pal.colors.textLight}
               autoCorrect={false}
               autoComplete="off"
@@ -175,7 +179,7 @@ export function Component({}: {}) {
               onEndEditing={createAppPassword}
               accessible={true}
               accessibilityLabel={_(msg`Name`)}
-              accessibilityHint="Input name for app password"
+              accessibilityHint={_(msg`Input name for app password`)}
             />
           </View>
         ) : (
@@ -184,7 +188,7 @@ export function Component({}: {}) {
             onPress={onCopy}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Copy`)}
-            accessibilityHint="Copies app password">
+            accessibilityHint={_(msg`Copies app password`)}>
             <Text type="2xl-bold" style={[pal.text]}>
               {appPassword}
             </Text>
@@ -221,7 +225,7 @@ export function Component({}: {}) {
       <View style={styles.btnContainer}>
         <Button
           type="primary"
-          label={!appPassword ? 'Create App Password' : 'Done'}
+          label={!appPassword ? _(msg`Create App Password`) : _(msg`Done`)}
           style={styles.btn}
           labelStyle={styles.btnLabel}
           onPress={!appPassword ? createAppPassword : onDone}
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index a2e918317..5156511d6 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -1,14 +1,12 @@
 import React, {useMemo, useCallback, useState} from 'react'
 import {
   ImageStyle,
-  KeyboardAvoidingView,
-  ScrollView,
   StyleSheet,
-  TextInput,
   TouchableOpacity,
   View,
   useWindowDimensions,
 } from 'react-native'
+import {ScrollView, TextInput} from './util'
 import {Image} from 'expo-image'
 import {usePalette} from 'lib/hooks/usePalette'
 import {gradients, s} from 'lib/styles'
@@ -17,13 +15,13 @@ import {MAX_ALT_TEXT} from 'lib/constants'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../util/text/Text'
 import LinearGradient from 'react-native-linear-gradient'
-import {isAndroid, isWeb} from 'platform/detection'
+import {isWeb} from 'platform/detection'
 import {ImageModel} from 'state/models/media/image'
 import {useLingui} from '@lingui/react'
 import {Trans, msg} from '@lingui/macro'
 import {useModalControls} from '#/state/modals'
 
-export const snapPoints = ['fullscreen']
+export const snapPoints = ['100%']
 
 interface Props {
   image: ImageModel
@@ -54,102 +52,86 @@ export function Component({image}: Props) {
     }
   }, [image, windim])
 
+  const onUpdate = useCallback(
+    (v: string) => {
+      v = enforceLen(v, MAX_ALT_TEXT)
+      setAltText(v)
+      image.setAltText(v)
+    },
+    [setAltText, image],
+  )
+
   const onPressSave = useCallback(() => {
     image.setAltText(altText)
     closeModal()
   }, [closeModal, image, altText])
 
-  const onPressCancel = () => {
-    closeModal()
-  }
-
   return (
-    <KeyboardAvoidingView
-      behavior={isAndroid ? 'height' : 'padding'}
-      style={[pal.view, styles.container]}>
-      <ScrollView
-        testID="altTextImageModal"
-        style={styles.scrollContainer}
-        keyboardShouldPersistTaps="always"
-        nativeID="imageAltText">
-        <View style={styles.scrollInner}>
-          <View style={[pal.viewLight, styles.imageContainer]}>
-            <Image
-              testID="selectedPhotoImage"
-              style={imageStyles}
-              source={{
-                uri: image.cropped?.path ?? image.path,
-              }}
-              contentFit="contain"
-              accessible={true}
-              accessibilityIgnoresInvertColors
-            />
-          </View>
-          <TextInput
-            testID="altTextImageInput"
-            style={[styles.textArea, pal.border, pal.text]}
-            keyboardAppearance={theme.colorScheme}
-            multiline
-            placeholder="Add alt text"
-            placeholderTextColor={pal.colors.textLight}
-            value={altText}
-            onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
-            accessibilityLabel={_(msg`Image alt text`)}
-            accessibilityHint=""
-            accessibilityLabelledBy="imageAltText"
-            autoFocus
+    <ScrollView
+      testID="altTextImageModal"
+      style={[pal.view, styles.scrollContainer]}
+      keyboardShouldPersistTaps="always"
+      nativeID="imageAltText">
+      <View style={styles.scrollInner}>
+        <View style={[pal.viewLight, styles.imageContainer]}>
+          <Image
+            testID="selectedPhotoImage"
+            style={imageStyles}
+            source={{
+              uri: image.cropped?.path ?? image.path,
+            }}
+            contentFit="contain"
+            accessible={true}
+            accessibilityIgnoresInvertColors
           />
-          <View style={styles.buttonControls}>
-            <TouchableOpacity
-              testID="altTextImageSaveBtn"
-              onPress={onPressSave}
-              accessibilityLabel={_(msg`Save alt text`)}
-              accessibilityHint={`Saves alt text, which reads: ${altText}`}
-              accessibilityRole="button">
-              <LinearGradient
-                colors={[gradients.blueLight.start, gradients.blueLight.end]}
-                start={{x: 0, y: 0}}
-                end={{x: 1, y: 1}}
-                style={[styles.button]}>
-                <Text type="button-lg" style={[s.white, s.bold]}>
-                  <Trans>Save</Trans>
-                </Text>
-              </LinearGradient>
-            </TouchableOpacity>
-            <TouchableOpacity
-              testID="altTextImageCancelBtn"
-              onPress={onPressCancel}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Cancel add image alt text`)}
-              accessibilityHint=""
-              onAccessibilityEscape={onPressCancel}>
-              <View style={[styles.button]}>
-                <Text type="button-lg" style={[pal.textLight]}>
-                  <Trans>Cancel</Trans>
-                </Text>
-              </View>
-            </TouchableOpacity>
-          </View>
         </View>
-      </ScrollView>
-    </KeyboardAvoidingView>
+        <TextInput
+          testID="altTextImageInput"
+          style={[styles.textArea, pal.border, pal.text]}
+          keyboardAppearance={theme.colorScheme}
+          multiline
+          placeholder={_(msg`Add alt text`)}
+          placeholderTextColor={pal.colors.textLight}
+          value={altText}
+          onChangeText={onUpdate}
+          accessibilityLabel={_(msg`Image alt text`)}
+          accessibilityHint=""
+          accessibilityLabelledBy="imageAltText"
+          autoFocus
+        />
+        <View style={styles.buttonControls}>
+          <TouchableOpacity
+            testID="altTextImageSaveBtn"
+            onPress={onPressSave}
+            accessibilityLabel={_(msg`Save alt text`)}
+            accessibilityHint=""
+            accessibilityRole="button">
+            <LinearGradient
+              colors={[gradients.blueLight.start, gradients.blueLight.end]}
+              start={{x: 0, y: 0}}
+              end={{x: 1, y: 1}}
+              style={[styles.button]}>
+              <Text type="button-lg" style={[s.white, s.bold]}>
+                <Trans>Done</Trans>
+              </Text>
+            </LinearGradient>
+          </TouchableOpacity>
+        </View>
+      </View>
+    </ScrollView>
   )
 }
 
 const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    height: '100%',
-    width: '100%',
-    paddingVertical: isWeb ? 0 : 18,
-  },
   scrollContainer: {
     flex: 1,
     height: '100%',
     paddingHorizontal: isWeb ? 0 : 12,
+    paddingVertical: isWeb ? 0 : 24,
   },
   scrollInner: {
     gap: 12,
+    paddingTop: isWeb ? 0 : 12,
   },
   imageContainer: {
     borderRadius: 8,
@@ -173,5 +155,6 @@ const styles = StyleSheet.create({
   },
   buttonControls: {
     gap: 8,
+    paddingBottom: isWeb ? 0 : 50,
   },
 })
diff --git a/src/view/com/modals/AppealLabel.tsx b/src/view/com/modals/AppealLabel.tsx
index edc6f4cd0..b0aaaf625 100644
--- a/src/view/com/modals/AppealLabel.tsx
+++ b/src/view/com/modals/AppealLabel.tsx
@@ -38,14 +38,14 @@ export function Component(props: ReportComponentProps) {
         ? 'com.atproto.repo.strongRef'
         : 'com.atproto.admin.defs#repoRef'
       await getAgent().createModerationReport({
-        reasonType: ComAtprotoModerationDefs.REASONOTHER,
+        reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
         subject: {
           $type,
           ...props,
         },
         reason: details,
       })
-      Toast.show("We'll look into your appeal promptly.")
+      Toast.show(_(msg`We'll look into your appeal promptly.`))
     } finally {
       closeModal()
     }
diff --git a/src/view/com/modals/BirthDateSettings.tsx b/src/view/com/modals/BirthDateSettings.tsx
index c78f06ed4..5ebc61137 100644
--- a/src/view/com/modals/BirthDateSettings.tsx
+++ b/src/view/com/modals/BirthDateSettings.tsx
@@ -23,7 +23,7 @@ import {
 } from '#/state/queries/preferences'
 import {logger} from '#/logger'
 
-export const snapPoints = ['50%']
+export const snapPoints = ['50%', '90%']
 
 function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
   const pal = usePalette('default')
@@ -63,6 +63,7 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
 
       <View>
         <DateInput
+          handleAsUTC
           testID="birthdayInput"
           value={date}
           onChange={setDate}
@@ -70,7 +71,7 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
           buttonStyle={[pal.border, styles.dateInputButton]}
           buttonLabelType="lg"
           accessibilityLabel={_(msg`Birthday`)}
-          accessibilityHint="Enter your birth date"
+          accessibilityHint={_(msg`Enter your birth date`)}
           accessibilityLabelledBy="birthDate"
         />
       </View>
diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx
index 44b102fa0..c5672bc81 100644
--- a/src/view/com/modals/ChangeEmail.tsx
+++ b/src/view/com/modals/ChangeEmail.tsx
@@ -38,7 +38,7 @@ export function Component() {
 
   const onRequestChange = async () => {
     if (email === currentAccount?.email) {
-      setError('Enter your new email above')
+      setError(_(msg`Enter your new email above`))
       return
     }
     setError('')
@@ -53,7 +53,7 @@ export function Component() {
           email: email.trim(),
           emailConfirmed: false,
         })
-        Toast.show('Email updated')
+        Toast.show(_(msg`Email updated`))
         setStage(Stages.Done)
       }
     } catch (e) {
@@ -85,7 +85,7 @@ export function Component() {
         email: email.trim(),
         emailConfirmed: false,
       })
-      Toast.show('Email updated')
+      Toast.show(_(msg`Email updated`))
       setStage(Stages.Done)
     } catch (e) {
       setError(cleanError(String(e)))
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index 31f6d6ea7..e578fa7da 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -147,7 +147,7 @@ export function Inner({
             onPress={onPressCancel}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Cancel change handle`)}
-            accessibilityHint="Exits handle change process"
+            accessibilityHint={_(msg`Exits handle change process`)}
             onAccessibilityEscape={onPressCancel}>
             <Text type="lg" style={pal.textLight}>
               Cancel
@@ -168,7 +168,7 @@ export function Inner({
               onPress={onPressSave}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Save handle change`)}
-              accessibilityHint={`Saves handle change to ${handle}`}>
+              accessibilityHint={_(msg`Saves handle change to ${handle}`)}>
               <Text type="2xl-medium" style={pal.link}>
                 <Trans>Save</Trans>
               </Text>
@@ -263,14 +263,16 @@ function ProvidedHandleForm({
           editable={!isProcessing}
           accessible={true}
           accessibilityLabel={_(msg`Handle`)}
-          accessibilityHint="Sets Bluesky username"
+          accessibilityHint={_(msg`Sets Bluesky username`)}
         />
       </View>
       <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}>
-        <Trans>Your full handle will be</Trans>{' '}
-        <Text type="md-bold" style={pal.textLight}>
-          @{createFullHandle(handle, userDomain)}
-        </Text>
+        <Trans>
+          Your full handle will be{' '}
+          <Text type="md-bold" style={pal.textLight}>
+            @{createFullHandle(handle, userDomain)}
+          </Text>
+        </Trans>
       </Text>
       <TouchableOpacity
         onPress={onToggleCustom}
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
index 5e869f396..307897fb8 100644
--- a/src/view/com/modals/Confirm.tsx
+++ b/src/view/com/modals/Confirm.tsx
@@ -12,7 +12,7 @@ import {cleanError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
+import {Trans, msg} from '@lingui/macro'
 import type {ConfirmModal} from '#/state/modals'
 import {useModalControls} from '#/state/modals'
 
@@ -72,10 +72,10 @@ export function Component({
           onPress={onPress}
           style={[styles.btn, confirmBtnStyle]}
           accessibilityRole="button"
-          accessibilityLabel={_(msg`Confirm`)}
+          accessibilityLabel={_(msg({message: 'Confirm', context: 'action'}))}
           accessibilityHint="">
           <Text style={[s.white, s.bold, s.f18]}>
-            {confirmBtnText ?? 'Confirm'}
+            {confirmBtnText ?? <Trans context="action">Confirm</Trans>}
           </Text>
         </TouchableOpacity>
       )}
@@ -85,10 +85,10 @@ export function Component({
           onPress={onPressCancel}
           style={[styles.btnCancel, s.mt10]}
           accessibilityRole="button"
-          accessibilityLabel={_(msg`Cancel`)}
+          accessibilityLabel={_(msg({message: 'Cancel', context: 'action'}))}
           accessibilityHint="">
           <Text type="button-lg" style={pal.textLight}>
-            {cancelBtnText ?? 'Cancel'}
+            {cancelBtnText ?? <Trans context="action">Cancel</Trans>}
           </Text>
         </TouchableOpacity>
       )}
diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
index 8b42e1b1d..d681fbf0b 100644
--- a/src/view/com/modals/ContentFilteringSettings.tsx
+++ b/src/view/com/modals/ContentFilteringSettings.tsx
@@ -104,6 +104,7 @@ export function Component({}: {}) {
 
 function AdultContentEnabledPref() {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {data: preferences} = usePreferencesQuery()
   const {mutate, variables} = usePreferencesSetAdultContentMutation()
   const {openModal} = useModalControls()
@@ -121,36 +122,44 @@ function AdultContentEnabledPref() {
         enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
       })
     } catch (e) {
-      Toast.show('There was an issue syncing your preferences with the server')
+      Toast.show(
+        _(msg`There was an issue syncing your preferences with the server`),
+      )
       logger.error('Failed to update preferences with server', {error: e})
     }
-  }, [variables, preferences, mutate])
+  }, [variables, preferences, mutate, _])
 
   return (
     <View style={s.mb10}>
       {isIOS ? (
         preferences?.adultContentEnabled ? null : (
           <Text type="md" style={pal.textLight}>
-            Adult content can only be enabled via the Web at{' '}
-            <TextLink
-              style={pal.link}
-              href="https://bsky.app"
-              text="bsky.app"
-            />
-            .
+            <Trans>
+              Adult content can only be enabled via the Web at{' '}
+              <TextLink
+                style={pal.link}
+                href="https://bsky.app"
+                text="bsky.app"
+              />
+              .
+            </Trans>
           </Text>
         )
       ) : typeof preferences?.birthDate === 'undefined' ? (
         <View style={[pal.viewLight, styles.agePrompt]}>
           <Text type="md" style={[pal.text, {flex: 1}]}>
-            Confirm your age to enable adult content.
+            <Trans>Confirm your age to enable adult content.</Trans>
           </Text>
-          <Button type="primary" label="Set Age" onPress={onSetAge} />
+          <Button
+            type="primary"
+            label={_(msg({message: 'Set Age', context: 'action'}))}
+            onPress={onSetAge}
+          />
         </View>
       ) : (preferences.userAge || 0) >= 18 ? (
         <ToggleButton
           type="default-light"
-          label="Enable Adult Content"
+          label={_(msg`Enable Adult Content`)}
           isSelected={variables?.enabled ?? preferences?.adultContentEnabled}
           onPress={onToggleAdultContent}
           style={styles.toggleBtn}
@@ -158,9 +167,13 @@ function AdultContentEnabledPref() {
       ) : (
         <View style={[pal.viewLight, styles.agePrompt]}>
           <Text type="md" style={[pal.text, {flex: 1}]}>
-            You must be 18 or older to enable adult content.
+            <Trans>You must be 18 or older to enable adult content.</Trans>
           </Text>
-          <Button type="primary" label="Set Age" onPress={onSetAge} />
+          <Button
+            type="primary"
+            label={_(msg({message: 'Set Age', context: 'action'}))}
+            onPress={onSetAge}
+          />
         </View>
       )}
     </View>
@@ -203,7 +216,7 @@ function ContentLabelPref({
 
       {disabled || !visibility ? (
         <Text type="sm-bold" style={pal.textLight}>
-          Hide
+          <Trans context="action">Hide</Trans>
         </Text>
       ) : (
         <SelectGroup
@@ -223,12 +236,14 @@ interface SelectGroupProps {
 }
 
 function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
+  const {_} = useLingui()
+
   return (
     <View style={styles.selectableBtns}>
       <SelectableBtn
         current={current}
         value="hide"
-        label="Hide"
+        label={_(msg`Hide`)}
         left
         onChange={onChange}
         labelGroup={labelGroup}
@@ -236,14 +251,14 @@ function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
       <SelectableBtn
         current={current}
         value="warn"
-        label="Warn"
+        label={_(msg`Warn`)}
         onChange={onChange}
         labelGroup={labelGroup}
       />
       <SelectableBtn
         current={current}
         value="ignore"
-        label="Show"
+        label={_(msg`Show`)}
         right
         onChange={onChange}
         labelGroup={labelGroup}
@@ -273,6 +288,8 @@ function SelectableBtn({
 }: SelectableBtnProps) {
   const pal = usePalette('default')
   const palPrimary = usePalette('inverted')
+  const {_} = useLingui()
+
   return (
     <Pressable
       style={[
@@ -285,7 +302,9 @@ function SelectableBtn({
       onPress={() => onChange(value)}
       accessibilityRole="button"
       accessibilityLabel={value}
-      accessibilityHint={`Set ${value} for ${labelGroup} content moderation policy`}>
+      accessibilityHint={_(
+        msg`Set ${value} for ${labelGroup} content moderation policy`,
+      )}>
       <Text style={current === value ? palPrimary.text : pal.text}>
         {label}
       </Text>
diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx
index 8d13cdf2f..0e11fcffd 100644
--- a/src/view/com/modals/CreateOrEditList.tsx
+++ b/src/view/com/modals/CreateOrEditList.tsx
@@ -8,7 +8,11 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {AppBskyGraphDefs} from '@atproto/api'
+import {
+  AppBskyGraphDefs,
+  AppBskyRichtextFacet,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import LinearGradient from 'react-native-linear-gradient'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Text} from '../util/text/Text'
@@ -30,6 +34,9 @@ import {
   useListCreateMutation,
   useListMetadataMutation,
 } from '#/state/queries/list'
+import {richTextToString} from '#/lib/strings/rich-text-helpers'
+import {shortenLinks} from '#/lib/strings/rich-text-manip'
+import {getAgent} from '#/state/session'
 
 const MAX_NAME = 64 // todo
 const MAX_DESCRIPTION = 300 // todo
@@ -65,16 +72,45 @@ export function Component({
     return 'app.bsky.graph.defs#curatelist'
   }, [list, purpose])
   const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist'
-  const purposeLabel = isCurateList ? 'User' : 'Moderation'
 
   const [isProcessing, setProcessing] = useState<boolean>(false)
   const [name, setName] = useState<string>(list?.name || '')
-  const [description, setDescription] = useState<string>(
-    list?.description || '',
-  )
+
+  const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => {
+    const text = list?.description
+    const facets = list?.descriptionFacets
+
+    if (!text || !facets) {
+      return new RichTextAPI({text: text || ''})
+    }
+
+    // We want to be working with a blank state here, so let's get the
+    // serialized version and turn it back into a RichText
+    const serialized = richTextToString(new RichTextAPI({text, facets}), false)
+
+    const richText = new RichTextAPI({text: serialized})
+    richText.detectFacetsWithoutResolution()
+
+    return richText
+  })
+  const graphemeLength = useMemo(() => {
+    return shortenLinks(descriptionRt).graphemeLength
+  }, [descriptionRt])
+  const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
+
   const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
   const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
 
+  const onDescriptionChange = useCallback(
+    (newText: string) => {
+      const richText = new RichTextAPI({text: newText})
+      richText.detectFacetsWithoutResolution()
+
+      setDescriptionRt(richText)
+    },
+    [setDescriptionRt],
+  )
+
   const onPressCancel = useCallback(() => {
     closeModal()
   }, [closeModal])
@@ -106,7 +142,7 @@ export function Component({
     }
     const nameTrimmed = name.trim()
     if (!nameTrimmed) {
-      setError('Name is required')
+      setError(_(msg`Name is required`))
       return
     }
     setProcessing(true)
@@ -114,30 +150,61 @@ export function Component({
       setError('')
     }
     try {
+      let richText = new RichTextAPI(
+        {text: descriptionRt.text.trimEnd()},
+        {cleanNewlines: true},
+      )
+
+      await richText.detectFacets(getAgent())
+      richText = shortenLinks(richText)
+
+      // filter out any mention facets that didn't map to a user
+      richText.facets = richText.facets?.filter(facet => {
+        const mention = facet.features.find(feature =>
+          AppBskyRichtextFacet.isMention(feature),
+        )
+        if (mention && !mention.did) {
+          return false
+        }
+        return true
+      })
+
       if (list) {
         await listMetadataMutation.mutateAsync({
           uri: list.uri,
           name: nameTrimmed,
-          description: description.trim(),
+          description: richText.text,
+          descriptionFacets: richText.facets,
           avatar: newAvatar,
         })
-        Toast.show(`${purposeLabel} list updated`)
+        Toast.show(
+          isCurateList
+            ? _(msg`User list updated`)
+            : _(msg`Moderation list updated`),
+        )
         onSave?.(list.uri)
       } else {
         const res = await listCreateMutation.mutateAsync({
           purpose: activePurpose,
           name,
-          description,
+          description: richText.text,
+          descriptionFacets: richText.facets,
           avatar: newAvatar,
         })
-        Toast.show(`${purposeLabel} list created`)
+        Toast.show(
+          isCurateList
+            ? _(msg`User list created`)
+            : _(msg`Moderation list created`),
+        )
         onSave?.(res.uri)
       }
       closeModal()
     } catch (e: any) {
       if (isNetworkError(e)) {
         setError(
-          'Failed to create the list. Check your internet connection and try again.',
+          _(
+            msg`Failed to create the list. Check your internet connection and try again.`,
+          ),
         )
       } else {
         setError(cleanError(e))
@@ -153,13 +220,13 @@ export function Component({
     closeModal,
     activePurpose,
     isCurateList,
-    purposeLabel,
     name,
-    description,
+    descriptionRt,
     newAvatar,
     list,
     listMetadataMutation,
     listCreateMutation,
+    _,
   ])
 
   return (
@@ -173,9 +240,17 @@ export function Component({
         ]}
         testID="createOrEditListModal">
         <Text style={[styles.title, pal.text]}>
-          <Trans>
-            {list ? 'Edit' : 'New'} {purposeLabel} List
-          </Trans>
+          {isCurateList ? (
+            list ? (
+              <Trans>Edit User List</Trans>
+            ) : (
+              <Trans>New User List</Trans>
+            )
+          ) : list ? (
+            <Trans>Edit Moderation List</Trans>
+          ) : (
+            <Trans>New Moderation List</Trans>
+          )}
         </Text>
         {error !== '' && (
           <View style={styles.errorContainer}>
@@ -195,14 +270,18 @@ export function Component({
         </View>
         <View style={styles.form}>
           <View>
-            <Text style={[styles.label, pal.text]} nativeID="list-name">
-              <Trans>List Name</Trans>
-            </Text>
+            <View style={styles.labelWrapper}>
+              <Text style={[styles.label, pal.text]} nativeID="list-name">
+                <Trans>List Name</Trans>
+              </Text>
+            </View>
             <TextInput
               testID="editNameInput"
               style={[styles.textInput, pal.border, pal.text]}
               placeholder={
-                isCurateList ? 'e.g. Great Posters' : 'e.g. Spammers'
+                isCurateList
+                  ? _(msg`e.g. Great Posters`)
+                  : _(msg`e.g. Spammers`)
               }
               placeholderTextColor={colors.gray4}
               value={name}
@@ -214,22 +293,30 @@ export function Component({
             />
           </View>
           <View style={s.pb10}>
-            <Text style={[styles.label, pal.text]} nativeID="list-description">
-              <Trans>Description</Trans>
-            </Text>
+            <View style={styles.labelWrapper}>
+              <Text
+                style={[styles.label, pal.text]}
+                nativeID="list-description">
+                <Trans>Description</Trans>
+              </Text>
+              <Text
+                style={[!isDescriptionOver ? pal.textLight : s.red3, s.f13]}>
+                {graphemeLength}/{MAX_DESCRIPTION}
+              </Text>
+            </View>
             <TextInput
               testID="editDescriptionInput"
               style={[styles.textArea, pal.border, pal.text]}
               placeholder={
                 isCurateList
-                  ? 'e.g. The posters who never miss.'
-                  : 'e.g. Users that repeatedly reply with ads.'
+                  ? _(msg`e.g. The posters who never miss.`)
+                  : _(msg`e.g. Users that repeatedly reply with ads.`)
               }
               placeholderTextColor={colors.gray4}
               keyboardAppearance={theme.colorScheme}
               multiline
-              value={description}
-              onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
+              value={descriptionRt.text}
+              onChangeText={onDescriptionChange}
               accessible={true}
               accessibilityLabel={_(msg`Description`)}
               accessibilityHint=""
@@ -243,7 +330,8 @@ export function Component({
           ) : (
             <TouchableOpacity
               testID="saveBtn"
-              style={s.mt10}
+              style={[s.mt10, isDescriptionOver && s.dimmed]}
+              disabled={isDescriptionOver}
               onPress={onPressSave}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Save`)}
@@ -252,9 +340,9 @@ export function Component({
                 colors={[gradients.blueLight.start, gradients.blueLight.end]}
                 start={{x: 0, y: 0}}
                 end={{x: 1, y: 1}}
-                style={[styles.btn]}>
+                style={styles.btn}>
                 <Text style={[s.white, s.bold]}>
-                  <Trans>Save</Trans>
+                  <Trans context="action">Save</Trans>
                 </Text>
               </LinearGradient>
             </TouchableOpacity>
@@ -269,7 +357,7 @@ export function Component({
             onAccessibilityEscape={onPressCancel}>
             <View style={[styles.btn]}>
               <Text style={[s.black, s.bold, pal.text]}>
-                <Trans>Cancel</Trans>
+                <Trans context="action">Cancel</Trans>
               </Text>
             </View>
           </TouchableOpacity>
@@ -286,12 +374,18 @@ const styles = StyleSheet.create({
     fontSize: 24,
     marginBottom: 18,
   },
-  label: {
-    fontWeight: 'bold',
+  labelWrapper: {
+    flexDirection: 'row',
+    gap: 8,
+    alignItems: 'center',
+    justifyContent: 'space-between',
     paddingHorizontal: 4,
     paddingBottom: 4,
     marginTop: 20,
   },
+  label: {
+    fontWeight: 'bold',
+  },
   form: {
     paddingHorizontal: 6,
   },
diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx
index ee16d46b3..945d7bc89 100644
--- a/src/view/com/modals/DeleteAccount.tsx
+++ b/src/view/com/modals/DeleteAccount.tsx
@@ -62,7 +62,7 @@ export function Component({}: {}) {
         password,
         token,
       })
-      Toast.show('Your account has been deleted')
+      Toast.show(_(msg`Your account has been deleted`))
       resetToTab('HomeTab')
       removeAccount(currentAccount)
       clearCurrentAccount()
@@ -125,7 +125,9 @@ export function Component({}: {}) {
                   onPress={onPressSendEmail}
                   accessibilityRole="button"
                   accessibilityLabel={_(msg`Send email`)}
-                  accessibilityHint="Sends email with confirmation code for account deletion">
+                  accessibilityHint={_(
+                    msg`Sends email with confirmation code for account deletion`,
+                  )}>
                   <LinearGradient
                     colors={[
                       gradients.blueLight.start,
@@ -135,7 +137,7 @@ export function Component({}: {}) {
                     end={{x: 1, y: 1}}
                     style={[styles.btn]}>
                     <Text type="button-lg" style={[s.white, s.bold]}>
-                      <Trans>Send Email</Trans>
+                      <Trans context="action">Send Email</Trans>
                     </Text>
                   </LinearGradient>
                 </TouchableOpacity>
@@ -147,7 +149,7 @@ export function Component({}: {}) {
                   accessibilityHint=""
                   onAccessibilityEscape={onCancel}>
                   <Text type="button-lg" style={pal.textLight}>
-                    <Trans>Cancel</Trans>
+                    <Trans context="action">Cancel</Trans>
                   </Text>
                 </TouchableOpacity>
               </>
@@ -158,7 +160,7 @@ export function Component({}: {}) {
             {/* TODO: Update this label to be more concise */}
             <Text
               type="lg"
-              style={styles.description}
+              style={[pal.text, styles.description]}
               nativeID="confirmationCode">
               <Trans>
                 Check your inbox for an email with the confirmation code to
@@ -174,9 +176,14 @@ export function Component({}: {}) {
               onChangeText={setConfirmCode}
               accessibilityLabelledBy="confirmationCode"
               accessibilityLabel={_(msg`Confirmation code`)}
-              accessibilityHint="Input confirmation code for account deletion"
+              accessibilityHint={_(
+                msg`Input confirmation code for account deletion`,
+              )}
             />
-            <Text type="lg" style={styles.description} nativeID="password">
+            <Text
+              type="lg"
+              style={[pal.text, styles.description]}
+              nativeID="password">
               <Trans>Please enter your password as well:</Trans>
             </Text>
             <TextInput
@@ -189,7 +196,7 @@ export function Component({}: {}) {
               onChangeText={setPassword}
               accessibilityLabelledBy="password"
               accessibilityLabel={_(msg`Password`)}
-              accessibilityHint="Input password for account deletion"
+              accessibilityHint={_(msg`Input password for account deletion`)}
             />
             {error ? (
               <View style={styles.mt20}>
@@ -220,7 +227,7 @@ export function Component({}: {}) {
                   accessibilityHint="Exits account deletion process"
                   onAccessibilityEscape={onCancel}>
                   <Text type="button-lg" style={pal.textLight}>
-                    <Trans>Cancel</Trans>
+                    <Trans context="action">Cancel</Trans>
                   </Text>
                 </TouchableOpacity>
               </>
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx
index 753907472..3b35ffee2 100644
--- a/src/view/com/modals/EditImage.tsx
+++ b/src/view/com/modals/EditImage.tsx
@@ -112,16 +112,16 @@ export const Component = observer(function EditImageImpl({
       // },
       {
         name: 'flip' as const,
-        label: 'Flip horizontal',
+        label: _(msg`Flip horizontal`),
         onPress: onFlipHorizontal,
       },
       {
         name: 'flip' as const,
-        label: 'Flip vertically',
+        label: _(msg`Flip vertically`),
         onPress: onFlipVertical,
       },
     ],
-    [onFlipHorizontal, onFlipVertical],
+    [onFlipHorizontal, onFlipVertical, _],
   )
 
   useEffect(() => {
@@ -284,7 +284,7 @@ export const Component = observer(function EditImageImpl({
                   size={label?.startsWith('Flip') ? 22 : 24}
                   style={[
                     pal.text,
-                    label === 'Flip vertically'
+                    label === _(msg`Flip vertically`)
                       ? styles.flipVertical
                       : undefined,
                   ]}
@@ -330,7 +330,7 @@ export const Component = observer(function EditImageImpl({
             end={{x: 1, y: 1}}
             style={[styles.btn]}>
             <Text type="xl-medium" style={s.white}>
-              <Trans>Done</Trans>
+              <Trans context="action">Done</Trans>
             </Text>
           </LinearGradient>
         </Pressable>
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index e044f8c0e..dd8ac9ae7 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -125,7 +125,7 @@ export function Component({
         newUserAvatar,
         newUserBanner,
       })
-      Toast.show('Profile updated')
+      Toast.show(_(msg`Profile updated`))
       onUpdate?.()
       closeModal()
     } catch (e: any) {
@@ -142,6 +142,7 @@ export function Component({
     newUserAvatar,
     newUserBanner,
     setImageError,
+    _,
   ])
 
   return (
@@ -181,7 +182,7 @@ export function Component({
             <TextInput
               testID="editProfileDisplayNameInput"
               style={[styles.textInput, pal.border, pal.text]}
-              placeholder="e.g. Alice Roberts"
+              placeholder={_(msg`e.g. Alice Roberts`)}
               placeholderTextColor={colors.gray4}
               value={displayName}
               onChangeText={v =>
@@ -189,7 +190,7 @@ export function Component({
               }
               accessible={true}
               accessibilityLabel={_(msg`Display name`)}
-              accessibilityHint="Edit your display name"
+              accessibilityHint={_(msg`Edit your display name`)}
             />
           </View>
           <View style={s.pb10}>
@@ -199,7 +200,7 @@ export function Component({
             <TextInput
               testID="editProfileDescriptionInput"
               style={[styles.textArea, pal.border, pal.text]}
-              placeholder="e.g. Artist, dog-lover, and avid reader."
+              placeholder={_(msg`e.g. Artist, dog-lover, and avid reader.`)}
               placeholderTextColor={colors.gray4}
               keyboardAppearance={theme.colorScheme}
               multiline
@@ -207,7 +208,7 @@ export function Component({
               onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
               accessible={true}
               accessibilityLabel={_(msg`Description`)}
-              accessibilityHint="Edit your profile description"
+              accessibilityHint={_(msg`Edit your profile description`)}
             />
           </View>
           {updateMutation.isPending ? (
@@ -221,7 +222,7 @@ export function Component({
               onPress={onPressSave}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Save`)}
-              accessibilityHint="Saves any changes to your profile">
+              accessibilityHint={_(msg`Saves any changes to your profile`)}>
               <LinearGradient
                 colors={[gradients.blueLight.start, gradients.blueLight.end]}
                 start={{x: 0, y: 0}}
diff --git a/src/view/com/modals/EmbedConsent.tsx b/src/view/com/modals/EmbedConsent.tsx
new file mode 100644
index 000000000..04104c52e
--- /dev/null
+++ b/src/view/com/modals/EmbedConsent.tsx
@@ -0,0 +1,153 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+import {s, colors, gradients} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {ScrollView} from './util'
+import {usePalette} from 'lib/hooks/usePalette'
+import {
+  EmbedPlayerSource,
+  embedPlayerSources,
+  externalEmbedLabels,
+} from '#/lib/strings/embed-player'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useSetExternalEmbedPref} from '#/state/preferences/external-embeds-prefs'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+
+export const snapPoints = [450]
+
+export function Component({
+  onAccept,
+  source,
+}: {
+  onAccept: () => void
+  source: EmbedPlayerSource
+}) {
+  const pal = usePalette('default')
+  const {closeModal} = useModalControls()
+  const {_} = useLingui()
+  const setExternalEmbedPref = useSetExternalEmbedPref()
+  const {isMobile} = useWebMediaQueries()
+
+  const onShowAllPress = React.useCallback(() => {
+    for (const key of embedPlayerSources) {
+      setExternalEmbedPref(key, 'show')
+    }
+    onAccept()
+    closeModal()
+  }, [closeModal, onAccept, setExternalEmbedPref])
+
+  const onShowPress = React.useCallback(() => {
+    setExternalEmbedPref(source, 'show')
+    onAccept()
+    closeModal()
+  }, [closeModal, onAccept, setExternalEmbedPref, source])
+
+  const onHidePress = React.useCallback(() => {
+    setExternalEmbedPref(source, 'hide')
+    closeModal()
+  }, [closeModal, setExternalEmbedPref, source])
+
+  return (
+    <ScrollView
+      testID="embedConsentModal"
+      style={[
+        s.flex1,
+        pal.view,
+        isMobile
+          ? {paddingHorizontal: 20, paddingTop: 10}
+          : {paddingHorizontal: 30},
+      ]}>
+      <Text style={[pal.text, styles.title]}>
+        <Trans>External Media</Trans>
+      </Text>
+
+      <Text style={pal.text}>
+        <Trans>
+          This content is hosted by {externalEmbedLabels[source]}. Do you want
+          to enable external media?
+        </Trans>
+      </Text>
+      <View style={[s.mt10]} />
+      <Text style={pal.textLight}>
+        <Trans>
+          External media may allow websites to collect information about you and
+          your device. No information is sent or requested until you press the
+          "play" button.
+        </Trans>
+      </Text>
+      <View style={[s.mt20]} />
+      <TouchableOpacity
+        testID="enableAllBtn"
+        onPress={onShowAllPress}
+        accessibilityRole="button"
+        accessibilityLabel={_(
+          msg`Show embeds from ${externalEmbedLabels[source]}`,
+        )}
+        accessibilityHint=""
+        onAccessibilityEscape={closeModal}>
+        <LinearGradient
+          colors={[gradients.blueLight.start, gradients.blueLight.end]}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={[styles.btn]}>
+          <Text style={[s.white, s.bold, s.f18]}>
+            <Trans>Enable External Media</Trans>
+          </Text>
+        </LinearGradient>
+      </TouchableOpacity>
+      <View style={[s.mt10]} />
+      <TouchableOpacity
+        testID="enableSourceBtn"
+        onPress={onShowPress}
+        accessibilityRole="button"
+        accessibilityLabel={_(
+          msg`Never load embeds from ${externalEmbedLabels[source]}`,
+        )}
+        accessibilityHint=""
+        onAccessibilityEscape={closeModal}>
+        <View style={[styles.btn, pal.btn]}>
+          <Text style={[pal.text, s.bold, s.f18]}>
+            <Trans>Enable {externalEmbedLabels[source]} only</Trans>
+          </Text>
+        </View>
+      </TouchableOpacity>
+      <View style={[s.mt10]} />
+      <TouchableOpacity
+        testID="disableSourceBtn"
+        onPress={onHidePress}
+        accessibilityRole="button"
+        accessibilityLabel={_(
+          msg`Never load embeds from ${externalEmbedLabels[source]}`,
+        )}
+        accessibilityHint=""
+        onAccessibilityEscape={closeModal}>
+        <View style={[styles.btn, pal.btn]}>
+          <Text style={[pal.text, s.bold, s.f18]}>
+            <Trans>No thanks</Trans>
+          </Text>
+        </View>
+      </TouchableOpacity>
+    </ScrollView>
+  )
+}
+
+const styles = StyleSheet.create({
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 12,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.gray1,
+  },
+})
diff --git a/src/view/com/modals/InAppBrowserConsent.tsx b/src/view/com/modals/InAppBrowserConsent.tsx
new file mode 100644
index 000000000..86bb46ca8
--- /dev/null
+++ b/src/view/com/modals/InAppBrowserConsent.tsx
@@ -0,0 +1,102 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+
+import {s} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {ScrollView} from './util'
+import {usePalette} from 'lib/hooks/usePalette'
+
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {
+  useOpenLink,
+  useSetInAppBrowser,
+} from '#/state/preferences/in-app-browser'
+
+export const snapPoints = [350]
+
+export function Component({href}: {href: string}) {
+  const pal = usePalette('default')
+  const {closeModal} = useModalControls()
+  const {_} = useLingui()
+  const setInAppBrowser = useSetInAppBrowser()
+  const openLink = useOpenLink()
+
+  const onUseIAB = React.useCallback(() => {
+    setInAppBrowser(true)
+    closeModal()
+    openLink(href, true)
+  }, [closeModal, setInAppBrowser, href, openLink])
+
+  const onUseLinking = React.useCallback(() => {
+    setInAppBrowser(false)
+    closeModal()
+    openLink(href, false)
+  }, [closeModal, setInAppBrowser, href, openLink])
+
+  return (
+    <ScrollView
+      testID="inAppBrowserConsentModal"
+      style={[s.flex1, pal.view, {paddingHorizontal: 20, paddingTop: 10}]}>
+      <Text style={[pal.text, styles.title]}>
+        <Trans>How should we open this link?</Trans>
+      </Text>
+      <Text style={pal.text}>
+        <Trans>
+          Your choice will be saved, but can be changed later in settings.
+        </Trans>
+      </Text>
+      <View style={[styles.btnContainer]}>
+        <Button
+          testID="confirmBtn"
+          type="inverted"
+          onPress={onUseIAB}
+          accessibilityLabel={_(msg`Use in-app browser`)}
+          accessibilityHint=""
+          label={_(msg`Use in-app browser`)}
+          labelContainerStyle={{justifyContent: 'center', padding: 8}}
+          labelStyle={[s.f18]}
+        />
+        <Button
+          testID="confirmBtn"
+          type="inverted"
+          onPress={onUseLinking}
+          accessibilityLabel={_(msg`Use my default browser`)}
+          accessibilityHint=""
+          label={_(msg`Use my default browser`)}
+          labelContainerStyle={{justifyContent: 'center', padding: 8}}
+          labelStyle={[s.f18]}
+        />
+        <Button
+          testID="cancelBtn"
+          type="default"
+          onPress={() => {
+            closeModal()
+          }}
+          accessibilityLabel={_(msg`Cancel`)}
+          accessibilityHint=""
+          label="Cancel"
+          labelContainerStyle={{justifyContent: 'center', padding: 8}}
+          labelStyle={[s.f18]}
+        />
+      </View>
+    </ScrollView>
+  )
+}
+
+const styles = StyleSheet.create({
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 12,
+  },
+  btnContainer: {
+    marginTop: 20,
+    flexDirection: 'column',
+    justifyContent: 'center',
+    rowGap: 10,
+  },
+})
diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx
index 0ebb545cf..c0318df01 100644
--- a/src/view/com/modals/InviteCodes.tsx
+++ b/src/view/com/modals/InviteCodes.tsx
@@ -18,7 +18,7 @@ import {ScrollView} from './util'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {Trans} from '@lingui/macro'
+import {Trans, msg} from '@lingui/macro'
 import {cleanError} from 'lib/strings/errors'
 import {useModalControls} from '#/state/modals'
 import {useInvitesState, useInvitesAPI} from '#/state/invites'
@@ -30,6 +30,7 @@ import {
   useInviteCodesQuery,
   InviteCodesQueryResponse,
 } from '#/state/queries/invites'
+import {useLingui} from '@lingui/react'
 
 export const snapPoints = ['70%']
 
@@ -49,6 +50,7 @@ export function Component() {
 
 export function Inner({invites}: {invites: InviteCodesQueryResponse}) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {closeModal} = useModalControls()
   const {isTabletOrDesktop} = useWebMediaQueries()
 
@@ -75,7 +77,7 @@ export function Inner({invites}: {invites: InviteCodesQueryResponse}) {
           ]}>
           <Button
             type="primary"
-            label="Done"
+            label={_(msg`Done`)}
             style={styles.btn}
             labelStyle={styles.btnLabel}
             onPress={onClose}
@@ -118,7 +120,7 @@ export function Inner({invites}: {invites: InviteCodesQueryResponse}) {
         <Button
           testID="closeBtn"
           type="primary"
-          label="Done"
+          label={_(msg`Done`)}
           style={styles.btn}
           labelStyle={styles.btnLabel}
           onPress={onClose}
@@ -140,15 +142,16 @@ function InviteCode({
   invites: InviteCodesQueryResponse
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const invitesState = useInvitesState()
   const {setInviteCopied} = useInvitesAPI()
   const uses = invite.uses
 
   const onPress = React.useCallback(() => {
     Clipboard.setString(invite.code)
-    Toast.show('Copied to clipboard')
+    Toast.show(_(msg`Copied to clipboard`))
     setInviteCopied(invite.code)
-  }, [setInviteCopied, invite])
+  }, [setInviteCopied, invite, _])
 
   return (
     <View
@@ -163,10 +166,10 @@ function InviteCode({
         accessibilityRole="button"
         accessibilityLabel={
           invites.available.length === 1
-            ? 'Invite codes: 1 available'
-            : `Invite codes: ${invites.available.length} available`
+            ? _(msg`Invite codes: 1 available`)
+            : _(msg`Invite codes: ${invites.available.length} available`)
         }
-        accessibilityHint="Opens list of invite codes">
+        accessibilityHint={_(msg`Opens list of invite codes`)}>
         <Text
           testID={`${testID}-code`}
           type={used ? 'md' : 'md-bold'}
diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx
index 39e6cc3e6..81fdc7285 100644
--- a/src/view/com/modals/LinkWarning.tsx
+++ b/src/view/com/modals/LinkWarning.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {Linking, SafeAreaView, StyleSheet, View} from 'react-native'
+import {SafeAreaView, StyleSheet, View} from 'react-native'
 import {ScrollView} from './util'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
@@ -12,6 +12,7 @@ import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
+import {useOpenLink} from '#/state/preferences/in-app-browser'
 
 export const snapPoints = ['50%']
 
@@ -21,10 +22,11 @@ export function Component({text, href}: {text: string; href: string}) {
   const {isMobile} = useWebMediaQueries()
   const {_} = useLingui()
   const potentiallyMisleading = isPossiblyAUrl(text)
+  const openLink = useOpenLink()
 
   const onPressVisit = () => {
     closeModal()
-    Linking.openURL(href)
+    openLink(href)
   }
 
   return (
diff --git a/src/view/com/modals/ListAddRemoveUsers.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx
index 14e16d6bf..27c33f806 100644
--- a/src/view/com/modals/ListAddRemoveUsers.tsx
+++ b/src/view/com/modals/ListAddRemoveUsers.tsx
@@ -67,7 +67,7 @@ export function Component({
           <TextInput
             testID="searchInput"
             style={[styles.searchInput, pal.border, pal.text]}
-            placeholder="Search for users"
+            placeholder={_(msg`Search for users`)}
             placeholderTextColor={pal.colors.textLight}
             value={query}
             onChangeText={setQuery}
@@ -85,7 +85,7 @@ export function Component({
               onPress={onPressCancelSearch}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Cancel search`)}
-              accessibilityHint="Exits inputting search query"
+              accessibilityHint={_(msg`Exits inputting search query`)}
               onAccessibilityEscape={onPressCancelSearch}
               hitSlop={HITSLOP_20}>
               <FontAwesomeIcon
@@ -141,7 +141,7 @@ export function Component({
             }}
             accessibilityLabel={_(msg`Done`)}
             accessibilityHint=""
-            label="Done"
+            label={_(msg({message: 'Done', context: 'action'}))}
             labelContainerStyle={{justifyContent: 'center', padding: 4}}
             labelStyle={[s.f18]}
           />
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 2aac20dac..7f814d971 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -38,6 +38,8 @@ import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as SwitchAccountModal from './SwitchAccount'
 import * as LinkWarningModal from './LinkWarning'
+import * as EmbedConsentModal from './EmbedConsent'
+import * as InAppBrowserConsentModal from './InAppBrowserConsent'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 const HANDLE_HEIGHT = 24
@@ -176,6 +178,12 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'link-warning') {
     snapPoints = LinkWarningModal.snapPoints
     element = <LinkWarningModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'embed-consent') {
+    snapPoints = EmbedConsentModal.snapPoints
+    element = <EmbedConsentModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'in-app-browser-consent') {
+    snapPoints = InAppBrowserConsentModal.snapPoints
+    element = <InAppBrowserConsentModal.Component {...activeModal} />
   } else {
     return null
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 12138f54d..d79663746 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -3,6 +3,7 @@ import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 
 import {useModals, useModalControls} from '#/state/modals'
 import type {Modal as ModalIface} from '#/state/modals'
@@ -34,9 +35,11 @@ import * as BirthDateSettingsModal from './BirthDateSettings'
 import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as LinkWarningModal from './LinkWarning'
+import * as EmbedConsentModal from './EmbedConsent'
 
 export function ModalsContainer() {
   const {isModalActive, activeModals} = useModals()
+  useWebBodyScrollLock(isModalActive)
 
   if (!isModalActive) {
     return null
@@ -62,7 +65,11 @@ function Modal({modal}: {modal: ModalIface}) {
   }
 
   const onPressMask = () => {
-    if (modal.name === 'crop-image' || modal.name === 'edit-image') {
+    if (
+      modal.name === 'crop-image' ||
+      modal.name === 'edit-image' ||
+      modal.name === 'alt-text-image'
+    ) {
       return // dont close on mask presses during crop
     }
     closeModal()
@@ -129,6 +136,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ChangeEmailModal.Component />
   } else if (modal.name === 'link-warning') {
     element = <LinkWarningModal.Component {...modal} />
+  } else if (modal.name === 'embed-consent') {
+    element = <EmbedConsentModal.Component {...modal} />
   } else {
     return null
   }
@@ -159,7 +168,8 @@ function Modal({modal}: {modal: ModalIface}) {
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore
+    position: 'fixed',
     top: 0,
     left: 0,
     width: '100%',
diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx
index c117023d4..ba7f76db1 100644
--- a/src/view/com/modals/ModerationDetails.tsx
+++ b/src/view/com/modals/ModerationDetails.tsx
@@ -10,6 +10,8 @@ import {isWeb} from 'platform/detection'
 import {listUriToHref} from 'lib/strings/url-helpers'
 import {Button} from '../util/forms/Button'
 import {useModalControls} from '#/state/modals'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
 
 export const snapPoints = [300]
 
@@ -23,19 +25,21 @@ export function Component({
   const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
+  const {_} = useLingui()
 
   let name
   let description
   if (!moderation.cause) {
-    name = 'Content Warning'
-    description =
-      'Moderator has chosen to set a general warning on the content.'
+    name = _(msg`Content Warning`)
+    description = _(
+      msg`Moderator has chosen to set a general warning on the content.`,
+    )
   } else if (moderation.cause.type === 'blocking') {
     if (moderation.cause.source.type === 'list') {
       const list = moderation.cause.source.list
-      name = 'User Blocked by List'
+      name = _(msg`User Blocked by List`)
       description = (
-        <>
+        <Trans>
           This user is included in the{' '}
           <TextLink
             type="2xl"
@@ -44,25 +48,30 @@ export function Component({
             style={pal.link}
           />{' '}
           list which you have blocked.
-        </>
+        </Trans>
       )
     } else {
-      name = 'User Blocked'
-      description = 'You have blocked this user. You cannot view their content.'
+      name = _(msg`User Blocked`)
+      description = _(
+        msg`You have blocked this user. You cannot view their content.`,
+      )
     }
   } else if (moderation.cause.type === 'blocked-by') {
-    name = 'User Blocks You'
-    description = 'This user has blocked you. You cannot view their content.'
+    name = _(msg`User Blocks You`)
+    description = _(
+      msg`This user has blocked you. You cannot view their content.`,
+    )
   } else if (moderation.cause.type === 'block-other') {
-    name = 'Content Not Available'
-    description =
-      'This content is not available because one of the users involved has blocked the other.'
+    name = _(msg`Content Not Available`)
+    description = _(
+      msg`This content is not available because one of the users involved has blocked the other.`,
+    )
   } else if (moderation.cause.type === 'muted') {
     if (moderation.cause.source.type === 'list') {
       const list = moderation.cause.source.list
-      name = <>Account Muted by List</>
+      name = _(msg`Account Muted by List`)
       description = (
-        <>
+        <Trans>
           This user is included the{' '}
           <TextLink
             type="2xl"
@@ -71,11 +80,11 @@ export function Component({
             style={pal.link}
           />{' '}
           list which you have muted.
-        </>
+        </Trans>
       )
     } else {
-      name = 'Account Muted'
-      description = 'You have muted this user.'
+      name = _(msg`Account Muted`)
+      description = _(msg`You have muted this user.`)
     }
   } else {
     name = moderation.cause.labelDef.strings[context].en.name
diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx
index edfbf6a82..77e68db70 100644
--- a/src/view/com/modals/ProfilePreview.tsx
+++ b/src/view/com/modals/ProfilePreview.tsx
@@ -14,11 +14,14 @@ import {ErrorScreen} from '../util/error/ErrorScreen'
 import {CenteredView} from '../util/Views'
 import {cleanError} from '#/lib/strings/errors'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export const snapPoints = [520, '100%']
 
 export function Component({did}: {did: string}) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const moderationOpts = useModerationOpts()
   const {
     data: profile,
@@ -43,7 +46,7 @@ export function Component({did}: {did: string}) {
   if (profileError) {
     return (
       <ErrorScreen
-        title="Oops!"
+        title={_(msg`Oops!`)}
         message={cleanError(profileError)}
         onPressTryAgain={refetchProfile}
       />
@@ -55,8 +58,8 @@ export function Component({did}: {did: string}) {
   // should never happen
   return (
     <ErrorScreen
-      title="Oops!"
-      message="Something went wrong and we're not sure what."
+      title={_(msg`Oops!`)}
+      message={_(msg`Something went wrong and we're not sure what.`)}
       onPressTryAgain={refetchProfile}
     />
   )
@@ -104,7 +107,7 @@ function ComponentLoaded({
             <>
               <InfoCircleIcon size={21} style={pal.textLight} />
               <ThemedText type="xl" fg="light">
-                Swipe up to see more
+                <Trans>Swipe up to see more</Trans>
               </ThemedText>
             </>
           )}
diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx
index a72da29b4..6e4881adc 100644
--- a/src/view/com/modals/Repost.tsx
+++ b/src/view/com/modals/Repost.tsx
@@ -37,11 +37,23 @@ export function Component({
           style={[styles.actionBtn]}
           onPress={onRepost}
           accessibilityRole="button"
-          accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'}
-          accessibilityHint={isReposted ? 'Remove repost' : 'Repost '}>
+          accessibilityLabel={
+            isReposted
+              ? _(msg`Undo repost`)
+              : _(msg({message: `Repost`, context: 'action'}))
+          }
+          accessibilityHint={
+            isReposted
+              ? _(msg`Remove repost`)
+              : _(msg({message: `Repost`, context: 'action'}))
+          }>
           <RepostIcon strokeWidth={2} size={24} style={s.blue3} />
           <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
-            <Trans>{!isReposted ? 'Repost' : 'Undo repost'}</Trans>
+            {!isReposted ? (
+              <Trans context="action">Repost</Trans>
+            ) : (
+              <Trans>Undo repost</Trans>
+            )}
           </Text>
         </TouchableOpacity>
         <TouchableOpacity
@@ -49,11 +61,13 @@ export function Component({
           style={[styles.actionBtn]}
           onPress={onQuote}
           accessibilityRole="button"
-          accessibilityLabel={_(msg`Quote post`)}
+          accessibilityLabel={_(
+            msg({message: `Quote post`, context: 'action'}),
+          )}
           accessibilityHint="">
           <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} />
           <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
-            <Trans>Quote Post</Trans>
+            <Trans context="action">Quote Post</Trans>
           </Text>
         </TouchableOpacity>
       </View>
diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx
index 092dd2d32..779a9e71b 100644
--- a/src/view/com/modals/SelfLabel.tsx
+++ b/src/view/com/modals/SelfLabel.tsx
@@ -92,7 +92,7 @@ export function Component({
                   testID="sexualLabelBtn"
                   selected={selected.includes('sexual')}
                   left
-                  label="Suggestive"
+                  label={_(msg`Suggestive`)}
                   onSelect={() => toggleAdultLabel('sexual')}
                   accessibilityHint=""
                   style={s.flex1}
@@ -100,7 +100,7 @@ export function Component({
                 <SelectableBtn
                   testID="nudityLabelBtn"
                   selected={selected.includes('nudity')}
-                  label="Nudity"
+                  label={_(msg`Nudity`)}
                   onSelect={() => toggleAdultLabel('nudity')}
                   accessibilityHint=""
                   style={s.flex1}
@@ -108,7 +108,7 @@ export function Component({
                 <SelectableBtn
                   testID="pornLabelBtn"
                   selected={selected.includes('porn')}
-                  label="Porn"
+                  label={_(msg`Porn`)}
                   right
                   onSelect={() => toggleAdultLabel('porn')}
                   accessibilityHint=""
@@ -154,7 +154,7 @@ export function Component({
           accessibilityLabel={_(msg`Confirm`)}
           accessibilityHint="">
           <Text style={[s.white, s.bold, s.f18]}>
-            <Trans>Done</Trans>
+            <Trans context="action">Done</Trans>
           </Text>
         </TouchableOpacity>
       </View>
diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx
index b30293859..550dffa1c 100644
--- a/src/view/com/modals/ServerInput.tsx
+++ b/src/view/com/modals/ServerInput.tsx
@@ -101,7 +101,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
               onChangeText={setCustomUrl}
               accessibilityLabel={_(msg`Custom domain`)}
               // TODO: Simplify this wording further to be understandable by everyone
-              accessibilityHint="Use your domain as your Bluesky client service provider"
+              accessibilityHint={_(
+                msg`Use your domain as your Bluesky client service provider`,
+              )}
             />
             <TouchableOpacity
               testID="customServerSelectBtn"
@@ -110,7 +112,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
               accessibilityRole="button"
               accessibilityLabel={`Confirm service. ${
                 customUrl === ''
-                  ? 'Button disabled. Input custom domain to proceed.'
+                  ? _(msg`Button disabled. Input custom domain to proceed.`)
                   : ''
               }`}
               accessibilityHint=""
diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx
index 37691e717..c034c4b52 100644
--- a/src/view/com/modals/SwitchAccount.tsx
+++ b/src/view/com/modals/SwitchAccount.tsx
@@ -62,7 +62,9 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
           onPress={isSwitchingAccounts ? undefined : onPressSignout}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Sign out`)}
-          accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}>
+          accessibilityHint={_(
+            msg`Signs ${profile?.displayName} out of Bluesky`,
+          )}>
           <Text type="lg" style={pal.link}>
             <Trans>Sign out</Trans>
           </Text>
@@ -92,8 +94,8 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
         isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account)
       }
       accessibilityRole="button"
-      accessibilityLabel={`Switch to ${account.handle}`}
-      accessibilityHint="Switches the account you are logged in to">
+      accessibilityLabel={_(msg`Switch to ${account.handle}`)}
+      accessibilityHint={_(msg`Switches the account you are logged in to`)}>
       {contents}
     </TouchableOpacity>
   )
diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx
index 0deef185b..0e49fc2f3 100644
--- a/src/view/com/modals/Threadgate.tsx
+++ b/src/view/com/modals/Threadgate.tsx
@@ -126,10 +126,10 @@ export function Component({
           }}
           style={styles.btn}
           accessibilityRole="button"
-          accessibilityLabel={_(msg`Done`)}
+          accessibilityLabel={_(msg({message: `Done`, context: 'action'}))}
           accessibilityHint="">
           <Text style={[s.white, s.bold, s.f18]}>
-            <Trans>Done</Trans>
+            <Trans context="action">Done</Trans>
           </Text>
         </TouchableOpacity>
       </View>
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index c51f862cc..23adbe1a8 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -76,10 +76,10 @@ export function Component({
           type="default"
           onPress={onPressDone}
           style={styles.footerBtn}
-          accessibilityLabel={_(msg`Done`)}
+          accessibilityLabel={_(msg({message: `Done`, context: 'action'}))}
           accessibilityHint=""
           onAccessibilityEscape={onPressDone}
-          label="Done"
+          label={_(msg({message: `Done`, context: 'action'}))}
         />
       </View>
     </View>
@@ -175,12 +175,22 @@ function ListItem({
           {sanitizeDisplayName(list.name)}
         </Text>
         <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-          {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '}
-          {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '}
-          by{' '}
-          {list.creator.did === currentAccount?.did
-            ? 'you'
-            : sanitizeHandle(list.creator.handle, '@')}
+          {list.purpose === 'app.bsky.graph.defs#curatelist' &&
+            (list.creator.did === currentAccount?.did ? (
+              <Trans>User list by you</Trans>
+            ) : (
+              <Trans>
+                User list by {sanitizeHandle(list.creator.handle, '@')}
+              </Trans>
+            ))}
+          {list.purpose === 'app.bsky.graph.defs#modlist' &&
+            (list.creator.did === currentAccount?.did ? (
+              <Trans>Moderation list by you</Trans>
+            ) : (
+              <Trans>
+                Moderation list by {sanitizeHandle(list.creator.handle, '@')}
+              </Trans>
+            ))}
         </Text>
       </View>
       <View>
diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx
index 4f2b1aadf..30a57afc5 100644
--- a/src/view/com/modals/VerifyEmail.tsx
+++ b/src/view/com/modals/VerifyEmail.tsx
@@ -75,7 +75,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
         token: confirmationCode.trim(),
       })
       updateCurrentAccount({emailConfirmed: true})
-      Toast.show('Email verified')
+      Toast.show(_(msg`Email verified`))
       closeModal()
     } catch (e) {
       setError(cleanError(String(e)))
@@ -97,9 +97,15 @@ export function Component({showReminder}: {showReminder?: boolean}) {
         {stage === Stages.Reminder && <ReminderIllustration />}
         <View style={styles.titleSection}>
           <Text type="title-lg" style={[pal.text, styles.title]}>
-            {stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
-            {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
-            {stage === Stages.Email ? 'Verify Your Email' : ''}
+            {stage === Stages.Reminder ? (
+              <Trans>Please Verify Your Email</Trans>
+            ) : stage === Stages.Email ? (
+              <Trans>Verify Your Email</Trans>
+            ) : stage === Stages.ConfirmCode ? (
+              <Trans>Enter Confirmation Code</Trans>
+            ) : (
+              ''
+            )}
           </Text>
         </View>
 
@@ -133,7 +139,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
                 size={16}
               />
               <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}>
-                {currentAccount?.email || '(no email)'}
+                {currentAccount?.email || _(msg`(no email)`)}
               </Text>
             </View>
             <Pressable
@@ -182,7 +188,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
                   onPress={() => setStage(Stages.Email)}
                   accessibilityLabel={_(msg`Get Started`)}
                   accessibilityHint=""
-                  label="Get Started"
+                  label={_(msg`Get Started`)}
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
                   labelStyle={[s.f18]}
                 />
@@ -195,7 +201,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
                     onPress={onSendEmail}
                     accessibilityLabel={_(msg`Send Confirmation Email`)}
                     accessibilityHint=""
-                    label="Send Confirmation Email"
+                    label={_(msg`Send Confirmation Email`)}
                     labelContainerStyle={{
                       justifyContent: 'center',
                       padding: 4,
@@ -207,7 +213,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
                     type="default"
                     accessibilityLabel={_(msg`I have a code`)}
                     accessibilityHint=""
-                    label="I have a confirmation code"
+                    label={_(msg`I have a confirmation code`)}
                     labelContainerStyle={{
                       justifyContent: 'center',
                       padding: 4,
@@ -224,7 +230,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
                   onPress={onConfirm}
                   accessibilityLabel={_(msg`Confirm`)}
                   accessibilityHint=""
-                  label="Confirm"
+                  label={_(msg`Confirm`)}
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
                   labelStyle={[s.f18]}
                 />
@@ -236,10 +242,16 @@ export function Component({showReminder}: {showReminder?: boolean}) {
                   closeModal()
                 }}
                 accessibilityLabel={
-                  stage === Stages.Reminder ? 'Not right now' : 'Cancel'
+                  stage === Stages.Reminder
+                    ? _(msg`Not right now`)
+                    : _(msg`Cancel`)
                 }
                 accessibilityHint=""
-                label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
+                label={
+                  stage === Stages.Reminder
+                    ? _(msg`Not right now`)
+                    : _(msg`Cancel`)
+                }
                 labelContainerStyle={{justifyContent: 'center', padding: 4}}
                 labelStyle={[s.f18]}
               />
diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx
index a31545c0a..263dd27a2 100644
--- a/src/view/com/modals/Waitlist.tsx
+++ b/src/view/com/modals/Waitlist.tsx
@@ -48,7 +48,7 @@ export function Component({}: {}) {
       } else {
         setError(
           resBody.error ||
-            'Something went wrong. Check your email and try again.',
+            _(msg`Something went wrong. Check your email and try again.`),
         )
       }
     } catch (e: any) {
@@ -75,7 +75,7 @@ export function Component({}: {}) {
         </Text>
         <TextInput
           style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]}
-          placeholder="Enter your email"
+          placeholder={_(msg`Enter your email`)}
           placeholderTextColor={pal.textLight.color}
           autoCapitalize="none"
           autoCorrect={false}
@@ -86,7 +86,9 @@ export function Component({}: {}) {
           enterKeyHint="done"
           accessible={true}
           accessibilityLabel={_(msg`Email`)}
-          accessibilityHint="Input your email to get on the Bluesky waitlist"
+          accessibilityHint={_(
+            msg`Input your email to get on the Bluesky waitlist`,
+          )}
         />
         {error ? (
           <View style={s.mt10}>
@@ -114,7 +116,9 @@ export function Component({}: {}) {
             <TouchableOpacity
               onPress={onPressSignup}
               accessibilityRole="button"
-              accessibilityHint={`Confirms signing up ${email} to the waitlist`}>
+              accessibilityHint={_(
+                msg`Confirms signing up ${email} to the waitlist`,
+              )}>
               <LinearGradient
                 colors={[gradients.blueLight.start, gradients.blueLight.end]}
                 start={{x: 0, y: 0}}
@@ -130,7 +134,9 @@ export function Component({}: {}) {
               onPress={onCancel}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Cancel waitlist signup`)}
-              accessibilityHint={`Exits signing up for waitlist with ${email}`}
+              accessibilityHint={_(
+                msg`Exits signing up for waitlist with ${email}`,
+              )}
               onAccessibilityEscape={onCancel}>
               <Text type="button-lg" style={pal.textLight}>
                 <Trans>Cancel</Trans>
diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx
index 2f701b799..2bc86f75e 100644
--- a/src/view/com/modals/report/InputIssueDetails.tsx
+++ b/src/view/com/modals/report/InputIssueDetails.tsx
@@ -42,7 +42,8 @@ export function InputIssueDetails({
         accessibilityHint="Add more details to your report">
         <FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} />
         <Text style={[pal.text, s.f18, pal.link]}>
-          <Trans> Back</Trans>
+          {' '}
+          <Trans>Back</Trans>
         </Text>
       </TouchableOpacity>
       <View style={[pal.btn, styles.detailsInputContainer]}>
diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx
index 60c3f06b7..afd0d417d 100644
--- a/src/view/com/modals/report/Modal.tsx
+++ b/src/view/com/modals/report/Modal.tsx
@@ -44,9 +44,9 @@ export function Component(content: ReportComponentProps) {
   const {isMobile} = useWebMediaQueries()
   const [isProcessing, setIsProcessing] = useState(false)
   const [showDetailsInput, setShowDetailsInput] = useState(false)
-  const [error, setError] = useState<string>()
-  const [issue, setIssue] = useState<string>()
-  const [details, setDetails] = useState<string>()
+  const [error, setError] = useState<string>('')
+  const [issue, setIssue] = useState<string>('')
+  const [details, setDetails] = useState<string>('')
   const isAccountReport = 'did' in content
   const subjectKey = isAccountReport ? content.did : content.uri
   const atUri = useMemo(
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index a99fe2c1d..2088acbac 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -13,6 +13,8 @@ import {logger} from '#/logger'
 import {cleanError} from '#/lib/strings/errors'
 import {useModerationOpts} from '#/state/queries/preferences'
 import {List, ListRef} from '../util/List'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
@@ -31,6 +33,7 @@ export function Feed({
 }) {
   const [isPTRing, setIsPTRing] = React.useState(false)
 
+  const {_} = useLingui()
   const moderationOpts = useModerationOpts()
   const {checkUnread} = useUnreadNotificationsApi()
   const {
@@ -101,14 +104,16 @@ export function Feed({
         return (
           <EmptyState
             icon="bell"
-            message="No notifications yet!"
+            message={_(msg`No notifications yet!`)}
             style={styles.emptyState}
           />
         )
       } else if (item === LOAD_MORE_ERROR_ITEM) {
         return (
           <LoadMoreRetryBtn
-            label="There was an issue fetching notifications. Tap here to try again."
+            label={_(
+              msg`There was an issue fetching notifications. Tap here to try again.`,
+            )}
             onPress={onPressRetryLoadMore}
           />
         )
@@ -117,7 +122,7 @@ export function Feed({
       }
       return <FeedItem item={item} moderationOpts={moderationOpts!} />
     },
-    [onPressRetryLoadMore, moderationOpts],
+    [onPressRetryLoadMore, moderationOpts, _],
   )
 
   const FeedFooter = React.useCallback(
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 24b7e4fb6..0dfac2a83 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -65,6 +65,7 @@ let FeedItem = ({
   moderationOpts: ModerationOpts
 }): React.ReactNode => {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
   const itemHref = useMemo(() => {
     if (item.type === 'post-like' || item.type === 'repost') {
@@ -151,24 +152,24 @@ let FeedItem = ({
   let icon: Props['icon'] | 'HeartIconSolid'
   let iconStyle: Props['style'] = []
   if (item.type === 'post-like') {
-    action = 'liked your post'
+    action = _(msg`liked your post`)
     icon = 'HeartIconSolid'
     iconStyle = [
       s.likeColor as FontAwesomeIconStyle,
       {position: 'relative', top: -4},
     ]
   } else if (item.type === 'repost') {
-    action = 'reposted your post'
+    action = _(msg`reposted your post`)
     icon = 'retweet'
     iconStyle = [s.green3 as FontAwesomeIconStyle]
   } else if (item.type === 'follow') {
-    action = 'followed you'
+    action = _(msg`followed you`)
     icon = 'user-plus'
     iconStyle = [s.blue3 as FontAwesomeIconStyle]
   } else if (item.type === 'feedgen-like') {
-    action = `liked your custom feed${
-      item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}'` : ''
-    }`
+    action = item.subjectUri
+      ? _(msg`liked your custom feed '${new AtUri(item.subjectUri).rkey}'`)
+      : _(msg`liked your custom feed`)
     icon = 'HeartIconSolid'
     iconStyle = [
       s.likeColor as FontAwesomeIconStyle,
@@ -314,14 +315,16 @@ function CondensedAuthorsList({
           onPress={onToggleAuthorsExpanded}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Hide user list`)}
-          accessibilityHint="Collapses list of users for a given notification">
+          accessibilityHint={_(
+            msg`Collapses list of users for a given notification`,
+          )}>
           <FontAwesomeIcon
             icon="angle-up"
             size={18}
             style={[styles.expandedAuthorsCloseBtnIcon, pal.text]}
           />
           <Text type="sm-medium" style={pal.text}>
-            <Trans>Hide</Trans>
+            <Trans context="action">Hide</Trans>
           </Text>
         </TouchableOpacity>
       </View>
@@ -343,7 +346,9 @@ function CondensedAuthorsList({
   return (
     <TouchableOpacity
       accessibilityLabel={_(msg`Show users`)}
-      accessibilityHint="Opens an expanded list of users in this notification"
+      accessibilityHint={_(
+        msg`Opens an expanded list of users in this notification`,
+      )}
       onPress={onToggleAuthorsExpanded}>
       <View style={styles.avis}>
         {authors.slice(0, MAX_AUTHORS).map(author => (
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 57c83f17c..385da5544 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -117,7 +117,7 @@ function FeedsTabBarTablet(
   return (
     // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
     <Animated.View
-      style={[pal.view, styles.tabBar, headerMinimalShellTransform]}
+      style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
       onLayout={e => {
         headerHeight.value = e.nativeEvent.layout.height
       }}>
@@ -134,13 +134,16 @@ function FeedsTabBarTablet(
 
 const styles = StyleSheet.create({
   tabBar: {
-    position: 'absolute',
+    // @ts-ignore Web only
+    position: 'sticky',
     zIndex: 1,
     // @ts-ignore Web only -prf
-    left: 'calc(50% - 299px)',
-    width: 598,
+    left: 'calc(50% - 300px)',
+    width: 600,
     top: 0,
     flexDirection: 'row',
     alignItems: 'center',
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
   },
 })
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 024f9bfab..b9959a6d9 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -20,6 +20,11 @@ import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
 import {Logo} from '#/view/icons/Logo'
 
+import {IS_DEV} from '#/env'
+import {atoms} from '#/alf'
+import {Link as Link2} from '#/components/Link'
+import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette'
+
 export function FeedsTabBar(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
@@ -68,13 +73,15 @@ export function FeedsTabBar(
         headerHeight.value = e.nativeEvent.layout.height
       }}>
       <View style={[pal.view, styles.topBar]}>
-        <View style={[pal.view]}>
+        <View style={[pal.view, {width: 100}]}>
           <TouchableOpacity
             testID="viewHeaderDrawerBtn"
             onPress={onPressAvi}
             accessibilityRole="button"
             accessibilityLabel={_(msg`Open navigation`)}
-            accessibilityHint="Access profile and other navigation links"
+            accessibilityHint={_(
+              msg`Access profile and other navigation links`,
+            )}
             hitSlop={HITSLOP_10}>
             <FontAwesomeIcon
               icon="bars"
@@ -86,7 +93,21 @@ export function FeedsTabBar(
         <View>
           <Logo width={30} />
         </View>
-        <View style={[pal.view, {width: 18}]}>
+        <View
+          style={[
+            atoms.flex_row,
+            atoms.justify_end,
+            atoms.align_center,
+            atoms.gap_md,
+            pal.view,
+            {width: 100},
+          ]}>
+          {IS_DEV && (
+            <Link2 to="/sys/debug">
+              <ColorPalette size="md" />
+            </Link2>
+          )}
+
           {hasSession && (
             <Link
               testID="viewHeaderHomeFeedPrefsBtn"
@@ -121,7 +142,8 @@ export function FeedsTabBar(
 
 const styles = StyleSheet.create({
   tabBar: {
-    position: 'absolute',
+    // @ts-ignore web-only
+    position: isWeb ? 'fixed' : 'absolute',
     zIndex: 1,
     left: 0,
     right: 0,
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index 61c3609f2..834b1c0d0 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -17,6 +17,7 @@ export interface PagerRef {
 export interface RenderTabBarFnProps {
   selectedPage: number
   onSelect?: (index: number) => void
+  tabBarAnchor?: JSX.Element | null | undefined // Ignored on native.
 }
 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
 
diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx
index 3b5e9164a..dde799e42 100644
--- a/src/view/com/pager/Pager.web.tsx
+++ b/src/view/com/pager/Pager.web.tsx
@@ -1,10 +1,12 @@
 import React from 'react'
+import {flushSync} from 'react-dom'
 import {View} from 'react-native'
 import {s} from 'lib/styles'
 
 export interface RenderTabBarFnProps {
   selectedPage: number
   onSelect?: (index: number) => void
+  tabBarAnchor?: JSX.Element
 }
 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
 
@@ -27,6 +29,8 @@ export const Pager = React.forwardRef(function PagerImpl(
   ref,
 ) {
   const [selectedPage, setSelectedPage] = React.useState(initialPage)
+  const scrollYs = React.useRef<Array<number | null>>([])
+  const anchorRef = React.useRef(null)
 
   React.useImperativeHandle(ref, () => ({
     setPage: (index: number) => setSelectedPage(index),
@@ -34,11 +38,36 @@ export const Pager = React.forwardRef(function PagerImpl(
 
   const onTabBarSelect = React.useCallback(
     (index: number) => {
-      setSelectedPage(index)
-      onPageSelected?.(index)
-      onPageSelecting?.(index)
+      const scrollY = window.scrollY
+      // We want to determine if the tabbar is already "sticking" at the top (in which
+      // case we should preserve and restore scroll), or if it is somewhere below in the
+      // viewport (in which case a scroll jump would be jarring). We determine this by
+      // measuring where the "anchor" element is (which we place just above the tabbar).
+      let anchorTop = anchorRef.current
+        ? (anchorRef.current as Element).getBoundingClientRect().top
+        : -scrollY // If there's no anchor, treat the top of the page as one.
+      const isSticking = anchorTop <= 5 // This would be 0 if browser scrollTo() was reliable.
+
+      if (isSticking) {
+        scrollYs.current[selectedPage] = window.scrollY
+      } else {
+        scrollYs.current[selectedPage] = null
+      }
+      flushSync(() => {
+        setSelectedPage(index)
+        onPageSelected?.(index)
+        onPageSelecting?.(index)
+      })
+      if (isSticking) {
+        const restoredScrollY = scrollYs.current[index]
+        if (restoredScrollY != null) {
+          window.scrollTo(0, restoredScrollY)
+        } else {
+          window.scrollTo(0, scrollY + anchorTop)
+        }
+      }
     },
-    [setSelectedPage, onPageSelected, onPageSelecting],
+    [selectedPage, setSelectedPage, onPageSelected, onPageSelecting],
   )
 
   return (
@@ -46,21 +75,11 @@ export const Pager = React.forwardRef(function PagerImpl(
       {tabBarPosition === 'top' &&
         renderTabBar({
           selectedPage,
+          tabBarAnchor: <View ref={anchorRef} />,
           onSelect: onTabBarSelect,
         })}
       {React.Children.map(children, (child, i) => (
-        <View
-          style={
-            selectedPage === i
-              ? s.flex1
-              : {
-                  position: 'absolute',
-                  pointerEvents: 'none',
-                  // @ts-ignore web-only
-                  visibility: 'hidden',
-                }
-          }
-          key={`page-${i}`}>
+        <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
           {child}
         </View>
       ))}
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 158940d67..279b607ad 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -18,7 +18,6 @@ import Animated, {
 } from 'react-native-reanimated'
 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {TabBar} from './TabBar'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {ListMethods} from '../util/List'
 import {ScrollProvider} from '#/lib/ScrollContext'
@@ -235,7 +234,6 @@ let PagerTabBar = ({
   onCurrentPageSelected?: (index: number) => void
   onSelect?: (index: number) => void
 }): React.ReactNode => {
-  const {isMobile} = useWebMediaQueries()
   const headerTransform = useAnimatedStyle(() => ({
     transform: [
       {
@@ -246,10 +244,7 @@ let PagerTabBar = ({
   return (
     <Animated.View
       pointerEvents="box-none"
-      style={[
-        isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
-        headerTransform,
-      ]}>
+      style={[styles.tabBarMobile, headerTransform]}>
       <View onLayout={onHeaderOnlyLayout} pointerEvents="box-none">
         {renderHeader?.()}
       </View>
@@ -325,14 +320,6 @@ const styles = StyleSheet.create({
     left: 0,
     width: '100%',
   },
-  tabBarDesktop: {
-    position: 'absolute',
-    zIndex: 1,
-    top: 0,
-    // @ts-ignore Web only -prf
-    left: 'calc(50% - 299px)',
-    width: 598,
-  },
 })
 
 function noop() {
diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx
new file mode 100644
index 000000000..0a18a9e7d
--- /dev/null
+++ b/src/view/com/pager/PagerWithHeader.web.tsx
@@ -0,0 +1,194 @@
+import * as React from 'react'
+import {FlatList, ScrollView, StyleSheet, View} from 'react-native'
+import {useAnimatedRef} from 'react-native-reanimated'
+import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
+import {TabBar} from './TabBar'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {ListMethods} from '../util/List'
+
+export interface PagerWithHeaderChildParams {
+  headerHeight: number
+  isFocused: boolean
+  scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null>
+}
+
+export interface PagerWithHeaderProps {
+  testID?: string
+  children:
+    | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
+    | ((props: PagerWithHeaderChildParams) => JSX.Element)
+  items: string[]
+  isHeaderReady: boolean
+  renderHeader?: () => JSX.Element
+  initialPage?: number
+  onPageSelected?: (index: number) => void
+  onCurrentPageSelected?: (index: number) => void
+}
+export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
+  function PageWithHeaderImpl(
+    {
+      children,
+      testID,
+      items,
+      renderHeader,
+      initialPage,
+      onPageSelected,
+      onCurrentPageSelected,
+    }: PagerWithHeaderProps,
+    ref,
+  ) {
+    const [currentPage, setCurrentPage] = React.useState(0)
+
+    const renderTabBar = React.useCallback(
+      (props: RenderTabBarFnProps) => {
+        return (
+          <PagerTabBar
+            items={items}
+            renderHeader={renderHeader}
+            currentPage={currentPage}
+            onCurrentPageSelected={onCurrentPageSelected}
+            onSelect={props.onSelect}
+            tabBarAnchor={props.tabBarAnchor}
+            testID={testID}
+          />
+        )
+      },
+      [items, renderHeader, currentPage, onCurrentPageSelected, testID],
+    )
+
+    const onPageSelectedInner = React.useCallback(
+      (index: number) => {
+        setCurrentPage(index)
+        onPageSelected?.(index)
+      },
+      [onPageSelected, setCurrentPage],
+    )
+
+    const onPageSelecting = React.useCallback((index: number) => {
+      setCurrentPage(index)
+    }, [])
+
+    return (
+      <Pager
+        ref={ref}
+        testID={testID}
+        initialPage={initialPage}
+        onPageSelected={onPageSelectedInner}
+        onPageSelecting={onPageSelecting}
+        renderTabBar={renderTabBar}
+        tabBarPosition="top">
+        {toArray(children)
+          .filter(Boolean)
+          .map((child, i) => {
+            return (
+              <View key={i} collapsable={false}>
+                <PagerItem isFocused={i === currentPage} renderTab={child} />
+              </View>
+            )
+          })}
+      </Pager>
+    )
+  },
+)
+
+let PagerTabBar = ({
+  currentPage,
+  items,
+  testID,
+  renderHeader,
+  onCurrentPageSelected,
+  onSelect,
+  tabBarAnchor,
+}: {
+  currentPage: number
+  items: string[]
+  testID?: string
+  renderHeader?: () => JSX.Element
+  onCurrentPageSelected?: (index: number) => void
+  onSelect?: (index: number) => void
+  tabBarAnchor?: JSX.Element | null | undefined
+}): React.ReactNode => {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  return (
+    <>
+      <View style={[!isMobile && styles.headerContainerDesktop, pal.border]}>
+        {renderHeader?.()}
+      </View>
+      {tabBarAnchor}
+      <View
+        style={[
+          styles.tabBarContainer,
+          isMobile
+            ? styles.tabBarContainerMobile
+            : styles.tabBarContainerDesktop,
+          pal.border,
+        ]}>
+        <TabBar
+          testID={testID}
+          items={items}
+          selectedPage={currentPage}
+          onSelect={onSelect}
+          onPressSelected={onCurrentPageSelected}
+        />
+      </View>
+    </>
+  )
+}
+PagerTabBar = React.memo(PagerTabBar)
+
+function PagerItem({
+  isFocused,
+  renderTab,
+}: {
+  isFocused: boolean
+  renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null
+}) {
+  const scrollElRef = useAnimatedRef()
+  if (renderTab == null) {
+    return null
+  }
+  return renderTab({
+    headerHeight: 0,
+    isFocused,
+    scrollElRef: scrollElRef as React.MutableRefObject<
+      ListMethods | ScrollView | null
+    >,
+  })
+}
+
+const styles = StyleSheet.create({
+  headerContainerDesktop: {
+    marginLeft: 'auto',
+    marginRight: 'auto',
+    width: 600,
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  tabBarContainer: {
+    // @ts-ignore web-only
+    position: 'sticky',
+    overflow: 'hidden',
+    top: 0,
+    zIndex: 1,
+  },
+  tabBarContainerDesktop: {
+    marginLeft: 'auto',
+    marginRight: 'auto',
+    width: 600,
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  tabBarContainerMobile: {
+    paddingLeft: 14,
+    paddingRight: 14,
+  },
+})
+
+function toArray<T>(v: T | T[]): T[] {
+  if (Array.isArray(v)) {
+    return v
+  }
+  return [v]
+}
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 6cd1f3551..072ef7e33 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -36,11 +36,13 @@ import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {
   UsePreferencesQueryResponse,
+  useModerationOpts,
   usePreferencesQuery,
 } from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
-import {isNative} from '#/platform/detection'
+import {isAndroid, isNative} from '#/platform/detection'
 import {logger} from '#/logger'
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 
 const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
 
@@ -79,14 +81,30 @@ export function PostThread({
     data: thread,
   } = usePostThreadQuery(uri)
   const {data: preferences} = usePreferencesQuery()
+
   const rootPost = thread?.type === 'post' ? thread.post : undefined
   const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
 
+  const moderationOpts = useModerationOpts()
+  const isNoPwi = React.useMemo(() => {
+    const mod =
+      rootPost && moderationOpts
+        ? moderatePost(rootPost, moderationOpts)
+        : undefined
+
+    const cause = mod?.content.cause
+
+    return cause
+      ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated'
+      : false
+  }, [rootPost, moderationOpts])
+
   useSetTitle(
-    rootPost &&
-      `${sanitizeDisplayName(
-        rootPost.author.displayName || `@${rootPost.author.handle}`,
-      )}: "${rootPostRecord?.text}"`,
+    rootPost && !isNoPwi
+      ? `${sanitizeDisplayName(
+          rootPost.author.displayName || `@${rootPost.author.handle}`,
+        )}: "${rootPostRecord!.text}"`
+      : '',
   )
   useEffect(() => {
     if (rootPost) {
@@ -139,7 +157,7 @@ function PostThreadLoaded({
   const {hasSession} = useSession()
   const {_} = useLingui()
   const pal = usePalette('default')
-  const {isTablet, isDesktop} = useWebMediaQueries()
+  const {isTablet, isDesktop, isTabletOrMobile} = useWebMediaQueries()
   const ref = useRef<ListMethods>(null)
   const highlightedPostRef = useRef<View | null>(null)
   const needsScrollAdjustment = useRef<boolean>(
@@ -157,7 +175,11 @@ function PostThreadLoaded({
   const posts = React.useMemo(() => {
     let arr = [TOP_COMPONENT].concat(
       Array.from(
-        flattenThreadSkeleton(sortThread(thread, threadViewPrefs), hasSession),
+        flattenThreadSkeleton(
+          sortThread(thread, threadViewPrefs),
+          hasSession,
+          treeView,
+        ),
       ),
     )
     if (arr.length > maxVisible) {
@@ -167,7 +189,7 @@ function PostThreadLoaded({
       arr.push(BOTTOM_COMPONENT)
     }
     return arr
-  }, [thread, maxVisible, threadViewPrefs, hasSession])
+  }, [thread, treeView, maxVisible, threadViewPrefs, hasSession])
 
   /**
    * NOTE
@@ -197,17 +219,35 @@ function PostThreadLoaded({
 
     // wait for loading to finish
     if (thread.type === 'post' && !!thread.parent) {
-      highlightedPostRef.current?.measure(
-        (_x, _y, _width, _height, _pageX, pageY) => {
-          ref.current?.scrollToOffset({
-            animated: false,
-            offset: pageY - (isDesktop ? 0 : 50),
-          })
-        },
-      )
+      function onMeasure(pageY: number) {
+        let spinnerHeight = 0
+        if (isDesktop) {
+          spinnerHeight = 40
+        } else if (isTabletOrMobile) {
+          spinnerHeight = 82
+        }
+        ref.current?.scrollToOffset({
+          animated: false,
+          offset: pageY - spinnerHeight,
+        })
+      }
+      if (isNative) {
+        highlightedPostRef.current?.measure(
+          (_x, _y, _width, _height, _pageX, pageY) => {
+            onMeasure(pageY)
+          },
+        )
+      } else {
+        // Measure synchronously to avoid a layout jump.
+        const domNode = highlightedPostRef.current
+        if (domNode) {
+          const pageY = (domNode as any as Element).getBoundingClientRect().top
+          onMeasure(pageY)
+        }
+      }
       needsScrollAdjustment.current = false
     }
-  }, [thread, isDesktop])
+  }, [thread, isDesktop, isTabletOrMobile])
 
   const onPTR = React.useCallback(async () => {
     setIsPTRing(true)
@@ -222,7 +262,11 @@ function PostThreadLoaded({
   const renderItem = React.useCallback(
     ({item, index}: {item: YieldedItem; index: number}) => {
       if (item === TOP_COMPONENT) {
-        return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null
+        return isTablet ? (
+          <ViewHeader
+            title={_(msg({message: `Post`, context: 'description'}))}
+          />
+        ) : null
       } else if (item === PARENT_SPINNER) {
         return (
           <View style={styles.parentSpinner}>
@@ -276,8 +320,10 @@ function PostThreadLoaded({
         // -prf
         return (
           <View
+            // @ts-ignore web-only
             style={{
-              height: 400,
+              // Leave enough space below that the scroll doesn't jump
+              height: isNative ? 400 : '100vh',
               borderTopWidth: 1,
               borderColor: pal.colors.border,
             }}
@@ -354,6 +400,7 @@ function PostThreadLoaded({
       style={s.hContentRegion}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
+      removeClippedSubviews={isAndroid ? false : undefined}
     />
   )
 }
@@ -393,7 +440,7 @@ function PostThreadBlocked() {
               style={[pal.link as FontAwesomeIconStyle, s.mr5]}
               size={14}
             />
-            Back
+            <Trans context="action">Back</Trans>
           </Text>
         </TouchableOpacity>
       </View>
@@ -464,10 +511,11 @@ function isThreadPost(v: unknown): v is ThreadPost {
 function* flattenThreadSkeleton(
   node: ThreadNode,
   hasSession: boolean,
+  treeView: boolean,
 ): Generator<YieldedItem, void> {
   if (node.type === 'post') {
     if (node.parent) {
-      yield* flattenThreadSkeleton(node.parent, hasSession)
+      yield* flattenThreadSkeleton(node.parent, hasSession, treeView)
     } else if (node.ctx.isParentLoading) {
       yield PARENT_SPINNER
     }
@@ -480,7 +528,10 @@ function* flattenThreadSkeleton(
     }
     if (node.replies?.length) {
       for (const reply of node.replies) {
-        yield* flattenThreadSkeleton(reply, hasSession)
+        yield* flattenThreadSkeleton(reply, hasSession, treeView)
+        if (!treeView && !node.ctx.isHighlightedPost) {
+          break
+        }
       }
     } else if (node.ctx.isChildLoading) {
       yield CHILD_SPINNER
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 986fd70b2..a27ee0a58 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -158,6 +158,7 @@ let PostThreadItemLoaded = ({
   onPostReply: () => void
 }): React.ReactNode => {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const langPrefs = useLanguagePrefs()
   const {openComposer} = useComposerControls()
   const {currentAccount} = useSession()
@@ -172,7 +173,7 @@ let PostThreadItemLoaded = ({
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey)
   }, [post.uri, post.author])
-  const itemTitle = `Post by ${post.author.handle}`
+  const itemTitle = _(msg`Post by ${post.author.handle}`)
   const authorHref = makeProfileLink(post.author)
   const authorTitle = post.author.handle
   const isAuthorMuted = post.author.viewer?.muted
@@ -180,12 +181,12 @@ let PostThreadItemLoaded = ({
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
   }, [post.uri, post.author])
-  const likesTitle = 'Likes on this post'
+  const likesTitle = _(msg`Likes on this post`)
   const repostsHref = React.useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
   }, [post.uri, post.author])
-  const repostsTitle = 'Reposts of this post'
+  const repostsTitle = _(msg`Reposts of this post`)
   const isModeratedPost =
     moderation.decisions.post.cause?.type === 'label' &&
     moderation.decisions.post.cause.label.src !== currentAccount?.did
@@ -214,6 +215,7 @@ let PostThreadItemLoaded = ({
           displayName: post.author.displayName,
           avatar: post.author.avatar,
         },
+        embed: post.embed,
       },
       onPost: onPostReply,
     })
@@ -224,7 +226,7 @@ let PostThreadItemLoaded = ({
   }, [setLimitLines])
 
   if (!record) {
-    return <ErrorMessage message="Invalid or unsupported post record" />
+    return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
   }
 
   if (isHighlightedPost) {
@@ -246,10 +248,9 @@ let PostThreadItemLoaded = ({
           </View>
         )}
 
-        <Link
+        <View
           testID={`postThreadItem-by-${post.author.handle}`}
           style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
-          noFeedback
           accessible={false}>
           <PostSandboxWarning />
           <View style={styles.layout}>
@@ -334,6 +335,7 @@ let PostThreadItemLoaded = ({
               postCid={post.cid}
               postUri={post.uri}
               record={record}
+              richText={richText}
               showAppealLabelItem={
                 post.author.did === currentAccount?.did && isModeratedPost
               }
@@ -367,6 +369,7 @@ let PostThreadItemLoaded = ({
                     richText={richText}
                     lineHeight={1.3}
                     style={s.flex1}
+                    selectable
                   />
                 </View>
               ) : undefined}
@@ -437,11 +440,12 @@ let PostThreadItemLoaded = ({
                 big
                 post={post}
                 record={record}
+                richText={richText}
                 onPressReply={onPressReply}
               />
             </View>
           </View>
-        </Link>
+        </View>
         <WhoCanReply post={post} />
       </>
     )
@@ -562,7 +566,7 @@ let PostThreadItemLoaded = ({
                 ) : undefined}
                 {limitLines ? (
                   <TextLink
-                    text="Show More"
+                    text={_(msg`Show More`)}
                     style={pal.link}
                     onPress={onPressShowMore}
                     href="#"
@@ -585,6 +589,7 @@ let PostThreadItemLoaded = ({
                 <PostCtrls
                   post={post}
                   record={record}
+                  richText={richText}
                   onPressReply={onPressReply}
                 />
               </View>
@@ -701,7 +706,7 @@ function ExpandedPostDetails({
       <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text>
       {needsTranslation && (
         <>
-          <Text style={[pal.textLight, s.ml5, s.mr5]}>•</Text>
+          <Text style={pal.textLight}> &middot; </Text>
           <Link href={translatorUrl} title={_(msg`Translate`)}>
             <Text style={pal.link}>
               <Trans>Translate</Trans>
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index fca4171c3..f035c32ad 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -27,6 +27,8 @@ import {countLines} from 'lib/strings/helpers'
 import {useModerationOpts} from '#/state/queries/preferences'
 import {useComposerControls} from '#/state/shell/composer'
 import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export function Post({
   post,
@@ -95,6 +97,7 @@ function PostInner({
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {openComposer} = useComposerControls()
   const [limitLines, setLimitLines] = useState(
     () => countLines(richText?.text) >= MAX_POST_LINES,
@@ -118,6 +121,7 @@ function PostInner({
           displayName: post.author.displayName,
           avatar: post.author.avatar,
         },
+        embed: post.embed,
       },
     })
   }, [openComposer, post, record])
@@ -158,13 +162,15 @@ function PostInner({
                 style={[pal.textLight, s.mr2]}
                 lineHeight={1.2}
                 numberOfLines={1}>
-                Reply to{' '}
-                <UserInfoText
-                  type="sm"
-                  did={replyAuthorDid}
-                  attr="displayName"
-                  style={[pal.textLight]}
-                />
+                <Trans context="description">
+                  Reply to{' '}
+                  <UserInfoText
+                    type="sm"
+                    did={replyAuthorDid}
+                    attr="displayName"
+                    style={[pal.textLight]}
+                  />
+                </Trans>
               </Text>
             </View>
           )}
@@ -187,7 +193,7 @@ function PostInner({
             ) : undefined}
             {limitLines ? (
               <TextLink
-                text="Show More"
+                text={_(msg`Show More`)}
                 style={pal.link}
                 onPress={onPressShowMore}
                 href="#"
@@ -207,7 +213,12 @@ function PostInner({
               </ContentHider>
             ) : null}
           </ContentHider>
-          <PostCtrls post={post} record={record} onPressReply={onPressReply} />
+          <PostCtrls
+            post={post}
+            record={record}
+            richText={richText}
+            onPressReply={onPressReply}
+          />
         </View>
       </View>
     </Link>
diff --git a/src/view/com/posts/CustomFeedEmptyState.tsx b/src/view/com/posts/CustomFeedEmptyState.tsx
index e83a94f03..62a10fd19 100644
--- a/src/view/com/posts/CustomFeedEmptyState.tsx
+++ b/src/view/com/posts/CustomFeedEmptyState.tsx
@@ -12,6 +12,7 @@ import {NavigationProp} from 'lib/routes/types'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {isWeb} from 'platform/detection'
+import {Trans} from '@lingui/macro'
 
 export function CustomFeedEmptyState() {
   const pal = usePalette('default')
@@ -33,15 +34,17 @@ export function CustomFeedEmptyState() {
         <MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
       </View>
       <Text type="xl-medium" style={[s.textCenter, pal.text]}>
-        This feed is empty! You may need to follow more users or tune your
-        language settings.
+        <Trans>
+          This feed is empty! You may need to follow more users or tune your
+          language settings.
+        </Trans>
       </Text>
       <Button
         type="inverted"
         style={styles.emptyBtn}
         onPress={onPressFindAccounts}>
         <Text type="lg-medium" style={palInverted.text}>
-          Find accounts to follow
+          <Trans>Find accounts to follow</Trans>
         </Text>
         <FontAwesomeIcon
           icon="angle-right"
diff --git a/src/view/com/posts/DiscoverFallbackHeader.tsx b/src/view/com/posts/DiscoverFallbackHeader.tsx
new file mode 100644
index 000000000..ffde89997
--- /dev/null
+++ b/src/view/com/posts/DiscoverFallbackHeader.tsx
@@ -0,0 +1,43 @@
+import React from 'react'
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+import {Text} from '../util/text/Text'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {TextLink} from '../util/Link'
+import {InfoCircleIcon} from '#/lib/icons'
+
+export function DiscoverFallbackHeader() {
+  const pal = usePalette('default')
+  return (
+    <View
+      style={[
+        {
+          flexDirection: 'row',
+          alignItems: 'center',
+          paddingVertical: 12,
+          paddingHorizontal: 12,
+          borderTopWidth: 1,
+        },
+        pal.border,
+        pal.viewLight,
+      ]}>
+      <View style={{width: 68, paddingLeft: 12}}>
+        <InfoCircleIcon size={36} style={pal.textLight} strokeWidth={1.5} />
+      </View>
+      <View style={{flex: 1}}>
+        <Text type="md" style={pal.text}>
+          <Trans>
+            We ran out of posts from your follows. Here's the latest from{' '}
+            <TextLink
+              type="md-medium"
+              href="/profile/bsky.app/feed/whats-hot"
+              text="Discover"
+              style={pal.link}
+            />
+            .
+          </Trans>
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 02a3537eb..04753fe6c 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -28,13 +28,18 @@ import {isWeb} from '#/platform/detection'
 import {listenPostCreated} from '#/state/events'
 import {useSession} from '#/state/session'
 import {STALE} from '#/state/queries'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
+import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-const REFRESH_AFTER = STALE.HOURS.ONE
+// DISABLED need to check if this is causing random feed refreshes -prf
+// const REFRESH_AFTER = STALE.HOURS.ONE
 const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
 
 let Feed = ({
@@ -44,6 +49,7 @@ let Feed = ({
   style,
   enabled,
   pollInterval,
+  disablePoll,
   scrollElRef,
   onScrolledDownChange,
   onHasNew,
@@ -61,6 +67,7 @@ let Feed = ({
   style?: StyleProp<ViewStyle>
   enabled?: boolean
   pollInterval?: number
+  disablePoll?: boolean
   scrollElRef?: ListRef
   onHasNew?: (v: boolean) => void
   onScrolledDownChange?: (isScrolledDown: boolean) => void
@@ -74,6 +81,7 @@ let Feed = ({
 }): React.ReactNode => {
   const theme = useTheme()
   const {track} = useAnalytics()
+  const {_} = useLingui()
   const queryClient = useQueryClient()
   const {currentAccount} = useSession()
   const [isPTRing, setIsPTRing] = React.useState(false)
@@ -104,7 +112,7 @@ let Feed = ({
   )
 
   const checkForNew = React.useCallback(async () => {
-    if (!data?.pages[0] || isFetching || !onHasNew || !enabled) {
+    if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) {
       return
     }
     try {
@@ -114,7 +122,7 @@ let Feed = ({
     } catch (e) {
       logger.error('Poll latest failed', {feed, error: String(e)})
     }
-  }, [feed, data, isFetching, onHasNew, enabled])
+  }, [feed, data, isFetching, onHasNew, enabled, disablePoll])
 
   const myDid = currentAccount?.did || ''
   const onPostCreated = React.useCallback(() => {
@@ -143,11 +151,12 @@ let Feed = ({
   React.useEffect(() => {
     if (enabled) {
       const timeSinceFirstLoad = Date.now() - lastFetchRef.current
-      if (timeSinceFirstLoad > REFRESH_AFTER) {
+      // DISABLED need to check if this is causing random feed refreshes -prf
+      /*if (timeSinceFirstLoad > REFRESH_AFTER) {
         // do a full refresh
         scrollElRef?.current?.scrollToOffset({offset: 0, animated: false})
         queryClient.resetQueries({queryKey: RQKEY(feed)})
-      } else if (
+      } else*/ if (
         timeSinceFirstLoad > CHECK_LATEST_AFTER &&
         checkForNewRef.current
       ) {
@@ -250,16 +259,24 @@ let Feed = ({
       } else if (item === LOAD_MORE_ERROR_ITEM) {
         return (
           <LoadMoreRetryBtn
-            label="There was an issue fetching posts. Tap here to try again."
+            label={_(
+              msg`There was an issue fetching posts. Tap here to try again.`,
+            )}
             onPress={onPressRetryLoadMore}
           />
         )
       } else if (item === LOADING_ITEM) {
         return <PostFeedLoadingPlaceholder />
+      } else if (item.rootUri === FALLBACK_MARKER_POST.post.uri) {
+        // HACK
+        // tell the user we fell back to discover
+        // see home.ts (feed api) for more info
+        // -prf
+        return <DiscoverFallbackHeader />
       }
       return <FeedSlice slice={item} />
     },
-    [feed, error, onPressTryAgain, onPressRetryLoadMore, renderEmptyState],
+    [feed, error, onPressTryAgain, onPressRetryLoadMore, renderEmptyState, _],
   )
 
   const shouldRenderEndOfFeed =
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index aeac45980..48ed49bb1 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -38,6 +38,7 @@ export function FeedErrorMessage({
   error?: Error
   onPressTryAgain: () => void
 }) {
+  const {_: _l} = useLingui()
   const knownError = React.useMemo(
     () => detectKnownError(feedDesc, error),
     [feedDesc, error],
@@ -60,7 +61,7 @@ export function FeedErrorMessage({
     return (
       <EmptyState
         icon="ban"
-        message="Posts hidden"
+        message={_l(msgLingui`Posts hidden`)}
         style={{paddingVertical: 40}}
       />
     )
@@ -134,7 +135,9 @@ function FeedgenErrorMessage({
           await removeFeed({uri})
         } catch (err) {
           Toast.show(
-            'There was an an issue removing this feed. Please check your internet connection and try again.',
+            _l(
+              msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
+            ),
           )
           logger.error('Failed to remove feed', {error: err})
         }
@@ -160,20 +163,20 @@ function FeedgenErrorMessage({
             {knownError === KnownError.FeedgenDoesNotExist && (
               <Button
                 type="inverted"
-                label="Remove feed"
+                label={_l(msgLingui`Remove feed`)}
                 onPress={onRemoveFeed}
               />
             )}
             <Button
               type="default-light"
-              label="View profile"
+              label={_l(msgLingui`View profile`)}
               onPress={onViewProfile}
             />
           </View>
         )
       }
     }
-  }, [knownError, onViewProfile, onRemoveFeed])
+  }, [knownError, onViewProfile, onRemoveFeed, _l])
 
   return (
     <View
@@ -191,7 +194,7 @@ function FeedgenErrorMessage({
 
       {rawError?.message && (
         <Text style={pal.textLight}>
-          <Trans>Message from server</Trans>: {rawError.message}
+          <Trans>Message from server: {rawError.message}</Trans>
         </Text>
       )}
 
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 942d7bf71..225607ca9 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -35,6 +35,8 @@ import {useComposerControls} from '#/state/shell/composer'
 import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 import {FeedNameText} from '../util/FeedInfoText'
 import {useSession} from '#/state/session'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export function FeedItem({
   post,
@@ -103,6 +105,7 @@ let FeedItemInner = ({
 }): React.ReactNode => {
   const {openComposer} = useComposerControls()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {currentAccount} = useSession()
   const href = useMemo(() => {
     const urip = new AtUri(post.uri)
@@ -131,6 +134,7 @@ let FeedItemInner = ({
           displayName: post.author.displayName,
           avatar: post.author.avatar,
         },
+        embed: post.embed,
       },
     })
   }, [post, record, openComposer])
@@ -181,24 +185,28 @@ let FeedItemInner = ({
                 style={pal.textLight}
                 lineHeight={1.2}
                 numberOfLines={1}>
-                From{' '}
-                <FeedNameText
-                  type="sm-bold"
-                  uri={reason.uri}
-                  href={reason.href}
-                  lineHeight={1.2}
-                  numberOfLines={1}
-                  style={pal.textLight}
-                />
+                <Trans context="from-feed">
+                  From{' '}
+                  <FeedNameText
+                    type="sm-bold"
+                    uri={reason.uri}
+                    href={reason.href}
+                    lineHeight={1.2}
+                    numberOfLines={1}
+                    style={pal.textLight}
+                  />
+                </Trans>
               </Text>
             </Link>
           ) : AppBskyFeedDefs.isReasonRepost(reason) ? (
             <Link
               style={styles.includeReason}
               href={makeProfileLink(reason.by)}
-              title={`Reposted by ${sanitizeDisplayName(
-                reason.by.displayName || reason.by.handle,
-              )}`}>
+              title={_(
+                msg`Reposted by ${sanitizeDisplayName(
+                  reason.by.displayName || reason.by.handle,
+                )}`,
+              )}>
               <FontAwesomeIcon
                 icon="retweet"
                 style={{
@@ -212,17 +220,19 @@ let FeedItemInner = ({
                 style={pal.textLight}
                 lineHeight={1.2}
                 numberOfLines={1}>
-                Reposted by{' '}
-                <TextLinkOnWebOnly
-                  type="sm-bold"
-                  style={pal.textLight}
-                  lineHeight={1.2}
-                  numberOfLines={1}
-                  text={sanitizeDisplayName(
-                    reason.by.displayName || sanitizeHandle(reason.by.handle),
-                  )}
-                  href={makeProfileLink(reason.by)}
-                />
+                <Trans>
+                  Reposted by{' '}
+                  <TextLinkOnWebOnly
+                    type="sm-bold"
+                    style={pal.textLight}
+                    lineHeight={1.2}
+                    numberOfLines={1}
+                    text={sanitizeDisplayName(
+                      reason.by.displayName || sanitizeHandle(reason.by.handle),
+                    )}
+                    href={makeProfileLink(reason.by)}
+                  />
+                </Trans>
               </Text>
             </Link>
           ) : null}
@@ -273,13 +283,15 @@ let FeedItemInner = ({
                 style={[pal.textLight, s.mr2]}
                 lineHeight={1.2}
                 numberOfLines={1}>
-                Reply to{' '}
-                <UserInfoText
-                  type="md"
-                  did={replyAuthorDid}
-                  attr="displayName"
-                  style={[pal.textLight, s.ml2]}
-                />
+                <Trans context="description">
+                  Reply to{' '}
+                  <UserInfoText
+                    type="md"
+                    did={replyAuthorDid}
+                    attr="displayName"
+                    style={[pal.textLight]}
+                  />
+                </Trans>
               </Text>
             </View>
           )}
@@ -292,6 +304,7 @@ let FeedItemInner = ({
           <PostCtrls
             post={post}
             record={record}
+            richText={richText}
             onPressReply={onPressReply}
             showAppealLabelItem={
               post.author.did === currentAccount?.did && isModeratedPost
@@ -316,6 +329,7 @@ let PostContent = ({
   postAuthor: AppBskyFeedDefs.PostView['author']
 }): React.ReactNode => {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const [limitLines, setLimitLines] = useState(
     () => countLines(richText.text) >= MAX_POST_LINES,
   )
@@ -345,7 +359,7 @@ let PostContent = ({
       ) : undefined}
       {limitLines ? (
         <TextLink
-          text="Show More"
+          text={_(msg`Show More`)}
           style={pal.link}
           onPress={onPressShowMore}
           href="#"
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index c1a8c0e18..84edee4a1 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -8,6 +8,7 @@ import Svg, {Circle, Line} from 'react-native-svg'
 import {FeedItem} from './FeedItem'
 import {usePalette} from 'lib/hooks/usePalette'
 import {makeProfileLink} from 'lib/routes/links'
+import {Trans} from '@lingui/macro'
 
 let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
   if (slice.isThread && slice.items.length > 3) {
@@ -99,7 +100,7 @@ function ViewFullThread({slice}: {slice: FeedPostSlice}) {
       </View>
 
       <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}>
-        View full thread
+        <Trans>View full thread</Trans>
       </Text>
     </Link>
   )
diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx
index aac29603d..ef02039af 100644
--- a/src/view/com/posts/FollowingEmptyState.tsx
+++ b/src/view/com/posts/FollowingEmptyState.tsx
@@ -12,6 +12,7 @@ import {NavigationProp} from 'lib/routes/types'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {isWeb} from 'platform/detection'
+import {Trans} from '@lingui/macro'
 
 export function FollowingEmptyState() {
   const pal = usePalette('default')
@@ -43,15 +44,17 @@ export function FollowingEmptyState() {
           <MagnifyingGlassIcon style={[styles.icon, pal.text]} size={62} />
         </View>
         <Text type="xl-medium" style={[s.textCenter, pal.text]}>
-          Your following feed is empty! Follow more users to see what's
-          happening.
+          <Trans>
+            Your following feed is empty! Follow more users to see what's
+            happening.
+          </Trans>
         </Text>
         <Button
           type="inverted"
           style={styles.emptyBtn}
           onPress={onPressFindAccounts}>
           <Text type="lg-medium" style={palInverted.text}>
-            Find accounts to follow
+            <Trans>Find accounts to follow</Trans>
           </Text>
           <FontAwesomeIcon
             icon="angle-right"
@@ -61,14 +64,14 @@ export function FollowingEmptyState() {
         </Button>
 
         <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
-          You can also discover new Custom Feeds to follow.
+          <Trans>You can also discover new Custom Feeds to follow.</Trans>
         </Text>
         <Button
           type="inverted"
           style={[styles.emptyBtn, s.mt10]}
           onPress={onPressDiscoverFeeds}>
           <Text type="lg-medium" style={palInverted.text}>
-            Discover new custom feeds
+            <Trans>Discover new custom feeds</Trans>
           </Text>
           <FontAwesomeIcon
             icon="angle-right"
diff --git a/src/view/com/posts/FollowingEndOfFeed.tsx b/src/view/com/posts/FollowingEndOfFeed.tsx
index 3f1297547..bea5bedea 100644
--- a/src/view/com/posts/FollowingEndOfFeed.tsx
+++ b/src/view/com/posts/FollowingEndOfFeed.tsx
@@ -11,6 +11,7 @@ import {NavigationProp} from 'lib/routes/types'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {isWeb} from 'platform/detection'
+import {Trans} from '@lingui/macro'
 
 export function FollowingEndOfFeed() {
   const pal = usePalette('default')
@@ -44,15 +45,17 @@ export function FollowingEndOfFeed() {
       ]}>
       <View style={styles.inner}>
         <Text type="xl-medium" style={[s.textCenter, pal.text]}>
-          You've reached the end of your feed! Find some more accounts to
-          follow.
+          <Trans>
+            You've reached the end of your feed! Find some more accounts to
+            follow.
+          </Trans>
         </Text>
         <Button
           type="inverted"
           style={styles.emptyBtn}
           onPress={onPressFindAccounts}>
           <Text type="lg-medium" style={palInverted.text}>
-            Find accounts to follow
+            <Trans>Find accounts to follow</Trans>
           </Text>
           <FontAwesomeIcon
             icon="angle-right"
@@ -62,14 +65,14 @@ export function FollowingEndOfFeed() {
         </Button>
 
         <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
-          You can also discover new Custom Feeds to follow.
+          <Trans>You can also discover new Custom Feeds to follow.</Trans>
         </Text>
         <Button
           type="inverted"
           style={[styles.emptyBtn, s.mt10]}
           onPress={onPressDiscoverFeeds}>
           <Text type="lg-medium" style={palInverted.text}>
-            Discover new custom feeds
+            <Trans>Discover new custom feeds</Trans>
           </Text>
           <FontAwesomeIcon
             icon="angle-right"
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 1252f8ca8..9cc635b66 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -5,6 +5,8 @@ import {Button, ButtonType} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
 import {useProfileFollowMutationQueue} from '#/state/queries/profile'
 import {Shadow} from '#/state/cache/types'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 export function FollowButton({
   unfollowedType = 'inverted',
@@ -18,13 +20,14 @@ export function FollowButton({
   labelStyle?: StyleProp<TextStyle>
 }) {
   const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
+  const {_} = useLingui()
 
   const onPressFollow = async () => {
     try {
       await queueFollow()
     } catch (e: any) {
       if (e?.name !== 'AbortError') {
-        Toast.show(`An issue occurred, please try again.`)
+        Toast.show(_(msg`An issue occurred, please try again.`))
       }
     }
   }
@@ -34,7 +37,7 @@ export function FollowButton({
       await queueUnfollow()
     } catch (e: any) {
       if (e?.name !== 'AbortError') {
-        Toast.show(`An issue occurred, please try again.`)
+        Toast.show(_(msg`An issue occurred, please try again.`))
       }
     }
   }
@@ -49,7 +52,7 @@ export function FollowButton({
         type={followedType}
         labelStyle={labelStyle}
         onPress={onPressUnfollow}
-        label="Unfollow"
+        label={_(msg({message: 'Unfollow', context: 'action'}))}
       />
     )
   } else {
@@ -58,7 +61,7 @@ export function FollowButton({
         type={unfollowedType}
         labelStyle={labelStyle}
         onPress={onPressFollow}
-        label="Follow"
+        label={_(msg({message: 'Follow', context: 'action'}))}
       />
     )
   }
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index ef95f5924..266adc51d 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -23,6 +23,7 @@ import {Shadow} from '#/state/cache/types'
 import {useModerationOpts} from '#/state/queries/preferences'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useSession} from '#/state/session'
+import {Trans} from '@lingui/macro'
 
 export function ProfileCard({
   testID,
@@ -137,7 +138,7 @@ function ProfileCardPills({
       {followedBy && (
         <View style={[s.mt5, pal.btn, styles.pill]}>
           <Text type="xs" style={pal.text}>
-            Follows You
+            <Trans>Follows You</Trans>
           </Text>
         </View>
       )}
@@ -190,8 +191,10 @@ function FollowersList({
         style={[styles.followsByDesc, pal.textLight]}
         numberOfLines={2}
         lineHeight={1.2}>
-        Followed by{' '}
-        {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
+        <Trans>
+          Followed by{' '}
+          {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
+        </Trans>
       </Text>
       {followersWithMods.slice(0, 3).map(({f, mod}) => (
         <View key={f.did} style={styles.followedByAviContainer}>
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 7d52216b0..d831ad777 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -192,14 +192,16 @@ let ProfileHeaderLoaded = ({
         track('ProfileHeader:FollowButtonClicked')
         await queueFollow()
         Toast.show(
-          `Following ${sanitizeDisplayName(
-            profile.displayName || profile.handle,
-          )}`,
+          _(
+            msg`Following ${sanitizeDisplayName(
+              profile.displayName || profile.handle,
+            )}`,
+          ),
         )
       } catch (e: any) {
         if (e?.name !== 'AbortError') {
           logger.error('Failed to follow', {error: String(e)})
-          Toast.show(`There was an issue! ${e.toString()}`)
+          Toast.show(_(msg`There was an issue! ${e.toString()}`))
         }
       }
     })
@@ -211,14 +213,16 @@ let ProfileHeaderLoaded = ({
         track('ProfileHeader:UnfollowButtonClicked')
         await queueUnfollow()
         Toast.show(
-          `No longer following ${sanitizeDisplayName(
-            profile.displayName || profile.handle,
-          )}`,
+          _(
+            msg`No longer following ${sanitizeDisplayName(
+              profile.displayName || profile.handle,
+            )}`,
+          ),
         )
       } catch (e: any) {
         if (e?.name !== 'AbortError') {
           logger.error('Failed to unfollow', {error: String(e)})
-          Toast.show(`There was an issue! ${e.toString()}`)
+          Toast.show(_(msg`There was an issue! ${e.toString()}`))
         }
       }
     })
@@ -253,27 +257,27 @@ let ProfileHeaderLoaded = ({
     track('ProfileHeader:MuteAccountButtonClicked')
     try {
       await queueMute()
-      Toast.show('Account muted')
+      Toast.show(_(msg`Account muted`))
     } catch (e: any) {
       if (e?.name !== 'AbortError') {
         logger.error('Failed to mute account', {error: e})
-        Toast.show(`There was an issue! ${e.toString()}`)
+        Toast.show(_(msg`There was an issue! ${e.toString()}`))
       }
     }
-  }, [track, queueMute])
+  }, [track, queueMute, _])
 
   const onPressUnmuteAccount = React.useCallback(async () => {
     track('ProfileHeader:UnmuteAccountButtonClicked')
     try {
       await queueUnmute()
-      Toast.show('Account unmuted')
+      Toast.show(_(msg`Account unmuted`))
     } catch (e: any) {
       if (e?.name !== 'AbortError') {
         logger.error('Failed to unmute account', {error: e})
-        Toast.show(`There was an issue! ${e.toString()}`)
+        Toast.show(_(msg`There was an issue! ${e.toString()}`))
       }
     }
-  }, [track, queueUnmute])
+  }, [track, queueUnmute, _])
 
   const onPressBlockAccount = React.useCallback(async () => {
     track('ProfileHeader:BlockAccountButtonClicked')
@@ -286,11 +290,11 @@ let ProfileHeaderLoaded = ({
       onPressConfirm: async () => {
         try {
           await queueBlock()
-          Toast.show('Account blocked')
+          Toast.show(_(msg`Account blocked`))
         } catch (e: any) {
           if (e?.name !== 'AbortError') {
             logger.error('Failed to block account', {error: e})
-            Toast.show(`There was an issue! ${e.toString()}`)
+            Toast.show(_(msg`There was an issue! ${e.toString()}`))
           }
         }
       },
@@ -308,11 +312,11 @@ let ProfileHeaderLoaded = ({
       onPressConfirm: async () => {
         try {
           await queueUnblock()
-          Toast.show('Account unblocked')
+          Toast.show(_(msg`Account unblocked`))
         } catch (e: any) {
           if (e?.name !== 'AbortError') {
             logger.error('Failed to unblock account', {error: e})
-            Toast.show(`There was an issue! ${e.toString()}`)
+            Toast.show(_(msg`There was an issue! ${e.toString()}`))
           }
         }
       },
@@ -451,7 +455,9 @@ let ProfileHeaderLoaded = ({
               style={[styles.btn, styles.mainBtn, pal.btn]}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Edit profile`)}
-              accessibilityHint="Opens editor for profile display name, avatar, background image, and description">
+              accessibilityHint={_(
+                msg`Opens editor for profile display name, avatar, background image, and description`,
+              )}>
               <Text type="button" style={pal.text}>
                 <Trans>Edit Profile</Trans>
               </Text>
@@ -466,7 +472,7 @@ let ProfileHeaderLoaded = ({
                 accessibilityLabel={_(msg`Unblock`)}
                 accessibilityHint="">
                 <Text type="button" style={[pal.text, s.bold]}>
-                  <Trans>Unblock</Trans>
+                  <Trans context="action">Unblock</Trans>
                 </Text>
               </TouchableOpacity>
             )
@@ -488,8 +494,12 @@ let ProfileHeaderLoaded = ({
                     },
                   ]}
                   accessibilityRole="button"
-                  accessibilityLabel={`Show follows similar to ${profile.handle}`}
-                  accessibilityHint={`Shows a list of users similar to this user.`}>
+                  accessibilityLabel={_(
+                    msg`Show follows similar to ${profile.handle}`,
+                  )}
+                  accessibilityHint={_(
+                    msg`Shows a list of users similar to this user.`,
+                  )}>
                   <FontAwesomeIcon
                     icon="user-plus"
                     style={[
@@ -511,8 +521,10 @@ let ProfileHeaderLoaded = ({
                   onPress={onPressUnfollow}
                   style={[styles.btn, styles.mainBtn, pal.btn]}
                   accessibilityRole="button"
-                  accessibilityLabel={`Unfollow ${profile.handle}`}
-                  accessibilityHint={`Hides posts from ${profile.handle} in your feed`}>
+                  accessibilityLabel={_(msg`Unfollow ${profile.handle}`)}
+                  accessibilityHint={_(
+                    msg`Hides posts from ${profile.handle} in your feed`,
+                  )}>
                   <FontAwesomeIcon
                     icon="check"
                     style={[pal.text, s.mr5]}
@@ -528,8 +540,10 @@ let ProfileHeaderLoaded = ({
                   onPress={onPressFollow}
                   style={[styles.btn, styles.mainBtn, palInverted.view]}
                   accessibilityRole="button"
-                  accessibilityLabel={`Follow ${profile.handle}`}
-                  accessibilityHint={`Shows posts from ${profile.handle} in your feed`}>
+                  accessibilityLabel={_(msg`Follow ${profile.handle}`)}
+                  accessibilityHint={_(
+                    msg`Shows posts from ${profile.handle} in your feed`,
+                  )}>
                   <FontAwesomeIcon
                     icon="plus"
                     style={[palInverted.text, s.mr5]}
@@ -580,7 +594,7 @@ let ProfileHeaderLoaded = ({
               invalidHandle ? styles.invalidHandle : undefined,
               styles.handle,
             ]}>
-            {invalidHandle ? 'âš Invalid Handle' : `@${profile.handle}`}
+            {invalidHandle ? _(msg`âš Invalid Handle`) : `@${profile.handle}`}
           </ThemedText>
         </View>
         {!blockHide && (
@@ -597,7 +611,7 @@ let ProfileHeaderLoaded = ({
                 }
                 asAnchor
                 accessibilityLabel={`${followers} ${pluralizedFollowers}`}
-                accessibilityHint={'Opens followers list'}>
+                accessibilityHint={_(msg`Opens followers list`)}>
                 <Text type="md" style={[s.bold, pal.text]}>
                   {followers}{' '}
                 </Text>
@@ -615,14 +629,16 @@ let ProfileHeaderLoaded = ({
                   })
                 }
                 asAnchor
-                accessibilityLabel={`${following} following`}
-                accessibilityHint={'Opens following list'}>
-                <Text type="md" style={[s.bold, pal.text]}>
-                  {following}{' '}
-                </Text>
-                <Text type="md" style={[pal.textLight]}>
-                  <Trans>following</Trans>
-                </Text>
+                accessibilityLabel={_(msg`${following} following`)}
+                accessibilityHint={_(msg`Opens following list`)}>
+                <Trans>
+                  <Text type="md" style={[s.bold, pal.text]}>
+                    {following}{' '}
+                  </Text>
+                  <Text type="md" style={[pal.textLight]}>
+                    following
+                  </Text>
+                </Trans>
               </Link>
               <Text type="md" style={[s.bold, pal.text]}>
                 {formatCount(profile.postsCount || 0)}{' '}
@@ -682,7 +698,7 @@ let ProfileHeaderLoaded = ({
         testID="profileHeaderAviButton"
         onPress={onPressAvi}
         accessibilityRole="image"
-        accessibilityLabel={`View ${profile.handle}'s avatar`}
+        accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
         accessibilityHint="">
         <View
           style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index ce5cf92c5..6edc61fcf 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -21,6 +21,7 @@ import {useModerationOpts} from '#/state/queries/preferences'
 import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useProfileFollowMutationQueue} from '#/state/queries/profile'
+import {Trans} from '@lingui/macro'
 
 const OUTER_PADDING = 10
 const INNER_PADDING = 14
@@ -60,7 +61,7 @@ export function ProfileHeaderSuggestedFollows({
             paddingRight: INNER_PADDING / 2,
           }}>
           <Text type="sm-bold" style={[pal.textLight]}>
-            Suggested for you
+            <Trans>Suggested for you</Trans>
           </Text>
 
           <Pressable
diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx
index 0e245f0f4..eaf00f3e6 100644
--- a/src/view/com/profile/ProfileSubpageHeader.tsx
+++ b/src/view/com/profile/ProfileSubpageHeader.tsx
@@ -16,7 +16,7 @@ import {BACK_HITSLOP} from 'lib/constants'
 import {isNative} from 'platform/detection'
 import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
 import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
+import {Trans, msg} from '@lingui/macro'
 import {useSetDrawerOpen} from '#/state/shell'
 import {emitSoftReset} from '#/state/events'
 
@@ -153,17 +153,19 @@ export function ProfileSubpageHeader({
             <LoadingPlaceholder width={50} height={8} />
           ) : (
             <Text type="xl" style={[pal.textLight]} numberOfLines={1}>
-              by{' '}
               {!creator ? (
-                '—'
+                <Trans>by —</Trans>
               ) : isOwner ? (
-                'you'
+                <Trans>by you</Trans>
               ) : (
-                <TextLink
-                  text={sanitizeHandle(creator.handle, '@')}
-                  href={makeProfileLink(creator)}
-                  style={pal.textLight}
-                />
+                <Trans>
+                  by{' '}
+                  <TextLink
+                    text={sanitizeHandle(creator.handle, '@')}
+                    href={makeProfileLink(creator)}
+                    style={pal.textLight}
+                  />
+                </Trans>
               )}
             </Text>
           )}
diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx
index 76d493886..221879df7 100644
--- a/src/view/com/util/AccountDropdownBtn.tsx
+++ b/src/view/com/util/AccountDropdownBtn.tsx
@@ -22,7 +22,7 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) {
       label: _(msg`Remove account`),
       onPress: () => {
         removeAccount(account)
-        Toast.show('Account removed from quick access')
+        Toast.show(_(msg`Account removed from quick access`))
       },
       icon: {
         ios: {
diff --git a/src/view/com/util/BlurView.android.tsx b/src/view/com/util/BlurView.android.tsx
new file mode 100644
index 000000000..eee1d9d86
--- /dev/null
+++ b/src/view/com/util/BlurView.android.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import {StyleSheet, View, ViewProps} from 'react-native'
+import {addStyle} from 'lib/styles'
+
+type BlurViewProps = ViewProps & {
+  blurType?: 'dark' | 'light'
+  blurAmount?: number
+}
+
+export const BlurView = ({
+  style,
+  blurType,
+  ...props
+}: React.PropsWithChildren<BlurViewProps>) => {
+  if (blurType === 'dark') {
+    style = addStyle(style, styles.dark)
+  } else {
+    style = addStyle(style, styles.light)
+  }
+  return <View style={style} {...props} />
+}
+
+const styles = StyleSheet.create({
+  dark: {
+    backgroundColor: '#0008',
+  },
+  light: {
+    backgroundColor: '#fff8',
+  },
+})
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index 397588cfb..5ec1d0014 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -2,6 +2,7 @@ import React, {Component, ErrorInfo, ReactNode} from 'react'
 import {ErrorScreen} from './error/ErrorScreen'
 import {CenteredView} from './Views'
 import {t} from '@lingui/macro'
+import {logger} from '#/logger'
 
 interface Props {
   children?: ReactNode
@@ -23,7 +24,7 @@ export class ErrorBoundary extends Component<Props, State> {
   }
 
   public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
-    console.error('Uncaught error:', error, errorInfo)
+    logger.error(error, {errorInfo})
   }
 
   public render() {
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index dcbec7cb4..db26258d6 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -1,6 +1,5 @@
 import React, {ComponentProps, memo, useMemo} from 'react'
 import {
-  Linking,
   GestureResponderEvent,
   Platform,
   StyleProp,
@@ -31,6 +30,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
 import {PressableWithHover} from './PressableWithHover'
 import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
 import {useModalControls} from '#/state/modals'
+import {useOpenLink} from '#/state/preferences/in-app-browser'
 
 type Event =
   | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@@ -65,6 +65,7 @@ export const Link = memo(function Link({
   const {closeModal} = useModalControls()
   const navigation = useNavigation<NavigationProp>()
   const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
+  const openLink = useOpenLink()
 
   const onPress = React.useCallback(
     (e?: Event) => {
@@ -74,11 +75,12 @@ export const Link = memo(function Link({
           navigation,
           sanitizeUrl(href),
           navigationAction,
+          openLink,
           e,
         )
       }
     },
-    [closeModal, navigation, navigationAction, href],
+    [closeModal, navigation, navigationAction, href, openLink],
   )
 
   if (noFeedback) {
@@ -172,6 +174,7 @@ export const TextLink = memo(function TextLink({
   const {...props} = useLinkProps({to: sanitizeUrl(href)})
   const navigation = useNavigation<NavigationProp>()
   const {openModal, closeModal} = useModalControls()
+  const openLink = useOpenLink()
 
   if (warnOnMismatchingLabel && typeof text !== 'string') {
     console.error('Unable to detect mismatching label')
@@ -200,6 +203,7 @@ export const TextLink = memo(function TextLink({
         navigation,
         sanitizeUrl(href),
         navigationAction,
+        openLink,
         e,
       )
     },
@@ -212,6 +216,7 @@ export const TextLink = memo(function TextLink({
       text,
       warnOnMismatchingLabel,
       navigationAction,
+      openLink,
     ],
   )
   const hrefAttrs = useMemo(() => {
@@ -301,6 +306,8 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
   )
 })
 
+const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/']
+
 // NOTE
 // we can't use the onPress given by useLinkProps because it will
 // match most paths to the HomeTab routes while we actually want to
@@ -317,6 +324,7 @@ function onPressInner(
   navigation: NavigationProp,
   href: string,
   navigationAction: 'push' | 'replace' | 'navigate' = 'push',
+  openLink: (href: string) => void,
   e?: Event,
 ) {
   let shouldHandle = false
@@ -344,8 +352,13 @@ function onPressInner(
 
   if (shouldHandle) {
     href = convertBskyAppUrlIfNeeded(href)
-    if (newTab || href.startsWith('http') || href.startsWith('mailto')) {
-      Linking.openURL(href)
+    if (
+      newTab ||
+      href.startsWith('http') ||
+      href.startsWith('mailto') ||
+      EXEMPT_PATHS.some(path => href.startsWith(path))
+    ) {
+      openLink(href)
     } else {
       closeModal() // close any active modals
 
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 9abd7d35a..d30a9d805 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -1,4 +1,4 @@
-import React, {memo, startTransition} from 'react'
+import React, {memo} from 'react'
 import {FlatListProps, RefreshControl} from 'react-native'
 import {FlatList_INTERNAL} from './Views'
 import {addStyle} from 'lib/styles'
@@ -39,9 +39,7 @@ function ListImpl<ItemT>(
   const pal = usePalette('default')
 
   function handleScrolledDownChange(didScrollDown: boolean) {
-    startTransition(() => {
-      onScrolledDownChange?.(didScrollDown)
-    })
+    onScrolledDownChange?.(didScrollDown)
   }
 
   const scrollHandler = useAnimatedScrollHandler({
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
new file mode 100644
index 000000000..3e81a8c37
--- /dev/null
+++ b/src/view/com/util/List.web.tsx
@@ -0,0 +1,341 @@
+import React, {isValidElement, memo, useRef, startTransition} from 'react'
+import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
+import {addStyle} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useScrollHandlers} from '#/lib/ScrollContext'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {batchedUpdates} from '#/lib/batchedUpdates'
+
+export type ListMethods = any // TODO: Better types.
+export type ListProps<ItemT> = Omit<
+  FlatListProps<ItemT>,
+  | 'onScroll' // Use ScrollContext instead.
+  | 'refreshControl' // Pass refreshing and/or onRefresh instead.
+  | 'contentOffset' // Pass headerOffset instead.
+> & {
+  onScrolledDownChange?: (isScrolledDown: boolean) => void
+  headerOffset?: number
+  refreshing?: boolean
+  onRefresh?: () => void
+  desktopFixedHeight: any // TODO: Better types.
+}
+export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
+
+function ListImpl<ItemT>(
+  {
+    ListHeaderComponent,
+    ListFooterComponent,
+    contentContainerStyle,
+    data,
+    desktopFixedHeight,
+    headerOffset,
+    keyExtractor,
+    refreshing: _unsupportedRefreshing,
+    onEndReached,
+    onEndReachedThreshold = 0,
+    onRefresh: _unsupportedOnRefresh,
+    onScrolledDownChange,
+    onContentSizeChange,
+    renderItem,
+    extraData,
+    style,
+    ...props
+  }: ListProps<ItemT>,
+  ref: React.Ref<ListMethods>,
+) {
+  const contextScrollHandlers = useScrollHandlers()
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  if (!isMobile) {
+    contentContainerStyle = addStyle(
+      contentContainerStyle,
+      styles.containerScroll,
+    )
+  }
+
+  let header: JSX.Element | null = null
+  if (ListHeaderComponent != null) {
+    if (isValidElement(ListHeaderComponent)) {
+      header = ListHeaderComponent
+    } else {
+      // @ts-ignore Nah it's fine.
+      header = <ListHeaderComponent />
+    }
+  }
+
+  let footer: JSX.Element | null = null
+  if (ListFooterComponent != null) {
+    if (isValidElement(ListFooterComponent)) {
+      footer = ListFooterComponent
+    } else {
+      // @ts-ignore Nah it's fine.
+      footer = <ListFooterComponent />
+    }
+  }
+
+  if (headerOffset != null) {
+    style = addStyle(style, {
+      paddingTop: headerOffset,
+    })
+  }
+
+  const nativeRef = React.useRef(null)
+  React.useImperativeHandle(
+    ref,
+    () =>
+      ({
+        scrollToTop() {
+          window.scrollTo({top: 0})
+        },
+        scrollToOffset({
+          animated,
+          offset,
+        }: {
+          animated: boolean
+          offset: number
+        }) {
+          window.scrollTo({
+            left: 0,
+            top: offset,
+            behavior: animated ? 'smooth' : 'instant',
+          })
+        },
+      } as any), // TODO: Better types.
+    [],
+  )
+
+  // --- onContentSizeChange ---
+  const containerRef = useRef(null)
+  useResizeObserver(containerRef, onContentSizeChange)
+
+  // --- onScroll ---
+  const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
+  const handleWindowScroll = useNonReactiveCallback(() => {
+    if (isInsideVisibleTree) {
+      contextScrollHandlers.onScroll?.(
+        {
+          contentOffset: {
+            x: Math.max(0, window.scrollX),
+            y: Math.max(0, window.scrollY),
+          },
+        } as any, // TODO: Better types.
+        null as any,
+      )
+    }
+  })
+  React.useEffect(() => {
+    if (!isInsideVisibleTree) {
+      // Prevents hidden tabs from firing scroll events.
+      // Only one list is expected to be firing these at a time.
+      return
+    }
+    window.addEventListener('scroll', handleWindowScroll)
+    return () => {
+      window.removeEventListener('scroll', handleWindowScroll)
+    }
+  }, [isInsideVisibleTree, handleWindowScroll])
+
+  // --- onScrolledDownChange ---
+  const isScrolledDown = useRef(false)
+  function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) {
+    const didScrollDown = !isAboveTheFold
+    if (isScrolledDown.current !== didScrollDown) {
+      isScrolledDown.current = didScrollDown
+      startTransition(() => {
+        onScrolledDownChange?.(didScrollDown)
+      })
+    }
+  }
+
+  // --- onEndReached ---
+  const onTailVisibilityChange = useNonReactiveCallback(
+    (isTailVisible: boolean) => {
+      if (isTailVisible) {
+        onEndReached?.({
+          distanceFromEnd: onEndReachedThreshold || 0,
+        })
+      }
+    },
+  )
+
+  return (
+    <View {...props} style={style} ref={nativeRef}>
+      <Visibility
+        onVisibleChange={setIsInsideVisibleTree}
+        style={
+          // This has position: fixed, so it should always report as visible
+          // unless we're within a display: none tree (like a hidden tab).
+          styles.parentTreeVisibilityDetector
+        }
+      />
+      <View
+        ref={containerRef}
+        style={[
+          styles.contentContainer,
+          contentContainerStyle,
+          desktopFixedHeight ? styles.minHeightViewport : null,
+          pal.border,
+        ]}>
+        <Visibility
+          onVisibleChange={handleAboveTheFoldVisibleChange}
+          style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
+        />
+        {header}
+        {(data as Array<ItemT>).map((item, index) => (
+          <Row<ItemT>
+            key={keyExtractor!(item, index)}
+            item={item}
+            index={index}
+            renderItem={renderItem}
+            extraData={extraData}
+          />
+        ))}
+        {onEndReached && (
+          <Visibility
+            topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
+            onVisibleChange={onTailVisibilityChange}
+          />
+        )}
+        {footer}
+      </View>
+    </View>
+  )
+}
+
+function useResizeObserver(
+  ref: React.RefObject<Element>,
+  onResize: undefined | ((w: number, h: number) => void),
+) {
+  const handleResize = useNonReactiveCallback(onResize ?? (() => {}))
+  const isActive = !!onResize
+  React.useEffect(() => {
+    if (!isActive) {
+      return
+    }
+    const resizeObserver = new ResizeObserver(entries => {
+      batchedUpdates(() => {
+        for (let entry of entries) {
+          const rect = entry.contentRect
+          handleResize(rect.width, rect.height)
+        }
+      })
+    })
+    const node = ref.current!
+    resizeObserver.observe(node)
+    return () => {
+      resizeObserver.unobserve(node)
+    }
+  }, [handleResize, isActive, ref])
+}
+
+let Row = function RowImpl<ItemT>({
+  item,
+  index,
+  renderItem,
+  extraData: _unused,
+}: {
+  item: ItemT
+  index: number
+  renderItem:
+    | null
+    | undefined
+    | ((data: {index: number; item: any; separators: any}) => React.ReactNode)
+  extraData: any
+}): React.ReactNode {
+  if (!renderItem) {
+    return null
+  }
+  return (
+    <View style={styles.row}>
+      {renderItem({item, index, separators: null as any})}
+    </View>
+  )
+}
+Row = React.memo(Row)
+
+let Visibility = ({
+  topMargin = '0px',
+  onVisibleChange,
+  style,
+}: {
+  topMargin?: string
+  onVisibleChange: (isVisible: boolean) => void
+  style?: ViewProps['style']
+}): React.ReactNode => {
+  const tailRef = React.useRef(null)
+  const isIntersecting = React.useRef(false)
+
+  const handleIntersection = useNonReactiveCallback(
+    (entries: IntersectionObserverEntry[]) => {
+      batchedUpdates(() => {
+        entries.forEach(entry => {
+          if (entry.isIntersecting !== isIntersecting.current) {
+            isIntersecting.current = entry.isIntersecting
+            onVisibleChange(entry.isIntersecting)
+          }
+        })
+      })
+    },
+  )
+
+  React.useEffect(() => {
+    const observer = new IntersectionObserver(handleIntersection, {
+      rootMargin: `${topMargin} 0px 0px 0px`,
+    })
+    const tail: Element | null = tailRef.current!
+    observer.observe(tail)
+    return () => {
+      observer.unobserve(tail)
+    }
+  }, [handleIntersection, topMargin])
+
+  return (
+    <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
+  )
+}
+Visibility = React.memo(Visibility)
+
+export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
+  props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
+) => React.ReactElement
+
+const styles = StyleSheet.create({
+  contentContainer: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  containerScroll: {
+    width: '100%',
+    maxWidth: 600,
+    marginLeft: 'auto',
+    marginRight: 'auto',
+  },
+  row: {
+    // @ts-ignore web only
+    contentVisibility: 'auto',
+  },
+  minHeightViewport: {
+    // @ts-ignore web only
+    minHeight: '100vh',
+  },
+  parentTreeVisibilityDetector: {
+    // @ts-ignore web only
+    position: 'fixed',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  aboveTheFoldDetector: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    // Bottom is dynamic.
+  },
+  visibilityDetector: {
+    pointerEvents: 'none',
+    zIndex: -1,
+  },
+})
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 31a4ef0c8..2c90e33ff 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -1,11 +1,14 @@
-import React, {useCallback} from 'react'
+import React, {useCallback, useEffect} from 'react'
+import EventEmitter from 'eventemitter3'
 import {ScrollProvider} from '#/lib/ScrollContext'
 import {NativeScrollEvent} from 'react-native'
 import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
 import {useShellLayout} from '#/state/shell/shell-layout'
-import {isWeb} from 'platform/detection'
+import {isNative, isWeb} from 'platform/detection'
 import {useSharedValue, interpolate} from 'react-native-reanimated'
 
+const WEB_HIDE_SHELL_THRESHOLD = 200
+
 function clamp(num: number, min: number, max: number) {
   'worklet'
   return Math.min(Math.max(num, min), max)
@@ -18,11 +21,22 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const startDragOffset = useSharedValue<number | null>(null)
   const startMode = useSharedValue<number | null>(null)
 
+  useEffect(() => {
+    if (isWeb) {
+      return listenToForcedWindowScroll(() => {
+        startDragOffset.value = null
+        startMode.value = null
+      })
+    }
+  })
+
   const onBeginDrag = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
-      startDragOffset.value = e.contentOffset.y
-      startMode.value = mode.value
+      if (isNative) {
+        startDragOffset.value = e.contentOffset.y
+        startMode.value = mode.value
+      }
     },
     [mode, startDragOffset, startMode],
   )
@@ -30,14 +44,16 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const onEndDrag = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
-      startDragOffset.value = null
-      startMode.value = null
-      if (e.contentOffset.y < headerHeight.value / 2) {
-        // If we're close to the top, show the shell.
-        setMode(false)
-      } else {
-        // Snap to whichever state is the closest.
-        setMode(Math.round(mode.value) === 1)
+      if (isNative) {
+        startDragOffset.value = null
+        startMode.value = null
+        if (e.contentOffset.y < headerHeight.value / 2) {
+          // If we're close to the top, show the shell.
+          setMode(false)
+        } else {
+          // Snap to whichever state is the closest.
+          setMode(Math.round(mode.value) === 1)
+        }
       }
     },
     [startDragOffset, startMode, setMode, mode, headerHeight],
@@ -46,41 +62,40 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const onScroll = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
-      if (startDragOffset.value === null || startMode.value === null) {
-        if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
-          // If we're close enough to the top, always show the shell.
-          // Even if we're not dragging.
-          setMode(false)
+      if (isNative) {
+        if (startDragOffset.value === null || startMode.value === null) {
+          if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
+            // If we're close enough to the top, always show the shell.
+            // Even if we're not dragging.
+            setMode(false)
+          }
           return
         }
-        if (isWeb) {
-          // On the web, there is no concept of "starting" the drag.
-          // When we get the first scroll event, we consider that the start.
-          startDragOffset.value = e.contentOffset.y
-          startMode.value = mode.value
-        }
-        return
-      }
 
-      // The "mode" value is always between 0 and 1.
-      // Figure out how much to move it based on the current dragged distance.
-      const dy = e.contentOffset.y - startDragOffset.value
-      const dProgress = interpolate(
-        dy,
-        [-headerHeight.value, headerHeight.value],
-        [-1, 1],
-      )
-      const newValue = clamp(startMode.value + dProgress, 0, 1)
-      if (newValue !== mode.value) {
-        // Manually adjust the value. This won't be (and shouldn't be) animated.
-        mode.value = newValue
-      }
-      if (isWeb) {
-        // On the web, there is no concept of "starting" the drag,
-        // so we don't have any specific anchor point to calculate the distance.
-        // Instead, update it continuosly along the way and diff with the last event.
+        // The "mode" value is always between 0 and 1.
+        // Figure out how much to move it based on the current dragged distance.
+        const dy = e.contentOffset.y - startDragOffset.value
+        const dProgress = interpolate(
+          dy,
+          [-headerHeight.value, headerHeight.value],
+          [-1, 1],
+        )
+        const newValue = clamp(startMode.value + dProgress, 0, 1)
+        if (newValue !== mode.value) {
+          // Manually adjust the value. This won't be (and shouldn't be) animated.
+          mode.value = newValue
+        }
+      } else {
+        // On the web, we don't try to follow the drag because we don't know when it ends.
+        // Instead, show/hide immediately based on whether we're scrolling up or down.
+        const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
         startDragOffset.value = e.contentOffset.y
-        startMode.value = mode.value
+
+        if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) {
+          setMode(false)
+        } else if (dy > 0) {
+          setMode(true)
+        }
       }
     },
     [headerHeight, mode, setMode, startDragOffset, startMode],
@@ -95,3 +110,26 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     </ScrollProvider>
   )
 }
+
+const emitter = new EventEmitter()
+
+if (isWeb) {
+  const originalScroll = window.scroll
+  window.scroll = function () {
+    emitter.emit('forced-scroll')
+    return originalScroll.apply(this, arguments as any)
+  }
+
+  const originalScrollTo = window.scrollTo
+  window.scrollTo = function () {
+    emitter.emit('forced-scroll')
+    return originalScrollTo.apply(this, arguments as any)
+  }
+}
+
+function listenToForcedWindowScroll(listener: () => void) {
+  emitter.addListener('forced-scroll', listener)
+  return () => {
+    emitter.removeListener('forced-scroll', listener)
+  }
+}
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index 223a069c8..66e363cd4 100644
--- a/src/view/com/util/Selector.tsx
+++ b/src/view/com/util/Selector.tsx
@@ -2,6 +2,8 @@ import React, {createRef, useState, useMemo, useRef} from 'react'
 import {Animated, Pressable, StyleSheet, View} from 'react-native'
 import {Text} from './text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 interface Layout {
   x: number
@@ -19,6 +21,7 @@ export function Selector({
   panX: Animated.Value
   onSelect?: (index: number) => void
 }) {
+  const {_} = useLingui()
   const containerRef = useRef<View>(null)
   const pal = usePalette('default')
   const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>(
@@ -100,8 +103,8 @@ export function Selector({
             testID={`selector-${i}`}
             key={item}
             onPress={() => onPressItem(i)}
-            accessibilityLabel={`Select ${item}`}
-            accessibilityHint={`Select option ${i} of ${numItems}`}>
+            accessibilityLabel={_(msg`Select ${item}`)}
+            accessibilityHint={_(msg`Select option ${i} of ${numItems}`)}>
             <View style={styles.item} ref={itemRefs[i]}>
               <Text
                 style={
diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx
index e86e37565..814b2fb15 100644
--- a/src/view/com/util/SimpleViewHeader.tsx
+++ b/src/view/com/util/SimpleViewHeader.tsx
@@ -14,6 +14,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NavigationProp} from 'lib/routes/types'
 import {useSetDrawerOpen} from '#/state/shell'
+import {isWeb} from '#/platform/detection'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
@@ -47,7 +48,14 @@ export function SimpleViewHeader({
 
   const Container = isMobile ? View : CenteredView
   return (
-    <Container style={[styles.header, isMobile && styles.headerMobile, style]}>
+    <Container
+      style={[
+        styles.header,
+        isMobile && styles.headerMobile,
+        isWeb && styles.headerWeb,
+        pal.view,
+        style,
+      ]}>
       {showBackButton ? (
         <TouchableOpacity
           testID="viewHeaderDrawerBtn"
@@ -89,6 +97,12 @@ const styles = StyleSheet.create({
     paddingHorizontal: 12,
     paddingVertical: 10,
   },
+  headerWeb: {
+    // @ts-ignore web-only
+    position: 'sticky',
+    top: 0,
+    zIndex: 1,
+  },
   backBtn: {
     width: 30,
     height: 30,
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
index beb67c30c..d5a843541 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -64,7 +64,8 @@ export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') {
 
 const styles = StyleSheet.create({
   container: {
-    position: 'absolute',
+    // @ts-ignore web only
+    position: 'fixed',
     left: 20,
     bottom: 20,
     // @ts-ignore web only
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 082cae59c..1ccfcf56c 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -11,6 +11,8 @@ import {NavigationProp} from 'lib/routes/types'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import Animated from 'react-native-reanimated'
 import {useSetDrawerOpen} from '#/state/shell'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
@@ -32,6 +34,7 @@ export function ViewHeader({
   renderButton?: () => JSX.Element
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const setDrawerOpen = useSetDrawerOpen()
   const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
@@ -75,9 +78,9 @@ export function ViewHeader({
             hitSlop={BACK_HITSLOP}
             style={canGoBack ? styles.backBtn : styles.backBtnWide}
             accessibilityRole="button"
-            accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
+            accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
             accessibilityHint={
-              canGoBack ? '' : 'Access navigation links and settings'
+              canGoBack ? '' : _(msg`Access navigation links and settings`)
             }>
             {canGoBack ? (
               <FontAwesomeIcon
diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx
index b4adbb557..a4238b8a4 100644
--- a/src/view/com/util/error/ErrorMessage.tsx
+++ b/src/view/com/util/error/ErrorMessage.tsx
@@ -53,7 +53,9 @@ export function ErrorMessage({
           onPress={onPressTryAgain}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Retry`)}
-          accessibilityHint="Retries the last action, which errored out">
+          accessibilityHint={_(
+            msg`Retries the last action, which errored out`,
+          )}>
           <FontAwesomeIcon
             icon="arrows-rotate"
             style={{color: theme.palette.error.icon}}
diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx
index 4cd6dd4b4..45444331c 100644
--- a/src/view/com/util/error/ErrorScreen.tsx
+++ b/src/view/com/util/error/ErrorScreen.tsx
@@ -63,14 +63,16 @@ export function ErrorScreen({
             style={[styles.btn]}
             onPress={onPressTryAgain}
             accessibilityLabel={_(msg`Retry`)}
-            accessibilityHint="Retries the last action, which errored out">
+            accessibilityHint={_(
+              msg`Retries the last action, which errored out`,
+            )}>
             <FontAwesomeIcon
               icon="arrows-rotate"
               style={pal.link as FontAwesomeIconStyle}
               size={16}
             />
             <Text type="button" style={[styles.btnText, pal.link]}>
-              <Trans>Try again</Trans>
+              <Trans context="action">Try again</Trans>
             </Text>
           </Button>
         </View>
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index 9787d92fb..27a16117b 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -6,6 +6,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {clamp} from 'lib/numbers'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import {isWeb} from '#/platform/detection'
 import Animated from 'react-native-reanimated'
 
 export interface FABProps
@@ -64,7 +65,8 @@ const styles = StyleSheet.create({
     borderRadius: 35,
   },
   outer: {
-    position: 'absolute',
+    // @ts-ignore web-only
+    position: isWeb ? 'fixed' : 'absolute',
     zIndex: 1,
   },
   inner: {
diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx
index 4aa5cb610..c5f0afc8f 100644
--- a/src/view/com/util/forms/DateInput.tsx
+++ b/src/view/com/util/forms/DateInput.tsx
@@ -13,6 +13,9 @@ import {Text} from '../text/Text'
 import {TypographyVariant} from 'lib/ThemeContext'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
+import {getLocales} from 'expo-localization'
+
+const LOCALE = getLocales()[0]
 
 interface Props {
   testID?: string
@@ -25,6 +28,7 @@ interface Props {
   accessibilityLabel: string
   accessibilityHint: string
   accessibilityLabelledBy?: string
+  handleAsUTC?: boolean
 }
 
 export function DateInput(props: Props) {
@@ -32,6 +36,12 @@ export function DateInput(props: Props) {
   const theme = useTheme()
   const pal = usePalette('default')
 
+  const formatter = React.useMemo(() => {
+    return new Intl.DateTimeFormat(LOCALE.languageTag, {
+      timeZone: props.handleAsUTC ? 'UTC' : undefined,
+    })
+  }, [props.handleAsUTC])
+
   const onChangeInternal = useCallback(
     (event: DateTimePickerEvent, date: Date | undefined) => {
       setShow(false)
@@ -64,7 +74,7 @@ export function DateInput(props: Props) {
             <Text
               type={props.buttonLabelType}
               style={[pal.text, props.buttonLabelStyle]}>
-              {props.value.toLocaleDateString()}
+              {formatter.format(props.value)}
             </Text>
           </View>
         </Button>
@@ -73,6 +83,7 @@ export function DateInput(props: Props) {
         <DateTimePicker
           testID={props.testID ? `${props.testID}-datepicker` : undefined}
           mode="date"
+          timeZoneName={props.handleAsUTC ? 'Etc/UTC' : undefined}
           display="spinner"
           // @ts-ignore applies in iOS only -prf
           themeVariant={theme.colorScheme}
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index ad8f50f5e..411b77484 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -75,6 +75,8 @@ export function DropdownButton({
   bottomOffset = 0,
   accessibilityLabel,
 }: PropsWithChildren<DropdownButtonProps>) {
+  const {_} = useLingui()
+
   const ref1 = useRef<TouchableOpacity>(null)
   const ref2 = useRef<View>(null)
 
@@ -141,7 +143,9 @@ export function DropdownButton({
         hitSlop={HITSLOP_10}
         ref={ref1}
         accessibilityRole="button"
-        accessibilityLabel={accessibilityLabel || `Opens ${numItems} options`}
+        accessibilityLabel={
+          accessibilityLabel || _(msg`Opens ${numItems} options`)
+        }
         accessibilityHint="">
         {children}
       </TouchableOpacity>
@@ -247,7 +251,7 @@ const DropdownItems = ({
                 onPress={() => onPressItem(index)}
                 accessibilityRole="button"
                 accessibilityLabel={item.label}
-                accessibilityHint={`Option ${index + 1} of ${numItems}`}>
+                accessibilityHint={_(msg`Option ${index + 1} of ${numItems}`)}>
                 {item.icon && (
                   <FontAwesomeIcon
                     style={styles.icon}
diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx
new file mode 100644
index 000000000..9e9888ad8
--- /dev/null
+++ b/src/view/com/util/forms/NativeDropdown.web.tsx
@@ -0,0 +1,241 @@
+import React from 'react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {Pressable, StyleSheet, View, Text} from 'react-native'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {HITSLOP_10} from 'lib/constants'
+
+// Custom Dropdown Menu Components
+// ==
+export const DropdownMenuRoot = DropdownMenu.Root
+export const DropdownMenuContent = DropdownMenu.Content
+
+type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
+export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
+  const theme = useTheme()
+  const [focused, setFocused] = React.useState(false)
+  const backgroundColor = theme.colorScheme === 'dark' ? '#fff1' : '#0001'
+
+  return (
+    <DropdownMenu.Item
+      {...props}
+      style={StyleSheet.flatten([
+        styles.item,
+        focused && {backgroundColor: backgroundColor},
+      ])}
+      onFocus={() => {
+        setFocused(true)
+      }}
+      onBlur={() => {
+        setFocused(false)
+      }}
+    />
+  )
+}
+
+// Types for Dropdown Menu and Items
+export type DropdownItem = {
+  label: string | 'separator'
+  onPress?: () => void
+  testID?: string
+  icon?: {
+    ios: MenuItemCommonProps['ios']
+    android: string
+    web: IconProp
+  }
+}
+type Props = {
+  items: DropdownItem[]
+  testID?: string
+  accessibilityLabel?: string
+  accessibilityHint?: string
+}
+
+export function NativeDropdown({
+  items,
+  children,
+  testID,
+  accessibilityLabel,
+  accessibilityHint,
+}: React.PropsWithChildren<Props>) {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const dropDownBackgroundColor =
+    theme.colorScheme === 'dark' ? pal.btn : pal.view
+  const [open, setOpen] = React.useState(false)
+  const buttonRef = React.useRef<HTMLButtonElement>(null)
+  const menuRef = React.useRef<HTMLDivElement>(null)
+  const {borderColor: separatorColor} =
+    theme.colorScheme === 'dark' ? pal.borderDark : pal.border
+
+  React.useEffect(() => {
+    function clickHandler(e: MouseEvent) {
+      const t = e.target
+
+      if (!open) return
+      if (!t) return
+      if (!buttonRef.current || !menuRef.current) return
+
+      if (
+        t !== buttonRef.current &&
+        !buttonRef.current.contains(t as Node) &&
+        t !== menuRef.current &&
+        !menuRef.current.contains(t as Node)
+      ) {
+        // prevent clicking through to links beneath dropdown
+        // only applies to mobile web
+        e.preventDefault()
+        e.stopPropagation()
+
+        // close menu
+        setOpen(false)
+      }
+    }
+
+    function keydownHandler(e: KeyboardEvent) {
+      if (e.key === 'Escape' && open) {
+        setOpen(false)
+      }
+    }
+
+    document.addEventListener('click', clickHandler, true)
+    window.addEventListener('keydown', keydownHandler, true)
+    return () => {
+      document.removeEventListener('click', clickHandler, true)
+      window.removeEventListener('keydown', keydownHandler, true)
+    }
+  }, [open, setOpen])
+
+  return (
+    <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}>
+      <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}>
+        <Pressable
+          ref={buttonRef as unknown as React.Ref<View>}
+          testID={testID}
+          accessibilityRole="button"
+          accessibilityLabel={accessibilityLabel}
+          accessibilityHint={accessibilityHint}
+          onPress={() => setOpen(o => !o)}
+          hitSlop={HITSLOP_10}>
+          {children}
+        </Pressable>
+      </DropdownMenu.Trigger>
+
+      <DropdownMenu.Portal>
+        <DropdownMenu.Content
+          ref={menuRef}
+          style={
+            StyleSheet.flatten([
+              styles.content,
+              dropDownBackgroundColor,
+            ]) as React.CSSProperties
+          }
+          loop>
+          {items.map((item, index) => {
+            if (item.label === 'separator') {
+              return (
+                <DropdownMenu.Separator
+                  key={getKey(item.label, index, item.testID)}
+                  style={
+                    StyleSheet.flatten([
+                      styles.separator,
+                      {backgroundColor: separatorColor},
+                    ]) as React.CSSProperties
+                  }
+                />
+              )
+            }
+            if (index > 1 && items[index - 1].label === 'separator') {
+              return (
+                <DropdownMenu.Group
+                  key={getKey(item.label, index, item.testID)}>
+                  <DropdownMenuItem
+                    key={getKey(item.label, index, item.testID)}
+                    onSelect={item.onPress}>
+                    <Text
+                      selectable={false}
+                      style={[pal.text, styles.itemTitle]}>
+                      {item.label}
+                    </Text>
+                    {item.icon && (
+                      <FontAwesomeIcon
+                        icon={item.icon.web}
+                        size={20}
+                        color={pal.colors.textLight}
+                      />
+                    )}
+                  </DropdownMenuItem>
+                </DropdownMenu.Group>
+              )
+            }
+            return (
+              <DropdownMenuItem
+                key={getKey(item.label, index, item.testID)}
+                onSelect={item.onPress}>
+                <Text selectable={false} style={[pal.text, styles.itemTitle]}>
+                  {item.label}
+                </Text>
+                {item.icon && (
+                  <FontAwesomeIcon
+                    icon={item.icon.web}
+                    size={20}
+                    color={pal.colors.textLight}
+                  />
+                )}
+              </DropdownMenuItem>
+            )
+          })}
+        </DropdownMenu.Content>
+      </DropdownMenu.Portal>
+    </DropdownMenuRoot>
+  )
+}
+
+const getKey = (label: string, index: number, id?: string) => {
+  if (id) {
+    return id
+  }
+  return `${label}_${index}`
+}
+
+const styles = StyleSheet.create({
+  separator: {
+    height: 1,
+    marginTop: 4,
+    marginBottom: 4,
+  },
+  content: {
+    backgroundColor: '#f0f0f0',
+    borderRadius: 8,
+    paddingTop: 4,
+    paddingBottom: 4,
+    paddingLeft: 4,
+    paddingRight: 4,
+    marginTop: 6,
+
+    // @ts-ignore web only -prf
+    boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
+  },
+  item: {
+    display: 'flex',
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    columnGap: 20,
+    // @ts-ignore -web
+    cursor: 'pointer',
+    paddingTop: 8,
+    paddingBottom: 8,
+    paddingLeft: 12,
+    paddingRight: 12,
+    borderRadius: 8,
+  },
+  itemTitle: {
+    fontSize: 16,
+    fontWeight: '500',
+    paddingRight: 10,
+  },
+})
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 1f2e067c2..b21caf2e7 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -2,7 +2,12 @@ import React, {memo} from 'react'
 import {Linking, StyleProp, View, ViewStyle} from 'react-native'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  AppBskyFeedPost,
+  AtUri,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {useTheme} from 'lib/ThemeContext'
 import {shareUrl} from 'lib/sharing'
@@ -24,6 +29,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSession} from '#/state/session'
 import {isWeb} from '#/platform/detection'
+import {richTextToString} from '#/lib/strings/rich-text-helpers'
 
 let PostDropdownBtn = ({
   testID,
@@ -31,6 +37,7 @@ let PostDropdownBtn = ({
   postCid,
   postUri,
   record,
+  richText,
   style,
   showAppealLabelItem,
 }: {
@@ -39,6 +46,7 @@ let PostDropdownBtn = ({
   postCid: string
   postUri: string
   record: AppBskyFeedPost.Record
+  richText: RichTextAPI
   style?: StyleProp<ViewStyle>
   showAppealLabelItem?: boolean
 }): React.ReactNode => {
@@ -71,32 +79,36 @@ let PostDropdownBtn = ({
   const onDeletePost = React.useCallback(() => {
     postDeleteMutation.mutateAsync({uri: postUri}).then(
       () => {
-        Toast.show('Post deleted')
+        Toast.show(_(msg`Post deleted`))
       },
       e => {
         logger.error('Failed to delete post', {error: e})
-        Toast.show('Failed to delete post, please try again')
+        Toast.show(_(msg`Failed to delete post, please try again`))
       },
     )
-  }, [postUri, postDeleteMutation])
+  }, [postUri, postDeleteMutation, _])
 
   const onToggleThreadMute = React.useCallback(() => {
     try {
       const muted = toggleThreadMute(rootUri)
       if (muted) {
-        Toast.show('You will no longer receive notifications for this thread')
+        Toast.show(
+          _(msg`You will no longer receive notifications for this thread`),
+        )
       } else {
-        Toast.show('You will now receive notifications for this thread')
+        Toast.show(_(msg`You will now receive notifications for this thread`))
       }
     } catch (e) {
       logger.error('Failed to toggle thread mute', {error: e})
     }
-  }, [rootUri, toggleThreadMute])
+  }, [rootUri, toggleThreadMute, _])
 
   const onCopyPostText = React.useCallback(() => {
-    Clipboard.setString(record?.text || '')
-    Toast.show('Copied to clipboard')
-  }, [record])
+    const str = richTextToString(richText, true)
+
+    Clipboard.setString(str)
+    Toast.show(_(msg`Copied to clipboard`))
+  }, [_, richText])
 
   const onOpenTranslate = React.useCallback(() => {
     Linking.openURL(translatorUrl)
@@ -253,7 +265,7 @@ let PostDropdownBtn = ({
       <NativeDropdown
         testID={testID}
         items={dropdownItems}
-        accessibilityLabel="More post options"
+        accessibilityLabel={_(msg`More post options`)}
         accessibilityHint="">
         <View style={style}>
           <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx
index 02b462b55..a78d23c9b 100644
--- a/src/view/com/util/forms/SearchInput.tsx
+++ b/src/view/com/util/forms/SearchInput.tsx
@@ -11,6 +11,7 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
+import {HITSLOP_10} from 'lib/constants'
 import {MagnifyingGlassIcon} from 'lib/icons'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -49,7 +50,7 @@ export function SearchInput({
       <TextInput
         testID="searchTextInput"
         ref={textInput}
-        placeholder="Search"
+        placeholder={_(msg`Search`)}
         placeholderTextColor={pal.colors.textLight}
         selectTextOnFocus
         returnKeyType="search"
@@ -71,7 +72,8 @@ export function SearchInput({
           onPress={onPressCancelSearchInner}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Clear search query`)}
-          accessibilityHint="">
+          accessibilityHint=""
+          hitSlop={HITSLOP_10}>
           <FontAwesomeIcon
             icon="xmark"
             size={16}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 6f203bf06..61cb6f69f 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -4,6 +4,8 @@ import {Image} from 'expo-image'
 import {clamp} from 'lib/numbers'
 import {Dimensions} from 'lib/media/types'
 import * as imageSizes from 'lib/media/image-sizes'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 const MIN_ASPECT_RATIO = 0.33 // 1/3
 const MAX_ASPECT_RATIO = 10 // 10/1
@@ -29,6 +31,7 @@ export function AutoSizedImage({
   style,
   children = null,
 }: Props) {
+  const {_} = useLingui()
   const [dim, setDim] = React.useState<Dimensions | undefined>(
     dimensionsHint || imageSizes.get(uri),
   )
@@ -64,7 +67,7 @@ export function AutoSizedImage({
           accessible={true} // Must set for `accessibilityLabel` to work
           accessibilityIgnoresInvertColors
           accessibilityLabel={alt}
-          accessibilityHint="Tap to view fully"
+          accessibilityHint={_(msg`Tap to view fully`)}
         />
         {children}
       </Pressable>
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 094b0c56c..e7110372c 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -2,6 +2,8 @@ import {AppBskyEmbedImages} from '@atproto/api'
 import React, {ComponentProps, FC} from 'react'
 import {StyleSheet, Text, Pressable, View} from 'react-native'
 import {Image} from 'expo-image'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type EventFunction = (index: number) => void
 
@@ -22,6 +24,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({
   onPressIn,
   onLongPress,
 }) => {
+  const {_} = useLingui()
   const image = images[index]
   return (
     <View style={styles.fullWidth}>
@@ -31,7 +34,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({
         onLongPress={onLongPress ? () => onLongPress(index) : undefined}
         style={styles.fullWidth}
         accessibilityRole="button"
-        accessibilityLabel={image.alt || 'Image'}
+        accessibilityLabel={image.alt || _(msg`Image`)}
         accessibilityHint="">
         <Image
           source={{uri: image.thumb}}
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index 1269b7ebf..b3a563116 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -63,7 +63,9 @@ export function ContentHider({
           }
         }}
         accessibilityRole="button"
-        accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+        accessibilityHint={
+          override ? _(msg`Hide the content`) : _(msg`Show the content`)
+        }
         accessibilityLabel=""
         style={[
           styles.cover,
@@ -92,7 +94,7 @@ export function ContentHider({
             <ShieldExclamation size={18} style={pal.textLight} />
           )}
         </Pressable>
-        <Text type="md" style={pal.text}>
+        <Text type="md" style={[pal.text, {flex: 1}]} numberOfLines={2}>
           {desc.name}
         </Text>
         <View style={styles.showBtn}>
@@ -129,7 +131,7 @@ const styles = StyleSheet.create({
   cover: {
     flexDirection: 'row',
     alignItems: 'center',
-    gap: 4,
+    gap: 6,
     borderRadius: 8,
     marginTop: 4,
     paddingVertical: 14,
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index bffb7ea1a..b1fa71d4a 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -9,7 +9,7 @@ import {addStyle} from 'lib/styles'
 import {describeModerationCause} from 'lib/moderation'
 import {ShieldExclamation} from 'lib/icons'
 import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
+import {Trans, msg} from '@lingui/macro'
 import {useModalControls} from '#/state/modals'
 
 interface Props extends ComponentProps<typeof Link> {
@@ -57,7 +57,9 @@ export function PostHider({
         }
       }}
       accessibilityRole="button"
-      accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+      accessibilityHint={
+        override ? _(msg`Hide the content`) : _(msg`Show the content`)
+      }
       accessibilityLabel=""
       style={[
         styles.description,
@@ -103,7 +105,7 @@ export function PostHider({
       </Text>
       {!moderation.noOverride && (
         <Text type="sm" style={[styles.showBtn, pal.link]}>
-          {override ? 'Hide' : 'Show'}
+          {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
         </Text>
       )}
     </Pressable>
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index a50b52175..50ef8a875 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -6,7 +6,11 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import {Text} from '../text/Text'
 import {PostDropdownBtn} from '../forms/PostDropdownBtn'
 import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
@@ -26,11 +30,14 @@ import {
 import {useComposerControls} from '#/state/shell/composer'
 import {Shadow} from '#/state/cache/types'
 import {useRequireAuth} from '#/state/session'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 let PostCtrls = ({
   big,
   post,
   record,
+  richText,
   showAppealLabelItem,
   style,
   onPressReply,
@@ -38,11 +45,13 @@ let PostCtrls = ({
   big?: boolean
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
+  richText: RichTextAPI
   showAppealLabelItem?: boolean
   style?: StyleProp<ViewStyle>
   onPressReply: () => void
 }): React.ReactNode => {
   const theme = useTheme()
+  const {_} = useLingui()
   const {openComposer} = useComposerControls()
   const {closeModal} = useModalControls()
   const postLikeMutation = usePostLikeMutation()
@@ -176,9 +185,9 @@ let PostCtrls = ({
           requireAuth(() => onPressToggleLike())
         }}
         accessibilityRole="button"
-        accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
-          post.likeCount
-        } ${pluralize(post.likeCount || 0, 'like')})`}
+        accessibilityLabel={`${
+          post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`)
+        } (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`}
         accessibilityHint=""
         hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
         {post.viewer?.like ? (
@@ -209,6 +218,7 @@ let PostCtrls = ({
           postCid={post.cid}
           postUri={post.uri}
           record={record}
+          richText={richText}
           showAppealLabelItem={showAppealLabelItem}
           style={styles.ctrlPad}
         />
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 620852d8e..d45bf1d87 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -8,6 +8,8 @@ import {pluralize} from 'lib/strings/helpers'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
 import {useModalControls} from '#/state/modals'
 import {useRequireAuth} from '#/state/session'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 interface Props {
   isReposted: boolean
@@ -25,6 +27,7 @@ let RepostButton = ({
   onQuote,
 }: Props): React.ReactNode => {
   const theme = useTheme()
+  const {_} = useLingui()
   const {openModal} = useModalControls()
   const requireAuth = useRequireAuth()
 
@@ -53,7 +56,9 @@ let RepostButton = ({
       style={[styles.control, !big && styles.controlPad]}
       accessibilityRole="button"
       accessibilityLabel={`${
-        isReposted ? 'Undo repost' : 'Repost'
+        isReposted
+          ? _(msg`Undo repost`)
+          : _(msg({message: 'Repost', context: 'action'}))
       } (${repostCount} ${pluralize(repostCount || 0, 'repost')})`}
       accessibilityHint=""
       hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
new file mode 100644
index 000000000..f06c8b794
--- /dev/null
+++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
@@ -0,0 +1,170 @@
+import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player'
+import React from 'react'
+import {Image, ImageLoadEventData} from 'expo-image'
+import {
+  ActivityIndicator,
+  GestureResponderEvent,
+  LayoutChangeEvent,
+  Pressable,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useExternalEmbedsPrefs} from 'state/preferences'
+import {useModalControls} from 'state/modals'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {AppBskyEmbedExternal} from '@atproto/api'
+
+export function ExternalGifEmbed({
+  link,
+  params,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+}) {
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const {openModal} = useModalControls()
+  const {_} = useLingui()
+
+  const thumbHasLoaded = React.useRef(false)
+  const viewWidth = React.useRef(0)
+
+  // Tracking if the placer has been activated
+  const [isPlayerActive, setIsPlayerActive] = React.useState(false)
+  // Tracking whether the gif has been loaded yet
+  const [isPrefetched, setIsPrefetched] = React.useState(false)
+  // Tracking whether the image is animating
+  const [isAnimating, setIsAnimating] = React.useState(true)
+  const [imageDims, setImageDims] = React.useState({height: 100, width: 1})
+
+  // Used for controlling animation
+  const imageRef = React.useRef<Image>(null)
+
+  const load = React.useCallback(() => {
+    setIsPlayerActive(true)
+    Image.prefetch(params.playerUri).then(() => {
+      // Replace the image once it's fetched
+      setIsPrefetched(true)
+    })
+  }, [params.playerUri])
+
+  const onPlayPress = React.useCallback(
+    (event: GestureResponderEvent) => {
+      // Don't propagate on web
+      event.preventDefault()
+
+      // Show consent if this is the first load
+      if (externalEmbedsPrefs?.[params.source] === undefined) {
+        openModal({
+          name: 'embed-consent',
+          source: params.source,
+          onAccept: load,
+        })
+        return
+      }
+      // If the player isn't active, we want to activate it and prefetch the gif
+      if (!isPlayerActive) {
+        load()
+        return
+      }
+      // Control animation on native
+      setIsAnimating(prev => {
+        if (prev) {
+          if (isNative) {
+            imageRef.current?.stopAnimating()
+          }
+          return false
+        } else {
+          if (isNative) {
+            imageRef.current?.startAnimating()
+          }
+          return true
+        }
+      })
+    },
+    [externalEmbedsPrefs, isPlayerActive, load, openModal, params.source],
+  )
+
+  const onLoad = React.useCallback((e: ImageLoadEventData) => {
+    if (thumbHasLoaded.current) return
+    setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current))
+    thumbHasLoaded.current = true
+  }, [])
+
+  const onLayout = React.useCallback((e: LayoutChangeEvent) => {
+    viewWidth.current = e.nativeEvent.layout.width
+  }, [])
+
+  return (
+    <Pressable
+      style={[
+        {height: imageDims.height},
+        styles.topRadius,
+        styles.gifContainer,
+      ]}
+      onPress={onPlayPress}
+      onLayout={onLayout}
+      accessibilityRole="button"
+      accessibilityHint={_(msg`Plays the GIF`)}
+      accessibilityLabel={_(msg`Play ${link.title}`)}>
+      {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay
+        <View style={[styles.layer, styles.overlayLayer]}>
+          <View style={[styles.overlayContainer, styles.topRadius]}>
+            {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
+              <FontAwesomeIcon icon="play" size={42} color="white" />
+            ) : (
+              // Activity indicator while gif loads
+              <ActivityIndicator size="large" color="white" />
+            )}
+          </View>
+        </View>
+      )}
+      <Image
+        source={{
+          uri:
+            !isPrefetched || (isWeb && !isAnimating)
+              ? link.thumb
+              : params.playerUri,
+        }} // Web uses the thumb to control playback
+        style={{flex: 1}}
+        ref={imageRef}
+        onLoad={onLoad}
+        autoplay={isAnimating}
+        contentFit="contain"
+        accessibilityIgnoresInvertColors
+        accessibilityLabel={link.title}
+        accessibilityHint={link.title}
+        cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
+      />
+    </Pressable>
+  )
+}
+
+const styles = StyleSheet.create({
+  topRadius: {
+    borderTopLeftRadius: 6,
+    borderTopRightRadius: 6,
+  },
+  layer: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  overlayContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: 'rgba(0,0,0,0.5)',
+  },
+  overlayLayer: {
+    zIndex: 2,
+  },
+  gifContainer: {
+    width: '100%',
+    overflow: 'hidden',
+  },
+})
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 27aa804d3..aaa98a41f 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -8,6 +8,8 @@ import {AppBskyEmbedExternal} from '@atproto/api'
 import {toNiceDomain} from 'lib/strings/url-helpers'
 import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
 import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
+import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
+import {useExternalEmbedsPrefs} from 'state/preferences'
 
 export const ExternalLinkEmbed = ({
   link,
@@ -16,69 +18,47 @@ export const ExternalLinkEmbed = ({
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+  const externalEmbedPrefs = useExternalEmbedsPrefs()
 
-  const embedPlayerParams = React.useMemo(
-    () => parseEmbedPlayerFromUrl(link.uri),
-    [link.uri],
-  )
+  const embedPlayerParams = React.useMemo(() => {
+    const params = parseEmbedPlayerFromUrl(link.uri)
+
+    if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
+      return params
+    }
+  }, [link.uri, externalEmbedPrefs])
 
   return (
-    <View
-      style={{
-        flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column',
-      }}>
+    <View style={styles.container}>
       {link.thumb && !embedPlayerParams ? (
-        <View
-          style={
-            !isMobile
-              ? {
-                  borderTopLeftRadius: 6,
-                  borderBottomLeftRadius: 6,
-                  width: 120,
-                  aspectRatio: 1,
-                  overflow: 'hidden',
-                }
-              : {
-                  borderTopLeftRadius: 6,
-                  borderTopRightRadius: 6,
-                  width: '100%',
-                  height: 200,
-                  overflow: 'hidden',
-                }
-          }>
-          <Image
-            style={styles.extImage}
-            source={{uri: link.thumb}}
-            accessibilityIgnoresInvertColors
-          />
-        </View>
+        <Image
+          style={{aspectRatio: 1.91}}
+          source={{uri: link.thumb}}
+          accessibilityIgnoresInvertColors
+        />
       ) : undefined}
-      {embedPlayerParams && (
-        <ExternalPlayer link={link} params={embedPlayerParams} />
-      )}
-      <View
-        style={{
-          paddingHorizontal: isMobile ? 10 : 14,
-          paddingTop: 8,
-          paddingBottom: 10,
-          flex: !isMobile ? 1 : undefined,
-        }}>
+      {(embedPlayerParams?.isGif && (
+        <ExternalGifEmbed link={link} params={embedPlayerParams} />
+      )) ||
+        (embedPlayerParams && (
+          <ExternalPlayer link={link} params={embedPlayerParams} />
+        ))}
+      <View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}>
         <Text
           type="sm"
           numberOfLines={1}
           style={[pal.textLight, styles.extUri]}>
           {toNiceDomain(link.uri)}
         </Text>
-        <Text
-          type="lg-bold"
-          numberOfLines={isMobile ? 4 : 2}
-          style={[pal.text]}>
-          {link.title || link.uri}
-        </Text>
-        {link.description ? (
+        {!embedPlayerParams?.isGif && (
+          <Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
+            {link.title || link.uri}
+          </Text>
+        )}
+        {link.description && !embedPlayerParams?.hideDetails ? (
           <Text
             type="md"
-            numberOfLines={isMobile ? 4 : 2}
+            numberOfLines={link.thumb ? 2 : 4}
             style={[pal.text, styles.extDescription]}>
             {link.description}
           </Text>
@@ -89,9 +69,16 @@ export const ExternalLinkEmbed = ({
 }
 
 const styles = StyleSheet.create({
-  extImage: {
+  container: {
+    flexDirection: 'column',
+    borderRadius: 6,
+    overflow: 'hidden',
+  },
+  info: {
     width: '100%',
-    height: 200,
+    bottom: 0,
+    paddingTop: 8,
+    paddingBottom: 10,
   },
   extUri: {
     marginTop: 2,
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
index 580cf363a..8b0858b69 100644
--- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -1,22 +1,32 @@
 import React from 'react'
 import {
   ActivityIndicator,
-  Dimensions,
   GestureResponderEvent,
   Pressable,
   StyleSheet,
+  useWindowDimensions,
   View,
 } from 'react-native'
+import Animated, {
+  measure,
+  runOnJS,
+  useAnimatedRef,
+  useFrameCallback,
+} from 'react-native-reanimated'
 import {Image} from 'expo-image'
 import {WebView} from 'react-native-webview'
-import YoutubePlayer from 'react-native-youtube-iframe'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {AppBskyEmbedExternal} from '@atproto/api'
 import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
 import {EventStopper} from '../EventStopper'
-import {AppBskyEmbedExternal} from '@atproto/api'
 import {isNative} from 'platform/detection'
-import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
+import {useExternalEmbedsPrefs} from 'state/preferences'
+import {useModalControls} from 'state/modals'
 
 interface ShouldStartLoadRequest {
   url: string
@@ -32,6 +42,8 @@ function PlaceholderOverlay({
   isPlayerActive: boolean
   onPress: (event: GestureResponderEvent) => void
 }) {
+  const {_} = useLingui()
+
   // If the player is active and not loading, we don't want to show the overlay.
   if (isPlayerActive && !isLoading) return null
 
@@ -39,8 +51,8 @@ function PlaceholderOverlay({
     <View style={[styles.layer, styles.overlayLayer]}>
       <Pressable
         accessibilityRole="button"
-        accessibilityLabel="Play Video"
-        accessibilityHint=""
+        accessibilityLabel={_(msg`Play Video`)}
+        accessibilityHint={_(msg`Play Video`)}
         onPress={onPress}
         style={[styles.overlayContainer, styles.topRadius]}>
         {!isPlayerActive ? (
@@ -77,31 +89,21 @@ function Player({
   return (
     <View style={[styles.layer, styles.playerLayer]}>
       <EventStopper>
-        {isNative && params.type === 'youtube_video' ? (
-          <YoutubePlayer
-            videoId={params.videoId}
-            play
-            height={height}
-            onReady={onLoad}
-            webViewStyle={[styles.webview, styles.topRadius]}
+        <View style={{height, width: '100%'}}>
+          <WebView
+            javaScriptEnabled={true}
+            onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+            mediaPlaybackRequiresUserAction={false}
+            allowsInlineMediaPlayback
+            bounces={false}
+            allowsFullscreenVideo
+            nestedScrollEnabled
+            source={{uri: params.playerUri}}
+            onLoad={onLoad}
+            setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
+            style={[styles.webview, styles.topRadius]}
           />
-        ) : (
-          <View style={{height, width: '100%'}}>
-            <WebView
-              javaScriptEnabled={true}
-              onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
-              mediaPlaybackRequiresUserAction={false}
-              allowsInlineMediaPlayback
-              bounces={false}
-              allowsFullscreenVideo
-              nestedScrollEnabled
-              source={{uri: params.playerUri}}
-              onLoad={onLoad}
-              setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
-              style={[styles.webview, styles.topRadius]}
-            />
-          </View>
-        )}
+        </View>
       </EventStopper>
     </View>
   )
@@ -116,6 +118,10 @@ export function ExternalPlayer({
   params: EmbedPlayerParams
 }) {
   const navigation = useNavigation<NavigationProp>()
+  const insets = useSafeAreaInsets()
+  const windowDims = useWindowDimensions()
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const {openModal} = useModalControls()
 
   const [isPlayerActive, setPlayerActive] = React.useState(false)
   const [isLoading, setIsLoading] = React.useState(true)
@@ -124,34 +130,51 @@ export function ExternalPlayer({
     height: 0,
   })
 
-  const viewRef = React.useRef<View>(null)
+  const viewRef = useAnimatedRef()
+
+  const frameCallback = useFrameCallback(() => {
+    const measurement = measure(viewRef)
+    if (!measurement) return
+
+    const {height: winHeight, width: winWidth} = windowDims
+
+    // Get the proper screen height depending on what is going on
+    const realWinHeight = isNative // If it is native, we always want the larger number
+      ? winHeight > winWidth
+        ? winHeight
+        : winWidth
+      : winHeight // On web, we always want the actual screen height
+
+    const top = measurement.pageY
+    const bot = measurement.pageY + measurement.height
+
+    // We can use the same logic on all platforms against the screenHeight that we get above
+    const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top
+
+    if (!isVisible) {
+      runOnJS(setPlayerActive)(false)
+    }
+  }, false) // False here disables autostarting the callback
 
   // watch for leaving the viewport due to scrolling
   React.useEffect(() => {
+    // We don't want to do anything if the player isn't active
+    if (!isPlayerActive) return
+
     // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
     // continue playing. We need to watch for the blur event
     const unsubscribe = navigation.addListener('blur', () => {
       setPlayerActive(false)
     })
 
-    const interval = setInterval(() => {
-      viewRef.current?.measure((x, y, w, h, pageX, pageY) => {
-        const window = Dimensions.get('window')
-        const top = pageY
-        const bot = pageY + h
-        const isVisible = isNative
-          ? top >= 0 && bot <= window.height
-          : !(top >= window.height || bot <= 0)
-        if (!isVisible) {
-          setPlayerActive(false)
-        }
-      })
-    }, 1e3)
+    // Start watching for changes
+    frameCallback.setActive(true)
+
     return () => {
       unsubscribe()
-      clearInterval(interval)
+      frameCallback.setActive(false)
     }
-  }, [viewRef, navigation])
+  }, [navigation, isPlayerActive, frameCallback])
 
   // calculate height for the player and the screen size
   const height = React.useMemo(
@@ -168,12 +191,26 @@ export function ExternalPlayer({
     setIsLoading(false)
   }, [])
 
-  const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
-    // Prevent this from propagating upward on web
-    event.preventDefault()
+  const onPlayPress = React.useCallback(
+    (event: GestureResponderEvent) => {
+      // Prevent this from propagating upward on web
+      event.preventDefault()
 
-    setPlayerActive(true)
-  }, [])
+      if (externalEmbedsPrefs?.[params.source] === undefined) {
+        openModal({
+          name: 'embed-consent',
+          source: params.source,
+          onAccept: () => {
+            setPlayerActive(true)
+          },
+        })
+        return
+      }
+
+      setPlayerActive(true)
+    },
+    [externalEmbedsPrefs, openModal, params.source],
+  )
 
   // measure the layout to set sizing
   const onLayout = React.useCallback(
@@ -187,7 +224,7 @@ export function ExternalPlayer({
   )
 
   return (
-    <View
+    <Animated.View
       ref={viewRef}
       style={{height}}
       collapsable={false}
@@ -205,7 +242,6 @@ export function ExternalPlayer({
           accessibilityIgnoresInvertColors
         />
       )}
-
       <PlaceholderOverlay
         isLoading={isLoading}
         isPlayerActive={isPlayerActive}
@@ -217,7 +253,7 @@ export function ExternalPlayer({
         height={height}
         onLoad={onLoad}
       />
-    </View>
+    </Animated.View>
   )
 }
 
@@ -248,4 +284,8 @@ const styles = StyleSheet.create({
   webview: {
     backgroundColor: 'transparent',
   },
+  gifContainer: {
+    width: '100%',
+    overflow: 'hidden',
+  },
 })
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index e793f983e..256817bba 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -6,6 +6,8 @@ import {
   AppBskyEmbedImages,
   AppBskyEmbedRecordWithMedia,
   ModerationUI,
+  AppBskyEmbedExternal,
+  RichText as RichTextAPI,
 } from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {PostMeta} from '../PostMeta'
@@ -17,6 +19,8 @@ import {PostEmbeds} from '.'
 import {PostAlerts} from '../moderation/PostAlerts'
 import {makeProfileLink} from 'lib/routes/links'
 import {InfoCircleIcon} from 'lib/icons'
+import {Trans} from '@lingui/macro'
+import {RichText} from 'view/com/util/text/RichText'
 
 export function MaybeQuoteEmbed({
   embed,
@@ -41,6 +45,7 @@ export function MaybeQuoteEmbed({
           uri: embed.record.uri,
           indexedAt: embed.record.indexedAt,
           text: embed.record.value.text,
+          facets: embed.record.value.facets,
           embeds: embed.record.embeds,
         }}
         moderation={moderation}
@@ -52,7 +57,7 @@ export function MaybeQuoteEmbed({
       <View style={[styles.errorContainer, pal.borderDark]}>
         <InfoCircleIcon size={18} style={pal.text} />
         <Text type="lg" style={pal.text}>
-          Blocked
+          <Trans>Blocked</Trans>
         </Text>
       </View>
     )
@@ -61,7 +66,7 @@ export function MaybeQuoteEmbed({
       <View style={[styles.errorContainer, pal.borderDark]}>
         <InfoCircleIcon size={18} style={pal.text} />
         <Text type="lg" style={pal.text}>
-          Deleted
+          <Trans>Deleted</Trans>
         </Text>
       </View>
     )
@@ -82,22 +87,30 @@ export function QuoteEmbed({
   const itemUrip = new AtUri(quote.uri)
   const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
   const itemTitle = `Post by ${quote.author.handle}`
-  const isEmpty = React.useMemo(
-    () => quote.text.trim().length === 0,
-    [quote.text],
-  )
-  const imagesEmbed = React.useMemo(
+  const richText = React.useMemo(
     () =>
-      quote.embeds?.find(
-        embed =>
-          AppBskyEmbedImages.isView(embed) ||
-          AppBskyEmbedRecordWithMedia.isView(embed),
-      ),
-    [quote.embeds],
+      quote.text.trim()
+        ? new RichTextAPI({text: quote.text, facets: quote.facets})
+        : undefined,
+    [quote.text, quote.facets],
   )
+  const embed = React.useMemo(() => {
+    const e = quote.embeds?.[0]
+
+    if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) {
+      return e
+    } else if (
+      AppBskyEmbedRecordWithMedia.isView(e) &&
+      (AppBskyEmbedImages.isView(e.media) ||
+        AppBskyEmbedExternal.isView(e.media))
+    ) {
+      return e.media
+    }
+  }, [quote.embeds])
   return (
     <Link
       style={[styles.container, pal.borderDark, style]}
+      hoverStyle={{borderColor: pal.colors.borderLinkHover}}
       href={itemHref}
       title={itemTitle}>
       <PostMeta
@@ -110,17 +123,16 @@ export function QuoteEmbed({
       {moderation ? (
         <PostAlerts moderation={moderation} style={styles.alert} />
       ) : null}
-      {!isEmpty ? (
-        <Text type="post-text" style={pal.text} numberOfLines={6}>
-          {quote.text}
-        </Text>
+      {richText ? (
+        <RichText
+          richText={richText}
+          type="post-text"
+          style={pal.text}
+          numberOfLines={20}
+          noLinks
+        />
       ) : null}
-      {AppBskyEmbedImages.isView(imagesEmbed) && (
-        <PostEmbeds embed={imagesEmbed} moderation={{}} />
-      )}
-      {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && (
-        <PostEmbeds embed={imagesEmbed.media} moderation={{}} />
-      )}
+      {embed && <PostEmbeds embed={embed} moderation={{}} />}
     </Link>
   )
 }
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index c94ce9684..6f168a293 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -22,7 +22,6 @@ import {Link} from '../Link'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
 import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
@@ -51,7 +50,6 @@ export function PostEmbeds({
 }) {
   const pal = usePalette('default')
   const {openLightbox} = useLightboxControls()
-  const {isMobile} = useWebMediaQueries()
 
   // quote post with media
   // =
@@ -63,7 +61,7 @@ export function PostEmbeds({
     const mediaModeration = isModOnQuote ? {} : moderation
     const quoteModeration = isModOnQuote ? moderation : {}
     return (
-      <View style={[styles.stackContainer, style]}>
+      <View style={style}>
         <PostEmbeds embed={embed.media} moderation={mediaModeration} />
         <ContentHider moderation={quoteModeration}>
           <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} />
@@ -129,10 +127,7 @@ export function PostEmbeds({
               dimensionsHint={aspectRatio}
               onPress={() => _openLightbox(0)}
               onPressIn={() => onPressIn(0)}
-              style={[
-                styles.singleImage,
-                isMobile && styles.singleImageMobile,
-              ]}>
+              style={[styles.singleImage]}>
               {alt === '' ? null : (
                 <View style={styles.altContainer}>
                   <Text style={styles.alt} accessible={false}>
@@ -151,11 +146,7 @@ export function PostEmbeds({
             images={embed.images}
             onPress={_openLightbox}
             onPressIn={onPressIn}
-            style={
-              embed.images.length === 1
-                ? [styles.singleImage, isMobile && styles.singleImageMobile]
-                : undefined
-            }
+            style={embed.images.length === 1 ? [styles.singleImage] : undefined}
           />
         </View>
       )
@@ -168,11 +159,14 @@ export function PostEmbeds({
     const link = embed.external
 
     return (
-      <View style={[styles.extOuter, pal.view, pal.border, style]}>
-        <Link asAnchor href={link.uri}>
-          <ExternalLinkEmbed link={link} />
-        </Link>
-      </View>
+      <Link
+        asAnchor
+        anchorNoUnderline
+        href={link.uri}
+        style={[styles.extOuter, pal.view, pal.borderDark, style]}
+        hoverStyle={{borderColor: pal.colors.borderLinkHover}}>
+        <ExternalLinkEmbed link={link} />
+      </Link>
     )
   }
 
@@ -180,18 +174,11 @@ export function PostEmbeds({
 }
 
 const styles = StyleSheet.create({
-  stackContainer: {
-    gap: 6,
-  },
   imagesContainer: {
     marginTop: 8,
   },
   singleImage: {
     borderRadius: 8,
-    maxHeight: 1000,
-  },
-  singleImageMobile: {
-    maxHeight: 500,
   },
   extOuter: {
     borderWidth: 1,
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index 99062e848..e910127fe 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -17,6 +17,8 @@ export function RichText({
   lineHeight = 1.2,
   style,
   numberOfLines,
+  selectable,
+  noLinks,
 }: {
   testID?: string
   type?: TypographyVariant
@@ -24,6 +26,8 @@ export function RichText({
   lineHeight?: number
   style?: StyleProp<TextStyle>
   numberOfLines?: number
+  selectable?: boolean
+  noLinks?: boolean
 }) {
   const theme = useTheme()
   const pal = usePalette('default')
@@ -42,7 +46,11 @@ export function RichText({
       }
       return (
         // @ts-ignore web only -prf
-        <Text testID={testID} style={[style, pal.text]} dataSet={WORD_WRAP}>
+        <Text
+          testID={testID}
+          style={[style, pal.text]}
+          dataSet={WORD_WRAP}
+          selectable={selectable}>
           {text}
         </Text>
       )
@@ -54,7 +62,8 @@ export function RichText({
         style={[style, pal.text, lineHeightStyle]}
         numberOfLines={numberOfLines}
         // @ts-ignore web only -prf
-        dataSet={WORD_WRAP}>
+        dataSet={WORD_WRAP}
+        selectable={selectable}>
         {text}
       </Text>
     )
@@ -70,7 +79,11 @@ export function RichText({
   for (const segment of richText.segments()) {
     const link = segment.link
     const mention = segment.mention
-    if (mention && AppBskyRichtextFacet.validateMention(mention).success) {
+    if (
+      !noLinks &&
+      mention &&
+      AppBskyRichtextFacet.validateMention(mention).success
+    ) {
       els.push(
         <TextLink
           key={key}
@@ -79,20 +92,26 @@ export function RichText({
           href={`/profile/${mention.did}`}
           style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
           dataSet={WORD_WRAP}
+          selectable={selectable}
         />,
       )
     } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
-      els.push(
-        <TextLink
-          key={key}
-          type={type}
-          text={toShortUrl(segment.text)}
-          href={link.uri}
-          style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
-          dataSet={WORD_WRAP}
-          warnOnMismatchingLabel
-        />,
-      )
+      if (noLinks) {
+        els.push(toShortUrl(segment.text))
+      } else {
+        els.push(
+          <TextLink
+            key={key}
+            type={type}
+            text={toShortUrl(segment.text)}
+            href={link.uri}
+            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
+            dataSet={WORD_WRAP}
+            warnOnMismatchingLabel
+            selectable={selectable}
+          />,
+        )
+      }
     } else {
       els.push(segment.text)
     }
@@ -105,7 +124,8 @@ export function RichText({
       style={[style, pal.text, lineHeightStyle]}
       numberOfLines={numberOfLines}
       // @ts-ignore web only -prf
-      dataSet={WORD_WRAP}>
+      dataSet={WORD_WRAP}
+      selectable={selectable}>
       {els}
     </Text>
   )
diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx
index ea97d59fe..ccb51bfca 100644
--- a/src/view/com/util/text/Text.tsx
+++ b/src/view/com/util/text/Text.tsx
@@ -2,12 +2,15 @@ import React from 'react'
 import {Text as RNText, TextProps} from 'react-native'
 import {s, lh} from 'lib/styles'
 import {useTheme, TypographyVariant} from 'lib/ThemeContext'
+import {isIOS} from 'platform/detection'
+import {UITextView} from 'react-native-ui-text-view'
 
 export type CustomTextProps = TextProps & {
   type?: TypographyVariant
   lineHeight?: number
   title?: string
   dataSet?: Record<string, string | number>
+  selectable?: boolean
 }
 
 export function Text({
@@ -17,16 +20,29 @@ export function Text({
   style,
   title,
   dataSet,
+  selectable,
   ...props
 }: React.PropsWithChildren<CustomTextProps>) {
   const theme = useTheme()
   const typography = theme.typography[type]
   const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
+
+  if (selectable && isIOS) {
+    return (
+      <UITextView
+        style={[s.black, typography, lineHeightStyle, style]}
+        {...props}>
+        {children}
+      </UITextView>
+    )
+  }
+
   return (
     <RNText
       style={[s.black, typography, lineHeightStyle, style]}
       // @ts-ignore web only -esb
       dataSet={Object.assign({tooltip: title}, dataSet || {})}
+      selectable={selectable}
       {...props}>
       {children}
     </RNText>
diff --git a/src/view/icons/Logo.tsx b/src/view/icons/Logo.tsx
index 15ab5a11c..9212381a9 100644
--- a/src/view/icons/Logo.tsx
+++ b/src/view/icons/Logo.tsx
@@ -1,4 +1,5 @@
 import React from 'react'
+import {StyleSheet, TextProps} from 'react-native'
 import Svg, {
   Path,
   Defs,
@@ -14,12 +15,14 @@ const ratio = 57 / 64
 
 type Props = {
   fill?: PathProps['fill']
-} & SvgProps
+  style?: TextProps['style']
+} & Omit<SvgProps, 'style'>
 
 export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) {
   const {fill, ...rest} = props
   const gradient = fill === 'sky'
-  const _fill = gradient ? 'url(#sky)' : fill || colors.blue3
+  const styles = StyleSheet.flatten(props.style)
+  const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3
   // @ts-ignore it's fiiiiine
   const size = parseInt(rest.width || 32)
   return (
@@ -29,7 +32,7 @@ export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) {
       ref={ref}
       viewBox="0 0 64 57"
       {...rest}
-      style={{width: size, height: size * ratio}}>
+      style={[{width: size, height: size * ratio}, styles]}>
       {gradient && (
         <Defs>
           <LinearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx
index 089d3f0a8..be139d2f2 100644
--- a/src/view/icons/index.tsx
+++ b/src/view/icons/index.tsx
@@ -29,9 +29,10 @@ import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
 import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
 import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
 import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
+import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
 import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation'
+import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay'
 import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser'
-import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
 import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
@@ -51,6 +52,7 @@ import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
 import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
 import {faHand} from '@fortawesome/free-solid-svg-icons/faHand'
 import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand'
+import {faHashtag} from '@fortawesome/free-solid-svg-icons/faHashtag'
 import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
 import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart'
 import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse'
@@ -70,6 +72,7 @@ import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste'
 import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
 import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
 import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
+import {faPhone} from '@fortawesome/free-solid-svg-icons/faPhone'
 import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
 import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus'
 import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft'
@@ -77,6 +80,7 @@ import {faReply} from '@fortawesome/free-solid-svg-icons/faReply'
 import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet'
 import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
 import {faSatelliteDish} from '@fortawesome/free-solid-svg-icons/faSatelliteDish'
+import {faServer} from '@fortawesome/free-solid-svg-icons/faServer'
 import {faShare} from '@fortawesome/free-solid-svg-icons/faShare'
 import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
 import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
@@ -129,9 +133,10 @@ library.add(
   faCircle,
   faCircleCheck,
   farCircleCheck,
+  faCircleDot,
   faCircleExclamation,
+  faCirclePlay,
   faCircleUser,
-  faCircleDot,
   faClone,
   farClone,
   faComment,
@@ -151,6 +156,7 @@ library.add(
   faGlobe,
   faHand,
   farHand,
+  faHashtag,
   faHeart,
   fasHeart,
   faHouse,
@@ -170,6 +176,7 @@ library.add(
   faPen,
   faPenNib,
   faPenToSquare,
+  faPhone,
   faPlay,
   faPlus,
   faQuoteLeft,
@@ -177,6 +184,7 @@ library.add(
   faRetweet,
   faRss,
   faSatelliteDish,
+  faServer,
   faShare,
   faShareFromSquare,
   faShield,
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index 154035f22..dc439c367 100644
--- a/src/view/screens/AppPasswords.tsx
+++ b/src/view/screens/AppPasswords.tsx
@@ -33,6 +33,7 @@ import {cleanError} from '#/lib/strings/errors'
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
 export function AppPasswords({}: Props) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const setMinimalShellMode = useSetMinimalShellMode()
   const {screen} = useAnalytics()
   const {isTabletOrDesktop} = useWebMediaQueries()
@@ -61,8 +62,8 @@ export function AppPasswords({}: Props) {
         ]}
         testID="appPasswordsScreen">
         <ErrorScreen
-          title="Oops!"
-          message="There was an issue with fetching your app passwords"
+          title={_(msg`Oops!`)}
+          message={_(msg`There was an issue with fetching your app passwords`)}
           details={cleanError(error)}
         />
       </CenteredView>
@@ -98,7 +99,7 @@ export function AppPasswords({}: Props) {
           <Button
             testID="appPasswordBtn"
             type="primary"
-            label="Add App Password"
+            label={_(msg`Add App Password`)}
             style={styles.btn}
             labelStyle={styles.btnLabel}
             onPress={onAdd}
@@ -139,7 +140,7 @@ export function AppPasswords({}: Props) {
               <Button
                 testID="appPasswordBtn"
                 type="primary"
-                label="Add App Password"
+                label={_(msg`Add App Password`)}
                 style={styles.btn}
                 labelStyle={styles.btnLabel}
                 onPress={onAdd}
@@ -152,7 +153,7 @@ export function AppPasswords({}: Props) {
             <Button
               testID="appPasswordBtn"
               type="primary"
-              label="Add App Password"
+              label={_(msg`Add App Password`)}
               style={styles.btn}
               labelStyle={styles.btnLabel}
               onPress={onAdd}
@@ -224,7 +225,7 @@ function AppPassword({
       ),
       async onPressConfirm() {
         await deleteMutation.mutateAsync({name})
-        Toast.show('App password deleted')
+        Toast.show(_(msg`App password deleted`))
       },
     })
   }, [deleteMutation, openModal, name, _])
diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx
index 0e0464200..f26b1505a 100644
--- a/src/view/screens/Debug.tsx
+++ b/src/view/screens/Debug.tsx
@@ -16,6 +16,8 @@ import {ToggleButton} from '../com/util/forms/ToggleButton'
 import {RadioGroup} from '../com/util/forms/RadioGroup'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
 import {ErrorMessage} from '../com/util/error/ErrorMessage'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 const MAIN_VIEWS = ['Base', 'Controls', 'Error', 'Notifs']
 
@@ -48,6 +50,7 @@ function DebugInner({
 }) {
   const [currentView, setCurrentView] = React.useState<number>(0)
   const pal = usePalette('default')
+  const {_} = useLingui()
 
   const renderItem = (item: any) => {
     return (
@@ -57,7 +60,7 @@ function DebugInner({
             type="default-light"
             onPress={onToggleColorScheme}
             isSelected={colorScheme === 'dark'}
-            label="Dark mode"
+            label={_(msg`Dark mode`)}
           />
         </View>
         {item.currentView === 3 ? (
@@ -77,7 +80,7 @@ function DebugInner({
 
   return (
     <View style={[s.hContentRegion, pal.view]}>
-      <ViewHeader title="Debug panel" />
+      <ViewHeader title={_(msg`Debug panel`)} />
       <ViewSelector
         swipeEnabled
         sections={MAIN_VIEWS}
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 20cdf815a..a913364d4 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -97,6 +97,7 @@ export function FeedsScreen(_props: Props) {
     data: preferences,
     isLoading: isPreferencesLoading,
     error: preferencesError,
+    refetch: refetchPreferences,
   } = usePreferencesQuery()
   const {
     data: popularFeeds,
@@ -151,9 +152,12 @@ export function FeedsScreen(_props: Props) {
   }, [query, debouncedSearch])
   const onPullToRefresh = React.useCallback(async () => {
     setIsPTR(true)
-    await refetchPopularFeeds()
+    await Promise.all([
+      refetchPreferences().catch(_e => undefined),
+      refetchPopularFeeds().catch(_e => undefined),
+    ])
     setIsPTR(false)
-  }, [setIsPTR, refetchPopularFeeds])
+  }, [setIsPTR, refetchPreferences, refetchPopularFeeds])
   const onEndReached = React.useCallback(() => {
     if (
       isPopularFeedsFetching ||
@@ -328,7 +332,7 @@ export function FeedsScreen(_props: Props) {
         hitSlop={10}
         accessibilityRole="button"
         accessibilityLabel={_(msg`Edit Saved Feeds`)}
-        accessibilityHint="Opens screen to edit Saved Feeds">
+        accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
         <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
       </Link>
     )
@@ -494,6 +498,8 @@ export function FeedsScreen(_props: Props) {
         // @ts-ignore our .web version only -prf
         desktopFixedHeight
         scrollIndicatorInsets={{right: 1}}
+        keyboardShouldPersistTaps="handled"
+        keyboardDismissMode="on-drag"
       />
 
       {hasSession && (
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index b8033f0b4..7d6a40f02 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -109,7 +109,9 @@ function HomeScreenReady({
   const homeFeedParams = React.useMemo<FeedParams>(() => {
     return {
       mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled),
-      mergeFeedSources: preferences.feeds.saved,
+      mergeFeedSources: preferences.feedViewPrefs.lab_mergeFeedEnabled
+        ? preferences.feeds.saved
+        : [],
     }
   }, [preferences])
 
diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx
index d28db7c6c..bdd5dd9b7 100644
--- a/src/view/screens/Lists.tsx
+++ b/src/view/screens/Lists.tsx
@@ -61,7 +61,7 @@ export function ListsScreen({}: Props) {
             <Trans>Public, shareable lists which can drive feeds.</Trans>
           </Text>
         </View>
-        <View>
+        <View style={[{marginLeft: 18}, isMobile && {marginLeft: 12}]}>
           <Button
             testID="newUserListBtn"
             type="default"
@@ -73,7 +73,7 @@ export function ListsScreen({}: Props) {
             }}>
             <FontAwesomeIcon icon="plus" color={pal.colors.text} />
             <Text type="button" style={pal.text}>
-              <Trans>New</Trans>
+              <Trans context="action">New</Trans>
             </Text>
           </Button>
         </View>
diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx
index 8680b851b..e727a1fb8 100644
--- a/src/view/screens/Log.tsx
+++ b/src/view/screens/Log.tsx
@@ -50,7 +50,9 @@ export function LogScreen({}: NativeStackScreenProps<
                   style={[styles.entry, pal.border, pal.view]}
                   onPress={toggler(entry.id)}
                   accessibilityLabel={_(msg`View debug entry`)}
-                  accessibilityHint="Opens additional details for a debug entry">
+                  accessibilityHint={_(
+                    msg`Opens additional details for a debug entry`,
+                  )}>
                   {entry.level === 'debug' ? (
                     <FontAwesomeIcon icon="info" />
                   ) : (
diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx
index 1bf8db2e0..96bb46cef 100644
--- a/src/view/screens/Moderation.tsx
+++ b/src/view/screens/Moderation.tsx
@@ -62,7 +62,7 @@ export function ModerationScreen({}: Props) {
       ]}
       testID="moderationScreen">
       <ViewHeader title={_(msg`Moderation`)} showOnDesktop />
-      <ScrollView>
+      <ScrollView contentContainerStyle={[styles.noBorder]}>
         <View style={styles.spacer} />
         <TouchableOpacity
           testID="contentFilteringBtn"
@@ -275,4 +275,10 @@ const styles = StyleSheet.create({
     borderRadius: 30,
     marginRight: 12,
   },
+  noBorder: {
+    borderBottomWidth: 0,
+    borderRightWidth: 0,
+    borderLeftWidth: 0,
+    borderTopWidth: 0,
+  },
 })
diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx
index d6a3b5f6f..b7d993acc 100644
--- a/src/view/screens/ModerationModlists.tsx
+++ b/src/view/screens/ModerationModlists.tsx
@@ -63,7 +63,7 @@ export function ModerationModlistsScreen({}: Props) {
             </Trans>
           </Text>
         </View>
-        <View>
+        <View style={[{marginLeft: 18}, isMobile && {marginLeft: 12}]}>
           <Button
             testID="newModListBtn"
             type="default"
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index 9f50c8b73..276dc842c 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -25,6 +25,7 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage'
 import {CenteredView} from '../com/util/Views'
 import {useComposerControls} from '#/state/shell/composer'
 import {useSession} from '#/state/session'
+import {isWeb} from '#/platform/detection'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
 export function PostThreadScreen({route}: Props) {
@@ -67,6 +68,7 @@ export function PostThreadScreen({route}: Props) {
           displayName: thread.post.author.displayName,
           avatar: thread.post.author.avatar,
         },
+        embed: thread.post.embed,
       },
       onPost: () =>
         queryClient.invalidateQueries({
@@ -77,7 +79,9 @@ export function PostThreadScreen({route}: Props) {
 
   return (
     <View style={s.hContentRegion}>
-      {isMobile && <ViewHeader title={_(msg`Post`)} />}
+      {isMobile && (
+        <ViewHeader title={_(msg({message: 'Post', context: 'description'}))} />
+      )}
       <View style={s.flex1}>
         {uriError ? (
           <CenteredView>
@@ -109,7 +113,8 @@ export function PostThreadScreen({route}: Props) {
 
 const styles = StyleSheet.create({
   prompt: {
-    position: 'absolute',
+    // @ts-ignore web-only
+    position: isWeb ? 'fixed' : 'absolute',
     left: 0,
     right: 0,
   },
diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx
new file mode 100644
index 000000000..1e8cedf7e
--- /dev/null
+++ b/src/view/screens/PreferencesExternalEmbeds.tsx
@@ -0,0 +1,138 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {s} from 'lib/styles'
+import {Text} from '../com/util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {
+  EmbedPlayerSource,
+  externalEmbedLabels,
+} from '#/lib/strings/embed-player'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans} from '@lingui/macro'
+import {ScrollView} from '../com/util/Views'
+import {
+  useExternalEmbedsPrefs,
+  useSetExternalEmbedPref,
+} from 'state/preferences'
+import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'PreferencesExternalEmbeds'
+>
+export function PreferencesExternalEmbeds({}: Props) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {screen} = useAnalytics()
+  const {isMobile} = useWebMediaQueries()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('PreferencesExternalEmbeds')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
+
+  return (
+    <View style={s.hContentRegion} testID="preferencesExternalEmbedsScreen">
+      <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>External Media Preferences</Trans>
+          </Text>
+          <Text style={pal.textLight}>
+            <Trans>Customize media from external sites.</Trans>
+          </Text>
+        </View>
+      </SimpleViewHeader>
+      <ScrollView
+        // @ts-ignore web only -prf
+        dataSet={{'stable-gutters': 1}}
+        contentContainerStyle={[pal.viewLight, {paddingBottom: 200}]}>
+        <View style={[pal.view]}>
+          <View style={styles.infoCard}>
+            <Text style={pal.text}>
+              <Trans>
+                External media may allow websites to collect information about
+                you and your device. No information is sent or requested until
+                you press the "play" button.
+              </Trans>
+            </Text>
+          </View>
+        </View>
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Enable media players for</Trans>
+        </Text>
+        {Object.entries(externalEmbedLabels).map(([key, label]) => (
+          <PrefSelector
+            source={key as EmbedPlayerSource}
+            label={label}
+            key={key}
+          />
+        ))}
+      </ScrollView>
+    </View>
+  )
+}
+
+function PrefSelector({
+  source,
+  label,
+}: {
+  source: EmbedPlayerSource
+  label: string
+}) {
+  const pal = usePalette('default')
+  const setExternalEmbedPref = useSetExternalEmbedPref()
+  const sources = useExternalEmbedsPrefs()
+
+  return (
+    <View>
+      <View style={[pal.view, styles.toggleCard]}>
+        <ToggleButton
+          type="default-light"
+          label={label}
+          labelType="lg"
+          isSelected={sources?.[source] === 'show'}
+          onPress={() =>
+            setExternalEmbedPref(
+              source,
+              sources?.[source] === 'show' ? 'hide' : 'show',
+            )
+          }
+        />
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  heading: {
+    paddingHorizontal: 18,
+    paddingTop: 14,
+    paddingBottom: 14,
+  },
+  spacer: {
+    height: 8,
+  },
+  infoCard: {
+    paddingHorizontal: 20,
+    paddingVertical: 14,
+  },
+  toggleCard: {
+    paddingVertical: 8,
+    paddingHorizontal: 6,
+    marginBottom: 1,
+  },
+})
diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx
index 20ef72923..7ad870937 100644
--- a/src/view/screens/PreferencesHomeFeed.tsx
+++ b/src/view/screens/PreferencesHomeFeed.tsx
@@ -27,8 +27,10 @@ function RepliesThresholdInput({
   initialValue: number
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const [value, setValue] = useState(initialValue)
   const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
+  const preValue = React.useRef(initialValue)
   const save = React.useMemo(
     () =>
       debounce(
@@ -46,7 +48,12 @@ function RepliesThresholdInput({
       <Slider
         value={value}
         onValueChange={(v: number | number[]) => {
-          const threshold = Math.floor(Array.isArray(v) ? v[0] : v)
+          let threshold = Array.isArray(v) ? v[0] : v
+          if (threshold > preValue.current) threshold = Math.floor(threshold)
+          else threshold = Math.ceil(threshold)
+
+          preValue.current = threshold
+
           setValue(threshold)
           save(threshold)
         }}
@@ -58,10 +65,12 @@ function RepliesThresholdInput({
       />
       <Text type="xs" style={pal.text}>
         {value === 0
-          ? `Show all replies`
-          : `Show replies with at least ${value} ${
-              value > 1 ? `likes` : `like`
-            }`}
+          ? _(msg`Show all replies`)
+          : _(
+              msg`Show replies with at least ${value} ${
+                value > 1 ? `likes` : `like`
+              }`,
+            )}
       </Text>
     </View>
   )
diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx
index 73d941932..321c67293 100644
--- a/src/view/screens/PreferencesThreads.tsx
+++ b/src/view/screens/PreferencesThreads.tsx
@@ -75,10 +75,16 @@ export function PreferencesThreads({navigation}: Props) {
                 <RadioGroup
                   type="default-light"
                   items={[
-                    {key: 'oldest', label: 'Oldest replies first'},
-                    {key: 'newest', label: 'Newest replies first'},
-                    {key: 'most-likes', label: 'Most-liked replies first'},
-                    {key: 'random', label: 'Random (aka "Poster\'s Roulette")'},
+                    {key: 'oldest', label: _(msg`Oldest replies first`)},
+                    {key: 'newest', label: _(msg`Newest replies first`)},
+                    {
+                      key: 'most-likes',
+                      label: _(msg`Most-liked replies first`),
+                    },
+                    {
+                      key: 'random',
+                      label: _(msg`Random (aka "Poster's Roulette")`),
+                    },
                   ]}
                   onSelect={key => setThreadViewPrefs({sort: key})}
                   initialSelection={preferences?.threadViewPrefs?.sort}
@@ -97,7 +103,7 @@ export function PreferencesThreads({navigation}: Props) {
               </Text>
               <ToggleButton
                 type="default-light"
-                label={prioritizeFollowedUsers ? 'Yes' : 'No'}
+                label={prioritizeFollowedUsers ? _(msg`Yes`) : _(msg`No`)}
                 isSelected={prioritizeFollowedUsers}
                 onPress={() =>
                   setThreadViewPrefs({
@@ -120,7 +126,7 @@ export function PreferencesThreads({navigation}: Props) {
               </Text>
               <ToggleButton
                 type="default-light"
-                label={treeViewEnabled ? 'Yes' : 'No'}
+                label={treeViewEnabled ? _(msg`Yes`) : _(msg`No`)}
                 isSelected={treeViewEnabled}
                 onPress={() =>
                   setThreadViewPrefs({
@@ -153,7 +159,7 @@ export function PreferencesThreads({navigation}: Props) {
           accessibilityLabel={_(msg`Confirm`)}
           accessibilityHint="">
           <Text style={[s.white, s.bold, s.f18]}>
-            <Trans>Done</Trans>
+            <Trans context="action">Done</Trans>
           </Text>
         </TouchableOpacity>
       </View>
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 4558ae33d..7fc4d7a20 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -371,6 +371,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
     {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
     ref,
   ) {
+    const {_} = useLingui()
     const queryClient = useQueryClient()
     const [hasNew, setHasNew] = React.useState(false)
     const [isScrolledDown, setIsScrolledDown] = React.useState(false)
@@ -388,8 +389,8 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
     }))
 
     const renderPostsEmpty = React.useCallback(() => {
-      return <EmptyState icon="feed" message="This feed is empty!" />
-    }, [])
+      return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
+    }, [_])
 
     return (
       <View>
@@ -408,7 +409,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
         {(isScrolledDown || hasNew) && (
           <LoadLatestBtn
             onPress={onScrollToTop}
-            label="Load new posts"
+            label={_(msg`Load new posts`)}
             showIndicator={hasNew}
           />
         )}
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index 211306c0d..61282497c 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -214,11 +214,21 @@ export function ProfileFeedScreenInner({
       }
     } catch (err) {
       Toast.show(
-        'There was an an issue updating your feeds, please check your internet connection and try again.',
+        _(
+          msg`There was an an issue updating your feeds, please check your internet connection and try again.`,
+        ),
       )
       logger.error('Failed up update feeds', {error: err})
     }
-  }, [feedInfo, isSaved, saveFeed, removeFeed, resetSaveFeed, resetRemoveFeed])
+  }, [
+    feedInfo,
+    isSaved,
+    saveFeed,
+    removeFeed,
+    resetSaveFeed,
+    resetRemoveFeed,
+    _,
+  ])
 
   const onTogglePinned = React.useCallback(async () => {
     try {
@@ -232,10 +242,10 @@ export function ProfileFeedScreenInner({
         resetPinFeed()
       }
     } catch (e) {
-      Toast.show('There was an issue contacting the server')
+      Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to toggle pinned feed', {error: e})
     }
-  }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed])
+  }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed, _])
 
   const onPressShare = React.useCallback(() => {
     const url = toShareUrl(feedInfo.route.href)
@@ -341,7 +351,7 @@ export function ProfileFeedScreenInner({
             <Button
               disabled={isSavePending || isRemovePending}
               type="default"
-              label={isSaved ? 'Unsave' : 'Save'}
+              label={isSaved ? _(msg`Unsave`) : _(msg`Save`)}
               onPress={onToggleSaved}
               style={styles.btn}
             />
@@ -349,7 +359,7 @@ export function ProfileFeedScreenInner({
               testID={isPinned ? 'unpinBtn' : 'pinBtn'}
               disabled={isPinPending || isUnpinPending}
               type={isPinned ? 'default' : 'inverted'}
-              label={isPinned ? 'Unpin' : 'Pin to home'}
+              label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)}
               onPress={onTogglePinned}
               style={styles.btn}
             />
@@ -444,6 +454,7 @@ interface FeedSectionProps {
 }
 const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
   function FeedSectionImpl({feed, headerHeight, scrollElRef, isFocused}, ref) {
+    const {_} = useLingui()
     const [hasNew, setHasNew] = React.useState(false)
     const [isScrolledDown, setIsScrolledDown] = React.useState(false)
     const queryClient = useQueryClient()
@@ -470,8 +481,8 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
     }, [onScrollToTop, isScreenFocused])
 
     const renderPostsEmpty = useCallback(() => {
-      return <EmptyState icon="feed" message="This feed is empty!" />
-    }, [])
+      return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
+    }, [_])
 
     return (
       <View>
@@ -479,6 +490,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
           enabled={isFocused}
           feed={feed}
           pollInterval={60e3}
+          disablePoll={hasNew}
           scrollElRef={scrollElRef}
           onHasNew={setHasNew}
           onScrolledDownChange={setIsScrolledDown}
@@ -488,7 +500,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
         {(isScrolledDown || hasNew) && (
           <LoadLatestBtn
             onPress={onScrollToTop}
-            label="Load new posts"
+            label={_(msg`Load new posts`)}
             showIndicator={hasNew}
           />
         )}
@@ -542,11 +554,13 @@ function AboutSection({
       }
     } catch (err) {
       Toast.show(
-        'There was an an issue contacting the server, please check your internet connection and try again.',
+        _(
+          msg`There was an an issue contacting the server, please check your internet connection and try again.`,
+        ),
       )
       logger.error('Failed up toggle like', {error: err})
     }
-  }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track])
+  }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track, _])
 
   return (
     <ScrollView
@@ -597,24 +611,28 @@ function AboutSection({
           {typeof likeCount === 'number' && (
             <TextLink
               href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
-              text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`}
+              text={_(
+                msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`,
+              )}
               style={[pal.textLight, s.semiBold]}
             />
           )}
         </View>
         <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-          Created by{' '}
           {isOwner ? (
-            'you'
+            <Trans>Created by you</Trans>
           ) : (
-            <TextLink
-              text={sanitizeHandle(feedInfo.creatorHandle, '@')}
-              href={makeProfileLink({
-                did: feedInfo.creatorDid,
-                handle: feedInfo.creatorHandle,
-              })}
-              style={pal.textLight}
-            />
+            <Trans>
+              Created by{' '}
+              <TextLink
+                text={sanitizeHandle(feedInfo.creatorHandle, '@')}
+                href={makeProfileLink({
+                  did: feedInfo.creatorDid,
+                  handle: feedInfo.creatorHandle,
+                })}
+                style={pal.textLight}
+              />
+            </Trans>
           )}
         </Text>
       </View>
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index c51758ae5..cb7962a9b 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -68,6 +68,7 @@ interface SectionRef {
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
 export function ProfileListScreen(props: Props) {
+  const {_} = useLingui()
   const {name: handleOrDid, rkey} = props.route.params
   const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
     AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
@@ -78,7 +79,9 @@ export function ProfileListScreen(props: Props) {
     return (
       <CenteredView>
         <ErrorScreen
-          error={`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`}
+          error={_(
+            msg`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`,
+          )}
         />
       </CenteredView>
     )
@@ -260,10 +263,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
         await pinFeed({uri: list.uri})
       }
     } catch (e) {
-      Toast.show('There was an issue contacting the server')
+      Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to toggle pinned feed', {error: e})
     }
-  }, [list.uri, isPinned, pinFeed, unpinFeed])
+  }, [list.uri, isPinned, pinFeed, unpinFeed, _])
 
   const onSubscribeMute = useCallback(() => {
     openModal({
@@ -272,15 +275,17 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       message: _(
         msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`,
       ),
-      confirmBtnText: 'Mute this List',
+      confirmBtnText: _(msg`Mute this List`),
       async onPressConfirm() {
         try {
           await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
-          Toast.show('List muted')
+          Toast.show(_(msg`List muted`))
           track('Lists:Mute')
         } catch {
           Toast.show(
-            'There was an issue. Please check your internet connection and try again.',
+            _(
+              msg`There was an issue. Please check your internet connection and try again.`,
+            ),
           )
         }
       },
@@ -293,14 +298,16 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const onUnsubscribeMute = useCallback(async () => {
     try {
       await listMuteMutation.mutateAsync({uri: list.uri, mute: false})
-      Toast.show('List unmuted')
+      Toast.show(_(msg`List unmuted`))
       track('Lists:Unmute')
     } catch {
       Toast.show(
-        'There was an issue. Please check your internet connection and try again.',
+        _(
+          msg`There was an issue. Please check your internet connection and try again.`,
+        ),
       )
     }
-  }, [list, listMuteMutation, track])
+  }, [list, listMuteMutation, track, _])
 
   const onSubscribeBlock = useCallback(() => {
     openModal({
@@ -309,15 +316,17 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       message: _(
         msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
       ),
-      confirmBtnText: 'Block this List',
+      confirmBtnText: _(msg`Block this List`),
       async onPressConfirm() {
         try {
           await listBlockMutation.mutateAsync({uri: list.uri, block: true})
-          Toast.show('List blocked')
+          Toast.show(_(msg`List blocked`))
           track('Lists:Block')
         } catch {
           Toast.show(
-            'There was an issue. Please check your internet connection and try again.',
+            _(
+              msg`There was an issue. Please check your internet connection and try again.`,
+            ),
           )
         }
       },
@@ -330,14 +339,16 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const onUnsubscribeBlock = useCallback(async () => {
     try {
       await listBlockMutation.mutateAsync({uri: list.uri, block: false})
-      Toast.show('List unblocked')
+      Toast.show(_(msg`List unblocked`))
       track('Lists:Unblock')
     } catch {
       Toast.show(
-        'There was an issue. Please check your internet connection and try again.',
+        _(
+          msg`There was an issue. Please check your internet connection and try again.`,
+        ),
       )
     }
-  }, [list, listBlockMutation, track])
+  }, [list, listBlockMutation, track, _])
 
   const onPressEdit = useCallback(() => {
     openModal({
@@ -353,7 +364,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       message: _(msg`Are you sure?`),
       async onPressConfirm() {
         await listDeleteMutation.mutateAsync({uri: list.uri})
-        Toast.show('List deleted')
+        Toast.show(_(msg`List deleted`))
         track('Lists:Delete')
         if (navigation.canGoBack()) {
           navigation.goBack()
@@ -545,7 +556,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
         <Button
           testID={isPinned ? 'unpinBtn' : 'pinBtn'}
           type={isPinned ? 'default' : 'inverted'}
-          label={isPinned ? 'Unpin' : 'Pin to home'}
+          label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)}
           onPress={onTogglePinned}
           disabled={isPending}
         />
@@ -554,14 +565,14 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
           <Button
             testID="unblockBtn"
             type="default"
-            label="Unblock"
+            label={_(msg`Unblock`)}
             onPress={onUnsubscribeBlock}
           />
         ) : isMuting ? (
           <Button
             testID="unmuteBtn"
             type="default"
-            label="Unmute"
+            label={_(msg`Unmute`)}
             onPress={onUnsubscribeMute}
           />
         ) : (
@@ -603,6 +614,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
     const [hasNew, setHasNew] = React.useState(false)
     const [isScrolledDown, setIsScrolledDown] = React.useState(false)
     const isScreenFocused = useIsFocused()
+    const {_} = useLingui()
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({
@@ -624,8 +636,8 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
     }, [onScrollToTop, isScreenFocused])
 
     const renderPostsEmpty = useCallback(() => {
-      return <EmptyState icon="feed" message="This feed is empty!" />
-    }, [])
+      return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
+    }, [_])
 
     return (
       <View>
@@ -634,6 +646,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
           enabled={isFocused}
           feed={feed}
           pollInterval={60e3}
+          disablePoll={hasNew}
           scrollElRef={scrollElRef}
           onHasNew={setHasNew}
           onScrolledDownChange={setIsScrolledDown}
@@ -643,7 +656,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
         {(isScrolledDown || hasNew) && (
           <LoadLatestBtn
             onPress={onScrollToTop}
-            label="Load new posts"
+            label={_(msg`Load new posts`)}
             showIndicator={hasNew}
           />
         )}
@@ -721,15 +734,30 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
               </Text>
             )}
             <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-              {isCurateList ? 'User list' : 'Moderation list'} by{' '}
-              {isOwner ? (
-                'you'
+              {isCurateList ? (
+                isOwner ? (
+                  <Trans>User list by you</Trans>
+                ) : (
+                  <Trans>
+                    User list by{' '}
+                    <TextLink
+                      text={sanitizeHandle(list.creator.handle || '', '@')}
+                      href={makeProfileLink(list.creator)}
+                      style={pal.textLight}
+                    />
+                  </Trans>
+                )
+              ) : isOwner ? (
+                <Trans>Moderation list by you</Trans>
               ) : (
-                <TextLink
-                  text={sanitizeHandle(list.creator.handle || '', '@')}
-                  href={makeProfileLink(list.creator)}
-                  style={pal.textLight}
-                />
+                <Trans>
+                  Moderation list by{' '}
+                  <TextLink
+                    text={sanitizeHandle(list.creator.handle || '', '@')}
+                    href={makeProfileLink(list.creator)}
+                    style={pal.textLight}
+                  />
+                </Trans>
               )}
             </Text>
           </View>
@@ -782,11 +810,11 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
       return (
         <EmptyState
           icon="users-slash"
-          message="This list is empty!"
+          message={_(msg`This list is empty!`)}
           style={{paddingTop: 40}}
         />
       )
-    }, [])
+    }, [_])
 
     return (
       <View>
@@ -802,7 +830,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
         {isScrolledDown && (
           <LoadLatestBtn
             onPress={onScrollToTop}
-            label="Scroll to top"
+            label={_(msg`Scroll to top`)}
             showIndicator={false}
           />
         )}
@@ -846,7 +874,7 @@ function ErrorScreen({error}: {error: string}) {
         <Button
           type="default"
           accessibilityLabel={_(msg`Go Back`)}
-          accessibilityHint="Return to previous page"
+          accessibilityHint={_(msg`Return to previous page`)}
           onPress={onPressBack}
           style={{flexShrink: 1}}>
           <Text type="button" style={pal.text}>
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index bbac30689..19ae37f0c 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -82,7 +82,7 @@ export function SavedFeeds({}: Props) {
         isTabletOrDesktop && styles.desktopContainer,
       ]}>
       <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder />
-      <ScrollView style={s.flex1}>
+      <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}>
         <View style={[pal.text, pal.border, styles.title]}>
           <Text type="title" style={pal.text}>
             <Trans>Pinned Feeds</Trans>
@@ -160,7 +160,7 @@ export function SavedFeeds({}: Props) {
                 type="sm"
                 style={pal.link}
                 href="https://github.com/bluesky-social/feed-generator"
-                text="See this guide"
+                text={_(msg`See this guide`)}
               />{' '}
               for more information.
             </Trans>
@@ -188,6 +188,7 @@ function ListItem({
   >['reset']
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
   const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
     useUnpinFeedMutation()
@@ -205,10 +206,10 @@ function ListItem({
         await pinFeed({uri: feedUri})
       }
     } catch (e) {
-      Toast.show('There was an issue contacting the server')
+      Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to toggle pinned feed', {error: e})
     }
-  }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState])
+  }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState, _])
 
   const onPressUp = React.useCallback(async () => {
     if (!isPinned) return
@@ -227,10 +228,10 @@ function ListItem({
         index: pinned.indexOf(feedUri),
       })
     } catch (e) {
-      Toast.show('There was an issue contacting the server')
+      Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to set pinned feed order', {error: e})
     }
-  }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
+  }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _])
 
   const onPressDown = React.useCallback(async () => {
     if (!isPinned) return
@@ -248,10 +249,10 @@ function ListItem({
         index: pinned.indexOf(feedUri),
       })
     } catch (e) {
-      Toast.show('There was an issue contacting the server')
+      Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to set pinned feed order', {error: e})
     }
-  }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
+  }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _])
 
   return (
     <Pressable
@@ -288,7 +289,7 @@ function ListItem({
       <FeedSourceCard
         key={feedUri}
         feedUri={feedUri}
-        style={styles.noBorder}
+        style={styles.noTopBorder}
         showSaveBtn
         showMinimalPlaceholder
       />
@@ -344,7 +345,7 @@ const styles = StyleSheet.create({
   webArrowUpButton: {
     marginBottom: 10,
   },
-  noBorder: {
+  noTopBorder: {
     borderTopWidth: 0,
   },
   footerText: {
@@ -352,4 +353,10 @@ const styles = StyleSheet.create({
     paddingTop: 22,
     paddingBottom: 100,
   },
+  noBorder: {
+    borderBottomWidth: 0,
+    borderRightWidth: 0,
+    borderLeftWidth: 0,
+    borderTopWidth: 0,
+  },
 })
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index b522edfba..df64cc5aa 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -42,11 +42,17 @@ import {useSetDrawerOpen} from '#/state/shell'
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {MagnifyingGlassIcon} from '#/lib/icons'
 import {useModerationOpts} from '#/state/queries/preferences'
-import {SearchResultCard} from '#/view/shell/desktop/Search'
+import {
+  MATCH_HANDLE,
+  SearchLinkCard,
+  SearchProfileCard,
+} from '#/view/shell/desktop/Search'
 import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
-import {isWeb} from '#/platform/detection'
+import {isNative, isWeb} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
 import {s} from '#/lib/styles'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import {augmentSearchQuery} from '#/lib/strings/helpers'
 
 function Loader() {
   const pal = usePalette('default')
@@ -83,9 +89,7 @@ function EmptyState({message, error}: {message: string; error?: string}) {
         },
       ]}>
       <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}>
-        <Text style={[pal.text]}>
-          <Trans>{message}</Trans>
-        </Text>
+        <Text style={[pal.text]}>{message}</Text>
 
         {error && (
           <>
@@ -162,6 +166,8 @@ function SearchScreenSuggestedFollows() {
       // @ts-ignore web only -prf
       desktopFixedHeight
       contentContainerStyle={{paddingBottom: 1200}}
+      keyboardShouldPersistTaps="handled"
+      keyboardDismissMode="on-drag"
     />
   ) : (
     <CenteredView sideBorders style={[pal.border, s.hContentRegion]}>
@@ -303,13 +309,23 @@ function SearchScreenUserResults({query}: {query: string}) {
 
 const SECTIONS_LOGGEDOUT = ['Users']
 const SECTIONS_LOGGEDIN = ['Posts', 'Users']
-export function SearchScreenInner({query}: {query?: string}) {
+export function SearchScreenInner({
+  query,
+  primarySearch,
+}: {
+  query?: string
+  primarySearch?: boolean
+}) {
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
-  const {hasSession} = useSession()
+  const {hasSession, currentAccount} = useSession()
   const {isDesktop} = useWebMediaQueries()
 
+  const augmentedQuery = React.useMemo(() => {
+    return augmentSearchQuery(query || '', {did: currentAccount?.did})
+  }, [query, currentAccount])
+
   const onPageSelected = React.useCallback(
     (index: number) => {
       setMinimalShellMode(false)
@@ -324,13 +340,15 @@ export function SearchScreenInner({query}: {query?: string}) {
         tabBarPosition="top"
         onPageSelected={onPageSelected}
         renderTabBar={props => (
-          <CenteredView sideBorders style={pal.border}>
+          <CenteredView
+            sideBorders
+            style={[pal.border, pal.view, styles.tabBarContainer]}>
             <TabBar items={SECTIONS_LOGGEDIN} {...props} />
           </CenteredView>
         )}
         initialPage={0}>
         <View>
-          <SearchScreenPostResults query={query} />
+          <SearchScreenPostResults query={augmentedQuery} />
         </View>
         <View>
           <SearchScreenUserResults query={query} />
@@ -365,7 +383,9 @@ export function SearchScreenInner({query}: {query?: string}) {
       tabBarPosition="top"
       onPageSelected={onPageSelected}
       renderTabBar={props => (
-        <CenteredView sideBorders style={pal.border}>
+        <CenteredView
+          sideBorders
+          style={[pal.border, pal.view, styles.tabBarContainer]}>
           <TabBar items={SECTIONS_LOGGEDOUT} {...props} />
         </CenteredView>
       )}
@@ -413,7 +433,7 @@ export function SearchScreenInner({query}: {query?: string}) {
             style={pal.textLight}
           />
           <Text type="xl" style={[pal.textLight, {paddingHorizontal: 18}]}>
-            {isDesktop ? (
+            {isDesktop && !primarySearch ? (
               <Trans>Find users with the search tool on the right</Trans>
             ) : (
               <Trans>Find users on Bluesky</Trans>
@@ -425,19 +445,7 @@ export function SearchScreenInner({query}: {query?: string}) {
   )
 }
 
-export function SearchScreenDesktop(
-  props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
-) {
-  const {isDesktop} = useWebMediaQueries()
-
-  return isDesktop ? (
-    <SearchScreenInner query={props.route.params?.q} />
-  ) : (
-    <SearchScreenMobile {...props} />
-  )
-}
-
-export function SearchScreenMobile(
+export function SearchScreen(
   props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
 ) {
   const theme = useTheme()
@@ -449,7 +457,7 @@ export function SearchScreenMobile(
   const moderationOpts = useModerationOpts()
   const search = useActorAutocompleteFn()
   const setMinimalShellMode = useSetMinimalShellMode()
-  const {isTablet} = useWebMediaQueries()
+  const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
 
   const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
     undefined,
@@ -462,32 +470,56 @@ export function SearchScreenMobile(
   const [inputIsFocused, setInputIsFocused] = React.useState(false)
   const [showAutocompleteResults, setShowAutocompleteResults] =
     React.useState(false)
+  const [searchHistory, setSearchHistory] = React.useState<string[]>([])
+
+  React.useEffect(() => {
+    const loadSearchHistory = async () => {
+      try {
+        const history = await AsyncStorage.getItem('searchHistory')
+        if (history !== null) {
+          setSearchHistory(JSON.parse(history))
+        }
+      } catch (e: any) {
+        logger.error('Failed to load search history', e)
+      }
+    }
+
+    loadSearchHistory()
+  }, [])
 
   const onPressMenu = React.useCallback(() => {
     track('ViewHeader:MenuButtonClicked')
     setDrawerOpen(true)
   }, [track, setDrawerOpen])
+
   const onPressCancelSearch = React.useCallback(() => {
+    scrollToTopWeb()
     textInput.current?.blur()
     setQuery('')
     setShowAutocompleteResults(false)
     if (searchDebounceTimeout.current)
       clearTimeout(searchDebounceTimeout.current)
   }, [textInput])
+
   const onPressClearQuery = React.useCallback(() => {
+    scrollToTopWeb()
     setQuery('')
     setShowAutocompleteResults(false)
   }, [setQuery])
+
   const onChangeText = React.useCallback(
     async (text: string) => {
+      scrollToTopWeb()
+
       setQuery(text)
 
       if (text.length > 0) {
         setIsFetching(true)
         setShowAutocompleteResults(true)
 
-        if (searchDebounceTimeout.current)
+        if (searchDebounceTimeout.current) {
           clearTimeout(searchDebounceTimeout.current)
+        }
 
         searchDebounceTimeout.current = setTimeout(async () => {
           const results = await search({query: text, limit: 30})
@@ -498,8 +530,9 @@ export function SearchScreenMobile(
           }
         }, 300)
       } else {
-        if (searchDebounceTimeout.current)
+        if (searchDebounceTimeout.current) {
           clearTimeout(searchDebounceTimeout.current)
+        }
         setSearchResults([])
         setIsFetching(false)
         setShowAutocompleteResults(false)
@@ -507,14 +540,47 @@ export function SearchScreenMobile(
     },
     [setQuery, search, setSearchResults],
   )
+
+  const updateSearchHistory = React.useCallback(
+    async (newQuery: string) => {
+      newQuery = newQuery.trim()
+      if (newQuery && !searchHistory.includes(newQuery)) {
+        let newHistory = [newQuery, ...searchHistory]
+
+        if (newHistory.length > 5) {
+          newHistory = newHistory.slice(0, 5)
+        }
+
+        setSearchHistory(newHistory)
+        try {
+          await AsyncStorage.setItem(
+            'searchHistory',
+            JSON.stringify(newHistory),
+          )
+        } catch (e: any) {
+          logger.error('Failed to save search history', e)
+        }
+      }
+    },
+    [searchHistory, setSearchHistory],
+  )
+
   const onSubmit = React.useCallback(() => {
+    scrollToTopWeb()
     setShowAutocompleteResults(false)
-  }, [setShowAutocompleteResults])
+    updateSearchHistory(query)
+  }, [query, setShowAutocompleteResults, updateSearchHistory])
 
   const onSoftReset = React.useCallback(() => {
+    scrollToTopWeb()
     onPressCancelSearch()
   }, [onPressCancelSearch])
 
+  const queryMaybeHandle = React.useMemo(() => {
+    const match = MATCH_HANDLE.exec(query)
+    return match && match[1]
+  }, [query])
+
   useFocusEffect(
     React.useCallback(() => {
       setMinimalShellMode(false)
@@ -522,19 +588,47 @@ export function SearchScreenMobile(
     }, [onSoftReset, setMinimalShellMode]),
   )
 
+  const handleHistoryItemClick = (item: React.SetStateAction<string>) => {
+    setQuery(item)
+    onSubmit()
+  }
+
+  const handleRemoveHistoryItem = (itemToRemove: string) => {
+    const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
+    setSearchHistory(updatedHistory)
+    AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch(
+      e => {
+        logger.error('Failed to update search history', e)
+      },
+    )
+  }
+
   return (
-    <View style={{flex: 1}}>
-      <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}>
-        <Pressable
-          testID="viewHeaderBackOrMenuBtn"
-          onPress={onPressMenu}
-          hitSlop={HITSLOP_10}
-          style={styles.headerMenuBtn}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Menu`)}
-          accessibilityHint="Access navigation links and settings">
-          <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} />
-        </Pressable>
+    <View style={isWeb ? null : {flex: 1}}>
+      <CenteredView
+        style={[
+          styles.header,
+          pal.border,
+          pal.view,
+          isTabletOrDesktop && {paddingTop: 10},
+        ]}
+        sideBorders={isTabletOrDesktop}>
+        {isTabletOrMobile && (
+          <Pressable
+            testID="viewHeaderBackOrMenuBtn"
+            onPress={onPressMenu}
+            hitSlop={HITSLOP_10}
+            style={styles.headerMenuBtn}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Menu`)}
+            accessibilityHint={_(msg`Access navigation links and settings`)}>
+            <FontAwesomeIcon
+              icon="bars"
+              size={18}
+              color={pal.colors.textLight}
+            />
+          </Pressable>
+        )}
 
         <View
           style={[
@@ -548,7 +642,7 @@ export function SearchScreenMobile(
           <TextInput
             testID="searchTextInput"
             ref={textInput}
-            placeholder="Search"
+            placeholder={_(msg`Search`)}
             placeholderTextColor={pal.colors.textLight}
             selectTextOnFocus
             returnKeyType="search"
@@ -556,7 +650,12 @@ export function SearchScreenMobile(
             style={[pal.text, styles.headerSearchInput]}
             keyboardAppearance={theme.colorScheme}
             onFocus={() => setInputIsFocused(true)}
-            onBlur={() => setInputIsFocused(false)}
+            onBlur={() => {
+              // HACK
+              // give 100ms to not stop click handlers in the search history
+              // -prf
+              setTimeout(() => setInputIsFocused(false), 100)
+            }}
             onChangeText={onChangeText}
             onSubmitEditing={onSubmit}
             autoFocus={false}
@@ -564,6 +663,7 @@ export function SearchScreenMobile(
             accessibilityLabel={_(msg`Search`)}
             accessibilityHint=""
             autoCorrect={false}
+            autoComplete="off"
             autoCapitalize="none"
           />
           {query ? (
@@ -572,7 +672,8 @@ export function SearchScreenMobile(
               onPress={onPressClearQuery}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Clear search query`)}
-              accessibilityHint="">
+              accessibilityHint=""
+              hitSlop={HITSLOP_10}>
               <FontAwesomeIcon
                 icon="xmark"
                 size={16}
@@ -584,7 +685,10 @@ export function SearchScreenMobile(
 
         {query || inputIsFocused ? (
           <View style={styles.headerCancelBtn}>
-            <Pressable onPress={onPressCancelSearch} accessibilityRole="button">
+            <Pressable
+              onPress={onPressCancelSearch}
+              accessibilityRole="button"
+              hitSlop={HITSLOP_10}>
               <Text style={[pal.text]}>
                 <Trans>Cancel</Trans>
               </Text>
@@ -593,29 +697,83 @@ export function SearchScreenMobile(
         ) : undefined}
       </CenteredView>
 
-      {showAutocompleteResults && moderationOpts ? (
+      {showAutocompleteResults ? (
         <>
-          {isFetching ? (
+          {isFetching || !moderationOpts ? (
             <Loader />
           ) : (
-            <ScrollView style={{height: '100%'}}>
-              {searchResults.length ? (
-                searchResults.map((item, i) => (
-                  <SearchResultCard
-                    key={item.did}
-                    profile={item}
-                    moderation={moderateProfile(item, moderationOpts)}
-                    style={i === 0 ? {borderTopWidth: 0} : {}}
-                  />
-                ))
-              ) : (
-                <EmptyState message={_(msg`No results found for ${query}`)} />
-              )}
+            <ScrollView
+              style={{height: '100%'}}
+              // @ts-ignore web only -prf
+              dataSet={{stableGutters: '1'}}
+              keyboardShouldPersistTaps="handled"
+              keyboardDismissMode="on-drag">
+              <SearchLinkCard
+                label={_(msg`Search for "${query}"`)}
+                onPress={isNative ? onSubmit : undefined}
+                to={
+                  isNative
+                    ? undefined
+                    : `/search?q=${encodeURIComponent(query)}`
+                }
+                style={{borderBottomWidth: 1}}
+              />
+
+              {queryMaybeHandle ? (
+                <SearchLinkCard
+                  label={_(msg`Go to @${queryMaybeHandle}`)}
+                  to={`/profile/${queryMaybeHandle}`}
+                />
+              ) : null}
+
+              {searchResults.map(item => (
+                <SearchProfileCard
+                  key={item.did}
+                  profile={item}
+                  moderation={moderateProfile(item, moderationOpts)}
+                />
+              ))}
 
               <View style={{height: 200}} />
             </ScrollView>
           )}
         </>
+      ) : !query && inputIsFocused ? (
+        <CenteredView
+          sideBorders={isTabletOrDesktop}
+          // @ts-ignore web only -prf
+          style={{
+            height: isWeb ? '100vh' : undefined,
+          }}>
+          <View style={styles.searchHistoryContainer}>
+            {searchHistory.length > 0 && (
+              <View style={styles.searchHistoryContent}>
+                <Text style={[pal.text, styles.searchHistoryTitle]}>
+                  Recent Searches
+                </Text>
+                {searchHistory.map((historyItem, index) => (
+                  <View key={index} style={styles.historyItemContainer}>
+                    <Pressable
+                      accessibilityRole="button"
+                      onPress={() => handleHistoryItemClick(historyItem)}
+                      style={styles.historyItem}>
+                      <Text style={pal.text}>{historyItem}</Text>
+                    </Pressable>
+                    <Pressable
+                      accessibilityRole="button"
+                      onPress={() => handleRemoveHistoryItem(historyItem)}>
+                      <FontAwesomeIcon
+                        icon="xmark"
+                        size={16}
+                        style={pal.textLight as FontAwesomeIconStyle}
+                      />
+                    </Pressable>
+                  </View>
+                ))}
+              </View>
+            )}
+          </View>
+        </CenteredView>
       ) : (
         <SearchScreenInner query={query} />
       )}
@@ -623,12 +781,25 @@ export function SearchScreenMobile(
   )
 }
 
+function scrollToTopWeb() {
+  if (isWeb) {
+    window.scrollTo(0, 0)
+  }
+}
+
+const HEADER_HEIGHT = 50
+
 const styles = StyleSheet.create({
   header: {
     flexDirection: 'row',
     alignItems: 'center',
     paddingHorizontal: 12,
     paddingVertical: 4,
+    height: HEADER_HEIGHT,
+    // @ts-ignore web only
+    position: isWeb ? 'sticky' : '',
+    top: 0,
+    zIndex: 1,
   },
   headerMenuBtn: {
     width: 30,
@@ -658,4 +829,30 @@ const styles = StyleSheet.create({
   headerCancelBtn: {
     paddingLeft: 10,
   },
+  tabBarContainer: {
+    // @ts-ignore web only
+    position: isWeb ? 'sticky' : '',
+    top: isWeb ? HEADER_HEIGHT : 0,
+    zIndex: 1,
+  },
+  searchHistoryContainer: {
+    width: '100%',
+    paddingHorizontal: 12,
+  },
+  searchHistoryContent: {
+    padding: 10,
+    borderRadius: 8,
+  },
+  searchHistoryTitle: {
+    fontWeight: 'bold',
+  },
+  historyItem: {
+    paddingVertical: 8,
+  },
+  historyItemContainer: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    paddingVertical: 8,
+  },
 })
diff --git a/src/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx
index a65149bf7..f6c0eca26 100644
--- a/src/view/screens/Search/index.tsx
+++ b/src/view/screens/Search/index.tsx
@@ -1,3 +1 @@
-import {SearchScreenMobile} from '#/view/screens/Search/Search'
-
-export const SearchScreen = SearchScreenMobile
+export {SearchScreen} from '#/view/screens/Search/Search'
diff --git a/src/view/screens/Search/index.web.tsx b/src/view/screens/Search/index.web.tsx
deleted file mode 100644
index 8e039e3cd..000000000
--- a/src/view/screens/Search/index.web.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import {SearchScreenDesktop} from '#/view/screens/Search/Search'
-
-export const SearchScreen = SearchScreenDesktop
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index d48112dae..3b50c5449 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -19,7 +19,6 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import * as AppInfo from 'lib/app-info'
 import {s, colors} from 'lib/styles'
 import {ScrollView} from '../com/util/Views'
-import {ViewHeader} from '../com/util/ViewHeader'
 import {Link, TextLink} from '../com/util/Link'
 import {Text} from '../com/util/text/Text'
 import * as Toast from '../com/util/Toast'
@@ -36,6 +35,7 @@ import {HandIcon, HashtagIcon} from 'lib/icons'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {makeProfileLink} from 'lib/routes/links'
 import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
+import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
 import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
 import {useModalControls} from '#/state/modals'
 import {
@@ -70,9 +70,15 @@ 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'
 
 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})
@@ -98,10 +104,10 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
           testID="signOutBtn"
           onPress={logout}
           accessibilityRole="button"
-          accessibilityLabel="Sign out"
+          accessibilityLabel={_(msg`Sign out`)}
           accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}>
           <Text type="lg" style={pal.link}>
-            Sign out
+            <Trans>Sign out</Trans>
           </Text>
         </TouchableOpacity>
       ) : (
@@ -116,7 +122,7 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
         did: currentAccount?.did,
         handle: currentAccount?.handle,
       })}
-      title="Your profile"
+      title={_(msg`Your profile`)}
       noFeedback>
       {contents}
     </Link>
@@ -128,8 +134,8 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
         isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account)
       }
       accessibilityRole="button"
-      accessibilityLabel={`Switch to ${account.handle}`}
-      accessibilityHint="Switches the account you are logged in to">
+      accessibilityLabel={_(msg`Switch to ${account.handle}`)}
+      accessibilityHint={_(msg`Switches the account you are logged in to`)}>
       {contents}
     </TouchableOpacity>
   )
@@ -145,6 +151,8 @@ export function SettingsScreen({}: Props) {
   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()
@@ -225,15 +233,15 @@ export function SettingsScreen({}: Props) {
 
   const onPressResetOnboarding = React.useCallback(async () => {
     onboardingDispatch({type: 'start'})
-    Toast.show('Onboarding reset')
-  }, [onboardingDispatch])
+    Toast.show(_(msg`Onboarding reset`))
+  }, [onboardingDispatch, _])
 
   const onPressBuildInfo = React.useCallback(() => {
     Clipboard.setString(
       `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`,
     )
-    Toast.show('Copied build version to clipboard')
-  }, [])
+    Toast.show(_(msg`Copied build version to clipboard`))
+  }, [_])
 
   const openHomeFeedPreferences = React.useCallback(() => {
     navigation.navigate('PreferencesHomeFeed')
@@ -265,20 +273,34 @@ export function SettingsScreen({}: Props) {
 
   const clearAllStorage = React.useCallback(async () => {
     await clearStorage()
-    Toast.show(`Storage cleared, you need to restart the app now.`)
-  }, [])
+    Toast.show(_(msg`Storage cleared, you need to restart the app now.`))
+  }, [_])
   const clearAllLegacyStorage = React.useCallback(async () => {
     await clearLegacyStorage()
-    Toast.show(`Legacy storage cleared, you need to restart the app now.`)
-  }, [])
+    Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`))
+  }, [_])
 
   return (
-    <View style={[s.hContentRegion]} testID="settingsScreen">
-      <ViewHeader title={_(msg`Settings`)} />
+    <View style={s.hContentRegion} testID="settingsScreen">
+      <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}}>
+        scrollIndicatorInsets={{right: 1}}
+        // @ts-ignore web only -prf
+        dataSet={{'stable-gutters': 1}}>
         <View style={styles.spacer20} />
         {currentAccount ? (
           <>
@@ -298,12 +320,18 @@ export function SettingsScreen({}: Props) {
                   />
                 </>
               )}
-              <Text type="lg" style={pal.text}>
-                {currentAccount.email || '(no email)'}{' '}
+              <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>Change</Trans>
+                  <Trans context="action">Change</Trans>
                 </Text>
               </Link>
             </View>
@@ -353,7 +381,7 @@ export function SettingsScreen({}: Props) {
           onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Add account`)}
-          accessibilityHint="Create a new Bluesky account">
+          accessibilityHint={_(msg`Create a new Bluesky account`)}>
           <View style={[styles.iconContainer, pal.btn]}>
             <FontAwesomeIcon
               icon="plus"
@@ -381,7 +409,7 @@ export function SettingsScreen({}: Props) {
           onPress={isSwitchingAccounts ? undefined : onPressInviteCodes}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Invite`)}
-          accessibilityHint="Opens invite code list"
+          accessibilityHint={_(msg`Opens invite code list`)}
           disabled={invites?.disabled}>
           <View
             style={[
@@ -419,7 +447,7 @@ export function SettingsScreen({}: Props) {
         <View style={[pal.view, styles.toggleCard]}>
           <ToggleButton
             type="default-light"
-            label="Require alt text before posting"
+            label={_(msg`Require alt text before posting`)}
             labelType="lg"
             isSelected={requireAltTextEnabled}
             onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)}
@@ -435,23 +463,23 @@ export function SettingsScreen({}: Props) {
           <View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
             <SelectableBtn
               selected={colorMode === 'system'}
-              label="System"
+              label={_(msg`System`)}
               left
               onSelect={() => setColorMode('system')}
-              accessibilityHint="Set color theme to system setting"
+              accessibilityHint={_(msg`Set color theme to system setting`)}
             />
             <SelectableBtn
               selected={colorMode === 'light'}
-              label="Light"
+              label={_(msg`Light`)}
               onSelect={() => setColorMode('light')}
-              accessibilityHint="Set color theme to light"
+              accessibilityHint={_(msg`Set color theme to light`)}
             />
             <SelectableBtn
               selected={colorMode === 'dark'}
-              label="Dark"
+              label={_(msg`Dark`)}
               right
               onSelect={() => setColorMode('dark')}
-              accessibilityHint="Set color theme to dark"
+              accessibilityHint={_(msg`Set color theme to dark`)}
             />
           </View>
         </View>
@@ -529,8 +557,8 @@ export function SettingsScreen({}: Props) {
           ]}
           onPress={isSwitchingAccounts ? undefined : onPressLanguageSettings}
           accessibilityRole="button"
-          accessibilityHint="Language settings"
-          accessibilityLabel={_(msg`Opens configurable language settings`)}>
+          accessibilityLabel={_(msg`Language settings`)}
+          accessibilityHint={_(msg`Opens configurable language settings`)}>
           <View style={[styles.iconContainer, pal.btn]}>
             <FontAwesomeIcon
               icon="language"
@@ -554,8 +582,8 @@ export function SettingsScreen({}: Props) {
               : () => navigation.navigate('Moderation')
           }
           accessibilityRole="button"
-          accessibilityHint=""
-          accessibilityLabel={_(msg`Opens moderation settings`)}>
+          accessibilityLabel={_(msg`Moderation settings`)}
+          accessibilityHint={_(msg`Opens moderation settings`)}>
           <View style={[styles.iconContainer, pal.btn]}>
             <HandIcon style={pal.text} size={18} strokeWidth={6} />
           </View>
@@ -563,6 +591,39 @@ export function SettingsScreen({}: Props) {
             <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]}>
@@ -577,8 +638,8 @@ export function SettingsScreen({}: Props) {
           ]}
           onPress={onPressAppPasswords}
           accessibilityRole="button"
-          accessibilityHint="Open app password settings"
-          accessibilityLabel={_(msg`Opens the app password settings page`)}>
+          accessibilityLabel={_(msg`App password settings`)}
+          accessibilityHint={_(msg`Opens the app password settings page`)}>
           <View style={[styles.iconContainer, pal.btn]}>
             <FontAwesomeIcon
               icon="lock"
@@ -599,7 +660,7 @@ export function SettingsScreen({}: Props) {
           onPress={isSwitchingAccounts ? undefined : onPressChangeHandle}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Change handle`)}
-          accessibilityHint="Choose a new Bluesky username or create">
+          accessibilityHint={_(msg`Choose a new Bluesky username or create`)}>
           <View style={[styles.iconContainer, pal.btn]}>
             <FontAwesomeIcon
               icon="at"
@@ -610,6 +671,17 @@ export function SettingsScreen({}: Props) {
             <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>Danger Zone</Trans>
@@ -620,7 +692,9 @@ export function SettingsScreen({}: Props) {
           accessible={true}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Delete account`)}
-          accessibilityHint="Opens modal for account deletion confirmation. Requires email code.">
+          accessibilityHint={_(
+            msg`Opens modal for account deletion confirmation. Requires email code.`,
+          )}>
           <View style={[styles.iconContainer, dangerBg]}>
             <FontAwesomeIcon
               icon={['far', 'trash-can']}
@@ -660,8 +734,8 @@ export function SettingsScreen({}: Props) {
               style={[pal.view, styles.linkCardNoIcon]}
               onPress={onPressStorybook}
               accessibilityRole="button"
-              accessibilityHint="Open storybook page"
-              accessibilityLabel={_(msg`Opens the storybook page`)}>
+              accessibilityLabel={_(msg`Open storybook page`)}
+              accessibilityHint={_(msg`Opens the storybook page`)}>
               <Text type="lg" style={pal.text}>
                 <Trans>Storybook</Trans>
               </Text>
@@ -670,8 +744,8 @@ export function SettingsScreen({}: Props) {
               style={[pal.view, styles.linkCardNoIcon]}
               onPress={onPressResetPreferences}
               accessibilityRole="button"
-              accessibilityHint="Reset preferences"
-              accessibilityLabel={_(msg`Resets the preferences state`)}>
+              accessibilityLabel={_(msg`Reset preferences`)}
+              accessibilityHint={_(msg`Resets the preferences state`)}>
               <Text type="lg" style={pal.text}>
                 <Trans>Reset preferences state</Trans>
               </Text>
@@ -680,8 +754,8 @@ export function SettingsScreen({}: Props) {
               style={[pal.view, styles.linkCardNoIcon]}
               onPress={onPressResetOnboarding}
               accessibilityRole="button"
-              accessibilityHint="Reset onboarding"
-              accessibilityLabel={_(msg`Resets the onboarding state`)}>
+              accessibilityLabel={_(msg`Reset onboarding`)}
+              accessibilityHint={_(msg`Resets the onboarding state`)}>
               <Text type="lg" style={pal.text}>
                 <Trans>Reset onboarding state</Trans>
               </Text>
@@ -690,8 +764,8 @@ export function SettingsScreen({}: Props) {
               style={[pal.view, styles.linkCardNoIcon]}
               onPress={clearAllLegacyStorage}
               accessibilityRole="button"
-              accessibilityHint="Clear all legacy storage data"
-              accessibilityLabel={_(msg`Clear all legacy storage data`)}>
+              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)
@@ -702,8 +776,8 @@ export function SettingsScreen({}: Props) {
               style={[pal.view, styles.linkCardNoIcon]}
               onPress={clearAllStorage}
               accessibilityRole="button"
-              accessibilityHint="Clear all storage data"
-              accessibilityLabel={_(msg`Clear all storage data`)}>
+              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>
diff --git a/src/view/screens/Storybook/Breakpoints.tsx b/src/view/screens/Storybook/Breakpoints.tsx
new file mode 100644
index 000000000..1b846d517
--- /dev/null
+++ b/src/view/screens/Storybook/Breakpoints.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme, useBreakpoints} from '#/alf'
+import {Text, H3} from '#/components/Typography'
+
+export function Breakpoints() {
+  const t = useTheme()
+  const breakpoints = useBreakpoints()
+
+  return (
+    <View>
+      <H3 style={[a.pb_md]}>Breakpoint Debugger</H3>
+      <Text style={[a.pb_md]}>
+        Current breakpoint: {!breakpoints.gtMobile && <Text>mobile</Text>}
+        {breakpoints.gtMobile && !breakpoints.gtTablet && <Text>tablet</Text>}
+        {breakpoints.gtTablet && <Text>desktop</Text>}
+      </Text>
+      <Text
+        style={[a.p_md, t.atoms.bg_contrast_100, {fontFamily: 'monospace'}]}>
+        {JSON.stringify(breakpoints, null, 2)}
+      </Text>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx
new file mode 100644
index 000000000..fbdc84eb4
--- /dev/null
+++ b/src/view/screens/Storybook/Buttons.tsx
@@ -0,0 +1,124 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {
+  Button,
+  ButtonVariant,
+  ButtonColor,
+  ButtonIcon,
+  ButtonText,
+} from '#/components/Button'
+import {H1} from '#/components/Typography'
+import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
+import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+
+export function Buttons() {
+  return (
+    <View style={[a.gap_md]}>
+      <H1>Buttons</H1>
+
+      <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}>
+        {['primary', 'secondary', 'negative'].map(color => (
+          <View key={color} style={[a.gap_md, a.align_start]}>
+            {['solid', 'outline', 'ghost'].map(variant => (
+              <React.Fragment key={variant}>
+                <Button
+                  variant={variant as ButtonVariant}
+                  color={color as ButtonColor}
+                  size="large"
+                  label="Click here">
+                  Button
+                </Button>
+                <Button
+                  disabled
+                  variant={variant as ButtonVariant}
+                  color={color as ButtonColor}
+                  size="large"
+                  label="Click here">
+                  Button
+                </Button>
+              </React.Fragment>
+            ))}
+          </View>
+        ))}
+
+        <View style={[a.flex_row, a.gap_md, a.align_start]}>
+          <View style={[a.gap_md, a.align_start]}>
+            {['gradient_sky', 'gradient_midnight', 'gradient_sunrise'].map(
+              name => (
+                <React.Fragment key={name}>
+                  <Button
+                    variant="gradient"
+                    color={name as ButtonColor}
+                    size="large"
+                    label="Click here">
+                    Button
+                  </Button>
+                  <Button
+                    disabled
+                    variant="gradient"
+                    color={name as ButtonColor}
+                    size="large"
+                    label="Click here">
+                    Button
+                  </Button>
+                </React.Fragment>
+              ),
+            )}
+          </View>
+          <View style={[a.gap_md, a.align_start]}>
+            {['gradient_sunset', 'gradient_nordic', 'gradient_bonfire'].map(
+              name => (
+                <React.Fragment key={name}>
+                  <Button
+                    variant="gradient"
+                    color={name as ButtonColor}
+                    size="large"
+                    label="Click here">
+                    Button
+                  </Button>
+                  <Button
+                    disabled
+                    variant="gradient"
+                    color={name as ButtonColor}
+                    size="large"
+                    label="Click here">
+                    Button
+                  </Button>
+                </React.Fragment>
+              ),
+            )}
+          </View>
+        </View>
+
+        <Button
+          variant="gradient"
+          color="gradient_sky"
+          size="large"
+          label="Link out">
+          <ButtonText>Link out</ButtonText>
+          <ButtonIcon icon={ArrowTopRight} />
+        </Button>
+
+        <Button
+          variant="gradient"
+          color="gradient_sky"
+          size="small"
+          label="Link out">
+          <ButtonText>Link out</ButtonText>
+          <ButtonIcon icon={ArrowTopRight} />
+        </Button>
+
+        <Button
+          variant="gradient"
+          color="gradient_sky"
+          size="small"
+          label="Link out">
+          <ButtonIcon icon={Globe} />
+          <ButtonText>See the world</ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx
new file mode 100644
index 000000000..db568c6bd
--- /dev/null
+++ b/src/view/screens/Storybook/Dialogs.tsx
@@ -0,0 +1,90 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {Button} from '#/components/Button'
+import {H3, P} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+import {useDialogStateControlContext} from '#/state/dialogs'
+
+export function Dialogs() {
+  const control = Dialog.useDialogControl()
+  const prompt = Prompt.usePromptControl()
+  const {closeAllDialogs} = useDialogStateControlContext()
+
+  return (
+    <View style={[a.gap_md]}>
+      <Button
+        variant="outline"
+        color="secondary"
+        size="small"
+        onPress={() => {
+          control.open()
+          prompt.open()
+        }}
+        label="Open basic dialog">
+        Open basic dialog
+      </Button>
+
+      <Button
+        variant="solid"
+        color="primary"
+        size="small"
+        onPress={() => prompt.open()}
+        label="Open prompt">
+        Open prompt
+      </Button>
+
+      <Prompt.Outer control={prompt}>
+        <Prompt.Title>This is a prompt</Prompt.Title>
+        <Prompt.Description>
+          This is a generic prompt component. It accepts a title and a
+          description, as well as two actions.
+        </Prompt.Description>
+        <Prompt.Actions>
+          <Prompt.Cancel>Cancel</Prompt.Cancel>
+          <Prompt.Action>Confirm</Prompt.Action>
+        </Prompt.Actions>
+      </Prompt.Outer>
+
+      <Dialog.Outer
+        control={control}
+        nativeOptions={{sheet: {snapPoints: ['90%']}}}>
+        <Dialog.Handle />
+
+        <Dialog.ScrollableInner
+          accessibilityDescribedBy="dialog-description"
+          accessibilityLabelledBy="dialog-title">
+          <View style={[a.relative, a.gap_md, a.w_full]}>
+            <H3 nativeID="dialog-title">Dialog</H3>
+            <P nativeID="dialog-description">
+              A scrollable dialog with an input within it.
+            </P>
+            <Dialog.Input value="" onChangeText={() => {}} label="Type here" />
+
+            <Button
+              variant="outline"
+              color="secondary"
+              size="small"
+              onPress={closeAllDialogs}
+              label="Close all dialogs">
+              Close all dialogs
+            </Button>
+            <View style={{height: 1000}} />
+            <View style={[a.flex_row, a.justify_end]}>
+              <Button
+                variant="outline"
+                color="primary"
+                size="small"
+                onPress={() => control.close()}
+                label="Open basic dialog">
+                Close basic dialog
+              </Button>
+            </View>
+          </View>
+        </Dialog.ScrollableInner>
+      </Dialog.Outer>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx
new file mode 100644
index 000000000..9396cca67
--- /dev/null
+++ b/src/view/screens/Storybook/Forms.tsx
@@ -0,0 +1,215 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {H1, H3} from '#/components/Typography'
+import * as TextField from '#/components/forms/TextField'
+import {DateField, Label} from '#/components/forms/DateField'
+import * as Toggle from '#/components/forms/Toggle'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+import {Button} from '#/components/Button'
+import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+
+export function Forms() {
+  const [toggleGroupAValues, setToggleGroupAValues] = React.useState(['a'])
+  const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b'])
+  const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b'])
+  const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn'])
+
+  const [value, setValue] = React.useState('')
+  const [date, setDate] = React.useState('2001-01-01')
+
+  return (
+    <View style={[a.gap_4xl, a.align_start]}>
+      <H1>Forms</H1>
+
+      <View style={[a.gap_md, a.align_start, a.w_full]}>
+        <H3>InputText</H3>
+
+        <TextField.Input
+          value={value}
+          onChangeText={setValue}
+          label="Text field"
+        />
+
+        <TextField.Root>
+          <TextField.Icon icon={Globe} />
+          <TextField.Input
+            value={value}
+            onChangeText={setValue}
+            label="Text field"
+          />
+        </TextField.Root>
+
+        <View style={[a.w_full]}>
+          <TextField.Label>Text field</TextField.Label>
+          <TextField.Root>
+            <TextField.Icon icon={Globe} />
+            <TextField.Input
+              value={value}
+              onChangeText={setValue}
+              label="Text field"
+            />
+            <TextField.Suffix label="@gmail.com">@gmail.com</TextField.Suffix>
+          </TextField.Root>
+        </View>
+
+        <View style={[a.w_full]}>
+          <TextField.Label>Textarea</TextField.Label>
+          <TextField.Input
+            multiline
+            numberOfLines={4}
+            value={value}
+            onChangeText={setValue}
+            label="Text field"
+          />
+        </View>
+
+        <H3>DateField</H3>
+
+        <View style={[a.w_full]}>
+          <Label>Date</Label>
+          <DateField
+            testID="date"
+            value={date}
+            onChangeDate={date => {
+              console.log(date)
+              setDate(date)
+            }}
+            label="Input"
+          />
+        </View>
+      </View>
+
+      <View style={[a.gap_md, a.align_start, a.w_full]}>
+        <H3>Toggles</H3>
+
+        <Toggle.Item name="a" label="Click me">
+          <Toggle.Checkbox />
+          <Toggle.Label>Uncontrolled toggle</Toggle.Label>
+        </Toggle.Item>
+
+        <Toggle.Group
+          label="Toggle"
+          type="checkbox"
+          maxSelections={2}
+          values={toggleGroupAValues}
+          onChange={setToggleGroupAValues}>
+          <View style={[a.gap_md]}>
+            <Toggle.Item name="a" label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="b" label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="c" label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="d" disabled label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="e" isInvalid label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+          </View>
+        </Toggle.Group>
+
+        <Toggle.Group
+          label="Toggle"
+          type="checkbox"
+          maxSelections={2}
+          values={toggleGroupBValues}
+          onChange={setToggleGroupBValues}>
+          <View style={[a.gap_md]}>
+            <Toggle.Item name="a" label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="b" label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="c" label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="d" disabled label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="e" isInvalid label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+          </View>
+        </Toggle.Group>
+
+        <Toggle.Group
+          label="Toggle"
+          type="radio"
+          values={toggleGroupCValues}
+          onChange={setToggleGroupCValues}>
+          <View style={[a.gap_md]}>
+            <Toggle.Item name="a" label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="b" label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="c" label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="d" disabled label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="e" isInvalid label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+          </View>
+        </Toggle.Group>
+      </View>
+
+      <Button
+        variant="gradient"
+        color="gradient_nordic"
+        size="small"
+        label="Reset all toggles"
+        onPress={() => {
+          setToggleGroupAValues(['a'])
+          setToggleGroupBValues(['a', 'b'])
+          setToggleGroupCValues(['a'])
+        }}>
+        Reset all toggles
+      </Button>
+
+      <View style={[a.gap_md, a.align_start, a.w_full]}>
+        <H3>ToggleButton</H3>
+
+        <ToggleButton.Group
+          label="Preferences"
+          values={toggleGroupDValues}
+          onChange={setToggleGroupDValues}>
+          <ToggleButton.Button name="hide" label="Hide">
+            Hide
+          </ToggleButton.Button>
+          <ToggleButton.Button name="warn" label="Warn">
+            Warn
+          </ToggleButton.Button>
+          <ToggleButton.Button name="show" label="Show">
+            Show
+          </ToggleButton.Button>
+        </ToggleButton.Group>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx
new file mode 100644
index 000000000..73466e077
--- /dev/null
+++ b/src/view/screens/Storybook/Icons.tsx
@@ -0,0 +1,41 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {H1} from '#/components/Typography'
+import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
+import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
+
+export function Icons() {
+  const t = useTheme()
+  return (
+    <View style={[a.gap_md]}>
+      <H1>Icons</H1>
+
+      <View style={[a.flex_row, a.gap_xl]}>
+        <Globe size="xs" fill={t.atoms.text.color} />
+        <Globe size="sm" fill={t.atoms.text.color} />
+        <Globe size="md" fill={t.atoms.text.color} />
+        <Globe size="lg" fill={t.atoms.text.color} />
+        <Globe size="xl" fill={t.atoms.text.color} />
+      </View>
+
+      <View style={[a.flex_row, a.gap_xl]}>
+        <ArrowTopRight size="xs" fill={t.atoms.text.color} />
+        <ArrowTopRight size="sm" fill={t.atoms.text.color} />
+        <ArrowTopRight size="md" fill={t.atoms.text.color} />
+        <ArrowTopRight size="lg" fill={t.atoms.text.color} />
+        <ArrowTopRight size="xl" fill={t.atoms.text.color} />
+      </View>
+
+      <View style={[a.flex_row, a.gap_xl]}>
+        <CalendarDays size="xs" fill={t.atoms.text.color} />
+        <CalendarDays size="sm" fill={t.atoms.text.color} />
+        <CalendarDays size="md" fill={t.atoms.text.color} />
+        <CalendarDays size="lg" fill={t.atoms.text.color} />
+        <CalendarDays size="xl" fill={t.atoms.text.color} />
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx
new file mode 100644
index 000000000..c3b1c0e0f
--- /dev/null
+++ b/src/view/screens/Storybook/Links.tsx
@@ -0,0 +1,48 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {ButtonText} from '#/components/Button'
+import {Link} from '#/components/Link'
+import {H1, H3} from '#/components/Typography'
+
+export function Links() {
+  return (
+    <View style={[a.gap_md, a.align_start]}>
+      <H1>Links</H1>
+
+      <View style={[a.gap_md, a.align_start]}>
+        <Link
+          to="https://blueskyweb.xyz"
+          warnOnMismatchingTextChild
+          style={[a.text_md]}>
+          External
+        </Link>
+        <Link to="https://blueskyweb.xyz" style={[a.text_md]}>
+          <H3>External with custom children</H3>
+        </Link>
+        <Link
+          to="https://blueskyweb.xyz"
+          warnOnMismatchingTextChild
+          style={[a.text_lg]}>
+          https://blueskyweb.xyz
+        </Link>
+        <Link
+          to="https://bsky.app/profile/bsky.app"
+          warnOnMismatchingTextChild
+          style={[a.text_md]}>
+          Internal
+        </Link>
+
+        <Link
+          variant="solid"
+          color="primary"
+          size="large"
+          label="View @bsky.app's profile"
+          to="https://bsky.app/profile/bsky.app">
+          <ButtonText>Link as a button</ButtonText>
+        </Link>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Palette.tsx b/src/view/screens/Storybook/Palette.tsx
new file mode 100644
index 000000000..b521fe860
--- /dev/null
+++ b/src/view/screens/Storybook/Palette.tsx
@@ -0,0 +1,336 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import * as tokens from '#/alf/tokens'
+import {atoms as a} from '#/alf'
+
+export function Palette() {
+  return (
+    <View style={[a.gap_md]}>
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[a.flex_1, {height: 60, backgroundColor: tokens.color.gray_0}]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_25},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_50},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_100},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_200},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_300},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_400},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_500},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_600},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_700},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_800},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_900},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_950},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_975},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_1000},
+          ]}
+        />
+      </View>
+
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_25},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_50},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_100},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_200},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_300},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_400},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_500},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_600},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_700},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_800},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_900},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_950},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_975},
+          ]}
+        />
+      </View>
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_25},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_50},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_100},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_200},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_300},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_400},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_500},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_600},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_700},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_800},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_900},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_950},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_975},
+          ]}
+        />
+      </View>
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_25}]}
+        />
+        <View
+          style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_50}]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_100},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_200},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_300},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_400},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_500},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_600},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_700},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_800},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_900},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_950},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_975},
+          ]}
+        />
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Shadows.tsx b/src/view/screens/Storybook/Shadows.tsx
new file mode 100644
index 000000000..f92112395
--- /dev/null
+++ b/src/view/screens/Storybook/Shadows.tsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {H1, Text} from '#/components/Typography'
+
+export function Shadows() {
+  const t = useTheme()
+
+  return (
+    <View style={[a.gap_md]}>
+      <H1>Shadows</H1>
+
+      <View style={[a.flex_row, a.gap_5xl]}>
+        <View
+          style={[
+            a.flex_1,
+            a.justify_center,
+            a.px_lg,
+            a.py_2xl,
+            t.atoms.bg,
+            t.atoms.shadow_sm,
+          ]}>
+          <Text>shadow_sm</Text>
+        </View>
+
+        <View
+          style={[
+            a.flex_1,
+            a.justify_center,
+            a.px_lg,
+            a.py_2xl,
+            t.atoms.bg,
+            t.atoms.shadow_md,
+          ]}>
+          <Text>shadow_md</Text>
+        </View>
+
+        <View
+          style={[
+            a.flex_1,
+            a.justify_center,
+            a.px_lg,
+            a.py_2xl,
+            t.atoms.bg,
+            t.atoms.shadow_lg,
+          ]}>
+          <Text>shadow_lg</Text>
+        </View>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Spacing.tsx b/src/view/screens/Storybook/Spacing.tsx
new file mode 100644
index 000000000..d7faf93a8
--- /dev/null
+++ b/src/view/screens/Storybook/Spacing.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text, H1} from '#/components/Typography'
+
+export function Spacing() {
+  const t = useTheme()
+  return (
+    <View style={[a.gap_md]}>
+      <H1>Spacing</H1>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>2xs (2px)</Text>
+        <View style={[a.flex_1, a.pt_2xs, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>xs (4px)</Text>
+        <View style={[a.flex_1, a.pt_xs, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>sm (8px)</Text>
+        <View style={[a.flex_1, a.pt_sm, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>md (12px)</Text>
+        <View style={[a.flex_1, a.pt_md, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>lg (16px)</Text>
+        <View style={[a.flex_1, a.pt_lg, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>xl (20px)</Text>
+        <View style={[a.flex_1, a.pt_xl, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>2xl (24px)</Text>
+        <View style={[a.flex_1, a.pt_2xl, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>3xl (28px)</Text>
+        <View style={[a.flex_1, a.pt_3xl, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>4xl (32px)</Text>
+        <View style={[a.flex_1, a.pt_4xl, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>5xl (40px)</Text>
+        <View style={[a.flex_1, a.pt_5xl, t.atoms.bg_contrast_300]} />
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Theming.tsx b/src/view/screens/Storybook/Theming.tsx
new file mode 100644
index 000000000..a05443473
--- /dev/null
+++ b/src/view/screens/Storybook/Theming.tsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Palette} from './Palette'
+
+export function Theming() {
+  const t = useTheme()
+
+  return (
+    <View style={[t.atoms.bg, a.gap_lg, a.p_xl]}>
+      <Palette />
+
+      <Text style={[a.font_bold, a.pt_xl, a.px_md]}>theme.atoms.text</Text>
+
+      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
+      <Text style={[a.font_bold, t.atoms.text_contrast_600, a.px_md]}>
+        theme.atoms.text_contrast_600
+      </Text>
+
+      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
+      <Text style={[a.font_bold, t.atoms.text_contrast_500, a.px_md]}>
+        theme.atoms.text_contrast_500
+      </Text>
+
+      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
+      <Text style={[a.font_bold, t.atoms.text_contrast_400, a.px_md]}>
+        theme.atoms.text_contrast_400
+      </Text>
+
+      <View style={[a.flex_1, t.atoms.border_contrast, a.border_t]} />
+
+      <View style={[a.w_full, a.gap_md]}>
+        <View style={[t.atoms.bg, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_25, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_25</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_50, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_50</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_100, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_100</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_200, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_200</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_300, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_300</Text>
+        </View>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx
new file mode 100644
index 000000000..2e1f04a66
--- /dev/null
+++ b/src/view/screens/Storybook/Typography.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {Text, H1, H2, H3, H4, H5, H6, P} from '#/components/Typography'
+
+export function Typography() {
+  return (
+    <View style={[a.gap_md]}>
+      <H1>H1 Heading</H1>
+      <H2>H2 Heading</H2>
+      <H3>H3 Heading</H3>
+      <H4>H4 Heading</H4>
+      <H5>H5 Heading</H5>
+      <H6>H6 Heading</H6>
+      <P>P Paragraph</P>
+
+      <Text style={[a.text_5xl]}>atoms.text_5xl</Text>
+      <Text style={[a.text_4xl]}>atoms.text_4xl</Text>
+      <Text style={[a.text_3xl]}>atoms.text_3xl</Text>
+      <Text style={[a.text_2xl]}>atoms.text_2xl</Text>
+      <Text style={[a.text_xl]}>atoms.text_xl</Text>
+      <Text style={[a.text_lg]}>atoms.text_lg</Text>
+      <Text style={[a.text_md]}>atoms.text_md</Text>
+      <Text style={[a.text_sm]}>atoms.text_sm</Text>
+      <Text style={[a.text_xs]}>atoms.text_xs</Text>
+      <Text style={[a.text_2xs]}>atoms.text_2xs</Text>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
new file mode 100644
index 000000000..d8898f20e
--- /dev/null
+++ b/src/view/screens/Storybook/index.tsx
@@ -0,0 +1,78 @@
+import React from 'react'
+import {View} from 'react-native'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+
+import {atoms as a, useTheme, ThemeProvider} from '#/alf'
+import {useSetColorMode} from '#/state/shell'
+import {Button} from '#/components/Button'
+
+import {Theming} from './Theming'
+import {Typography} from './Typography'
+import {Spacing} from './Spacing'
+import {Buttons} from './Buttons'
+import {Links} from './Links'
+import {Forms} from './Forms'
+import {Dialogs} from './Dialogs'
+import {Breakpoints} from './Breakpoints'
+import {Shadows} from './Shadows'
+import {Icons} from './Icons'
+
+export function Storybook() {
+  const t = useTheme()
+  const setColorMode = useSetColorMode()
+
+  return (
+    <ScrollView>
+      <CenteredView style={[t.atoms.bg]}>
+        <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}>
+          <View style={[a.flex_row, a.align_start, a.gap_md]}>
+            <Button
+              variant="outline"
+              color="primary"
+              size="small"
+              label='Set theme to "system"'
+              onPress={() => setColorMode('system')}>
+              System
+            </Button>
+            <Button
+              variant="solid"
+              color="secondary"
+              size="small"
+              label='Set theme to "system"'
+              onPress={() => setColorMode('light')}>
+              Light
+            </Button>
+            <Button
+              variant="solid"
+              color="secondary"
+              size="small"
+              label='Set theme to "system"'
+              onPress={() => setColorMode('dark')}>
+              Dark
+            </Button>
+          </View>
+
+          <ThemeProvider theme="light">
+            <Theming />
+          </ThemeProvider>
+          <ThemeProvider theme="dim">
+            <Theming />
+          </ThemeProvider>
+          <ThemeProvider theme="dark">
+            <Theming />
+          </ThemeProvider>
+
+          <Typography />
+          <Spacing />
+          <Shadows />
+          <Buttons />
+          <Icons />
+          <Links />
+          <Forms />
+          <Dialogs />
+          <Breakpoints />
+        </View>
+      </CenteredView>
+    </ScrollView>
+  )
+}
diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx
index 6856f6759..9e7d36ec7 100644
--- a/src/view/screens/Support.tsx
+++ b/src/view/screens/Support.tsx
@@ -34,10 +34,10 @@ export const SupportScreen = (_props: Props) => {
         </Text>
         <Text style={[pal.text, s.p20]}>
           <Trans>
-            The support form has been moved. If you need help, please
+            The support form has been moved. If you need help, please{' '}
             <TextLink
               href={HELP_DESK_URL}
-              text=" click here"
+              text={_(msg`click here`)}
               style={pal.link}
             />{' '}
             or visit {HELP_DESK_URL} to get in touch with us.
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 73f9f540e..99e659d62 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -5,6 +5,11 @@ import {ComposePost} from '../com/composer/Composer'
 import {useComposerState} from 'state/shell/composer'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
+import {
+  EmojiPicker,
+  EmojiPickerState,
+} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx'
 
 const BOTTOM_BAR_HEIGHT = 61
 
@@ -12,11 +17,33 @@ export function Composer({}: {winHeight: number}) {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const state = useComposerState()
+  const isActive = !!state
+  useWebBodyScrollLock(isActive)
+
+  const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
+    isOpen: false,
+    pos: {top: 0, left: 0, right: 0, bottom: 0},
+  })
+
+  const onOpenPicker = React.useCallback((pos: DOMRect | undefined) => {
+    if (!pos) return
+    setPickerState({
+      isOpen: true,
+      pos,
+    })
+  }, [])
+
+  const onClosePicker = React.useCallback(() => {
+    setPickerState(prev => ({
+      ...prev,
+      isOpen: false,
+    }))
+  }, [])
 
   // rendering
   // =
 
-  if (!state) {
+  if (!isActive) {
     return <View />
   }
 
@@ -41,15 +68,18 @@ export function Composer({}: {winHeight: number}) {
           quote={state.quote}
           onPost={state.onPost}
           mention={state.mention}
+          openPicker={onOpenPicker}
         />
       </Animated.View>
+      <EmojiPicker state={pickerState} close={onClosePicker} />
     </Animated.View>
   )
 }
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore
+    position: 'fixed',
     top: 0,
     left: 0,
     width: '100%',
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 14bc6af26..c30874c2f 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -53,6 +53,8 @@ import {useInviteCodesQuery} from '#/state/queries/invites'
 import {NavSignupCard} from '#/view/shell/NavSignupCard'
 import {TextLink} from '../com/util/Link'
 
+import {useTheme as useAlfTheme} from '#/alf'
+
 let DrawerProfileCard = ({
   account,
   onPressProfile,
@@ -68,7 +70,7 @@ let DrawerProfileCard = ({
     <TouchableOpacity
       testID="profileCardButton"
       accessibilityLabel={_(msg`Profile`)}
-      accessibilityHint="Navigates to your profile"
+      accessibilityHint={_(msg`Navigates to your profile`)}
       onPress={onPressProfile}>
       <UserAvatar
         size={80}
@@ -106,6 +108,7 @@ export {DrawerProfileCard}
 
 let DrawerContent = ({}: {}): React.ReactNode => {
   const theme = useTheme()
+  const t = useAlfTheme()
   const pal = usePalette('default')
   const {_} = useLingui()
   const setDrawerOpen = useSetDrawerOpen()
@@ -208,7 +211,7 @@ let DrawerContent = ({}: {}): React.ReactNode => {
       testID="drawer"
       style={[
         styles.view,
-        theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
+        theme.colorScheme === 'light' ? pal.view : t.atoms.bg_contrast_25,
       ]}>
       <SafeAreaView style={s.flex1}>
         <ScrollView style={styles.main}>
@@ -435,7 +438,9 @@ let NotificationsMenuItem = ({
       label={_(msg`Notifications`)}
       accessibilityLabel={_(msg`Notifications`)}
       accessibilityHint={
-        numUnreadNotifications === '' ? '' : `${numUnreadNotifications} unread`
+        numUnreadNotifications === ''
+          ? ''
+          : _(msg`${numUnreadNotifications} unread`)
       }
       count={numUnreadNotifications}
       bold={isActive}
diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx
index ae9381440..f226406f5 100644
--- a/src/view/shell/bottom-bar/BottomBarStyles.tsx
+++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx
@@ -12,6 +12,10 @@ export const styles = StyleSheet.create({
     paddingLeft: 5,
     paddingRight: 10,
   },
+  bottomBarWeb: {
+    // @ts-ignore web-only
+    position: 'fixed',
+  },
   ctrl: {
     flex: 1,
     paddingTop: 13,
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index c5dc376b7..b330c4b80 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -57,6 +57,7 @@ export function BottomBarWeb() {
     <Animated.View
       style={[
         styles.bottomBar,
+        styles.bottomBarWeb,
         pal.view,
         pal.border,
         {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
index 43dc28159..9fea6e49f 100644
--- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx
+++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
@@ -124,6 +124,7 @@ function NativeStackNavigator({
       },
     }
   }
+
   return (
     <NavigationContent>
       <NativeStackView
@@ -136,7 +137,7 @@ function NativeStackNavigator({
       {isWeb && !isMobile && (
         <>
           <DesktopLeftNav />
-          <DesktopRightNav />
+          <DesktopRightNav routeName={activeRoute.name} />
         </>
       )}
     </NavigationContent>
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index 93b96e704..a8f5f1c66 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -21,7 +21,7 @@ export function DesktopFeeds() {
   })
 
   return (
-    <View style={[styles.container, pal.view, pal.border]}>
+    <View style={[styles.container, pal.view]}>
       <FeedItem href="/" title="Following" current={route.name === 'Home'} />
       {feeds
         .filter(f => f.displayName !== 'Following')
@@ -91,7 +91,5 @@ const styles = StyleSheet.create({
     width: 300,
     paddingHorizontal: 12,
     paddingVertical: 18,
-    borderTopWidth: 1,
-    borderBottomWidth: 1,
   },
 })
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index f3c8c1d11..b27898828 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -239,24 +239,26 @@ function ComposeBtn() {
     return null
   }
   return (
-    <TouchableOpacity
-      disabled={isFetchingHandle}
-      style={[styles.newPostBtn]}
-      onPress={onPressCompose}
-      accessibilityRole="button"
-      accessibilityLabel={_(msg`New post`)}
-      accessibilityHint="">
-      <View style={styles.newPostBtnIconWrapper}>
-        <ComposeIcon2
-          size={19}
-          strokeWidth={2}
-          style={styles.newPostBtnLabel}
-        />
-      </View>
-      <Text type="button" style={styles.newPostBtnLabel}>
-        <Trans>New Post</Trans>
-      </Text>
-    </TouchableOpacity>
+    <View style={styles.newPostBtnContainer}>
+      <TouchableOpacity
+        disabled={isFetchingHandle}
+        style={styles.newPostBtn}
+        onPress={onPressCompose}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New post`)}
+        accessibilityHint="">
+        <View style={styles.newPostBtnIconWrapper}>
+          <ComposeIcon2
+            size={19}
+            strokeWidth={2}
+            style={styles.newPostBtnLabel}
+          />
+        </View>
+        <Text type="button" style={styles.newPostBtnLabel}>
+          <Trans context="action">New Post</Trans>
+        </Text>
+      </TouchableOpacity>
+    </View>
   )
 }
 
@@ -440,10 +442,11 @@ export function DesktopLeftNav() {
 
 const styles = StyleSheet.create({
   leftNav: {
-    position: 'absolute',
+    // @ts-ignore web only
+    position: 'fixed',
     top: 10,
     // @ts-ignore web only
-    right: 'calc(50vw + 312px)',
+    left: 'calc(50vw - 300px - 220px - 20px)',
     width: 220,
     // @ts-ignore web only
     maxHeight: 'calc(100vh - 10px)',
@@ -512,6 +515,9 @@ const styles = StyleSheet.create({
     fontSize: 14,
   },
 
+  newPostBtnContainer: {
+    flexDirection: 'row',
+  },
   newPostBtn: {
     flexDirection: 'row',
     alignItems: 'center',
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 8d9961a5f..328c527e4 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -16,7 +16,7 @@ import {Plural, Trans, msg, plural} from '@lingui/macro'
 import {useSession} from '#/state/session'
 import {useInviteCodesQuery} from '#/state/queries/invites'
 
-export function DesktopRightNav() {
+export function DesktopRightNav({routeName}: {routeName: string}) {
   const pal = usePalette('default')
   const palError = usePalette('error')
   const {_} = useLingui()
@@ -30,12 +30,20 @@ export function DesktopRightNav() {
   return (
     <View style={[styles.rightNav, pal.view]}>
       <View style={{paddingVertical: 20}}>
-        <DesktopSearch />
-
-        {hasSession && (
-          <View style={{paddingTop: 18, marginBottom: 18}}>
+        {routeName === 'Search' ? (
+          <View style={{marginBottom: 18}}>
             <DesktopFeeds />
           </View>
+        ) : (
+          <>
+            <DesktopSearch />
+
+            {hasSession && (
+              <View style={[pal.border, styles.desktopFeedsContainer]}>
+                <DesktopFeeds />
+              </View>
+            )}
+          </>
         )}
 
         <View
@@ -48,7 +56,7 @@ export function DesktopRightNav() {
           {isSandbox ? (
             <View style={[palError.view, styles.messageLine, s.p10]}>
               <Text type="md" style={[palError.text, s.bold]}>
-                SANDBOX. Posts and accounts are not permanent.
+                <Trans>SANDBOX. Posts and accounts are not permanent.</Trans>
               </Text>
             </View>
           ) : undefined}
@@ -169,17 +177,18 @@ function InviteCodes() {
 
 const styles = StyleSheet.create({
   rightNav: {
-    position: 'absolute',
     // @ts-ignore web only
-    left: 'calc(50vw + 320px)',
-    width: 304,
+    position: 'fixed',
+    // @ts-ignore web only
+    left: 'calc(50vw + 300px + 20px)',
+    width: 300,
     maxHeight: '100%',
     overflowY: 'auto',
   },
 
   message: {
     paddingVertical: 18,
-    paddingHorizontal: 10,
+    paddingHorizontal: 12,
   },
   messageLine: {
     marginBottom: 10,
@@ -187,7 +196,7 @@ const styles = StyleSheet.create({
 
   inviteCodes: {
     borderTopWidth: 1,
-    paddingHorizontal: 16,
+    paddingHorizontal: 12,
     paddingVertical: 12,
     flexDirection: 'row',
   },
@@ -196,4 +205,10 @@ const styles = StyleSheet.create({
     marginRight: 6,
     flexShrink: 0,
   },
+  desktopFeedsContainer: {
+    borderTopWidth: 1,
+    borderBottomWidth: 1,
+    marginTop: 18,
+    marginBottom: 18,
+  },
 })
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 6201f828f..4a9483733 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -29,13 +29,63 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 import {useModerationOpts} from '#/state/queries/preferences'
 
-export function SearchResultCard({
-  profile,
+export const MATCH_HANDLE =
+  /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/
+
+export function SearchLinkCard({
+  label,
+  to,
+  onPress,
   style,
+}: {
+  label: string
+  to?: string
+  onPress?: () => void
+  style?: ViewStyle
+}) {
+  const pal = usePalette('default')
+
+  const inner = (
+    <View
+      style={[pal.border, {paddingVertical: 16, paddingHorizontal: 12}, style]}>
+      <Text type="md" style={[pal.text]}>
+        {label}
+      </Text>
+    </View>
+  )
+
+  if (onPress) {
+    return (
+      <TouchableOpacity
+        onPress={onPress}
+        accessibilityLabel={label}
+        accessibilityHint="">
+        {inner}
+      </TouchableOpacity>
+    )
+  }
+
+  return (
+    <Link href={to} asAnchor anchorNoUnderline>
+      <View
+        style={[
+          pal.border,
+          {paddingVertical: 16, paddingHorizontal: 12},
+          style,
+        ]}>
+        <Text type="md" style={[pal.text]}>
+          {label}
+        </Text>
+      </View>
+    </Link>
+  )
+}
+
+export function SearchProfileCard({
+  profile,
   moderation,
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
-  style: ViewStyle
   moderation: ProfileModeration
 }) {
   const pal = usePalette('default')
@@ -50,9 +100,7 @@ export function SearchResultCard({
       <View
         style={[
           pal.border,
-          style,
           {
-            borderTopWidth: 1,
             flexDirection: 'row',
             alignItems: 'center',
             gap: 12,
@@ -147,6 +195,11 @@ export function DesktopSearch() {
     navigation.dispatch(StackActions.push('Search', {q: query}))
   }, [query, navigation, setSearchResults])
 
+  const queryMaybeHandle = React.useMemo(() => {
+    const match = MATCH_HANDLE.exec(query)
+    return match && match[1]
+  }, [query])
+
   return (
     <View style={[styles.container, pal.view]}>
       <View
@@ -169,6 +222,9 @@ export function DesktopSearch() {
             accessibilityRole="search"
             accessibilityLabel={_(msg`Search`)}
             accessibilityHint=""
+            autoCorrect={false}
+            autoComplete="off"
+            autoCapitalize="none"
           />
           {query ? (
             <View style={styles.cancelBtn}>
@@ -176,7 +232,7 @@ export function DesktopSearch() {
                 onPress={onPressCancelSearch}
                 accessibilityRole="button"
                 accessibilityLabel={_(msg`Cancel search`)}
-                accessibilityHint="Exits inputting search query"
+                accessibilityHint={_(msg`Exits inputting search query`)}
                 onAccessibilityEscape={onPressCancelSearch}>
                 <Text type="lg" style={[pal.link]}>
                   <Trans>Cancel</Trans>
@@ -195,22 +251,26 @@ export function DesktopSearch() {
             </View>
           ) : (
             <>
-              {searchResults.length ? (
-                searchResults.map((item, i) => (
-                  <SearchResultCard
-                    key={item.did}
-                    profile={item}
-                    moderation={moderateProfile(item, moderationOpts)}
-                    style={i === 0 ? {borderTopWidth: 0} : {}}
-                  />
-                ))
-              ) : (
-                <View>
-                  <Text style={[pal.textLight, styles.noResults]}>
-                    <Trans>No results found for {query}</Trans>
-                  </Text>
-                </View>
-              )}
+              <SearchLinkCard
+                label={_(msg`Search for "${query}"`)}
+                to={`/search?q=${encodeURIComponent(query)}`}
+                style={{borderBottomWidth: 1}}
+              />
+
+              {queryMaybeHandle ? (
+                <SearchLinkCard
+                  label={_(msg`Go to @${queryMaybeHandle}`)}
+                  to={`/profile/${queryMaybeHandle}`}
+                />
+              ) : null}
+
+              {searchResults.map(item => (
+                <SearchProfileCard
+                  key={item.did}
+                  profile={item}
+                  moderation={moderateProfile(item, moderationOpts)}
+                />
+              ))}
             </>
           )}
         </View>
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 51c03ae3d..5320aebfc 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -28,6 +28,7 @@ import {isAndroid} from 'platform/detection'
 import {useSession} from '#/state/session'
 import {useCloseAnyActiveElement} from '#/state/util'
 import * as notifications from 'lib/notifications/notifications'
+import {Outlet as PortalOutlet} from '#/components/Portal'
 
 function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
@@ -94,6 +95,7 @@ function ShellInner() {
       </View>
       <Composer winHeight={winDim.height} />
       <ModalsContainer />
+      <PortalOutlet />
       <Lightbox />
     </>
   )
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 38da860bd..76f4f5c9b 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -15,6 +15,8 @@ import {useAuxClick} from 'lib/hooks/useAuxClick'
 import {t} from '@lingui/macro'
 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
 import {useCloseAllActiveElements} from '#/state/util'
+import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
+import {Outlet as PortalOutlet} from '#/components/Portal'
 
 function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
@@ -23,6 +25,7 @@ function ShellInner() {
   const navigator = useNavigation<NavigationProp>()
   const closeAllActiveElements = useCloseAllActiveElements()
 
+  useWebBodyScrollLock(isDrawerOpen)
   useAuxClick()
 
   useEffect(() => {
@@ -33,27 +36,26 @@ function ShellInner() {
   }, [navigator, closeAllActiveElements])
 
   return (
-    <View style={[s.hContentRegion, {overflow: 'hidden'}]}>
-      <View style={s.hContentRegion}>
-        <ErrorBoundary>
-          <FlatNavigator />
-        </ErrorBoundary>
-      </View>
+    <>
+      <ErrorBoundary>
+        <FlatNavigator />
+      </ErrorBoundary>
       <Composer winHeight={0} />
       <ModalsContainer />
+      <PortalOutlet />
       <Lightbox />
       {!isDesktop && isDrawerOpen && (
         <TouchableOpacity
           onPress={() => setDrawerOpen(false)}
           style={styles.drawerMask}
           accessibilityLabel={t`Close navigation footer`}
-          accessibilityHint="Closes bottom navigation bar">
+          accessibilityHint={t`Closes bottom navigation bar`}>
           <View style={styles.drawerContainer}>
             <DrawerContent />
           </View>
         </TouchableOpacity>
       )}
-    </View>
+    </>
   )
 }
 
@@ -76,7 +78,8 @@ const styles = StyleSheet.create({
     backgroundColor: colors.black, // TODO
   },
   drawerMask: {
-    position: 'absolute',
+    // @ts-ignore web only
+    position: 'fixed',
     width: '100%',
     height: '100%',
     top: 0,
@@ -85,7 +88,8 @@ const styles = StyleSheet.create({
   },
   drawerContainer: {
     display: 'flex',
-    position: 'absolute',
+    // @ts-ignore web only
+    position: 'fixed',
     top: 0,
     left: 0,
     height: '100%',