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.tsx102
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx5
-rw-r--r--src/view/com/composer/Prompt.tsx15
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx16
-rw-r--r--src/view/com/composer/photos/Gallery.tsx47
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx11
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx5
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx46
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx21
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx10
-rw-r--r--src/view/com/composer/text-input/mobile/Autocomplete.tsx39
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx11
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts23
13 files changed, 206 insertions, 145 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index e44a0ce01..6f058d39e 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -16,7 +16,6 @@ import LinearGradient from 'react-native-linear-gradient'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {RichText} from '@atproto/api'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
 import {ExternalEmbed} from './ExternalEmbed'
 import {Text} from '../util/text/Text'
@@ -26,9 +25,8 @@ import * as Toast from '../util/Toast'
 import {TextInput, TextInputRef} from './text-input/TextInput'
 import {CharProgress} from './char-progress/CharProgress'
 import {UserAvatar} from '../util/UserAvatar'
-import {useStores} from 'state/index'
 import * as apilib from 'lib/api/index'
-import {ComposerOpts} from 'state/models/ui/shell'
+import {ComposerOpts} from 'state/shell/composer'
 import {s, colors, gradients} from 'lib/styles'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
@@ -49,6 +47,18 @@ import {LabelsBtn} from './labels/LabelsBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
 import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
 import {insertMentionAt} from 'lib/strings/mention-manip'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModals, useModalControls} from '#/state/modals'
