about summary refs log tree commit diff
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-11-01 03:37:30 +0000
committerGitHub <noreply@github.com>2024-11-01 03:37:30 +0000
commit4c31403330abeba2c0b9e910239c18672e5fcb0d (patch)
tree572883bbe0fa28a64d86d39d039c4c7155fd648a
parent68bb45105160597f420e401822837d4de675cb30 (diff)
downloadvoidsky-4c31403330abeba2c0b9e910239c18672e5fcb0d.tar.zst
Check video state for the entire thread (#5957)
* Switch to using post ID for post actions

* Pass post-bound dispatch to ComposerPost

* Check video state for entire thread

* Always bind post actions to an ID

* Rename variable for consistency

* Fix clashing keys
-rw-r--r--src/view/com/composer/Composer.tsx212
-rw-r--r--src/view/com/composer/state/composer.ts23
-rw-r--r--src/view/com/composer/state/video.ts3
3 files changed, 162 insertions, 76 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 1cc646b40..b464a88fc 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -55,6 +55,7 @@ import {until} from '#/lib/async/until'
 import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {logEvent} from '#/lib/statsig/statsig'
@@ -168,46 +169,78 @@ export const ComposePost = ({
 
   // TODO: Display drafts for other posts in the thread.
   const thread = composerState.thread
-  const draft = thread.posts[composerState.activePostIndex]
-  const dispatch = useCallback((postAction: PostAction) => {
-    composerDispatch({
-      type: 'update_post',
-      postAction,
-    })
-  }, [])
-
-  let videoState: VideoState | NoVideoState = NO_VIDEO
-  if (draft.embed.media?.type === 'video') {
-    videoState = draft.embed.media.video
-  }
+  const activePost = thread.posts[composerState.activePostIndex]
+  const dispatch = useCallback(
+    (postAction: PostAction) => {
+      composerDispatch({
+        type: 'update_post',
+        postId: activePost.id,
+        postAction,
+      })
+    },
+    [activePost.id],
+  )
 
   const selectVideo = React.useCallback(
-    (asset: ImagePickerAsset) => {
+    (postId: string, asset: ImagePickerAsset) => {
       const abortController = new AbortController()
-      dispatch({type: 'embed_add_video', asset, abortController})
+      composerDispatch({
+        type: 'update_post',
+        postId: postId,
+        postAction: {
+          type: 'embed_add_video',
+          asset,
+          abortController,
+        },
+      })
       processVideo(
         asset,
-        videoAction => dispatch({type: 'embed_update_video', videoAction}),
+        videoAction => {
+          composerDispatch({
+            type: 'update_post',
+            postId: postId,
+            postAction: {
+              type: 'embed_update_video',
+              videoAction,
+            },
+          })
+        },
         agent,
         currentDid,
         abortController.signal,
         _,
       )
     },
-    [_, agent, currentDid, dispatch],
+    [_, agent, currentDid, composerDispatch],
   )
 
-  // Whenever we receive an initial video uri, we should immediately run compression if necessary
-  useEffect(() => {
+  const onInitVideo = useNonReactiveCallback(() => {
     if (initVideoUri) {
-      selectVideo(initVideoUri)
+      selectVideo(activePost.id, initVideoUri)
     }
-  }, [initVideoUri, selectVideo])
+  })
 
-  const clearVideo = React.useCallback(() => {
-    videoState.abortController.abort()
-    dispatch({type: 'embed_remove_video'})
-  }, [videoState.abortController, dispatch])
+  useEffect(() => {
+    onInitVideo()
+  }, [onInitVideo])
+
+  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',
+          },
+        })
+      }
+    },
+    [thread, composerDispatch],
+  )
 
   const [publishOnUpload, setPublishOnUpload] = useState(false)
 
