about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/constants.ts1
-rw-r--r--src/lib/media/video/util.ts4
-rw-r--r--src/view/com/composer/Composer.tsx35
-rw-r--r--src/view/com/composer/state/video.ts13
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx42
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx1
-rw-r--r--src/view/com/composer/videos/VideoPreview.web.tsx86
-rw-r--r--src/view/com/composer/videos/pickVideo.ts21
-rw-r--r--src/view/com/composer/videos/pickVideo.web.ts94
9 files changed, 183 insertions, 114 deletions
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index cd9183c95..ee066d919 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -154,6 +154,7 @@ export const SUPPORTED_MIME_TYPES = [
   'video/mpeg',
   'video/webm',
   'video/quicktime',
+  'image/gif',
 ] as const
 
 export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
diff --git a/src/lib/media/video/util.ts b/src/lib/media/video/util.ts
index 87b422c2c..b80e0a4a1 100644
--- a/src/lib/media/video/util.ts
+++ b/src/lib/media/video/util.ts
@@ -32,6 +32,8 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
       return 'mpeg'
     case 'video/quicktime':
       return 'mov'
+    case 'image/gif':
+      return 'gif'
     default:
       throw new Error(`Unsupported mime type: ${mimeType}`)
   }
@@ -47,6 +49,8 @@ export function extToMime(ext: string) {
       return 'video/mpeg'
     case 'mov':
       return 'video/quicktime'
+    case 'gif':
+      return 'image/gif'
     default:
       throw new Error(`Unsupported file extension: ${ext}`)
   }
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 5d9f60766..e4b09cf0f 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -56,13 +56,18 @@ import {useQueryClient} from '@tanstack/react-query'
 import * as apilib from '#/lib/api/index'
 import {EmbeddingDisabledError} from '#/lib/api/resolve'
 import {until} from '#/lib/async/until'
-import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
+import {
+  MAX_GRAPHEME_LENGTH,
+  SUPPORTED_MIME_TYPES,
+  SupportedMimeTypes,
+} from '#/lib/constants'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {useEmail} from '#/lib/hooks/useEmail'
 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 {mimeToExt} from '#/lib/media/video/util'
 import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {colors, s} from '#/lib/styles'
@@ -130,6 +135,7 @@ import {
   ThreadDraft,
 } from './state/composer'
 import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
+import {getVideoMetadata} from './videos/pickVideo'
 import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
 
 type CancelRef = {
@@ -746,14 +752,24 @@ let ComposerPost = React.memo(function ComposerPost({
 
   const onPhotoPasted = useCallback(
     async (uri: string) => {
-      if (uri.startsWith('data:video/')) {
-        onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0})
+      if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) {
+        if (isNative) return // web only
+        const [mimeType] = uri.slice('data:'.length).split(';')
+        if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
+          Toast.show(_(msg`Unsupported video type`), 'xmark')
+          return
+        }
+        const name = `pasted.${mimeToExt(mimeType)}`
+        const file = await fetch(uri)
+          .then(res => res.blob())
+          .then(blob => new File([blob], name, {type: mimeType}))
+        onSelectVideo(post.id, await getVideoMetadata(file))
       } else {
         const res = await pasteImage(uri)
         onImageAdd([res])
       }
     },
-    [post.id, onSelectVideo, onImageAdd],
+    [post.id, onSelectVideo, onImageAdd, _],
   )
 
   return (
@@ -1009,17 +1025,6 @@ function ComposerEmbeds({
                   asset={video.asset}
                   video={video.video}
                   isActivePost={isActivePost}
-                  setDimensions={(width: number, height: number) => {
-                    dispatch({
-                      type: 'embed_update_video',
-                      videoAction: {
-                        type: 'update_dimensions',
-                        width,
-                        height,
-                        signal: video.abortController.signal,
-                      },
-                    })
-                  }}
                   clear={clearVideo}
                 />
               ) : null)}
diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts
index 8814a7e61..7ce4a0cf8 100644
--- a/src/view/com/composer/state/video.ts
+++ b/src/view/com/composer/state/video.ts
@@ -37,12 +37,6 @@ export type VideoAction =
     }
   | {type: 'update_progress'; progress: number; signal: AbortSignal}
   | {
-      type: 'update_dimensions'
-      width: number
-      height: number
-      signal: AbortSignal
-    }
-  | {
       type: 'update_alt_text'
       altText: string
       signal: AbortSignal
@@ -185,13 +179,6 @@ export function videoReducer(
         progress: action.progress,
       }
     }