+import {useRequireAltTextEnabled} from '#/state/preferences'
+import {
+  useLanguagePrefs,
+  useLanguagePrefsApi,
+  toPostLanguages,
+} from '#/state/preferences/languages'
+import {useSession, getAgent} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useComposerControls} from '#/state/shell/composer'
 
 type Props = ComposerOpts
 export const ComposePost = observer(function ComposePost({
@@ -57,10 +67,18 @@ export const ComposePost = observer(function ComposePost({
   quote: initQuote,
   mention: initMention,
 }: Props) {
+  const {currentAccount} = useSession()
+  const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
+  const {activeModals} = useModals()
+  const {openModal, closeModal} = useModalControls()
+  const {closeComposer} = useComposerControls()
   const {track} = useAnalytics()
   const pal = usePalette('default')
   const {isDesktop, isMobile} = useWebMediaQueries()
-  const store = useStores()
+  const {_} = useLingui()
+  const requireAltTextEnabled = useRequireAltTextEnabled()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
   const textInput = useRef<TextInputRef>(null)
   const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
   const [isProcessing, setIsProcessing] = useState(false)
@@ -86,15 +104,10 @@ export const ComposePost = observer(function ComposePost({
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
   const [labels, setLabels] = useState<string[]>([])
   const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
-  const gallery = useMemo(() => new GalleryModel(store), [store])
+  const gallery = useMemo(() => new GalleryModel(), [])
   const onClose = useCallback(() => {
-    store.shell.closeComposer()
-  }, [store])
-
-  const autocompleteView = useMemo<UserAutocompleteModel>(
-    () => new UserAutocompleteModel(store),
-    [store],
-  )
+    closeComposer()
+  }, [closeComposer])
 
   const insets = useSafeAreaInsets()
   const viewStyles = useMemo(
@@ -108,27 +121,27 @@ export const ComposePost = observer(function ComposePost({
 
   const onPressCancel = useCallback(() => {
     if (graphemeLength > 0 || !gallery.isEmpty) {
-      if (store.shell.activeModals.some(modal => modal.name === 'confirm')) {
-        store.shell.closeModal()
+      if (activeModals.some(modal => modal.name === 'confirm')) {
+        closeModal()
       }
       if (Keyboard) {
         Keyboard.dismiss()
       }
-      store.shell.openModal({
+      openModal({
         name: 'confirm',
-        title: 'Discard draft',
+        title: _(msg`Discard draft`),
         onPressConfirm: onClose,
         onPressCancel: () => {
-          store.shell.closeModal()
+          closeModal()
         },
-        message: "Are you sure you'd like to discard this draft?",
-        confirmBtnText: 'Discard',
+        message: _(msg`Are you sure you'd like to discard this draft?`),
+        confirmBtnText: _(msg`Discard`),
         confirmBtnStyle: {backgroundColor: colors.red4},
       })
     } else {
       onClose()
     }
-  }, [store, onClose, graphemeLength, gallery])
+  }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _])
   // android back button
   useEffect(() => {
     if (!isAndroid) {
@@ -147,11 +160,6 @@ export const ComposePost = observer(function ComposePost({
     }
   }, [onPressCancel])
 
-  // initial setup
-  useEffect(() => {
-    autocompleteView.setup()
-  }, [autocompleteView])
-
   // listen to escape key on desktop web
   const onEscape = useCallback(
     (e: KeyboardEvent) => {
@@ -187,7 +195,7 @@ export const ComposePost = observer(function ComposePost({
     if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
       return
     }
-    if (store.preferences.requireAltTextEnabled && gallery.needsAltText) {
+    if (requireAltTextEnabled && gallery.needsAltText) {
       return
     }
 
@@ -201,7 +209,7 @@ export const ComposePost = observer(function ComposePost({
     setIsProcessing(true)
 
     try {
-      await apilib.post(store, {
+      await apilib.post(getAgent(), {
         rawText: richtext.text,
         replyTo: replyTo?.uri,
         images: gallery.images,
@@ -209,8 +217,7 @@ export const ComposePost = observer(function ComposePost({
         extLink,
         labels,
         onStateChange: setProcessingState,
-        knownHandles: autocompleteView.knownHandles,
-        langs: store.preferences.postLanguages,
+        langs: toPostLanguages(langPrefs.postLanguage),
       })
     } catch (e: any) {
       if (extLink) {
@@ -230,9 +237,9 @@ export const ComposePost = observer(function ComposePost({
       if (replyTo && replyTo.uri) track('Post:Reply')
     }
     if (!replyTo) {
-      store.me.mainFeed.onPostCreated()
+      // TODO onPostCreated
     }
-    store.preferences.savePostLanguageToHistory()
+    setLangPrefs.savePostLanguageToHistory()
     onPost?.()
     onClose()
     Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
@@ -241,12 +248,8 @@ export const ComposePost = observer(function ComposePost({
   const canPost = useMemo(
     () =>
       graphemeLength <= MAX_GRAPHEME_LENGTH &&
-      (!store.preferences.requireAltTextEnabled || !gallery.needsAltText),
-    [
-      graphemeLength,
-      store.preferences.requireAltTextEnabled,
-      gallery.needsAltText,
-    ],
+      (!requireAltTextEnabled || !gallery.needsAltText),
+    [graphemeLength, requireAltTextEnabled, gallery.needsAltText],
   )
   const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
 
@@ -265,9 +268,11 @@ export const ComposePost = observer(function ComposePost({
             onPress={onPressCancel}
             onAccessibilityEscape={onPressCancel}
             accessibilityRole="button"
-            accessibilityLabel="Cancel"
+            accessibilityLabel={_(msg`Cancel`)}
             accessibilityHint="Closes post composer and discards post draft">
-            <Text style={[pal.link, s.f18]}>Cancel</Text>
+            <Text style={[pal.link, s.f18]}>
+              <Trans>Cancel</Trans>
+            </Text>
           </TouchableOpacity>
           <View style={s.flex1} />
           {isProcessing ? (
@@ -308,13 +313,15 @@ export const ComposePost = observer(function ComposePost({
                 </TouchableOpacity>
               ) : (
                 <View style={[styles.postBtn, pal.btn]}>
-                  <Text style={[pal.textLight, s.f16, s.bold]}>Post</Text>
+                  <Text style={[pal.textLight, s.f16, s.bold]}>
+                    <Trans>Post</Trans>
+                  </Text>
                 </View>
               )}
             </>
           )}
         </View>
-        {store.preferences.requireAltTextEnabled && gallery.needsAltText && (
+        {requireAltTextEnabled && gallery.needsAltText && (
           <View style={[styles.reminderLine, pal.viewLight]}>
             <View style={styles.errorIcon}>
               <FontAwesomeIcon
@@ -324,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
               />
             </View>
             <Text style={[pal.text, s.flex1]}>
-              One or more images is missing alt text.
+              <Trans>One or more images is missing alt text.</Trans>
             </Text>
           </View>
         )}
@@ -366,13 +373,12 @@ export const ComposePost = observer(function ComposePost({
               styles.textInputLayout,
               isNative && styles.textInputLayoutMobile,
             ]}>
-            <UserAvatar avatar={store.me.avatar} size={50} />
+            <UserAvatar avatar={currentProfile?.avatar} size={50} />
             <TextInput
               ref={textInput}
               richtext={richtext}
               placeholder={selectTextInputPlaceholder}
               suggestedLinks={suggestedLinks}
-              autocompleteView={autocompleteView}
               autoFocus={true}
               setRichText={setRichText}
               onPhotoPasted={onPhotoPasted}
@@ -380,7 +386,7 @@ export const ComposePost = observer(function ComposePost({
               onSuggestedLinksChanged={setSuggestedLinks}
               onError={setError}
               accessible={true}
-              accessibilityLabel="Write post"
+              accessibilityLabel={_(msg`Write post`)}
               accessibilityHint={`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`}
             />
           </View>
@@ -409,11 +415,11 @@ export const ComposePost = observer(function ComposePost({
                   style={[pal.borderDark, styles.addExtLinkBtn]}
                   onPress={() => onPressAddLinkCard(url)}
                   accessibilityRole="button"
-                  accessibilityLabel="Add link card"
+                  accessibilityLabel={_(msg`Add link card`)}
                   accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
                   <Text style={pal.text}>
-                    Add link card:{' '}
-                    <Text style={pal.link}>{toShortUrl(url)}</Text>
+                    <Trans>Add link card:</Trans>
+                    <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text>
                   </Text>
                 </TouchableOpacity>
               ))}
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index c9200ec63..502e4b4d2 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -11,6 +11,8 @@ import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ExternalEmbedDraft} from 'lib/api/index'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 export const ExternalEmbed = ({
   link,
@@ -21,6 +23,7 @@ export const ExternalEmbed = ({
 }) => {
   const pal = usePalette('default')
   const palError = usePalette('error')
+  const {_} = useLingui()
   if (!link) {
     return <View />
   }
@@ -64,7 +67,7 @@ export const ExternalEmbed = ({
         style={styles.removeBtn}
         onPress={onRemove}
         accessibilityRole="button"
-        accessibilityLabel="Remove image preview"
+        accessibilityLabel={_(msg`Remove image preview`)}
         accessibilityHint={`Removes default thumbnail from ${link.uri}`}
         onAccessibilityEscape={onRemove}>
         <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx
index e54404f52..ae055f9ac 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/composer/Prompt.tsx
@@ -3,12 +3,17 @@ import {StyleSheet, TouchableOpacity} from 'react-native'
 import {UserAvatar} from '../util/UserAvatar'
 import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSession} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
 
 export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
-  const store = useStores()
+  const {currentAccount} = useSession()
+  const {data: profile} = useProfileQuery({did: currentAccount?.did})
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isDesktop} = useWebMediaQueries()
   return (
     <TouchableOpacity
@@ -16,16 +21,16 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
       style={[pal.view, pal.border, styles.prompt]}
       onPress={() => onPressCompose()}
       accessibilityRole="button"
-      accessibilityLabel="Compose reply"
+      accessibilityLabel={_(msg`Compose reply`)}
       accessibilityHint="Opens composer">
-      <UserAvatar avatar={store.me.avatar} size={38} />
+      <UserAvatar avatar={profile?.avatar} size={38} />
       <Text
         type="xl"
         style={[
           pal.text,
           isDesktop ? styles.labelDesktopWeb : styles.labelMobile,
         ]}>
-        Write your reply
+        <Trans>Write your reply</Trans>
       </Text>
     </TouchableOpacity>
   )
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index 96908d47f..a10684691 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -1,15 +1,16 @@
 import React from 'react'
 import {Keyboard, StyleSheet} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {Button} from 'view/com/util/forms/Button'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {ShieldExclamation} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {isNative} from 'platform/detection'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useModalControls} from '#/state/modals'
 
-export const LabelsBtn = observer(function LabelsBtn({
+export function LabelsBtn({
   labels,
   hasMedia,
   onChange,
@@ -19,14 +20,15 @@ export const LabelsBtn = observer(function LabelsBtn({
   onChange: (v: string[]) => void
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
 
   return (
     <Button
       type="default-light"
       testID="labelsBtn"
       style={[styles.button, !hasMedia && styles.dimmed]}
-      accessibilityLabel="Content warnings"
+      accessibilityLabel={_(msg`Content warnings`)}
       accessibilityHint=""
       onPress={() => {
         if (isNative) {
@@ -34,7 +36,7 @@ export const LabelsBtn = observer(function LabelsBtn({
             Keyboard.dismiss()
           }
         }
-        store.shell.openModal({name: 'self-label', labels, hasMedia, onChange})
+        openModal({name: 'self-label', labels, hasMedia, onChange})
       }}>
       <ShieldExclamation style={pal.link} size={26} />
       {labels.length > 0 ? (
@@ -46,7 +48,7 @@ export const LabelsBtn = observer(function LabelsBtn({
       ) : null}
     </Button>
   )
-})
+}
 
 const styles = StyleSheet.create({
   button: {
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index fcd99842a..69c8debb0 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -7,11 +7,13 @@ import {s, colors} from 'lib/styles'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {Image} from 'expo-image'
 import {Text} from 'view/com/util/text/Text'
-import {openAltTextModal} from 'lib/media/alt-text'
 import {Dimensions} from 'lib/media/types'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {isNative} from 'platform/detection'
 
 const IMAGE_GAP = 8
 
@@ -47,9 +49,10 @@ const GalleryInner = observer(function GalleryImpl({
   gallery,
   containerInfo,
 }: GalleryInnerProps) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
 
   let side: number
 
@@ -113,15 +116,18 @@ const GalleryInner = observer(function GalleryImpl({
             <TouchableOpacity
               testID="altTextButton"
               accessibilityRole="button"
-              accessibilityLabel="Add alt text"
+              accessibilityLabel={_(msg`Add alt text`)}
               accessibilityHint=""
               onPress={() => {
                 Keyboard.dismiss()
-                openAltTextModal(store, image)
+                openModal({
+                  name: 'alt-text-image',
+                  image,
+                })
               }}
               style={[styles.altTextControl, altTextControlStyle]}>
               <Text style={styles.altTextControlLabel} accessible={false}>
-                ALT
+                <Trans>ALT</Trans>
               </Text>
               {image.altText.length > 0 ? (
                 <FontAwesomeIcon
@@ -135,9 +141,19 @@ const GalleryInner = observer(function GalleryImpl({
               <TouchableOpacity
                 testID="editPhotoButton"
                 accessibilityRole="button"
-                accessibilityLabel="Edit image"
+                accessibilityLabel={_(msg`Edit image`)}
                 accessibilityHint=""
-                onPress={() => gallery.edit(image)}
+                onPress={() => {
+                  if (isNative) {
+                    gallery.crop(image)
+                  } else {
+                    openModal({
+                      name: 'edit-image',
+                      image,
+                      gallery,
+                    })
+                  }
+                }}
                 style={styles.imageControl}>
                 <FontAwesomeIcon
                   icon="pen"
@@ -148,7 +164,7 @@ const GalleryInner = observer(function GalleryImpl({
               <TouchableOpacity
                 testID="removePhotoButton"
                 accessibilityRole="button"
-                accessibilityLabel="Remove image"
+                accessibilityLabel={_(msg`Remove image`)}
                 accessibilityHint=""
                 onPress={() => gallery.remove(image)}
                 style={styles.imageControl}>
@@ -161,11 +177,14 @@ const GalleryInner = observer(function GalleryImpl({
             </View>
             <TouchableOpacity
               accessibilityRole="button"
-              accessibilityLabel="Add alt text"
+              accessibilityLabel={_(msg`Add alt text`)}
               accessibilityHint=""
               onPress={() => {
                 Keyboard.dismiss()
-                openAltTextModal(store, image)
+                openModal({
+                  name: 'alt-text-image',
+                  image,
+                })
               }}
               style={styles.altTextHiddenRegion}
             />
@@ -187,8 +206,10 @@ const GalleryInner = observer(function GalleryImpl({
           <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} />
         </View>
         <Text type="sm" style={[pal.textLight, s.flex1]}>
-          Alt text describes images for blind and low-vision users, and helps
-          give context to everyone.
+          <Trans>
+            Alt text describes images for blind and low-vision users, and helps
+            give context to everyone.
+          </Trans>
         </Text>
       </View>
     </>
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 99e820d51..69f63c55f 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -6,13 +6,14 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {useStores} from 'state/index'
 import {openCamera} from 'lib/media/picker'
 import {useCameraPermission} from 'lib/hooks/usePermissions'
 import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
 import {GalleryModel} from 'state/models/media/gallery'
 import {isMobileWeb, isNative} from 'platform/detection'
 import {logger} from '#/logger'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 type Props = {
   gallery: GalleryModel
@@ -21,7 +22,7 @@ type Props = {
 export function OpenCameraBtn({gallery}: Props) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
-  const store = useStores()
+  const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
 
   const onPressTakePicture = useCallback(async () => {
@@ -31,7 +32,7 @@ export function OpenCameraBtn({gallery}: Props) {
         return
       }
 
-      const img = await openCamera(store, {
+      const img = await openCamera({
         width: POST_IMG_MAX.width,
         height: POST_IMG_MAX.height,
         freeStyleCropEnabled: true,
@@ -42,7 +43,7 @@ export function OpenCameraBtn({gallery}: Props) {
       // ignore
       logger.warn('Error using camera', {error: err})
     }
-  }, [gallery, track, store, requestCameraAccessIfNeeded])
+  }, [gallery, track, requestCameraAccessIfNeeded])
 
   const shouldShowCameraButton = isNative || isMobileWeb
   if (!shouldShowCameraButton) {
@@ -56,7 +57,7 @@ export function OpenCameraBtn({gallery}: Props) {
       style={styles.button}
       hitSlop={HITSLOP_10}
       accessibilityRole="button"
-      accessibilityLabel="Camera"
+      accessibilityLabel={_(msg`Camera`)}
       accessibilityHint="Opens camera on device">
       <FontAwesomeIcon
         icon="camera"
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index a6826eb98..af0a22b01 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -10,6 +10,8 @@ import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
 import {GalleryModel} from 'state/models/media/gallery'
 import {HITSLOP_10} from 'lib/constants'
 import {isNative} from 'platform/detection'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 type Props = {
   gallery: GalleryModel
@@ -18,6 +20,7 @@ type Props = {
 export function SelectPhotoBtn({gallery}: Props) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
+  const {_} = useLingui()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
 
   const onPressSelectPhotos = useCallback(async () => {
@@ -37,7 +40,7 @@ export function SelectPhotoBtn({gallery}: Props) {
       style={styles.button}
       hitSlop={HITSLOP_10}
       accessibilityRole="button"
-      accessibilityLabel="Gallery"
+      accessibilityLabel={_(msg`Gallery`)}
       accessibilityHint="Opens device photo gallery">
       <FontAwesomeIcon
         icon={['far', 'image']}
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
index 4faac3750..78b1e9ba2 100644
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -1,6 +1,5 @@
 import React, {useCallback, useMemo} from 'react'
 import {StyleSheet, Keyboard} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -12,13 +11,24 @@ import {
   DropdownItemButton,
 } from 'view/com/util/forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {isNative} from 'platform/detection'
 import {codeToLanguageName} from '../../../../locale/helpers'
+import {useModalControls} from '#/state/modals'
+import {
+  useLanguagePrefs,
+  useLanguagePrefsApi,
+  toPostLanguages,
+  hasPostLanguage,
+} from '#/state/preferences/languages'
+import {t, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-export const SelectLangBtn = observer(function SelectLangBtn() {
+export function SelectLangBtn() {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
 
   const onPressMore = useCallback(async () => {
     if (isNative) {
@@ -26,11 +36,10 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
         Keyboard.dismiss()
       }
     }
-    store.shell.openModal({name: 'post-languages-settings'})
-  }, [store])
+    openModal({name: 'post-languages-settings'})
+  }, [openModal])
 
-  const postLanguagesPref = store.preferences.postLanguages
-  const postLanguagePref = store.preferences.postLanguage
+  const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
   const items: DropdownItem[] = useMemo(() => {
     let arr: DropdownItemButton[] = []
 
@@ -49,13 +58,14 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
 
       arr.push({
         icon:
-          langCodes.every(code => store.preferences.hasPostLanguage(code)) &&
-          langCodes.length === postLanguagesPref.length
+          langCodes.every(code =>
+            hasPostLanguage(langPrefs.postLanguage, code),
+          ) && langCodes.length === postLanguagesPref.length
             ? ['fas', 'circle-dot']
             : ['far', 'circle'],
         label: langName,
         onPress() {
-          store.preferences.setPostLanguage(commaSeparatedLangCodes)
+          setLangPrefs.setPostLanguage(commaSeparatedLangCodes)
         },
       })
     }
@@ -65,24 +75,24 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
        * Re-join here after sanitization bc postLanguageHistory is an array of
        * comma-separated strings too
        */
-      add(postLanguagePref)
+      add(langPrefs.postLanguage)
     }
 
     // comma-separted strings of lang codes that have been used in the past
-    for (const lang of store.preferences.postLanguageHistory) {
+    for (const lang of langPrefs.postLanguageHistory) {
       add(lang)
     }
 
     return [
-      {heading: true, label: 'Post language'},
+      {heading: true, label: t`Post language`},
       ...arr.slice(0, 6),
       {sep: true},
       {
-        label: 'Other...',
+        label: t`Other...`,
         onPress: onPressMore,
       },
     ]
-  }, [store.preferences, onPressMore, postLanguagePref, postLanguagesPref])
+  }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref])
 
   return (
     <DropdownButton
@@ -91,7 +101,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
       items={items}
       openUpwards
       style={styles.button}
-      accessibilityLabel="Language selection"
+      accessibilityLabel={_(msg`Language selection`)}
       accessibilityHint="">
       {postLanguagesPref.length > 0 ? (
         <Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}>
@@ -106,7 +116,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
       )}
     </DropdownButton>
   )
-})
+}
 
 const styles = StyleSheet.create({
   button: {
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 2810129f6..13fe3a0b3 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -3,6 +3,7 @@ import React, {
   useCallback,
   useRef,
   useMemo,
+  useState,
   ComponentProps,
 } from 'react'
 import {
@@ -18,7 +19,6 @@ import PasteInput, {
 } from '@mattermost/react-native-paste-input'
 import {AppBskyRichtextFacet, RichText} from '@atproto/api'
 import isEqual from 'lodash.isequal'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {Autocomplete} from './mobile/Autocomplete'
 import {Text} from 'view/com/util/text/Text'
 import {cleanError} from 'lib/strings/errors'
@@ -38,7 +38,6 @@ interface TextInputProps extends ComponentProps<typeof RNTextInput> {
   richtext: RichText
   placeholder: string
   suggestedLinks: Set<string>
-  autocompleteView: UserAutocompleteModel
   setRichText: (v: RichText | ((v: RichText) => RichText)) => void
   onPhotoPasted: (uri: string) => void
   onPressPublish: (richtext: RichText) => Promise<void>
@@ -56,7 +55,6 @@ export const TextInput = forwardRef(function TextInputImpl(
     richtext,
     placeholder,
     suggestedLinks,
-    autocompleteView,
     setRichText,
     onPhotoPasted,
     onSuggestedLinksChanged,
@@ -69,6 +67,7 @@ export const TextInput = forwardRef(function TextInputImpl(
   const textInput = useRef<PasteInputRef>(null)
   const textInputSelection = useRef<Selection>({start: 0, end: 0})
   const theme = useTheme()
+  const [autocompletePrefix, setAutocompletePrefix] = useState('')
 
   React.useImperativeHandle(ref, () => ({
     focus: () => textInput.current?.focus(),
@@ -99,10 +98,9 @@ export const TextInput = forwardRef(function TextInputImpl(
           textInputSelection.current?.start || 0,
         )
         if (prefix) {
-          autocompleteView.setActive(true)
-          autocompleteView.setPrefix(prefix.value)
-        } else {
-          autocompleteView.setActive(false)
+          setAutocompletePrefix(prefix.value)
+        } else if (autocompletePrefix) {
+          setAutocompletePrefix('')
         }
 
         const set: Set<string> = new Set()
@@ -139,7 +137,8 @@ export const TextInput = forwardRef(function TextInputImpl(
     },
     [
       setRichText,
-      autocompleteView,
+      autocompletePrefix,
+      setAutocompletePrefix,
       suggestedLinks,
       onSuggestedLinksChanged,
       onPhotoPasted,
@@ -179,9 +178,9 @@ export const TextInput = forwardRef(function TextInputImpl(
           item,
         ),
       )
-      autocompleteView.setActive(false)
+      setAutocompletePrefix('')
     },
-    [onChangeText, richtext, autocompleteView],
+    [onChangeText, richtext, setAutocompletePrefix],
   )
 
   const textDecorated = useMemo(() => {
@@ -221,7 +220,7 @@ export const TextInput = forwardRef(function TextInputImpl(
         {textDecorated}
       </PasteInput>
       <Autocomplete
-        view={autocompleteView}
+        prefix={autocompletePrefix}
         onSelect={onSelectAutocompleteItem}
       />
     </View>
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 35482bc70..4c31da338 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -11,13 +11,13 @@ import {Paragraph} from '@tiptap/extension-paragraph'
 import {Placeholder} from '@tiptap/extension-placeholder'
 import {Text} from '@tiptap/extension-text'
 import isEqual from 'lodash.isequal'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {createSuggestion} from './web/Autocomplete'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {isUriImage, blobToDataUri} from 'lib/media/util'
 import {Emoji} from './web/EmojiPicker.web'
 import {LinkDecorator} from './web/LinkDecorator'
 import {generateJSON} from '@tiptap/html'
+import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 
 export interface TextInputRef {
   focus: () => void
@@ -28,7 +28,6 @@ interface TextInputProps {
   richtext: RichText
   placeholder: string
   suggestedLinks: Set<string>
-  autocompleteView: UserAutocompleteModel
   setRichText: (v: RichText | ((v: RichText) => RichText)) => void
   onPhotoPasted: (uri: string) => void
   onPressPublish: (richtext: RichText) => Promise<void>
@@ -43,7 +42,6 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     richtext,
     placeholder,
     suggestedLinks,
-    autocompleteView,
     setRichText,
     onPhotoPasted,
     onPressPublish,
@@ -52,6 +50,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   TextInputProps,
   ref,
 ) {
+  const autocomplete = useActorAutocompleteFn()
+
   const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
   const extensions = React.useMemo(
     () => [
@@ -61,7 +61,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
         HTMLAttributes: {
           class: 'mention',
         },
-        suggestion: createSuggestion({autocompleteView}),
+        suggestion: createSuggestion({autocomplete}),
       }),
       Paragraph,
       Placeholder.configure({
@@ -71,7 +71,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       History,
       Hardbreak,
     ],
-    [autocompleteView, placeholder],
+    [autocomplete, placeholder],
   )
 
   React.useEffect(() => {
diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
index f8335d4b9..c400aa48d 100644
--- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
@@ -1,31 +1,40 @@
-import React, {useEffect} from 'react'
+import React, {useEffect, useRef} from 'react'
 import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {useGrapheme} from '../hooks/useGrapheme'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
+import {Trans} from '@lingui/macro'
+import {AppBskyActorDefs} from '@atproto/api'
 
-export const Autocomplete = observer(function AutocompleteImpl({
-  view,
+export function Autocomplete({
+  prefix,
   onSelect,
 }: {
-  view: UserAutocompleteModel
+  prefix: string
   onSelect: (item: string) => void
 }) {
   const pal = usePalette('default')
   const positionInterp = useAnimatedValue(0)
   const {getGraphemeString} = useGrapheme()
+  const isActive = !!prefix
+  const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix)
+  const suggestionsRef = useRef<
+    AppBskyActorDefs.ProfileViewBasic[] | undefined
+  >(undefined)
+  if (suggestions) {
+    suggestionsRef.current = suggestions
+  }
 
   useEffect(() => {
     Animated.timing(positionInterp, {
-      toValue: view.isActive ? 1 : 0,
+      toValue: isActive ? 1 : 0,
       duration: 200,
       useNativeDriver: true,
     }).start()
-  }, [positionInterp, view.isActive])
+  }, [positionInterp, isActive])
 
   const topAnimStyle = {
     transform: [
@@ -40,10 +49,10 @@ export const Autocomplete = observer(function AutocompleteImpl({
 
   return (
     <Animated.View style={topAnimStyle}>
-      {view.isActive ? (
+      {isActive ? (
         <View style={[pal.view, styles.container, pal.border]}>
-          {view.suggestions.length > 0 ? (
-            view.suggestions.slice(0, 5).map(item => {
+          {suggestionsRef.current?.length ? (
+            suggestionsRef.current.slice(0, 5).map(item => {
               // Eventually use an average length
               const MAX_CHARS = 40
               const MAX_HANDLE_CHARS = 20
@@ -82,14 +91,18 @@ export const Autocomplete = observer(function AutocompleteImpl({
             })
           ) : (
             <Text type="sm" style={[pal.text, pal.border, styles.noResults]}>
-              No result
+              {isFetching ? (
+                <Trans>Loading...</Trans>
+              ) : (
+                <Trans>No result</Trans>
+              )}
             </Text>
           )}
         </View>
       ) : null}
     </Animated.View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index bbed26d48..1f7412561 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -12,7 +12,7 @@ import {
   SuggestionProps,
   SuggestionKeyDownProps,
 } from '@tiptap/suggestion'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
+import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
@@ -23,15 +23,14 @@ interface MentionListRef {
 }
 
 export function createSuggestion({
-  autocompleteView,
+  autocomplete,
 }: {
-  autocompleteView: UserAutocompleteModel
+  autocomplete: ActorAutocompleteFn
 }): Omit<SuggestionOptions, 'editor'> {
   return {
     async items({query}) {
-      autocompleteView.setActive(true)
-      await autocompleteView.setPrefix(query)
-      return autocompleteView.suggestions.slice(0, 8)
+      const suggestions = await autocomplete({query})
+      return suggestions.slice(0, 8)
     },
 
     render: () => {
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index eda1a6704..ef3958c9d 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -1,5 +1,4 @@
 import {useState, useEffect} from 'react'
-import {useStores} from 'state/index'
 import {ImageModel} from 'state/models/media/image'
 import * as apilib from 'lib/api/index'
 import {getLinkMeta} from 'lib/link-meta/link-meta'
@@ -14,19 +13,21 @@ import {
   isBskyCustomFeedUrl,
   isBskyListUrl,
 } from 'lib/strings/url-helpers'
-import {ComposerOpts} from 'state/models/ui/shell'
+import {ComposerOpts} from 'state/shell/composer'
 import {POST_IMG_MAX} from 'lib/constants'
 import {logger} from '#/logger'
+import {getAgent} from '#/state/session'
+import {useGetPost} from '#/state/queries/post'
 
 export function useExternalLinkFetch({
   setQuote,
 }: {
   setQuote: (opts: ComposerOpts['quote']) => void
 }) {
-  const store = useStores()
   const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
     undefined,
   )
+  const getPost = useGetPost()
 
   useEffect(() => {
     let aborted = false
@@ -38,7 +39,7 @@ export function useExternalLinkFetch({
     }
     if (!extLink.meta) {
       if (isBskyPostUrl(extLink.uri)) {
-        getPostAsQuote(store, extLink.uri).then(
+        getPostAsQuote(getPost, extLink.uri).then(
           newQuote => {
             if (aborted) {
               return
@@ -48,13 +49,13 @@ export function useExternalLinkFetch({
           },
           err => {
             logger.error('Failed to fetch post for quote embedding', {
-              error: err,
+              error: err.toString(),
             })
             setExtLink(undefined)
           },
         )
       } else if (isBskyCustomFeedUrl(extLink.uri)) {
-        getFeedAsEmbed(store, extLink.uri).then(
+        getFeedAsEmbed(getAgent(), extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -72,7 +73,7 @@ export function useExternalLinkFetch({
           },
         )
       } else if (isBskyListUrl(extLink.uri)) {
-        getListAsEmbed(store, extLink.uri).then(
+        getListAsEmbed(getAgent(), extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -90,7 +91,7 @@ export function useExternalLinkFetch({
           },
         )
       } else {
-        getLinkMeta(store, extLink.uri).then(meta => {
+        getLinkMeta(getAgent(), extLink.uri).then(meta => {
           if (aborted) {
             return
           }
@@ -120,9 +121,7 @@ export function useExternalLinkFetch({
           setExtLink({
             ...extLink,
             isLoading: false, // done
-            localThumb: localThumb
-              ? new ImageModel(store, localThumb)
-              : undefined,
+            localThumb: localThumb ? new ImageModel(localThumb) : undefined,
           })
         })
       return cleanup
@@ -134,7 +133,7 @@ export function useExternalLinkFetch({
       })
     }
     return cleanup
-  }, [store, extLink, setQuote])
+  }, [extLink, setQuote, getPost])
 
   return {extLink, setExtLink}
 }