about summary refs log tree commit diff
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-10-08 09:07:40 +0900
committerGitHub <noreply@github.com>2024-10-07 17:07:40 -0700
commite1ca3ae40e8a208ab2ab0b89a96b8e314042c75b (patch)
tree3552743fa299b00b5f13e31721fa219ec50c136e
parentc06040cc209338fc37980648b31d4d64cc0c5c09 (diff)
downloadvoidsky-e1ca3ae40e8a208ab2ab0b89a96b8e314042c75b.tar.zst
Move remaining composer state into reducer (#5623)
Co-authored-by: Mary <git@mary.my.id>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
-rw-r--r--src/lib/api/index.ts45
-rw-r--r--src/lib/api/resolve.ts93
-rw-r--r--src/lib/link-meta/bsky.ts225
-rw-r--r--src/lib/link-meta/link-meta.ts12
-rw-r--r--src/view/com/composer/Composer.tsx121
-rw-r--r--src/view/com/composer/state/composer.ts65
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx2
7 files changed, 201 insertions, 362 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 6edb111e6..4b203d28b 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -4,7 +4,6 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   AppBskyEmbedVideo,
-  AppBskyFeedPostgate,
   AtUri,
   BlobRef,
   BskyAgent,
@@ -17,7 +16,7 @@ import {QueryClient} from '@tanstack/react-query'
 import {isNetworkError} from '#/lib/strings/errors'
 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
 import {logger} from '#/logger'
-import {ComposerImage, compressImage} from '#/state/gallery'
+import {compressImage} from '#/state/gallery'
 import {writePostgateRecord} from '#/state/queries/postgate'
 import {
   fetchResolveGifQuery,
@@ -25,32 +24,18 @@ import {
 } from '#/state/queries/resolve-link'
 import {
   createThreadgateRecord,
-  ThreadgateAllowUISetting,
   threadgateAllowUISettingToAllowRecordValue,
   writeThreadgateRecord,
 } from '#/state/queries/threadgate'
-import {ComposerState, EmbedDraft} from '#/view/com/composer/state/composer'
+import {ComposerDraft, EmbedDraft} from '#/view/com/composer/state/composer'
 import {createGIFDescription} from '../gif-alt-text'
-import {LinkMeta} from '../link-meta/link-meta'
 import {uploadBlob} from './upload-blob'
 
 export {uploadBlob}
 
-export interface ExternalEmbedDraft {
-  uri: string
-  isLoading: boolean
-  meta?: LinkMeta
-  embed?: AppBskyEmbedRecord.Main
-  localThumb?: ComposerImage
-}
-
 interface PostOpts {
-  composerState: ComposerState // TODO: Not used yet.
-  rawText: string
+  draft: ComposerDraft
   replyTo?: string
-  labels?: string[]
-  threadgate: ThreadgateAllowUISetting[]
-  postgate: AppBskyFeedPostgate.Record
   onStateChange?: (state: string) => void
   langs?: string[]
 }
@@ -60,8 +45,12 @@ export async function post(
   queryClient: QueryClient,
   opts: PostOpts,
 ) {
+  const draft = opts.draft
   let reply
-  let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
+  let rt = new RichText(
+    {text: draft.richtext.text.trimEnd()},
+    {cleanNewlines: true},
+  )
 
   opts.onStateChange?.('Processing...')
 
@@ -73,7 +62,7 @@ export async function post(
   const embed = await resolveEmbed(
     agent,
     queryClient,
-    opts.composerState,
+    draft,
     opts.onStateChange,
   )
 
@@ -98,10 +87,10 @@ export async function post(
 
   // set labels
   let labels: ComAtprotoLabelDefs.SelfLabels | undefined
-  if (opts.labels?.length) {
+  if (draft.labels.length) {
     labels = {
       $type: 'com.atproto.label.defs#selfLabels',
-      values: opts.labels.map(val => ({val})),
+      values: draft.labels.map(val => ({val})),
     }
   }
 
@@ -135,7 +124,7 @@ export async function post(
     }
   }
 
-  if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
+  if (draft.threadgate.some(tg => tg.type !== 'everybody')) {
     try {
       // TODO: this needs to be batch-created with the post!
       await writeThreadgateRecord({
@@ -143,7 +132,7 @@ export async function post(
         postUri: res.uri,
         threadgate: createThreadgateRecord({
           post: res.uri,
-          allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
+          allow: threadgateAllowUISettingToAllowRecordValue(draft.threadgate),
         }),
       })
     } catch (e: any) {
@@ -158,8 +147,8 @@ export async function post(
   }
 
   if (
-    opts.postgate.embeddingRules?.length ||
-    opts.postgate.detachedEmbeddingUris?.length
+    draft.postgate.embeddingRules?.length ||
+    draft.postgate.detachedEmbeddingUris?.length
   ) {
     try {
       // TODO: this needs to be batch-created with the post!
@@ -167,7 +156,7 @@ export async function post(
         agent,
         postUri: res.uri,
         postgate: {
-          ...opts.postgate,
+          ...draft.postgate,
           post: res.uri,
         },
       })
@@ -188,7 +177,7 @@ export async function post(
 async function resolveEmbed(
   agent: BskyAgent,
   queryClient: QueryClient,
-  draft: ComposerState,
+  draft: ComposerDraft,
   onStateChange: ((state: string) => void) | undefined,
 ): Promise<
   | AppBskyEmbedImages.Main
diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts
index 4f409e100..1115a4fb0 100644
--- a/src/lib/api/resolve.ts
+++ b/src/lib/api/resolve.ts
@@ -1,18 +1,21 @@
-import {AppBskyActorDefs, ComAtprotoRepoStrongRef} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  AppBskyFeedPost,
+  AppBskyGraphStarterpack,
+  ComAtprotoRepoStrongRef,
+} from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {BskyAgent} from '@atproto/api'
 
 import {POST_IMG_MAX} from '#/lib/constants'
-import {
-  getFeedAsEmbed,
-  getListAsEmbed,
-  getPostAsQuote,
-  getStarterPackAsEmbed,
-} from '#/lib/link-meta/bsky'
 import {getLinkMeta} from '#/lib/link-meta/link-meta'
 import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
 import {downloadAndResize} from '#/lib/media/manip'
 import {
+  createStarterPackUri,
+  parseStarterPackUri,
+} from '#/lib/strings/starter-pack'
+import {
   isBskyCustomFeedUrl,
   isBskyListUrl,
   isBskyPostUrl,
@@ -24,6 +27,7 @@ import {ComposerImage} from '#/state/gallery'
 import {createComposerImage} from '#/state/gallery'
 import {Gif} from '#/state/queries/tenor'
 import {createGIFDescription} from '../gif-alt-text'
+import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
 
 type ResolvedExternalLink = {
   type: 'external'
@@ -60,6 +64,12 @@ export type ResolvedLink =
   | ResolvedPostRecord
   | ResolvedOtherRecord
 
+class EmbeddingDisabledError extends Error {
+  constructor() {
+    super('Embedding is disabled for this record')
+  }
+}
+
 export async function resolveLink(
   agent: BskyAgent,
   uri: string,
@@ -68,55 +78,88 @@ export async function resolveLink(
     uri = await resolveShortLink(uri)
   }
   if (isBskyPostUrl(uri)) {
-    // TODO: Remove this abstraction.
-    // TODO: Nice error messages (e.g. EmbeddingDisabledError).
-    const result = await getPostAsQuote(getPost, uri)
+    uri = convertBskyAppUrlIfNeeded(uri)
+    const [_0, user, _1, rkey] = uri.split('/').filter(Boolean)
+    const recordUri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
+    const post = await getPost({uri: recordUri})
+    if (post.viewer?.embeddingDisabled) {
+      throw new EmbeddingDisabledError()
+    }
     return {
       type: 'record',
       record: {
-        cid: result.cid,
-        uri: result.uri,
+        cid: post.cid,
+        uri: post.uri,
       },
       kind: 'post',
-      meta: result,
+      meta: {
+        text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '',
+        indexedAt: post.indexedAt,
+        author: post.author,
+      },
     }
   }
   if (isBskyCustomFeedUrl(uri)) {
-    // TODO: Remove this abstraction.
-    const result = await getFeedAsEmbed(agent, fetchDid, uri)
+    uri = convertBskyAppUrlIfNeeded(uri)
+    const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean)
+    const did = await fetchDid(handleOrDid)
+    const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
+    const res = await agent.app.bsky.feed.getFeedGenerator({feed})
     return {
       type: 'record',
-      record: result.embed!.record,
+      record: {
+        uri: res.data.view.uri,
+        cid: res.data.view.cid,
+      },
       kind: 'other',
       meta: {
         // TODO: Include hydrated content instead.
-        title: result.meta!.title!,
+        title: res.data.view.displayName,
       },
     }
   }
   if (isBskyListUrl(uri)) {
-    // TODO: Remove this abstraction.
-    const result = await getListAsEmbed(agent, fetchDid, uri)
+    uri = convertBskyAppUrlIfNeeded(uri)
+    const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean)
+    const did = await fetchDid(handleOrDid)
+    const list = makeRecordUri(did, 'app.bsky.graph.list', rkey)
+    const res = await agent.app.bsky.graph.getList({list})
     return {
       type: 'record',
-      record: result.embed!.record,
+      record: {
+        uri: res.data.list.uri,
+        cid: res.data.list.cid,
+      },
       kind: 'other',
       meta: {
         // TODO: Include hydrated content instead.
-        title: result.meta!.title!,
+        title: res.data.list.name,
       },
     }
   }
   if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
-    // TODO: Remove this abstraction.
-    const result = await getStarterPackAsEmbed(agent, fetchDid, uri)
+    const parsed = parseStarterPackUri(uri)
+    if (!parsed) {
+      throw new Error(
+        'Unexpectedly called getStarterPackAsEmbed with a non-starterpack url',
+      )
+    }
+    const did = await fetchDid(parsed.name)
+    const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
+    const res = await agent.app.bsky.graph.getStarterPack({starterPack})
+    const record = res.data.starterPack.record
     return {
       type: 'record',
-      record: result.embed!.record,
+      record: {
+        uri: res.data.starterPack.uri,
+        cid: res.data.starterPack.cid,
+      },
       kind: 'other',
       meta: {
         // TODO: Include hydrated content instead.
-        title: result.meta!.title!,
+        title: AppBskyGraphStarterpack.isRecord(record)
+          ? record.name
+          : 'Starter Pack',
       },
     }
   }
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
deleted file mode 100644
index 3d49f5237..000000000
--- a/src/lib/link-meta/bsky.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-import {AppBskyFeedPost, AppBskyGraphStarterpack, BskyAgent} from '@atproto/api'
-
-import {useFetchDid} from '#/state/queries/handle'
-import {useGetPost} from '#/state/queries/post'
-import * as apilib from 'lib/api/index'
-import {
-  createStarterPackUri,
-  parseStarterPackUri,
-} from 'lib/strings/starter-pack'
-import {ComposerOptsQuote} from 'state/shell/composer'
-// import {match as matchRoute} from 'view/routes'
-import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
-import {LikelyType, LinkMeta} from './link-meta'
-
-// TODO
-// import {Home} from 'view/screens/Home'
-// import {Search} from 'view/screens/Search'
-// import {Notifications} from 'view/screens/Notifications'
-// import {PostThread} from 'view/screens/PostThread'
-// import {PostUpvotedBy} from 'view/screens/PostUpvotedBy'
-// import {PostRepostedBy} from 'view/screens/PostRepostedBy'
-// import {Profile} from 'view/screens/Profile'
-// import {ProfileFollowers} from 'view/screens/ProfileFollowers'
-// import {ProfileFollows} from 'view/screens/ProfileFollows'
-
-// NOTE
-// this is a hack around the lack of hosted social metadata
-// remove once that's implemented
-// -prf
-export async function extractBskyMeta(
-  agent: BskyAgent,
-  url: string,
-): Promise<LinkMeta> {
-  url = convertBskyAppUrlIfNeeded(url)
-  // const route = matchRoute(url)
-  let meta: LinkMeta = {
-    likelyType: LikelyType.AtpData,
-    url,
-    // title: route.defaultTitle,
-  }
-
-  // if (route.Com === Home) {
-  //   meta = {
-  //     ...meta,
-  //     title: 'Bluesky',
-  //     description: 'A new kind of social network',
-  //   }
-  // } else if (route.Com === Search) {
-  //   meta = {
-  //     ...meta,
-  //     title: 'Search - Bluesky',
-  //     description: 'A new kind of social network',
-  //   }
-  // } else if (route.Com === Notifications) {
-  //   meta = {
-  //     ...meta,
-  //     title: 'Notifications - Bluesky',
-  //     description: 'A new kind of social network',
-  //   }
-  // } else if (
-  //   route.Com === PostThread ||
-  //   route.Com === PostUpvotedBy ||
-  //   route.Com === PostRepostedBy
-  // ) {
-  //   // post and post-related screens
-  //   const threadUri = makeRecordUri(
-  //     route.params.name,
-  //     'app.bsky.feed.post',
-  //     route.params.rkey,
-  //   )
-  //   const threadView = new PostThreadViewModel(store, {
-  //     uri: threadUri,
-  //     depth: 0,
-  //   })
-  //   await threadView.setup().catch(_err => undefined)
-  //   const title = [
-  //     route.Com === PostUpvotedBy
-  //       ? 'Likes on a post by'
-  //       : route.Com === PostRepostedBy
-  //       ? 'Reposts of a post by'
-  //       : 'Post by',
-  //     threadView.thread?.post.author.displayName ||
-  //       threadView.thread?.post.author.handle ||
-  //       'a bluesky user',
-  //   ].join(' ')
-  //   meta = {
-  //     ...meta,
-  //     title,
-  //     description: threadView.thread?.postRecord?.text,
-  //   }
-  // } else if (
-  //   route.Com === Profile ||
-  //   route.Com === ProfileFollowers ||
-  //   route.Com === ProfileFollows
-  // ) {
-  //   // profile and profile-related screens
-  //   const profile = await store.profiles.getProfile(route.params.name)
-  //   if (profile?.data) {
-  //     meta = {
-  //       ...meta,
-  //       title: profile.data.displayName || profile.data.handle,
-  //       description: profile.data.description,
-  //     }
-  //   }
-  // }
-
-  return meta
-}
-
-export class EmbeddingDisabledError extends Error {
-  constructor() {
-    super('Embedding is disabled for this record')
-  }
-}
-export async function getPostAsQuote(
-  getPost: ReturnType<typeof useGetPost>,
-  url: string,
-): Promise<ComposerOptsQuote> {
-  url = convertBskyAppUrlIfNeeded(url)
-  const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
-  const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
-  const post = await getPost({uri: uri})
-  if (post.viewer?.embeddingDisabled) {
-    throw new EmbeddingDisabledError()
-  }
-  return {
-    uri: post.uri,
-    cid: post.cid,
-    text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '',
-    indexedAt: post.indexedAt,
-    author: post.author,
-  }
-}
-
-export async function getFeedAsEmbed(
-  agent: BskyAgent,
-  fetchDid: ReturnType<typeof useFetchDid>,
-  url: string,
-): Promise<apilib.ExternalEmbedDraft> {
-  url = convertBskyAppUrlIfNeeded(url)
-  const [_0, handleOrDid, _1, rkey] = url.split('/').filter(Boolean)
-  const did = await fetchDid(handleOrDid)
-  const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
-  const res = await agent.app.bsky.feed.getFeedGenerator({feed})
-  return {
-    isLoading: false,
-    uri: feed,
-    meta: {
-      url: feed,
-      likelyType: LikelyType.AtpData,
-      title: res.data.view.displayName,
-    },
-    embed: {
-      $type: 'app.bsky.embed.record',
-      record: {
-        uri: res.data.view.uri,
-        cid: res.data.view.cid,
-      },
-    },
-  }
-}
-
-export async function getListAsEmbed(
-  agent: BskyAgent,
-  fetchDid: ReturnType<typeof useFetchDid>,
-  url: string,
-): Promise<apilib.ExternalEmbedDraft> {
-  url = convertBskyAppUrlIfNeeded(url)
-  const [_0, handleOrDid, _1, rkey] = url.split('/').filter(Boolean)
-  const did = await fetchDid(handleOrDid)
-  const list = makeRecordUri(did, 'app.bsky.graph.list', rkey)
-  const res = await agent.app.bsky.graph.getList({list})
-  return {
-    isLoading: false,
-    uri: list,
-    meta: {
-      url: list,
-      likelyType: LikelyType.AtpData,
-      title: res.data.list.name,
-    },
-    embed: {
-      $type: 'app.bsky.embed.record',
-      record: {
-        uri: res.data.list.uri,
-        cid: res.data.list.cid,
-      },
-    },
-  }
-}
-
-export async function getStarterPackAsEmbed(
-  agent: BskyAgent,
-  fetchDid: ReturnType<typeof useFetchDid>,
-  url: string,
-): Promise<apilib.ExternalEmbedDraft> {
-  const parsed = parseStarterPackUri(url)
-  if (!parsed) {
-    throw new Error(
-      'Unexepectedly called getStarterPackAsEmbed with a non-starterpack url',
-    )
-  }
-  const did = await fetchDid(parsed.name)
-  const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
-  const res = await agent.app.bsky.graph.getStarterPack({starterPack})
-  const record = res.data.starterPack.record
-  return {
-    isLoading: false,
-    uri: starterPack,
-    meta: {
-      url: starterPack,
-      likelyType: LikelyType.AtpData,
-      // Validation here should never fail
-      title: AppBskyGraphStarterpack.isRecord(record)
-        ? record.name
-        : 'Starter Pack',
-    },
-    embed: {
-      $type: 'app.bsky.embed.record',
-      record: {
-        uri: res.data.starterPack.uri,
-        cid: res.data.starterPack.cid,
-      },
-    },
-  }
-}
diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts
index 6416df2b7..ea9190b6e 100644
--- a/src/lib/link-meta/link-meta.ts
+++ b/src/lib/link-meta/link-meta.ts
@@ -1,10 +1,9 @@
 import {BskyAgent} from '@atproto/api'
 
-import {LINK_META_PROXY} from 'lib/constants'
-import {getGiphyMetaUri} from 'lib/strings/embed-player'
-import {parseStarterPackUri} from 'lib/strings/starter-pack'
+import {LINK_META_PROXY} from '#/lib/constants'
+import {getGiphyMetaUri} from '#/lib/strings/embed-player'
+import {parseStarterPackUri} from '#/lib/strings/starter-pack'
 import {isBskyAppUrl} from '../strings/url-helpers'
-import {extractBskyMeta} from './bsky'
 
 export enum LikelyType {
   HTML,
@@ -31,7 +30,10 @@ export async function getLinkMeta(
   timeout = 15e3,
 ): Promise<LinkMeta> {
   if (isBskyAppUrl(url) && !parseStarterPackUri(url)) {
-    return extractBskyMeta(agent, url)
+    return {
+      likelyType: LikelyType.AtpData,
+      url,
+    }
   }
 
   let urlp
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index ecafea500..87f72ce42 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -42,7 +42,6 @@ import {
   AppBskyFeedGetPostThread,
   BskyAgent,
 } from '@atproto/api'
-import {RichText} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -57,7 +56,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
-import {insertMentionAt} from '#/lib/strings/mention-manip'
 import {shortenLinks} from '#/lib/strings/rich-text-manip'
 import {colors, s} from '#/lib/styles'
 import {logger} from '#/logger'
@@ -73,11 +71,8 @@ import {
   useLanguagePrefs,
   useLanguagePrefsApi,
 } from '#/state/preferences/languages'
-import {createPostgateRecord} from '#/state/queries/postgate/util'
 import {useProfileQuery} from '#/state/queries/profile'
 import {Gif} from '#/state/queries/tenor'
-import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
-import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
 import {useAgent, useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
 import {ComposerOpts} from '#/state/shell/composer'
@@ -168,35 +163,40 @@ export const ComposePost = ({
   const [isProcessing, setIsProcessing] = useState(false)
   const [processingState, setProcessingState] = useState('')
   const [error, setError] = useState('')
-  const [richtext, setRichText] = useState(
-    new RichText({
-      text: initText
-        ? initText
-        : initMention
-        ? insertMentionAt(
-            `@${initMention}`,
-            initMention.length + 1,
-            `${initMention}`,
-          ) // insert mention if passed in
-        : '',
-    }),
-  )
-  const graphemeLength = useMemo(() => {
-    return shortenLinks(richtext).graphemeLength
-  }, [richtext])
 
-  // TODO: Move more state here.
-  const [composerState, dispatch] = useReducer(
+  const [draft, dispatch] = useReducer(
     composerReducer,
-    {initImageUris, initQuoteUri: initQuote?.uri},
+    {initImageUris, initQuoteUri: initQuote?.uri, initText, initMention},
     createComposerState,
   )
-
+  const richtext = draft.richtext
+  let quote: string | undefined
+  if (draft.embed.quote) {
+    quote = draft.embed.quote.uri
+  }
+  let images = NO_IMAGES
+  if (draft.embed.media?.type === 'images') {
+    images = draft.embed.media.images
+  }
   let videoState: VideoState | NoVideoState = NO_VIDEO
-  if (composerState.embed.media?.type === 'video') {
-    videoState = composerState.embed.media.video
+  if (draft.embed.media?.type === 'video') {
+    videoState = draft.embed.media.video
+  }
+  let extGif: Gif | undefined
+  let extGifAlt: string | undefined
+  if (draft.embed.media?.type === 'gif') {
+    extGif = draft.embed.media.gif
+    extGifAlt = draft.embed.media.alt
+  }
+  let extLink: string | undefined
+  if (draft.embed.link) {
+    extLink = draft.embed.link.uri
   }
 
+  const graphemeLength = useMemo(() => {
+    return shortenLinks(richtext).graphemeLength
+  }, [richtext])
+
   const selectVideo = React.useCallback(
     (asset: ImagePickerAsset) => {
       const abortController = new AbortController()
@@ -241,35 +241,8 @@ export const ComposePost = ({
   )
 
   const hasVideo = Boolean(videoState.asset || videoState.video)
-
   const [publishOnUpload, setPublishOnUpload] = useState(false)
 
-  const [labels, setLabels] = useState<string[]>([])
-  const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] =
-    useState<ThreadgateAllowUISetting[]>(
-      threadgateViewToAllowUISetting(undefined),
-    )
-  const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
-
-  let quote: string | undefined
-  if (composerState.embed.quote) {
-    quote = composerState.embed.quote.uri
-  }
-  let images = NO_IMAGES
-  if (composerState.embed.media?.type === 'images') {
-    images = composerState.embed.media.images
-  }
-  let extGif: Gif | undefined
-  let extGifAlt: string | undefined
-  if (composerState.embed.media?.type === 'gif') {
-    extGif = composerState.embed.media.gif
-    extGifAlt = composerState.embed.media.alt
-  }
-  let extLink: string | undefined
-  if (composerState.embed.link) {
-    extLink = composerState.embed.link.uri
-  }
-
   const onClose = useCallback(() => {
     closeComposer()
   }, [closeComposer])
@@ -419,12 +392,8 @@ export const ComposePost = ({
       try {
         postUri = (
           await apilib.post(agent, queryClient, {
-            composerState, // TODO: move more state here.
-            rawText: richtext.text,
+            draft: draft,
             replyTo: replyTo?.uri,
-            labels,
-            threadgate: threadgateAllowUISettings,
-            postgate,
             onStateChange: setProcessingState,
             langs: toPostLanguages(langPrefs.postLanguage),
           })
@@ -497,24 +466,21 @@ export const ComposePost = ({
     [
       _,
       agent,
-      composerState,
+      draft,
       extLink,
       images,
       graphemeLength,
       isAltTextRequiredAndMissing,
       isProcessing,
-      labels,
       langPrefs.postLanguage,
       onClose,
       onPost,
-      postgate,
       quote,
       initQuote,
       initQuoteCount,
       replyTo,
       richtext.text,
       setLangPrefs,
-      threadgateAllowUISettings,
       videoState.asset,
       videoState.status,
       queryClient,
@@ -615,8 +581,10 @@ export const ComposePost = ({
               ) : (
                 <View style={[styles.postBtnWrapper]}>
                   <LabelsBtn
-                    labels={labels}
-                    onChange={setLabels}
+                    labels={draft.labels}
+                    onChange={nextLabels => {
+                      dispatch({type: 'update_labels', labels: nextLabels})
+                    }}
                     hasMedia={hasMedia || Boolean(extLink)}
                   />
                   {canPost ? (
@@ -698,7 +666,9 @@ export const ComposePost = ({
                 richtext={richtext}
                 placeholder={selectTextInputPlaceholder}
                 autoFocus
-                setRichText={setRichText}
+                setRichText={rt => {
+                  dispatch({type: 'update_richtext', richtext: rt})
+                }}
                 onPhotoPasted={onPhotoPasted}
                 onPressPublish={() => onPressPublish()}
                 onNewLink={onNewLink}
@@ -734,7 +704,7 @@ export const ComposePost = ({
               </View>
             )}
 
-            {!composerState.embed.media && extLink && (
+            {!draft.embed.media && extLink && (
               <View style={a.relative} key={extLink}>
                 <ExternalEmbedLink
                   uri={extLink}
@@ -815,12 +785,17 @@ export const ComposePost = ({
 
           {replyTo ? null : (
             <ThreadgateBtn
-              postgate={postgate}
-              onChangePostgate={setPostgate}
-              threadgateAllowUISettings={threadgateAllowUISettings}
-              onChangeThreadgateAllowUISettings={
-                onChangeThreadgateAllowUISettings
-              }
+              postgate={draft.postgate}
+              onChangePostgate={nextPostgate => {
+                dispatch({type: 'update_postgate', postgate: nextPostgate})
+              }}
+              threadgateAllowUISettings={draft.threadgate}
+              onChangeThreadgateAllowUISettings={nextThreadgate => {
+                dispatch({
+                  type: 'update_threadgate',
+                  threadgate: nextThreadgate,
+                })
+              }}
               style={bottomBarAnimatedStyle}
               Portal={Portal.Portal}
             />
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index 6156d3cfa..e37690342 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -1,12 +1,17 @@
 import {ImagePickerAsset} from 'expo-image-picker'
+import {AppBskyFeedPostgate, RichText} from '@atproto/api'
 
+import {insertMentionAt} from '#/lib/strings/mention-manip'
 import {
   isBskyPostUrl,
   postUriToRelativePath,
   toBskyAppUrl,
 } from '#/lib/strings/url-helpers'
 import {ComposerImage, createInitialImages} from '#/state/gallery'
+import {createPostgateRecord} from '#/state/queries/postgate/util'
 import {Gif} from '#/state/queries/tenor'
+import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate'
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
 import {ComposerOpts} from '#/state/shell/composer'
 import {createVideoState, VideoAction, videoReducer, VideoState} from './video'
 
@@ -41,12 +46,19 @@ export type EmbedDraft = {
   link: Link | undefined
 }
 
-export type ComposerState = {
-  // TODO: Other draft data.
+export type ComposerDraft = {
+  richtext: RichText
+  labels: string[]
+  postgate: AppBskyFeedPostgate.Record
+  threadgate: ThreadgateAllowUISetting[]
   embed: EmbedDraft
 }
 
 export type ComposerAction =
+  | {type: 'update_richtext'; richtext: RichText}
+  | {type: 'update_labels'; labels: string[]}
+  | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record}
+  | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]}
   | {type: 'embed_add_images'; images: ComposerImage[]}
   | {type: 'embed_update_image'; image: ComposerImage}
   | {type: 'embed_remove_image'; image: ComposerImage}
@@ -67,10 +79,34 @@ export type ComposerAction =
 export const MAX_IMAGES = 4
 
 export function composerReducer(
-  state: ComposerState,
+  state: ComposerDraft,
   action: ComposerAction,
-): ComposerState {
+): ComposerDraft {
   switch (action.type) {
+    case 'update_richtext': {
+      return {
+        ...state,
+        richtext: action.richtext,
+      }
+    }
+    case 'update_labels': {
+      return {
+        ...state,
+        labels: action.labels,
+      }
+    }
+    case 'update_postgate': {
+      return {
+        ...state,
+        postgate: action.postgate,
+      }
+    }
+    case 'update_threadgate': {
+      return {
+        ...state,
+        threadgate: action.threadgate,
+      }
+    }
     case 'embed_add_images': {
       if (action.images.length === 0) {
         return state
@@ -293,12 +329,16 @@ export function composerReducer(
 }
 
 export function createComposerState({
+  initText,
+  initMention,
   initImageUris,
   initQuoteUri,
 }: {
+  initText: string | undefined
+  initMention: string | undefined
   initImageUris: ComposerOpts['imageUris']
   initQuoteUri: string | undefined
-}): ComposerState {
+}): ComposerDraft {
   let media: ImagesMedia | undefined
   if (initImageUris?.length) {
     media = {
@@ -317,7 +357,22 @@ export function createComposerState({
       }
     }
   }
+  const initRichText = new RichText({
+    text: initText
+      ? initText
+      : initMention
+      ? insertMentionAt(
+          `@${initMention}`,
+          initMention.length + 1,
+          `${initMention}`,
+        )
+      : '',
+  })
   return {
+    richtext: initRichText,
+    labels: [],
+    postgate: createPostgateRecord({post: ''}),
+    threadgate: threadgateViewToAllowUISetting(undefined),
     embed: {
       quote,
       media,
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 39baa2cb6..43074fa5b 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -43,7 +43,7 @@ export interface TextInputRef {
 interface TextInputProps extends ComponentProps<typeof RNTextInput> {
   richtext: RichText
   placeholder: string
-  setRichText: (v: RichText | ((v: RichText) => RichText)) => void
+  setRichText: (v: RichText) => void
   onPhotoPasted: (uri: string) => void
   onPressPublish: (richtext: RichText) => Promise<void>
   onNewLink: (uri: string) => void