[APP-1318] SelectMediaButton (#8828)
* Integrate Sonner for toasts * Fix animation on iOS * Refactor API * Update e2e file * [APP-1318] Post composer: combine image & video buttons (#8710) * add: select media btn * udpate: compose post with combined image and video support * add: video combine button with edge cases * add select media btn * test: select media btn * add: media button update * remove unused files and update toast on android * update: make strings shorter * add: ValidatedVideoAsset type * update link comments and add toast support for native and web * rebase latest toast and update toast structure * remove unused prop * fix types * undo changes to yarn.lock * remove: support for mkv files * update: eslint and prettier (cherry picked from commit f69779ee130f07e1c49219b53117e3bdd1a9f81b) * Add missing props to launchImageLibraryAsync (cherry picked from commit 2e80ae561fd66850f787cac0aae0fa5a6980f8f5) * Rough out new approach (cherry picked from commit 9add225160e7e407befc73e9cdd9743a30cdf1cd) * Comments and cleanup (cherry picked from commit e69bd186e7335372f440c446ae6643ed0fb15db9) * Handle native case (cherry picked from commit 74e38acdfd9181d0557426691fcbcbf0800481ca) * Refactor (cherry picked from commit 68aea496db8df54dba5f58da267ad962c28ef995) * Rename (cherry picked from commit 8609e59ad14219e7378ee6cb9514d633ce7efc27) * Cleanup, comments (cherry picked from commit 6c9c98648e37257285a9c8caeb1eadcc56c81402) * Rename (cherry picked from commit 66e3db539d5baa41436c9e49af06e87a78e9e7e1) * Handle selectionLimit on Android (cherry picked from commit 251f06dd5e65a7083b810bad3d81114b2fe9ab39) * create composer images in parallel (cherry picked from commit 70ea79d9d76d99e9c99a7d2296caed84c718650e) * Update toast API usage (cherry picked from commit e370018b8ed8cdfd7675c9634058c72cb59d39de) * Ensure once one type of media is selected, you can only select more of that type (cherry picked from commit 1a9e6e0cdb5234667f08e3dd9107ae598941fc23) * Remove TODO and debug code * Add more descriptive a11y label to button Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Add back post success toast * Include mimeType in toast error * Remove unneeded toast * Clarify hint * Typo Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * allow gifs on native, just treat as images * disable haptic toast * allow gifs on native, treat as videos * only do keyboard dismiss on native * tweak pasting logic * hide web scrubber in certain situations * Update MaxImages translation Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Add plural formatting to a11y hint translation Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * fix suggestion * Protect against no valid assets selected * Handle conversion of too-big assets on web * Reorder * Bump expo-image-picker to include bug/perf improvements See https://github.com/expo/expo/blob/main/packages/expo-image-picker/CHANGELOG.md#1700--2025-08-13 * Handle edge case validations * Ok actually bump expo-image-picker * Comment * HEIC support Android * Fix handling for new picker version, improve size validation * Remove getVideoMetadata handling, no longer needed * Handle web video duration * Update src/view/com/composer/SelectMediaButton.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> --------- Co-authored-by: Anastasiya Uraleva <anastasiyauraleva@gmail.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user