diff options
Diffstat (limited to 'src/view/com/composer')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 268 | ||||
-rw-r--r-- | src/view/com/composer/SelectMediaButton.tsx | 524 | ||||
-rw-r--r-- | src/view/com/composer/photos/ImageAltTextDialog.tsx | 5 | ||||
-rw-r--r-- | src/view/com/composer/photos/SelectPhotoBtn.tsx | 60 | ||||
-rw-r--r-- | src/view/com/composer/videos/SelectVideoBtn.tsx | 88 | ||||
-rw-r--r-- | src/view/com/composer/videos/VideoPreview.tsx | 31 |
6 files changed, 723 insertions, 253 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 296545353..c3e0526b9 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 { @@ -77,7 +78,11 @@ import {logger} from '#/logger' import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {useDialogStateControlContext} from '#/state/dialogs' import {emitPostCreated} from '#/state/events' -import {type ComposerImage, pasteImage} from '#/state/gallery' +import { + type ComposerImage, + createComposerImage, + pasteImage, +} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { @@ -103,7 +108,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 +117,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,9 +129,15 @@ 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 * as toast from '#/components/Toast' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { + type AssetType, + SelectMediaButton, + type SelectMediaButtonProps, +} from './SelectMediaButton' +import { type ComposerAction, composerReducer, createComposerState, @@ -514,12 +522,13 @@ export const ComposePost = ({ onPostSuccess?.(postSuccessData) } onClose() - Toast.show( + 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`), + {type: 'success'}, ) }, [ _, @@ -654,84 +663,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> + + <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> + <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,11 +824,16 @@ let ComposerPost = React.memo(function ComposerPost({ const onPhotoPasted = useCallback( async (uri: string) => { - if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) { + if ( + uri.startsWith('data:video/') || + (isWeb && uri.startsWith('data:image/gif')) + ) { 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(_(msg`Unsupported video type: ${mimeType}`), { + type: 'error', + }) return } const name = `pasted.${mimeToExt(mimeType)}` @@ -1251,7 +1269,6 @@ function ComposerFooter({ dispatch, showAddButton, onEmojiButtonPress, - onError, onSelectVideo, onAddPost, }: { @@ -1266,11 +1283,32 @@ function ComposerFooter({ const t = useTheme() const {_} = useLingui() const {isMobile} = useWebMediaQueries() + /* + * Once we've allowed a certain type of asset to be selected, we don't allow + * other types of media to be selected. + */ + const [selectedAssetsType, setSelectedAssetsType] = useState< + AssetType | undefined + >(undefined) const media = post.embed.media 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 selectedAssetsCount = 0 + let isMediaSelectionDisabled = false + + if (media?.type === 'images') { + isMediaSelectionDisabled = isMaxImages + selectedAssetsCount = images.length + } else if (media?.type === 'video') { + isMediaSelectionDisabled = isMaxVideos + selectedAssetsCount = 1 + } else { + isMediaSelectionDisabled = !!media + } const onImageAdd = useCallback( (next: ComposerImage[]) => { @@ -1289,6 +1327,54 @@ function ComposerFooter({ [dispatch], ) + /* + * Reset if the user clears any selected media + */ + if (selectedAssetsType !== undefined && !media) { + setSelectedAssetsType(undefined) + } + + const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>( + async ({type, assets, errors}) => { + setSelectedAssetsType(type) + + if (assets.length) { + if (type === 'image') { + const images: ComposerImage[] = [] + + await Promise.all( + assets.map(async image => { + const composerImage = await createComposerImage({ + path: image.uri, + width: image.width, + height: image.height, + mime: image.mimeType!, + }) + images.push(composerImage) + }), + ).catch(e => { + logger.error(`createComposerImage failed`, { + safeMessage: e.message, + }) + }) + + onImageAdd(images) + } else if (type === 'video') { + onSelectVideo(post.id, assets[0]) + } else if (type === 'gif') { + onSelectVideo(post.id, assets[0]) + } + } + + errors.map(error => { + toast.show(error, { + type: 'warning', + }) + }) + }, + [post.id, onSelectVideo, onImageAdd], + ) + return ( <View style={[ @@ -1307,15 +1393,11 @@ function ComposerFooter({ <VideoUploadToolbar state={video} /> ) : ( <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> - <SelectPhotoBtn - size={images.length} - disabled={media?.type === 'images' ? isMaxImages : !!media} - onAdd={onImageAdd} - /> - <SelectVideoBtn - onSelectVideo={asset => onSelectVideo(post.id, asset)} - disabled={!!media} - setError={onError} + <SelectMediaButton + disabled={isMediaSelectionDisabled} + allowedAssetTypes={selectedAssetsType} + selectedAssetsCount={selectedAssetsCount} + onSelectAssets={onSelectAssets} /> <OpenCameraBtn disabled={media?.type === 'images' ? isMaxImages : !!media} diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx new file mode 100644 index 000000000..026d0ac19 --- /dev/null +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -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> + ) +} diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx index 724149937..b356cde9b 100644 --- a/src/view/com/composer/photos/ImageAltTextDialog.tsx +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -96,13 +96,12 @@ const ImageAltTextInner = ({ <View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}> <Image style={imageStyle} - source={{ - uri: (image.transformed ?? image.source).path, - }} + source={{uri: (image.transformed ?? image.source).path}} contentFit="contain" accessible={true} accessibilityIgnoresInvertColors enableLiveTextInteraction + autoplay={false} /> </View> </View> diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx deleted file mode 100644 index f4c6aa328..000000000 --- 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 ( - <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> - ) -} diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx deleted file mode 100644 index 96715955f..000000000 --- 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 ( - <> - <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> - </> - ) -} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 255174bea..84cb1dba7 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -1,9 +1,10 @@ import React from 'react' import {View} from 'react-native' -import {ImagePickerAsset} from 'expo-image-picker' +import {Image} from 'expo-image' +import {type ImagePickerAsset} from 'expo-image-picker' import {BlueskyVideoView} from '@haileyok/bluesky-video' -import {CompressedVideo} from '#/lib/media/video/types' +import {type CompressedVideo} from '#/lib/media/video/types' import {clamp} from '#/lib/numbers' import {useAutoplayDisabled} from '#/state/preferences' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' @@ -48,13 +49,25 @@ export function VideoPreview({ <VideoTranscodeBackdrop uri={asset.uri} /> </View> {isActivePost && ( - <BlueskyVideoView - url={video.uri} - autoplay={!autoplayDisabled} - beginMuted={true} - forceTakeover={true} - ref={playerRef} - /> + <> + {video.mimeType === 'image/gif' ? ( + <Image + style={[a.flex_1]} + autoplay={!autoplayDisabled} + source={{uri: video.uri}} + accessibilityIgnoresInvertColors + cachePolicy="none" + /> + ) : ( + <BlueskyVideoView + url={video.uri} + autoplay={!autoplayDisabled} + beginMuted={true} + forceTakeover={true} + ref={playerRef} + /> + )} + </> )} <ExternalEmbedRemoveBtn onRemove={clear} /> {autoplayDisabled && ( |