about summary refs log tree commit diff
path: root/src/view/com/composer
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/composer')
-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
8 files changed, 682 insertions, 462 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,
-  },
-})