-  } else if (action.type === 'update_dimensions') {
-    if (state.asset) {
-      return {
-        ...state,
-        asset: {...state.asset, width: action.width, height: action.height},
-      }
-    }
   } else if (action.type === 'update_alt_text') {
     return {
       ...state,
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
index ac9ae521c..1b052ccdd 100644
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ b/src/view/com/composer/videos/SelectVideoBtn.tsx
@@ -1,11 +1,6 @@
 import {useCallback} from 'react'
 import {Keyboard} from 'react-native'
-import {
-  ImagePickerAsset,
-  launchImageLibraryAsync,
-  MediaTypeOptions,
-  UIImagePickerPreferredAssetRepresentationMode,
-} from 'expo-image-picker'
+import {ImagePickerAsset} from 'expo-image-picker'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -22,6 +17,7 @@ import {useDialogControl} from '#/components/Dialog'
 import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
 import * as Prompt from '#/components/Prompt'
+import {pickVideo} from './pickVideo'
 
 const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds
 
@@ -52,24 +48,22 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
       Keyboard.dismiss()
       control.open()
     } else {
-      const response = await launchImageLibraryAsync({
-        exif: false,
-        mediaTypes: MediaTypeOptions.Videos,
-        quality: 1,
-        legacy: true,
-        preferredAssetRepresentationMode:
-          UIImagePickerPreferredAssetRepresentationMode.Current,
-      })
+      const response = await pickVideo()
       if (response.assets && response.assets.length > 0) {
         const asset = response.assets[0]
         try {
           if (isWeb) {
+            // asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
+            if (asset.duration && asset.duration > VIDEO_MAX_DURATION) {
+              throw Error(_(msg`Videos must be less than 60 seconds long`))
+            }
             // compression step on native converts to mp4, so no need to check there
-            const mimeType = getMimeType(asset)
             if (
-              !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)
+              !SUPPORTED_MIME_TYPES.includes(
+                asset.mimeType as SupportedMimeTypes,
+              )
             ) {
-              throw Error(_(msg`Unsupported video type: ${mimeType}`))
+              throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
             }
           } else {
             if (typeof asset.duration !== 'number') {
@@ -142,17 +136,3 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
     </>
   )
 }
-
-function getMimeType(asset: ImagePickerAsset) {
-  if (isWeb) {
-    const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
-    if (!mimeType) {
-      throw new Error('Could not determine mime type')
-    }
-    return mimeType
-  }
-  if (!asset.mimeType) {
-    throw new Error('Could not determine mime type')
-  }
-  return asset.mimeType
-}
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index fff7545a5..255174bea 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -20,7 +20,6 @@ export function VideoPreview({
   asset: ImagePickerAsset
   video: CompressedVideo
   isActivePost: boolean
-  setDimensions: (width: number, height: number) => void
   clear: () => void
 }) {
   const t = useTheme()
diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx
index 5b3f727a9..f20f8b383 100644
--- a/src/view/com/composer/videos/VideoPreview.web.tsx
+++ b/src/view/com/composer/videos/VideoPreview.web.tsx
@@ -1,4 +1,3 @@
-import {useEffect, useRef} from 'react'
 import {View} from 'react-native'
 import {ImagePickerAsset} from 'expo-image-picker'
 import {msg} from '@lingui/macro'
@@ -12,58 +11,22 @@ import * as Toast from '#/view/com/util/Toast'
 import {atoms as a} from '#/alf'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
 
-const MAX_DURATION = 60
-
 export function VideoPreview({
   asset,
   video,
-  setDimensions,
+
   clear,
 }: {
   asset: ImagePickerAsset
   video: CompressedVideo
-  setDimensions: (width: number, height: number) => void
+
   clear: () => void
 }) {
-  const ref = useRef<HTMLVideoElement>(null)
   const {_} = useLingui()
+  // TODO: figure out how to pause a GIF for reduced motion
+  // it's not possible using an img tag -sfn
   const autoplayDisabled = useAutoplayDisabled()
 
-  useEffect(() => {
-    if (!ref.current) return
-
-    const abortController = new AbortController()
-    const {signal} = abortController
-    ref.current.addEventListener(
-      'loadedmetadata',
-      function () {
-        setDimensions(this.videoWidth, this.videoHeight)
-        if (!isNaN(this.duration)) {
-          if (this.duration > MAX_DURATION) {
-            Toast.show(
-              _(msg`Videos must be less than 60 seconds long`),
-              'xmark',
-            )
-            clear()
-          }
-        }
-      },
-      {signal},
-    )
-    ref.current.addEventListener(
-      'error',
-      () => {
-        Toast.show(_(msg`Could not process your video`), 'xmark')
-        clear()
-      },
-      {signal},
-    )
-
-    return () => {
-      abortController.abort()
-    }
-  }, [setDimensions, _, clear])
-
   let aspectRatio = asset.width / asset.height
 
   if (isNaN(aspectRatio)) {
@@ -83,19 +46,34 @@ export function VideoPreview({
         a.relative,
       ]}>
       <ExternalEmbedRemoveBtn onRemove={clear} />
-      <video
-        ref={ref}
-        src={video.uri}
-        style={{width: '100%', height: '100%', objectFit: 'cover'}}
-        autoPlay={!autoplayDisabled}
-        loop
-        muted
-        playsInline
-      />
-      {autoplayDisabled && (
-        <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
-          <PlayButtonIcon />
-        </View>
+      {video.mimeType === 'image/gif' ? (
+        <img
+          src={video.uri}
+          style={{width: '100%', height: '100%', objectFit: 'cover'}}
+          alt="GIF"
+        />
+      ) : (
+        <>
+          <video
+            src={video.uri}
+            style={{width: '100%', height: '100%', objectFit: 'cover'}}
+            autoPlay={!autoplayDisabled}
+            loop
+            muted
+            playsInline
+            onError={err => {
+              console.error('Error loading video', err)
+              Toast.show(_(msg`Could not process your video`), 'xmark')
+              clear()
+            }}
+          />
+          {autoplayDisabled && (
+            <View
+              style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+              <PlayButtonIcon />
+            </View>
+          )}
+        </>
       )}
     </View>
   )
diff --git a/src/view/com/composer/videos/pickVideo.ts b/src/view/com/composer/videos/pickVideo.ts
new file mode 100644
index 000000000..0edf7d0de
--- /dev/null
+++ b/src/view/com/composer/videos/pickVideo.ts
@@ -0,0 +1,21 @@
+import {
+  ImagePickerAsset,
+  launchImageLibraryAsync,
+  MediaTypeOptions,
+  UIImagePickerPreferredAssetRepresentationMode,
+} from 'expo-image-picker'
+
+export async function pickVideo() {
+  return await launchImageLibraryAsync({
+    exif: false,
+    mediaTypes: MediaTypeOptions.Videos,
+    quality: 1,
+    legacy: true,
+    preferredAssetRepresentationMode:
+      UIImagePickerPreferredAssetRepresentationMode.Current,
+  })
+}
+
+export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => {
+  throw new Error('getVideoMetadata is web only')
+}
diff --git a/src/view/com/composer/videos/pickVideo.web.ts b/src/view/com/composer/videos/pickVideo.web.ts
new file mode 100644
index 000000000..56a38fa56
--- /dev/null
+++ b/src/view/com/composer/videos/pickVideo.web.ts
@@ -0,0 +1,94 @@
+import {ImagePickerAsset, ImagePickerResult} from 'expo-image-picker'
+
+import {SUPPORTED_MIME_TYPES} from '#/lib/constants'
+
+// mostly copied from expo-image-picker and adapted to support gifs
+// also adds support for reading video metadata
+
+export async function pickVideo(): Promise<ImagePickerResult> {
+  const input = document.createElement('input')
+  input.style.display = 'none'
+  input.setAttribute('type', 'file')
+  // TODO: do we need video/* here? -sfn
+  input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(','))
+  input.setAttribute('id', String(Math.random()))
+
+  document.body.appendChild(input)
+
+  return new Promise(resolve => {
+    input.addEventListener('change', async () => {
+      if (input.files) {
+        const file = input.files[0]
+        resolve({
+          canceled: false,
+          assets: [await getVideoMetadata(file)],
+        })
+      } else {
+        resolve({canceled: true, assets: null})
+      }
+      document.body.removeChild(input)
+    })
+
+    const event = new MouseEvent('click')
+    input.dispatchEvent(event)
+  })
+}
+
+// TODO: we're converting to a dataUrl here, and then converting back to an
+// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it
+// lets us use the ImagePickerAsset type, which the rest of the code expects.
+// We should unwind this and just pass the ArrayBuffer/objectUrl through the system
+// instead of a string -sfn
+export const getVideoMetadata = (file: File): Promise<ImagePickerAsset> => {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => {
+      const uri = reader.result as string
+
+      if (file.type === 'image/gif') {
+        const img = new Image()
+        img.onload = () => {
+          resolve({
+            uri,
+            mimeType: 'image/gif',
+            width: img.width,
+            height: img.height,
+            // todo: calculate gif duration. seems possible if you read the bytes
+            // https://codepen.io/Ryman/pen/nZpYwY
+            // for now let's just let the server reject it, since that seems uncommon -sfn
+            duration: null,
+          })
+        }
+        img.onerror = (_ev, _source, _lineno, _colno, error) => {
+          console.log('Failed to grab GIF metadata', error)
+          reject(new Error('Failed to grab GIF metadata'))
+        }
+        img.src = uri
+      } else {
+        const video = document.createElement('video')
+        const blobUrl = URL.createObjectURL(file)
+
+        video.preload = 'metadata'
+        video.src = blobUrl
+
+        video.onloadedmetadata = () => {
+          URL.revokeObjectURL(blobUrl)
+          resolve({
+            uri,
+            mimeType: file.type,
+            width: video.videoWidth,
+            height: video.videoHeight,
+            // convert seconds to ms
+            duration: video.duration * 1000,
+          })
+        }
+        video.onerror = (_ev, _source, _lineno, _colno, error) => {
+          URL.revokeObjectURL(blobUrl)
+          console.log('Failed to grab video metadata', error)
+          reject(new Error('Failed to grab video metadata'))
+        }
+      }
+    }
+    reader.readAsDataURL(file)
+  })
+}