Comments and cleanup

(cherry picked from commit e69bd186e7335372f440c446ae6643ed0fb15db9)
This commit is contained in:
Eric Bailey
2025-08-12 16:51:50 -05:00
committed by Samuel Newman
parent c6264508f7
commit a599a379ba
2 changed files with 162 additions and 41 deletions
+1 -1
View File
@@ -1340,7 +1340,7 @@ function ComposerFooter({
errors.map((error, i) => { errors.map((error, i) => {
toast.show({ toast.show({
type: 'error', type: 'warning',
content: error, content: error,
a11yLabel: error, a11yLabel: error,
duration: 3e3 * (errors.length - i), duration: 3e3 * (errors.length - i),
+161 -40
View File
@@ -7,14 +7,17 @@ import {
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {VIDEO_MAX_DURATION_MS} from '#/lib/constants'
import { import {
usePhotoLibraryPermission, usePhotoLibraryPermission,
useVideoLibraryPermission, useVideoLibraryPermission,
} from '#/lib/hooks/usePermissions' } from '#/lib/hooks/usePermissions'
import {extractDataUriMime} from '#/lib/media/util' import {extractDataUriMime} from '#/lib/media/util'
import {isIOS, isNative} from '#/platform/detection' 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 {MAX_IMAGES} from '#/view/com/composer/state/composer'
// import {getVideoMetadata} from '#/view/com/composer/videos/pickVideo' import {getVideoMetadata} from '#/view/com/composer/videos/pickVideo'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
@@ -38,11 +41,16 @@ export type SelectedAsset = {
type: 'video' | 'image' | 'gif' type: 'video' | 'image' | 'gif'
} }
export type ValidatedImagePickerAsset = Omit<ImagePickerAsset, 'mimeType'> & {
mimeType: string
}
export enum SelectedAssetError { export enum SelectedAssetError {
Unsupported = 'Unsupported', Unsupported = 'Unsupported',
MixedTypes = 'MixedTypes', MixedTypes = 'MixedTypes',
MaxImages = 'MaxImages', MaxImages = 'MaxImages',
MaxVideos = 'MaxVideos', MaxVideos = 'MaxVideos',
VideoTooLong = 'VideoTooLong',
MaxGIFs = 'MaxGIFs', MaxGIFs = 'MaxGIFs',
} }
@@ -60,6 +68,7 @@ const SUPPORTED_IMAGE_MIME_TYPES = (
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
'image/svg+xml', 'image/svg+xml',
'image/webp',
isIOS && 'image/heic', isIOS && 'image/heic',
] as const ] as const
).filter(Boolean) ).filter(Boolean)
@@ -94,9 +103,15 @@ function getImagePickerAssetType(asset: ImagePickerAsset):
type: undefined type: undefined
mimeType: undefined mimeType: undefined
} { } {
/*
* Try to use the `mimeType` reported by Expo's library first.
*/
let mimeType = asset.mimeType let mimeType = asset.mimeType
if (!mimeType) { if (!mimeType) {
/*
* We can try to infer this from the data-uri.
*/
const maybeMimeType = extractDataUriMime(asset.uri) const maybeMimeType = extractDataUriMime(asset.uri)
if ( if (
@@ -105,20 +120,41 @@ function getImagePickerAssetType(asset: ImagePickerAsset):
) { ) {
mimeType = maybeMimeType mimeType = maybeMimeType
} else if (maybeMimeType.startsWith('file/')) { } 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() const extension = asset.uri.split('.').pop()?.toLowerCase()
mimeType = extensionToMimeType[extension || ''] mimeType = extensionToMimeType[extension || '']
} }
} }
let type: SelectedAsset['type'] | undefined if (!mimeType) {
return {
success: false,
type: undefined,
mimeType: undefined,
}
}
/*
* We can now do some validation of the `mimeType` and distill it down into
* our supported `type` groups.
*/
let type: SelectedAsset['type'] | undefined
if (mimeType === 'image/gif') { if (mimeType === 'image/gif') {
type = 'gif' type = 'gif'
} else if ( } else if (mimeType?.startsWith('video/')) {
mimeType?.startsWith('video/') && /**
SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType) * We don't care about mimeType at this point on native, since the
) { * `processVideo` step later on will convert to `.mp4`.
type = 'video' */
if (
!isWeb ||
SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType)
) {
type = 'video'
}
} else if ( } else if (
mimeType?.startsWith('image/') && mimeType?.startsWith('image/') &&
SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType as SupportedImageMimeType) SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType as SupportedImageMimeType)
@@ -126,7 +162,16 @@ function getImagePickerAssetType(asset: ImagePickerAsset):
type = 'image' type = 'image'
} }
if (!type || !mimeType) { // console.log({
// asset,
// type,
// mimeType,
// })
/*
* If we weren't able to find a valid type, we don't support this asset.
*/
if (!type) {
return { return {
success: false, success: false,
type: undefined, type: undefined,
@@ -141,6 +186,22 @@ function getImagePickerAssetType(asset: ImagePickerAsset):
} }
} }
/*
* 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) {
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)
}
export function SelectMediaBtn({ export function SelectMediaBtn({
disabled, disabled,
selectedAssetsCount, selectedAssetsCount,
@@ -156,24 +217,36 @@ export function SelectMediaBtn({
const processSelectedAssets = useCallback( const processSelectedAssets = useCallback(
async (assets: ImagePickerAsset[]) => { async (assets: ImagePickerAsset[]) => {
const errors = new Set<SelectedAssetError>() /*
let supportedAssets: ImagePickerAsset[] = [] * A deduped set of error codes, which we'll use later
*/
const errorCodes = 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 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) { for (const asset of assets) {
const {success, type, mimeType} = getImagePickerAssetType(asset) const {success, type, mimeType} = getImagePickerAssetType(asset)
if (!success) { if (!success) {
errors.add(SelectedAssetError.Unsupported) errorCodes.add(SelectedAssetError.Unsupported)
continue continue
} }
// set the primary media type to the first valid asset type // set the primary media type to the first valid asset type
primaryMediaType = primaryMediaType || type primaryMediaType = primaryMediaType || type
// ignore mixed types
if (type !== primaryMediaType) { if (type !== primaryMediaType) {
// Selecting a mix of media types is not allowed errorCodes.add(SelectedAssetError.MixedTypes)
errors.add(SelectedAssetError.MixedTypes)
continue continue
} }
@@ -185,43 +258,91 @@ export function SelectMediaBtn({
if (primaryMediaType === 'image') { if (primaryMediaType === 'image') {
if (supportedAssets.length > selectionLimit) { if (supportedAssets.length > selectionLimit) {
errors.add(SelectedAssetError.MaxImages) errorCodes.add(SelectedAssetError.MaxImages)
supportedAssets = supportedAssets.slice(0, selectionLimit) supportedAssets = supportedAssets.slice(0, selectionLimit)
} }
} else { } else if (primaryMediaType === 'video') {
if (supportedAssets.length > 1) { if (supportedAssets.length > 1) {
if (primaryMediaType === 'video') { errorCodes.add(SelectedAssetError.MaxVideos)
errors.add(SelectedAssetError.MaxVideos) supportedAssets = supportedAssets.slice(0, 1)
} else if (primaryMediaType === 'gif') { }
errors.add(SelectedAssetError.MaxGIFs)
}
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,
},
)
errorCodes.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
) {
errorCodes.add(SelectedAssetError.VideoTooLong)
supportedAssets = []
}
} else if (primaryMediaType === 'gif') {
if (supportedAssets.length > 1) {
errorCodes.add(SelectedAssetError.MaxGIFs)
supportedAssets = supportedAssets.slice(0, 1) supportedAssets = supportedAssets.slice(0, 1)
} }
} }
/*
* 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.`,
),
}[error]
})
/*
* Finally, report the selected assets and any errors back to the
* composer.
*/
onSelectAssets({ onSelectAssets({
type: primaryMediaType!, type: primaryMediaType!,
assets: supportedAssets, assets: supportedAssets,
errors: Array.from(errors).map(error => { errors,
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.MaxGIFs]: _(
msg`You can only select one GIF at a time.`,
),
}[error]
}),
}) })
}, },
[_, onSelectAssets, selectionLimit], [_, onSelectAssets, selectionLimit],