about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/composer/Composer.tsx540
-rw-r--r--src/view/com/composer/ComposerReplyTo.tsx1
-rw-r--r--src/view/com/composer/photos/Gallery.tsx5
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx4
-rw-r--r--src/view/com/composer/state/composer.ts81
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx13
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx22
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx5
8 files changed, 459 insertions, 212 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 129869e47..3c9808448 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -28,6 +28,9 @@ import Animated, {
   interpolateColor,
   LayoutAnimationConfig,
   LinearTransition,
+  runOnUI,
+  scrollTo,
+  useAnimatedRef,
   useAnimatedStyle,
   useDerivedValue,
   useSharedValue,
@@ -167,9 +170,10 @@ export const ComposePost = ({
     createComposerState,
   )
 
-  // TODO: Display drafts for other posts in the thread.
   const thread = composerState.thread
   const activePost = thread.posts[composerState.activePostIndex]
+  const nextPost: PostDraft | undefined =
+    thread.posts[composerState.activePostIndex + 1]
   const dispatch = useCallback(
     (postAction: PostAction) => {
       composerDispatch({
@@ -226,20 +230,15 @@ export const ComposePost = ({
 
   const clearVideo = React.useCallback(
     (postId: string) => {
-      const post = thread.posts.find(p => p.id === postId)
-      const postMedia = post?.embed.media
-      if (postMedia?.type === 'video') {
-        postMedia.video.abortController.abort()
-        composerDispatch({
-          type: 'update_post',
-          postId: postId,
-          postAction: {
-            type: 'embed_remove_video',
-          },
-        })
-      }
+      composerDispatch({
+        type: 'update_post',
+        postId: postId,
+        postAction: {
+          type: 'embed_remove_video',
+        },
+      })
     },
-    [thread, composerDispatch],
+    [composerDispatch],
   )
 
   const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -297,180 +296,189 @@ export const ComposePost = ({
     }
   }, [onPressCancel, closeAllDialogs, closeAllModals])
 
-  const isAltTextRequiredAndMissing = useMemo(() => {
+  const missingAltError = useMemo(() => {
     if (!requireAltTextEnabled) {
-      return false
+      return
     }
-    return thread.posts.some(post => {
-      const media = post.embed.media
+    for (let i = 0; i < thread.posts.length; i++) {
+      const media = thread.posts[i].embed.media
       if (media) {
         if (media.type === 'images' && media.images.some(img => !img.alt)) {
-          return true
+          return _(msg`One or more images is missing alt text.`)
         }
         if (media.type === 'gif' && !media.alt) {
-          return true
+          return _(msg`One or more GIFs is missing alt text.`)
+        }
+        if (
+          media.type === 'video' &&
+          media.video.status !== 'error' &&
+          !media.video.altText
+        ) {
+          return _(msg`One or more videos is missing alt text.`)
         }
       }
-    })
-  }, [thread, requireAltTextEnabled])
+    }
+  }, [thread, requireAltTextEnabled, _])
 
   const canPost =
-    !isAltTextRequiredAndMissing &&
+    !missingAltError &&
     thread.posts.every(
       post =>
         post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
-        !(
-          post.richtext.text.trim().length === 0 &&
-          !post.embed.link &&
-          !post.embed.media &&
-          !post.embed.quote
-        ) &&
+        !isEmptyPost(post) &&
         !(
           post.embed.media?.type === 'video' &&
           post.embed.media.video.status === 'error'
         ),
     )
 
-  const onPressPublish = React.useCallback(
-    async (finishedUploading: boolean) => {
-      if (isPublishing) {
-        return
-      }
-
-      if (!canPost) {
-        return
-      }
+  const onPressPublish = React.useCallback(async () => {
+    if (isPublishing) {
+      return
+    }
 
-      if (
-        !finishedUploading &&
-        thread.posts.some(
-          post =>
-            post.embed.media?.type === 'video' &&
-            post.embed.media.video.asset &&
-            post.embed.media.video.status !== 'done',
-        )
-      ) {
-        setPublishOnUpload(true)
-        return
-      }
+    if (!canPost) {
+      return
+    }
 
-      setError('')
-      setIsPublishing(true)
+    if (
+      thread.posts.some(
+        post =>
+          post.embed.media?.type === 'video' &&
+          post.embed.media.video.asset &&
+          post.embed.media.video.status !== 'done',
+      )
+    ) {
+      setPublishOnUpload(true)
+      return
+    }
 
-      let postUri
+    setError('')
+    setIsPublishing(true)
+
+    let postUri
+    try {
+      postUri = (
+        await apilib.post(agent, queryClient, {
+          thread,
+          replyTo: replyTo?.uri,
+          onStateChange: setPublishingStage,
+          langs: toPostLanguages(langPrefs.postLanguage),
+        })
+      ).uris[0]
       try {
-        postUri = (
-          await apilib.post(agent, queryClient, {
-            thread,
-            replyTo: replyTo?.uri,
-            onStateChange: setPublishingStage,
-            langs: toPostLanguages(langPrefs.postLanguage),
-          })
-        ).uris[0]
-        try {
-          await whenAppViewReady(agent, postUri, res => {
-            const postedThread = res.data.thread
-            return AppBskyFeedDefs.isThreadViewPost(postedThread)
-          })
-        } catch (waitErr: any) {
-          logger.error(waitErr, {
-            message: `Waiting for app view failed`,
-          })
-          // Keep going because the post *was* published.
-        }
-      } catch (e: any) {
-        logger.error(e, {
-          message: `Composer: create post failed`,
-          hasImages: thread.posts.some(p => p.embed.media?.type === 'images'),
+        await whenAppViewReady(agent, postUri, res => {
+          const postedThread = res.data.thread
+          return AppBskyFeedDefs.isThreadViewPost(postedThread)
         })
-
-        let err = cleanError(e.message)
-        if (err.includes('not locate record')) {
-          err = _(
-            msg`We're sorry! The post you are replying to has been deleted.`,
-          )
-        } else if (e instanceof EmbeddingDisabledError) {
-          err = _(msg`This post's author has disabled quote posts.`)
-        }
-        setError(err)
-        setIsPublishing(false)
-        return
-      } finally {
-        if (postUri) {
-          let index = 0
-          for (let post of thread.posts) {
-            logEvent('post:create', {
-              imageCount:
-                post.embed.media?.type === 'images'
-                  ? post.embed.media.images.length
-                  : 0,
-              isReply: index > 0 || !!replyTo,
-              hasLink: !!post.embed.link,
-              hasQuote: !!post.embed.quote,
-              langs: langPrefs.postLanguage,
-              logContext: 'Composer',
-            })
-            index++
-          }
-        }
+      } catch (waitErr: any) {
+        logger.error(waitErr, {
+          message: `Waiting for app view failed`,
+        })
+        // Keep going because the post *was* published.
       }
-      if (postUri && !replyTo) {
-        emitPostCreated()
+    } catch (e: any) {
+      logger.error(e, {
+        message: `Composer: create post failed`,
+        hasImages: thread.posts.some(p => p.embed.media?.type === 'images'),
+      })
+
+      let err = cleanError(e.message)
+      if (err.includes('not locate record')) {
+        err = _(
+          msg`We're sorry! The post you are replying to has been deleted.`,
+        )
+      } else if (e instanceof EmbeddingDisabledError) {
+        err = _(msg`This post's author has disabled quote posts.`)
       }
-      setLangPrefs.savePostLanguageToHistory()
-      if (initQuote) {
-        // We want to wait for the quote count to update before we call `onPost`, which will refetch data
-        whenAppViewReady(agent, initQuote.uri, res => {
-          const quotedThread = res.data.thread
-          if (
-            AppBskyFeedDefs.isThreadViewPost(quotedThread) &&
-            quotedThread.post.quoteCount !== initQuote.quoteCount
-          ) {
-            onPost?.(postUri)
-            return true
-          }
-          return false
-        })
-      } else {
-        onPost?.(postUri)
+      setError(err)
+      setIsPublishing(false)
+      return
+    } finally {
+      if (postUri) {
+        let index = 0
+        for (let post of thread.posts) {
+          logEvent('post:create', {
+            imageCount:
+              post.embed.media?.type === 'images'
+                ? post.embed.media.images.length
+                : 0,
+            isReply: index > 0 || !!replyTo,
+            hasLink: !!post.embed.link,
+            hasQuote: !!post.embed.quote,
+            langs: langPrefs.postLanguage,
+            logContext: 'Composer',
+          })
+          index++
+        }
       }
-      onClose()
-      Toast.show(
-        replyTo
-          ? _(msg`Your reply has been published`)
-          : _(msg`Your post has been published`),
-      )
-    },
-    [
-      _,
-      agent,
-      thread,
-      canPost,
-      isPublishing,
-      langPrefs.postLanguage,
-      onClose,
-      onPost,
-      initQuote,
-      replyTo,
-      setLangPrefs,
-      queryClient,
-    ],
-  )
+    }
+    if (postUri && !replyTo) {
+      emitPostCreated()
+    }
+    setLangPrefs.savePostLanguageToHistory()
+    if (initQuote) {
+      // We want to wait for the quote count to update before we call `onPost`, which will refetch data
+      whenAppViewReady(agent, initQuote.uri, res => {
+        const quotedThread = res.data.thread
+        if (
+          AppBskyFeedDefs.isThreadViewPost(quotedThread) &&
+          quotedThread.post.quoteCount !== initQuote.quoteCount
+        ) {
+          onPost?.(postUri)
+          return true
+        }
+        return false
+      })
+    } else {
+      onPost?.(postUri)
+    }
+    onClose()
+    Toast.show(
+      replyTo
+        ? _(msg`Your reply has been published`)
+        : _(msg`Your post has been published`),
+    )
+  }, [
+    _,
+    agent,
+    thread,
+    canPost,
+    isPublishing,
+    langPrefs.postLanguage,
+    onClose,
+    onPost,
+    initQuote,
+    replyTo,
+    setLangPrefs,
+    queryClient,
+  ])
+
+  // Preserves the referential identity passed to each post item.
+  // Avoids re-rendering all posts on each keystroke.
+  const onComposerPostPublish = useNonReactiveCallback(() => {
+    onPressPublish()
+  })
 
   React.useEffect(() => {
     if (publishOnUpload) {
+      let erroredVideos = 0
       let uploadingVideos = 0
       for (let post of thread.posts) {
         if (post.embed.media?.type === 'video') {
           const video = post.embed.media.video
-          if (!video.pendingPublish) {
+          if (video.status === 'error') {
+            erroredVideos++
+          } else if (video.status !== 'done') {
             uploadingVideos++
           }
         }
       }
-      if (uploadingVideos === 0) {
+      if (erroredVideos > 0) {
+        setPublishOnUpload(false)
+      } else if (uploadingVideos === 0) {
         setPublishOnUpload(false)
-        onPressPublish(true)
+        onPressPublish()
       }
     }
   }, [thread.posts, onPressPublish, publishOnUpload])
@@ -495,7 +503,16 @@ export const ComposePost = ({
     openEmojiPicker?.(textInput.current?.getCursorPosition())
   }, [openEmojiPicker])
 
+  const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
+  useEffect(() => {
+    if (composerState.mutableNeedsFocusActive) {
+      composerState.mutableNeedsFocusActive = false
+      textInput.current?.focus()
+    }
+  }, [composerState])
+
   const {
+    contentHeight,
     scrollHandler,
     onScrollViewContentSizeChange,
     onScrollViewLayout,
@@ -503,8 +520,44 @@ export const ComposePost = ({
     bottomBarAnimatedStyle,
   } = useAnimatedBorders()
 
+  useEffect(() => {
+    if (composerState.mutableNeedsScrollToBottom) {
+      composerState.mutableNeedsScrollToBottom = false
+      runOnUI(scrollTo)(scrollViewRef, 0, contentHeight.value, true)
+    }
+  }, [composerState, scrollViewRef, contentHeight])
+
   const keyboardVerticalOffset = useKeyboardVerticalOffset()
 
+  const footer = (
+    <>
+      <SuggestedLanguage text={activePost.richtext.text} />
+      <ComposerPills
+        isReply={!!replyTo}
+        post={activePost}
+        thread={composerState.thread}
+        dispatch={composerDispatch}
+        bottomBarAnimatedStyle={bottomBarAnimatedStyle}
+      />
+      <ComposerFooter
+        post={activePost}
+        dispatch={dispatch}
+        showAddButton={
+          !isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost))
+        }
+        onError={setError}
+        onEmojiButtonPress={onEmojiButtonPress}
+        onSelectVideo={selectVideo}
+        onAddPost={() => {
+          composerDispatch({
+            type: 'add_post',
+          })
+        }}
+      />
+    </>
+  )
+
+  const isFooterSticky = !isNative && thread.posts.length > 1
   return (
     <BottomSheetPortalProvider>
       <KeyboardAvoidingView
@@ -521,11 +574,12 @@ export const ComposePost = ({
             isReply={!!replyTo}
             isPublishQueued={publishOnUpload}
             isPublishing={isPublishing}
+            isThread={thread.posts.length > 1}
             publishingStage={publishingStage}
             topBarAnimatedStyle={topBarAnimatedStyle}
             onCancel={onPressCancel}
-            onPublish={() => onPressPublish(false)}>
-            {isAltTextRequiredAndMissing && <AltTextReminder />}
+            onPublish={onPressPublish}>
+            {missingAltError && <AltTextReminder error={missingAltError} />}
             <ErrorBanner
               error={error}
               videoState={erroredVideo}
@@ -539,6 +593,7 @@ export const ComposePost = ({
           </ComposerTopBar>
 
           <Animated.ScrollView
+            ref={scrollViewRef}
             layout={native(LinearTransition)}
             onScroll={scrollHandler}
             style={styles.scrollView}
@@ -546,37 +601,27 @@ export const ComposePost = ({
             onContentSizeChange={onScrollViewContentSizeChange}
             onLayout={onScrollViewLayout}>
             {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
-            <ComposerPost
-              key={activePost.id}
-              post={activePost}
-              dispatch={composerDispatch}
-              textInput={textInput}
-              isReply={!!replyTo}
-              canRemoveQuote={!initQuote}
-              onSelectVideo={asset => selectVideo(activePost.id, asset)}
-              onClearVideo={() => clearVideo(activePost.id)}
-              onPublish={() => onPressPublish(false)}
-              onError={setError}
-            />
+            {thread.posts.map((post, index) => (
+              <React.Fragment key={post.id}>
+                <ComposerPost
+                  post={post}
+                  dispatch={composerDispatch}
+                  textInput={post.id === activePost.id ? textInput : null}
+                  isFirstPost={index === 0}
+                  isReply={index > 0 || !!replyTo}
+                  isActive={post.id === activePost.id}
+                  canRemovePost={thread.posts.length > 1}
+                  canRemoveQuote={index > 0 || !initQuote}
+                  onSelectVideo={selectVideo}
+                  onClearVideo={clearVideo}
+                  onPublish={onComposerPostPublish}
+                  onError={setError}
+                />
+                {isFooterSticky && post.id === activePost.id && footer}
+              </React.Fragment>
+            ))}
           </Animated.ScrollView>
-
-          <React.Fragment key={activePost.id}>
-            <SuggestedLanguage text={activePost.richtext.text} />
-            <ComposerPills
-              isReply={!!replyTo}
-              post={activePost}
-              thread={composerState.thread}
-              dispatch={composerDispatch}
-              bottomBarAnimatedStyle={bottomBarAnimatedStyle}
-            />
-            <ComposerFooter
-              post={activePost}
-              dispatch={dispatch}
-              onError={setError}
-              onEmojiButtonPress={onEmojiButtonPress}
-              onSelectVideo={asset => selectVideo(activePost.id, asset)}
-            />
-          </React.Fragment>
+          {!isFooterSticky && footer}
         </View>
 
         <Prompt.Basic
@@ -592,11 +637,14 @@ export const ComposePost = ({
   )
 }
 
-function ComposerPost({
+let ComposerPost = React.memo(function ComposerPost({
   post,
   dispatch,
   textInput,
+  isActive,
   isReply,
+  isFirstPost,
+  canRemovePost,
   canRemoveQuote,
   onClearVideo,
   onSelectVideo,
@@ -606,10 +654,13 @@ function ComposerPost({
   post: PostDraft
   dispatch: (action: ComposerAction) => void
   textInput: React.Ref<TextInputRef>
+  isActive: boolean
   isReply: boolean
+  isFirstPost: boolean
+  canRemovePost: boolean
   canRemoveQuote: boolean
-  onClearVideo: () => void
-  onSelectVideo: (asset: ImagePickerAsset) => void
+  onClearVideo: (postId: string) => void
+  onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
   onError: (error: string) => void
   onPublish: (richtext: RichText) => void
 }) {
@@ -619,10 +670,13 @@ function ComposerPost({
   const {data: currentProfile} = useProfileQuery({did: currentDid})
   const richtext = post.richtext
   const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media
-  const forceMinHeight = isWeb && isTextOnly
+  const forceMinHeight = isWeb && isTextOnly && isActive
   const selectTextInputPlaceholder = isReply
-    ? _(msg`Write your reply`)
+    ? isFirstPost
+      ? _(msg`Write your reply`)
+      : _(msg`Add another post`)
     : _(msg`What's up?`)
+  const discardPromptControl = Prompt.usePromptControl()
 
   const dispatchPost = useCallback(
     (action: PostAction) => {
@@ -655,17 +709,17 @@ function ComposerPost({
   const onPhotoPasted = useCallback(
     async (uri: string) => {
       if (uri.startsWith('data:video/')) {
-        onSelectVideo({uri, type: 'video', height: 0, width: 0})
+        onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0})
       } else {
         const res = await pasteImage(uri)
         onImageAdd([res])
       }
     },
-    [onSelectVideo, onImageAdd],
+    [post.id, onSelectVideo, onImageAdd],
   )
 
   return (
-    <>
+    <View style={[styles.post, !isActive && styles.inactivePost]}>
       <View
         style={[
           styles.textInputLayout,
@@ -685,6 +739,12 @@ function ComposerPost({
           setRichText={rt => {
             dispatchPost({type: 'update_richtext', richtext: rt})
           }}
+          onFocus={() => {
+            dispatch({
+              type: 'focus_post',
+              postId: post.id,
+            })
+          }}
           onPhotoPasted={onPhotoPasted}
           onNewLink={onNewLink}
           onError={onError}
@@ -697,21 +757,65 @@ function ComposerPost({
         />
       </View>
 
+      {canRemovePost && isActive && (
+        <>
+          <Button
+            label={_(msg`Delete post`)}
+            size="small"
+            color="secondary"
+            variant="ghost"
+            shape="round"
+            style={[a.absolute, {top: 0, right: 0}]}
+            onPress={() => {
+              if (
+                post.shortenedGraphemeLength > 0 ||
+                post.embed.media ||
+                post.embed.link ||
+                post.embed.quote
+              ) {
+                discardPromptControl.open()
+              } else {
+                dispatch({
+                  type: 'remove_post',
+                  postId: post.id,
+                })
+              }
+            }}>
+            <ButtonIcon icon={X} />
+          </Button>
+          <Prompt.Basic
+            control={discardPromptControl}
+            title={_(msg`Discard post?`)}
+            description={_(msg`Are you sure you'd like to discard this post?`)}
+            onConfirm={() => {
+              dispatch({
+                type: 'remove_post',
+                postId: post.id,
+              })
+            }}
+            confirmButtonCta={_(msg`Discard`)}
+            confirmButtonColor="negative"
+          />
+        </>
+      )}
+
       <ComposerEmbeds
         canRemoveQuote={canRemoveQuote}
         embed={post.embed}
         dispatch={dispatchPost}
-        clearVideo={onClearVideo}
+        clearVideo={() => onClearVideo(post.id)}
+        isActivePost={isActive}
       />
-    </>
+    </View>
   )
-}
+})
 
 function ComposerTopBar({
   canPost,
   isReply,
   isPublishQueued,
   isPublishing,
+  isThread,
   publishingStage,
   onCancel,
   onPublish,
@@ -723,6 +827,7 @@ function ComposerTopBar({
   canPost: boolean
   isReply: boolean
   isPublishQueued: boolean
+  isThread: boolean
   onCancel: () => void
   onPublish: () => void
   topBarAnimatedStyle: StyleProp<ViewStyle>
@@ -769,6 +874,8 @@ function ComposerTopBar({
             <ButtonText style={[a.text_md]}>
               {isReply ? (
                 <Trans context="action">Reply</Trans>
+              ) : isThread ? (
+                <Trans context="action">Post All</Trans>
               ) : (
                 <Trans context="action">Post</Trans>
               )}
@@ -781,7 +888,7 @@ function ComposerTopBar({
   )
 }
 
-function AltTextReminder() {
+function AltTextReminder({error}: {error: string}) {
   const pal = usePalette('default')
   return (
     <View style={[styles.reminderLine, pal.viewLight]}>
@@ -792,9 +899,7 @@ function AltTextReminder() {
           size={10}
         />
       </View>
-      <Text style={[pal.text, a.flex_1]}>
-        <Trans>One or more images is missing alt text.</Trans>
-      </Text>
+      <Text style={[pal.text, a.flex_1]}>{error}</Text>
     </View>
   )
 }
@@ -804,11 +909,13 @@ function ComposerEmbeds({
   dispatch,
   clearVideo,
   canRemoveQuote,
+  isActivePost,
 }: {
   embed: EmbedDraft
   dispatch: (action: PostAction) => void
   clearVideo: () => void
   canRemoveQuote: boolean
+  isActivePost: boolean
 }) {
   const video = embed.media?.type === 'video' ? embed.media.video : null
   return (
@@ -860,6 +967,7 @@ function ComposerEmbeds({
                 <VideoPreview
                   asset={video.asset}
                   video={video.video}
+                  isActivePost={isActivePost}
                   setDimensions={(width: number, height: number) => {
                     dispatch({
                       type: 'embed_update_video',
@@ -988,15 +1096,19 @@ function ComposerPills({
 function ComposerFooter({
   post,
   dispatch,
+  showAddButton,
   onEmojiButtonPress,
   onError,
   onSelectVideo,
+  onAddPost,
 }: {
   post: PostDraft
   dispatch: (action: PostAction) => void
+  showAddButton: boolean
   onEmojiButtonPress: () => void
   onError: (error: string) => void
-  onSelectVideo: (asset: ImagePickerAsset) => void
+  onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
+  onAddPost: () => void
 }) {
   const t = useTheme()
   const {_} = useLingui()
@@ -1047,7 +1159,7 @@ function ComposerFooter({
               onAdd={onImageAdd}
             />
             <SelectVideoBtn
-              onSelectVideo={onSelectVideo}
+              onSelectVideo={asset => onSelectVideo(post.id, asset)}
               disabled={!!media}
               setError={onError}
             />
@@ -1072,6 +1184,21 @@ function ComposerFooter({
         )}
       </View>
       <View style={[a.flex_row, a.align_center, a.justify_between]}>
+        {showAddButton && (
+          <Button
+            label={_(msg`Add new post`)}
+            onPress={onAddPost}
+            style={[a.p_sm, a.m_2xs]}
+            variant="ghost"
+            shape="round"
+            color="primary">
+            <FontAwesomeIcon
+              icon="add"
+              size={20}
+              color={t.palette.primary_500}
+            />
+          </Button>
+        )}
         <SelectLangBtn />
         <CharProgress
           count={post.shortenedGraphemeLength}
@@ -1179,6 +1306,7 @@ function useAnimatedBorders() {
   })
 
   return {
+    contentHeight,
     scrollHandler,
     onScrollViewContentSizeChange,
     onScrollViewLayout,
@@ -1217,6 +1345,15 @@ async function whenAppViewReady(
   )
 }
 
+function isEmptyPost(post: PostDraft) {
+  return (
+    post.richtext.text.trim().length === 0 &&
+    !post.embed.media &&
+    !post.embed.link &&
+    !post.embed.quote
+  )
+}
+
 const styles = StyleSheet.create({
   topbarInner: {
     flexDirection: 'row',
@@ -1261,9 +1398,14 @@ const styles = StyleSheet.create({
     justifyContent: 'center',
     marginRight: 5,
   },
+  post: {
+    marginHorizontal: 16,
+  },
+  inactivePost: {
+    opacity: 0.5,
+  },
   scrollView: {
     flex: 1,
-    paddingHorizontal: 16,
   },
   textInputLayout: {
     flexDirection: 'row',
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index 5bd5abbc8..cfd2b9065 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -204,6 +204,7 @@ const styles = StyleSheet.create({
     paddingTop: 4,
     paddingBottom: 16,
     marginBottom: 12,
+    marginHorizontal: 16,
   },
   replyToPost: {
     flex: 1,
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index e65c5407a..af784b4f6 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -159,7 +159,10 @@ const GalleryItem = ({
   }
 
   return (
-    <View style={imageStyle}>
+    <View
+      style={imageStyle}
+      // Fixes ALT and icons appearing with half opacity when the post is inactive
+      renderToHardwareTextureAndroid>
       <TouchableOpacity
         testID="altTextButton"
         accessibilityRole="button"
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
index 695c84950..94dbc35c6 100644
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -7,6 +7,7 @@ import {
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {isNative} from '#/platform/detection'
 import {useModalControls} from '#/state/modals'
@@ -102,6 +103,7 @@ export function SelectLangBtn() {
       items={items}
       openUpwards
       style={styles.button}
+      hitSlop={LANG_DROPDOWN_HITSLOP}
       accessibilityLabel={_(msg`Language selection`)}
       accessibilityHint="">
       {postLanguagesPref.length > 0 ? (
@@ -121,7 +123,7 @@ export function SelectLangBtn() {
 
 const styles = StyleSheet.create({
   button: {
-    paddingHorizontal: 15,
+    marginHorizontal: 15,
   },
   label: {
     maxWidth: 100,
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index 4c94d5eac..27bed6d44 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -85,7 +85,9 @@ export type ThreadDraft = {
 
 export type ComposerState = {
   thread: ThreadDraft
-  activePostIndex: number // TODO: Add actions to update this.
+  activePostIndex: number
+  mutableNeedsFocusActive: boolean
+  mutableNeedsScrollToBottom: boolean
 }
 
 export type ComposerAction =
@@ -96,6 +98,17 @@ export type ComposerAction =
       postId: string
       postAction: PostAction
     }
+  | {
+      type: 'add_post'
+    }
+  | {
+      type: 'remove_post'
+      postId: string
+    }
+  | {
+      type: 'focus_post'
+      postId: string
+    }
 
 export const MAX_IMAGES = 4
 
@@ -142,6 +155,69 @@ export function composerReducer(
         },
       }
     }
+    case 'add_post': {
+      const activePostIndex = state.activePostIndex
+      const isAtTheEnd = activePostIndex === state.thread.posts.length - 1
+      const nextPosts = [...state.thread.posts]
+      nextPosts.splice(activePostIndex + 1, 0, {
+        id: nanoid(),
+        richtext: new RichText({text: ''}),
+        shortenedGraphemeLength: 0,
+        labels: [],
+        embed: {
+          quote: undefined,
+          media: undefined,
+          link: undefined,
+        },
+      })
+      return {
+        ...state,
+        mutableNeedsScrollToBottom: isAtTheEnd,
+        thread: {
+          ...state.thread,
+          posts: nextPosts,
+        },
+      }
+    }
+    case 'remove_post': {
+      if (state.thread.posts.length < 2) {
+        return state
+      }
+      let nextActivePostIndex = state.activePostIndex
+      const indexToRemove = state.thread.posts.findIndex(
+        p => p.id === action.postId,
+      )
+      let nextPosts = [...state.thread.posts]
+      if (indexToRemove !== -1) {
+        const postToRemove = state.thread.posts[indexToRemove]
+        if (postToRemove.embed.media?.type === 'video') {
+          postToRemove.embed.media.video.abortController.abort()
+        }
+        nextPosts.splice(indexToRemove, 1)
+        nextActivePostIndex = Math.max(0, indexToRemove - 1)
+      }
+      return {
+        ...state,
+        activePostIndex: nextActivePostIndex,
+        mutableNeedsFocusActive: true,
+        thread: {
+          ...state.thread,
+          posts: nextPosts,
+        },
+      }
+    }
+    case 'focus_post': {
+      const nextActivePostIndex = state.thread.posts.findIndex(
+        p => p.id === action.postId,
+      )
+      if (nextActivePostIndex === -1) {
+        return state
+      }
+      return {
+        ...state,
+        activePostIndex: nextActivePostIndex,
+      }
+    }
   }
 }
 
@@ -275,6 +351,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
       const prevMedia = state.embed.media
       let nextMedia = prevMedia
       if (prevMedia?.type === 'video') {
+        prevMedia.video.abortController.abort()
         nextMedia = undefined
       }
       let nextLabels = state.labels
@@ -436,6 +513,8 @@ export function createComposerState({
   })
   return {
     activePostIndex: 0,
+    mutableNeedsFocusActive: false,
+    mutableNeedsScrollToBottom: false,
     thread: {
       posts: [
         {
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 9da220c1b..f4cb004ed 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -47,6 +47,7 @@ interface TextInputProps {
   onPressPublish: (richtext: RichText) => void
   onNewLink: (uri: string) => void
   onError: (err: string) => void
+  onFocus: () => void
 }
 
 export const TextInput = React.forwardRef(function TextInputImpl(
@@ -58,6 +59,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     onPhotoPasted,
     onPressPublish,
     onNewLink,
+    onFocus,
   }: // onError, TODO
   TextInputProps,
   ref,
@@ -149,6 +151,9 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   const editor = useEditor(
     {
       extensions,
+      onFocus() {
+        onFocus?.()
+      },
       editorProps: {
         attributes: {
           class: modeClass,
@@ -244,8 +249,12 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   }, [onEmojiInserted])
 
   React.useImperativeHandle(ref, () => ({
-    focus: () => {}, // TODO
-    blur: () => {}, // TODO
+    focus: () => {
+      editor?.chain().focus()
+    },
+    blur: () => {
+      editor?.chain().blur()
+    },
     getCursorPosition: () => {
       const pos = editor?.state.selection.$anchor.pos
       return pos ? editor?.view.coordsAtPos(pos) : undefined
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 50a38f976..fff7545a5 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -9,14 +9,17 @@ import {useAutoplayDisabled} from '#/state/preferences'
 import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
 import {atoms as a, useTheme} from '#/alf'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
+import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
 
 export function VideoPreview({
   asset,
   video,
   clear,
+  isActivePost,
 }: {
   asset: ImagePickerAsset
   video: CompressedVideo
+  isActivePost: boolean
   setDimensions: (width: number, height: number) => void
   clear: () => void
 }) {
@@ -42,13 +45,18 @@ export function VideoPreview({
         t.atoms.border_contrast_low,
         {backgroundColor: 'black'},
       ]}>
-      <BlueskyVideoView
-        url={video.uri}
-        autoplay={!autoplayDisabled}
-        beginMuted={true}
-        forceTakeover={true}
-        ref={playerRef}
-      />
+      <View style={[a.absolute, a.inset_0]}>
+        <VideoTranscodeBackdrop uri={asset.uri} />
+      </View>
+      {isActivePost && (
+        <BlueskyVideoView
+          url={video.uri}
+          autoplay={!autoplayDisabled}
+          beginMuted={true}
+          forceTakeover={true}
+          ref={playerRef}
+        />
+      )}
       <ExternalEmbedRemoveBtn onRemove={clear} />
       {autoplayDisabled && (
         <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index a989cd5c5..f0751e45b 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -2,6 +2,7 @@ import React, {PropsWithChildren, useMemo, useRef} from 'react'
 import {
   Dimensions,
   GestureResponderEvent,
+  Insets,
   StyleProp,
   StyleSheet,
   TouchableOpacity,
@@ -64,6 +65,7 @@ interface DropdownButtonProps {
   openUpwards?: boolean
   rightOffset?: number
   bottomOffset?: number
+  hitSlop?: Insets
   accessibilityLabel?: string
   accessibilityHint?: string
 }
@@ -80,6 +82,7 @@ export function DropdownButton({
   openUpwards = false,
   rightOffset = 0,
   bottomOffset = 0,
+  hitSlop = HITSLOP_10,
   accessibilityLabel,
 }: PropsWithChildren<DropdownButtonProps>) {
   const {_} = useLingui()
@@ -152,7 +155,7 @@ export function DropdownButton({
         testID={testID}
         style={style}
         onPress={onPress}
-        hitSlop={HITSLOP_10}
+        hitSlop={hitSlop}
         ref={ref1}
         accessibilityRole="button"
         accessibilityLabel={