[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:
committed by
Samuel Newman
parent
1c988e6471
commit
e5200c1146
@@ -58,6 +58,7 @@ buck-out/
|
|||||||
# Ruby / CocoaPods
|
# Ruby / CocoaPods
|
||||||
/ios/Pods/
|
/ios/Pods/
|
||||||
/vendor/bundle/
|
/vendor/bundle/
|
||||||
|
Gemfile.lock
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import Animated, {
|
|||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
|
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {
|
import {
|
||||||
@@ -103,7 +104,6 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
|
|||||||
import {Gallery} from '#/view/com/composer/photos/Gallery'
|
import {Gallery} from '#/view/com/composer/photos/Gallery'
|
||||||
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
|
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
|
||||||
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
|
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 {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
|
||||||
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
||||||
// TODO: Prevent naming components that coincide with RN primitives
|
// TODO: Prevent naming components that coincide with RN primitives
|
||||||
@@ -113,12 +113,10 @@ import {
|
|||||||
type TextInputRef,
|
type TextInputRef,
|
||||||
} from '#/view/com/composer/text-input/TextInput'
|
} from '#/view/com/composer/text-input/TextInput'
|
||||||
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
|
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 {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
|
||||||
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
|
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
|
||||||
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
|
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
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 {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
|
import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {toast} from '#/components/Toast'
|
||||||
import {Text as NewText} from '#/components/Typography'
|
import {Text as NewText} from '#/components/Typography'
|
||||||
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||||
|
import {SelectMediaBtn} from './SelectMediaBtn'
|
||||||
import {
|
import {
|
||||||
type ComposerAction,
|
type ComposerAction,
|
||||||
composerReducer,
|
composerReducer,
|
||||||
@@ -514,13 +514,6 @@ export const ComposePost = ({
|
|||||||
onPostSuccess?.(postSuccessData)
|
onPostSuccess?.(postSuccessData)
|
||||||
}
|
}
|
||||||
onClose()
|
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,
|
agent,
|
||||||
@@ -650,84 +643,88 @@ export const ComposePost = ({
|
|||||||
const isWebFooterSticky = !isNative && thread.posts.length > 1
|
const isWebFooterSticky = !isNative && thread.posts.length > 1
|
||||||
return (
|
return (
|
||||||
<BottomSheetPortalProvider>
|
<BottomSheetPortalProvider>
|
||||||
<KeyboardAvoidingView
|
<RootSiblingParent>
|
||||||
testID="composePostView"
|
<KeyboardAvoidingView
|
||||||
behavior={isIOS ? 'padding' : 'height'}
|
testID="composePostView"
|
||||||
keyboardVerticalOffset={keyboardVerticalOffset}
|
behavior={isIOS ? 'padding' : 'height'}
|
||||||
style={a.flex_1}>
|
keyboardVerticalOffset={keyboardVerticalOffset}
|
||||||
<View
|
style={a.flex_1}>
|
||||||
style={[a.flex_1, viewStyles]}
|
<View
|
||||||
aria-modal
|
style={[a.flex_1, viewStyles]}
|
||||||
accessibilityViewIsModal>
|
aria-modal
|
||||||
<ComposerTopBar
|
accessibilityViewIsModal>
|
||||||
canPost={canPost}
|
<RootSiblingParent>
|
||||||
isReply={!!replyTo}
|
<ComposerTopBar
|
||||||
isPublishQueued={publishOnUpload}
|
canPost={canPost}
|
||||||
isPublishing={isPublishing}
|
isReply={!!replyTo}
|
||||||
isThread={thread.posts.length > 1}
|
isPublishQueued={publishOnUpload}
|
||||||
publishingStage={publishingStage}
|
isPublishing={isPublishing}
|
||||||
topBarAnimatedStyle={topBarAnimatedStyle}
|
isThread={thread.posts.length > 1}
|
||||||
onCancel={onPressCancel}
|
publishingStage={publishingStage}
|
||||||
onPublish={onPressPublish}>
|
topBarAnimatedStyle={topBarAnimatedStyle}
|
||||||
{missingAltError && <AltTextReminder error={missingAltError} />}
|
onCancel={onPressCancel}
|
||||||
<ErrorBanner
|
onPublish={onPressPublish}>
|
||||||
error={error}
|
{missingAltError && <AltTextReminder error={missingAltError} />}
|
||||||
videoState={erroredVideo}
|
<ErrorBanner
|
||||||
clearError={() => setError('')}
|
error={error}
|
||||||
clearVideo={
|
videoState={erroredVideo}
|
||||||
erroredVideoPostId
|
clearError={() => setError('')}
|
||||||
? () => clearVideo(erroredVideoPostId)
|
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}
|
|
||||||
/>
|
/>
|
||||||
{isWebFooterSticky && post.id === activePost.id && (
|
</ComposerTopBar>
|
||||||
<View style={styles.stickyFooterWeb}>{footer}</View>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</Animated.ScrollView>
|
|
||||||
{!isWebFooterSticky && footer}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Prompt.Basic
|
<Animated.ScrollView
|
||||||
control={discardPromptControl}
|
ref={scrollViewRef}
|
||||||
title={_(msg`Discard draft?`)}
|
layout={native(LinearTransition)}
|
||||||
description={_(msg`Are you sure you'd like to discard this draft?`)}
|
onScroll={scrollHandler}
|
||||||
onConfirm={onClose}
|
contentContainerStyle={a.flex_grow}
|
||||||
confirmButtonCta={_(msg`Discard`)}
|
style={a.flex_1}
|
||||||
confirmButtonColor="negative"
|
keyboardShouldPersistTaps="always"
|
||||||
/>
|
onContentSizeChange={onScrollViewContentSizeChange}
|
||||||
</KeyboardAvoidingView>
|
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>
|
</BottomSheetPortalProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -811,7 +808,11 @@ let ComposerPost = React.memo(function ComposerPost({
|
|||||||
if (isNative) return // web only
|
if (isNative) return // web only
|
||||||
const [mimeType] = uri.slice('data:'.length).split(';')
|
const [mimeType] = uri.slice('data:'.length).split(';')
|
||||||
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
const name = `pasted.${mimeToExt(mimeType)}`
|
const name = `pasted.${mimeToExt(mimeType)}`
|
||||||
@@ -1248,6 +1249,7 @@ function ComposerFooter({
|
|||||||
showAddButton,
|
showAddButton,
|
||||||
onEmojiButtonPress,
|
onEmojiButtonPress,
|
||||||
onError,
|
onError,
|
||||||
|
|
||||||
onSelectVideo,
|
onSelectVideo,
|
||||||
onAddPost,
|
onAddPost,
|
||||||
}: {
|
}: {
|
||||||
@@ -1267,6 +1269,17 @@ function ComposerFooter({
|
|||||||
const images = media?.type === 'images' ? media.images : []
|
const images = media?.type === 'images' ? media.images : []
|
||||||
const video = media?.type === 'video' ? media.video : null
|
const video = media?.type === 'video' ? media.video : null
|
||||||
const isMaxImages = images.length >= MAX_IMAGES
|
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(
|
const onImageAdd = useCallback(
|
||||||
(next: ComposerImage[]) => {
|
(next: ComposerImage[]) => {
|
||||||
@@ -1303,14 +1316,11 @@ function ComposerFooter({
|
|||||||
<VideoUploadToolbar state={video} />
|
<VideoUploadToolbar state={video} />
|
||||||
) : (
|
) : (
|
||||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<SelectPhotoBtn
|
<SelectMediaBtn
|
||||||
size={images.length}
|
size={images.length}
|
||||||
disabled={media?.type === 'images' ? isMaxImages : !!media}
|
disabled={isMediaSelectionDisabled}
|
||||||
onAdd={onImageAdd}
|
onAdd={onImageAdd}
|
||||||
/>
|
|
||||||
<SelectVideoBtn
|
|
||||||
onSelectVideo={asset => onSelectVideo(post.id, asset)}
|
onSelectVideo={asset => onSelectVideo(post.id, asset)}
|
||||||
disabled={!!media}
|
|
||||||
setError={onError}
|
setError={onError}
|
||||||
/>
|
/>
|
||||||
<OpenCameraBtn
|
<OpenCameraBtn
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user