about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--package.json2
-rw-r--r--patches/expo-image-picker+16.1.4.patch38
-rw-r--r--patches/expo-image-picker+16.1.4.patch.md5
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx2
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx16
-rw-r--r--src/lib/constants.ts4
-rw-r--r--src/lib/haptics.ts4
-rw-r--r--src/lib/media/picker.shared.ts6
-rw-r--r--src/lib/media/video/compress.ts13
-rw-r--r--src/view/com/composer/Composer.tsx268
-rw-r--r--src/view/com/composer/SelectMediaButton.tsx524
-rw-r--r--src/view/com/composer/photos/ImageAltTextDialog.tsx5
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx60
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx88
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx31
-rw-r--r--yarn.lock15
17 files changed, 763 insertions, 319 deletions
diff --git a/.gitignore b/.gitignore
index 7233b3547..0bef5d88a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,6 +58,7 @@ buck-out/
 # Ruby / CocoaPods
 /ios/Pods/
 /vendor/bundle/
+Gemfile.lock
 
 # Testing
 coverage/
diff --git a/package.json b/package.json
index ee5d276f4..0f3f68571 100644
--- a/package.json
+++ b/package.json
@@ -146,7 +146,7 @@
     "expo-image": "^2.4.0",
     "expo-image-crop-tool": "^0.1.8",
     "expo-image-manipulator": "~13.1.7",
-    "expo-image-picker": "~16.1.4",
+    "expo-image-picker": "^17.0.2",
     "expo-intent-launcher": "^12.1.5",
     "expo-linear-gradient": "~14.1.5",
     "expo-linking": "~7.1.5",
