about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-11-14 10:41:55 -0800
committerGitHub <noreply@github.com>2023-11-14 10:41:55 -0800
commit0a26e78dcbbf48dad5daae73b210e236d706b22c (patch)
treec06c737ed49e8294bf5cbec1a75c36b591cb6669 /src
parentc687172de96bd6aa85d3aa025c2e0f024640f345 (diff)
downloadvoidsky-0a26e78dcbbf48dad5daae73b210e236d706b22c.tar.zst
Composer update (react-query refactor) (#1899)
* Move composer state to a context

* Rework composer to use RQ

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src')
-rw-r--r--src/lib/api/index.ts13
-rw-r--r--src/lib/link-meta/bsky.ts2
-rw-r--r--src/lib/media/picker.tsx11
-rw-r--r--src/lib/media/picker.web.tsx11
-rw-r--r--src/state/models/media/gallery.ts9
-rw-r--r--src/state/models/media/image.ts9
-rw-r--r--src/state/models/ui/shell.ts52
-rw-r--r--src/state/queries/actor-autocomplete.ts54
-rw-r--r--src/state/shell/composer.tsx74
-rw-r--r--src/state/shell/index.tsx5
-rw-r--r--src/view/com/composer/Composer.tsx33
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx21
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx20
-rw-r--r--src/view/com/composer/text-input/mobile/Autocomplete.tsx18
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx11
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts2
-rw-r--r--src/view/com/feeds/FeedPage.tsx6
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx15
-rw-r--r--src/view/com/post/Post.tsx8
-rw-r--r--src/view/com/posts/FeedItem.tsx8
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx8
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx2
-rw-r--r--src/view/screens/Feeds.tsx8
-rw-r--r--src/view/screens/PostThread.tsx8
-rw-r--r--src/view/screens/Profile.tsx6
-rw-r--r--src/view/screens/ProfileFeed.tsx6
-rw-r--r--src/view/screens/ProfileList.tsx8
-rw-r--r--src/view/shell/Composer.tsx27
-rw-r--r--src/view/shell/Composer.web.tsx31
-rw-r--r--src/view/shell/desktop/LeftNav.tsx4
-rw-r--r--src/view/shell/index.tsx9
-rw-r--r--src/view/shell/index.web.tsx9
32 files changed, 269 insertions, 239 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index a98834888..92620c459 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -82,12 +82,11 @@ interface PostOpts {
   extLink?: ExternalEmbedDraft
   images?: ImageModel[]
   labels?: string[]
-  knownHandles?: Set<string>
   onStateChange?: (state: string) => void
   langs?: string[]
 }
 
-export async function post(store: RootStoreModel, opts: PostOpts) {
+export async function post(agent: BskyAgent, opts: PostOpts) {
   let embed:
     | AppBskyEmbedImages.Main
     | AppBskyEmbedExternal.Main
@@ -103,7 +102,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
   )
 
   opts.onStateChange?.('Processing...')
-  await rt.detectFacets(store.agent)
+  await rt.detectFacets(agent)
   rt = shortenLinks(rt)
 
   // filter out any mention facets that didn't map to a user
@@ -136,7 +135,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
       await image.compress()
       const path = image.compressed?.path ?? image.path
       const {width, height} = image.compressed || image
-      const res = await uploadBlob(store.agent, path, 'image/jpeg')
+      const res = await uploadBlob(agent, path, 'image/jpeg')
       images.push({
         image: res.data.blob,
         alt: image.altText ?? '',
@@ -186,7 +185,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
         }
         if (encoding) {
           const thumbUploadRes = await uploadBlob(
-            store.agent,
+            agent,
             opts.extLink.localThumb.path,
             encoding,
           )
@@ -225,7 +224,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
   // add replyTo if post is a reply to another post
   if (opts.replyTo) {
     const replyToUrip = new AtUri(opts.replyTo)
-    const parentPost = await store.agent.getPost({
+    const parentPost = await agent.getPost({
       repo: replyToUrip.host,
       rkey: replyToUrip.rkey,
     })
@@ -258,7 +257,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
 
   try {
     opts.onStateChange?.('Posting...')
-    return await store.agent.post({
+    return await agent.post({
       text: rt.text,
       facets: rt.facets,
       reply,
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index b052ed04b..2134c3292 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -4,7 +4,7 @@ import {LikelyType, LinkMeta} from './link-meta'
 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
 import {RootStoreModel} from 'state/index'
 import {PostThreadModel} from 'state/models/content/post-thread'
-import {ComposerOptsQuote} from 'state/models/ui/shell'
+import {ComposerOptsQuote} from 'state/shell/composer'
 
 // TODO
 // import {Home} from 'view/screens/Home'
diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx
index d0ee1ae22..e91a5b8dd 100644
--- a/src/lib/media/picker.tsx
+++ b/src/lib/media/picker.tsx
@@ -3,7 +3,6 @@ import {
   openCropper as openCropperFn,
   Image as RNImage,
 } from 'react-native-image-crop-picker'
-import {RootStoreModel} from 'state/index'
 import {CameraOpts, CropperOptions} from './types'
 export {openPicker} from './picker.shared'
 
@@ -16,10 +15,7 @@ export {openPicker} from './picker.shared'
  * -prf
  */
 
-export async function openCamera(
-  _store: RootStoreModel,
-  opts: CameraOpts,
-): Promise<RNImage> {
+export async function openCamera(opts: CameraOpts): Promise<RNImage> {
   const item = await openCameraFn({
     width: opts.width,
     height: opts.height,
@@ -39,10 +35,7 @@ export async function openCamera(
   }
 }
 
-export async function openCropper(
-  _store: RootStoreModel,
-  opts: CropperOptions,
-) {
+export async function openCropper(opts: CropperOptions) {
   const item = await openCropperFn({
     ...opts,
     forceJpg: true, // ios only
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx
index 50b9c73e9..995a0c95f 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -1,23 +1,16 @@
 /// <reference lib="dom" />
 
 import {CameraOpts, CropperOptions} from './types'
-import {RootStoreModel} from 'state/index'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 export {openPicker} from './picker.shared'
 import {unstable__openModal} from '#/state/modals'
 
-export async function openCamera(
-  _store: RootStoreModel,
-  _opts: CameraOpts,
-): Promise<RNImage> {
+export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
   // const mediaType = opts.mediaType || 'photo' TODO
   throw new Error('TODO')
 }
 
-export async function openCropper(
-  _store: RootStoreModel,
-  opts: CropperOptions,
-): Promise<RNImage> {
+export async function openCropper(opts: CropperOptions): Promise<RNImage> {
   // TODO handle more opts
   return new Promise((resolve, reject) => {
     unstable__openModal({
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index f9c3efcad..04023bf82 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -1,5 +1,4 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {RootStoreModel} from 'state/index'
 import {ImageModel} from './image'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {openPicker} from 'lib/media/picker'
@@ -8,10 +7,8 @@ import {getImageDim} from 'lib/media/manip'
 export class GalleryModel {
   images: ImageModel[] = []
 
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(this, {
-      rootStore: false,
-    })
+  constructor() {
+    makeAutoObservable(this)
   }
 
   get isEmpty() {
@@ -33,7 +30,7 @@ export class GalleryModel {
 
     // Temporarily enforce uniqueness but can eventually also use index
     if (!this.images.some(i => i.path === image_.path)) {
-      const image = new ImageModel(this.rootStore, image_)
+      const image = new ImageModel(image_)
 
       // Initial resize
       image.manipulate({})
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index b3796060c..6a226484e 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -1,5 +1,4 @@
 import {Image as RNImage} from 'react-native-image-crop-picker'
-import {RootStoreModel} from 'state/index'
 import {makeAutoObservable, runInAction} from 'mobx'
 import {POST_IMG_MAX} from 'lib/constants'
 import * as ImageManipulator from 'expo-image-manipulator'
@@ -42,10 +41,8 @@ export class ImageModel implements Omit<RNImage, 'size'> {
   }
   prevAttributes: ImageManipulationAttributes = {}
 
-  constructor(public rootStore: RootStoreModel, image: Omit<RNImage, 'size'>) {
-    makeAutoObservable(this, {
-      rootStore: false,
-    })
+  constructor(image: Omit<RNImage, 'size'>) {
+    makeAutoObservable(this)
 
     this.path = image.path
     this.width = image.width
@@ -178,7 +175,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
         height: this.height,
       })
 
-      const cropped = await openCropper(this.rootStore, {
+      const cropped = await openCropper({
         mediaType: 'photo',
         path: this.path,
         freeStyleCropEnabled: true,
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 9ce9b6635..310d4f0f9 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -1,4 +1,4 @@
-import {AppBskyEmbedRecord, AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
@@ -37,41 +37,9 @@ export class ImagesLightbox implements LightboxModel {
   }
 }
 
-export interface ComposerOptsPostRef {
-  uri: string
-  cid: string
-  text: string
-  author: {
-    handle: string
-    displayName?: string
-    avatar?: string
-  }
-}
-export interface ComposerOptsQuote {
-  uri: string
-  cid: string
-  text: string
-  indexedAt: string
-  author: {
-    did: string
-    handle: string
-    displayName?: string
-    avatar?: string
-  }
-  embeds?: AppBskyEmbedRecord.ViewRecord['embeds']
-}
-export interface ComposerOpts {
-  replyTo?: ComposerOptsPostRef
-  onPost?: () => void
-  quote?: ComposerOptsQuote
-  mention?: string // handle of user to mention
-}
-
 export class ShellUiModel {
   isLightboxActive = false
   activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null
-  isComposerActive = false
-  composerOpts: ComposerOpts | undefined
   tickEveryMinute = Date.now()
 
   constructor(public rootStore: RootStoreModel) {
@@ -92,10 +60,6 @@ export class ShellUiModel {
       this.closeLightbox()
       return true
     }
-    if (this.isComposerActive) {
-      this.closeComposer()
-      return true
-    }
     return false
   }
 
@@ -106,9 +70,6 @@ export class ShellUiModel {
     if (this.isLightboxActive) {
       this.closeLightbox()
     }
-    if (this.isComposerActive) {
-      this.closeComposer()
-    }
   }
 
   openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) {
@@ -122,17 +83,6 @@ export class ShellUiModel {
     this.activeLightbox = null
   }
 
-  openComposer(opts: ComposerOpts) {
-    this.rootStore.emitNavigation()
-    this.isComposerActive = true
-    this.composerOpts = opts
-  }
-
-  closeComposer() {
-    this.isComposerActive = false
-    this.composerOpts = undefined
-  }
-
   setupClock() {
     setInterval(() => {
       runInAction(() => {
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 18abb6314..62c4781c4 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -1,7 +1,8 @@
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, BskyAgent} from '@atproto/api'
 import {useQuery} from '@tanstack/react-query'
 import {useSession} from '../session'
 import {useMyFollowsQuery} from './my-follows'
+import AwaitLock from 'await-lock'
 
 export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
 
@@ -21,6 +22,57 @@ export function useActorAutocompleteQuery(prefix: string) {
   })
 }
 
+export class ActorAutocomplete {
+  // state
+  isLoading = false
+  isActive = false
+  prefix = ''
+  lock = new AwaitLock()
+
+  // data
+  suggestions: AppBskyActorDefs.ProfileViewBasic[] = []
+
+  constructor(
+    public agent: BskyAgent,
+    public follows?: AppBskyActorDefs.ProfileViewBasic[] | undefined,
+  ) {}
+
+  setFollows(follows: AppBskyActorDefs.ProfileViewBasic[]) {
+    this.follows = follows
+  }
+
+  async query(prefix: string) {
+    const origPrefix = prefix.trim().toLocaleLowerCase()
+    this.prefix = origPrefix
+    await this.lock.acquireAsync()
+    try {
+      if (this.prefix) {
+        if (this.prefix !== origPrefix) {
+          return // another prefix was set before we got our chance
+        }
+
+        // start with follow results
+        this.suggestions = computeSuggestions(this.prefix, this.follows)
+
+        // ask backend
+        const res = await this.agent.searchActorsTypeahead({
+          term: this.prefix,
+          limit: 8,
+        })
+        this.suggestions = computeSuggestions(
+          this.prefix,
+          this.follows,
+          res.data.actors,
+        )
+      } else {
+        this.suggestions = computeSuggestions(this.prefix, this.follows)
+      }
+    } finally {
+      this.lock.release()
+    }
+  }
+}
+
 function computeSuggestions(
   prefix: string,
   follows: AppBskyActorDefs.ProfileViewBasic[] = [],
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
new file mode 100644
index 000000000..a350bd7f3
--- /dev/null
+++ b/src/state/shell/composer.tsx
@@ -0,0 +1,74 @@
+import React from 'react'
+import {AppBskyEmbedRecord} from '@atproto/api'
+
+export interface ComposerOptsPostRef {
+  uri: string
+  cid: string
+  text: string
+  author: {
+    handle: string
+    displayName?: string
+    avatar?: string
+  }
+}
+export interface ComposerOptsQuote {
+  uri: string
+  cid: string
+  text: string
+  indexedAt: string
+  author: {
+    did: string
+    handle: string
+    displayName?: string
+    avatar?: string
+  }
+  embeds?: AppBskyEmbedRecord.ViewRecord['embeds']
+}
+export interface ComposerOpts {
+  replyTo?: ComposerOptsPostRef
+  onPost?: () => void
+  quote?: ComposerOptsQuote
+  mention?: string // handle of user to mention
+}
+
+type StateContext = ComposerOpts | undefined
+type ControlsContext = {
+  openComposer: (opts: ComposerOpts) => void
+  closeComposer: () => void
+}
+
+const stateContext = React.createContext<StateContext>(undefined)
+const controlsContext = React.createContext<ControlsContext>({
+  openComposer(_opts: ComposerOpts) {},
+  closeComposer() {},
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [state, setState] = React.useState<StateContext>()
+  const api = React.useMemo(
+    () => ({
+      openComposer(opts: ComposerOpts) {
+        setState(opts)
+      },
+      closeComposer() {
+        setState(undefined)
+      },
+    }),
+    [setState],
+  )
+  return (
+    <stateContext.Provider value={state}>
+      <controlsContext.Provider value={api}>
+        {children}
+      </controlsContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export function useComposerState() {
+  return React.useContext(stateContext)
+}
+
+export function useComposerControls() {
+  return React.useContext(controlsContext)
+}
diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx
index eb549b9f9..63c3763d1 100644
--- a/src/state/shell/index.tsx
+++ b/src/state/shell/index.tsx
@@ -5,6 +5,7 @@ import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
 import {Provider as MinimalModeProvider} from './minimal-mode'
 import {Provider as ColorModeProvider} from './color-mode'
 import {Provider as OnboardingProvider} from './onboarding'
+import {Provider as ComposerProvider} from './composer'
 
 export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open'
 export {
@@ -22,7 +23,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         <DrawerSwipableProvider>
           <MinimalModeProvider>
             <ColorModeProvider>
-              <OnboardingProvider>{children}</OnboardingProvider>
+              <OnboardingProvider>
+                <ComposerProvider>{children}</ComposerProvider>
+              </OnboardingProvider>
             </ColorModeProvider>
           </MinimalModeProvider>
         </DrawerSwipableProvider>
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 65c485a29..4db9a3a32 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'
@@ -58,6 +56,9 @@ import {
   useLanguagePrefsApi,
   toPostLanguages,
 } from '#/state/preferences/languages'
+import {useSession} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useComposerControls} from '#/state/shell/composer'
 
 type Props = ComposerOpts
 export const ComposePost = observer(function ComposePost({
@@ -66,12 +67,14 @@ export const ComposePost = observer(function ComposePost({
   quote: initQuote,
   mention: initMention,
 }: Props) {
+  const {agent, 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()
@@ -101,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(
@@ -162,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) => {
@@ -216,7 +209,7 @@ export const ComposePost = observer(function ComposePost({
     setIsProcessing(true)
 
     try {
-      await apilib.post(store, {
+      await apilib.post(agent, {
         rawText: richtext.text,
         replyTo: replyTo?.uri,
         images: gallery.images,
@@ -224,7 +217,6 @@ export const ComposePost = observer(function ComposePost({
         extLink,
         labels,
         onStateChange: setProcessingState,
-        knownHandles: autocompleteView.knownHandles,
         langs: toPostLanguages(langPrefs.postLanguage),
       })
     } catch (e: any) {
@@ -381,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}
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..7690a5876 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -11,13 +11,15 @@ 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 {ActorAutocomplete} from '#/state/queries/actor-autocomplete'
+import {useSession} from '#/state/session'
+import {useMyFollowsQuery} from '#/state/queries/my-follows'
 
 export interface TextInputRef {
   focus: () => void
@@ -28,7 +30,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 +44,6 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     richtext,
     placeholder,
     suggestedLinks,
-    autocompleteView,
     setRichText,
     onPhotoPasted,
     onPressPublish,
@@ -52,6 +52,16 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   TextInputProps,
   ref,
 ) {
+  const {agent} = useSession()
+  const autocomplete = React.useMemo(
+    () => new ActorAutocomplete(agent),
+    [agent],
+  )
+  const {data: follows} = useMyFollowsQuery()
+  if (follows) {
+    autocomplete.setFollows(follows)
+  }
+
   const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
   const extensions = React.useMemo(
     () => [
@@ -61,7 +71,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
         HTMLAttributes: {
           class: 'mention',
         },
-        suggestion: createSuggestion({autocompleteView}),
+        suggestion: createSuggestion({autocomplete}),
       }),
       Paragraph,
       Placeholder.configure({
@@ -71,7 +81,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..9ccd717fb 100644
--- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
@@ -1,31 +1,33 @@
 import React, {useEffect} 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'
 
 export const Autocomplete = observer(function AutocompleteImpl({
-  view,
+  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} = useActorAutocompleteQuery(prefix)
 
   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 +42,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 => {
+          {suggestions?.length ? (
+            suggestions.slice(0, 5).map(item => {
               // Eventually use an average length
               const MAX_CHARS = 40
               const MAX_HANDLE_CHARS = 20
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index bbed26d48..c6b773d86 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 {ActorAutocomplete} 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: ActorAutocomplete
 }): Omit<SuggestionOptions, 'editor'> {
   return {
     async items({query}) {
-      autocompleteView.setActive(true)
-      await autocompleteView.setPrefix(query)
-      return autocompleteView.suggestions.slice(0, 8)
+      await autocomplete.query(query)
+      return autocomplete.suggestions.slice(0, 8)
     },
 
     render: () => {
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index eda1a6704..9bdd927a6 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -14,7 +14,7 @@ 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'
 
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 8d6a4a3d0..562b1c141 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -22,6 +22,7 @@ import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSession} from '#/state/session'
+import {useComposerControls} from '#/state/shell/composer'
 
 const POLL_FREQ = 30e3 // 30sec
 
@@ -46,6 +47,7 @@ export function FeedPage({
   const {_} = useLingui()
   const {isDesktop} = useWebMediaQueries()
   const queryClient = useQueryClient()
+  const {openComposer} = useComposerControls()
   const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
   const {screen, track} = useAnalytics()
   const headerOffset = useHeaderOffset()
@@ -80,8 +82,8 @@ export function FeedPage({
 
   const onPressCompose = React.useCallback(() => {
     track('HomeScreen:PressCompose')
-    store.shell.openComposer({})
-  }, [store, track])
+    openComposer({})
+  }, [openComposer, track])
 
   const onPressLoadLatest = React.useCallback(() => {
     scrollToTop()
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 88889fd18..c81b762c3 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -20,7 +20,6 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {countLines, pluralize} from 'lib/strings/helpers'
 import {isEmbedByEmbedder} from 'lib/embeds'
 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
-import {useStores} from 'state/index'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
@@ -39,6 +38,8 @@ import {MAX_POST_LINES} from 'lib/constants'
 import {Trans} from '@lingui/macro'
 import {useLanguagePrefs} from '#/state/preferences'
 import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
+import {useComposerControls} from '#/state/shell/composer'
+import {useModerationOpts} from '#/state/queries/preferences'
 
 export function PostThreadItem({
   post,
@@ -65,7 +66,7 @@ export function PostThreadItem({
   hasPrecedingItem: boolean
   onPostReply: () => void
 }) {
-  const store = useStores()
+  const moderationOpts = useModerationOpts()
   const postShadowed = usePostShadow(post, dataUpdatedAt)
   const richText = useMemo(
     () =>
@@ -77,8 +78,8 @@ export function PostThreadItem({
   )
   const moderation = useMemo(
     () =>
-      post ? moderatePost(post, store.preferences.moderationOpts) : undefined,
-    [post, store],
+      post && moderationOpts ? moderatePost(post, moderationOpts) : undefined,
+    [post, moderationOpts],
   )
   if (postShadowed === POST_TOMBSTONE) {
     return <PostThreadItemDeleted />
@@ -145,8 +146,8 @@ function PostThreadItemLoaded({
   onPostReply: () => void
 }) {
   const pal = usePalette('default')
-  const store = useStores()
   const langPrefs = useLanguagePrefs()
+  const {openComposer} = useComposerControls()
   const [limitLines, setLimitLines] = React.useState(
     countLines(richText?.text) >= MAX_POST_LINES,
   )
@@ -187,7 +188,7 @@ function PostThreadItemLoaded({
   )
 
   const onPressReply = React.useCallback(() => {
-    store.shell.openComposer({
+    openComposer({
       replyTo: {
         uri: post.uri,
         cid: post.cid,
@@ -200,7 +201,7 @@ function PostThreadItemLoaded({
       },
       onPost: onPostReply,
     })
-  }, [store, post, record, onPostReply])
+  }, [openComposer, post, record, onPostReply])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 4a5b8041e..09edbe12f 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -19,7 +19,6 @@ import {PostAlerts} from '../util/moderation/PostAlerts'
 import {Text} from '../util/text/Text'
 import {RichText} from '../util/text/RichText'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {makeProfileLink} from 'lib/routes/links'
@@ -27,6 +26,7 @@ import {MAX_POST_LINES} from 'lib/constants'
 import {countLines} from 'lib/strings/helpers'
 import {useModerationOpts} from '#/state/queries/preferences'
 import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
+import {useComposerControls} from '#/state/shell/composer'
 
 export function Post({
   post,
@@ -97,7 +97,7 @@ function PostInner({
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {openComposer} = useComposerControls()
   const [limitLines, setLimitLines] = useState(
     countLines(richText?.text) >= MAX_POST_LINES,
   )
@@ -110,7 +110,7 @@ function PostInner({
   }
 
   const onPressReply = React.useCallback(() => {
-    store.shell.openComposer({
+    openComposer({
       replyTo: {
         uri: post.uri,
         cid: post.cid,
@@ -122,7 +122,7 @@ function PostInner({
         },
       },
     })
-  }, [store, post, record])
+  }, [openComposer, post, record])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index d24a18f0e..31981cc54 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -24,7 +24,6 @@ import {RichText} from '../util/text/RichText'
 import {PostSandboxWarning} from '../util/PostSandboxWarning'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
@@ -34,6 +33,7 @@ import {isEmbedByEmbedder} from 'lib/embeds'
 import {MAX_POST_LINES} from 'lib/constants'
 import {countLines} from 'lib/strings/helpers'
 import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
+import {useComposerControls} from '#/state/shell/composer'
 
 export function FeedItem({
   post,
@@ -102,7 +102,7 @@ function FeedItemInner({
   isThreadLastChild?: boolean
   isThreadParent?: boolean
 }) {
-  const store = useStores()
+  const {openComposer} = useComposerControls()
   const pal = usePalette('default')
   const {track} = useAnalytics()
   const [limitLines, setLimitLines] = useState(
@@ -124,7 +124,7 @@ function FeedItemInner({
 
   const onPressReply = React.useCallback(() => {
     track('FeedItem:PostReply')
-    store.shell.openComposer({
+    openComposer({
       replyTo: {
         uri: post.uri,
         cid: post.cid,
@@ -136,7 +136,7 @@ function FeedItemInner({
         },
       },
     })
-  }, [post, record, track, store])
+  }, [post, record, track, openComposer])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index a764ed525..7e95bde87 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -13,7 +13,6 @@ import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
 import {s, colors} from 'lib/styles'
 import {pluralize} from 'lib/strings/helpers'
 import {useTheme} from 'lib/ThemeContext'
-import {useStores} from 'state/index'
 import {RepostButton} from './RepostButton'
 import {Haptics} from 'lib/haptics'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
@@ -24,6 +23,7 @@ import {
   usePostRepostMutation,
   usePostUnrepostMutation,
 } from '#/state/queries/post'
+import {useComposerControls} from '#/state/shell/composer'
 
 export function PostCtrls({
   big,
@@ -38,8 +38,8 @@ export function PostCtrls({
   style?: StyleProp<ViewStyle>
   onPressReply: () => void
 }) {
-  const store = useStores()
   const theme = useTheme()
+  const {openComposer} = useComposerControls()
   const {closeModal} = useModalControls()
   const postLikeMutation = usePostLikeMutation()
   const postUnlikeMutation = usePostUnlikeMutation()
@@ -90,7 +90,7 @@ export function PostCtrls({
 
   const onQuote = useCallback(() => {
     closeModal()
-    store.shell.openComposer({
+    openComposer({
       quote: {
         uri: post.uri,
         cid: post.cid,
@@ -100,7 +100,7 @@ export function PostCtrls({
       },
     })
     Haptics.default()
-  }, [post, record, store.shell, closeModal])
+  }, [post, record, openComposer, closeModal])
   return (
     <View style={[styles.ctrls, style]}>
       <TouchableOpacity
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index f82b5b7df..e793f983e 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -12,7 +12,7 @@ import {PostMeta} from '../PostMeta'
 import {Link} from '../Link'
 import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
-import {ComposerOptsQuote} from 'state/models/ui/shell'
+import {ComposerOptsQuote} from 'state/shell/composer'
 import {PostEmbeds} from '.'
 import {PostAlerts} from '../moderation/PostAlerts'
 import {makeProfileLink} from 'lib/routes/links'
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 7a3daee8d..a6d47f5ce 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -8,7 +8,6 @@ import {FAB} from 'view/com/util/fab/FAB'
 import {Link} from 'view/com/util/Link'
 import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {ComposeIcon2, CogIcon} from 'lib/icons'
 import {s} from 'lib/styles'
@@ -34,6 +33,7 @@ import {
   useSearchPopularFeedsMutation,
 } from '#/state/queries/feed'
 import {cleanError} from 'lib/strings/errors'
+import {useComposerControls} from '#/state/shell/composer'
 
 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
 
@@ -90,8 +90,8 @@ type FlatlistSlice =
 export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
   _props: Props,
 ) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {openComposer} = useComposerControls()
   const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
   const [query, setQuery] = React.useState('')
   const [isPTR, setIsPTR] = React.useState(false)
@@ -128,8 +128,8 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
     [search],
   )
   const onPressCompose = React.useCallback(() => {
-    store.shell.openComposer({})
-  }, [store])
+    openComposer({})
+  }, [openComposer])
   const onChangeQuery = React.useCallback(
     (text: string) => {
       setQuery(text)
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index c76bf44e3..752f78dce 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -10,7 +10,6 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
 import {ComposePrompt} from 'view/com/composer/Prompt'
-import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {
@@ -24,14 +23,15 @@ import {useSetMinimalShellMode} from '#/state/shell'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {ErrorMessage} from '../com/util/error/ErrorMessage'
 import {CenteredView} from '../com/util/Views'
+import {useComposerControls} from '#/state/shell/composer'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
 export const PostThreadScreen = withAuthRequired(
   observer(function PostThreadScreenImpl({route}: Props) {
-    const store = useStores()
     const queryClient = useQueryClient()
     const {fabMinimalShellTransform} = useMinimalShellMode()
     const setMinimalShellMode = useSetMinimalShellMode()
+    const {openComposer} = useComposerControls()
     const safeAreaInsets = useSafeAreaInsets()
     const {name, rkey} = route.params
     const {isMobile} = useWebMediaQueries()
@@ -54,7 +54,7 @@ export const PostThreadScreen = withAuthRequired(
       if (thread?.type !== 'post') {
         return
       }
-      store.shell.openComposer({
+      openComposer({
         replyTo: {
           uri: thread.post.uri,
           cid: thread.post.cid,
@@ -70,7 +70,7 @@ export const PostThreadScreen = withAuthRequired(
             queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''),
           }),
       })
-    }, [store, queryClient, resolvedUri])
+    }, [openComposer, queryClient, resolvedUri])
 
     return (
       <View style={s.hContentRegion}>
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 724c47c95..17ea4498c 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -36,6 +36,7 @@ import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
 import {cleanError} from '#/lib/strings/errors'
 import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
 import {useQueryClient} from '@tanstack/react-query'
+import {useComposerControls} from '#/state/shell/composer'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
 export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({
@@ -128,6 +129,7 @@ function ProfileScreenLoaded({
   const store = useStores()
   const {currentAccount} = useSession()
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {openComposer} = useComposerControls()
   const {screen, track} = useAnalytics()
   const [currentPage, setCurrentPage] = React.useState(0)
   const {_} = useLingui()
@@ -193,8 +195,8 @@ function ProfileScreenLoaded({
       profile.handle === 'handle.invalid'
         ? undefined
         : profile.handle
-    store.shell.openComposer({mention})
-  }, [store, currentAccount, track, profile])
+    openComposer({mention})
+  }, [openComposer, currentAccount, track, profile])
 
   const onPageSelected = React.useCallback(
     i => {
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index 537fe7362..f62790be6 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -16,7 +16,6 @@ import {CommonNavigatorParams} from 'lib/routes/types'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 import {colors, s} from 'lib/styles'
 import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
@@ -62,6 +61,7 @@ import {
 } from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
 import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
+import {useComposerControls} from '#/state/shell/composer'
 
 const SECTION_TITLES = ['Posts', 'About']
 
@@ -163,9 +163,9 @@ export const ProfileFeedScreenInner = function ProfileFeedScreenInnerImpl({
 }) {
   const {_} = useLingui()
   const pal = usePalette('default')
-  const store = useStores()
   const {currentAccount} = useSession()
   const {openModal} = useModalControls()
+  const {openComposer} = useComposerControls()
   const {track} = useAnalytics()
   const feedSectionRef = React.useRef<SectionRef>(null)
 
@@ -420,7 +420,7 @@ export const ProfileFeedScreenInner = function ProfileFeedScreenInnerImpl({
       </PagerWithHeader>
       <FAB
         testID="composeFAB"
-        onPress={() => store.shell.openComposer({})}
+        onPress={() => openComposer({})}
         icon={
           <ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} />
         }
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 42c3741db..594f4907d 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -28,7 +28,6 @@ import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {FAB} from 'view/com/util/fab/FAB'
 import {Haptics} from 'lib/haptics'
 import {FeedDescriptor} from '#/state/queries/post-feed'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -55,6 +54,7 @@ import {
 } from '#/state/queries/list'
 import {cleanError} from '#/lib/strings/errors'
 import {useSession} from '#/state/session'
+import {useComposerControls} from '#/state/shell/composer'
 
 const SECTION_TITLES_CURATE = ['Posts', 'About']
 const SECTION_TITLES_MOD = ['About']
@@ -106,9 +106,9 @@ function ProfileListScreenLoaded({
   uri,
   list,
 }: Props & {uri: string; list: AppBskyGraphDefs.ListView}) {
-  const store = useStores()
   const {_} = useLingui()
   const queryClient = useQueryClient()
+  const {openComposer} = useComposerControls()
   const setMinimalShellMode = useSetMinimalShellMode()
   const {rkey} = route.params
   const feedSectionRef = React.useRef<SectionRef>(null)
@@ -191,7 +191,7 @@ function ProfileListScreenLoaded({
         </PagerWithHeader>
         <FAB
           testID="composeFAB"
-          onPress={() => store.shell.openComposer({})}
+          onPress={() => openComposer({})}
           icon={
             <ComposeIcon2
               strokeWidth={1.5}
@@ -227,7 +227,7 @@ function ProfileListScreenLoaded({
       </PagerWithHeader>
       <FAB
         testID="composeFAB"
-        onPress={() => store.shell.openComposer({})}
+        onPress={() => openComposer({})}
         icon={
           <ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} />
         }
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index 219a594ed..d37ff4fb7 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -2,30 +2,21 @@ import React, {useEffect} from 'react'
 import {observer} from 'mobx-react-lite'
 import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
 import {ComposePost} from '../com/composer/Composer'
-import {ComposerOpts} from 'state/models/ui/shell'
+import {useComposerState} from 'state/shell/composer'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {usePalette} from 'lib/hooks/usePalette'
 
 export const Composer = observer(function ComposerImpl({
-  active,
   winHeight,
-  replyTo,
-  onPost,
-  quote,
-  mention,
 }: {
-  active: boolean
   winHeight: number
-  replyTo?: ComposerOpts['replyTo']
-  onPost?: ComposerOpts['onPost']
-  quote?: ComposerOpts['quote']
-  mention?: ComposerOpts['mention']
 }) {
+  const state = useComposerState()
   const pal = usePalette('default')
   const initInterp = useAnimatedValue(0)
 
   useEffect(() => {
-    if (active) {
+    if (state) {
       Animated.timing(initInterp, {
         toValue: 1,
         duration: 300,
@@ -35,7 +26,7 @@ export const Composer = observer(function ComposerImpl({
     } else {
       initInterp.setValue(0)
     }
-  }, [initInterp, active])
+  }, [initInterp, state])
   const wrapperAnimStyle = {
     transform: [
       {
@@ -50,7 +41,7 @@ export const Composer = observer(function ComposerImpl({
   // rendering
   // =
 
-  if (!active) {
+  if (!state) {
     return <View />
   }
 
@@ -60,10 +51,10 @@ export const Composer = observer(function ComposerImpl({
       aria-modal
       accessibilityViewIsModal>
       <ComposePost
-        replyTo={replyTo}
-        onPost={onPost}
-        quote={quote}
-        mention={mention}
+        replyTo={state.replyTo}
+        onPost={state.onPost}
+        quote={state.quote}
+        mention={state.mention}
       />
     </Animated.View>
   )
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index c3ec37e57..e08c792a4 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -1,34 +1,21 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
 import {StyleSheet, View} from 'react-native'
 import {ComposePost} from '../com/composer/Composer'
-import {ComposerOpts} from 'state/models/ui/shell'
+import {useComposerState} from 'state/shell/composer'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 
 const BOTTOM_BAR_HEIGHT = 61
 
-export const Composer = observer(function ComposerImpl({
-  active,
-  replyTo,
-  quote,
-  onPost,
-  mention,
-}: {
-  active: boolean
-  winHeight: number
-  replyTo?: ComposerOpts['replyTo']
-  quote: ComposerOpts['quote']
-  onPost?: ComposerOpts['onPost']
-  mention?: ComposerOpts['mention']
-}) {
+export function Composer({}: {winHeight: number}) {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+  const state = useComposerState()
 
   // rendering
   // =
 
-  if (!active) {
+  if (!state) {
     return <View />
   }
 
@@ -42,15 +29,15 @@ export const Composer = observer(function ComposerImpl({
           pal.border,
         ]}>
         <ComposePost
-          replyTo={replyTo}
-          quote={quote}
-          onPost={onPost}
-          mention={mention}
+          replyTo={state.replyTo}
+          quote={state.quote}
+          onPost={state.onPost}
+          mention={state.mention}
         />
       </View>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   mask: {
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index d7814cb5d..90cf144d2 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -44,6 +44,7 @@ import {Trans, msg} from '@lingui/macro'
 import {useProfileQuery} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
 import {useUnreadNotifications} from '#/state/queries/notifications/unread'
+import {useComposerControls} from '#/state/shell/composer'
 
 const ProfileCard = observer(function ProfileCardImpl() {
   const {currentAccount} = useSession()
@@ -195,6 +196,7 @@ const NavItem = observer(function NavItemImpl({
 function ComposeBtn() {
   const store = useStores()
   const {getState} = useNavigation()
+  const {openComposer} = useComposerControls()
   const {_} = useLingui()
   const {isTablet} = useWebMediaQueries()
 
@@ -224,7 +226,7 @@ function ComposeBtn() {
   }
 
   const onPressCompose = async () =>
-    store.shell.openComposer({mention: await getProfileHandle()})
+    openComposer({mention: await getProfileHandle()})
 
   if (isTablet) {
     return null
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 75ed07475..ff7a7dcda 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -89,14 +89,7 @@ const ShellInner = observer(function ShellInnerImpl() {
           </Drawer>
         </ErrorBoundary>
       </View>
-      <Composer
-        active={store.shell.isComposerActive}
-        winHeight={winDim.height}
-        replyTo={store.shell.composerOpts?.replyTo}
-        onPost={store.shell.composerOpts?.onPost}
-        quote={store.shell.composerOpts?.quote}
-        mention={store.shell.composerOpts?.mention}
-      />
+      <Composer winHeight={winDim.height} />
       <ModalsContainer />
       <Lightbox />
     </>
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index a74cd126f..e134358d9 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -61,14 +61,7 @@ const ShellInner = observer(function ShellInnerImpl() {
           <DesktopRightNav />
         </>
       )}
-      <Composer
-        active={store.shell.isComposerActive}
-        winHeight={0}
-        replyTo={store.shell.composerOpts?.replyTo}
-        quote={store.shell.composerOpts?.quote}
-        onPost={store.shell.composerOpts?.onPost}
-        mention={store.shell.composerOpts?.mention}
-      />
+      <Composer winHeight={0} />
       {showBottomBar && <BottomBarWeb />}
       <ModalsContainer />
       <Lightbox />