From 7a08d61d889328ff5e3b8ba61faab71a5568df2f Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Fri, 1 Nov 2024 03:45:55 +0000
Subject: Thread composer UI (#6050)

* Basic adding of posts

* Switch active post on focus

* Conditionally show plus button

* Insert posts midthread

* Track active/inactive post

* Delete posts in a thread

* Focus after deletion

* Tweak empty post detection

* Mix height for active only

* Move toolbar with post on web

* Fix footer positioning

* Post All button

* Fix reply to positioning

* Improve memoization

* Improve memoization for clearVideo

* Remove unnecessary argument

* Add some manual memoization to fix re-renders

* Scroll to bottom on add new

* Fix opacity on Android

* Add backdrop

* Fix videos

* Check alt for video too

* Clear pending publish on error

* Fork alt message by type

* Separate placeholder for next posts

* Limit hitslop to avoid clashes
---
 src/view/com/composer/Composer.tsx | 540 +++++++++++++++++++++++--------------
 1 file changed, 341 insertions(+), 199 deletions(-)

(limited to 'src/view/com/composer/Composer.tsx')

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',
-- 
cgit 1.4.1

