about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-07-30 08:25:31 -0700
committerGitHub <noreply@github.com>2024-07-30 08:25:31 -0700
commit8ddb28d3c54b63fb81ca361e741e5a6a46c1d25f (patch)
treeb62aa2dbfb2b9b8d84efbe9ff2a4ef6124dc1bed
parent43ba0f21f6796ebbdd0156c9fa89ebc7d56376e7 (diff)
downloadvoidsky-8ddb28d3c54b63fb81ca361e741e5a6a46c1d25f.tar.zst
[Video] Uploads (#4754)
* state for video uploads

* get upload working

* add a debug log

* add post progress

* progress

* fetch data

* add some progress info, web uploads

* post on finished uploading (wip)

* add a note

* add some todos

* clear video

* merge some stuff

* convert to `createUploadTask`

* patch expo modules core

* working native upload progress

* platform fork

* upload progress for web

* cleanup

* cleanup

* more tweaks

* simplify

* fix type errors

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
-rw-r--r--patches/expo-modules-core+1.12.11.patch12
-rw-r--r--src/lib/api/index.ts4
-rw-r--r--src/lib/media/video/types.ts36
-rw-r--r--src/state/queries/video/compress-video.ts31
-rw-r--r--src/state/queries/video/util.ts15
-rw-r--r--src/state/queries/video/video-upload.ts59
-rw-r--r--src/state/queries/video/video-upload.web.ts66
-rw-r--r--src/state/queries/video/video.ts212
-rw-r--r--src/state/shell/post-progress.tsx18
-rw-r--r--src/view/com/composer/Composer.tsx193
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx1
-rw-r--r--src/view/com/composer/videos/VideoTranscodeProgress.tsx8
-rw-r--r--src/view/com/composer/videos/state.ts51
13 files changed, 594 insertions, 112 deletions
diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch
index 4878bb9f7..a4ee027c8 100644
--- a/patches/expo-modules-core+1.12.11.patch
+++ b/patches/expo-modules-core+1.12.11.patch
@@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644
 
      Map<String, Object> constants = new HashMap<>(3);
      constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
+diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
+index 109d3fe..c7fce9e 100644
+--- a/node_modules/expo-modules-core/build/uuid/uuid.js
++++ b/node_modules/expo-modules-core/build/uuid/uuid.js
+@@ -1,5 +1,7 @@
+ import bytesToUuid from './lib/bytesToUuid';
+ import { Uuidv5Namespace } from './uuid.types';
++import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled';
++ensureNativeModulesAreInstalled();
+ const nativeUuidv4 = globalThis?.expo?.uuidv4;
+ const nativeUuidv5 = globalThis?.expo?.uuidv5;
+ function uuidv4() {
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 5b1c998cb..12e30bf6c 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -54,6 +54,10 @@ interface PostOpts {
     uri: string
     cid: string
   }
+  video?: {
+    uri: string
+    cid: string
+  }
   extLink?: ExternalEmbedDraft
   images?: ImageModel[]
   labels?: string[]
diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts
new file mode 100644
index 000000000..c458da96e
--- /dev/null
+++ b/src/lib/media/video/types.ts
@@ -0,0 +1,36 @@
+/**
+ * TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION.
+ * @temporary
+ * PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented.
+ * Not joking, this is temporary.
+ */
+
+export interface JobStatus {
+  jobId: string
+  did: string
+  cid: string
+  state: JobState
+  progress?: number
+  errorHuman?: string
+  errorMachine?: string
+}
+
+export enum JobState {
+  JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED',
+  JOB_STATE_CREATED = 'JOB_STATE_CREATED',
+  JOB_STATE_ENCODING = 'JOB_STATE_ENCODING',
+  JOB_STATE_ENCODED = 'JOB_STATE_ENCODED',
+  JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING',
+  JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED',
+  JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING',
+  JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED',
+  JOB_STATE_FAILED = 'JOB_STATE_FAILED',
+  JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED',
+}
+
+export interface UploadVideoResponse {
+  job_id: string
+  did: string
+  cid: string
+  state: JobState
+}
diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts
new file mode 100644
index 000000000..a2c739cfd
--- /dev/null
+++ b/src/state/queries/video/compress-video.ts
@@ -0,0 +1,31 @@
+import {ImagePickerAsset} from 'expo-image-picker'
+import {useMutation} from '@tanstack/react-query'
+
+import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
+
+export function useCompressVideoMutation({
+  onProgress,
+  onSuccess,
+  onError,
+}: {
+  onProgress: (progress: number) => void
+  onError: (e: any) => void
+  onSuccess: (video: CompressedVideo) => void
+}) {
+  return useMutation({
+    mutationFn: async (asset: ImagePickerAsset) => {
+      return await compressVideo(asset.uri, {
+        onProgress: num => onProgress(trunc2dp(num)),
+      })
+    },
+    onError,
+    onSuccess,
+    onMutate: () => {
+      onProgress(0)
+    },
+  })
+}
+
+function trunc2dp(num: number) {
+  return Math.trunc(num * 100) / 100
+}
diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts
new file mode 100644
index 000000000..266d8aee3
--- /dev/null
+++ b/src/state/queries/video/util.ts
@@ -0,0 +1,15 @@
+const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
+
+export const createVideoEndpointUrl = (
+  route: string,
+  params?: Record<string, string>,
+) => {
+  const url = new URL(`${UPLOAD_ENDPOINT}`)
+  url.pathname = route
+  if (params) {
+    for (const key in params) {
+      url.searchParams.set(key, params[key])
+    }
+  }
+  return url.href
+}
diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts
new file mode 100644
index 000000000..4d7f7995c
--- /dev/null
+++ b/src/state/queries/video/video-upload.ts
@@ -0,0 +1,59 @@
+import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
+import {useMutation} from '@tanstack/react-query'
+import {nanoid} from 'nanoid/non-secure'
+
+import {CompressedVideo} from 'lib/media/video/compress'
+import {UploadVideoResponse} from 'lib/media/video/types'
+import {createVideoEndpointUrl} from 'state/queries/video/util'
+import {useSession} from 'state/session'
+const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
+
+export const useUploadVideoMutation = ({
+  onSuccess,
+  onError,
+  setProgress,
+}: {
+  onSuccess: (response: UploadVideoResponse) => void
+  onError: (e: any) => void
+  setProgress: (progress: number) => void
+}) => {
+  const {currentAccount} = useSession()
+
+  return useMutation({
+    mutationFn: async (video: CompressedVideo) => {
+      const uri = createVideoEndpointUrl('/upload', {
+        did: currentAccount!.did,
+        name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
+      })
+
+      const uploadTask = createUploadTask(
+        uri,
+        video.uri,
+        {
+          headers: {
+            'dev-key': UPLOAD_HEADER,
+            'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
+          },
+          httpMethod: 'POST',
+          uploadType: FileSystemUploadType.BINARY_CONTENT,
+        },
+        p => {
+          setProgress(p.totalBytesSent / p.totalBytesExpectedToSend)
+        },
+      )
+      const res = await uploadTask.uploadAsync()
+
+      if (!res?.body) {
+        throw new Error('No response')
+      }
+
+      // @TODO rm, useful for debugging/getting video cid
+      console.log('[VIDEO]', res.body)
+      const responseBody = JSON.parse(res.body) as UploadVideoResponse
+      onSuccess(responseBody)
+      return responseBody
+    },
+    onError,
+    onSuccess,
+  })
+}
diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts
new file mode 100644
index 000000000..b5b9e93bf
--- /dev/null
+++ b/src/state/queries/video/video-upload.web.ts
@@ -0,0 +1,66 @@
+import {useMutation} from '@tanstack/react-query'
+import {nanoid} from 'nanoid/non-secure'
+
+import {CompressedVideo} from 'lib/media/video/compress'
+import {UploadVideoResponse} from 'lib/media/video/types'
+import {createVideoEndpointUrl} from 'state/queries/video/util'
+import {useSession} from 'state/session'
+const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
+
+export const useUploadVideoMutation = ({
+  onSuccess,
+  onError,
+  setProgress,
+}: {
+  onSuccess: (response: UploadVideoResponse) => void
+  onError: (e: any) => void
+  setProgress: (progress: number) => void
+}) => {
+  const {currentAccount} = useSession()
+
+  return useMutation({
+    mutationFn: async (video: CompressedVideo) => {
+      const uri = createVideoEndpointUrl('/upload', {
+        did: currentAccount!.did,
+        name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
+      })
+
+      const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
+
+      const xhr = new XMLHttpRequest()
+      const res = (await new Promise((resolve, reject) => {
+        xhr.upload.addEventListener('progress', e => {
+          const progress = e.loaded / e.total
+          setProgress(progress)
+        })
+        xhr.onloadend = () => {
+          if (xhr.readyState === 4) {
+            const uploadRes = JSON.parse(
+              xhr.responseText,
+            ) as UploadVideoResponse
+            resolve(uploadRes)
+            onSuccess(uploadRes)
+          } else {
+            reject()
+            onError(new Error('Failed to upload video'))
+          }
+        }
+        xhr.onerror = () => {
+          reject()
+          onError(new Error('Failed to upload video'))
+        }
+        xhr.open('POST', uri)
+        xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type?
+        // @TODO remove this header for prod
+        xhr.setRequestHeader('dev-key', UPLOAD_HEADER)
+        xhr.send(bytes)
+      })) as UploadVideoResponse
+
+      // @TODO rm for prod
+      console.log('[VIDEO]', res)
+      return res
+    },
+    onError,
+    onSuccess,
+  })
+}
diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts
new file mode 100644
index 000000000..295db38b4
--- /dev/null
+++ b/src/state/queries/video/video.ts
@@ -0,0 +1,212 @@
+import React from 'react'
+import {ImagePickerAsset} from 'expo-image-picker'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQuery} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {CompressedVideo} from 'lib/media/video/compress'
+import {VideoTooLargeError} from 'lib/media/video/errors'
+import {JobState, JobStatus} from 'lib/media/video/types'
+import {useCompressVideoMutation} from 'state/queries/video/compress-video'
+import {createVideoEndpointUrl} from 'state/queries/video/util'
+import {useUploadVideoMutation} from 'state/queries/video/video-upload'
+
+type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
+
+type Action =
+  | {
+      type: 'SetStatus'
+      status: Status
+    }
+  | {
+      type: 'SetProgress'
+      progress: number
+    }
+  | {
+      type: 'SetError'
+      error: string | undefined
+    }
+  | {type: 'Reset'}
+  | {type: 'SetAsset'; asset: ImagePickerAsset}
+  | {type: 'SetVideo'; video: CompressedVideo}
+  | {type: 'SetJobStatus'; jobStatus: JobStatus}
+
+export interface State {
+  status: Status
+  progress: number
+  asset?: ImagePickerAsset
+  video: CompressedVideo | null
+  jobStatus?: JobStatus
+  error?: string
+}
+
+function reducer(state: State, action: Action): State {
+  let updatedState = state
+  if (action.type === 'SetStatus') {
+    updatedState = {...state, status: action.status}
+  } else if (action.type === 'SetProgress') {
+    updatedState = {...state, progress: action.progress}
+  } else if (action.type === 'SetError') {
+    updatedState = {...state, error: action.error}
+  } else if (action.type === 'Reset') {
+    updatedState = {
+      status: 'idle',
+      progress: 0,
+      video: null,
+    }
+  } else if (action.type === 'SetAsset') {
+    updatedState = {...state, asset: action.asset}
+  } else if (action.type === 'SetVideo') {
+    updatedState = {...state, video: action.video}
+  } else if (action.type === 'SetJobStatus') {
+    updatedState = {...state, jobStatus: action.jobStatus}
+  }
+  return updatedState
+}
+
+export function useUploadVideo({
+  setStatus,
+  onSuccess,
+}: {
+  setStatus: (status: string) => void
+  onSuccess: () => void
+}) {
+  const {_} = useLingui()
+  const [state, dispatch] = React.useReducer(reducer, {
+    status: 'idle',
+    progress: 0,
+    video: null,
+  })
+
+  const {setJobId} = useUploadStatusQuery({
+    onStatusChange: (status: JobStatus) => {
+      // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
+      // Leaving it for now though
+      dispatch({
+        type: 'SetJobStatus',
+        jobStatus: status,
+      })
+      setStatus(status.state.toString())
+    },
+    onSuccess: () => {
+      dispatch({
+        type: 'SetStatus',
+        status: 'idle',
+      })
+      onSuccess()
+    },
+  })
+
+  const {mutate: onVideoCompressed} = useUploadVideoMutation({
+    onSuccess: response => {
+      dispatch({
+        type: 'SetStatus',
+        status: 'processing',
+      })
+      setJobId(response.job_id)
+    },
+    onError: e => {
+      dispatch({
+        type: 'SetError',
+        error: _(msg`An error occurred while uploading the video.`),
+      })
+      logger.error('Error uploading video', {safeMessage: e})
+    },
+    setProgress: p => {
+      dispatch({type: 'SetProgress', progress: p})
+    },
+  })
+
+  const {mutate: onSelectVideo} = useCompressVideoMutation({
+    onProgress: p => {
+      dispatch({type: 'SetProgress', progress: p})
+    },
+    onError: e => {
+      if (e instanceof VideoTooLargeError) {
+        dispatch({
+          type: 'SetError',
+          error: _(msg`The selected video is larger than 100MB.`),
+        })
+      } else {
+        dispatch({
+          type: 'SetError',
+          // @TODO better error message from server, left untranslated on purpose
+          error: 'An error occurred while compressing the video.',
+        })
+        logger.error('Error compressing video', {safeMessage: e})
+      }
+    },
+    onSuccess: (video: CompressedVideo) => {
+      dispatch({
+        type: 'SetVideo',
+        video,
+      })
+      dispatch({
+        type: 'SetStatus',
+        status: 'uploading',
+      })
+      onVideoCompressed(video)
+    },
+  })
+
+  const selectVideo = (asset: ImagePickerAsset) => {
+    dispatch({
+      type: 'SetAsset',
+      asset,
+    })
+    dispatch({
+      type: 'SetStatus',
+      status: 'compressing',
+    })
+    onSelectVideo(asset)
+  }
+
+  const clearVideo = () => {
+    // @TODO cancel any running jobs
+    dispatch({type: 'Reset'})
+  }
+
+  return {
+    state,
+    dispatch,
+    selectVideo,
+    clearVideo,
+  }
+}
+
+const useUploadStatusQuery = ({
+  onStatusChange,
+  onSuccess,
+}: {
+  onStatusChange: (status: JobStatus) => void
+  onSuccess: () => void
+}) => {
+  const [enabled, setEnabled] = React.useState(true)
+  const [jobId, setJobId] = React.useState<string>()
+
+  const {isLoading, isError} = useQuery({
+    queryKey: ['video-upload'],
+    queryFn: async () => {
+      const url = createVideoEndpointUrl(`/job/${jobId}/status`)
+      const res = await fetch(url)
+      const status = (await res.json()) as JobStatus
+      if (status.state === JobState.JOB_STATE_COMPLETED) {
+        setEnabled(false)
+        onSuccess()
+      }
+      onStatusChange(status)
+      return status
+    },
+    enabled: Boolean(jobId && enabled),
+    refetchInterval: 1500,
+  })
+
+  return {
+    isLoading,
+    isError,
+    setJobId: (_jobId: string) => {
+      setJobId(_jobId)
+    },
+  }
+}
diff --git a/src/state/shell/post-progress.tsx b/src/state/shell/post-progress.tsx
new file mode 100644
index 000000000..0df2a6be4
--- /dev/null
+++ b/src/state/shell/post-progress.tsx
@@ -0,0 +1,18 @@
+import React from 'react'
+
+interface PostProgressState {
+  progress: number
+  status: 'pending' | 'success' | 'error' | 'idle'
+  error?: string
+}
+
+const PostProgressContext = React.createContext<PostProgressState>({
+  progress: 0,
+  status: 'idle',
+})
+
+export function Provider() {}
+
+export function usePostProgress() {
+  return React.useContext(PostProgressContext)
+}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 72b6fae5f..08ce4441f 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -13,10 +13,16 @@ import {
   Keyboard,
   KeyboardAvoidingView,
   LayoutChangeEvent,
+  StyleProp,
   StyleSheet,
   View,
+  ViewStyle,
 } from 'react-native'
+// @ts-expect-error no type definition
+import ProgressCircle from 'react-native-progress/Circle'
 import Animated, {
+  FadeIn,
+  FadeOut,
   interpolateColor,
   useAnimatedStyle,
   useSharedValue,
@@ -55,6 +61,7 @@ import {
 import {useProfileQuery} from '#/state/queries/profile'
 import {Gif} from '#/state/queries/tenor'
 import {ThreadgateSetting} from '#/state/queries/threadgate'
+import {useUploadVideo} from '#/state/queries/video/video'
 import {useAgent, useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
 import {useAnalytics} from 'lib/analytics/analytics'
@@ -70,6 +77,7 @@ import {colors, s} from 'lib/styles'
 import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
 import {useDialogStateControlContext} from 'state/dialogs'
 import {GalleryModel} from 'state/models/media/gallery'
+import {State as VideoUploadState} from 'state/queries/video/video'
 import {ComposerOpts} from 'state/shell/composer'
 import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
 import {atoms as a, useTheme} from '#/alf'
@@ -96,7 +104,6 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
 import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
 import {useExternalLinkFetch} from './useExternalLinkFetch'
 import {SelectVideoBtn} from './videos/SelectVideoBtn'
-import {useVideoState} from './videos/state'
 import {VideoPreview} from './videos/VideoPreview'
 import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
 
@@ -159,14 +166,21 @@ export const ComposePost = observer(function ComposePost({
   const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
     initQuote,
   )
+
   const {
-    video,
-    onSelectVideo,
-    videoPending,
-    videoProcessingData,
+    selectVideo,
     clearVideo,
-    videoProcessingProgress,
-  } = useVideoState({setError})
+    state: videoUploadState,
+  } = useUploadVideo({
+    setStatus: (status: string) => setProcessingState(status),
+    onSuccess: () => {
+      if (publishOnUpload) {
+        onPressPublish(true)
+      }
+    },
+  })
+  const [publishOnUpload, setPublishOnUpload] = useState(false)
+
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
   const [extGif, setExtGif] = useState<Gif>()
   const [labels, setLabels] = useState<string[]>([])
@@ -274,7 +288,7 @@ export const ComposePost = observer(function ComposePost({
     return false
   }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
 
-  const onPressPublish = async () => {
+  const onPressPublish = async (finishedUploading?: boolean) => {
     if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
       return
     }
@@ -283,6 +297,15 @@ export const ComposePost = observer(function ComposePost({
       return
     }
 
+    if (
+      !finishedUploading &&
+      videoUploadState.status !== 'idle' &&
+      videoUploadState.asset
+    ) {
+      setPublishOnUpload(true)
+      return
+    }
+
     setError('')
 
     if (
@@ -387,8 +410,12 @@ export const ComposePost = observer(function ComposePost({
     : _(msg`What's up?`)
 
   const canSelectImages =
-    gallery.size < 4 && !extLink && !video && !videoPending
-  const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
+    gallery.size < 4 &&
+    !extLink &&
+    videoUploadState.status === 'idle' &&
+    !videoUploadState.video
+  const hasMedia =
+    gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
 
   const onEmojiButtonPress = useCallback(() => {
     openPicker?.(textInput.current?.getCursorPosition())
@@ -500,7 +527,10 @@ export const ComposePost = observer(function ComposePost({
                     shape="default"
                     size="small"
                     style={[a.rounded_full, a.py_sm]}
-                    onPress={onPressPublish}>
+                    onPress={() => onPressPublish()}
+                    disabled={
+                      videoUploadState.status !== 'idle' && publishOnUpload
+                    }>
                     <ButtonText style={[a.text_md]}>
                       {replyTo ? (
                         <Trans context="action">Reply</Trans>
@@ -572,7 +602,7 @@ export const ComposePost = observer(function ComposePost({
               autoFocus
               setRichText={setRichText}
               onPhotoPasted={onPhotoPasted}
-              onPressPublish={onPressPublish}
+              onPressPublish={() => onPressPublish()}
               onNewLink={onNewLink}
               onError={setError}
               accessible={true}
@@ -602,29 +632,33 @@ export const ComposePost = observer(function ComposePost({
             </View>
           )}
 
-          {quote ? (
-            <View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
-              <View style={{pointerEvents: 'none'}}>
-                <QuoteEmbed quote={quote} />
+          <View style={[a.mt_md]}>
+            {quote ? (
+              <View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
+                <View style={{pointerEvents: 'none'}}>
+                  <QuoteEmbed quote={quote} />
+                </View>
+                {quote.uri !== initQuote?.uri && (
+                  <QuoteX onRemove={() => setQuote(undefined)} />
+                )}
               </View>
-              {quote.uri !== initQuote?.uri && (
-                <QuoteX onRemove={() => setQuote(undefined)} />
-              )}
-            </View>
-          ) : null}
-          {videoPending && videoProcessingData ? (
-            <VideoTranscodeProgress
-              input={videoProcessingData}
-              progress={videoProcessingProgress}
-            />
-          ) : (
-            video && (
+            ) : null}
+            {videoUploadState.status === 'compressing' &&
+            videoUploadState.asset ? (
+              <VideoTranscodeProgress
+                asset={videoUploadState.asset}
+                progress={videoUploadState.progress}
+              />
+            ) : videoUploadState.video ? (
               // remove suspense when we get rid of lazy
               <Suspense fallback={null}>
-                <VideoPreview video={video} clear={clearVideo} />
+                <VideoPreview
+                  video={videoUploadState.video}
+                  clear={clearVideo}
+                />
               </Suspense>
-            )
-          )}
+            ) : null}
+          </View>
         </Animated.ScrollView>
         <SuggestedLanguage text={richtext.text} />
 
@@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({
             t.atoms.border_contrast_medium,
             styles.bottomBar,
           ]}>
-          <View style={[a.flex_row, a.align_center, a.gap_xs]}>
-            <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
-            {gate('videos') && (
-              <SelectVideoBtn
-                onSelectVideo={onSelectVideo}
-                disabled={!canSelectImages}
+          {videoUploadState.status !== 'idle' ? (
+            <VideoUploadToolbar state={videoUploadState} />
+          ) : (
+            <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
+              <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
+              {gate('videos') && (
+                <SelectVideoBtn
+                  onSelectVideo={selectVideo}
+                  disabled={!canSelectImages}
+                />
+              )}
+              <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
+              <SelectGifBtn
+                onClose={focusTextInput}
+                onSelectGif={onSelectGif}
+                disabled={hasMedia}
               />
-            )}
-            <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
-            <SelectGifBtn
-              onClose={focusTextInput}
-              onSelectGif={onSelectGif}
-              disabled={hasMedia}
-            />
-            {!isMobile ? (
-              <Button
-                onPress={onEmojiButtonPress}
-                style={a.p_sm}
-                label={_(msg`Open emoji picker`)}
-                accessibilityHint={_(msg`Open emoji picker`)}
-                variant="ghost"
-                shape="round"
-                color="primary">
-                <EmojiSmile size="lg" />
-              </Button>
-            ) : null}
-          </View>
+              {!isMobile ? (
+                <Button
+                  onPress={onEmojiButtonPress}
+                  style={a.p_sm}
+                  label={_(msg`Open emoji picker`)}
+                  accessibilityHint={_(msg`Open emoji picker`)}
+                  variant="ghost"
+                  shape="round"
+                  color="primary">
+                  <EmojiSmile size="lg" />
+                </Button>
+              ) : null}
+            </ToolbarWrapper>
+          )}
           <View style={a.flex_1} />
           <SelectLangBtn />
           <CharProgress count={graphemeLength} />
@@ -893,3 +931,44 @@ const styles = StyleSheet.create({
     borderTopWidth: StyleSheet.hairlineWidth,
   },
 })
+
+function ToolbarWrapper({
+  style,
+  children,
+}: {
+  style: StyleProp<ViewStyle>
+  children: React.ReactNode
+}) {
+  if (isWeb) return children
+  return (
+    <Animated.View
+      style={style}
+      entering={FadeIn.duration(400)}
+      exiting={FadeOut.duration(400)}>
+      {children}
+    </Animated.View>
+  )
+}
+
+function VideoUploadToolbar({state}: {state: VideoUploadState}) {
+  const t = useTheme()
+
+  const progress =
+    state.status === 'compressing' || state.status === 'uploading'
+      ? state.progress
+      : state.jobStatus?.progress ?? 100
+
+  return (
+    <ToolbarWrapper
+      style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
+      <ProgressCircle
+        size={30}
+        borderWidth={1}
+        borderColor={t.atoms.border_contrast_low.borderColor}
+        color={t.palette.primary_500}
+        progress={progress}
+      />
+      <Text>{state.status}</Text>
+    </ToolbarWrapper>
+  )
+}
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index b04cdf1c8..8e2a22852 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -17,6 +17,7 @@ export function VideoPreview({
   const player = useVideoPlayer(video.uri, player => {
     player.loop = true
     player.play()
+    player.volume = 0
   })
 
   return (
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
index 79407cd3e..db58448a3 100644
--- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
@@ -9,15 +9,15 @@ import {Text} from '#/components/Typography'
 import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
 
 export function VideoTranscodeProgress({
-  input,
+  asset,
   progress,
 }: {
-  input: ImagePickerAsset
+  asset: ImagePickerAsset
   progress: number
 }) {
   const t = useTheme()
 
-  const aspectRatio = input.width / input.height
+  const aspectRatio = asset.width / asset.height
 
   return (
     <View
@@ -29,7 +29,7 @@ export function VideoTranscodeProgress({
         a.overflow_hidden,
         {aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
       ]}>
-      <VideoTranscodeBackdrop uri={input.uri} />
+      <VideoTranscodeBackdrop uri={asset.uri} />
       <View
         style={[
           a.flex_1,
diff --git a/src/view/com/composer/videos/state.ts b/src/view/com/composer/videos/state.ts
deleted file mode 100644
index 3670f3d1f..000000000
--- a/src/view/com/composer/videos/state.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import {useState} from 'react'
-import {ImagePickerAsset} from 'expo-image-picker'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useMutation} from '@tanstack/react-query'
-
-import {compressVideo} from '#/lib/media/video/compress'
-import {logger} from '#/logger'
-import {VideoTooLargeError} from 'lib/media/video/errors'
-import * as Toast from 'view/com/util/Toast'
-
-export function useVideoState({setError}: {setError: (error: string) => void}) {
-  const {_} = useLingui()
-  const [progress, setProgress] = useState(0)
-
-  const {mutate, data, isPending, isError, reset, variables} = useMutation({
-    mutationFn: async (asset: ImagePickerAsset) => {
-      const compressed = await compressVideo(asset.uri, {
-        onProgress: num => setProgress(trunc2dp(num)),
-      })
-
-      return compressed
-    },
-    onError: (e: any) => {
-      // Don't log these errors in sentry, just let the user know
-      if (e instanceof VideoTooLargeError) {
-        Toast.show(_(msg`Videos cannot be larger than 100MB`), 'xmark')
-        return
-      }
-      logger.error('Failed to compress video', {safeError: e})
-      setError(_(msg`Could not compress video`))
-    },
-    onMutate: () => {
-      setProgress(0)
-    },
-  })
-
-  return {
-    video: data,
-    onSelectVideo: mutate,
-    videoPending: isPending,
-    videoProcessingData: variables,
-    videoError: isError,
-    clearVideo: reset,
-    videoProcessingProgress: progress,
-  }
-}
-
-function trunc2dp(num: number) {
-  return Math.trunc(num * 100) / 100
-}