diff --git a/patches/expo-image-picker+16.1.4.patch b/patches/expo-image-picker+16.1.4.patch
deleted file mode 100644
index 0396fecbc..000000000
--- a/patches/expo-image-picker+16.1.4.patch
+++ /dev/null
@@ -1,38 +0,0 @@
-diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
-index c863fb8..cde8859 100644
---- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
-+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
-@@ -101,16 +101,30 @@ internal class MediaHandler(
-       val fileData = getAdditionalFileData(sourceUri)
-       val mimeType = getType(context.contentResolver, sourceUri)
- 
-+      // Extract basic metadata
-+      var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
-+      var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
-+      val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
-+
-+      // Android returns the encoded width/height which do not take the display rotation into
-+      // account. For videos recorded in portrait mode the encoded dimensions are often landscape
-+      // (e.g. 1920x1080) paired with a 90°/270° rotation flag.  iOS adjusts these values before
-+      // reporting them, so to keep the behaviour consistent across platforms we swap the width
-+      // and height when the rotation indicates the video should be displayed in portrait.
-+      if (rotation % 180 != 0) {
-+        width = height.also { height = width }
-+      }
-+
-       return ImagePickerAsset(
-         type = MediaType.VIDEO,
-         uri = outputUri.toString(),
--        width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
--        height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
-+        width = width,
-+        height = height,
-         fileName = fileData?.fileName,
-         fileSize = fileData?.fileSize,
-         mimeType = mimeType,
-         duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
--        rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION),
-+        rotation = rotation,
-         assetId = sourceUri.getMediaStoreAssetId()
-       )
-     } catch (cause: FailedToExtractVideoMetadataException) {
diff --git a/patches/expo-image-picker+16.1.4.patch.md b/patches/expo-image-picker+16.1.4.patch.md
deleted file mode 100644
index 7855e8621..000000000
--- a/patches/expo-image-picker+16.1.4.patch.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Expo Image Picker patch
-
-Cherry-picked https://github.com/expo/expo/pull/37849
-
-Remove when we update to a version that includes this commit.
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
index d84a90fa6..e4814462f 100644
--- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
@@ -146,6 +146,8 @@ export function Scrubber({
   const progress = scrubberActive ? seekPosition : currentTime
   const progressPercent = (progress / duration) * 100
 
+  if (duration < 3) return null
+
   return (
     <View
       testID="scrubber"
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
index 676b52661..7a54ef486 100644
--- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
@@ -373,13 +373,15 @@ export function Controls({
             onPress={onPressPlayPause}
           />
           <View style={a.flex_1} />
-          <Text
-            style={[
-              a.px_xs,
-              {color: t.palette.white, fontVariant: ['tabular-nums']},
-            ]}>
-            {formatTime(currentTime)} / {formatTime(duration)}
-          </Text>
+          {Math.round(duration) > 0 && (
+            <Text
+              style={[
+                a.px_xs,
+                {color: t.palette.white, fontVariant: ['tabular-nums']},
+              ]}>
+              {formatTime(currentTime)} / {formatTime(duration)}
+            </Text>
+          )}
           {hasSubtitleTrack && (
             <ControlButton
               active={subtitlesEnabled}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 21f0ab870..130722b9c 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -181,6 +181,10 @@ export const VIDEO_SERVICE = 'https://video.bsky.app'
 export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
 
 export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
+/**
+ * Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
+ * ISO megabytes.
+ */
 export const VIDEO_MAX_SIZE = 1000 * 1000 * 100 // 100mb
 
 export const SUPPORTED_MIME_TYPES = [
diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts
index 234be777d..32e371644 100644
--- a/src/lib/haptics.ts
+++ b/src/lib/haptics.ts
@@ -4,7 +4,6 @@ import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
 
 import {isIOS, isWeb} from '#/platform/detection'
 import {useHapticsDisabled} from '#/state/preferences/disable-haptics'
-import * as Toast from '#/view/com/util/Toast'
 
 export function useHaptics() {
   const isHapticsDisabled = useHapticsDisabled()
@@ -23,7 +22,8 @@ export function useHaptics() {
 
       // DEV ONLY - show a toast when a haptic is meant to fire on simulator
       if (__DEV__ && !Device.isDevice) {
-        Toast.show(`Buzzz!`)
+        // disabled because it's annoying
+        // Toast.show(`Buzzz!`)
       }
     },
     [isHapticsDisabled],
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index 8fd76f414..8ec1154c8 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -17,16 +17,12 @@ export async function openPicker(opts?: ImagePickerOptions) {
     exif: false,
     mediaTypes: ['images'],
     quality: 1,
+    selectionLimit: 1,
     ...opts,
     legacy: true,
   })
 
-  if (response.assets && response.assets.length > 4) {
-    Toast.show(t`You may only select up to 4 images`, 'exclamation-circle')
-  }
-
   return (response.assets ?? [])
-    .slice(0, 4)
     .filter(asset => {
       if (asset.mimeType?.startsWith('image/')) return true
       Toast.show(t`Only image files are supported`, 'exclamation-circle')
diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts
index c2d1470c6..1d00bfcea 100644
--- a/src/lib/media/video/compress.ts
+++ b/src/lib/media/video/compress.ts
@@ -1,8 +1,8 @@
 import {getVideoMetaData, Video} from 'react-native-compressor'
-import {ImagePickerAsset} from 'expo-image-picker'
+import {type ImagePickerAsset} from 'expo-image-picker'
 
-import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
-import {CompressedVideo} from './types'
+import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
+import {type CompressedVideo} from './types'
 import {extToMime} from './util'
 
 const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
@@ -20,6 +20,13 @@ export async function compressVideo(
     file.mimeType as SupportedMimeTypes,
   )
 
+  if (file.mimeType === 'image/gif') {
+    // let's hope they're small enough that they don't need compression!
+    // this compression library doesn't support gifs
+    // worst case - server rejects them. I think that's fine -sfn
+    return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
+  }
+
   const minimumFileSizeForCompress = isAcceptableFormat
     ? MIN_SIZE_FOR_COMPRESSION
     : 0
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 296545353..c3e0526b9 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -40,6 +40,7 @@ import Animated, {
   ZoomIn,
   ZoomOut,
 } from 'react-native-reanimated'
+import {RootSiblingParent} from 'react-native-root-siblings'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {type ImagePickerAsset} from 'expo-image-picker'
 import {
@@ -77,7 +78,11 @@ import {logger} from '#/logger'
 import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
 import {useDialogStateControlContext} from '#/state/dialogs'
 import {emitPostCreated} from '#/state/events'
-import {type ComposerImage, pasteImage} from '#/state/gallery'
+import {
+  type ComposerImage,
+  createComposerImage,
+  pasteImage,
+} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
 import {useRequireAltTextEnabled} from '#/state/preferences'
 import {
@@ -103,7 +108,6 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
 import {Gallery} from '#/view/com/composer/photos/Gallery'
 import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
 import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
-import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn'
 import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
 import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
 // TODO: Prevent naming components that coincide with RN primitives
@@ -113,12 +117,10 @@ import {
   type TextInputRef,
 } from '#/view/com/composer/text-input/TextInput'
 import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
-import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
 import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
 import {Text} from '#/view/com/util/text/Text'
-import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, native, useTheme, web} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -127,9 +129,15 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
 import * as Prompt from '#/components/Prompt'
+import * as toast from '#/components/Toast'
 import {Text as NewText} from '#/components/Typography'
 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
 import {
+  type AssetType,
+  SelectMediaButton,
+  type SelectMediaButtonProps,
+} from './SelectMediaButton'
+import {
   type ComposerAction,
   composerReducer,
   createComposerState,
@@ -514,12 +522,13 @@ export const ComposePost = ({
       onPostSuccess?.(postSuccessData)
     }
     onClose()
-    Toast.show(
+    toast.show(
       thread.posts.length > 1
         ? _(msg`Your posts have been published`)
         : replyTo
           ? _(msg`Your reply has been published`)
           : _(msg`Your post has been published`),
+      {type: 'success'},
     )
   }, [
     _,
@@ -654,84 +663,88 @@ export const ComposePost = ({
   const isWebFooterSticky = !isNative && thread.posts.length > 1
   return (
     <BottomSheetPortalProvider>
-      <KeyboardAvoidingView
-        testID="composePostView"
-        behavior={isIOS ? 'padding' : 'height'}
-        keyboardVerticalOffset={keyboardVerticalOffset}
-        style={a.flex_1}>
-        <View
-          style={[a.flex_1, viewStyles]}
-          aria-modal
-          accessibilityViewIsModal>
-          <ComposerTopBar
-            canPost={canPost}
-            isReply={!!replyTo}
-            isPublishQueued={publishOnUpload}
-            isPublishing={isPublishing}
-            isThread={thread.posts.length > 1}
-            publishingStage={publishingStage}
-            topBarAnimatedStyle={topBarAnimatedStyle}
-            onCancel={onPressCancel}
-            onPublish={onPressPublish}>
-            {missingAltError && <AltTextReminder error={missingAltError} />}
-            <ErrorBanner
-              error={error}
-              videoState={erroredVideo}
-              clearError={() => setError('')}
-              clearVideo={
-                erroredVideoPostId
-                  ? () => clearVideo(erroredVideoPostId)
-                  : () => {}
-              }
-            />
-          </ComposerTopBar>
-
-          <Animated.ScrollView
-            ref={scrollViewRef}
-            layout={native(LinearTransition)}
-            onScroll={scrollHandler}
-            contentContainerStyle={a.flex_grow}
-            style={a.flex_1}
-            keyboardShouldPersistTaps="always"
-            onContentSizeChange={onScrollViewContentSizeChange}
-            onLayout={onScrollViewLayout}>
-            {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
-            {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}
-                  isLastPost={index === thread.posts.length - 1}
-                  isPartOfThread={thread.posts.length > 1}
-                  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}
+      <RootSiblingParent>
+        <KeyboardAvoidingView
+          testID="composePostView"
+          behavior={isIOS ? 'padding' : 'height'}
+          keyboardVerticalOffset={keyboardVerticalOffset}
+          style={a.flex_1}>
+          <View
+            style={[a.flex_1, viewStyles]}
+            aria-modal
+            accessibilityViewIsModal>
+            <RootSiblingParent>
+              <ComposerTopBar
+                canPost={canPost}
+                isReply={!!replyTo}
+                isPublishQueued={publishOnUpload}
+                isPublishing={isPublishing}
+                isThread={thread.posts.length > 1}
+                publishingStage={publishingStage}
+                topBarAnimatedStyle={topBarAnimatedStyle}
+                onCancel={onPressCancel}
+                onPublish={onPressPublish}>
+                {missingAltError && <AltTextReminder error={missingAltError} />}
+                <ErrorBanner
+                  error={error}
+                  videoState={erroredVideo}
+                  clearError={() => setError('')}
+                  clearVideo={
+                    erroredVideoPostId
+                      ? () => clearVideo(erroredVideoPostId)
+                      : () => {}
+                  }
                 />
-                {isWebFooterSticky && post.id === activePost.id && (
-                  <View style={styles.stickyFooterWeb}>{footer}</View>
-                )}
-              </React.Fragment>
-            ))}
-          </Animated.ScrollView>
-          {!isWebFooterSticky && footer}
-        </View>
+              </ComposerTopBar>
+
+              <Animated.ScrollView
+                ref={scrollViewRef}
+                layout={native(LinearTransition)}
+                onScroll={scrollHandler}
+                contentContainerStyle={a.flex_grow}
+                style={a.flex_1}
+                keyboardShouldPersistTaps="always"
+                onContentSizeChange={onScrollViewContentSizeChange}
+                onLayout={onScrollViewLayout}>
+                {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
+                {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}
+                      isLastPost={index === thread.posts.length - 1}
+                      isPartOfThread={thread.posts.length > 1}
+                      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}
+                    />
+                    {isWebFooterSticky && post.id === activePost.id && (
+                      <View style={styles.stickyFooterWeb}>{footer}</View>
+                    )}
+                  </React.Fragment>
+                ))}
+              </Animated.ScrollView>
+              {!isWebFooterSticky && footer}
+            </RootSiblingParent>
+          </View>
 
-        <Prompt.Basic
-          control={discardPromptControl}
-          title={_(msg`Discard draft?`)}
-          description={_(msg`Are you sure you'd like to discard this draft?`)}
-          onConfirm={onClose}
-          confirmButtonCta={_(msg`Discard`)}
-          confirmButtonColor="negative"
-        />
-      </KeyboardAvoidingView>
+          <Prompt.Basic
+            control={discardPromptControl}
+            title={_(msg`Discard draft?`)}
+            description={_(msg`Are you sure you'd like to discard this draft?`)}
+            onConfirm={onClose}
+            confirmButtonCta={_(msg`Discard`)}
+            confirmButtonColor="negative"
+          />
+        </KeyboardAvoidingView>
+      </RootSiblingParent>
     </BottomSheetPortalProvider>
   )
 }
@@ -811,11 +824,16 @@ let ComposerPost = React.memo(function ComposerPost({
 
   const onPhotoPasted = useCallback(
     async (uri: string) => {
-      if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) {
+      if (
+        uri.startsWith('data:video/') ||
+        (isWeb && 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')
+          toast.show(_(msg`Unsupported video type: ${mimeType}`), {
+            type: 'error',
+          })
           return
         }
         const name = `pasted.${mimeToExt(mimeType)}`
@@ -1251,7 +1269,6 @@ function ComposerFooter({
   dispatch,
   showAddButton,
   onEmojiButtonPress,
-  onError,
   onSelectVideo,
   onAddPost,
 }: {
@@ -1266,11 +1283,32 @@ function ComposerFooter({
   const t = useTheme()
   const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
+  /*
+   * Once we've allowed a certain type of asset to be selected, we don't allow
+   * other types of media to be selected.
+   */
+  const [selectedAssetsType, setSelectedAssetsType] = useState<
+    AssetType | undefined
+  >(undefined)
 
   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
+  const isMaxVideos = !!video
+
+  let selectedAssetsCount = 0
+  let isMediaSelectionDisabled = false
+
+  if (media?.type === 'images') {
+    isMediaSelectionDisabled = isMaxImages
+    selectedAssetsCount = images.length
+  } else if (media?.type === 'video') {
+    isMediaSelectionDisabled = isMaxVideos
+    selectedAssetsCount = 1
+  } else {
+    isMediaSelectionDisabled = !!media
+  }
 
   const onImageAdd = useCallback(
     (next: ComposerImage[]) => {
@@ -1289,6 +1327,54 @@ function ComposerFooter({
     [dispatch],
   )
 
+  /*
+   * Reset if the user clears any selected media
+   */
+  if (selectedAssetsType !== undefined && !media) {
+    setSelectedAssetsType(undefined)
+  }
+
+  const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>(
+    async ({type, assets, errors}) => {
+      setSelectedAssetsType(type)
+
+      if (assets.length) {
+        if (type === 'image') {
+          const images: ComposerImage[] = []
+
+          await Promise.all(
+            assets.map(async image => {
+              const composerImage = await createComposerImage({
+                path: image.uri,
+                width: image.width,
+                height: image.height,
+                mime: image.mimeType!,
+              })
+              images.push(composerImage)
+            }),
+          ).catch(e => {
+            logger.error(`createComposerImage failed`, {
+              safeMessage: e.message,
+            })
+          })
+
+          onImageAdd(images)
+        } else if (type === 'video') {
+          onSelectVideo(post.id, assets[0])
+        } else if (type === 'gif') {
+          onSelectVideo(post.id, assets[0])
+        }
+      }
+
+      errors.map(error => {
+        toast.show(error, {
+          type: 'warning',
+        })
+      })
+    },
+    [post.id, onSelectVideo, onImageAdd],
+  )
+
   return (
     <View
       style={[
@@ -1307,15 +1393,11 @@ function ComposerFooter({
             <VideoUploadToolbar state={video} />
           ) : (
             <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
-              <SelectPhotoBtn
-                size={images.length}
-                disabled={media?.type === 'images' ? isMaxImages : !!media}
-                onAdd={onImageAdd}
-              />
-              <SelectVideoBtn
-                onSelectVideo={asset => onSelectVideo(post.id, asset)}
-                disabled={!!media}
-                setError={onError}
+              <SelectMediaButton
+                disabled={isMediaSelectionDisabled}
+                allowedAssetTypes={selectedAssetsType}
+                selectedAssetsCount={selectedAssetsCount}
+                onSelectAssets={onSelectAssets}
               />
               <OpenCameraBtn
                 disabled={media?.type === 'images' ? isMaxImages : !!media}
diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx
new file mode 100644
index 000000000..026d0ac19
--- /dev/null
+++ b/src/view/com/composer/SelectMediaButton.tsx
@@ -0,0 +1,524 @@
+import {useCallback} from 'react'
+import {Keyboard} from 'react-native'
+import {
+  type ImagePickerAsset,
+  launchImageLibraryAsync,
+  UIImagePickerPreferredAssetRepresentationMode,
+} from 'expo-image-picker'
+import {msg, plural} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants'
+import {
+  usePhotoLibraryPermission,
+  useVideoLibraryPermission,
+} from '#/lib/hooks/usePermissions'
+import {extractDataUriMime} from '#/lib/media/util'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {MAX_IMAGES} from '#/view/com/composer/state/composer'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
+import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
+import * as toast from '#/components/Toast'
+
+export type SelectMediaButtonProps = {
+  disabled?: boolean
+  /**
+   * If set, this limits the types of assets that can be selected.
+   */
+  allowedAssetTypes: AssetType | undefined
+  selectedAssetsCount: number
+  onSelectAssets: (props: {
+    type: AssetType
+    assets: ImagePickerAsset[]
+    errors: string[]
+  }) => void
+}
+
+/**
+ * Generic asset classes, or buckets, that we support.
+ */
+export type AssetType = 'video' | 'image' | 'gif'
+
+/**
+ * Shadows `ImagePickerAsset` from `expo-image-picker`, but with a guaranteed `mimeType`
+ */
+type ValidatedImagePickerAsset = Omit<ImagePickerAsset, 'mimeType'> & {
+  mimeType: string
+}
+
+/**
+ * Codes for known validation states
+ */
+enum SelectedAssetError {
+  Unsupported = 'Unsupported',
+  MixedTypes = 'MixedTypes',
+  MaxImages = 'MaxImages',
+  MaxVideos = 'MaxVideos',
+  VideoTooLong = 'VideoTooLong',
+  FileTooBig = 'FileTooBig',
+  MaxGIFs = 'MaxGIFs',
+}
+
+/**
+ * Supported video mime types. This differs slightly from
+ * `SUPPORTED_MIME_TYPES` from `#/lib/constants` because we only care about
+ * videos here.
+ */
+const SUPPORTED_VIDEO_MIME_TYPES = [
+  'video/mp4',
+  'video/mpeg',
+  'video/webm',
+  'video/quicktime',
+] as const
+type SupportedVideoMimeType = (typeof SUPPORTED_VIDEO_MIME_TYPES)[number]
+function isSupportedVideoMimeType(
+  mimeType: string,
+): mimeType is SupportedVideoMimeType {
+  return SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType)
+}
+
+/**
+ * Supported image mime types.
+ */
+const SUPPORTED_IMAGE_MIME_TYPES = (
+  [
+    'image/gif',
+    'image/jpeg',
+    'image/png',
+    'image/svg+xml',
+    'image/webp',
+    'image/avif',
+    isNative && 'image/heic',
+  ] as const
+).filter(Boolean)
+type SupportedImageMimeType = Exclude<
+  (typeof SUPPORTED_IMAGE_MIME_TYPES)[number],
+  boolean
+>
+function isSupportedImageMimeType(
+  mimeType: string,
+): mimeType is SupportedImageMimeType {
+  return SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType as SupportedImageMimeType)
+}
+
+/**
+ * This is a last-ditch effort type thing here, try not to rely on this.
+ */
+const extensionToMimeType: Record<
+  string,
+  SupportedVideoMimeType | SupportedImageMimeType
+> = {
+  mp4: 'video/mp4',
+  mov: 'video/quicktime',
+  webm: 'video/webm',
+  webp: 'image/webp',
+  gif: 'image/gif',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  png: 'image/png',
+  svg: 'image/svg+xml',
+  heic: 'image/heic',
+}
+
+/**
+ * Attempts to bucket the given asset into one of our known types based on its
+ * `mimeType`. If `mimeType` is not available, we try to infer it through
+ * various means.
+ */
+function classifyImagePickerAsset(asset: ImagePickerAsset):
+  | {
+      success: true
+      type: AssetType
+      mimeType: string
+    }
+  | {
+      success: false
+      type: undefined
+      mimeType: undefined
+    } {
+  /*
+   * Try to use the `mimeType` reported by `expo-image-picker` first.
+   */
+  let mimeType = asset.mimeType
+
+  if (!mimeType) {
+    /*
+     * We can try to infer this from the data-uri.
+     */
+    const maybeMimeType = extractDataUriMime(asset.uri)
+
+    if (
+      maybeMimeType.startsWith('image/') ||
+      maybeMimeType.startsWith('video/')
+    ) {
+      mimeType = maybeMimeType
+    } else if (maybeMimeType.startsWith('file/')) {
+      /*
+       * On the off-chance we get a `file/*` mime, try to infer from the
+       * extension.
+       */
+      const extension = asset.uri.split('.').pop()?.toLowerCase()
+      mimeType = extensionToMimeType[extension || '']
+    }
+  }
+
+  if (!mimeType) {
+    return {
+      success: false,
+      type: undefined,
+      mimeType: undefined,
+    }
+  }
+
+  /*
+   * Distill this down into a type "class".
+   */
+  let type: AssetType | undefined
+  if (mimeType === 'image/gif') {
+    type = 'gif'
+  } else if (mimeType?.startsWith('video/')) {
+    type = 'video'
+  } else if (mimeType?.startsWith('image/')) {
+    type = 'image'
+  }
+
+  /*
+   * If we weren't able to find a valid type, we don't support this asset.
+   */
+  if (!type) {
+    return {
+      success: false,
+      type: undefined,
+      mimeType: undefined,
+    }
+  }
+
+  return {
+    success: true,
+    type,
+    mimeType,
+  }
+}
+
+/**
+ * Takes in raw assets from `expo-image-picker` and applies validation. Returns
+ * the dominant `AssetType`, any valid assets, and any errors encountered along
+ * the way.
+ */
+async function processImagePickerAssets(
+  assets: ImagePickerAsset[],
+  {
+    selectionCountRemaining,
+    allowedAssetTypes,
+  }: {
+    selectionCountRemaining: number
+    allowedAssetTypes: AssetType | undefined
+  },
+) {
+  /*
+   * A deduped set of error codes, which we'll use later
+   */
+  const errors = new Set<SelectedAssetError>()
+
+  /*
+   * We only support selecting a single type of media at a time, so this gets
+   * set to whatever the first valid asset type is, OR to whatever
+   * `allowedAssetTypes` is set to.
+   */
+  let selectableAssetType: AssetType | undefined
+
+  /*
+   * This will hold the assets that we can actually use, after filtering
+   */
+  let supportedAssets: ValidatedImagePickerAsset[] = []
+
+  for (const asset of assets) {
+    const {success, type, mimeType} = classifyImagePickerAsset(asset)
+
+    if (!success) {
+      errors.add(SelectedAssetError.Unsupported)
+      continue
+    }
+
+    /*
+     * If we have an `allowedAssetTypes` prop, constrain to that. Otherwise,
+     * set this to the first valid asset type we see, and then use that to
+     * constrain all remaining selected assets.
+     */
+    selectableAssetType = allowedAssetTypes || selectableAssetType || type
+
+    // ignore mixed types
+    if (type !== selectableAssetType) {
+      errors.add(SelectedAssetError.MixedTypes)
+      continue
+    }
+
+    if (type === 'video') {
+      /**
+       * We don't care too much about mimeType at this point on native,
+       * since the `processVideo` step later on will convert to `.mp4`.
+       */
+      if (isWeb && !isSupportedVideoMimeType(mimeType)) {
+        errors.add(SelectedAssetError.Unsupported)
+        continue
+      }
+
+      /*
+       * Filesize appears to be stable across all platforms, so we can use it
+       * to filter out large files on web. On native, we compress these anyway,
+       * so we only check on web.
+       */
+      if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
+        errors.add(SelectedAssetError.FileTooBig)
+        continue
+      }
+    }
+
+    if (type === 'image') {
+      if (!isSupportedImageMimeType(mimeType)) {
+        errors.add(SelectedAssetError.Unsupported)
+        continue
+      }
+    }
+
+    if (type === 'gif') {
+      /*
+       * Filesize appears to be stable across all platforms, so we can use it
+       * to filter out large files on web. On native, we compress GIFs as
+       * videos anyway, so we only check on web.
+       */
+      if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
+        errors.add(SelectedAssetError.FileTooBig)
+        continue
+      }
+    }
+
+    /*
+     * All validations passed, we have an asset!
+     */
+    supportedAssets.push({
+      mimeType,
+      ...asset,
+      /*
+       * In `expo-image-picker` >= v17, `uri` is now a `blob:` URL, not a
+       * data-uri. Our handling elsewhere in the app (for web) relies on the
+       * base64 data-uri, so we construct it here for web only.
+       */
+      uri:
+        isWeb && asset.base64
+          ? `data:${mimeType};base64,${asset.base64}`
+          : asset.uri,
+    })
+  }
+
+  if (supportedAssets.length > 0) {
+    if (selectableAssetType === 'image') {
+      if (supportedAssets.length > selectionCountRemaining) {
+        errors.add(SelectedAssetError.MaxImages)
+        supportedAssets = supportedAssets.slice(0, selectionCountRemaining)
+      }
+    } else if (selectableAssetType === 'video') {
+      if (supportedAssets.length > 1) {
+        errors.add(SelectedAssetError.MaxVideos)
+        supportedAssets = supportedAssets.slice(0, 1)
+      }
+
+      if (supportedAssets[0].duration) {
+        if (isWeb) {
+          /*
+           * Web reports duration as seconds
+           */
+          supportedAssets[0].duration = supportedAssets[0].duration * 1000
+        }
+
+        if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) {
+          errors.add(SelectedAssetError.VideoTooLong)
+          supportedAssets = []
+        }
+      } else {
+        errors.add(SelectedAssetError.Unsupported)
+        supportedAssets = []
+      }
+    } else if (selectableAssetType === 'gif') {
+      if (supportedAssets.length > 1) {
+        errors.add(SelectedAssetError.MaxGIFs)
+        supportedAssets = supportedAssets.slice(0, 1)
+      }
+    }
+  }
+
+  return {
+    type: selectableAssetType!, // set above
+    assets: supportedAssets,
+    errors,
+  }
+}
+
+export function SelectMediaButton({
+  disabled,
+  allowedAssetTypes,
+  selectedAssetsCount,
+  onSelectAssets,
+}: SelectMediaButtonProps) {
+  const {_} = useLingui()
+  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+  const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
+  const sheetWrapper = useSheetWrapper()
+  const t = useTheme()
+
+  const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
+
+  const processSelectedAssets = useCallback(
+    async (rawAssets: ImagePickerAsset[]) => {
+      const {
+        type,
+        assets,
+        errors: errorCodes,
+      } = await processImagePickerAssets(rawAssets, {
+        selectionCountRemaining,
+        allowedAssetTypes,
+      })
+
+      /*
+       * Convert error codes to user-friendly messages.
+       */
+      const errors = Array.from(errorCodes).map(error => {
+        return {
+          [SelectedAssetError.Unsupported]: _(
+            msg`One or more of your selected files are not supported.`,
+          ),
+          [SelectedAssetError.MixedTypes]: _(
+            msg`Selecting multiple media types is not supported.`,
+          ),
+          [SelectedAssetError.MaxImages]: _(
+            msg({
+              message: `You can select up to ${plural(MAX_IMAGES, {
+                other: '# images',
+              })} in total.`,
+              comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`,
+            }),
+          ),
+          [SelectedAssetError.MaxVideos]: _(
+            msg`You can only select one video at a time.`,
+          ),
+          [SelectedAssetError.VideoTooLong]: _(
+            msg`Videos must be less than 3 minutes long.`,
+          ),
+          [SelectedAssetError.MaxGIFs]: _(
+            msg`You can only select one GIF at a time.`,
+          ),
+          [SelectedAssetError.FileTooBig]: _(
+            msg`One or more of your selected files is too large. Maximum size is 100 MB.`,
+          ),
+        }[error]
+      })
+
+      /*
+       * Report the selected assets and any errors back to the
+       * composer.
+       */
+      onSelectAssets({
+        type,
+        assets,
+        errors,
+      })
+    },
+    [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes],
+  )
+
+  const onPressSelectMedia = useCallback(async () => {
+    if (isNative) {
+      const [photoAccess, videoAccess] = await Promise.all([
+        requestPhotoAccessIfNeeded(),
+        requestVideoAccessIfNeeded(),
+      ])
+
+      if (!photoAccess && !videoAccess) {
+        toast.show(_(msg`You need to allow access to your media library.`), {
+          type: 'error',
+        })
+        return
+      }
+    }
+
+    if (isNative && Keyboard.isVisible()) {
+      Keyboard.dismiss()
+    }
+
+    const {assets, canceled} = await sheetWrapper(
+      launchImageLibraryAsync({
+        exif: false,
+        mediaTypes: ['images', 'videos'],
+        quality: 1,
+        allowsMultipleSelection: true,
+        legacy: true,
+        base64: isWeb,
+        selectionLimit: isIOS ? selectionCountRemaining : undefined,
+        preferredAssetRepresentationMode:
+          UIImagePickerPreferredAssetRepresentationMode.Current,
+        videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
+      }),
+    )
+
+    if (canceled) return
+
+    await processSelectedAssets(assets)
+  }, [
+    _,
+    requestPhotoAccessIfNeeded,
+    requestVideoAccessIfNeeded,
+    sheetWrapper,
+    processSelectedAssets,
+    selectionCountRemaining,
+  ])
+
+  return (
+    <Button
+      testID="openMediaBtn"
+      onPress={onPressSelectMedia}
+      label={_(
+        msg({
+          message: `Add media to post`,
+          comment: `Accessibility label for button in composer to add photos or a video to a post`,
+        }),
+      )}
+      accessibilityHint={
+        isNative
+          ? _(
+              msg({
+                message: `Opens device gallery to select up to ${plural(
+                  MAX_IMAGES,
+                  {
+                    other: '# images',
+                  },
+                )}, or a single video.`,
+                comment: `Accessibility hint on native for button in composer to add images or a video to a post. Maximum number of images that can be selected is currently 4 but may change.`,
+              }),
+            )
+          : _(
+              msg({
+                message: `Opens device gallery to select up to ${plural(
+                  MAX_IMAGES,
+                  {
+                    other: '# images',
+                  },
+                )}, or a single video or GIF.`,
+                comment: `Accessibility hint on web for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`,
+              }),
+            )
+      }
+      style={a.p_sm}
+      variant="ghost"
+      shape="round"
+      color="primary"
+      disabled={disabled}>
+      <ImageIcon
+        size="lg"
+        style={disabled && t.atoms.text_contrast_low}
+        accessibilityIgnoresInvertColors={true}
+      />
+    </Button>
+  )
+}
diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx
index 724149937..b356cde9b 100644
--- a/src/view/com/composer/photos/ImageAltTextDialog.tsx
+++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx
@@ -96,13 +96,12 @@ const ImageAltTextInner = ({
         <View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}>
           <Image
             style={imageStyle}
-            source={{
-              uri: (image.transformed ?? image.source).path,
-            }}
+            source={{uri: (image.transformed ?? image.source).path}}
             contentFit="contain"
             accessible={true}
             accessibilityIgnoresInvertColors
             enableLiveTextInteraction
+            autoplay={false}
           />
         </View>
       </View>
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
deleted file mode 100644
index f4c6aa328..000000000
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
-import {useCallback} from 'react'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
-import {openPicker} from '#/lib/media/picker'
-import {isNative} from '#/platform/detection'
-import {ComposerImage, createComposerImage} from '#/state/gallery'
-import {atoms as a, useTheme} from '#/alf'
-import {Button} from '#/components/Button'
-import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
-import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
-
-type Props = {
-  size: number
-  disabled?: boolean
-  onAdd: (next: ComposerImage[]) => void
-}
-
-export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
-  const {_} = useLingui()
-  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
-  const t = useTheme()
-  const sheetWrapper = useSheetWrapper()
-
-  const onPressSelectPhotos = useCallback(async () => {
-    if (isNative && !(await requestPhotoAccessIfNeeded())) {
-      return
-    }
-
-    const images = await sheetWrapper(
-      openPicker({
-        selectionLimit: 4 - size,
-        allowsMultipleSelection: true,
-      }),
-    )
-
-    const results = await Promise.all(
-      images.map(img => createComposerImage(img)),
-    )
-
-    onAdd(results)
-  }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper])
-
-  return (
-    <Button
-      testID="openGalleryBtn"
-      onPress={onPressSelectPhotos}
-      label={_(msg`Gallery`)}
-      accessibilityHint={_(msg`Opens device photo gallery`)}
-      style={a.p_sm}
-      variant="ghost"
-      shape="round"
-      color="primary"
-      disabled={disabled}>
-      <Image size="lg" style={disabled && t.atoms.text_contrast_low} />
-    </Button>
-  )
-}
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
deleted file mode 100644
index 96715955f..000000000
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import {useCallback} from 'react'
-import {type ImagePickerAsset} from 'expo-image-picker'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {
-  SUPPORTED_MIME_TYPES,
-  type SupportedMimeTypes,
-  VIDEO_MAX_DURATION_MS,
-} from '#/lib/constants'
-import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
-import {isWeb} from '#/platform/detection'
-import {isNative} from '#/platform/detection'
-import {atoms as a, useTheme} from '#/alf'
-import {Button} from '#/components/Button'
-import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
-import {pickVideo} from './pickVideo'
-
-type Props = {
-  onSelectVideo: (video: ImagePickerAsset) => void
-  disabled?: boolean
-  setError: (error: string) => void
-}
-
-export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
-  const {_} = useLingui()
-  const t = useTheme()
-  const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
-
-  const onPressSelectVideo = useCallback(async () => {
-    if (isNative && !(await requestVideoAccessIfNeeded())) {
-      return
-    }
-
-    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_MS) {
-            throw Error(_(msg`Videos must be less than 3 minutes long`))
-          }
-          // compression step on native converts to mp4, so no need to check there
-          if (
-            !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes)
-          ) {
-            throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
-          }
-        } else {
-          if (typeof asset.duration !== 'number') {
-            throw Error('Asset is not a video')
-          }
-          if (asset.duration > VIDEO_MAX_DURATION_MS) {
-            throw Error(_(msg`Videos must be less than 3 minutes long`))
-          }
-        }
-        onSelectVideo(asset)
-      } catch (err) {
-        if (err instanceof Error) {
-          setError(err.message)
-        } else {
-          setError(_(msg`An error occurred while selecting the video`))
-        }
-      }
-    }
-  }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo])
-
-  return (
-    <>
-      <Button
-        testID="openGifBtn"
-        onPress={onPressSelectVideo}
-        label={_(msg`Select video`)}
-        accessibilityHint={_(msg`Opens video picker`)}
-        style={a.p_sm}
-        variant="ghost"
-        shape="round"
-        color="primary"
-        disabled={disabled}>
-        <VideoClipIcon
-          size="lg"
-          style={disabled && t.atoms.text_contrast_low}
-        />
-      </Button>
-    </>
-  )
-}
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 255174bea..84cb1dba7 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -1,9 +1,10 @@
 import React from 'react'
 import {View} from 'react-native'
-import {ImagePickerAsset} from 'expo-image-picker'
+import {Image} from 'expo-image'
+import {type ImagePickerAsset} from 'expo-image-picker'
 import {BlueskyVideoView} from '@haileyok/bluesky-video'
 
-import {CompressedVideo} from '#/lib/media/video/types'
+import {type CompressedVideo} from '#/lib/media/video/types'
 import {clamp} from '#/lib/numbers'
 import {useAutoplayDisabled} from '#/state/preferences'
 import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
@@ -48,13 +49,25 @@ export function VideoPreview({
         <VideoTranscodeBackdrop uri={asset.uri} />
       </View>
       {isActivePost && (
-        <BlueskyVideoView
-          url={video.uri}
-          autoplay={!autoplayDisabled}
-          beginMuted={true}
-          forceTakeover={true}
-          ref={playerRef}
-        />
+        <>
+          {video.mimeType === 'image/gif' ? (
+            <Image
+              style={[a.flex_1]}
+              autoplay={!autoplayDisabled}
+              source={{uri: video.uri}}
+              accessibilityIgnoresInvertColors
+              cachePolicy="none"
+            />
+          ) : (
+            <BlueskyVideoView
+              url={video.uri}
+              autoplay={!autoplayDisabled}
+              beginMuted={true}
+              forceTakeover={true}
+              ref={playerRef}
+            />
+          )}
+        </>
       )}
       <ExternalEmbedRemoveBtn onRemove={clear} />
       {autoplayDisabled && (
diff --git a/yarn.lock b/yarn.lock
index dc6489bf8..9145826a4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11288,6 +11288,11 @@ expo-image-loader@~5.1.0:
   resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-5.1.0.tgz#f7d65f9b9a9714eaaf5d50a406cb34cb25262153"
   integrity sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q==
 
+expo-image-loader@~6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072"
+  integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==
+
 expo-image-manipulator@~13.1.7:
   version "13.1.7"
   resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-13.1.7.tgz#e891ce9b49d75962eafdf5b7d670116583379e76"
@@ -11295,12 +11300,12 @@ expo-image-manipulator@~13.1.7:
   dependencies:
     expo-image-loader "~5.1.0"
 
-expo-image-picker@~16.1.4:
-  version "16.1.4"
-  resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-16.1.4.tgz#d4ac2d1f64f6ec9347c3f64f8435b40e6e4dcc40"
-  integrity sha512-bTmmxtw1AohUT+HxEBn2vYwdeOrj1CLpMXKjvi9FKSoSbpcarT4xxI0z7YyGwDGHbrJqyyic3I9TTdP2J2b4YA==
+expo-image-picker@^17.0.2:
+  version "17.0.2"
+  resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.2.tgz#79af7192b2947e54686d0ece6ccbb5f6a178a809"
+  integrity sha512-O74FIrc37KB4ZxC/BMUL3fEZwdmIB60As0q5XczRlzPvWismBl7GG3pPy+o5SGUI2jcepTvQAa2PcNcMbUZNYg==
   dependencies:
-    expo-image-loader "~5.1.0"
+    expo-image-loader "~6.0.0"
 
 expo-image@^2.4.0:
   version "2.4.0"