@@ -425,13 +458,38 @@ export const ComposePost = ({
   )
 
   React.useEffect(() => {
-    if (videoState.pendingPublish && publishOnUpload) {
-      if (!videoState.pendingPublish.mutableProcessed) {
-        videoState.pendingPublish.mutableProcessed = true
+    if (publishOnUpload) {
+      let uploadingVideos = 0
+      for (let post of thread.posts) {
+        if (post.embed.media?.type === 'video') {
+          const video = post.embed.media.video
+          if (!video.pendingPublish) {
+            uploadingVideos++
+          }
+        }
+      }
+      if (uploadingVideos === 0) {
+        setPublishOnUpload(false)
         onPressPublish(true)
       }
     }
-  }, [onPressPublish, publishOnUpload, videoState.pendingPublish])
+  }, [thread.posts, onPressPublish, publishOnUpload])
+
+  // TODO: It might make more sense to display this error per-post.
+  // Right now we're just displaying the first one.
+  let erroredVideoPostId: string | undefined
+  let erroredVideo: VideoState | NoVideoState = NO_VIDEO
+  for (let i = 0; i < thread.posts.length; i++) {
+    const post = thread.posts[i]
+    if (
+      post.embed.media?.type === 'video' &&
+      post.embed.media.video.status === 'error'
+    ) {
+      erroredVideoPostId = post.id
+      erroredVideo = post.embed.media.video
+      break
+    }
+  }
 
   const onEmojiButtonPress = useCallback(() => {
     openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -461,7 +519,7 @@ export const ComposePost = ({
           <ComposerTopBar
             canPost={canPost}
             isReply={!!replyTo}
-            isPublishQueued={videoState.status !== 'idle' && publishOnUpload}
+            isPublishQueued={publishOnUpload}
             isPublishing={isPublishing}
             publishingStage={publishingStage}
             topBarAnimatedStyle={topBarAnimatedStyle}
@@ -470,9 +528,13 @@ export const ComposePost = ({
             {isAltTextRequiredAndMissing && <AltTextReminder />}
             <ErrorBanner
               error={error}
-              videoState={videoState}
+              videoState={erroredVideo}
               clearError={() => setError('')}
-              clearVideo={clearVideo}
+              clearVideo={
+                erroredVideoPostId
+                  ? () => clearVideo(erroredVideoPostId)
+                  : () => {}
+              }
             />
           </ComposerTopBar>
 
@@ -485,35 +547,36 @@ export const ComposePost = ({
             onLayout={onScrollViewLayout}>
             {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
             <ComposerPost
-              draft={draft}
-              dispatch={dispatch}
+              key={activePost.id}
+              post={activePost}
+              dispatch={composerDispatch}
               textInput={textInput}
               isReply={!!replyTo}
               canRemoveQuote={!initQuote}
-              onSelectVideo={selectVideo}
-              onClearVideo={clearVideo}
+              onSelectVideo={asset => selectVideo(activePost.id, asset)}
+              onClearVideo={() => clearVideo(activePost.id)}
               onPublish={() => onPressPublish(false)}
               onError={setError}
             />
           </Animated.ScrollView>
 
-          <SuggestedLanguage text={draft.richtext.text} />
-
-          <ComposerPills
-            isReply={!!replyTo}
-            post={draft}
-            thread={composerState.thread}
-            dispatch={composerDispatch}
-            bottomBarAnimatedStyle={bottomBarAnimatedStyle}
-          />
-
-          <ComposerFooter
-            draft={draft}
-            dispatch={dispatch}
-            onError={setError}
-            onEmojiButtonPress={onEmojiButtonPress}
-            onSelectVideo={selectVideo}
-          />
+          <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>
         </View>
 
         <Prompt.Basic
@@ -530,7 +593,7 @@ export const ComposePost = ({
 }
 
 function ComposerPost({
-  draft,
+  post,
   dispatch,
   textInput,
   isReply,
@@ -540,8 +603,8 @@ function ComposerPost({
   onError,
   onPublish,
 }: {
-  draft: PostDraft
-  dispatch: (action: PostAction) => void
+  post: PostDraft
+  dispatch: (action: ComposerAction) => void
   textInput: React.Ref<TextInputRef>
   isReply: boolean
   canRemoveQuote: boolean
@@ -554,29 +617,39 @@ function ComposerPost({
   const currentDid = currentAccount!.did
   const {_} = useLingui()
   const {data: currentProfile} = useProfileQuery({did: currentDid})
-  const richtext = draft.richtext
-  const isTextOnly =
-    !draft.embed.link && !draft.embed.quote && !draft.embed.media
+  const richtext = post.richtext
+  const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media
   const forceMinHeight = isWeb && isTextOnly
   const selectTextInputPlaceholder = isReply
     ? _(msg`Write your reply`)
     : _(msg`What's up?`)
 
+  const dispatchPost = useCallback(
+    (action: PostAction) => {
+      dispatch({
+        type: 'update_post',
+        postId: post.id,
+        postAction: action,
+      })
+    },
+    [dispatch, post.id],
+  )
+
   const onImageAdd = useCallback(
     (next: ComposerImage[]) => {
-      dispatch({
+      dispatchPost({
         type: 'embed_add_images',
         images: next,
       })
     },
-    [dispatch],
+    [dispatchPost],
   )
 
   const onNewLink = useCallback(
     (uri: string) => {
-      dispatch({type: 'embed_add_uri', uri})
+      dispatchPost({type: 'embed_add_uri', uri})
     },
-    [dispatch],
+    [dispatchPost],
   )
 
   const onPhotoPasted = useCallback(
@@ -610,7 +683,7 @@ function ComposerPost({
           autoFocus
           webForceMinHeight={forceMinHeight}
           setRichText={rt => {
-            dispatch({type: 'update_richtext', richtext: rt})
+            dispatchPost({type: 'update_richtext', richtext: rt})
           }}
           onPhotoPasted={onPhotoPasted}
           onNewLink={onNewLink}
@@ -626,8 +699,8 @@ function ComposerPost({
 
       <ComposerEmbeds
         canRemoveQuote={canRemoveQuote}
-        embed={draft.embed}
-        dispatch={dispatch}
+        embed={post.embed}
+        dispatch={dispatchPost}
         clearVideo={onClearVideo}
       />
     </>
@@ -898,6 +971,7 @@ function ComposerPills({
             onChange={nextLabels => {
               dispatch({
                 type: 'update_post',
+                postId: post.id,
                 postAction: {
                   type: 'update_labels',
                   labels: nextLabels,
@@ -912,13 +986,13 @@ function ComposerPills({
 }
 
 function ComposerFooter({
-  draft,
+  post,
   dispatch,
   onEmojiButtonPress,
   onError,
   onSelectVideo,
 }: {
-  draft: PostDraft
+  post: PostDraft
   dispatch: (action: PostAction) => void
   onEmojiButtonPress: () => void
   onError: (error: string) => void
@@ -928,7 +1002,7 @@ function ComposerFooter({
   const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
 
-  const media = draft.embed.media
+  const media = post.embed.media
   const images = media?.type === 'images' ? media.images : []
   const video = media?.type === 'video' ? media.video : null
   const isMaxImages = images.length >= MAX_IMAGES
@@ -1000,7 +1074,7 @@ function ComposerFooter({
       <View style={[a.flex_row, a.align_center, a.justify_between]}>
         <SelectLangBtn />
         <CharProgress
-          count={draft.shortenedGraphemeLength}
+          count={post.shortenedGraphemeLength}
           style={{width: 65}}
         />
       </View>
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index 353600ef6..4c94d5eac 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -1,5 +1,6 @@
 import {ImagePickerAsset} from 'expo-image-picker'
 import {AppBskyFeedPostgate, RichText} from '@atproto/api'
+import {nanoid} from 'nanoid/non-secure'
 
 import {SelfLabel} from '#/lib/moderation'
 import {insertMentionAt} from '#/lib/strings/mention-manip'
@@ -49,6 +50,7 @@ export type EmbedDraft = {
 }
 
 export type PostDraft = {
+  id: string
   richtext: RichText
   labels: SelfLabel[]
   embed: EmbedDraft
@@ -89,7 +91,11 @@ export type ComposerState = {
 export type ComposerAction =
   | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record}
   | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]}
-  | {type: 'update_post'; postAction: PostAction}
+  | {
+      type: 'update_post'
+      postId: string
+      postAction: PostAction
+    }
 
 export const MAX_IMAGES = 4
 
@@ -117,11 +123,17 @@ export function composerReducer(
       }
     }
     case 'update_post': {
-      const nextPosts = [...state.thread.posts]
-      nextPosts[state.activePostIndex] = postReducer(
-        state.thread.posts[state.activePostIndex],
-        action.postAction,
+      let nextPosts = state.thread.posts
+      const postIndex = state.thread.posts.findIndex(
+        p => p.id === action.postId,
       )
+      if (postIndex !== -1) {
+        nextPosts = state.thread.posts.slice()
+        nextPosts[postIndex] = postReducer(
+          state.thread.posts[postIndex],
+          action.postAction,
+        )
+      }
       return {
         ...state,
         thread: {
@@ -427,6 +439,7 @@ export function createComposerState({
     thread: {
       posts: [
         {
+          id: nanoid(),
           richtext: initRichText,
           shortenedGraphemeLength: 0,
           labels: [],
diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts
index e29687200..8814a7e61 100644
--- a/src/view/com/composer/state/video.ts
+++ b/src/view/com/composer/state/video.ts
@@ -132,7 +132,7 @@ type DoneState = {
   asset: ImagePickerAsset
   video: CompressedVideo
   jobId?: undefined
-  pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean}
+  pendingPublish: {blobRef: BlobRef}
   altText: string
   captions: CaptionsTrack[]
 }
@@ -250,7 +250,6 @@ export function videoReducer(
         video: state.video,
         pendingPublish: {
           blobRef: action.blobRef,
-          mutableProcessed: false,
         },
         altText: state.altText,
         captions: state.captions,