import {useCallback} from 'react' import { type ImagePickerAsset, launchImageLibraryAsync, UIImagePickerPreferredAssetRepresentationMode, } from 'expo-image-picker' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {VIDEO_MAX_DURATION_MS} from '#/lib/constants' import { usePhotoLibraryPermission, useVideoLibraryPermission, } from '#/lib/hooks/usePermissions' import {extractDataUriMime} from '#/lib/media/util' import {mimeToExt} from '#/lib/media/video/util' import {logger} from '#/logger' import {isIOS, isNative, isWeb} from '#/platform/detection' import {MAX_IMAGES} from '#/view/com/composer/state/composer' import {getVideoMetadata} from '#/view/com/composer/videos/pickVideo' 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' import {toast} from '#/components/Toast' export type Props = { size: number disabled?: boolean setError: (error: string) => void selectedAssetsCount: number onSelectAssets: (props: { type: SelectedAsset['type'] assets: ImagePickerAsset[] errors: string[] }) => void } export type SelectedAsset = { asset: ImagePickerAsset type: 'video' | 'image' | 'gif' } export type ValidatedImagePickerAsset = Omit & { mimeType: string } export enum SelectedAssetError { Unsupported = 'Unsupported', MixedTypes = 'MixedTypes', MaxImages = 'MaxImages', MaxVideos = 'MaxVideos', VideoTooLong = 'VideoTooLong', MaxGIFs = 'MaxGIFs', NoGifsOnNative = 'NoGifsOnNative', } const SUPPORTED_VIDEO_MIME_TYPES = [ 'video/mp4', 'video/mpeg', 'video/webm', 'video/quicktime', ] as const export type SupportedVideoMimeType = (typeof SUPPORTED_VIDEO_MIME_TYPES)[number] function isSupportedVideoMimeType( mimeType: string, ): mimeType is SupportedVideoMimeType { return SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType) } const SUPPORTED_IMAGE_MIME_TYPES = ( [ 'image/gif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp', isIOS && 'image/heic', ] as const ).filter(Boolean) export 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) } const extensionToMimeType: Record< string, SupportedVideoMimeType | SupportedImageMimeType > = { mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', gif: 'image/gif', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', svg: 'image/svg+xml', heic: 'image/heic', } function classifyImagePickerAsset(asset: ImagePickerAsset): | { success: true type: SelectedAsset['type'] mimeType: string } | { success: false type: undefined mimeType: undefined } { /* * Try to use the `mimeType` reported by Expo's library 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: SelectedAsset['type'] | undefined if (mimeType === 'image/gif') { type = 'gif' } else if (mimeType?.startsWith('video/')) { type = 'video' } else if (mimeType?.startsWith('image/')) { type = 'image' } // console.log({ // asset, // type, // mimeType, // }) /* * 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, } } /* * On web, certain file formats (like `.mov`) don't give us a duration or * dimensions, so we need to load the file manually to extract this. */ async function getAdditionalVideoMetadata(asset: ValidatedImagePickerAsset) { if (isNative) return asset const file = await fetch(asset.uri) .then(res => res.blob()) .then( blob => new File([blob], `tmp.${mimeToExt(asset.mimeType)}`, { type: asset.mimeType, }), ) return await getVideoMetadata(file) } async function processImagePickerAssets( assets: ImagePickerAsset[], { selectionLimit, }: { selectionLimit: number }, ) { /* * 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 asset type is. */ let primaryMediaType: SelectedAsset['type'] | 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 } // set the primary media type to the first valid asset type primaryMediaType = primaryMediaType || type // ignore mixed types if (type !== primaryMediaType) { 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 } } if (type === 'image') { if (!isSupportedImageMimeType(mimeType)) { errors.add(SelectedAssetError.Unsupported) continue } } if (type === 'gif') { if (isNative) { errors.add(SelectedAssetError.NoGifsOnNative) continue } } /* * All validations passed, we have an asset! */ supportedAssets.push({ mimeType, ...asset, }) } if (primaryMediaType === 'image') { if (supportedAssets.length > selectionLimit) { errors.add(SelectedAssetError.MaxImages) supportedAssets = supportedAssets.slice(0, selectionLimit) } } else if (primaryMediaType === 'video') { if (supportedAssets.length > 1) { errors.add(SelectedAssetError.MaxVideos) supportedAssets = supportedAssets.slice(0, 1) } const selectedVideo = supportedAssets[0] if (typeof selectedVideo.duration !== 'number') { try { const metadata = await getAdditionalVideoMetadata(selectedVideo) selectedVideo.duration = metadata.duration selectedVideo.width = metadata.width selectedVideo.height = metadata.height } catch (e: any) { logger.error(`processSelectedAssets: failed to get video metadata`, { safeMessage: e.message, }) errors.add(SelectedAssetError.Unsupported) supportedAssets = [] } } else { /* * The `duration` is in seconds on web, but in milliseconds on * native. We normalize to milliseconds. */ if (isWeb) { selectedVideo.duration = selectedVideo.duration * 1000 } } if ( selectedVideo.duration && selectedVideo.duration > VIDEO_MAX_DURATION_MS ) { errors.add(SelectedAssetError.VideoTooLong) supportedAssets = [] } } else if (primaryMediaType === 'gif') { if (supportedAssets.length > 1) { errors.add(SelectedAssetError.MaxGIFs) supportedAssets = supportedAssets.slice(0, 1) } } return { type: primaryMediaType!, // set above assets: supportedAssets, errors, } } export function SelectMediaBtn({ disabled, selectedAssetsCount, onSelectAssets, }: Props) { const {_} = useLingui() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() const sheetWrapper = useSheetWrapper() const t = useTheme() const selectionLimit = MAX_IMAGES - selectedAssetsCount const processSelectedAssets = useCallback( async (rawAssets: ImagePickerAsset[]) => { const { type, assets, errors: errorCodes, } = await processImagePickerAssets(rawAssets, {selectionLimit}) /* * 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`You can select up to ${MAX_IMAGES} total images.`, ), [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.NoGifsOnNative]: _( msg`GIFs are only supported on web at this time.`, ), }[error] }) /* * Report the selected assets and any errors back to the * composer. */ onSelectAssets({ type, assets, errors, }) }, [_, onSelectAssets, selectionLimit], ) const onPressSelectMedia = useCallback(async () => { if (isNative) { const [photoAccess, videoAccess] = await Promise.all([ requestPhotoAccessIfNeeded(), requestVideoAccessIfNeeded(), ]) if (!photoAccess && !videoAccess) { toast.show({ type: 'error', content: 'You need to allow access to your media library.', a11yLabel: 'You need to allow access to your media library.', }) return } } const {assets, canceled} = await sheetWrapper( launchImageLibraryAsync({ exif: false, mediaTypes: ['images', 'videos'], quality: 1, allowsMultipleSelection: true, legacy: true, selectionLimit, preferredAssetRepresentationMode: UIImagePickerPreferredAssetRepresentationMode.Current, }), ) if (canceled) return await processSelectedAssets(assets) }, [ requestPhotoAccessIfNeeded, requestVideoAccessIfNeeded, sheetWrapper, processSelectedAssets, selectionLimit, ]) return ( ) }