diff --git a/.gitignore b/.gitignore index 7233b35472..0bef5d88a1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ buck-out/ # Ruby / CocoaPods /ios/Pods/ /vendor/bundle/ +Gemfile.lock # Testing coverage/ diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 71714fddee..b91009196a 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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 ( - - - 1} - publishingStage={publishingStage} - topBarAnimatedStyle={topBarAnimatedStyle} - onCancel={onPressCancel} - onPublish={onPressPublish}> - {missingAltError && } - setError('')} - clearVideo={ - erroredVideoPostId - ? () => clearVideo(erroredVideoPostId) - : () => {} - } - /> - - - - {replyTo ? : undefined} - {thread.posts.map((post, index) => ( - - 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} + + + + + 1} + publishingStage={publishingStage} + topBarAnimatedStyle={topBarAnimatedStyle} + onCancel={onPressCancel} + onPublish={onPressPublish}> + {missingAltError && } + setError('')} + clearVideo={ + erroredVideoPostId + ? () => clearVideo(erroredVideoPostId) + : () => {} + } /> - {isWebFooterSticky && post.id === activePost.id && ( - {footer} - )} - - ))} - - {!isWebFooterSticky && footer} - + - - + + {replyTo ? : undefined} + {thread.posts.map((post, index) => ( + + 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 && ( + {footer} + )} + + ))} + + {!isWebFooterSticky && footer} + + + + + + ) } @@ -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({ ) : ( - - onSelectVideo(post.id, asset)} - disabled={!!media} setError={onError} /> 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 ( + + ) +} + +async function generateComposerImages( + images: ImagePickerAsset[], +): Promise { + 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 { + 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 +} diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx deleted file mode 100644 index f4c6aa328a..0000000000 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx deleted file mode 100644 index 96715955f0..0000000000 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ /dev/null @@ -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 ( - <> - - - ) -}