81c0456794
(cherry picked from commit 8609e59ad14219e7378ee6cb9514d633ce7efc27)
463 lines
12 KiB
TypeScript
463 lines
12 KiB
TypeScript
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<ImagePickerAsset, 'mimeType'> & {
|
|
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<SelectedAssetError>()
|
|
|
|
/*
|
|
* 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 (
|
|
<Button
|
|
testID="openMediaBtn"
|
|
onPress={onPressSelectMedia}
|
|
label={_(msg`Media`)}
|
|
accessibilityHint={_(
|
|
msg`Opens device gallery to select images, a video, or a GIF.`,
|
|
)}
|
|
style={a.p_sm}
|
|
variant="ghost"
|
|
shape="round"
|
|
color="primary"
|
|
disabled={disabled}>
|
|
<Image
|
|
size="lg"
|
|
style={disabled && t.atoms.text_contrast_low}
|
|
accessibilityIgnoresInvertColors={true}
|
|
/>
|
|
</Button>
|
|
)
|
|
}
|