diff --git a/.gitignore b/.gitignore
index 7233b35472..0bef5d88a1 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 ee5d276f4a..0f3f685713 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 0396fecbc1..0000000000
--- 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 7855e86212..0000000000
--- 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 d84a90fa62..e4814462f7 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 (
-
- {formatTime(currentTime)} / {formatTime(duration)}
-
+ {Math.round(duration) > 0 && (
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+ )}
{hasSubtitleTrack && (
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 c2d1470c63..1d00bfcea1 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 2965453530..c3e0526b9a 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,8 +129,14 @@ 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,
@@ -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 (
-
-
- 1}
- publishingStage={publishingStage}
- topBarAnimatedStyle={topBarAnimatedStyle}
- onCancel={onPressCancel}
- onPublish={onPressPublish}>
- {missingAltError && }
- setError('')}
- clearVideo={
- erroredVideoPostId
- ? () => clearVideo(erroredVideoPostId)
- : () => {}
- }
- />
-
-
-
- {replyTo ? : undefined}
- {thread.posts.map((post, index) => (
-
- 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}
+
+
+
+
+ 1}
+ publishingStage={publishingStage}
+ topBarAnimatedStyle={topBarAnimatedStyle}
+ onCancel={onPressCancel}
+ onPublish={onPressPublish}>
+ {missingAltError && }
+ setError('')}
+ clearVideo={
+ erroredVideoPostId
+ ? () => clearVideo(erroredVideoPostId)
+ : () => {}
+ }
/>
- {isWebFooterSticky && post.id === activePost.id && (
- {footer}
- )}
-
- ))}
-
- {!isWebFooterSticky && footer}
-
+
-
-
+
+ {replyTo ? : undefined}
+ {thread.posts.map((post, index) => (
+
+ 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 && (
+ {footer}
+ )}
+
+ ))}
+
+ {!isWebFooterSticky && footer}
+
+
+
+
+
+
)
}
@@ -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(
+ 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 (
) : (
-
- onSelectVideo(post.id, asset)}
- disabled={!!media}
- setError={onError}
+
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 & {
+ 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()
+
+ /*
+ * 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 (
+
+ )
+}
diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx
index 724149937c..b356cde9b7 100644
--- a/src/view/com/composer/photos/ImageAltTextDialog.tsx
+++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx
@@ -96,13 +96,12 @@ const ImageAltTextInner = ({
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
deleted file mode 100644
index f4c6aa328a..0000000000
--- 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 (
-
- )
-}
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
deleted file mode 100644
index 96715955f0..0000000000
--- 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 (
- <>
-
- >
- )
-}
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 255174beab..84cb1dba75 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({
{isActivePost && (
-
+ <>
+ {video.mimeType === 'image/gif' ? (
+
+ ) : (
+
+ )}
+ >
)}
{autoplayDisabled && (
diff --git a/yarn.lock b/yarn.lock
index dc6489bf85..9145826a4a 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"