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/composer/Composer.tsx54
-rw-r--r--src/view/com/composer/SelectMediaButton.tsx59
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx133
-rw-r--r--src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx382
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx57
-rw-r--r--src/view/com/composer/text-input/TextInput.types.ts42
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx104
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx313
-rw-r--r--src/view/com/feeds/FeedPage.tsx21
-rw-r--r--src/view/com/modals/ChangePassword.tsx350
-rw-r--r--src/view/com/modals/DeleteAccount.tsx2
-rw-r--r--src/view/com/modals/Modal.tsx8
-rw-r--r--src/view/com/modals/Modal.web.tsx6
-rw-r--r--src/view/com/modals/lang-settings/PostLanguagesSettings.tsx145
-rw-r--r--src/view/com/notifications/NotificationFeedItem.tsx2
-rw-r--r--src/view/com/post-thread/PostThread.tsx910
-rw-r--r--src/view/com/post-thread/PostThreadComposePrompt.tsx95
-rw-r--r--src/view/com/post-thread/PostThreadFollowBtn.tsx139
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx1036
-rw-r--r--src/view/com/post-thread/PostThreadLoadMore.tsx65
-rw-r--r--src/view/com/post-thread/PostThreadShowHiddenReplies.tsx62
-rw-r--r--src/view/com/posts/PostFeedItem.tsx4
-rw-r--r--src/view/com/profile/ProfileMenu.tsx17
-rw-r--r--src/view/com/testing/TestCtrls.e2e.tsx18
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx397
-rw-r--r--src/view/screens/Debug.tsx51
-rw-r--r--src/view/screens/DebugMod.tsx53
-rw-r--r--src/view/screens/PostThread.tsx9
-rw-r--r--src/view/screens/Storybook/Toasts.tsx97
-rw-r--r--src/view/shell/desktop/Feeds.tsx19
-rw-r--r--src/view/shell/desktop/LeftNav.tsx52
-rw-r--r--src/view/shell/desktop/RightNav.tsx6
-rw-r--r--src/view/shell/desktop/SidebarTrendingTopics.tsx1
33 files changed, 923 insertions, 3786 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index d0dbdfaba..20f2549ad 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -46,12 +46,14 @@ import {
   AppBskyFeedDefs,
   type AppBskyFeedGetPostThread,
   AppBskyUnspeccedDefs,
+  AtUri,
   type BskyAgent,
   type RichText,
 } from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
 import * as apilib from '#/lib/api/index'
@@ -70,6 +72,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {mimeToExt} from '#/lib/media/video/util'
+import {type NavigationProp} from '#/lib/routes/types'
 import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {colors} from '#/lib/styles'
@@ -107,14 +110,11 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
 import {Gallery} from '#/view/com/composer/photos/Gallery'
 import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
 import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
-import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
+import {SelectPostLanguagesBtn} from '#/view/com/composer/select-language/SelectPostLanguagesDialog'
 import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
 // TODO: Prevent naming components that coincide with RN primitives
 // due to linting false positives
-import {
-  TextInput,
-  type TextInputRef,
-} from '#/view/com/composer/text-input/TextInput'
+import {TextInput} from '#/view/com/composer/text-input/TextInput'
 import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
 import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
@@ -128,7 +128,7 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
 import * as Prompt from '#/components/Prompt'
