[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)
This commit is contained in:
Anastasiya Uraleva
2025-08-12 08:59:50 -07:00
committed by Samuel Newman
parent 1c988e6471
commit e5200c1146
5 changed files with 607 additions and 240 deletions
+102 -92
View File
@@ -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 {
@@ -103,7 +104,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 +113,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 +125,10 @@ 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 {toast} from '#/components/Toast'
import {Text as NewText} from '#/components/Typography'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import {SelectMediaBtn} from './SelectMediaBtn'
import {
type ComposerAction,
composerReducer,
@@ -514,13 +514,6 @@ export const ComposePost = ({
onPostSuccess?.(postSuccessData)
}
onClose()
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`),
)
}, [
_,
agent,
@@ -650,84 +643,88 @@ export const ComposePost = ({
const isWebFooterSticky = !isNative && thread.posts.length > 1
return (
<BottomSheetPortalProvider>
<KeyboardAvoidingView
testID="composePostView"
behavior={isIOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
<View
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<ComposerTopBar
canPost={canPost}
isReply={!!replyTo}
isPublishQueued={publishOnUpload}
isPublishing={isPublishing}
isThread={thread.posts.length > 1}
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={onPressPublish}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
videoState={erroredVideo}
clearError={() => setError('')}
clearVideo={
erroredVideoPostId
? () => clearVideo(erroredVideoPostId)
: () => {}
}
/>
</ComposerTopBar>
<Animated.ScrollView
ref={scrollViewRef}
layout={native(LinearTransition)}
onScroll={scrollHandler}
contentContainerStyle={a.flex_grow}
style={a.flex_1}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{thread.posts.map((post, index) => (
<React.Fragment key={post.id}>
<ComposerPost
post={post}
dispatch={composerDispatch}
textInput={post.id === activePost.id ? textInput : null}
isFirstPost={index === 0}
isLastPost={index === thread.posts.length - 1}
isPartOfThread={thread.posts.length > 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}
<RootSiblingParent>
<KeyboardAvoidingView
testID="composePostView"
behavior={isIOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
<View
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<RootSiblingParent>
<ComposerTopBar
canPost={canPost}
isReply={!!replyTo}
isPublishQueued={publishOnUpload}
isPublishing={isPublishing}
isThread={thread.posts.length > 1}
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={onPressPublish}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
videoState={erroredVideo}
clearError={() => setError('')}
clearVideo={
erroredVideoPostId
? () => clearVideo(erroredVideoPostId)
: () => {}
}
/>
{isWebFooterSticky && post.id === activePost.id && (
<View style={styles.stickyFooterWeb}>{footer}</View>
)}
</React.Fragment>
))}
</Animated.ScrollView>
{!isWebFooterSticky && footer}
</View>
</ComposerTopBar>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
/>
</KeyboardAvoidingView>
<Animated.ScrollView
ref={scrollViewRef}
layout={native(LinearTransition)}
onScroll={scrollHandler}
contentContainerStyle={a.flex_grow}
style={a.flex_1}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{thread.posts.map((post, index) => (
<React.Fragment key={post.id}>
<ComposerPost
post={post}
dispatch={composerDispatch}
textInput={post.id === activePost.id ? textInput : null}
isFirstPost={index === 0}
isLastPost={index === thread.posts.length - 1}
isPartOfThread={thread.posts.length > 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 && (
<View style={styles.stickyFooterWeb}>{footer}</View>
)}
</React.Fragment>
))}
</Animated.ScrollView>
{!isWebFooterSticky && footer}
</RootSiblingParent>
</View>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
/>
</KeyboardAvoidingView>
</RootSiblingParent>
</BottomSheetPortalProvider>
)
}
@@ -811,7 +808,11 @@ let ComposerPost = React.memo(function ComposerPost({
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({
type: 'error',
content: _(msg`Unsupported video type`),
a11yLabel: _(msg`Unsupported video type.`),
})
return
}
const name = `pasted.${mimeToExt(mimeType)}`
@@ -1248,6 +1249,7 @@ function ComposerFooter({
showAddButton,
onEmojiButtonPress,
onError,
onSelectVideo,
onAddPost,
}: {
@@ -1267,6 +1269,17 @@ function ComposerFooter({
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 isMediaSelectionDisabled = false
if (media?.type === 'images') {
isMediaSelectionDisabled = isMaxImages
} else if (media?.type === 'video') {
isMediaSelectionDisabled = isMaxVideos
} else {
isMediaSelectionDisabled = !!media
}
const onImageAdd = useCallback(
(next: ComposerImage[]) => {
@@ -1303,14 +1316,11 @@ function ComposerFooter({
<VideoUploadToolbar state={video} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn
<SelectMediaBtn
size={images.length}
disabled={media?.type === 'images' ? isMaxImages : !!media}
disabled={isMediaSelectionDisabled}
onAdd={onImageAdd}
/>
<SelectVideoBtn
onSelectVideo={asset => onSelectVideo(post.id, asset)}
disabled={!!media}
setError={onError}
/>
<OpenCameraBtn
+504
View File
@@ -0,0 +1,504 @@
import {useCallback} from 'react'
import {type ImagePickerAsset, launchImageLibraryAsync} 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 {
usePhotoLibraryPermission,
useVideoLibraryPermission,
} from '#/lib/hooks/usePermissions'
import {getDataUriSize} from '#/lib/media/util'
import {isNative, isWeb} from '#/platform/detection'
import {type ComposerImage, createComposerImage} from '#/state/gallery'
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'
type Props = {
size: number
disabled?: boolean
onAdd: (next: ComposerImage[]) => void
onSelectVideo: (asset: ImagePickerAsset) => void
setError: (error: string) => void
}
// (Note)APiligrim
// The SelectMediaBtn is responsible for supporting the selection of the following media in the Composer:
// up to 4 images
// up to 1 video
// up to 1 GIF (handled like a video - passed to onSelectVideo)
export function SelectMediaBtn({disabled, onAdd, onSelectVideo}: Props) {
const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const t = useTheme()
const processSelectedAssets = useCallback(
async (assets: ImagePickerAsset[]) => {
function performEarlyValidation(assetList: ImagePickerAsset[]) {
const images: ImagePickerAsset[] = []
const videosAndGifs: ImagePickerAsset[] = []
const invalids: ImagePickerAsset[] = []
// Categorize assets using basic checks (even without complete metadata)
for (const asset of assetList) {
const ext = asset.uri?.split('.').pop()?.toLowerCase()
const isGif = isGifAsset(asset, ext)
const isVideo = isVideoAsset(asset)
const isImage = isImageAsset(asset)
if (isGif || isVideo) {
// GIFs and videos are handled the same way (fixing regression)
videosAndGifs.push(asset)
} else if (isImage) {
images.push(asset)
} else {
invalids.push(asset)
console.error('Invalid asset detected:', asset)
}
}
// Determine media type based on selected assets and preserve selection order
let mediaType: 'image' | 'video' | null = null
for (const asset of assetList) {
const ext = asset.uri?.split('.').pop()?.toLowerCase()
const isGif = isGifAsset(asset, ext)
const isVideo = isVideoAsset(asset)
const isImage = isImageAsset(asset)
if (isGif || isVideo) {
// GIFs are treated like videos
mediaType = 'video'
break
} else if (isImage) {
mediaType = 'image'
break
}
}
let validAssets: ImagePickerAsset[] = []
let trimmed = false
if (mediaType === 'image') {
validAssets = images.slice(0, 4)
if (images.length > 4) trimmed = true
if (videosAndGifs.length > 0) {
toast.show({
type: 'info',
content:
'You can select either images or videos. Taking the images.',
a11yLabel:
'You can select either images or videos. Taking the images.',
})
}
} else if (mediaType === 'video') {
// For videos/GIFs: take only the first one, show toast if mixed media
validAssets = videosAndGifs.slice(0, 1)
if (images.length > 0 || videosAndGifs.length > 1) {
toast.show({
type: 'info',
content:
'You can select either images or videos. Taking the first video.',
a11yLabel:
'You can select either images or videos. Taking the first video.',
})
}
}
return {validAssets, mediaType, trimmed}
}
async function normalizeAssets(assetList: ImagePickerAsset[]) {
const normalizedAssets: ImagePickerAsset[] = []
const failedAssets: ImagePickerAsset[] = []
for (const asset of assetList) {
try {
let normalizedAsset = asset
// Check if we need to enrich metadata
const needsEnrichment =
!asset.mimeType ||
(asset.type === 'video' && !asset.duration) ||
(isGifAsset(asset, asset.uri?.split('.').pop()?.toLowerCase()) &&
!asset.duration)
if (needsEnrichment) {
const enrichedAsset = await getMissingMetadata(asset)
if (enrichedAsset) {
normalizedAsset = enrichedAsset
}
// Last ditch effort: infer mimeType from extension if still missing
if (!normalizedAsset.mimeType) {
normalizedAsset = {
...normalizedAsset,
mimeType:
normalizedAsset.type === 'image'
? 'image/jpeg'
: inferMimeTypeFromURI(normalizedAsset.uri || ''),
}
}
}
normalizedAssets.push(normalizedAsset)
} catch (error) {
console.error('Failed to normalize asset:', asset, error)
failedAssets.push(asset)
}
}
return {normalizedAssets, failedAssets}
}
function performFinalValidation(
assetList: ImagePickerAsset[],
mediaType: 'image' | 'video' | null,
) {
const validAssets: ImagePickerAsset[] = []
for (const asset of assetList) {
if (mediaType === 'image') {
// Images just need basic validation
if (asset.mimeType && asset.mimeType.startsWith('image/')) {
validAssets.push(asset)
} else {
console.error('Image failed final validation:', asset)
}
} else if (mediaType === 'video') {
// Videos and GIFs need duration and format checks
const isValid = validateVideoOrGIF(asset)
if (isValid) {
validAssets.push(asset)
} else {
console.error('Video/GIF failed final validation:', asset)
}
}
}
return validAssets
}
// 1. Early validation to detect asset format and count limits
const {validAssets, mediaType, trimmed} = performEarlyValidation(assets)
if (validAssets.length === 0) {
toast.show({
type: 'error',
content: 'No valid media files selected',
a11yLabel: 'No valid media files selected',
})
return
}
// Show toast if selected files were trimmed
if (trimmed) {
toast.show({
type: 'info',
content: 'Selection limited to first 4 files.',
a11yLabel: 'Selection limited to first 4 files.',
})
}
// 2. Normalize assets by adding missing metadata
const {normalizedAssets, failedAssets} =
await normalizeAssets(validAssets)
if (failedAssets.length > 0) {
console.error('Some assets failed to normalize:', failedAssets)
if (normalizedAssets.length === 0) {
toast.show({
type: 'error',
content: 'Failed to process selected files',
a11yLabel: 'Failed to process selected files',
})
return
} else {
toast.show({
type: 'info',
content: 'Some files could not be processed. Using valid files.',
a11yLabel: 'Some files could not be processed. Using valid files.',
})
}
}
// 3. Final validation checks on normalized assets
const finalAssets = performFinalValidation(normalizedAssets, mediaType)
if (finalAssets.length === 0) {
toast.show({
type: 'error',
content: 'This media type is not supported',
a11yLabel: 'This media type is not supported',
})
return
}
// 4. Add finalized assets to the composer
if (mediaType === 'image') {
const composerImages = await generateComposerImages(finalAssets)
onAdd(composerImages)
} else {
// Both videos and GIFs get selected with onSelectVideo
onSelectVideo(finalAssets[0])
}
},
[onAdd, onSelectVideo],
)
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
}
}
//APiligrim
//Note: selectionLimit doesn't work reliably on Android, so we handle limiting in code
const response = await sheetWrapper(
launchImageLibraryAsync({
exif: false,
mediaTypes: ['images', 'videos'],
quality: 1,
allowsMultipleSelection: true,
legacy: true,
}),
)
if (!response.assets || response.assets.length === 0) {
return
}
await processSelectedAssets(response.assets)
}, [
requestPhotoAccessIfNeeded,
requestVideoAccessIfNeeded,
sheetWrapper,
processSelectedAssets,
])
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>
)
}
async function generateComposerImages(
images: ImagePickerAsset[],
): Promise<ComposerImage[]> {
const imageMetas = images.map(image => ({
mime: image.mimeType || 'image/jpeg',
height: image.height,
width: image.width,
path: image.uri,
size: getDataUriSize(image.uri),
}))
const results = await Promise.all(
imageMetas.map(img => createComposerImage(img)),
)
return results
}
function isGifAsset(asset: ImagePickerAsset, ext?: string): boolean {
return asset.mimeType === 'image/gif' || ext === 'gif'
}
function isVideoAsset(asset: ImagePickerAsset): boolean {
if (asset.type === 'video') {
return true
}
if (asset.mimeType && asset.mimeType.startsWith('video/')) {
return true
}
if (asset.uri && /\.(mp4|mov|avi|webm|m4v)$/i.test(asset.uri)) {
return true
}
// Check for data URI with video mime type
if (asset.uri && asset.uri.startsWith('data:video/')) {
return true
}
if (typeof asset.duration === 'number' && asset.duration > 0) {
return true
}
return false
}
function isImageAsset(asset: ImagePickerAsset): boolean {
if (asset.type === 'image') return true
if (asset.mimeType && asset.mimeType.startsWith('image/')) return true
return false
}
function validateVideoOrGIF(asset: ImagePickerAsset): boolean {
if (!asset) {
console.error('Asset is null or undefined')
return false
}
if (isWeb) {
// asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) {
toast.show({
type: 'error',
content: 'Videos must be less than 3 minutes long',
a11yLabel: 'Videos must be less than 3 minutes long',
})
return false
}
// compression step on native converts to mp4, so no need to check there
if (
asset.mimeType &&
!SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes)
) {
toast.show({
type: 'error',
content: 'This video format is not supported',
a11yLabel: 'This video format is not supported',
})
return false
}
} else {
// Check if asset exists and has duration property before accessing it
if (!asset.duration || typeof asset.duration !== 'number') {
toast.show({
type: 'error',
content: 'Please select a valid video file',
a11yLabel: 'Please select a valid video file',
})
return false
}
if (asset.duration > VIDEO_MAX_DURATION_MS) {
toast.show({
type: 'error',
content: 'Videos must be less than 3 minutes long',
a11yLabel: 'Videos must be less than 3 minutes long',
})
return false
}
}
return true
}
function inferMimeTypeFromURI(uri: string): string {
const ext = uri.split('.').pop()?.toLowerCase()
switch (ext) {
case 'mp4':
return 'video/mp4'
case 'mov':
return 'video/quicktime'
case 'webm':
return 'video/webm'
case 'avi':
return 'video/x-msvideo'
case 'gif':
return 'image/gif'
default:
return 'video/mp4'
}
}
async function getMissingMetadata(
asset: ImagePickerAsset,
): Promise<ImagePickerAsset | null> {
if (!isWeb || !asset.uri) return asset
if (asset.uri.startsWith('data:')) {
try {
const mimeTypeMatch = asset.uri.match(/^data:([^;]+)/)
const extractedMimeType = mimeTypeMatch ? mimeTypeMatch[1] : null
const response = await fetch(asset.uri)
const blob = await response.blob()
const file = new File([blob], 'file', {
type: blob.type || extractedMimeType || '',
})
let enrichedAsset = asset
// If it's a video, try to get video metadata
if (
extractedMimeType?.startsWith('video/') ||
blob.type.startsWith('video/')
) {
try {
const videoAsset = await getVideoMetadata(file)
enrichedAsset = {
...asset,
...videoAsset,
mimeType: videoAsset.mimeType || extractedMimeType || blob.type,
type: 'video',
}
} catch (error) {
console.error(
' if getting metadata for a video fails, using basic info',
error,
)
enrichedAsset = {
...asset,
mimeType: extractedMimeType || blob.type,
type: extractedMimeType?.startsWith('video/')
? 'video'
: asset.type,
}
}
} else {
// For non-video assets (images and GIFs), setting the mime type
enrichedAsset = {
...asset,
mimeType: extractedMimeType || blob.type,
}
}
return enrichedAsset
} catch (error) {
console.error('Failed to extract metadata:', error)
return null
}
}
return asset
}
@@ -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 (
<Button
testID="openGalleryBtn"
onPress={onPressSelectPhotos}
label={_(msg`Gallery`)}
accessibilityHint={_(msg`Opens device photo gallery`)}
style={a.p_sm}
variant="ghost"
shape="round"
color="primary"
disabled={disabled}>
<Image size="lg" style={disabled && t.atoms.text_contrast_low} />
</Button>
)
}
@@ -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 (
<>
<Button
testID="openGifBtn"
onPress={onPressSelectVideo}
label={_(msg`Select video`)}
accessibilityHint={_(msg`Opens video picker`)}
style={a.p_sm}
variant="ghost"
shape="round"
color="primary"
disabled={disabled}>
<VideoClipIcon
size="lg"
style={disabled && t.atoms.text_contrast_low}
/>
</Button>
</>
)
}