-import * as toast from '#/components/Toast'
+import * as Toast from '#/components/Toast'
 import {Text as NewText} from '#/components/Typography'
 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
 import {
@@ -152,6 +152,7 @@ import {
   processVideo,
   type VideoState,
 } from './state/video'
+import {type TextInputRef} from './text-input/TextInput.types'
 import {getVideoMetadata} from './videos/pickVideo'
 import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
 
@@ -188,6 +189,7 @@ export const ComposePost = ({
   const {closeAllDialogs} = useDialogStateControlContext()
   const {closeAllModals} = useModalControls()
   const {data: preferences} = usePreferencesQuery()
+  const navigation = useNavigation<NavigationProp>()
 
   const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
   const [isPublishing, setIsPublishing] = useState(false)
@@ -302,7 +304,9 @@ export const ComposePost = ({
   )
 
   const onPressCancel = useCallback(() => {
-    if (
+    if (textInput.current?.maybeClosePopup()) {
+      return
+    } else if (
       thread.posts.some(
         post =>
           post.shortenedGraphemeLength > 0 ||
@@ -521,12 +525,29 @@ export const ComposePost = ({
       onPostSuccess?.(postSuccessData)
     }
     onClose()
-    toast.show(
-      thread.posts.length > 1
-        ? _(msg`Your posts have been published`)
-        : replyTo
-          ? _(msg`Your reply has been published`)
-          : _(msg`Your post has been published`),
+    Toast.show(
+      <Toast.Outer>
+        <Toast.Icon />
+        <Toast.Text>
+          {thread.posts.length > 1
+            ? _(msg`Your posts were sent`)
+            : replyTo
+              ? _(msg`Your reply was sent`)
+              : _(msg`Your post was sent`)}
+        </Toast.Text>
+        {postUri && (
+          <Toast.Action
+            label={_(msg`View post`)}
+            onPress={() => {
+              const {host: name, rkey} = new AtUri(postUri)
+              navigation.navigate('PostThread', {name, rkey})
+            }}>
+            <Trans context="Action to view the post the user just created">
+              View
+            </Trans>
+          </Toast.Action>
+        )}
+      </Toast.Outer>,
       {type: 'success'},
     )
   }, [
@@ -543,6 +564,7 @@ export const ComposePost = ({
     replyTo,
     setLangPrefs,
     queryClient,
+    navigation,
   ])
 
   // Preserves the referential identity passed to each post item.
@@ -826,7 +848,7 @@ let ComposerPost = React.memo(function ComposerPost({
         if (isNative) return // web only
         const [mimeType] = uri.slice('data:'.length).split(';')
         if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
-          toast.show(_(msg`Unsupported video type: ${mimeType}`), {
+          Toast.show(_(msg`Unsupported video type: ${mimeType}`), {
             type: 'error',
           })
           return
@@ -1362,7 +1384,7 @@ function ComposerFooter({
       }
 
       errors.map(error => {
-        toast.show(error, {
+        Toast.show(error, {
           type: 'warning',
         })
       })
@@ -1431,7 +1453,7 @@ function ComposerFooter({
             />
           </Button>
         )}
-        <SelectLangBtn />
+        <SelectPostLanguagesBtn />
         <CharProgress
           count={post.shortenedGraphemeLength}
           style={{width: 65}}
diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx
index 026d0ac19..9401b7975 100644
--- a/src/view/com/composer/SelectMediaButton.tsx
+++ b/src/view/com/composer/SelectMediaButton.tsx
@@ -1,10 +1,6 @@
 import {useCallback} from 'react'
 import {Keyboard} from 'react-native'
-import {
-  type ImagePickerAsset,
-  launchImageLibraryAsync,
-  UIImagePickerPreferredAssetRepresentationMode,
-} from 'expo-image-picker'
+import {type ImagePickerAsset} from 'expo-image-picker'
 import {msg, plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -13,8 +9,9 @@ import {
   usePhotoLibraryPermission,
   useVideoLibraryPermission,
 } from '#/lib/hooks/usePermissions'
+import {openUnifiedPicker} from '#/lib/media/picker'
 import {extractDataUriMime} from '#/lib/media/util'
-import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {isNative, isWeb} from '#/platform/detection'
 import {MAX_IMAGES} from '#/view/com/composer/state/composer'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
@@ -410,7 +407,7 @@ export function SelectMediaButton({
             msg`You can only select one GIF at a time.`,
           ),
           [SelectedAssetError.FileTooBig]: _(
-            msg`One or more of your selected files is too large. Maximum size is 100 MB.`,
+            msg`One or more of your selected files are too large. Maximum size is 100 MB.`,
           ),
         }[error]
       })
@@ -448,18 +445,7 @@ export function SelectMediaButton({
     }
 
     const {assets, canceled} = await sheetWrapper(
-      launchImageLibraryAsync({
-        exif: false,
-        mediaTypes: ['images', 'videos'],
-        quality: 1,
-        allowsMultipleSelection: true,
-        legacy: true,
-        base64: isWeb,
-        selectionLimit: isIOS ? selectionCountRemaining : undefined,
-        preferredAssetRepresentationMode:
-          UIImagePickerPreferredAssetRepresentationMode.Current,
-        videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
-      }),
+      openUnifiedPicker({selectionCountRemaining}),
     )
 
     if (canceled) return
@@ -481,34 +467,17 @@ export function SelectMediaButton({
       label={_(
         msg({
           message: `Add media to post`,
-          comment: `Accessibility label for button in composer to add photos or a video to a post`,
+          comment: `Accessibility label for button in composer to add images, a video, or a GIF to a post`,
+        }),
+      )}
+      accessibilityHint={_(
+        msg({
+          message: `Opens device gallery to select up to ${plural(MAX_IMAGES, {
+            other: '# images',
+          })}, or a single video or GIF.`,
+          comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`,
         }),
       )}
-      accessibilityHint={
-        isNative
-          ? _(
-              msg({
-                message: `Opens device gallery to select up to ${plural(
-                  MAX_IMAGES,
-                  {
-                    other: '# images',
-                  },
-                )}, or a single video.`,
-                comment: `Accessibility hint on native for button in composer to add images or a video to a post. Maximum number of images that can be selected is currently 4 but may change.`,
-              }),
-            )
-          : _(
-              msg({
-                message: `Opens device gallery to select up to ${plural(
-                  MAX_IMAGES,
-                  {
-                    other: '# images',
-                  },
-                )}, or a single video or GIF.`,
-                comment: `Accessibility hint on web for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`,
-              }),
-            )
-      }
       style={a.p_sm}
       variant="ghost"
       shape="round"
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
deleted file mode 100644
index f487b1244..000000000
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-import {useCallback, useMemo} from 'react'
-import {Keyboard, StyleSheet} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants'
-import {usePalette} from '#/lib/hooks/usePalette'
-import {isNative} from '#/platform/detection'
-import {useModalControls} from '#/state/modals'
-import {
-  hasPostLanguage,
-  toPostLanguages,
-  useLanguagePrefs,
-  useLanguagePrefsApi,
-} from '#/state/preferences/languages'
-import {
-  DropdownButton,
-  DropdownItem,
-  DropdownItemButton,
-} from '#/view/com/util/forms/DropdownButton'
-import {Text} from '#/view/com/util/text/Text'
-import {codeToLanguageName} from '../../../../locale/helpers'
-
-export function SelectLangBtn() {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {openModal} = useModalControls()
-  const langPrefs = useLanguagePrefs()
-  const setLangPrefs = useLanguagePrefsApi()
-
-  const onPressMore = useCallback(async () => {
-    if (isNative) {
-      if (Keyboard.isVisible()) {
-        Keyboard.dismiss()
-      }
-    }
-    openModal({name: 'post-languages-settings'})
-  }, [openModal])
-
-  const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
-  const items: DropdownItem[] = useMemo(() => {
-    let arr: DropdownItemButton[] = []
-
-    function add(commaSeparatedLangCodes: string) {
-      const langCodes = commaSeparatedLangCodes.split(',')
-      const langName = langCodes
-        .map(code => codeToLanguageName(code, langPrefs.appLanguage))
-        .join(' + ')
-
-      /*
-       * Filter out any duplicates
-       */
-      if (arr.find((item: DropdownItemButton) => item.label === langName)) {
-        return
-      }
-
-      arr.push({
-        icon:
-          langCodes.every(code =>
-            hasPostLanguage(langPrefs.postLanguage, code),
-          ) && langCodes.length === postLanguagesPref.length
-            ? ['fas', 'circle-dot']
-            : ['far', 'circle'],
-        label: langName,
-        onPress() {
-          setLangPrefs.setPostLanguage(commaSeparatedLangCodes)
-        },
-      })
-    }
-
-    if (postLanguagesPref.length) {
-      /*
-       * Re-join here after sanitization bc postLanguageHistory is an array of
-       * comma-separated strings too
-       */
-      add(langPrefs.postLanguage)
-    }
-
-    // comma-separted strings of lang codes that have been used in the past
-    for (const lang of langPrefs.postLanguageHistory) {
-      add(lang)
-    }
-
-    return [
-      {heading: true, label: _(msg`Post language`)},
-      ...arr.slice(0, 6),
-      {sep: true},
-      {
-        label: _(msg`Other...`),
-        onPress: onPressMore,
-      },
-    ]
-  }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref, _])
-
-  return (
-    <DropdownButton
-      type="bare"
-      testID="selectLangBtn"
-      items={items}
-      openUpwards
-      style={styles.button}
-      hitSlop={LANG_DROPDOWN_HITSLOP}
-      accessibilityLabel={_(msg`Language selection`)}
-      accessibilityHint="">
-      {postLanguagesPref.length > 0 ? (
-        <Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}>
-          {postLanguagesPref
-            .map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
-            .join(', ')}
-        </Text>
-      ) : (
-        <FontAwesomeIcon
-          icon="language"
-          style={pal.link as FontAwesomeIconStyle}
-          size={26}
-        />
-      )}
-    </DropdownButton>
-  )
-}
-
-const styles = StyleSheet.create({
-  button: {
-    marginHorizontal: 15,
-  },
-  label: {
-    maxWidth: 100,
-  },
-})
diff --git a/src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx b/src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx
new file mode 100644
index 000000000..c8ecc2b89
--- /dev/null
+++ b/src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx
@@ -0,0 +1,382 @@
+import {useCallback, useMemo, useState} from 'react'
+import {Keyboard, useWindowDimensions, View} from 'react-native'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants'
+import {languageName} from '#/locale/helpers'
+import {codeToLanguageName} from '#/locale/helpers'
+import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
+import {isNative, isWeb} from '#/platform/detection'
+import {
+  toPostLanguages,
+  useLanguagePrefs,
+  useLanguagePrefsApi,
+} from '#/state/preferences/languages'
+import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
+import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {SearchInput} from '#/components/forms/SearchInput'
+import * as Toggle from '#/components/forms/Toggle'
+import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
+import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
+import {Text} from '#/components/Typography'
+
+export function SelectPostLanguagesBtn() {
+  const {_} = useLingui()
+  const langPrefs = useLanguagePrefs()
+  const t = useTheme()
+  const control = Dialog.useDialogControl()
+
+  const onPressMore = useCallback(async () => {
+    if (isNative) {
+      if (Keyboard.isVisible()) {
+        Keyboard.dismiss()
+      }
+    }
+    control.open()
+  }, [control])
+
+  const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
+
+  return (
+    <>
+      <Button
+        testID="selectLangBtn"
+        onPress={onPressMore}
+        size="small"
+        hitSlop={LANG_DROPDOWN_HITSLOP}
+        label={_(
+          msg({
+            message: `Post language selection`,
+            comment: `Accessibility label for button that opens dialog to choose post language settings`,
+          }),
+        )}
+        accessibilityHint={_(msg`Opens post language settings`)}
+        style={[a.mx_md]}>
+        {({pressed, hovered, focused}) => {
+          const color =
+            pressed || hovered || focused
+              ? t.palette.primary_300
+              : t.palette.primary_500
+          if (postLanguagesPref.length > 0) {
+            return (
+              <Text
+                style={[
+                  {color},
+                  a.font_bold,
+                  a.text_sm,
+                  a.leading_snug,
+                  {maxWidth: 100},
+                ]}
+                numberOfLines={1}>
+                {postLanguagesPref
+                  .map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
+                  .join(', ')}
+              </Text>
+            )
+          } else {
+            return <GlobeIcon size="xs" style={{color}} />
+          }
+        }}
+      </Button>
+
+      <LanguageDialog control={control} />
+    </>
+  )
+}
+
+function LanguageDialog({control}: {control: Dialog.DialogControlProps}) {
+  const {height} = useWindowDimensions()
+  const insets = useSafeAreaInsets()
+
+  const renderErrorBoundary = useCallback(
+    (error: any) => <DialogError details={String(error)} />,
+    [],
+  )
+
+  return (
+    <Dialog.Outer
+      control={control}
+      nativeOptions={{minHeight: height - insets.top}}>
+      <Dialog.Handle />
+      <ErrorBoundary renderError={renderErrorBoundary}>
+        <PostLanguagesSettingsDialogInner />
+      </ErrorBoundary>
+    </Dialog.Outer>
+  )
+}
+
+export function PostLanguagesSettingsDialogInner() {
+  const control = Dialog.useDialogContext()
+  const [headerHeight, setHeaderHeight] = useState(0)
+
+  const allowedLanguages = useMemo(() => {
+    const uniqueLanguagesMap = LANGUAGES.filter(lang => !!lang.code2).reduce(
+      (acc, lang) => {
+        acc[lang.code2] = lang
+        return acc
+      },
+      {} as Record<string, Language>,
+    )
+
+    return Object.values(uniqueLanguagesMap)
+  }, [])
+
+  const langPrefs = useLanguagePrefs()
+  const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>(
+    langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage],
+  )
+  const [search, setSearch] = useState('')
+
+  const setLangPrefs = useLanguagePrefsApi()
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const handleClose = () => {
+    control.close(() => {
+      let langsString = checkedLanguagesCode2.join(',')
+      if (!langsString) {
+        langsString = langPrefs.primaryLanguage
+      }
+      setLangPrefs.setPostLanguage(langsString)
+    })
+  }
+
+  // NOTE(@elijaharita): Displayed languages are split into 3 lists for
+  // ordering.
+  const displayedLanguages = useMemo(() => {
+    function mapCode2List(code2List: string[]) {
+      return code2List.map(code2 => LANGUAGES_MAP_CODE2[code2]).filter(Boolean)
+    }
+
+    // NOTE(@elijaharita): Get recent language codes and map them to language
+    // objects. Both the user account's saved language history and the current
+    // checked languages are displayed here.
+    const recentLanguagesCode2 =
+      Array.from(
+        new Set([...checkedLanguagesCode2, ...langPrefs.postLanguageHistory]),
+      ).slice(0, 5) || []
+    const recentLanguages = mapCode2List(recentLanguagesCode2)
+
+    // NOTE(@elijaharita): helper functions
+    const matchesSearch = (lang: Language) =>
+      lang.name.toLowerCase().includes(search.toLowerCase())
+    const isChecked = (lang: Language) =>
+      checkedLanguagesCode2.includes(lang.code2)
+    const isInRecents = (lang: Language) =>
+      recentLanguagesCode2.includes(lang.code2)
+
+    const checkedRecent = recentLanguages.filter(isChecked)
+
+    if (search) {
+      // NOTE(@elijaharita): if a search is active, we ALWAYS show checked
+      // items, as well as any items that match the search.
+      const uncheckedRecent = recentLanguages
+        .filter(lang => !isChecked(lang))
+        .filter(matchesSearch)
+      const unchecked = allowedLanguages.filter(lang => !isChecked(lang))
+      const all = unchecked
+        .filter(matchesSearch)
+        .filter(lang => !isInRecents(lang))
+
+      return {
+        all,
+        checkedRecent,
+        uncheckedRecent,
+      }
+    } else {
+      // NOTE(@elijaharita): if no search is active, we show everything.
+      const uncheckedRecent = recentLanguages.filter(lang => !isChecked(lang))
+      const all = allowedLanguages
+        .filter(lang => !recentLanguagesCode2.includes(lang.code2))
+        .filter(lang => !isInRecents(lang))
+
+      return {
+        all,
+        checkedRecent,
+        uncheckedRecent,
+      }
+    }
+  }, [
+    allowedLanguages,
+    search,
+    langPrefs.postLanguageHistory,
+    checkedLanguagesCode2,
+  ])
+
+  const listHeader = (
+    <View
+      style={[a.pb_xs, t.atoms.bg, isNative && a.pt_2xl]}
+      onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
+      <View style={[a.flex_row, a.w_full, a.justify_between]}>
+        <View>
+          <Text
+            nativeID="dialog-title"
+            style={[
+              t.atoms.text,
+              a.text_left,
+              a.font_bold,
+              a.text_xl,
+              a.mb_sm,
+            ]}>
+            <Trans>Choose Post Languages</Trans>
+          </Text>
+          <Text
+            nativeID="dialog-description"
+            style={[
+              t.atoms.text_contrast_medium,
+              a.text_left,
+              a.text_md,
+              a.mb_lg,
+            ]}>
+            <Trans>Select up to 3 languages used in this post</Trans>
+          </Text>
+        </View>
+
+        {isWeb && (
+          <Button
+            variant="ghost"
+            size="small"
+            color="secondary"
+            shape="round"
+            label={_(msg`Close dialog`)}
+            onPress={handleClose}>
+            <ButtonIcon icon={XIcon} />
+          </Button>
+        )}
+      </View>
+
+      <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs, a.pb_0]}>
+        <SearchInput
+          value={search}
+          onChangeText={setSearch}
+          placeholder={_(msg`Search languages`)}
+          label={_(msg`Search languages`)}
+          maxLength={50}
+          onClearText={() => setSearch('')}
+        />
+      </View>
+    </View>
+  )
+
+  const isCheckedRecentEmpty =
+    displayedLanguages.checkedRecent.length > 0 ||
+    displayedLanguages.uncheckedRecent.length > 0
+
+  const isDisplayedLanguagesEmpty = displayedLanguages.all.length === 0
+
+  const flatListData = [
+    ...(isCheckedRecentEmpty
+      ? [{type: 'header', label: _(msg`Recently used`)}]
+      : []),
+    ...displayedLanguages.checkedRecent.map(lang => ({type: 'item', lang})),
+    ...displayedLanguages.uncheckedRecent.map(lang => ({type: 'item', lang})),
+    ...(isDisplayedLanguagesEmpty
+      ? []
+      : [{type: 'header', label: _(msg`All languages`)}]),
+    ...displayedLanguages.all.map(lang => ({type: 'item', lang})),
+  ]
+
+  return (
+    <Toggle.Group
+      values={checkedLanguagesCode2}
+      onChange={setCheckedLanguagesCode2}
+      type="checkbox"
+      maxSelections={3}
+      label={_(msg`Select languages`)}
+      style={web([a.contents])}>
+      <Dialog.InnerFlatList
+        data={flatListData}
+        ListHeaderComponent={listHeader}
+        stickyHeaderIndices={[0]}
+        contentContainerStyle={[a.gap_0]}
+        style={[isNative && a.px_lg, web({paddingBottom: 120})]}
+        scrollIndicatorInsets={{top: headerHeight}}
+        renderItem={({item, index}) => {
+          if (item.type === 'header') {
+            return (
+              <Text
+                key={index}
+                style={[
+                  a.px_0,
+                  a.py_md,
+                  a.font_bold,
+                  a.text_xs,
+                  t.atoms.text_contrast_low,
+                  a.pt_3xl,
+                ]}>
+                {item.label}
+              </Text>
+            )
+          }
+          const lang = item.lang
+
+          return (
+            <Toggle.Item
+              key={lang.code2}
+              name={lang.code2}
+              label={languageName(lang, langPrefs.appLanguage)}
+              style={[
+                t.atoms.border_contrast_low,
+                a.border_b,
+                a.rounded_0,
+                a.px_0,
+                a.py_md,
+              ]}>
+              <Toggle.LabelText style={[a.flex_1]}>
+                {languageName(lang, langPrefs.appLanguage)}
+              </Toggle.LabelText>
+              <Toggle.Checkbox />
+            </Toggle.Item>
+          )
+        }}
+        footer={
+          <Dialog.FlatListFooter>
+            <Button
+              label={_(msg`Close dialog`)}
+              onPress={handleClose}
+              color="primary"
+              size="large">
+              <ButtonText>
+                <Trans>Done</Trans>
+              </ButtonText>
+            </Button>
+          </Dialog.FlatListFooter>
+        }
+      />
+    </Toggle.Group>
+  )
+}
+
+function DialogError({details}: {details?: string}) {
+  const {_} = useLingui()
+  const control = Dialog.useDialogContext()
+
+  return (
+    <Dialog.ScrollableInner
+      style={a.gap_md}
+      label={_(msg`An error has occurred`)}>
+      <Dialog.Close />
+      <ErrorScreen
+        title={_(msg`Oh no!`)}
+        message={_(
+          msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
+        )}
+        details={details}
+      />
+      <Button
+        label={_(msg`Close dialog`)}
+        onPress={() => control.close()}
+        color="primary"
+        size="large">
+        <ButtonText>
+          <Trans>Close</Trans>
+        </ButtonText>
+      </Button>
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index ea92d0b91..8b3e61b0e 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -1,7 +1,6 @@
-import React, {
-  type ComponentProps,
-  forwardRef,
+import {
   useCallback,
+  useImperativeHandle,
   useMemo,
   useRef,
   useState,
@@ -9,7 +8,6 @@ import React, {
 import {
   type NativeSyntheticEvent,
   Text as RNText,
-  type TextInput as RNTextInput,
   type TextInputSelectionChangeEventData,
   View,
 } from 'react-native'
@@ -33,57 +31,38 @@ import {
 import {atoms as a, useAlf} from '#/alf'
 import {normalizeTextStyles} from '#/alf/typography'
 import {Autocomplete} from './mobile/Autocomplete'
-
-export interface TextInputRef {
-  focus: () => void
-  blur: () => void
-  getCursorPosition: () => DOMRect | undefined
-}
-
-interface TextInputProps extends ComponentProps<typeof RNTextInput> {
-  richtext: RichText
-  placeholder: string
-  webForceMinHeight: boolean
-  hasRightPadding: boolean
-  isActive: boolean
-  setRichText: (v: RichText) => void
-  onPhotoPasted: (uri: string) => void
-  onPressPublish: (richtext: RichText) => void
-  onNewLink: (uri: string) => void
-  onError: (err: string) => void
-}
+import {type TextInputProps} from './TextInput.types'
 
 interface Selection {
   start: number
   end: number
 }
 
-export const TextInput = forwardRef(function TextInputImpl(
-  {
-    richtext,
-    placeholder,
-    hasRightPadding,
-    setRichText,
-    onPhotoPasted,
-    onNewLink,
-    onError,
-    ...props
-  }: TextInputProps,
+export function TextInput({
   ref,
-) {
+  richtext,
+  placeholder,
+  hasRightPadding,
+  setRichText,
+  onPhotoPasted,
+  onNewLink,
+  onError,
+  ...props
+}: TextInputProps) {
   const {theme: t, fonts} = useAlf()
   const textInput = useRef<PasteInputRef>(null)
   const textInputSelection = useRef<Selection>({start: 0, end: 0})
   const theme = useTheme()
   const [autocompletePrefix, setAutocompletePrefix] = useState('')
-  const prevLength = React.useRef(richtext.length)
+  const prevLength = useRef(richtext.length)
 
-  React.useImperativeHandle(ref, () => ({
+  useImperativeHandle(ref, () => ({
     focus: () => textInput.current?.focus(),
     blur: () => {
       textInput.current?.blur()
     },
     getCursorPosition: () => undefined, // Not implemented on native
+    maybeClosePopup: () => false, // Not needed on native
   }))
 
   const pastSuggestedUris = useRef(new Set<string>())
@@ -185,7 +164,7 @@ export const TextInput = forwardRef(function TextInputImpl(
     [onChangeText, richtext, setAutocompletePrefix],
   )
 
-  const inputTextStyle = React.useMemo(() => {
+  const inputTextStyle = useMemo(() => {
     const style = normalizeTextStyles(
       [a.text_lg, a.leading_snug, t.atoms.text],
       {
@@ -277,4 +256,4 @@ export const TextInput = forwardRef(function TextInputImpl(
       />
     </View>
   )
-})
+}
diff --git a/src/view/com/composer/text-input/TextInput.types.ts b/src/view/com/composer/text-input/TextInput.types.ts
new file mode 100644
index 000000000..fab2bc32f
--- /dev/null
+++ b/src/view/com/composer/text-input/TextInput.types.ts
@@ -0,0 +1,42 @@
+import {type TextInput} from 'react-native'
+import {type RichText} from '@atproto/api'
+
+export type TextInputRef = {
+  focus: () => void
+  blur: () => void
+  /**
+   * @platform web
+   */
+  getCursorPosition: () =>
+    | {left: number; right: number; top: number; bottom: number}
+    | undefined
+  /**
+   * Closes the autocomplete popup if it is open.
+   * Returns `true` if the popup was closed, `false` otherwise.
+   *
+   * @platform web
+   */
+  maybeClosePopup: () => boolean
+}
+
+export type TextInputProps = {
+  ref: React.Ref<TextInputRef>
+  richtext: RichText
+  webForceMinHeight: boolean
+  hasRightPadding: boolean
+  isActive: boolean
+  setRichText: (v: RichText) => void
+  onPhotoPasted: (uri: string) => void
+  onPressPublish: (richtext: RichText) => void
+  onNewLink: (uri: string) => void
+  onError: (err: string) => void
+  onFocus: () => void
+} & Pick<
+  React.ComponentProps<typeof TextInput>,
+  | 'placeholder'
+  | 'autoFocus'
+  | 'style'
+  | 'accessible'
+  | 'accessibilityLabel'
+  | 'accessibilityHint'
+>
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index cb7ed194a..9f6cc6ae2 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -1,4 +1,11 @@
-import React, {useRef} from 'react'
+import {
+  useCallback,
+  useEffect,
+  useImperativeHandle,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
 import {StyleSheet, View} from 'react-native'
 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
 import {AppBskyRichtextFacet, RichText} from '@atproto/api'
@@ -16,7 +23,6 @@ import {EditorContent, type JSONContent, useEditor} from '@tiptap/react'
 import Graphemer from 'graphemer'
 
 import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
-import {usePalette} from '#/lib/hooks/usePalette'
 import {blobToDataUri, isUriImage} from '#/lib/media/util'
 import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 import {
@@ -27,57 +33,34 @@ import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEm
 import {atoms as a, useAlf} from '#/alf'
 import {normalizeTextStyles} from '#/alf/typography'
 import {Portal} from '#/components/Portal'
-import {Text} from '../../util/text/Text'
-import {createSuggestion} from './web/Autocomplete'
+import {Text} from '#/components/Typography'
+import {type TextInputProps} from './TextInput.types'
+import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
 import {type Emoji} from './web/EmojiPicker'
 import {LinkDecorator} from './web/LinkDecorator'
 import {TagDecorator} from './web/TagDecorator'
 
-export interface TextInputRef {
-  focus: () => void
-  blur: () => void
-  getCursorPosition: () => DOMRect | undefined
-}
-
-interface TextInputProps {
-  richtext: RichText
-  placeholder: string
-  suggestedLinks: Set<string>
-  webForceMinHeight: boolean
-  hasRightPadding: boolean
-  isActive: boolean
-  setRichText: (v: RichText | ((v: RichText) => RichText)) => void
-  onPhotoPasted: (uri: string) => void
-  onPressPublish: (richtext: RichText) => void
-  onNewLink: (uri: string) => void
-  onError: (err: string) => void
-  onFocus: () => void
-}
-
-export const TextInput = React.forwardRef(function TextInputImpl(
-  {
-    richtext,
-    placeholder,
-    webForceMinHeight,
-    hasRightPadding,
-    isActive,
-    setRichText,
-    onPhotoPasted,
-    onPressPublish,
-    onNewLink,
-    onFocus,
-  }: // onError, TODO
-  TextInputProps,
+export function TextInput({
   ref,
-) {
+  richtext,
+  placeholder,
+  webForceMinHeight,
+  hasRightPadding,
+  isActive,
+  setRichText,
+  onPhotoPasted,
+  onPressPublish,
+  onNewLink,
+  onFocus,
+}: TextInputProps) {
   const {theme: t, fonts} = useAlf()
   const autocomplete = useActorAutocompleteFn()
-  const pal = usePalette('default')
   const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
 
-  const [isDropping, setIsDropping] = React.useState(false)
+  const [isDropping, setIsDropping] = useState(false)
+  const autocompleteRef = useRef<AutocompleteRef>(null)
 
-  const extensions = React.useMemo(
+  const extensions = useMemo(
     () => [
       Document,
       LinkDecorator,
@@ -86,7 +69,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
         HTMLAttributes: {
           class: 'mention',
         },
-        suggestion: createSuggestion({autocomplete}),
+        suggestion: createSuggestion({autocomplete, autocompleteRef}),
       }),
       Paragraph,
       Placeholder.configure({
@@ -99,7 +82,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     [autocomplete, placeholder],
   )
 
-  React.useEffect(() => {
+  useEffect(() => {
     if (!isActive) {
       return
     }
@@ -109,7 +92,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     }
   }, [onPressPublish, isActive])
 
-  React.useEffect(() => {
+  useEffect(() => {
     if (!isActive) {
       return
     }
@@ -119,7 +102,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     }
   }, [isActive, onPhotoPasted])
 
-  React.useEffect(() => {
+  useEffect(() => {
     if (!isActive) {
       return
     }
@@ -296,13 +279,13 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     [modeClass],
   )
 
-  const onEmojiInserted = React.useCallback(
+  const onEmojiInserted = useCallback(
     (emoji: Emoji) => {
       editor?.chain().focus().insertContent(emoji.native).run()
     },
     [editor],
   )
-  React.useEffect(() => {
+  useEffect(() => {
     if (!isActive) {
       return
     }
@@ -312,7 +295,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     }
   }, [onEmojiInserted, isActive])
 
-  React.useImperativeHandle(ref, () => ({
+  useImperativeHandle(ref, () => ({
     focus: () => {
       editor?.chain().focus()
     },
@@ -323,9 +306,10 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       const pos = editor?.state.selection.$anchor.pos
       return pos ? editor?.view.coordsAtPos(pos) : undefined
     },
+    maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false,
   }))
 
-  const inputStyle = React.useMemo(() => {
+  const inputStyle = useMemo(() => {
     const style = normalizeTextStyles(
       [a.text_lg, a.leading_snug, t.atoms.text],
       {
@@ -360,10 +344,20 @@ export const TextInput = React.forwardRef(function TextInputImpl(
             style={styles.dropContainer}
             entering={FadeIn.duration(80)}
             exiting={FadeOut.duration(80)}>
-            <View style={[pal.view, pal.border, styles.dropModal]}>
+            <View
+              style={[
+                t.atoms.bg,
+                t.atoms.border_contrast_low,
+                styles.dropModal,
+              ]}>
               <Text
-                type="lg"
-                style={[pal.text, pal.borderDark, styles.dropText]}>
+                style={[
+                  a.text_lg,
+                  a.font_bold,
+                  t.atoms.text_contrast_medium,
+                  t.atoms.border_contrast_high,
+                  styles.dropText,
+                ]}>
                 <Trans>Drop to add images</Trans>
               </Text>
             </View>
@@ -372,7 +366,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       )}
     </>
   )
-})
+}
 
 function editorJsonToText(
   json: JSONContent,
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index 94ecb53cc..1a95736c3 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -1,6 +1,6 @@
 import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
-import {Pressable, StyleSheet, View} from 'react-native'
-import {type AppBskyActorDefs} from '@atproto/api'
+import {Pressable, View} from 'react-native'
+import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
 import {Trans} from '@lingui/macro'
 import {ReactRenderer} from '@tiptap/react'
 import {
@@ -10,25 +10,26 @@ import {
 } from '@tiptap/suggestion'
 import tippy, {type Instance as TippyInstance} from 'tippy.js'
 
-import {usePalette} from '#/lib/hooks/usePalette'
-import {sanitizeDisplayName} from '#/lib/strings/display-names'
-import {sanitizeHandle} from '#/lib/strings/handles'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
-import {Text} from '#/view/com/util/text/Text'
-import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a} from '#/alf'
-import {useSimpleVerificationState} from '#/components/verification'
-import {VerificationCheck} from '#/components/verification/VerificationCheck'
-import {useGrapheme} from '../hooks/useGrapheme'
+import {atoms as a, useTheme} from '#/alf'
+import * as ProfileCard from '#/components/ProfileCard'
+import {Text} from '#/components/Typography'
 
 interface MentionListRef {
   onKeyDown: (props: SuggestionKeyDownProps) => boolean
 }
 
+export interface AutocompleteRef {
+  maybeClose: () => boolean
+}
+
 export function createSuggestion({
   autocomplete,
+  autocompleteRef,
 }: {
   autocomplete: ActorAutocompleteFn
+  autocompleteRef: React.Ref<AutocompleteRef>
 }): Omit<SuggestionOptions, 'editor'> {
   return {
     async items({query}) {
@@ -40,10 +41,15 @@ export function createSuggestion({
       let component: ReactRenderer<MentionListRef> | undefined
       let popup: TippyInstance[] | undefined
 
+      const hide = () => {
+        popup?.[0]?.destroy()
+        component?.destroy()
+      }
+
       return {
         onStart: props => {
           component = new ReactRenderer(MentionList, {
-            props,
+            props: {...props, autocompleteRef, hide},
             editor: props.editor,
           })
 
@@ -78,204 +84,163 @@ export function createSuggestion({
 
         onKeyDown(props) {
           if (props.event.key === 'Escape') {
-            popup?.[0]?.hide()
-
-            return true
+            return false
           }
 
           return component?.ref?.onKeyDown(props) || false
         },
 
         onExit() {
-          popup?.[0]?.destroy()
-          component?.destroy()
+          hide()
         },
       }
     },
   }
 }
 
-const MentionList = forwardRef<MentionListRef, SuggestionProps>(
-  function MentionListImpl(props: SuggestionProps, ref) {
-    const [selectedIndex, setSelectedIndex] = useState(0)
-    const pal = usePalette('default')
+const MentionList = forwardRef<
+  MentionListRef,
+  SuggestionProps & {
+    autocompleteRef: React.Ref<AutocompleteRef>
+    hide: () => void
+  }
+>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) {
+  const [selectedIndex, setSelectedIndex] = useState(0)
+  const t = useTheme()
+  const moderationOpts = useModerationOpts()
 
-    const selectItem = (index: number) => {
-      const item = props.items[index]
+  const selectItem = (index: number) => {
+    const item = items[index]
 
-      if (item) {
-        props.command({id: item.handle})
-      }
+    if (item) {
+      command({id: item.handle})
     }
+  }
 
-    const upHandler = () => {
-      setSelectedIndex(
-        (selectedIndex + props.items.length - 1) % props.items.length,
-      )
-    }
+  const upHandler = () => {
+    setSelectedIndex((selectedIndex + items.length - 1) % items.length)
+  }
 
-    const downHandler = () => {
-      setSelectedIndex((selectedIndex + 1) % props.items.length)
-    }
+  const downHandler = () => {
+    setSelectedIndex((selectedIndex + 1) % items.length)
+  }
 
-    const enterHandler = () => {
-      selectItem(selectedIndex)
-    }
+  const enterHandler = () => {
+    selectItem(selectedIndex)
+  }
+
+  useEffect(() => setSelectedIndex(0), [items])
+
+  useImperativeHandle(autocompleteRef, () => ({
+    maybeClose: () => {
+      hide()
+      return true
+    },
+  }))
+
+  useImperativeHandle(ref, () => ({
+    onKeyDown: ({event}) => {
+      if (event.key === 'ArrowUp') {
+        upHandler()
+        return true
+      }
+
+      if (event.key === 'ArrowDown') {
+        downHandler()
+        return true
+      }
+
+      if (event.key === 'Enter' || event.key === 'Tab') {
+        enterHandler()
+        return true
+      }
+
+      return false
+    },
+  }))
 
-    useEffect(() => setSelectedIndex(0), [props.items])
-
-    useImperativeHandle(ref, () => ({
-      onKeyDown: ({event}) => {
-        if (event.key === 'ArrowUp') {
-          upHandler()
-          return true
-        }
-
-        if (event.key === 'ArrowDown') {
-          downHandler()
-          return true
-        }
-
-        if (event.key === 'Enter' || event.key === 'Tab') {
-          enterHandler()
-          return true
-        }
-
-        return false
-      },
-    }))
-
-    const {items} = props
-
-    return (
-      <div className="items">
-        <View style={[pal.borderDark, pal.view, styles.container]}>
-          {items.length > 0 ? (
-            items.map((item, index) => {
-              const isSelected = selectedIndex === index
-
-              return (
-                <AutocompleteProfileCard
-                  key={item.handle}
-                  profile={item}
-                  isSelected={isSelected}
-                  itemIndex={index}
-                  totalItems={items.length}
-                  onPress={() => {
-                    selectItem(index)
-                  }}
-                />
-              )
-            })
-          ) : (
-            <Text type="sm" style={[pal.text, styles.noResult]}>
-              <Trans>No result</Trans>
-            </Text>
-          )}
-        </View>
-      </div>
-    )
-  },
-)
+  if (!moderationOpts) return null
+
+  return (
+    <div className="items">
+      <View
+        style={[
+          t.atoms.border_contrast_low,
+          t.atoms.bg,
+          a.rounded_sm,
+          a.border,
+          a.p_xs,
+          {width: 300},
+        ]}>
+        {items.length > 0 ? (
+          items.map((item, index) => {
+            const isSelected = selectedIndex === index
+
+            return (
+              <AutocompleteProfileCard
+                key={item.handle}
+                profile={item}
+                isSelected={isSelected}
+                onPress={() => selectItem(index)}
+                onHover={() => setSelectedIndex(index)}
+                moderationOpts={moderationOpts}
+              />
+            )
+          })
+        ) : (
+          <Text style={[a.text_sm, a.px_md, a.py_md]}>
+            <Trans>No result</Trans>
+          </Text>
+        )}
+      </View>
+    </div>
+  )
+})
 
 function AutocompleteProfileCard({
   profile,
   isSelected,
-  itemIndex,
-  totalItems,
   onPress,
+  onHover,
+  moderationOpts,
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
   isSelected: boolean
-  itemIndex: number
-  totalItems: number
   onPress: () => void
+  onHover: () => void
+  moderationOpts: ModerationOpts
 }) {
-  const pal = usePalette('default')
-  const {getGraphemeString} = useGrapheme()
-  const {name: displayName} = getGraphemeString(
-    sanitizeDisplayName(profile.displayName || sanitizeHandle(profile.handle)),
-    30, // Heuristic value; can be modified
-  )
-  const state = useSimpleVerificationState({
-    profile,
-  })
+  const t = useTheme()
+
   return (
     <Pressable
       style={[
-        isSelected ? pal.viewLight : undefined,
-        pal.borderDark,
-        styles.mentionContainer,
-        itemIndex === 0
-          ? styles.firstMention
-          : itemIndex === totalItems - 1
-            ? styles.lastMention
-            : undefined,
+        isSelected && t.atoms.bg_contrast_25,
+        a.align_center,
+        a.justify_between,
+        a.flex_row,
+        a.px_md,
+        a.py_sm,
+        a.gap_2xl,
+        a.rounded_xs,
+        a.transition_color,
       ]}
       onPress={onPress}
+      onPointerEnter={onHover}
       accessibilityRole="button">
-      <View style={[styles.avatarAndDisplayName, a.flex_1]}>
-        <UserAvatar
-          avatar={profile.avatar ?? null}
-          size={26}
-          type={profile.associated?.labeler ? 'labeler' : 'user'}
-        />
-        <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
-          <Text emoji style={[pal.text]} numberOfLines={1}>
-            {displayName}
-          </Text>
-          {state.isVerified && (
-            <View>
-              <VerificationCheck
-                width={12}
-                verifier={state.role === 'verifier'}
-              />
-            </View>
-          )}
-        </View>
-      </View>
-      <View>
-        <Text type="xs" style={pal.textLight} numberOfLines={1}>
-          {sanitizeHandle(profile.handle, '@')}
-        </Text>
+      <View style={[a.flex_1]}>
+        <ProfileCard.Header>
+          <ProfileCard.Avatar
+            profile={profile}
+            moderationOpts={moderationOpts}
+            disabledPreview
+          />
+          <ProfileCard.NameAndHandle
+            profile={profile}
+            moderationOpts={moderationOpts}
+          />
+        </ProfileCard.Header>
       </View>
     </Pressable>
   )
 }
-
-const styles = StyleSheet.create({
-  container: {
-    width: 500,
-    borderRadius: 6,
-    borderWidth: 1,
-    borderStyle: 'solid',
-    padding: 4,
-  },
-  mentionContainer: {
-    display: 'flex',
-    alignItems: 'center',
-    justifyContent: 'space-between',
-    flexDirection: 'row',
-    paddingHorizontal: 12,
-    paddingVertical: 8,
-    gap: 16,
-  },
-  firstMention: {
-    borderTopLeftRadius: 2,
-    borderTopRightRadius: 2,
-  },
-  lastMention: {
-    borderBottomLeftRadius: 2,
-    borderBottomRightRadius: 2,
-  },
-  avatarAndDisplayName: {
-    display: 'flex',
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 6,
-  },
-  noResult: {
-    paddingHorizontal: 12,
-    paddingVertical: 8,
-  },
-})
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index e8a177a8d..0f93e66e3 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
 import {type NavigationProp, useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
-import {VIDEO_FEED_URIS} from '#/lib/constants'
+import {DISCOVER_FEED_URI, VIDEO_FEED_URIS} from '#/lib/constants'
 import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {ComposeIcon2} from '#/lib/icons'
 import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
@@ -17,9 +17,12 @@ import {isNative} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
 import {useSetHomeBadge} from '#/state/home-badge'
-import {type SavedFeedSourceInfo} from '#/state/queries/feed'
-import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
-import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed'
+import {type FeedSourceInfo} from '#/state/queries/feed'
+import {
+  type FeedDescriptor,
+  type FeedParams,
+  RQKEY as FEED_RQKEY,
+} from '#/state/queries/post-feed'
 import {truncateAndInvalidate} from '#/state/queries/util'
 import {useSession} from '#/state/session'
 import {useSetMinimalShellMode} from '#/state/shell'
@@ -51,7 +54,7 @@ export function FeedPage({
   renderEmptyState: () => JSX.Element
   renderEndOfFeed?: () => JSX.Element
   savedFeedConfig?: AppBskyActorDefs.SavedFeed
-  feedInfo: SavedFeedSourceInfo
+  feedInfo: FeedSourceInfo
 }) {
   const {hasSession} = useSession()
   const {_} = useLingui()
@@ -61,7 +64,7 @@ export function FeedPage({
   const [isScrolledDown, setIsScrolledDown] = useState(false)
   const setMinimalShellMode = useSetMinimalShellMode()
   const headerOffset = useHeaderOffset()
-  const feedFeedback = useFeedFeedback(feed, hasSession)
+  const feedFeedback = useFeedFeedback(feedInfo, hasSession)
   const scrollElRef = useRef<ListMethods>(null)
   const [hasNew, setHasNew] = useState(false)
   const setHomeBadge = useSetHomeBadge()
@@ -127,8 +130,12 @@ export function FeedPage({
   }, [scrollToTop, feed, queryClient])
 
   const shouldPrefetch = isNative && isPageAdjacent
+  const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI
   return (
-    <View testID={testID}>
+    <View
+      testID={testID}
+      // @ts-expect-error web only -sfn
+      dataSet={{nosnippet: isDiscoverFeed ? '' : undefined}}>
       <MainScrollProvider>
         <FeedFeedbackProvider value={feedFeedback}>
           <PostFeed
diff --git a/src/view/com/modals/ChangePassword.tsx b/src/view/com/modals/ChangePassword.tsx
deleted file mode 100644
index 9b96e7db0..000000000
--- a/src/view/com/modals/ChangePassword.tsx
+++ /dev/null
@@ -1,350 +0,0 @@
-import {useState} from 'react'
-import {
-  ActivityIndicator,
-  SafeAreaView,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import * as EmailValidator from 'email-validator'
-
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {cleanError, isNetworkError} from '#/lib/strings/errors'
-import {checkAndFormatResetCode} from '#/lib/strings/password'
-import {colors, s} from '#/lib/styles'
-import {logger} from '#/logger'
-import {isAndroid, isWeb} from '#/platform/detection'
-import {useModalControls} from '#/state/modals'
-import {useAgent, useSession} from '#/state/session'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {Button} from '../util/forms/Button'
-import {Text} from '../util/text/Text'
-import {ScrollView} from './util'
-import {TextInput} from './util'
-
-enum Stages {
-  RequestCode,
-  ChangePassword,
-  Done,
-}
-
-export const snapPoints = isAndroid ? ['90%'] : ['45%']
-
-export function Component() {
-  const pal = usePalette('default')
-  const {currentAccount} = useSession()
-  const agent = useAgent()
-  const {_} = useLingui()
-  const [stage, setStage] = useState<Stages>(Stages.RequestCode)
-  const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [resetCode, setResetCode] = useState<string>('')
-  const [newPassword, setNewPassword] = useState<string>('')
-  const [error, setError] = useState<string>('')
-  const {isMobile} = useWebMediaQueries()
-  const {closeModal} = useModalControls()
-
-  const onRequestCode = async () => {
-    if (
-      !currentAccount?.email ||
-      !EmailValidator.validate(currentAccount.email)
-    ) {
-      return setError(_(msg`Your email appears to be invalid.`))
-    }
-
-    setError('')
-    setIsProcessing(true)
-    try {
-      await agent.com.atproto.server.requestPasswordReset({
-        email: currentAccount.email,
-      })
-      setStage(Stages.ChangePassword)
-    } catch (e: any) {
-      const errMsg = e.toString()
-      logger.warn('Failed to request password reset', {error: e})
-      if (isNetworkError(e)) {
-        setError(
-          _(
-            msg`Unable to contact your service. Please check your Internet connection.`,
-          ),
-        )
-      } else {
-        setError(cleanError(errMsg))
-      }
-    } finally {
-      setIsProcessing(false)
-    }
-  }
-
-  const onChangePassword = async () => {
-    const formattedCode = checkAndFormatResetCode(resetCode)
-    if (!formattedCode) {
-      setError(
-        _(
-          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
-        ),
-      )
-      return
-    }
-    if (!newPassword) {
-      setError(
-        _(msg`Please enter a password. It must be at least 8 characters long.`),
-      )
-      return
-    }
-    if (newPassword.length < 8) {
-      setError(_(msg`Password must be at least 8 characters long.`))
-      return
-    }
-
-    setError('')
-    setIsProcessing(true)
-    try {
-      await agent.com.atproto.server.resetPassword({
-        token: formattedCode,
-        password: newPassword,
-      })
-      setStage(Stages.Done)
-    } catch (e: any) {
-      const errMsg = e.toString()
-      logger.warn('Failed to set new password', {error: e})
-      if (isNetworkError(e)) {
-        setError(
-          _(
-            msg`Unable to contact your service. Please check your Internet connection.`,
-          ),
-        )
-      } else {
-        setError(cleanError(errMsg))
-      }
-    } finally {
-      setIsProcessing(false)
-    }
-  }
-
-  const onBlur = () => {
-    const formattedCode = checkAndFormatResetCode(resetCode)
-    if (!formattedCode) {
-      setError(
-        _(
-          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
-        ),
-      )
-      return
-    }
-    setResetCode(formattedCode)
-  }
-
-  return (
-    <SafeAreaView style={[pal.view, s.flex1]}>
-      <ScrollView
-        contentContainerStyle={[
-          styles.container,
-          isMobile && styles.containerMobile,
-        ]}
-        keyboardShouldPersistTaps="handled">
-        <View>
-          <View style={styles.titleSection}>
-            <Text type="title-lg" style={[pal.text, styles.title]}>
-              {stage !== Stages.Done
-                ? _(msg`Change Password`)
-                : _(msg`Password Changed`)}
-            </Text>
-          </View>
-
-          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
-            {stage === Stages.RequestCode ? (
-              <Trans>
-                If you want to change your password, we will send you a code to
-                verify that this is your account.
-              </Trans>
-            ) : stage === Stages.ChangePassword ? (
-              <Trans>
-                Enter the code you received to change your password.
-              </Trans>
-            ) : (
-              <Trans>Your password has been changed successfully!</Trans>
-            )}
-          </Text>
-
-          {stage === Stages.RequestCode && (
-            <View style={[s.flexRow, s.justifyCenter, s.mt10]}>
-              <TouchableOpacity
-                testID="skipSendEmailButton"
-                onPress={() => setStage(Stages.ChangePassword)}
-                accessibilityRole="button"
-                accessibilityLabel={_(msg`Go to next`)}
-                accessibilityHint={_(msg`Navigates to the next screen`)}>
-                <Text type="xl" style={[pal.link, s.pr5]}>
-                  <Trans>Already have a code?</Trans>
-                </Text>
-              </TouchableOpacity>
-            </View>
-          )}
-          {stage === Stages.ChangePassword && (
-            <View style={[pal.border, styles.group]}>
-              <View style={[styles.groupContent]}>
-                <FontAwesomeIcon
-                  icon="ticket"
-                  style={[pal.textLight, styles.groupContentIcon]}
-                />
-                <TextInput
-                  testID="codeInput"
-                  style={[pal.text, styles.textInput]}
-                  placeholder={_(msg`Reset code`)}
-                  placeholderTextColor={pal.colors.textLight}
-                  value={resetCode}
-                  onChangeText={setResetCode}
-                  onFocus={() => setError('')}
-                  onBlur={onBlur}
-                  accessible={true}
-                  accessibilityLabel={_(msg`Reset Code`)}
-                  accessibilityHint=""
-                  autoCapitalize="none"
-                  autoCorrect={false}
-                  autoComplete="off"
-                />
-              </View>
-              <View
-                style={[
-                  pal.borderDark,
-                  styles.groupContent,
-                  styles.groupBottom,
-                ]}>
-                <FontAwesomeIcon
-                  icon="lock"
-                  style={[pal.textLight, styles.groupContentIcon]}
-                />
-                <TextInput
-                  testID="codeInput"
-                  style={[pal.text, styles.textInput]}
-                  placeholder={_(msg`New password`)}
-                  placeholderTextColor={pal.colors.textLight}
-                  onChangeText={setNewPassword}
-                  secureTextEntry
-                  accessible={true}
-                  accessibilityLabel={_(msg`New Password`)}
-                  accessibilityHint=""
-                  autoCapitalize="none"
-                  autoComplete="new-password"
-                />
-              </View>
-            </View>
-          )}
-          {error ? (
-            <ErrorMessage message={error} style={styles.error} />
-          ) : undefined}
-        </View>
-        <View style={[styles.btnContainer]}>
-          {isProcessing ? (
-            <View style={styles.btn}>
-              <ActivityIndicator color="#fff" />
-            </View>
-          ) : (
-            <View style={{gap: 6}}>
-              {stage === Stages.RequestCode && (
-                <Button
-                  testID="requestChangeBtn"
-                  type="primary"
-                  onPress={onRequestCode}
-                  accessibilityLabel={_(msg`Request Code`)}
-                  accessibilityHint=""
-                  label={_(msg`Request Code`)}
-                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
-                  labelStyle={[s.f18]}
-                />
-              )}
-              {stage === Stages.ChangePassword && (
-                <Button
-                  testID="confirmBtn"
-                  type="primary"
-                  onPress={onChangePassword}
-                  accessibilityLabel={_(msg`Next`)}
-                  accessibilityHint=""
-                  label={_(msg`Next`)}
-                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
-                  labelStyle={[s.f18]}
-                />
-              )}
-              <Button
-                testID="cancelBtn"
-                type={stage !== Stages.Done ? 'default' : 'primary'}
-                onPress={() => {
-                  closeModal()
-                }}
-                accessibilityLabel={
-                  stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`)
-                }
-                accessibilityHint=""
-                label={stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`)}
-                labelContainerStyle={{justifyContent: 'center', padding: 4}}
-                labelStyle={[s.f18]}
-              />
-            </View>
-          )}
-        </View>
-      </ScrollView>
-    </SafeAreaView>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    justifyContent: 'space-between',
-  },
-  containerMobile: {
-    paddingHorizontal: 18,
-    paddingBottom: 35,
-  },
-  titleSection: {
-    paddingTop: isWeb ? 0 : 4,
-    paddingBottom: isWeb ? 14 : 10,
-  },
-  title: {
-    textAlign: 'center',
-    fontWeight: '600',
-    marginBottom: 5,
-  },
-  error: {
-    borderRadius: 6,
-  },
-  textInput: {
-    width: '100%',
-    paddingHorizontal: 14,
-    paddingVertical: 10,
-    fontSize: 16,
-  },
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderRadius: 32,
-    padding: 14,
-    backgroundColor: colors.blue3,
-  },
-  btnContainer: {
-    paddingTop: 20,
-  },
-  group: {
-    borderWidth: 1,
-    borderRadius: 10,
-    marginVertical: 20,
-  },
-  groupLabel: {
-    paddingHorizontal: 20,
-    paddingBottom: 5,
-  },
-  groupContent: {
-    flexDirection: 'row',
-    alignItems: 'center',
-  },
-  groupBottom: {
-    borderTopWidth: 1,
-  },
-  groupContentIcon: {
-    marginLeft: 10,
-  },
-})
diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx
index 5e188ee06..80ff15768 100644
--- a/src/view/com/modals/DeleteAccount.tsx
+++ b/src/view/com/modals/DeleteAccount.tsx
@@ -10,6 +10,7 @@ import {LinearGradient} from 'expo-linear-gradient'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {DM_SERVICE_HEADERS} from '#/lib/constants'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {cleanError} from '#/lib/strings/errors'
@@ -17,7 +18,6 @@ import {colors, gradients, s} from '#/lib/styles'
 import {useTheme} from '#/lib/ThemeContext'
 import {isAndroid, isWeb} from '#/platform/detection'
 import {useModalControls} from '#/state/modals'
-import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
 import {useAgent, useSession, useSessionApi} from '#/state/session'
 import {atoms as a, useTheme as useNewTheme} from '#/alf'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index f9afd183e..79971e660 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -7,12 +7,10 @@ import {usePalette} from '#/lib/hooks/usePalette'
 import {useModalControls, useModals} from '#/state/modals'
 import {FullWindowOverlay} from '#/components/FullWindowOverlay'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
-import * as ChangePasswordModal from './ChangePassword'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as InviteCodesModal from './InviteCodes'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
-import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as UserAddRemoveListsModal from './UserAddRemoveLists'
 
 const DEFAULT_SNAPPOINTS = ['90%']
@@ -61,12 +59,6 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'content-languages-settings') {
     snapPoints = ContentLanguagesSettingsModal.snapPoints
     element = <ContentLanguagesSettingsModal.Component />
-  } else if (activeModal?.name === 'post-languages-settings') {
-    snapPoints = PostLanguagesSettingsModal.snapPoints
-    element = <PostLanguagesSettingsModal.Component />
-  } else if (activeModal?.name === 'change-password') {
-    snapPoints = ChangePasswordModal.snapPoints
-    element = <ChangePasswordModal.Component />
   } else {
     return null
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 3eb744380..d0799a390 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -6,12 +6,10 @@ import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {type Modal as ModalIface} from '#/state/modals'
 import {useModalControls, useModals} from '#/state/modals'
-import * as ChangePasswordModal from './ChangePassword'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as InviteCodesModal from './InviteCodes'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
-import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as UserAddRemoveLists from './UserAddRemoveLists'
 
 export function ModalsContainer() {
@@ -60,10 +58,6 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <InviteCodesModal.Component />
   } else if (modal.name === 'content-languages-settings') {
     element = <ContentLanguagesSettingsModal.Component />
-  } else if (modal.name === 'post-languages-settings') {
-    element = <PostLanguagesSettingsModal.Component />
-  } else if (modal.name === 'change-password') {
-    element = <ChangePasswordModal.Component />
   } else {
     return null
   }
diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
deleted file mode 100644
index 8c2969674..000000000
--- a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {Trans} from '@lingui/macro'
-
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {deviceLanguageCodes} from '#/locale/deviceLocales'
-import {languageName} from '#/locale/helpers'
-import {useModalControls} from '#/state/modals'
-import {
-  hasPostLanguage,
-  useLanguagePrefs,
-  useLanguagePrefsApi,
-} from '#/state/preferences/languages'
-import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
-import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
-import {Text} from '../../util/text/Text'
-import {ScrollView} from '../util'
-import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
-
-export const snapPoints = ['100%']
-
-export function Component() {
-  const {closeModal} = useModalControls()
-  const langPrefs = useLanguagePrefs()
-  const setLangPrefs = useLanguagePrefsApi()
-  const pal = usePalette('default')
-  const {isMobile} = useWebMediaQueries()
-  const onPressDone = React.useCallback(() => {
-    closeModal()
-  }, [closeModal])
-
-  const languages = React.useMemo(() => {
-    const langs = LANGUAGES.filter(
-      lang =>
-        !!lang.code2.trim() &&
-        LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
-    )
-    // sort so that device & selected languages are on top, then alphabetically
-    langs.sort((a, b) => {
-      const hasA =
-        hasPostLanguage(langPrefs.postLanguage, a.code2) ||
-        deviceLanguageCodes.includes(a.code2)
-      const hasB =
-        hasPostLanguage(langPrefs.postLanguage, b.code2) ||
-        deviceLanguageCodes.includes(b.code2)
-      if (hasA === hasB) return a.name.localeCompare(b.name)
-      if (hasA) return -1
-      return 1
-    })
-    return langs
-  }, [langPrefs])
-
-  const onPress = React.useCallback(
-    (code2: string) => {
-      setLangPrefs.togglePostLanguage(code2)
-    },
-    [setLangPrefs],
-  )
-
-  return (
-    <View
-      testID="postLanguagesModal"
-      style={[
-        pal.view,
-        styles.container,
-        // @ts-ignore vh is on web only
-        isMobile
-          ? {
-              paddingTop: 20,
-            }
-          : {
-              maxHeight: '90vh',
-            },
-      ]}>
-      <Text style={[pal.text, styles.title]}>
-        <Trans>Post Languages</Trans>
-      </Text>
-      <Text style={[pal.text, styles.description]}>
-        <Trans>Which languages are used in this post?</Trans>
-      </Text>
-      <ScrollView style={styles.scrollContainer}>
-        {languages.map(lang => {
-          const isSelected = hasPostLanguage(langPrefs.postLanguage, lang.code2)
-
-          // enforce a max of 3 selections for post languages
-          let isDisabled = false
-          if (langPrefs.postLanguage.split(',').length >= 3 && !isSelected) {
-            isDisabled = true
-          }
-
-          return (
-            <ToggleButton
-              key={lang.code2}
-              label={languageName(lang, langPrefs.appLanguage)}
-              isSelected={isSelected}
-              onPress={() => (isDisabled ? undefined : onPress(lang.code2))}
-              style={[
-                pal.border,
-                styles.languageToggle,
-                isDisabled && styles.dimmed,
-              ]}
-            />
-          )
-        })}
-        <View
-          style={{
-            height: isMobile ? 60 : 0,
-          }}
-        />
-      </ScrollView>
-      <ConfirmLanguagesButton onPress={onPressDone} />
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-  title: {
-    textAlign: 'center',
-    fontWeight: '600',
-    fontSize: 24,
-    marginBottom: 12,
-  },
-  description: {
-    textAlign: 'center',
-    paddingHorizontal: 16,
-    marginBottom: 10,
-  },
-  scrollContainer: {
-    flex: 1,
-    paddingHorizontal: 10,
-  },
-  languageToggle: {
-    borderTopWidth: 1,
-    borderRadius: 0,
-    paddingHorizontal: 6,
-    paddingVertical: 12,
-  },
-  dimmed: {
-    opacity: 0.5,
-  },
-})
diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx
index dc048bd26..ce774e888 100644
--- a/src/view/com/notifications/NotificationFeedItem.tsx
+++ b/src/view/com/notifications/NotificationFeedItem.tsx
@@ -31,6 +31,7 @@ import {useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {MAX_POST_LINES} from '#/lib/constants'
+import {DM_SERVICE_HEADERS} from '#/lib/constants'
 import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {makeProfileLink} from '#/lib/routes/links'
@@ -41,7 +42,6 @@ import {sanitizeHandle} from '#/lib/strings/handles'
 import {niceDate} from '#/lib/strings/time'
 import {s} from '#/lib/styles'
 import {logger} from '#/logger'
-import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
 import {type FeedNotification} from '#/state/queries/notifications/feed'
 import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
 import {useAgent} from '#/state/session'
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
deleted file mode 100644
index bbf9f4a20..000000000
--- a/src/view/com/post-thread/PostThread.tsx
+++ /dev/null
@@ -1,910 +0,0 @@
-import React, {memo, useRef, useState} from 'react'
-import {useWindowDimensions, View} from 'react-native'
-import {runOnJS, useAnimatedStyle} from 'react-native-reanimated'
-import Animated from 'react-native-reanimated'
-import {
-  AppBskyFeedDefs,
-  type AppBskyFeedThreadgate,
-  moderatePost,
-} from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {HITSLOP_10} from '#/lib/constants'
-import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
-import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
-import {useSetTitle} from '#/lib/hooks/useSetTitle'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {ScrollProvider} from '#/lib/ScrollContext'
-import {sanitizeDisplayName} from '#/lib/strings/display-names'
-import {cleanError} from '#/lib/strings/errors'
-import {isAndroid, isNative, isWeb} from '#/platform/detection'
-import {useFeedFeedback} from '#/state/feed-feedback'
-import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {
-  fillThreadModerationCache,
-  sortThread,
-  type ThreadBlocked,
-  type ThreadModerationCache,
-  type ThreadNode,
-  type ThreadNotFound,
-  type ThreadPost,
-  usePostThreadQuery,
-} from '#/state/queries/post-thread'
-import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
-import {usePreferencesQuery} from '#/state/queries/preferences'
-import {useSession} from '#/state/session'
-import {useShellLayout} from '#/state/shell/shell-layout'
-import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
-import {useUnstablePostSource} from '#/state/unstable-post-source'
-import {List, type ListMethods} from '#/view/com/util/List'
-import {atoms as a, useTheme} from '#/alf'
-import {Button, ButtonIcon} from '#/components/Button'
-import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
-import {Header} from '#/components/Layout'
-import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
-import * as Menu from '#/components/Menu'
-import {Text} from '#/components/Typography'
-import {PostThreadComposePrompt} from './PostThreadComposePrompt'
-import {PostThreadItem} from './PostThreadItem'
-import {PostThreadLoadMore} from './PostThreadLoadMore'
-import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
-
-// FlatList maintainVisibleContentPosition breaks if too many items
-// are prepended. This seems to be an optimal number based on *shrug*.
-const PARENTS_CHUNK_SIZE = 15
-
-const MAINTAIN_VISIBLE_CONTENT_POSITION = {
-  // We don't insert any elements before the root row while loading.
-  // So the row we want to use as the scroll anchor is the first row.
-  minIndexForVisible: 0,
-}
-
-const REPLY_PROMPT = {_reactKey: '__reply__'}
-const LOAD_MORE = {_reactKey: '__load_more__'}
-const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'}
-const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'}
-
-enum HiddenRepliesState {
-  Hide,
-  Show,
-  ShowAndOverridePostHider,
-}
-
-type YieldedItem =
-  | ThreadPost
-  | ThreadBlocked
-  | ThreadNotFound
-  | typeof SHOW_HIDDEN_REPLIES
-  | typeof SHOW_MUTED_REPLIES
-type RowItem =
-  | YieldedItem
-  // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
-  | typeof REPLY_PROMPT
-  | typeof LOAD_MORE
-
-type ThreadSkeletonParts = {
-  parents: YieldedItem[]
-  highlightedPost: ThreadNode
-  replies: YieldedItem[]
-}
-
-const keyExtractor = (item: RowItem) => {
-  return item._reactKey
-}
-
-export function PostThread({uri}: {uri: string}) {
-  const {hasSession, currentAccount} = useSession()
-  const {_} = useLingui()
-  const t = useTheme()
-  const {isMobile} = useWebMediaQueries()
-  const initialNumToRender = useInitialNumToRender()
-  const {height: windowHeight} = useWindowDimensions()
-  const [hiddenRepliesState, setHiddenRepliesState] = React.useState(
-    HiddenRepliesState.Hide,
-  )
-  const headerRef = React.useRef<View | null>(null)
-  const anchorPostSource = useUnstablePostSource(uri)
-  const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
-
-  const {data: preferences} = usePreferencesQuery()
-  const {
-    isFetching,
-    isError: isThreadError,
-    error: threadError,
-    refetch,
-    data: {thread, threadgate} = {},
-    dataUpdatedAt: fetchedAt,
-  } = usePostThreadQuery(uri)
-
-  // The original source of truth for these are the server settings.
-  const serverPrefs = preferences?.threadViewPrefs
-  const serverPrioritizeFollowedUsers =
-    serverPrefs?.prioritizeFollowedUsers ?? true
-  const serverTreeViewEnabled = serverPrefs?.lab_treeViewEnabled ?? false
-  const serverSortReplies = serverPrefs?.sort ?? 'hotness'
-
-  // However, we also need these to work locally for PWI (without persistence).
-  // So we're mirroring them locally.
-  const prioritizeFollowedUsers = serverPrioritizeFollowedUsers
-  const [treeViewEnabled, setTreeViewEnabled] = useState(serverTreeViewEnabled)
-  const [sortReplies, setSortReplies] = useState(serverSortReplies)
-
-  // We'll reset the local state if new server state flows down to us.
-  const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs)
-  if (prevServerPrefs !== serverPrefs) {
-    setPrevServerPrefs(serverPrefs)
-    setTreeViewEnabled(serverTreeViewEnabled)
-    setSortReplies(serverSortReplies)
-  }
-
-  // And we'll update the local state when mutating the server prefs.
-  const {mutate: mutateThreadViewPrefs} = useSetThreadViewPreferencesMutation()
-  function updateTreeViewEnabled(newTreeViewEnabled: boolean) {
-    setTreeViewEnabled(newTreeViewEnabled)
-    if (hasSession) {
-      mutateThreadViewPrefs({lab_treeViewEnabled: newTreeViewEnabled})
-    }
-  }
-  function updateSortReplies(newSortReplies: string) {
-    setSortReplies(newSortReplies)
-    if (hasSession) {
-      mutateThreadViewPrefs({sort: newSortReplies})
-    }
-  }
-
-  const treeView = React.useMemo(
-    () => treeViewEnabled && hasBranchingReplies(thread),
-    [treeViewEnabled, thread],
-  )
-
-  const rootPost = thread?.type === 'post' ? thread.post : undefined
-  const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
-  const threadgateRecord = threadgate?.record as
-    | AppBskyFeedThreadgate.Record
-    | undefined
-  const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
-    threadgateRecord,
-  })
-
-  const moderationOpts = useModerationOpts()
-  const isNoPwi = React.useMemo(() => {
-    const mod =
-      rootPost && moderationOpts
-        ? moderatePost(rootPost, moderationOpts)
-        : undefined
-    return !!mod
-      ?.ui('contentList')
-      .blurs.find(
-        cause =>
-          cause.type === 'label' &&
-          cause.labelDef.identifier === '!no-unauthenticated',
-      )
-  }, [rootPost, moderationOpts])
-
-  // Values used for proper rendering of parents
-  const ref = useRef<ListMethods>(null)
-  const highlightedPostRef = useRef<View | null>(null)
-  const [maxParents, setMaxParents] = React.useState(
-    isWeb ? Infinity : PARENTS_CHUNK_SIZE,
-  )
-  const [maxReplies, setMaxReplies] = React.useState(50)
-
-  useSetTitle(
-    rootPost && !isNoPwi
-      ? `${sanitizeDisplayName(
-          rootPost.author.displayName || `@${rootPost.author.handle}`,
-        )}: "${rootPostRecord!.text}"`
-      : '',
-  )
-
-  // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
-  // This ensures that the first render contains no parents--even if they are already available in the cache.
-  // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
-  // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
-  const [deferParents, setDeferParents] = React.useState(isNative)
-
-  const currentDid = currentAccount?.did
-  const threadModerationCache = React.useMemo(() => {
-    const cache: ThreadModerationCache = new WeakMap()
-    if (thread && moderationOpts) {
-      fillThreadModerationCache(cache, thread, moderationOpts)
-    }
-    return cache
-  }, [thread, moderationOpts])
-
-  const [justPostedUris, setJustPostedUris] = React.useState(
-    () => new Set<string>(),
-  )
-
-  const [fetchedAtCache] = React.useState(() => new Map<string, number>())
-  const [randomCache] = React.useState(() => new Map<string, number>())
-  const skeleton = React.useMemo(() => {
-    if (!thread) return null
-    return createThreadSkeleton(
-      sortThread(
-        thread,
-        {
-          // Prefer local state as the source of truth.
-          sort: sortReplies,
-          lab_treeViewEnabled: treeViewEnabled,
-          prioritizeFollowedUsers,
-        },
-        threadModerationCache,
-        currentDid,
-        justPostedUris,
-        threadgateHiddenReplies,
-        fetchedAtCache,
-        fetchedAt,
-        randomCache,
-      ),
-      currentDid,
-      treeView,
-      threadModerationCache,
-      hiddenRepliesState !== HiddenRepliesState.Hide,
-      threadgateHiddenReplies,
-    )
-  }, [
-    thread,
-    prioritizeFollowedUsers,
-    sortReplies,
-    treeViewEnabled,
-    currentDid,
-    treeView,
-    threadModerationCache,
-    hiddenRepliesState,
-    justPostedUris,
-    threadgateHiddenReplies,
-    fetchedAtCache,
-    fetchedAt,
-    randomCache,
-  ])
-
-  const error = React.useMemo(() => {
-    if (AppBskyFeedDefs.isNotFoundPost(thread)) {
-      return {
-        title: _(msg`Post not found`),
-        message: _(msg`The post may have been deleted.`),
-      }
-    } else if (skeleton?.highlightedPost.type === 'blocked') {
-      return {
-        title: _(msg`Post hidden`),
-        message: _(
-          msg`You have blocked the author or you have been blocked by the author.`,
-        ),
-      }
-    } else if (threadError?.message.startsWith('Post not found')) {
-      return {
-        title: _(msg`Post not found`),
-        message: _(msg`The post may have been deleted.`),
-      }
-    } else if (isThreadError) {
-      return {
-        message: threadError ? cleanError(threadError) : undefined,
-      }
-    }
-
-    return null
-  }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
-
-  // construct content
-  const posts = React.useMemo(() => {
-    if (!skeleton) return []
-
-    const {parents, highlightedPost, replies} = skeleton
-    let arr: RowItem[] = []
-    if (highlightedPost.type === 'post') {
-      // We want to wait for parents to load before rendering.
-      // If you add something here, you'll need to update both
-      // maintainVisibleContentPosition and onContentSizeChange
-      // to "hold onto" the correct row instead of the first one.
-
-      /*
-       * This is basically `!!parents.length`, see notes on `isParentLoading`
-       */
-      if (!highlightedPost.ctx.isParentLoading && !deferParents) {
-        // When progressively revealing parents, rendering a placeholder
-        // here will cause scrolling jumps. Don't add it unless you test it.
-        // QT'ing this thread is a great way to test all the scrolling hacks:
-        // https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o
-
-        // Everything is loaded
-        let startIndex = Math.max(0, parents.length - maxParents)
-        for (let i = startIndex; i < parents.length; i++) {
-          arr.push(parents[i])
-        }
-      }
-      arr.push(highlightedPost)
-      if (!highlightedPost.post.viewer?.replyDisabled) {
-        arr.push(REPLY_PROMPT)
-      }
-      for (let i = 0; i < replies.length; i++) {
-        arr.push(replies[i])
-        if (i === maxReplies) {
-          break
-        }
-      }
-    }
-    return arr
-  }, [skeleton, deferParents, maxParents, maxReplies])
-
-  // This is only used on the web to keep the post in view when its parents load.
-  // On native, we rely on `maintainVisibleContentPosition` instead.
-  const didAdjustScrollWeb = useRef<boolean>(false)
-  const onContentSizeChangeWeb = React.useCallback(() => {
-    // only run once
-    if (didAdjustScrollWeb.current) {
-      return
-    }
-    // wait for loading to finish
-    if (thread?.type === 'post' && !!thread.parent) {
-      // Measure synchronously to avoid a layout jump.
-      const postNode = highlightedPostRef.current
-      const headerNode = headerRef.current
-      if (postNode && headerNode) {
-        let pageY = (postNode as any as Element).getBoundingClientRect().top
-        pageY -= (headerNode as any as Element).getBoundingClientRect().height
-        pageY = Math.max(0, pageY)
-        ref.current?.scrollToOffset({
-          animated: false,
-          offset: pageY,
-        })
-      }
-      didAdjustScrollWeb.current = true
-    }
-  }, [thread])
-
-  // On native, we reveal parents in chunks. Although they're all already
-  // loaded and FlatList already has its own virtualization, unfortunately FlatList
-  // has a bug that causes the content to jump around if too many items are getting
-  // prepended at once. It also jumps around if items get prepended during scroll.
-  // To work around this, we prepend rows after scroll bumps against the top and rests.
-  const needsBumpMaxParents = React.useRef(false)
-  const onStartReached = React.useCallback(() => {
-    if (skeleton?.parents && maxParents < skeleton.parents.length) {
-      needsBumpMaxParents.current = true
-    }
-  }, [maxParents, skeleton?.parents])
-  const bumpMaxParentsIfNeeded = React.useCallback(() => {
-    if (!isNative) {
-      return
-    }
-    if (needsBumpMaxParents.current) {
-      needsBumpMaxParents.current = false
-      setMaxParents(n => n + PARENTS_CHUNK_SIZE)
-    }
-  }, [])
-  const onScrollToTop = bumpMaxParentsIfNeeded
-  const onMomentumEnd = React.useCallback(() => {
-    'worklet'
-    runOnJS(bumpMaxParentsIfNeeded)()
-  }, [bumpMaxParentsIfNeeded])
-
-  const onEndReached = React.useCallback(() => {
-    if (isFetching || posts.length < maxReplies) return
-    setMaxReplies(prev => prev + 50)
-  }, [isFetching, maxReplies, posts.length])
-
-  const onPostReply = React.useCallback(
-    (postUri: string | undefined) => {
-      refetch()
-      if (postUri) {
-        setJustPostedUris(set => {
-          const nextSet = new Set(set)
-          nextSet.add(postUri)
-          return nextSet
-        })
-      }
-    },
-    [refetch],
-  )
-
-  const {openComposer} = useOpenComposer()
-  const onReplyToAnchor = React.useCallback(() => {
-    if (thread?.type !== 'post') {
-      return
-    }
-    if (anchorPostSource) {
-      feedFeedback.sendInteraction({
-        item: thread.post.uri,
-        event: 'app.bsky.feed.defs#interactionReply',
-        feedContext: anchorPostSource.post.feedContext,
-        reqId: anchorPostSource.post.reqId,
-      })
-    }
-    openComposer({
-      replyTo: {
-        uri: thread.post.uri,
-        cid: thread.post.cid,
-        text: thread.record.text,
-        author: thread.post.author,
-        embed: thread.post.embed,
-        moderation: threadModerationCache.get(thread),
-        langs: thread.record.langs,
-      },
-      onPost: onPostReply,
-    })
-  }, [
-    openComposer,
-    thread,
-    onPostReply,
-    threadModerationCache,
-    anchorPostSource,
-    feedFeedback,
-  ])
-
-  const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
-  const hasParents =
-    skeleton?.highlightedPost?.type === 'post' &&
-    (skeleton.highlightedPost.ctx.isParentLoading ||
-      Boolean(skeleton?.parents && skeleton.parents.length > 0))
-
-  const renderItem = ({item, index}: {item: RowItem; index: number}) => {
-    if (item === REPLY_PROMPT && hasSession) {
-      return (
-        <View>
-          {!isMobile && (
-            <PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
-          )}
-        </View>
-      )
-    } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
-      return (
-        <PostThreadShowHiddenReplies
-          type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
-          onPress={() =>
-            setHiddenRepliesState(
-              item === SHOW_HIDDEN_REPLIES
-                ? HiddenRepliesState.Show
-                : HiddenRepliesState.ShowAndOverridePostHider,
-            )
-          }
-          hideTopBorder={index === 0}
-        />
-      )
-    } else if (isThreadNotFound(item)) {
-      return (
-        <View
-          style={[
-            a.p_lg,
-            index !== 0 && a.border_t,
-            t.atoms.border_contrast_low,
-            t.atoms.bg_contrast_25,
-          ]}>
-          <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
-            <Trans>Deleted post.</Trans>
-          </Text>
-        </View>
-      )
-    } else if (isThreadBlocked(item)) {
-      return (
-        <View
-          style={[
-            a.p_lg,
-            index !== 0 && a.border_t,
-            t.atoms.border_contrast_low,
-            t.atoms.bg_contrast_25,
-          ]}>
-          <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
-            <Trans>Blocked post.</Trans>
-          </Text>
-        </View>
-      )
-    } else if (isThreadPost(item)) {
-      const prev = isThreadPost(posts[index - 1])
-        ? (posts[index - 1] as ThreadPost)
-        : undefined
-      const next = isThreadPost(posts[index + 1])
-        ? (posts[index + 1] as ThreadPost)
-        : undefined
-      const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
-      const showParentReplyLine =
-        (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
-      const hasUnrevealedParents =
-        index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
-
-      if (!treeView && prev && item.ctx.hasMoreSelfThread) {
-        return <PostThreadLoadMore post={prev.post} />
-      }
-
-      return (
-        <View
-          ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
-          onLayout={deferParents ? () => setDeferParents(false) : undefined}>
-          <PostThreadItem
-            post={item.post}
-            record={item.record}
-            threadgateRecord={threadgateRecord ?? undefined}
-            moderation={threadModerationCache.get(item)}
-            treeView={treeView}
-            depth={item.ctx.depth}
-            prevPost={prev}
-            nextPost={next}
-            isHighlightedPost={item.ctx.isHighlightedPost}
-            hasMore={item.ctx.hasMore}
-            showChildReplyLine={showChildReplyLine}
-            showParentReplyLine={showParentReplyLine}
-            hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
-            overrideBlur={
-              hiddenRepliesState ===
-                HiddenRepliesState.ShowAndOverridePostHider &&
-              item.ctx.depth > 0
-            }
-            onPostReply={onPostReply}
-            hideTopBorder={index === 0 && !item.ctx.isParentLoading}
-            anchorPostSource={anchorPostSource}
-          />
-        </View>
-      )
-    }
-    return null
-  }
-
-  if (!thread || !preferences || error) {
-    return (
-      <ListMaybePlaceholder
-        isLoading={!error}
-        isError={Boolean(error)}
-        noEmpty
-        onRetry={refetch}
-        errorTitle={error?.title}
-        errorMessage={error?.message}
-      />
-    )
-  }
-
-  return (
-    <>
-      <Header.Outer headerRef={headerRef}>
-        <Header.BackButton />
-        <Header.Content>
-          <Header.TitleText>
-            <Trans context="description">Post</Trans>
-          </Header.TitleText>
-        </Header.Content>
-        <Header.Slot>
-          <ThreadMenu
-            sortReplies={sortReplies}
-            treeViewEnabled={treeViewEnabled}
-            setSortReplies={updateSortReplies}
-            setTreeViewEnabled={updateTreeViewEnabled}
-          />
-        </Header.Slot>
-      </Header.Outer>
-
-      <ScrollProvider onMomentumEnd={onMomentumEnd}>
-        <List
-          ref={ref}
-          data={posts}
-          renderItem={renderItem}
-          keyExtractor={keyExtractor}
-          onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
-          onStartReached={onStartReached}
-          onEndReached={onEndReached}
-          onEndReachedThreshold={2}
-          onScrollToTop={onScrollToTop}
-          /**
-           * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
-           */
-          maintainVisibleContentPosition={
-            isNative && hasParents
-              ? MAINTAIN_VISIBLE_CONTENT_POSITION
-              : undefined
-          }
-          desktopFixedHeight
-          removeClippedSubviews={isAndroid ? false : undefined}
-          ListFooterComponent={
-            <ListFooter
-              // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
-              // initial render
-              isFetchingNextPage={isFetching}
-              error={cleanError(threadError)}
-              onRetry={refetch}
-              // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
-              // work without causing weird jumps on web or glitches on native
-              height={windowHeight - 200}
-            />
-          }
-          initialNumToRender={initialNumToRender}
-          windowSize={11}
-          sideBorders={false}
-        />
-      </ScrollProvider>
-      {isMobile && canReply && hasSession && (
-        <MobileComposePrompt onPressReply={onReplyToAnchor} />
-      )}
-    </>
-  )
-}
-
-let ThreadMenu = ({
-  sortReplies,
-  treeViewEnabled,
-  setSortReplies,
-  setTreeViewEnabled,
-}: {
-  sortReplies: string
-  treeViewEnabled: boolean
-  setSortReplies: (newValue: string) => void
-  setTreeViewEnabled: (newValue: boolean) => void
-}): React.ReactNode => {
-  const {_} = useLingui()
-  return (
-    <Menu.Root>
-      <Menu.Trigger label={_(msg`Thread options`)}>
-        {({props}) => (
-          <Button
-            label={_(msg`Thread options`)}
-            size="small"
-            variant="ghost"
-            color="secondary"
-            shape="round"
-            hitSlop={HITSLOP_10}
-            {...props}>
-            <ButtonIcon icon={SettingsSlider} size="md" />
-          </Button>
-        )}
-      </Menu.Trigger>
-      <Menu.Outer>
-        <Menu.LabelText>
-          <Trans>Show replies as</Trans>
-        </Menu.LabelText>
-        <Menu.Group>
-          <Menu.Item
-            label={_(msg`Linear`)}
-            onPress={() => {
-              setTreeViewEnabled(false)
-            }}>
-            <Menu.ItemText>
-              <Trans>Linear</Trans>
-            </Menu.ItemText>
-            <Menu.ItemRadio selected={!treeViewEnabled} />
-          </Menu.Item>
-          <Menu.Item
-            label={_(msg`Threaded`)}
-            onPress={() => {
-              setTreeViewEnabled(true)
-            }}>
-            <Menu.ItemText>
-              <Trans>Threaded</Trans>
-            </Menu.ItemText>
-            <Menu.ItemRadio selected={treeViewEnabled} />
-          </Menu.Item>
-        </Menu.Group>
-        <Menu.Divider />
-        <Menu.LabelText>
-          <Trans>Reply sorting</Trans>
-        </Menu.LabelText>
-        <Menu.Group>
-          <Menu.Item
-            label={_(msg`Hot replies first`)}
-            onPress={() => {
-              setSortReplies('hotness')
-            }}>
-            <Menu.ItemText>
-              <Trans>Hot replies first</Trans>
-            </Menu.ItemText>
-            <Menu.ItemRadio selected={sortReplies === 'hotness'} />
-          </Menu.Item>
-          <Menu.Item
-            label={_(msg`Oldest replies first`)}
-            onPress={() => {
-              setSortReplies('oldest')
-            }}>
-            <Menu.ItemText>
-              <Trans>Oldest replies first</Trans>
-            </Menu.ItemText>
-            <Menu.ItemRadio selected={sortReplies === 'oldest'} />
-          </Menu.Item>
-          <Menu.Item
-            label={_(msg`Newest replies first`)}
-            onPress={() => {
-              setSortReplies('newest')
-            }}>
-            <Menu.ItemText>
-              <Trans>Newest replies first</Trans>
-            </Menu.ItemText>
-            <Menu.ItemRadio selected={sortReplies === 'newest'} />
-          </Menu.Item>
-          <Menu.Item
-            label={_(msg`Most-liked replies first`)}
-            onPress={() => {
-              setSortReplies('most-likes')
-            }}>
-            <Menu.ItemText>
-              <Trans>Most-liked replies first</Trans>
-            </Menu.ItemText>
-            <Menu.ItemRadio selected={sortReplies === 'most-likes'} />
-          </Menu.Item>
-          <Menu.Item
-            label={_(msg`Random (aka "Poster's Roulette")`)}
-            onPress={() => {
-              setSortReplies('random')
-            }}>
-            <Menu.ItemText>
-              <Trans>Random (aka "Poster's Roulette")</Trans>
-            </Menu.ItemText>
-            <Menu.ItemRadio selected={sortReplies === 'random'} />
-          </Menu.Item>
-        </Menu.Group>
-      </Menu.Outer>
-    </Menu.Root>
-  )
-}
-ThreadMenu = memo(ThreadMenu)
-
-function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
-  const {footerHeight} = useShellLayout()
-
-  const animatedStyle = useAnimatedStyle(() => {
-    return {
-      bottom: footerHeight.get(),
-    }
-  })
-
-  return (
-    <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
-      <PostThreadComposePrompt onPressCompose={onPressReply} />
-    </Animated.View>
-  )
-}
-
-function isThreadPost(v: unknown): v is ThreadPost {
-  return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
-}
-
-function isThreadNotFound(v: unknown): v is ThreadNotFound {
-  return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
-}
-
-function isThreadBlocked(v: unknown): v is ThreadBlocked {
-  return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
-}
-
-function createThreadSkeleton(
-  node: ThreadNode,
-  currentDid: string | undefined,
-  treeView: boolean,
-  modCache: ThreadModerationCache,
-  showHiddenReplies: boolean,
-  threadgateRecordHiddenReplies: Set<string>,
-): ThreadSkeletonParts | null {
-  if (!node) return null
-
-  return {
-    parents: Array.from(flattenThreadParents(node, !!currentDid)),
-    highlightedPost: node,
-    replies: Array.from(
-      flattenThreadReplies(
-        node,
-        currentDid,
-        treeView,
-        modCache,
-        showHiddenReplies,
-        threadgateRecordHiddenReplies,
-      ),
-    ),
-  }
-}
-
-function* flattenThreadParents(
-  node: ThreadNode,
-  hasSession: boolean,
-): Generator<YieldedItem, void> {
-  if (node.type === 'post') {
-    if (node.parent) {
-      yield* flattenThreadParents(node.parent, hasSession)
-    }
-    if (!node.ctx.isHighlightedPost) {
-      yield node
-    }
-  } else if (node.type === 'not-found') {
-    yield node
-  } else if (node.type === 'blocked') {
-    yield node
-  }
-}
-
-// The enum is ordered to make them easy to merge
-enum HiddenReplyType {
-  None = 0,
-  Muted = 1,
-  Hidden = 2,
-}
-
-function* flattenThreadReplies(
-  node: ThreadNode,
-  currentDid: string | undefined,
-  treeView: boolean,
-  modCache: ThreadModerationCache,
-  showHiddenReplies: boolean,
-  threadgateRecordHiddenReplies: Set<string>,
-): Generator<YieldedItem, HiddenReplyType> {
-  if (node.type === 'post') {
-    // dont show pwi-opted-out posts to logged out users
-    if (!currentDid && hasPwiOptOut(node)) {
-      return HiddenReplyType.None
-    }
-
-    // handle blurred items
-    if (node.ctx.depth > 0) {
-      const modui = modCache.get(node)?.ui('contentList')
-      if (modui?.blur || modui?.filter) {
-        if (!showHiddenReplies || node.ctx.depth > 1) {
-          if ((modui.blurs[0] || modui.filters[0]).type === 'muted') {
-            return HiddenReplyType.Muted
-          }
-          return HiddenReplyType.Hidden
-        }
-      }
-
-      if (!showHiddenReplies) {
-        const hiddenByThreadgate = threadgateRecordHiddenReplies.has(
-          node.post.uri,
-        )
-        const authorIsViewer = node.post.author.did === currentDid
-        if (hiddenByThreadgate && !authorIsViewer) {
-          return HiddenReplyType.Hidden
-        }
-      }
-    }
-
-    if (!node.ctx.isHighlightedPost) {
-      yield node
-    }
-
-    if (node.replies?.length) {
-      let hiddenReplies = HiddenReplyType.None
-      for (const reply of node.replies) {
-        let hiddenReply = yield* flattenThreadReplies(
-          reply,
-          currentDid,
-          treeView,
-          modCache,
-          showHiddenReplies,
-          threadgateRecordHiddenReplies,
-        )
-        if (hiddenReply > hiddenReplies) {
-          hiddenReplies = hiddenReply
-        }
-        if (!treeView && !node.ctx.isHighlightedPost) {
-          break
-        }
-      }
-
-      // show control to enable hidden replies
-      if (node.ctx.depth === 0) {
-        if (hiddenReplies === HiddenReplyType.Muted) {
-          yield SHOW_MUTED_REPLIES
-        } else if (hiddenReplies === HiddenReplyType.Hidden) {
-          yield SHOW_HIDDEN_REPLIES
-        }
-      }
-    }
-  } else if (node.type === 'not-found') {
-    yield node
-  } else if (node.type === 'blocked') {
-    yield node
-  }
-  return HiddenReplyType.None
-}
-
-function hasPwiOptOut(node: ThreadPost) {
-  return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
-}
-
-function hasBranchingReplies(node?: ThreadNode) {
-  if (!node) {
-    return false
-  }
-  if (node.type !== 'post') {
-    return false
-  }
-  if (!node.replies) {
-    return false
-  }
-  if (node.replies.length === 1) {
-    return hasBranchingReplies(node.replies[0])
-  }
-  return true
-}
diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx
deleted file mode 100644
index dc0561725..000000000
--- a/src/view/com/post-thread/PostThreadComposePrompt.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import {type StyleProp, View, type ViewStyle} from 'react-native'
-import {LinearGradient} from 'expo-linear-gradient'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {PressableScale} from '#/lib/custom-animations/PressableScale'
-import {useHaptics} from '#/lib/haptics'
-import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
-import {useProfileQuery} from '#/state/queries/profile'
-import {useSession} from '#/state/session'
-import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
-import {transparentifyColor} from '#/alf/util/colorGeneration'
-import {useInteractionState} from '#/components/hooks/useInteractionState'
-import {Text} from '#/components/Typography'
-
-export function PostThreadComposePrompt({
-  onPressCompose,
-  style,
-}: {
-  onPressCompose: () => void
-  style?: StyleProp<ViewStyle>
-}) {
-  const {currentAccount} = useSession()
-  const {data: profile} = useProfileQuery({did: currentAccount?.did})
-  const {_} = useLingui()
-  const {gtMobile} = useBreakpoints()
-  const t = useTheme()
-  const playHaptic = useHaptics()
-  const {
-    state: hovered,
-    onIn: onHoverIn,
-    onOut: onHoverOut,
-  } = useInteractionState()
-
-  useHideBottomBarBorderForScreen()
-
-  return (
-    <View
-      style={[
-        a.px_sm,
-        gtMobile
-          ? [a.py_xs, a.border_t, t.atoms.border_contrast_low, t.atoms.bg]
-          : [a.pb_2xs],
-        style,
-      ]}>
-      {!gtMobile && (
-        <LinearGradient
-          key={t.name} // android does not update when you change the colors. sigh.
-          start={[0.5, 0]}
-          end={[0.5, 1]}
-          colors={[
-            transparentifyColor(t.atoms.bg.backgroundColor, 0),
-            t.atoms.bg.backgroundColor,
-          ]}
-          locations={[0.15, 0.4]}
-          style={[a.absolute, a.inset_0]}
-        />
-      )}
-      <PressableScale
-        accessibilityRole="button"
-        accessibilityLabel={_(msg`Compose reply`)}
-        accessibilityHint={_(msg`Opens composer`)}
-        onPress={() => {
-          onPressCompose()
-          playHaptic('Light')
-        }}
-        onLongPress={ios(() => {
-          onPressCompose()
-          playHaptic('Heavy')
-        })}
-        onHoverIn={onHoverIn}
-        onHoverOut={onHoverOut}
-        style={[
-          a.flex_row,
-          a.align_center,
-          a.p_sm,
-          a.gap_sm,
-          a.rounded_full,
-          (!gtMobile || hovered) && t.atoms.bg_contrast_25,
-          native([a.border, t.atoms.border_contrast_low]),
-          a.transition_color,
-        ]}>
-        <UserAvatar
-          size={24}
-          avatar={profile?.avatar}
-          type={profile?.associated?.labeler ? 'labeler' : 'user'}
-        />
-        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
-          <Trans>Write your reply</Trans>
-        </Text>
-      </PressableScale>
-    </View>
-  )
-}
diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx
deleted file mode 100644
index fc9296cad..000000000
--- a/src/view/com/post-thread/PostThreadFollowBtn.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React from 'react'
-import {type AppBskyActorDefs} from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/native'
-
-import {logger} from '#/logger'
-import {useProfileShadow} from '#/state/cache/profile-shadow'
-import {
-  useProfileFollowMutationQueue,
-  useProfileQuery,
-} from '#/state/queries/profile'
-import {useRequireAuth} from '#/state/session'
-import * as Toast from '#/view/com/util/Toast'
-import {atoms as a, useBreakpoints} from '#/alf'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
-
-export function PostThreadFollowBtn({did}: {did: string}) {
-  const {data: profile, isLoading} = useProfileQuery({did})
-
-  // We will never hit this - the profile will always be cached or loaded above
-  // but it keeps the typechecker happy
-  if (isLoading || !profile) return null
-
-  return <PostThreadFollowBtnLoaded profile={profile} />
-}
-
-function PostThreadFollowBtnLoaded({
-  profile: profileUnshadowed,
-}: {
-  profile: AppBskyActorDefs.ProfileViewDetailed
-}) {
-  const navigation = useNavigation()
-  const {_} = useLingui()
-  const {gtMobile} = useBreakpoints()
-  const profile = useProfileShadow(profileUnshadowed)
-  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
-    profile,
-    'PostThreadItem',
-  )
-  const requireAuth = useRequireAuth()
-
-  const isFollowing = !!profile.viewer?.following
-  const isFollowedBy = !!profile.viewer?.followedBy
-  const [wasFollowing, setWasFollowing] = React.useState<boolean>(isFollowing)
-
-  // This prevents the button from disappearing as soon as we follow.
-  const showFollowBtn = React.useMemo(
-    () => !isFollowing || !wasFollowing,
-    [isFollowing, wasFollowing],
-  )
-
-  /**
-   * We want this button to stay visible even after following, so that the user can unfollow if they want.
-   * However, we need it to disappear after we push to a screen and then come back. We also need it to
-   * show up if we view the post while following, go to the profile and unfollow, then come back to the
-   * post.
-   *
-   * We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native,
-   * we could do this only on focus because the transition animation gives us time to not notice the
-   * sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the
-   * button renders. So, we update the state in both cases.
-   */
-  React.useEffect(() => {
-    const updateWasFollowing = () => {
-      if (wasFollowing !== isFollowing) {
-        setWasFollowing(isFollowing)
-      }
-    }
-
-    const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing)
-    const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing)
-
-    return () => {
-      unsubscribeFocus()
-      unsubscribeBlur()
-    }
-  }, [isFollowing, wasFollowing, navigation])
-
-  const onPress = React.useCallback(() => {
-    if (!isFollowing) {
-      requireAuth(async () => {
-        try {
-          await queueFollow()
-        } catch (e: any) {
-          if (e?.name !== 'AbortError') {
-            logger.error('Failed to follow', {message: String(e)})
-            Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
-          }
-        }
-      })
-    } else {
-      requireAuth(async () => {
-        try {
-          await queueUnfollow()
-        } catch (e: any) {
-          if (e?.name !== 'AbortError') {
-            logger.error('Failed to unfollow', {message: String(e)})
-            Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
-          }
-        }
-      })
-    }
-  }, [isFollowing, requireAuth, queueFollow, _, queueUnfollow])
-
-  if (!showFollowBtn) return null
-
-  return (
-    <Button
-      testID="followBtn"
-      label={_(msg`Follow ${profile.handle}`)}
-      onPress={onPress}
-      size="small"
-      variant="solid"
-      color={isFollowing ? 'secondary' : 'secondary_inverted'}
-      style={[a.rounded_full]}>
-      {gtMobile && (
-        <ButtonIcon
-          icon={isFollowing ? Check : Plus}
-          position="left"
-          size="sm"
-        />
-      )}
-      <ButtonText>
-        {!isFollowing ? (
-          isFollowedBy ? (
-            <Trans>Follow back</Trans>
-          ) : (
-            <Trans>Follow</Trans>
-          )
-        ) : (
-          <Trans>Following</Trans>
-        )}
-      </ButtonText>
-    </Button>
-  )
-}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
deleted file mode 100644
index 679a506b9..000000000
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ /dev/null
@@ -1,1036 +0,0 @@
-import {memo, useCallback, useMemo, useState} from 'react'
-import {
-  type GestureResponderEvent,
-  StyleSheet,
-  Text as RNText,
-  View,
-} from 'react-native'
-import {
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
-  type AppBskyFeedThreadgate,
-  AtUri,
-  type ModerationDecision,
-  RichText as RichTextAPI,
-} from '@atproto/api'
-import {msg, Plural, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {useActorStatus} from '#/lib/actor-status'
-import {MAX_POST_LINES} from '#/lib/constants'
-import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useTranslate} from '#/lib/hooks/useTranslate'
-import {makeProfileLink} from '#/lib/routes/links'
-import {sanitizeDisplayName} from '#/lib/strings/display-names'
-import {sanitizeHandle} from '#/lib/strings/handles'
-import {countLines} from '#/lib/strings/helpers'
-import {niceDate} from '#/lib/strings/time'
-import {s} from '#/lib/styles'
-import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
-import {logger} from '#/logger'
-import {
-  POST_TOMBSTONE,
-  type Shadow,
-  usePostShadow,
-} from '#/state/cache/post-shadow'
-import {useProfileShadow} from '#/state/cache/profile-shadow'
-import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
-import {useLanguagePrefs} from '#/state/preferences'
-import {type ThreadPost} from '#/state/queries/post-thread'
-import {useSession} from '#/state/session'
-import {type OnPostSuccessData} from '#/state/shell/composer'
-import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
-import {type PostSource} from '#/state/unstable-post-source'
-import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
-import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
-import {Link} from '#/view/com/util/Link'
-import {formatCount} from '#/view/com/util/numeric/format'
-import {PostMeta} from '#/view/com/util/PostMeta'
-import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, useTheme} from '#/alf'
-import {colors} from '#/components/Admonition'
-import {Button} from '#/components/Button'
-import {useInteractionState} from '#/components/hooks/useInteractionState'
-import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
-import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
-import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
-import {InlineLinkText} from '#/components/Link'
-import {ContentHider} from '#/components/moderation/ContentHider'
-import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
-import {PostAlerts} from '#/components/moderation/PostAlerts'
-import {PostHider} from '#/components/moderation/PostHider'
-import {type AppModerationCause} from '#/components/Pills'
-import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
-import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
-import {PostControls} from '#/components/PostControls'
-import * as Prompt from '#/components/Prompt'
-import {RichText} from '#/components/RichText'
-import {SubtleWebHover} from '#/components/SubtleWebHover'
-import {Text} from '#/components/Typography'
-import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
-import {WhoCanReply} from '#/components/WhoCanReply'
-import * as bsky from '#/types/bsky'
-
-export function PostThreadItem({
-  post,
-  record,
-  moderation,
-  treeView,
-  depth,
-  prevPost,
-  nextPost,
-  isHighlightedPost,
-  hasMore,
-  showChildReplyLine,
-  showParentReplyLine,
-  hasPrecedingItem,
-  overrideBlur,
-  onPostReply,
-  onPostSuccess,
-  hideTopBorder,
-  threadgateRecord,
-  anchorPostSource,
-}: {
-  post: AppBskyFeedDefs.PostView
-  record: AppBskyFeedPost.Record
-  moderation: ModerationDecision | undefined
-  treeView: boolean
-  depth: number
-  prevPost: ThreadPost | undefined
-  nextPost: ThreadPost | undefined
-  isHighlightedPost?: boolean
-  hasMore?: boolean
-  showChildReplyLine?: boolean
-  showParentReplyLine?: boolean
-  hasPrecedingItem: boolean
-  overrideBlur: boolean
-  onPostReply: (postUri: string | undefined) => void
-  onPostSuccess?: (data: OnPostSuccessData) => void
-  hideTopBorder?: boolean
-  threadgateRecord?: AppBskyFeedThreadgate.Record
-  anchorPostSource?: PostSource
-}) {
-  const postShadowed = usePostShadow(post)
-  const richText = useMemo(
-    () =>
-      new RichTextAPI({
-        text: record.text,
-        facets: record.facets,
-      }),
-    [record],
-  )
-  if (postShadowed === POST_TOMBSTONE) {
-    return <PostThreadItemDeleted hideTopBorder={hideTopBorder} />
-  }
-  if (richText && moderation) {
-    return (
-      <PostThreadItemLoaded
-        // Safeguard from clobbering per-post state below:
-        key={postShadowed.uri}
-        post={postShadowed}
-        prevPost={prevPost}
-        nextPost={nextPost}
-        record={record}
-        richText={richText}
-        moderation={moderation}
-        treeView={treeView}
-        depth={depth}
-        isHighlightedPost={isHighlightedPost}
-        hasMore={hasMore}
-        showChildReplyLine={showChildReplyLine}
-        showParentReplyLine={showParentReplyLine}
-        hasPrecedingItem={hasPrecedingItem}
-        overrideBlur={overrideBlur}
-        onPostReply={onPostReply}
-        onPostSuccess={onPostSuccess}
-        hideTopBorder={hideTopBorder}
-        threadgateRecord={threadgateRecord}
-        anchorPostSource={anchorPostSource}
-      />
-    )
-  }
-  return null
-}
-
-function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) {
-  const t = useTheme()
-  return (
-    <View
-      style={[
-        t.atoms.bg,
-        t.atoms.border_contrast_low,
-        a.p_xl,
-        a.pl_lg,
-        a.flex_row,
-        a.gap_md,
-        !hideTopBorder && a.border_t,
-      ]}>
-      <TrashIcon style={[t.atoms.text]} />
-      <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
-        <Trans>This post has been deleted.</Trans>
-      </Text>
-    </View>
-  )
-}
-
-let PostThreadItemLoaded = ({
-  post,
-  record,
-  richText,
-  moderation,
-  treeView,
-  depth,
-  prevPost,
-  nextPost,
-  isHighlightedPost,
-  hasMore,
-  showChildReplyLine,
-  showParentReplyLine,
-  hasPrecedingItem,
-  overrideBlur,
-  onPostReply,
-  onPostSuccess,
-  hideTopBorder,
-  threadgateRecord,
-  anchorPostSource,
-}: {
-  post: Shadow<AppBskyFeedDefs.PostView>
-  record: AppBskyFeedPost.Record
-  richText: RichTextAPI
-  moderation: ModerationDecision
-  treeView: boolean
-  depth: number
-  prevPost: ThreadPost | undefined
-  nextPost: ThreadPost | undefined
-  isHighlightedPost?: boolean
-  hasMore?: boolean
-  showChildReplyLine?: boolean
-  showParentReplyLine?: boolean
-  hasPrecedingItem: boolean
-  overrideBlur: boolean
-  onPostReply: (postUri: string | undefined) => void
-  onPostSuccess?: (data: OnPostSuccessData) => void
-  hideTopBorder?: boolean
-  threadgateRecord?: AppBskyFeedThreadgate.Record
-  anchorPostSource?: PostSource
-}): React.ReactNode => {
-  const {currentAccount, hasSession} = useSession()
-  const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
-
-  const t = useTheme()
-  const pal = usePalette('default')
-  const {_, i18n} = useLingui()
-  const langPrefs = useLanguagePrefs()
-  const {openComposer} = useOpenComposer()
-  const [limitLines, setLimitLines] = useState(
-    () => countLines(richText?.text) >= MAX_POST_LINES,
-  )
-  const shadowedPostAuthor = useProfileShadow(post.author)
-  const rootUri = record.reply?.root?.uri || post.uri
-  const postHref = useMemo(() => {
-    const urip = new AtUri(post.uri)
-    return makeProfileLink(post.author, 'post', urip.rkey)
-  }, [post.uri, post.author])
-  const itemTitle = _(msg`Post by ${post.author.handle}`)
-  const authorHref = makeProfileLink(post.author)
-  const authorTitle = post.author.handle
-  const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
-  const likesHref = useMemo(() => {
-    const urip = new AtUri(post.uri)
-    return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
-  }, [post.uri, post.author])
-  const likesTitle = _(msg`Likes on this post`)
-  const repostsHref = useMemo(() => {
-    const urip = new AtUri(post.uri)
-    return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
-  }, [post.uri, post.author])
-  const repostsTitle = _(msg`Reposts of this post`)
-  const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
-    threadgateRecord,
-  })
-  const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
-    const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
-    const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did
-    return isControlledByViewer && isPostHiddenByThreadgate
-      ? [
-          {
-            type: 'reply-hidden',
-            source: {type: 'user', did: currentAccount?.did},
-            priority: 6,
-          },
-        ]
-      : []
-  }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri])
-  const quotesHref = useMemo(() => {
-    const urip = new AtUri(post.uri)
-    return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
-  }, [post.uri, post.author])
-  const quotesTitle = _(msg`Quotes of this post`)
-  const onlyFollowersCanReply = !!threadgateRecord?.allow?.find(
-    rule => rule.$type === 'app.bsky.feed.threadgate#followerRule',
-  )
-  const showFollowButton =
-    currentAccount?.did !== post.author.did && !onlyFollowersCanReply
-
-  const needsTranslation = useMemo(
-    () =>
-      Boolean(
-        langPrefs.primaryLanguage &&
-          !isPostInLanguage(post, [langPrefs.primaryLanguage]),
-      ),
-    [post, langPrefs.primaryLanguage],
-  )
-
-  const onPressReply = () => {
-    if (anchorPostSource && isHighlightedPost) {
-      feedFeedback.sendInteraction({
-        item: post.uri,
-        event: 'app.bsky.feed.defs#interactionReply',
-        feedContext: anchorPostSource.post.feedContext,
-        reqId: anchorPostSource.post.reqId,
-      })
-    }
-    openComposer({
-      replyTo: {
-        uri: post.uri,
-        cid: post.cid,
-        text: record.text,
-        author: post.author,
-        embed: post.embed,
-        moderation,
-        langs: record.langs,
-      },
-      onPost: onPostReply,
-      onPostSuccess: onPostSuccess,
-    })
-  }
-
-  const onOpenAuthor = () => {
-    if (anchorPostSource) {
-      feedFeedback.sendInteraction({
-        item: post.uri,
-        event: 'app.bsky.feed.defs#clickthroughAuthor',
-        feedContext: anchorPostSource.post.feedContext,
-        reqId: anchorPostSource.post.reqId,
-      })
-    }
-  }
-
-  const onOpenEmbed = () => {
-    if (anchorPostSource) {
-      feedFeedback.sendInteraction({
-        item: post.uri,
-        event: 'app.bsky.feed.defs#clickthroughEmbed',
-        feedContext: anchorPostSource.post.feedContext,
-        reqId: anchorPostSource.post.reqId,
-      })
-    }
-  }
-
-  const onPressShowMore = useCallback(() => {
-    setLimitLines(false)
-  }, [setLimitLines])
-
-  const {isActive: live} = useActorStatus(post.author)
-
-  const reason = anchorPostSource?.post.reason
-  const viaRepost = useMemo(() => {
-    if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
-      return {
-        uri: reason.uri,
-        cid: reason.cid,
-      }
-    }
-  }, [reason])
-
-  if (!record) {
-    return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
-  }
-
-  if (isHighlightedPost) {
-    return (
-      <>
-        {rootUri !== post.uri && (
-          <View
-            style={[
-              a.pl_lg,
-              a.flex_row,
-              a.pb_xs,
-              {height: a.pt_lg.paddingTop},
-            ]}>
-            <View style={{width: 42}}>
-              <View
-                style={[
-                  styles.replyLine,
-                  a.flex_grow,
-                  {backgroundColor: pal.colors.replyLine},
-                ]}
-              />
-            </View>
-          </View>
-        )}
-
-        <View
-          testID={`postThreadItem-by-${post.author.handle}`}
-          style={[
-            a.px_lg,
-            t.atoms.border_contrast_low,
-            // root post styles
-            rootUri === post.uri && [a.pt_lg],
-          ]}>
-          <View style={[a.flex_row, a.gap_md, a.pb_md]}>
-            <PreviewableUserAvatar
-              size={42}
-              profile={post.author}
-              moderation={moderation.ui('avatar')}
-              type={post.author.associated?.labeler ? 'labeler' : 'user'}
-              live={live}
-              onBeforePress={onOpenAuthor}
-            />
-            <View style={[a.flex_1]}>
-              <View style={[a.flex_row, a.align_center]}>
-                <Link
-                  style={[a.flex_shrink]}
-                  href={authorHref}
-                  title={authorTitle}
-                  onBeforePress={onOpenAuthor}>
-                  <Text
-                    emoji
-                    style={[
-                      a.text_lg,
-                      a.font_bold,
-                      a.leading_snug,
-                      a.self_start,
-                    ]}
-                    numberOfLines={1}>
-                    {sanitizeDisplayName(
-                      post.author.displayName ||
-                        sanitizeHandle(post.author.handle),
-                      moderation.ui('displayName'),
-                    )}
-                  </Text>
-                </Link>
-
-                <View style={[{paddingLeft: 3, top: -1}]}>
-                  <VerificationCheckButton
-                    profile={shadowedPostAuthor}
-                    size="md"
-                  />
-                </View>
-              </View>
-              <Link style={s.flex1} href={authorHref} title={authorTitle}>
-                <Text
-                  emoji
-                  style={[
-                    a.text_md,
-                    a.leading_snug,
-                    t.atoms.text_contrast_medium,
-                  ]}
-                  numberOfLines={1}>
-                  {sanitizeHandle(post.author.handle, '@')}
-                </Text>
-              </Link>
-            </View>
-            {showFollowButton && (
-              <View>
-                <PostThreadFollowBtn did={post.author.did} />
-              </View>
-            )}
-          </View>
-          <View style={[a.pb_sm]}>
-            <LabelsOnMyPost post={post} style={[a.pb_sm]} />
-            <ContentHider
-              modui={moderation.ui('contentView')}
-              ignoreMute
-              childContainerStyle={[a.pt_sm]}>
-              <PostAlerts
-                modui={moderation.ui('contentView')}
-                size="lg"
-                includeMute
-                style={[a.pb_sm]}
-                additionalCauses={additionalPostAlerts}
-              />
-              {richText?.text ? (
-                <RichText
-                  enableTags
-                  selectable
-                  value={richText}
-                  style={[a.flex_1, a.text_xl]}
-                  authorHandle={post.author.handle}
-                  shouldProxyLinks={true}
-                />
-              ) : undefined}
-              {post.embed && (
-                <View style={[a.py_xs]}>
-                  <Embed
-                    embed={post.embed}
-                    moderation={moderation}
-                    viewContext={PostEmbedViewContext.ThreadHighlighted}
-                    onOpen={onOpenEmbed}
-                  />
-                </View>
-              )}
-            </ContentHider>
-            <ExpandedPostDetails
-              post={post}
-              record={record}
-              isThreadAuthor={isThreadAuthor}
-              needsTranslation={needsTranslation}
-            />
-            {post.repostCount !== 0 ||
-            post.likeCount !== 0 ||
-            post.quoteCount !== 0 ? (
-              // Show this section unless we're *sure* it has no engagement.
-              <View
-                style={[
-                  a.flex_row,
-                  a.align_center,
-                  a.gap_lg,
-                  a.border_t,
-                  a.border_b,
-                  a.mt_md,
-                  a.py_md,
-                  t.atoms.border_contrast_low,
-                ]}>
-                {post.repostCount != null && post.repostCount !== 0 ? (
-                  <Link href={repostsHref} title={repostsTitle}>
-                    <Text
-                      testID="repostCount-expanded"
-                      style={[a.text_md, t.atoms.text_contrast_medium]}>
-                      <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                        {formatCount(i18n, post.repostCount)}
-                      </Text>{' '}
-                      <Plural
-                        value={post.repostCount}
-                        one="repost"
-                        other="reposts"
-                      />
-                    </Text>
-                  </Link>
-                ) : null}
-                {post.quoteCount != null &&
-                post.quoteCount !== 0 &&
-                !post.viewer?.embeddingDisabled ? (
-                  <Link href={quotesHref} title={quotesTitle}>
-                    <Text
-                      testID="quoteCount-expanded"
-                      style={[a.text_md, t.atoms.text_contrast_medium]}>
-                      <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                        {formatCount(i18n, post.quoteCount)}
-                      </Text>{' '}
-                      <Plural
-                        value={post.quoteCount}
-                        one="quote"
-                        other="quotes"
-                      />
-                    </Text>
-                  </Link>
-                ) : null}
-                {post.likeCount != null && post.likeCount !== 0 ? (
-                  <Link href={likesHref} title={likesTitle}>
-                    <Text
-                      testID="likeCount-expanded"
-                      style={[a.text_md, t.atoms.text_contrast_medium]}>
-                      <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                        {formatCount(i18n, post.likeCount)}
-                      </Text>{' '}
-                      <Plural value={post.likeCount} one="like" other="likes" />
-                    </Text>
-                  </Link>
-                ) : null}
-              </View>
-            ) : null}
-            <View
-              style={[
-                a.pt_sm,
-                a.pb_2xs,
-                {
-                  marginLeft: -5,
-                },
-              ]}>
-              <FeedFeedbackProvider value={feedFeedback}>
-                <PostControls
-                  big
-                  post={post}
-                  record={record}
-                  richText={richText}
-                  onPressReply={onPressReply}
-                  onPostReply={onPostReply}
-                  logContext="PostThreadItem"
-                  threadgateRecord={threadgateRecord}
-                  feedContext={anchorPostSource?.post?.feedContext}
-                  reqId={anchorPostSource?.post?.reqId}
-                  viaRepost={viaRepost}
-                />
-              </FeedFeedbackProvider>
-            </View>
-          </View>
-        </View>
-      </>
-    )
-  } else {
-    const isThreadedChild = treeView && depth > 0
-    const isThreadedChildAdjacentTop =
-      isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1
-    const isThreadedChildAdjacentBot =
-      isThreadedChild && nextPost?.ctx.depth === depth
-    return (
-      <PostOuterWrapper
-        post={post}
-        depth={depth}
-        showParentReplyLine={!!showParentReplyLine}
-        treeView={treeView}
-        hasPrecedingItem={hasPrecedingItem}
-        hideTopBorder={hideTopBorder}>
-        <PostHider
-          testID={`postThreadItem-by-${post.author.handle}`}
-          href={postHref}
-          disabled={overrideBlur}
-          modui={moderation.ui('contentList')}
-          iconSize={isThreadedChild ? 24 : 42}
-          iconStyles={
-            isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2}
-          }
-          profile={post.author}
-          interpretFilterAsBlur>
-          <View
-            style={{
-              flexDirection: 'row',
-              gap: 10,
-              paddingLeft: 8,
-              height: isThreadedChildAdjacentTop ? 8 : 16,
-            }}>
-            <View style={{width: 42}}>
-              {!isThreadedChild && showParentReplyLine && (
-                <View
-                  style={[
-                    styles.replyLine,
-                    {
-                      flexGrow: 1,
-                      backgroundColor: pal.colors.replyLine,
-                      marginBottom: 4,
-                    },
-                  ]}
-                />
-              )}
-            </View>
-          </View>
-
-          <View
-            style={[
-              a.flex_row,
-              a.px_sm,
-              a.gap_md,
-              {
-                paddingBottom:
-                  showChildReplyLine && !isThreadedChild
-                    ? 0
-                    : isThreadedChildAdjacentBot
-                      ? 4
-                      : 8,
-              },
-            ]}>
-            {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
-            {!isThreadedChild && (
-              <View>
-                <PreviewableUserAvatar
-                  size={42}
-                  profile={post.author}
-                  moderation={moderation.ui('avatar')}
-                  type={post.author.associated?.labeler ? 'labeler' : 'user'}
-                  live={live}
-                />
-
-                {showChildReplyLine && (
-                  <View
-                    style={[
-                      styles.replyLine,
-                      {
-                        flexGrow: 1,
-                        backgroundColor: pal.colors.replyLine,
-                        marginTop: 4,
-                      },
-                    ]}
-                  />
-                )}
-              </View>
-            )}
-
-            <View style={[a.flex_1]}>
-              <PostMeta
-                author={post.author}
-                moderation={moderation}
-                timestamp={post.indexedAt}
-                postHref={postHref}
-                showAvatar={isThreadedChild}
-                avatarSize={24}
-                style={[a.pb_xs]}
-              />
-              <LabelsOnMyPost post={post} style={[a.pb_xs]} />
-              <PostAlerts
-                modui={moderation.ui('contentList')}
-                style={[a.pb_2xs]}
-                additionalCauses={additionalPostAlerts}
-              />
-              {richText?.text ? (
-                <View style={[a.pb_2xs, a.pr_sm]}>
-                  <RichText
-                    enableTags
-                    value={richText}
-                    style={[a.flex_1, a.text_md]}
-                    numberOfLines={limitLines ? MAX_POST_LINES : undefined}
-                    authorHandle={post.author.handle}
-                    shouldProxyLinks={true}
-                  />
-                  {limitLines && (
-                    <ShowMoreTextButton
-                      style={[a.text_md]}
-                      onPress={onPressShowMore}
-                    />
-                  )}
-                </View>
-              ) : undefined}
-              {post.embed && (
-                <View style={[a.pb_xs]}>
-                  <Embed
-                    embed={post.embed}
-                    moderation={moderation}
-                    viewContext={PostEmbedViewContext.Feed}
-                  />
-                </View>
-              )}
-              <PostControls
-                post={post}
-                record={record}
-                richText={richText}
-                onPressReply={onPressReply}
-                logContext="PostThreadItem"
-                threadgateRecord={threadgateRecord}
-              />
-            </View>
-          </View>
-          {hasMore ? (
-            <Link
-              style={[
-                styles.loadMore,
-                {
-                  paddingLeft: treeView ? 8 : 70,
-                  paddingTop: 0,
-                  paddingBottom: treeView ? 4 : 12,
-                },
-              ]}
-              href={postHref}
-              title={itemTitle}
-              noFeedback>
-              <Text
-                style={[t.atoms.text_contrast_medium, a.font_bold, a.text_sm]}>
-                <Trans>More</Trans>
-              </Text>
-              <ChevronRightIcon
-                size="xs"
-                style={[t.atoms.text_contrast_medium]}
-              />
-            </Link>
-          ) : undefined}
-        </PostHider>
-      </PostOuterWrapper>
-    )
-  }
-}
-PostThreadItemLoaded = memo(PostThreadItemLoaded)
-
-function PostOuterWrapper({
-  post,
-  treeView,
-  depth,
-  showParentReplyLine,
-  hasPrecedingItem,
-  hideTopBorder,
-  children,
-}: React.PropsWithChildren<{
-  post: AppBskyFeedDefs.PostView
-  treeView: boolean
-  depth: number
-  showParentReplyLine: boolean
-  hasPrecedingItem: boolean
-  hideTopBorder?: boolean
-}>) {
-  const t = useTheme()
-  const {
-    state: hover,
-    onIn: onHoverIn,
-    onOut: onHoverOut,
-  } = useInteractionState()
-  if (treeView && depth > 0) {
-    return (
-      <View
-        style={[
-          a.flex_row,
-          a.px_sm,
-          a.flex_row,
-          t.atoms.border_contrast_low,
-          styles.cursor,
-          depth === 1 && a.border_t,
-        ]}
-        onPointerEnter={onHoverIn}
-        onPointerLeave={onHoverOut}>
-        {Array.from(Array(depth - 1)).map((_, n: number) => (
-          <View
-            key={`${post.uri}-padding-${n}`}
-            style={[
-              a.ml_sm,
-              t.atoms.border_contrast_low,
-              {
-                borderLeftWidth: 2,
-                paddingLeft: a.pl_sm.paddingLeft - 2, // minus border
-              },
-            ]}
-          />
-        ))}
-        <View style={a.flex_1}>
-          <SubtleWebHover
-            hover={hover}
-            style={{
-              left: (depth === 1 ? 0 : 2) - a.pl_sm.paddingLeft,
-              right: -a.pr_sm.paddingRight,
-            }}
-          />
-          {children}
-        </View>
-      </View>
-    )
-  }
-  return (
-    <View
-      onPointerEnter={onHoverIn}
-      onPointerLeave={onHoverOut}
-      style={[
-        a.border_t,
-        a.px_sm,
-        t.atoms.border_contrast_low,
-        showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
-        hideTopBorder && styles.noTopBorder,
-        styles.cursor,
-      ]}>
-      <SubtleWebHover hover={hover} />
-      {children}
-    </View>
-  )
-}
-
-function ExpandedPostDetails({
-  post,
-  record,
-  isThreadAuthor,
-  needsTranslation,
-}: {
-  post: AppBskyFeedDefs.PostView
-  record: AppBskyFeedPost.Record
-  isThreadAuthor: boolean
-  needsTranslation: boolean
-}) {
-  const t = useTheme()
-  const pal = usePalette('default')
-  const {_, i18n} = useLingui()
-  const translate = useTranslate()
-  const isRootPost = !('reply' in post.record)
-  const langPrefs = useLanguagePrefs()
-
-  const onTranslatePress = useCallback(
-    (e: GestureResponderEvent) => {
-      e.preventDefault()
-      translate(record.text || '', langPrefs.primaryLanguage)
-
-      if (
-        bsky.dangerousIsType<AppBskyFeedPost.Record>(
-          post.record,
-          AppBskyFeedPost.isRecord,
-        )
-      ) {
-        logger.metric(
-          'translate',
-          {
-            sourceLanguages: post.record.langs ?? [],
-            targetLanguage: langPrefs.primaryLanguage,
-            textLength: post.record.text.length,
-          },
-          {statsig: false},
-        )
-      }
-
-      return false
-    },
-    [translate, record.text, langPrefs, post],
-  )
-
-  return (
-    <View style={[a.gap_md, a.pt_md, a.align_start]}>
-      <BackdatedPostIndicator post={post} />
-      <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
-        <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
-          {niceDate(i18n, post.indexedAt)}
-        </Text>
-        {isRootPost && (
-          <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
-        )}
-        {needsTranslation && (
-          <>
-            <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
-              &middot;
-            </Text>
-
-            <InlineLinkText
-              // overridden to open an intent on android, but keep
-              // as anchor tag for accessibility
-              to={getTranslatorLink(record.text, langPrefs.primaryLanguage)}
-              label={_(msg`Translate`)}
-              style={[a.text_sm, pal.link]}
-              onPress={onTranslatePress}>
-              <Trans>Translate</Trans>
-            </InlineLinkText>
-          </>
-        )}
-      </View>
-    </View>
-  )
-}
-
-function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
-  const t = useTheme()
-  const {_, i18n} = useLingui()
-  const control = Prompt.usePromptControl()
-
-  const indexedAt = new Date(post.indexedAt)
-  const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>(
-    post.record,
-    AppBskyFeedPost.isRecord,
-  )
-    ? new Date(post.record.createdAt)
-    : new Date(post.indexedAt)
-
-  // backdated if createdAt is 24 hours or more before indexedAt
-  const isBackdated =
-    indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
-
-  if (!isBackdated) return null
-
-  const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
-
-  return (
-    <>
-      <Button
-        label={_(msg`Archived post`)}
-        accessibilityHint={_(
-          msg`Shows information about when this post was created`,
-        )}
-        onPress={e => {
-          e.preventDefault()
-          e.stopPropagation()
-          control.open()
-        }}>
-        {({hovered, pressed}) => (
-          <View
-            style={[
-              a.flex_row,
-              a.align_center,
-              a.rounded_full,
-              t.atoms.bg_contrast_25,
-              (hovered || pressed) && t.atoms.bg_contrast_50,
-              {
-                gap: 3,
-                paddingHorizontal: 6,
-                paddingVertical: 3,
-              },
-            ]}>
-            <CalendarClockIcon fill={orange} size="sm" aria-hidden />
-            <Text
-              style={[
-                a.text_xs,
-                a.font_bold,
-                a.leading_tight,
-                t.atoms.text_contrast_medium,
-              ]}>
-              <Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
-            </Text>
-          </View>
-        )}
-      </Button>
-
-      <Prompt.Outer control={control}>
-        <Prompt.TitleText>
-          <Trans>Archived post</Trans>
-        </Prompt.TitleText>
-        <Prompt.DescriptionText>
-          <Trans>
-            This post claims to have been created on{' '}
-            <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>,
-            but was first seen by Bluesky on{' '}
-            <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>.
-          </Trans>
-        </Prompt.DescriptionText>
-        <Text
-          style={[
-            a.text_md,
-            a.leading_snug,
-            t.atoms.text_contrast_high,
-            a.pb_xl,
-          ]}>
-          <Trans>
-            Bluesky cannot confirm the authenticity of the claimed date.
-          </Trans>
-        </Text>
-        <Prompt.Actions>
-          <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
-        </Prompt.Actions>
-      </Prompt.Outer>
-    </>
-  )
-}
-
-function getThreadAuthor(
-  post: AppBskyFeedDefs.PostView,
-  record: AppBskyFeedPost.Record,
-): string {
-  if (!record.reply) {
-    return post.author.did
-  }
-  try {
-    return new AtUri(record.reply.root.uri).host
-  } catch {
-    return ''
-  }
-}
-
-const styles = StyleSheet.create({
-  outer: {
-    borderTopWidth: StyleSheet.hairlineWidth,
-    paddingLeft: 8,
-  },
-  noTopBorder: {
-    borderTopWidth: 0,
-  },
-  meta: {
-    flexDirection: 'row',
-    paddingVertical: 2,
-  },
-  metaExpandedLine1: {
-    paddingVertical: 0,
-  },
-  loadMore: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'flex-start',
-    gap: 4,
-    paddingHorizontal: 20,
-  },
-  replyLine: {
-    width: 2,
-    marginLeft: 'auto',
-    marginRight: 'auto',
-  },
-  cursor: {
-    // @ts-ignore web only
-    cursor: 'pointer',
-  },
-})
diff --git a/src/view/com/post-thread/PostThreadLoadMore.tsx b/src/view/com/post-thread/PostThreadLoadMore.tsx
deleted file mode 100644
index 27e2ea724..000000000
--- a/src/view/com/post-thread/PostThreadLoadMore.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import * as React from 'react'
-import {View} from 'react-native'
-import {AppBskyFeedDefs, AtUri} from '@atproto/api'
-import {Trans} from '@lingui/macro'
-
-import {makeProfileLink} from '#/lib/routes/links'
-import {atoms as a, useTheme} from '#/alf'
-import {Text} from '#/components/Typography'
-import {Link} from '../util/Link'
-import {UserAvatar} from '../util/UserAvatar'
-
-export function PostThreadLoadMore({post}: {post: AppBskyFeedDefs.PostView}) {
-  const t = useTheme()
-
-  const postHref = React.useMemo(() => {
-    const urip = new AtUri(post.uri)
-    return makeProfileLink(post.author, 'post', urip.rkey)
-  }, [post.uri, post.author])
-
-  return (
-    <Link
-      href={postHref}
-      style={[a.flex_row, a.align_center, a.py_md, {paddingHorizontal: 14}]}
-      hoverStyle={[t.atoms.bg_contrast_25]}>
-      <View style={[a.flex_row]}>
-        <View
-          style={{
-            alignItems: 'center',
-            justifyContent: 'center',
-            width: 34,
-            height: 34,
-            borderRadius: 18,
-            backgroundColor: t.atoms.bg.backgroundColor,
-            marginRight: -20,
-          }}>
-          <UserAvatar
-            avatar={post.author.avatar}
-            size={30}
-            type={post.author.associated?.labeler ? 'labeler' : 'user'}
-          />
-        </View>
-        <View
-          style={{
-            alignItems: 'center',
-            justifyContent: 'center',
-            width: 34,
-            height: 34,
-            borderRadius: 18,
-            backgroundColor: t.atoms.bg.backgroundColor,
-          }}>
-          <UserAvatar
-            avatar={post.author.avatar}
-            size={30}
-            type={post.author.associated?.labeler ? 'labeler' : 'user'}
-          />
-        </View>
-      </View>
-      <View style={[a.px_sm]}>
-        <Text style={[{color: t.palette.primary_500}, a.text_md]}>
-          <Trans>Continue thread...</Trans>
-        </Text>
-      </View>
-    </Link>
-  )
-}
diff --git a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx b/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
deleted file mode 100644
index 7dc75520b..000000000
--- a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import {View} from 'react-native'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {atoms as a, useTheme} from '#/alf'
-import {Button} from '#/components/Button'
-import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
-import {Text} from '#/components/Typography'
-
-export function PostThreadShowHiddenReplies({
-  type,
-  onPress,
-  hideTopBorder,
-}: {
-  type: 'hidden' | 'muted'
-  onPress: () => void
-  hideTopBorder?: boolean
-}) {
-  const {_} = useLingui()
-  const t = useTheme()
-  const label =
-    type === 'muted' ? _(msg`Show muted replies`) : _(msg`Show hidden replies`)
-
-  return (
-    <Button onPress={onPress} label={label}>
-      {({hovered, pressed}) => (
-        <View
-          style={[
-            a.flex_1,
-            a.flex_row,
-            a.align_center,
-            a.gap_sm,
-            a.py_lg,
-            a.px_xl,
-            !hideTopBorder && a.border_t,
-            t.atoms.border_contrast_low,
-            hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg,
-          ]}>
-          <View
-            style={[
-              t.atoms.bg_contrast_25,
-              a.align_center,
-              a.justify_center,
-              {
-                width: 26,
-                height: 26,
-                borderRadius: 13,
-                marginRight: 4,
-              },
-            ]}>
-            <EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} />
-          </View>
-          <Text
-            style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]}
-            numberOfLines={1}>
-            {label}
-          </Text>
-        </View>
-      )}
-    </Button>
-  )
-}
diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx
index c2780a2a5..2f03a168b 100644
--- a/src/view/com/posts/PostFeedItem.tsx
+++ b/src/view/com/posts/PostFeedItem.tsx
@@ -176,7 +176,7 @@ let FeedItemInner = ({
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey)
   }, [post.uri, post.author])
-  const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
+  const {sendInteraction, feedSourceInfo} = useFeedFeedbackContext()
 
   const onPressReply = () => {
     sendInteraction({
@@ -234,7 +234,7 @@ let FeedItemInner = ({
     })
     unstableCacheProfileView(queryClient, post.author)
     setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), {
-      feed: feedDescriptor,
+      feedSourceInfo,
       post: {
         post,
         reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined,
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
index 879bf22f9..df8b2e481 100644
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -27,6 +27,7 @@ import {EventStopper} from '#/view/com/util/EventStopper'
 import * as Toast from '#/view/com/util/Toast'
 import {Button, ButtonIcon} from '#/components/Button'
 import {useDialogControl} from '#/components/Dialog'
+import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
 import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
 import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
 import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
@@ -45,6 +46,7 @@ import {
 } from '#/components/icons/Person'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
+import {StarterPack} from '#/components/icons/StarterPack'
 import {EditLiveDialog} from '#/components/live/EditLiveDialog'
 import {GoLiveDialog} from '#/components/live/GoLiveDialog'
 import * as Menu from '#/components/Menu'
@@ -88,6 +90,7 @@ let ProfileMenu = ({
   const blockPromptControl = Prompt.usePromptControl()
   const loggedOutWarningPromptControl = Prompt.usePromptControl()
   const goLiveDialogControl = useDialogControl()
+  const addToStarterPacksDialogControl = useDialogControl()
 
   const showLoggedOutWarning = React.useMemo(() => {
     return (
@@ -301,6 +304,15 @@ let ProfileMenu = ({
                   </>
                 )}
                 <Menu.Item
+                  testID="profileHeaderDropdownStarterPackAddRemoveBtn"
+                  label={_(msg`Add to starter packs`)}
+                  onPress={addToStarterPacksDialogControl.open}>
+                  <Menu.ItemText>
+                    <Trans>Add to starter packs</Trans>
+                  </Menu.ItemText>
+                  <Menu.ItemIcon icon={StarterPack} />
+                </Menu.Item>
+                <Menu.Item
                   testID="profileHeaderDropdownListAddRemoveBtn"
                   label={_(msg`Add to lists`)}
                   onPress={onPressAddRemoveLists}>
@@ -440,6 +452,11 @@ let ProfileMenu = ({
         </Menu.Outer>
       </Menu.Root>
 
+      <StarterPackDialog
+        control={addToStarterPacksDialogControl}
+        targetDid={profile.did}
+      />
+
       <ReportDialog
         control={reportDialogControl}
         subject={{
diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx
index 3273cf195..8e39e28c0 100644
--- a/src/view/com/testing/TestCtrls.e2e.tsx
+++ b/src/view/com/testing/TestCtrls.e2e.tsx
@@ -1,8 +1,10 @@
-import {LogBox, Pressable, View} from 'react-native'
+import {useState} from 'react'
+import {LogBox, Pressable, View, TextInput} from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
 
+import {setBlueskyProxyHeader} from '#/lib/constants'
 import {useModalControls} from '#/state/modals'
-import {useSessionApi} from '#/state/session'
+import {useSessionApi, useAgent} from '#/state/session'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useOnboardingDispatch} from '#/state/shell/onboarding'
 import {navigate} from '../../../Navigation'
@@ -18,6 +20,7 @@ LogBox.ignoreAllLogs()
 const BTN = {height: 1, width: 1, backgroundColor: 'red'}
 
 export function TestCtrls() {
+  const agent = useAgent()
   const queryClient = useQueryClient()
   const {logoutEveryAccount, login} = useSessionApi()
   const {openModal} = useModalControls()
@@ -45,8 +48,19 @@ export function TestCtrls() {
     )
     setShowLoggedOut(false)
   }
+  const [proxyHeader, setProxyHeader] = useState('')
   return (
     <View style={{position: 'absolute', top: 100, right: 0, zIndex: 100}}>
+      <TextInput
+        testID="e2eProxyHeaderInput"
+        onChangeText={val => setProxyHeader(val as any)}
+        onSubmitEditing={() => {
+          const header = `${proxyHeader}#bsky_appview`
+          setBlueskyProxyHeader(header as any)
+          agent.configureProxy(header as any)
+        }}
+        style={BTN}
+      />
       <Pressable
         testID="e2eSignInAlice"
         onPress={onPressSignInAlice}
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
deleted file mode 100644
index e20dadb49..000000000
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ /dev/null
@@ -1,397 +0,0 @@
-import {type PropsWithChildren} from 'react'
-import {useMemo, useRef} from 'react'
-import {
-  Dimensions,
-  type GestureResponderEvent,
-  type Insets,
-  type StyleProp,
-  StyleSheet,
-  TouchableOpacity,
-  TouchableWithoutFeedback,
-  useWindowDimensions,
-  View,
-  type ViewStyle,
-} from 'react-native'
-import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated'
-import RootSiblings from 'react-native-root-siblings'
-import {type IconProp} from '@fortawesome/fontawesome-svg-core'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import type React from 'react'
-
-import {HITSLOP_10} from '#/lib/constants'
-import {usePalette} from '#/lib/hooks/usePalette'
-import {colors} from '#/lib/styles'
-import {useTheme} from '#/lib/ThemeContext'
-import {isWeb} from '#/platform/detection'
-import {native} from '#/alf'
-import {FullWindowOverlay} from '#/components/FullWindowOverlay'
-import {Text} from '../text/Text'
-import {Button, type ButtonType} from './Button'
-
-const ESTIMATED_BTN_HEIGHT = 50
-const ESTIMATED_SEP_HEIGHT = 16
-const ESTIMATED_HEADING_HEIGHT = 60
-
-export interface DropdownItemButton {
-  testID?: string
-  icon?: IconProp
-  label: string
-  onPress: () => void
-}
-export interface DropdownItemSeparator {
-  sep: true
-}
-export interface DropdownItemHeading {
-  heading: true
-  label: string
-}
-export type DropdownItem =
-  | DropdownItemButton
-  | DropdownItemSeparator
-  | DropdownItemHeading
-type MaybeDropdownItem = DropdownItem | false | undefined
-
-export type DropdownButtonType = ButtonType | 'bare'
-
-interface DropdownButtonProps {
-  testID?: string
-  type?: DropdownButtonType
-  style?: StyleProp<ViewStyle>
-  items: MaybeDropdownItem[]
-  label?: string
-  menuWidth?: number
-  children?: React.ReactNode
-  openToRight?: boolean
-  openUpwards?: boolean
-  rightOffset?: number
-  bottomOffset?: number
-  hitSlop?: Insets
-  accessibilityLabel?: string
-  accessibilityHint?: string
-}
-
-/**
- * @deprecated use Menu from `#/components/Menu.tsx` instead
- */
-export function DropdownButton({
-  testID,
-  type = 'bare',
-  style,
-  items,
-  label,
-  menuWidth,
-  children,
-  openToRight = false,
-  openUpwards = false,
-  rightOffset = 0,
-  bottomOffset = 0,
-  hitSlop = HITSLOP_10,
-  accessibilityLabel,
-}: PropsWithChildren<DropdownButtonProps>) {
-  const {_} = useLingui()
-
-  const ref1 = useRef<View>(null)
-  const ref2 = useRef<View>(null)
-
-  const onPress = (e: GestureResponderEvent) => {
-    const ref = ref1.current || ref2.current
-    const {height: winHeight} = Dimensions.get('window')
-    const pressY = e.nativeEvent.pageY
-    ref?.measure(
-      (
-        _x: number,
-        _y: number,
-        width: number,
-        _height: number,
-        pageX: number,
-        pageY: number,
-      ) => {
-        if (!menuWidth) {
-          menuWidth = 200
-        }
-        let estimatedMenuHeight = 0
-        for (const item of items) {
-          if (item && isSep(item)) {
-            estimatedMenuHeight += ESTIMATED_SEP_HEIGHT
-          } else if (item && isBtn(item)) {
-            estimatedMenuHeight += ESTIMATED_BTN_HEIGHT
-          } else if (item && isHeading(item)) {
-            estimatedMenuHeight += ESTIMATED_HEADING_HEIGHT
-          }
-        }
-        const newX = openToRight
-          ? pageX + width + rightOffset
-          : pageX + width - menuWidth
-
-        // Add a bit of additional room
-        let newY = pressY + bottomOffset + 20
-        if (openUpwards || newY + estimatedMenuHeight > winHeight) {
-          newY -= estimatedMenuHeight
-        }
-        createDropdownMenu(
-          newX,
-          newY,
-          pageY,
-          menuWidth,
-          items.filter(v => !!v) as DropdownItem[],
-          openUpwards,
-        )
-      },
-    )
-  }
-
-  const numItems = useMemo(
-    () =>
-      items.filter(item => {
-        if (item === undefined || item === false) {
-          return false
-        }
-
-        return isBtn(item)
-      }).length,
-    [items],
-  )
-
-  if (type === 'bare') {
-    return (
-      <TouchableOpacity
-        testID={testID}
-        style={style}
-        onPress={onPress}
-        hitSlop={hitSlop}
-        ref={ref1}
-        accessibilityRole="button"
-        accessibilityLabel={
-          accessibilityLabel || _(msg`Opens ${numItems} options`)
-        }
-        accessibilityHint="">
-        {children}
-      </TouchableOpacity>
-    )
-  }
-  return (
-    <View ref={ref2}>
-      <Button
-        type={type}
-        testID={testID}
-        onPress={onPress}
-        style={style}
-        label={label}>
-        {children}
-      </Button>
-    </View>
-  )
-}
-
-function createDropdownMenu(
-  x: number,
-  y: number,
-  pageY: number,
-  width: number,
-  items: DropdownItem[],
-  opensUpwards = false,
-): RootSiblings {
-  const onPressItem = (index: number) => {
-    sibling.destroy()
-    const item = items[index]
-    if (isBtn(item)) {
-      item.onPress()
-    }
-  }
-  const onOuterPress = () => sibling.destroy()
-  const sibling = new RootSiblings(
-    (
-      <DropdownItems
-        onOuterPress={onOuterPress}
-        x={x}
-        y={y}
-        pageY={pageY}
-        width={width}
-        items={items}
-        onPressItem={onPressItem}
-        openUpwards={opensUpwards}
-      />
-    ),
-  )
-  return sibling
-}
-
-type DropDownItemProps = {
-  onOuterPress: () => void
-  x: number
-  y: number
-  pageY: number
-  width: number
-  items: DropdownItem[]
-  onPressItem: (index: number) => void
-  openUpwards: boolean
-}
-
-const DropdownItems = ({
-  onOuterPress,
-  x,
-  y,
-  pageY,
-  width,
-  items,
-  onPressItem,
-  openUpwards,
-}: DropDownItemProps) => {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const {_} = useLingui()
-  const {height: screenHeight} = useWindowDimensions()
-  const dropDownBackgroundColor =
-    theme.colorScheme === 'dark' ? pal.btn : pal.view
-  const separatorColor =
-    theme.colorScheme === 'dark' ? pal.borderDark : pal.border
-
-  const numItems = items.filter(isBtn).length
-
-  // TODO: Refactor dropdown components to:
-  // - (On web, if not handled by React Native) use semantic <select />
-  // and <option /> elements for keyboard navigation out of the box
-  // - (On mobile) be buttons by default, accept `label` and `nativeID`
-  // props, and always have an explicit label
-  return (
-    <FullWindowOverlay>
-      {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */}
-      <TouchableWithoutFeedback
-        onPress={onOuterPress}
-        accessibilityLabel={_(msg`Toggle dropdown`)}
-        accessibilityHint="">
-        <Animated.View
-          entering={FadeIn}
-          style={[
-            styles.bg,
-            // On web we need to adjust the top and bottom relative to the scroll position
-            isWeb
-              ? {
-                  top: -pageY,
-                  bottom: pageY - screenHeight,
-                }
-              : {
-                  top: 0,
-                  bottom: 0,
-                },
-          ]}
-        />
-      </TouchableWithoutFeedback>
-      <Animated.View
-        entering={native(
-          openUpwards ? FadeInDown.springify(1000) : FadeInUp.springify(1000),
-        )}
-        style={[
-          styles.menu,
-          {left: x, top: y, width},
-          dropDownBackgroundColor,
-        ]}>
-        {items.map((item, index) => {
-          if (isBtn(item)) {
-            return (
-              <TouchableOpacity
-                testID={item.testID}
-                key={index}
-                style={[styles.menuItem]}
-                onPress={() => onPressItem(index)}
-                accessibilityRole="button"
-                accessibilityLabel={item.label}
-                accessibilityHint={_(
-                  msg`Selects option ${index + 1} of ${numItems}`,
-                )}>
-                {item.icon && (
-                  <FontAwesomeIcon
-                    style={styles.icon}
-                    icon={item.icon}
-                    color={pal.text.color as string}
-                  />
-                )}
-                <Text style={[styles.label, pal.text]}>{item.label}</Text>
-              </TouchableOpacity>
-            )
-          } else if (isSep(item)) {
-            return (
-              <View key={index} style={[styles.separator, separatorColor]} />
-            )
-          } else if (isHeading(item)) {
-            return (
-              <View style={[styles.heading, pal.border]} key={index}>
-                <Text style={[pal.text, styles.headingLabel]}>
-                  {item.label}
-                </Text>
-              </View>
-            )
-          }
-          return null
-        })}
-      </Animated.View>
-    </FullWindowOverlay>
-  )
-}
-
-function isSep(item: DropdownItem): item is DropdownItemSeparator {
-  return 'sep' in item && item.sep
-}
-function isHeading(item: DropdownItem): item is DropdownItemHeading {
-  return 'heading' in item && item.heading
-}
-function isBtn(item: DropdownItem): item is DropdownItemButton {
-  return !isSep(item) && !isHeading(item)
-}
-
-const styles = StyleSheet.create({
-  bg: {
-    position: 'absolute',
-    left: 0,
-    width: '100%',
-    backgroundColor: 'rgba(0, 0, 0, 0.1)',
-  },
-  menu: {
-    position: 'absolute',
-    backgroundColor: '#fff',
-    borderRadius: 14,
-    paddingVertical: 6,
-  },
-  menuItem: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingVertical: 10,
-    paddingLeft: 15,
-    paddingRight: 40,
-  },
-  menuItemBorder: {
-    borderTopWidth: 1,
-    borderTopColor: colors.gray1,
-    marginTop: 4,
-    paddingTop: 12,
-  },
-  icon: {
-    marginLeft: 2,
-    marginRight: 8,
-    flexShrink: 0,
-  },
-  label: {
-    fontSize: 18,
-    flexShrink: 1,
-    flexGrow: 1,
-  },
-  separator: {
-    borderTopWidth: 1,
-    marginVertical: 8,
-  },
-  heading: {
-    flexDirection: 'row',
-    justifyContent: 'center',
-    paddingVertical: 10,
-    paddingLeft: 15,
-    paddingRight: 20,
-    borderBottomWidth: 1,
-    marginBottom: 6,
-  },
-  headingLabel: {
-    fontSize: 18,
-    fontWeight: '600',
-  },
-})
diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx
index 1a236f8bc..8b81cee10 100644
--- a/src/view/screens/Debug.tsx
+++ b/src/view/screens/Debug.tsx
@@ -4,17 +4,16 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {usePalette} from '#/lib/hooks/usePalette'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
 import {s} from '#/lib/styles'
-import {PaletteColorName, ThemeProvider} from '#/lib/ThemeContext'
+import {type PaletteColorName, ThemeProvider} from '#/lib/ThemeContext'
 import {EmptyState} from '#/view/com/util/EmptyState'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
 import {Button} from '#/view/com/util/forms/Button'
-import {
-  DropdownButton,
-  DropdownItem,
-} from '#/view/com/util/forms/DropdownButton'
 import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
 import * as LoadingPlaceholder from '#/view/com/util/LoadingPlaceholder'
 import {Text} from '#/view/com/util/text/Text'
@@ -134,8 +133,6 @@ function ControlsView() {
     <ScrollView style={[s.pl10, s.pr10]}>
       <Heading label="Buttons" />
       <ButtonsView />
-      <Heading label="Dropdown Buttons" />
-      <DropdownButtonsView />
       <Heading label="Toggle Buttons" />
       <ToggleButtonsView />
       <View style={s.footerSpacer} />
@@ -396,44 +393,6 @@ function ButtonsView() {
   )
 }
 
-const DROPDOWN_ITEMS: DropdownItem[] = [
-  {
-    icon: ['far', 'paste'],
-    label: 'Copy post text',
-    onPress() {},
-  },
-  {
-    icon: 'share',
-    label: 'Share...',
-    onPress() {},
-  },
-  {
-    icon: 'circle-exclamation',
-    label: 'Report post',
-    onPress() {},
-  },
-]
-function DropdownButtonsView() {
-  const defaultPal = usePalette('default')
-  return (
-    <View style={[defaultPal.view]}>
-      <View style={s.mb5}>
-        <DropdownButton
-          type="primary"
-          items={DROPDOWN_ITEMS}
-          menuWidth={200}
-          label="Primary button"
-        />
-      </View>
-      <View style={s.mb5}>
-        <DropdownButton type="bare" items={DROPDOWN_ITEMS} menuWidth={200}>
-          <Text>Bare</Text>
-        </DropdownButton>
-      </View>
-    </View>
-  )
-}
-
 function ToggleButtonsView() {
   const defaultPal = usePalette('default')
   const buttonStyles = s.mb5
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index d2e492f7e..f2afe8235 100644
--- a/src/view/screens/DebugMod.tsx
+++ b/src/view/screens/DebugMod.tsx
@@ -31,8 +31,11 @@ import {
   groupNotifications,
   shouldFilterNotif,
 } from '#/state/queries/notifications/util'
+import {threadPost} from '#/state/queries/usePostThread/views'
 import {useSession} from '#/state/session'
 import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {ThreadItemAnchor} from '#/screens/PostThread/components/ThreadItemAnchor'
+import {ThreadItemPost} from '#/screens/PostThread/components/ThreadItemPost'
 import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -49,7 +52,6 @@ import * as ProfileCard from '#/components/ProfileCard'
 import {H1, H3, P, Text} from '#/components/Typography'
 import {ScreenHider} from '../../components/moderation/ScreenHider'
 import {NotificationFeedItem} from '../com/notifications/NotificationFeedItem'
-import {PostThreadItem} from '../com/post-thread/PostThreadItem'
 import {PostFeedItem} from '../com/posts/PostFeedItem'
 
 const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys(
@@ -519,13 +521,13 @@ export const DebugModScreen = ({}: NativeStackScreenProps<
                   <MockPostFeedItem post={post} moderation={postModeration} />
 
                   <Heading title="Post" subtitle="viewed directly" />
-                  <MockPostThreadItem post={post} moderation={postModeration} />
+                  <MockPostThreadItem post={post} moderationOpts={modOpts} />
 
                   <Heading title="Post" subtitle="reply in thread" />
                   <MockPostThreadItem
                     post={post}
-                    moderation={postModeration}
-                    reply
+                    moderationOpts={modOpts}
+                    isReply
                   />
                 </>
               )}
@@ -837,28 +839,33 @@ function MockPostFeedItem({
 
 function MockPostThreadItem({
   post,
-  moderation,
-  reply,
+  moderationOpts,
+  isReply,
 }: {
   post: AppBskyFeedDefs.PostView
-  moderation: ModerationDecision
-  reply?: boolean
+  moderationOpts: ModerationOpts
+  isReply?: boolean
 }) {
-  return (
-    <PostThreadItem
-      // @ts-ignore
-      post={post}
-      record={post.record as AppBskyFeedPost.Record}
-      moderation={moderation}
-      depth={reply ? 1 : 0}
-      isHighlightedPost={!reply}
-      treeView={false}
-      prevPost={undefined}
-      nextPost={undefined}
-      hasPrecedingItem={false}
-      overrideBlur={false}
-      onPostReply={() => {}}
-    />
+  const thread = threadPost({
+    uri: post.uri,
+    depth: isReply ? 1 : 0,
+    value: {
+      $type: 'app.bsky.unspecced.defs#threadItemPost',
+      post,
+      moreParents: false,
+      moreReplies: 0,
+      opThread: false,
+      hiddenByThreadgate: false,
+      mutedByViewer: false,
+    },
+    moderationOpts,
+    threadgateHiddenReplies: new Set<string>(),
+  })
+
+  return isReply ? (
+    <ThreadItemPost item={thread} />
+  ) : (
+    <ThreadItemAnchor item={thread} />
   )
 }
 
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index cc611e0d6..f07c971fb 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -5,17 +5,14 @@ import {
   type CommonNavigatorParams,
   type NativeStackScreenProps,
 } from '#/lib/routes/types'
-import {useGate} from '#/lib/statsig/statsig'
 import {makeRecordUri} from '#/lib/strings/url-helpers'
 import {useSetMinimalShellMode} from '#/state/shell'
-import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread'
 import {PostThread} from '#/screens/PostThread'
 import * as Layout from '#/components/Layout'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
 export function PostThreadScreen({route}: Props) {
   const setMinimalShellMode = useSetMinimalShellMode()
-  const gate = useGate()
 
   const {name, rkey} = route.params
   const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
@@ -28,11 +25,7 @@ export function PostThreadScreen({route}: Props) {
 
   return (
     <Layout.Screen testID="postThreadScreen">
-      {gate('post_threads_v2_unspecced') || __DEV__ ? (
-        <PostThread uri={uri} />
-      ) : (
-        <PostThreadComponent uri={uri} />
-      )}
+      <PostThread uri={uri} />
     </Layout.Screen>
   )
 }
diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx
index 98d5b05e3..91fe0d970 100644
--- a/src/view/screens/Storybook/Toasts.tsx
+++ b/src/view/screens/Storybook/Toasts.tsx
@@ -2,10 +2,58 @@ import {Pressable, View} from 'react-native'
 
 import {show as deprecatedShow} from '#/view/com/util/Toast'
 import {atoms as a} from '#/alf'
-import * as toast from '#/components/Toast'
-import {Toast} from '#/components/Toast/Toast'
+import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
+import * as Toast from '#/components/Toast'
 import {H1} from '#/components/Typography'
 
+function DefaultToast({
+  content,
+  type = 'default',
+}: {
+  content: string
+  type?: Toast.ToastType
+}) {
+  return (
+    <Toast.ToastConfigProvider id="default-toast" type={type}>
+      <Toast.Outer>
+        <Toast.Icon icon={GlobeIcon} />
+        <Toast.Text>{content}</Toast.Text>
+      </Toast.Outer>
+    </Toast.ToastConfigProvider>
+  )
+}
+
+function ToastWithAction() {
+  return (
+    <Toast.Outer>
+      <Toast.Icon icon={GlobeIcon} />
+      <Toast.Text>This toast has an action button</Toast.Text>
+      <Toast.Action
+        label="Action"
+        onPress={() => console.log('Action clicked!')}>
+        Action
+      </Toast.Action>
+    </Toast.Outer>
+  )
+}
+
+function LongToastWithAction() {
+  return (
+    <Toast.Outer>
+      <Toast.Icon icon={GlobeIcon} />
+      <Toast.Text>
+        This is a longer message to test how the toast handles multiple lines of
+        text content.
+      </Toast.Text>
+      <Toast.Action
+        label="Action"
+        onPress={() => console.log('Action clicked!')}>
+        Action
+      </Toast.Action>
+    </Toast.Outer>
+  )
+}
+
 export function Toasts() {
   return (
     <View style={[a.gap_md]}>
@@ -14,62 +62,77 @@ export function Toasts() {
       <View style={[a.gap_md]}>
         <Pressable
           accessibilityRole="button"
-          onPress={() => toast.show(`Hey I'm a toast!`)}>
-          <Toast content="Hey I'm a toast!" />
+          onPress={() => Toast.show(<ToastWithAction />, {type: 'success'})}>
+          <ToastWithAction />
+        </Pressable>
+        <Pressable
+          accessibilityRole="button"
+          onPress={() => Toast.show(<ToastWithAction />, {type: 'error'})}>
+          <ToastWithAction />
+        </Pressable>
+        <Pressable
+          accessibilityRole="button"
+          onPress={() => Toast.show(<LongToastWithAction />)}>
+          <LongToastWithAction />
+        </Pressable>
+        <Pressable
+          accessibilityRole="button"
+          onPress={() => Toast.show(`Hey I'm a toast!`)}>
+          <DefaultToast content="Hey I'm a toast!" />
         </Pressable>
         <Pressable
           accessibilityRole="button"
           onPress={() =>
-            toast.show(`This toast will disappear after 6 seconds`, {
+            Toast.show(`This toast will disappear after 6 seconds`, {
               duration: 6e3,
             })
           }>
-          <Toast content="This toast will disappear after 6 seconds" />
+          <DefaultToast content="This toast will disappear after 6 seconds" />
         </Pressable>
         <Pressable
           accessibilityRole="button"
           onPress={() =>
-            toast.show(
+            Toast.show(
               `This is a longer message to test how the toast handles multiple lines of text content.`,
             )
           }>
-          <Toast content="This is a longer message to test how the toast handles multiple lines of text content." />
+          <DefaultToast content="This is a longer message to test how the toast handles multiple lines of text content." />
         </Pressable>
         <Pressable
           accessibilityRole="button"
           onPress={() =>
-            toast.show(`Success! Yayyyyyyy :)`, {
+            Toast.show(`Success! Yayyyyyyy :)`, {
               type: 'success',
             })
           }>
-          <Toast content="Success! Yayyyyyyy :)" type="success" />
+          <DefaultToast content="Success! Yayyyyyyy :)" type="success" />
         </Pressable>
         <Pressable
           accessibilityRole="button"
           onPress={() =>
-            toast.show(`I'm providing info!`, {
+            Toast.show(`I'm providing info!`, {
               type: 'info',
             })
           }>
-          <Toast content="I'm providing info!" type="info" />
+          <DefaultToast content="I'm providing info!" type="info" />
         </Pressable>
         <Pressable
           accessibilityRole="button"
           onPress={() =>
-            toast.show(`This is a warning toast`, {
+            Toast.show(`This is a warning toast`, {
               type: 'warning',
             })
           }>
-          <Toast content="This is a warning toast" type="warning" />
+          <DefaultToast content="This is a warning toast" type="warning" />
         </Pressable>
         <Pressable
           accessibilityRole="button"
           onPress={() =>
-            toast.show(`This is an error toast :(`, {
+            Toast.show(`This is an error toast :(`, {
               type: 'error',
             })
           }>
-          <Toast content="This is an error toast :(" type="error" />
+          <DefaultToast content="This is an error toast :(" type="error" />
         </Pressable>
 
         <Pressable
@@ -80,7 +143,7 @@ export function Toasts() {
               'exclamation-circle',
             )
           }>
-          <Toast
+          <DefaultToast
             content="This is a test of the deprecated API"
             type="warning"
           />
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index 7a56722cc..441b35e3b 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -4,7 +4,7 @@ import {useLingui} from '@lingui/react'
 import {useNavigation, useNavigationState} from '@react-navigation/native'
 
 import {getCurrentRoute} from '#/lib/routes/helpers'
-import {NavigationProp} from '#/lib/routes/types'
+import {type NavigationProp} from '#/lib/routes/types'
 import {emitSoftReset} from '#/state/events'
 import {usePinnedFeedsInfos} from '#/state/queries/feed'
 import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
@@ -30,7 +30,8 @@ export function DesktopFeeds() {
       <View
         style={[
           {
-            gap: 12,
+            gap: 10,
+            paddingVertical: 2,
           },
         ]}>
         {Array(5)
@@ -66,6 +67,7 @@ export function DesktopFeeds() {
            * height of the screen with lots of feeds.
            */
           paddingVertical: 2,
+          marginHorizontal: -2,
           overflowY: 'auto',
         }),
       ]}>
@@ -90,6 +92,10 @@ export function DesktopFeeds() {
               current
                 ? [a.font_bold, t.atoms.text]
                 : [t.atoms.text_contrast_medium],
+              web({
+                marginHorizontal: 2,
+                width: 'calc(100% - 4px)',
+              }),
             ]}
             numberOfLines={1}>
             {feedInfo.displayName}
@@ -100,7 +106,14 @@ export function DesktopFeeds() {
       <InlineLinkText
         to="/feeds"
         label={_(msg`More feeds`)}
-        style={[a.text_md, a.leading_snug]}
+        style={[
+          a.text_md,
+          a.leading_snug,
+          web({
+            marginHorizontal: 2,
+            width: 'calc(100% - 4px)',
+          }),
+        ]}
         numberOfLines={1}>
         {_(msg`More feeds`)}
       </InlineLinkText>
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index aa18f9b70..cf1ff8425 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -236,6 +236,7 @@ function SwitchMenuItems({
     setShowLoggedOut(true)
     closeEverything()
   }
+
   return (
     <Menu.Outer>
       {accounts && accounts.length > 0 && (
@@ -255,6 +256,7 @@ function SwitchMenuItems({
           <Menu.Divider />
         </>
       )}
+      <SwitcherMenuProfileLink />
       <Menu.Item
         label={_(msg`Add another account`)}
         onPress={onAddAnotherAccount}>
@@ -273,6 +275,56 @@ function SwitchMenuItems({
   )
 }
 
+function SwitcherMenuProfileLink() {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const navigation = useNavigation()
+  const context = Menu.useMenuContext()
+  const profileLink = currentAccount ? makeProfileLink(currentAccount) : '/'
+  const [pathName] = useMemo(() => router.matchPath(profileLink), [profileLink])
+  const currentRouteInfo = useNavigationState(state => {
+    if (!state) {
+      return {name: 'Home'}
+    }
+    return getCurrentRoute(state)
+  })
+  let isCurrent =
+    currentRouteInfo.name === 'Profile'
+      ? isTab(currentRouteInfo.name, pathName) &&
+        (currentRouteInfo.params as CommonNavigatorParams['Profile']).name ===
+          currentAccount?.handle
+      : isTab(currentRouteInfo.name, pathName)
+  const onProfilePress = useCallback(
+    (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
+      if (e.ctrlKey || e.metaKey || e.altKey) {
+        return
+      }
+      e.preventDefault()
+      context.control.close()
+      if (isCurrent) {
+        emitSoftReset()
+      } else {
+        const [screen, params] = router.matchPath(profileLink)
+        // @ts-expect-error TODO: type matchPath well enough that it can be plugged into navigation.navigate directly
+        navigation.navigate(screen, params, {pop: true})
+      }
+    },
+    [navigation, profileLink, isCurrent, context],
+  )
+  return (
+    <Menu.Item
+      label={_(msg`Go to profile`)}
+      // @ts-expect-error The function signature differs on web -inb
+      onPress={onProfilePress}
+      href={profileLink}>
+      <Menu.ItemIcon icon={UserCircle} />
+      <Menu.ItemText>
+        <Trans>Go to profile</Trans>
+      </Menu.ItemText>
+    </Menu.Item>
+  )
+}
+
 function SwitchMenuItem({
   account,
   profile,
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 26795e0fd..1e000340a 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -65,6 +65,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
       style={[
         gutters,
         a.gap_lg,
+        a.pr_2xs,
         web({
           position: 'fixed',
           left: '50%',
@@ -74,7 +75,10 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
             },
             ...a.scrollbar_offset.transform,
           ],
-          width: width + gutters.paddingLeft,
+          /**
+           * Compensate for the right padding above (2px) to retain intended width.
+           */
+          width: width + gutters.paddingLeft + 2,
           maxHeight: '100%',
           overflowY: 'auto',
         }),
diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx
index 6b49f5834..c8ef49ee7 100644
--- a/src/view/shell/desktop/SidebarTrendingTopics.tsx
+++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx
@@ -82,6 +82,7 @@ function Inner() {
                 <TrendingTopicLink
                   key={topic.link}
                   topic={topic}
+                  style={a.rounded_full}
                   onPress={() => {
                     logEvent('trendingTopic:click', {context: 'sidebar'})
                   }}>