An ATproto social media client -- with an independent Appview.

[APP-1318] `SelectMediaButton` (#8828)

* Integrate Sonner for toasts

* Fix animation on iOS

* Refactor API

* Update e2e file

* [APP-1318] Post composer: combine image & video buttons (#8710)

* add: select media btn

* udpate: compose post with combined image and video support

* add: video combine button with edge cases

* add select media btn

* test: select media btn

* add: media button update

* remove unused files and update toast on android

* update: make strings shorter

* add: ValidatedVideoAsset type

* update link comments and add toast support for native and web

* rebase latest toast and update toast structure

* remove unused prop

* fix types

* undo changes to yarn.lock

* remove: support for mkv files

* update: eslint and prettier

(cherry picked from commit f69779ee130f07e1c49219b53117e3bdd1a9f81b)

* Add missing props to launchImageLibraryAsync

(cherry picked from commit 2e80ae561fd66850f787cac0aae0fa5a6980f8f5)

* Rough out new approach

(cherry picked from commit 9add225160e7e407befc73e9cdd9743a30cdf1cd)

* Comments and cleanup

(cherry picked from commit e69bd186e7335372f440c446ae6643ed0fb15db9)

* Handle native case

(cherry picked from commit 74e38acdfd9181d0557426691fcbcbf0800481ca)

* Refactor

(cherry picked from commit 68aea496db8df54dba5f58da267ad962c28ef995)

* Rename

(cherry picked from commit 8609e59ad14219e7378ee6cb9514d633ce7efc27)

* Cleanup, comments

(cherry picked from commit 6c9c98648e37257285a9c8caeb1eadcc56c81402)

* Rename

(cherry picked from commit 66e3db539d5baa41436c9e49af06e87a78e9e7e1)

* Handle selectionLimit on Android

(cherry picked from commit 251f06dd5e65a7083b810bad3d81114b2fe9ab39)

* create composer images in parallel

(cherry picked from commit 70ea79d9d76d99e9c99a7d2296caed84c718650e)

* Update toast API usage

(cherry picked from commit e370018b8ed8cdfd7675c9634058c72cb59d39de)

* Ensure once one type of media is selected, you can only select more of that type

(cherry picked from commit 1a9e6e0cdb5234667f08e3dd9107ae598941fc23)

* Remove TODO and debug code

* Add more descriptive a11y label to button

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Add back post success toast

* Include mimeType in toast error

* Remove unneeded toast

* Clarify hint

* Typo

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* allow gifs on native, just treat as images

* disable haptic toast

* allow gifs on native, treat as videos

* only do keyboard dismiss on native

* tweak pasting logic

* hide web scrubber in certain situations

* Update MaxImages translation

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Add plural formatting to a11y hint translation

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix suggestion

* Protect against no valid assets selected

* Handle conversion of too-big assets on web

* Reorder

* Bump expo-image-picker to include bug/perf improvements

See https://github.com/expo/expo/blob/main/packages/expo-image-picker/CHANGELOG.md#1700--2025-08-13

* Handle edge case validations

* Ok actually bump expo-image-picker

* Comment

* HEIC support Android

* Fix handling for new picker version, improve size validation

* Remove getVideoMetadata handling, no longer needed

* Handle web video duration

* Update src/view/com/composer/SelectMediaButton.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

---------

Co-authored-by: Anastasiya Uraleva <anastasiyauraleva@gmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Eric Bailey
surfdude29
Anastasiya Uraleva
Samuel Newman
and committed by
GitHub
122a4689 cced762a

+763 -319
+1
.gitignore
··· 58 58 # Ruby / CocoaPods 59 59 /ios/Pods/ 60 60 /vendor/bundle/ 61 + Gemfile.lock 61 62 62 63 # Testing 63 64 coverage/
+1 -1
package.json
··· 146 146 "expo-image": "^2.4.0", 147 147 "expo-image-crop-tool": "^0.1.8", 148 148 "expo-image-manipulator": "~13.1.7", 149 - "expo-image-picker": "~16.1.4", 149 + "expo-image-picker": "^17.0.2", 150 150 "expo-intent-launcher": "^12.1.5", 151 151 "expo-linear-gradient": "~14.1.5", 152 152 "expo-linking": "~7.1.5",
-38
patches/expo-image-picker+16.1.4.patch
··· 1 - diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt 2 - index c863fb8..cde8859 100644 3 - --- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt 4 - +++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt 5 - @@ -101,16 +101,30 @@ internal class MediaHandler( 6 - val fileData = getAdditionalFileData(sourceUri) 7 - val mimeType = getType(context.contentResolver, sourceUri) 8 - 9 - + // Extract basic metadata 10 - + var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) 11 - + var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) 12 - + val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) 13 - + 14 - + // Android returns the encoded width/height which do not take the display rotation into 15 - + // account. For videos recorded in portrait mode the encoded dimensions are often landscape 16 - + // (e.g. 1920x1080) paired with a 90°/270° rotation flag. iOS adjusts these values before 17 - + // reporting them, so to keep the behaviour consistent across platforms we swap the width 18 - + // and height when the rotation indicates the video should be displayed in portrait. 19 - + if (rotation % 180 != 0) { 20 - + width = height.also { height = width } 21 - + } 22 - + 23 - return ImagePickerAsset( 24 - type = MediaType.VIDEO, 25 - uri = outputUri.toString(), 26 - - width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH), 27 - - height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT), 28 - + width = width, 29 - + height = height, 30 - fileName = fileData?.fileName, 31 - fileSize = fileData?.fileSize, 32 - mimeType = mimeType, 33 - duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION), 34 - - rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION), 35 - + rotation = rotation, 36 - assetId = sourceUri.getMediaStoreAssetId() 37 - ) 38 - } catch (cause: FailedToExtractVideoMetadataException) {
-5
patches/expo-image-picker+16.1.4.patch.md
··· 1 - # Expo Image Picker patch 2 - 3 - Cherry-picked https://github.com/expo/expo/pull/37849 4 - 5 - Remove when we update to a version that includes this commit.
+2
src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
··· 146 146 const progress = scrubberActive ? seekPosition : currentTime 147 147 const progressPercent = (progress / duration) * 100 148 148 149 + if (duration < 3) return null 150 + 149 151 return ( 150 152 <View 151 153 testID="scrubber"
+9 -7
src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
··· 373 373 onPress={onPressPlayPause} 374 374 /> 375 375 <View style={a.flex_1} /> 376 - <Text 377 - style={[ 378 - a.px_xs, 379 - {color: t.palette.white, fontVariant: ['tabular-nums']}, 380 - ]}> 381 - {formatTime(currentTime)} / {formatTime(duration)} 382 - </Text> 376 + {Math.round(duration) > 0 && ( 377 + <Text 378 + style={[ 379 + a.px_xs, 380 + {color: t.palette.white, fontVariant: ['tabular-nums']}, 381 + ]}> 382 + {formatTime(currentTime)} / {formatTime(duration)} 383 + </Text> 384 + )} 383 385 {hasSubtitleTrack && ( 384 386 <ControlButton 385 387 active={subtitlesEnabled}
+4
src/lib/constants.ts
··· 181 181 export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app' 182 182 183 183 export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds 184 + /** 185 + * Maximum size of a video in megabytes, _not_ mebibytes. Backend uses 186 + * ISO megabytes. 187 + */ 184 188 export const VIDEO_MAX_SIZE = 1000 * 1000 * 100 // 100mb 185 189 186 190 export const SUPPORTED_MIME_TYPES = [
+2 -2
src/lib/haptics.ts
··· 4 4 5 5 import {isIOS, isWeb} from '#/platform/detection' 6 6 import {useHapticsDisabled} from '#/state/preferences/disable-haptics' 7 - import * as Toast from '#/view/com/util/Toast' 8 7 9 8 export function useHaptics() { 10 9 const isHapticsDisabled = useHapticsDisabled() ··· 23 22 24 23 // DEV ONLY - show a toast when a haptic is meant to fire on simulator 25 24 if (__DEV__ && !Device.isDevice) { 26 - Toast.show(`Buzzz!`) 25 + // disabled because it's annoying 26 + // Toast.show(`Buzzz!`) 27 27 } 28 28 }, 29 29 [isHapticsDisabled],
+1 -5
src/lib/media/picker.shared.ts
··· 17 17 exif: false, 18 18 mediaTypes: ['images'], 19 19 quality: 1, 20 + selectionLimit: 1, 20 21 ...opts, 21 22 legacy: true, 22 23 }) 23 24 24 - if (response.assets && response.assets.length > 4) { 25 - Toast.show(t`You may only select up to 4 images`, 'exclamation-circle') 26 - } 27 - 28 25 return (response.assets ?? []) 29 - .slice(0, 4) 30 26 .filter(asset => { 31 27 if (asset.mimeType?.startsWith('image/')) return true 32 28 Toast.show(t`Only image files are supported`, 'exclamation-circle')
+10 -3
src/lib/media/video/compress.ts
··· 1 1 import {getVideoMetaData, Video} from 'react-native-compressor' 2 - import {ImagePickerAsset} from 'expo-image-picker' 2 + import {type ImagePickerAsset} from 'expo-image-picker' 3 3 4 - import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' 5 - import {CompressedVideo} from './types' 4 + import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants' 5 + import {type CompressedVideo} from './types' 6 6 import {extToMime} from './util' 7 7 8 8 const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb ··· 19 19 const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( 20 20 file.mimeType as SupportedMimeTypes, 21 21 ) 22 + 23 + if (file.mimeType === 'image/gif') { 24 + // let's hope they're small enough that they don't need compression! 25 + // this compression library doesn't support gifs 26 + // worst case - server rejects them. I think that's fine -sfn 27 + return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'} 28 + } 22 29 23 30 const minimumFileSizeForCompress = isAcceptableFormat 24 31 ? MIN_SIZE_FOR_COMPRESSION
+175 -93
src/view/com/composer/Composer.tsx
··· 40 40 ZoomIn, 41 41 ZoomOut, 42 42 } from 'react-native-reanimated' 43 + import {RootSiblingParent} from 'react-native-root-siblings' 43 44 import {useSafeAreaInsets} from 'react-native-safe-area-context' 44 45 import {type ImagePickerAsset} from 'expo-image-picker' 45 46 import { ··· 77 78 import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' 78 79 import {useDialogStateControlContext} from '#/state/dialogs' 79 80 import {emitPostCreated} from '#/state/events' 80 - import {type ComposerImage, pasteImage} from '#/state/gallery' 81 + import { 82 + type ComposerImage, 83 + createComposerImage, 84 + pasteImage, 85 + } from '#/state/gallery' 81 86 import {useModalControls} from '#/state/modals' 82 87 import {useRequireAltTextEnabled} from '#/state/preferences' 83 88 import { ··· 103 108 import {Gallery} from '#/view/com/composer/photos/Gallery' 104 109 import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn' 105 110 import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn' 106 - import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn' 107 111 import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn' 108 112 import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage' 109 113 // TODO: Prevent naming components that coincide with RN primitives ··· 113 117 type TextInputRef, 114 118 } from '#/view/com/composer/text-input/TextInput' 115 119 import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' 116 - import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn' 117 120 import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' 118 121 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' 119 122 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' 120 123 import {Text} from '#/view/com/util/text/Text' 121 - import * as Toast from '#/view/com/util/Toast' 122 124 import {UserAvatar} from '#/view/com/util/UserAvatar' 123 125 import {atoms as a, native, useTheme, web} from '#/alf' 124 126 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 127 129 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 128 130 import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' 129 131 import * as Prompt from '#/components/Prompt' 132 + import * as toast from '#/components/Toast' 130 133 import {Text as NewText} from '#/components/Typography' 131 134 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' 135 + import { 136 + type AssetType, 137 + SelectMediaButton, 138 + type SelectMediaButtonProps, 139 + } from './SelectMediaButton' 132 140 import { 133 141 type ComposerAction, 134 142 composerReducer, ··· 514 522 onPostSuccess?.(postSuccessData) 515 523 } 516 524 onClose() 517 - Toast.show( 525 + toast.show( 518 526 thread.posts.length > 1 519 527 ? _(msg`Your posts have been published`) 520 528 : replyTo 521 529 ? _(msg`Your reply has been published`) 522 530 : _(msg`Your post has been published`), 531 + {type: 'success'}, 523 532 ) 524 533 }, [ 525 534 _, ··· 654 663 const isWebFooterSticky = !isNative && thread.posts.length > 1 655 664 return ( 656 665 <BottomSheetPortalProvider> 657 - <KeyboardAvoidingView 658 - testID="composePostView" 659 - behavior={isIOS ? 'padding' : 'height'} 660 - keyboardVerticalOffset={keyboardVerticalOffset} 661 - style={a.flex_1}> 662 - <View 663 - style={[a.flex_1, viewStyles]} 664 - aria-modal 665 - accessibilityViewIsModal> 666 - <ComposerTopBar 667 - canPost={canPost} 668 - isReply={!!replyTo} 669 - isPublishQueued={publishOnUpload} 670 - isPublishing={isPublishing} 671 - isThread={thread.posts.length > 1} 672 - publishingStage={publishingStage} 673 - topBarAnimatedStyle={topBarAnimatedStyle} 674 - onCancel={onPressCancel} 675 - onPublish={onPressPublish}> 676 - {missingAltError && <AltTextReminder error={missingAltError} />} 677 - <ErrorBanner 678 - error={error} 679 - videoState={erroredVideo} 680 - clearError={() => setError('')} 681 - clearVideo={ 682 - erroredVideoPostId 683 - ? () => clearVideo(erroredVideoPostId) 684 - : () => {} 685 - } 686 - /> 687 - </ComposerTopBar> 688 - 689 - <Animated.ScrollView 690 - ref={scrollViewRef} 691 - layout={native(LinearTransition)} 692 - onScroll={scrollHandler} 693 - contentContainerStyle={a.flex_grow} 694 - style={a.flex_1} 695 - keyboardShouldPersistTaps="always" 696 - onContentSizeChange={onScrollViewContentSizeChange} 697 - onLayout={onScrollViewLayout}> 698 - {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} 699 - {thread.posts.map((post, index) => ( 700 - <React.Fragment key={post.id}> 701 - <ComposerPost 702 - post={post} 703 - dispatch={composerDispatch} 704 - textInput={post.id === activePost.id ? textInput : null} 705 - isFirstPost={index === 0} 706 - isLastPost={index === thread.posts.length - 1} 707 - isPartOfThread={thread.posts.length > 1} 708 - isReply={index > 0 || !!replyTo} 709 - isActive={post.id === activePost.id} 710 - canRemovePost={thread.posts.length > 1} 711 - canRemoveQuote={index > 0 || !initQuote} 712 - onSelectVideo={selectVideo} 713 - onClearVideo={clearVideo} 714 - onPublish={onComposerPostPublish} 715 - onError={setError} 666 + <RootSiblingParent> 667 + <KeyboardAvoidingView 668 + testID="composePostView" 669 + behavior={isIOS ? 'padding' : 'height'} 670 + keyboardVerticalOffset={keyboardVerticalOffset} 671 + style={a.flex_1}> 672 + <View 673 + style={[a.flex_1, viewStyles]} 674 + aria-modal 675 + accessibilityViewIsModal> 676 + <RootSiblingParent> 677 + <ComposerTopBar 678 + canPost={canPost} 679 + isReply={!!replyTo} 680 + isPublishQueued={publishOnUpload} 681 + isPublishing={isPublishing} 682 + isThread={thread.posts.length > 1} 683 + publishingStage={publishingStage} 684 + topBarAnimatedStyle={topBarAnimatedStyle} 685 + onCancel={onPressCancel} 686 + onPublish={onPressPublish}> 687 + {missingAltError && <AltTextReminder error={missingAltError} />} 688 + <ErrorBanner 689 + error={error} 690 + videoState={erroredVideo} 691 + clearError={() => setError('')} 692 + clearVideo={ 693 + erroredVideoPostId 694 + ? () => clearVideo(erroredVideoPostId) 695 + : () => {} 696 + } 716 697 /> 717 - {isWebFooterSticky && post.id === activePost.id && ( 718 - <View style={styles.stickyFooterWeb}>{footer}</View> 719 - )} 720 - </React.Fragment> 721 - ))} 722 - </Animated.ScrollView> 723 - {!isWebFooterSticky && footer} 724 - </View> 698 + </ComposerTopBar> 725 699 726 - <Prompt.Basic 727 - control={discardPromptControl} 728 - title={_(msg`Discard draft?`)} 729 - description={_(msg`Are you sure you'd like to discard this draft?`)} 730 - onConfirm={onClose} 731 - confirmButtonCta={_(msg`Discard`)} 732 - confirmButtonColor="negative" 733 - /> 734 - </KeyboardAvoidingView> 700 + <Animated.ScrollView 701 + ref={scrollViewRef} 702 + layout={native(LinearTransition)} 703 + onScroll={scrollHandler} 704 + contentContainerStyle={a.flex_grow} 705 + style={a.flex_1} 706 + keyboardShouldPersistTaps="always" 707 + onContentSizeChange={onScrollViewContentSizeChange} 708 + onLayout={onScrollViewLayout}> 709 + {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} 710 + {thread.posts.map((post, index) => ( 711 + <React.Fragment key={post.id}> 712 + <ComposerPost 713 + post={post} 714 + dispatch={composerDispatch} 715 + textInput={post.id === activePost.id ? textInput : null} 716 + isFirstPost={index === 0} 717 + isLastPost={index === thread.posts.length - 1} 718 + isPartOfThread={thread.posts.length > 1} 719 + isReply={index > 0 || !!replyTo} 720 + isActive={post.id === activePost.id} 721 + canRemovePost={thread.posts.length > 1} 722 + canRemoveQuote={index > 0 || !initQuote} 723 + onSelectVideo={selectVideo} 724 + onClearVideo={clearVideo} 725 + onPublish={onComposerPostPublish} 726 + onError={setError} 727 + /> 728 + {isWebFooterSticky && post.id === activePost.id && ( 729 + <View style={styles.stickyFooterWeb}>{footer}</View> 730 + )} 731 + </React.Fragment> 732 + ))} 733 + </Animated.ScrollView> 734 + {!isWebFooterSticky && footer} 735 + </RootSiblingParent> 736 + </View> 737 + 738 + <Prompt.Basic 739 + control={discardPromptControl} 740 + title={_(msg`Discard draft?`)} 741 + description={_(msg`Are you sure you'd like to discard this draft?`)} 742 + onConfirm={onClose} 743 + confirmButtonCta={_(msg`Discard`)} 744 + confirmButtonColor="negative" 745 + /> 746 + </KeyboardAvoidingView> 747 + </RootSiblingParent> 735 748 </BottomSheetPortalProvider> 736 749 ) 737 750 } ··· 811 824 812 825 const onPhotoPasted = useCallback( 813 826 async (uri: string) => { 814 - if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) { 827 + if ( 828 + uri.startsWith('data:video/') || 829 + (isWeb && uri.startsWith('data:image/gif')) 830 + ) { 815 831 if (isNative) return // web only 816 832 const [mimeType] = uri.slice('data:'.length).split(';') 817 833 if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { 818 - Toast.show(_(msg`Unsupported video type`), 'xmark') 834 + toast.show(_(msg`Unsupported video type: ${mimeType}`), { 835 + type: 'error', 836 + }) 819 837 return 820 838 } 821 839 const name = `pasted.${mimeToExt(mimeType)}` ··· 1251 1269 dispatch, 1252 1270 showAddButton, 1253 1271 onEmojiButtonPress, 1254 - onError, 1255 1272 onSelectVideo, 1256 1273 onAddPost, 1257 1274 }: { ··· 1266 1283 const t = useTheme() 1267 1284 const {_} = useLingui() 1268 1285 const {isMobile} = useWebMediaQueries() 1286 + /* 1287 + * Once we've allowed a certain type of asset to be selected, we don't allow 1288 + * other types of media to be selected. 1289 + */ 1290 + const [selectedAssetsType, setSelectedAssetsType] = useState< 1291 + AssetType | undefined 1292 + >(undefined) 1269 1293 1270 1294 const media = post.embed.media 1271 1295 const images = media?.type === 'images' ? media.images : [] 1272 1296 const video = media?.type === 'video' ? media.video : null 1273 1297 const isMaxImages = images.length >= MAX_IMAGES 1298 + const isMaxVideos = !!video 1299 + 1300 + let selectedAssetsCount = 0 1301 + let isMediaSelectionDisabled = false 1302 + 1303 + if (media?.type === 'images') { 1304 + isMediaSelectionDisabled = isMaxImages 1305 + selectedAssetsCount = images.length 1306 + } else if (media?.type === 'video') { 1307 + isMediaSelectionDisabled = isMaxVideos 1308 + selectedAssetsCount = 1 1309 + } else { 1310 + isMediaSelectionDisabled = !!media 1311 + } 1274 1312 1275 1313 const onImageAdd = useCallback( 1276 1314 (next: ComposerImage[]) => { ··· 1289 1327 [dispatch], 1290 1328 ) 1291 1329 1330 + /* 1331 + * Reset if the user clears any selected media 1332 + */ 1333 + if (selectedAssetsType !== undefined && !media) { 1334 + setSelectedAssetsType(undefined) 1335 + } 1336 + 1337 + const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>( 1338 + async ({type, assets, errors}) => { 1339 + setSelectedAssetsType(type) 1340 + 1341 + if (assets.length) { 1342 + if (type === 'image') { 1343 + const images: ComposerImage[] = [] 1344 + 1345 + await Promise.all( 1346 + assets.map(async image => { 1347 + const composerImage = await createComposerImage({ 1348 + path: image.uri, 1349 + width: image.width, 1350 + height: image.height, 1351 + mime: image.mimeType!, 1352 + }) 1353 + images.push(composerImage) 1354 + }), 1355 + ).catch(e => { 1356 + logger.error(`createComposerImage failed`, { 1357 + safeMessage: e.message, 1358 + }) 1359 + }) 1360 + 1361 + onImageAdd(images) 1362 + } else if (type === 'video') { 1363 + onSelectVideo(post.id, assets[0]) 1364 + } else if (type === 'gif') { 1365 + onSelectVideo(post.id, assets[0]) 1366 + } 1367 + } 1368 + 1369 + errors.map(error => { 1370 + toast.show(error, { 1371 + type: 'warning', 1372 + }) 1373 + }) 1374 + }, 1375 + [post.id, onSelectVideo, onImageAdd], 1376 + ) 1377 + 1292 1378 return ( 1293 1379 <View 1294 1380 style={[ ··· 1307 1393 <VideoUploadToolbar state={video} /> 1308 1394 ) : ( 1309 1395 <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> 1310 - <SelectPhotoBtn 1311 - size={images.length} 1312 - disabled={media?.type === 'images' ? isMaxImages : !!media} 1313 - onAdd={onImageAdd} 1314 - /> 1315 - <SelectVideoBtn 1316 - onSelectVideo={asset => onSelectVideo(post.id, asset)} 1317 - disabled={!!media} 1318 - setError={onError} 1396 + <SelectMediaButton 1397 + disabled={isMediaSelectionDisabled} 1398 + allowedAssetTypes={selectedAssetsType} 1399 + selectedAssetsCount={selectedAssetsCount} 1400 + onSelectAssets={onSelectAssets} 1319 1401 /> 1320 1402 <OpenCameraBtn 1321 1403 disabled={media?.type === 'images' ? isMaxImages : !!media}
+524
src/view/com/composer/SelectMediaButton.tsx
··· 1 + import {useCallback} from 'react' 2 + import {Keyboard} from 'react-native' 3 + import { 4 + type ImagePickerAsset, 5 + launchImageLibraryAsync, 6 + UIImagePickerPreferredAssetRepresentationMode, 7 + } from 'expo-image-picker' 8 + import {msg, plural} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + 11 + import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants' 12 + import { 13 + usePhotoLibraryPermission, 14 + useVideoLibraryPermission, 15 + } from '#/lib/hooks/usePermissions' 16 + import {extractDataUriMime} from '#/lib/media/util' 17 + import {isIOS, isNative, isWeb} from '#/platform/detection' 18 + import {MAX_IMAGES} from '#/view/com/composer/state/composer' 19 + import {atoms as a, useTheme} from '#/alf' 20 + import {Button} from '#/components/Button' 21 + import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 22 + import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 23 + import * as toast from '#/components/Toast' 24 + 25 + export type SelectMediaButtonProps = { 26 + disabled?: boolean 27 + /** 28 + * If set, this limits the types of assets that can be selected. 29 + */ 30 + allowedAssetTypes: AssetType | undefined 31 + selectedAssetsCount: number 32 + onSelectAssets: (props: { 33 + type: AssetType 34 + assets: ImagePickerAsset[] 35 + errors: string[] 36 + }) => void 37 + } 38 + 39 + /** 40 + * Generic asset classes, or buckets, that we support. 41 + */ 42 + export type AssetType = 'video' | 'image' | 'gif' 43 + 44 + /** 45 + * Shadows `ImagePickerAsset` from `expo-image-picker`, but with a guaranteed `mimeType` 46 + */ 47 + type ValidatedImagePickerAsset = Omit<ImagePickerAsset, 'mimeType'> & { 48 + mimeType: string 49 + } 50 + 51 + /** 52 + * Codes for known validation states 53 + */ 54 + enum SelectedAssetError { 55 + Unsupported = 'Unsupported', 56 + MixedTypes = 'MixedTypes', 57 + MaxImages = 'MaxImages', 58 + MaxVideos = 'MaxVideos', 59 + VideoTooLong = 'VideoTooLong', 60 + FileTooBig = 'FileTooBig', 61 + MaxGIFs = 'MaxGIFs', 62 + } 63 + 64 + /** 65 + * Supported video mime types. This differs slightly from 66 + * `SUPPORTED_MIME_TYPES` from `#/lib/constants` because we only care about 67 + * videos here. 68 + */ 69 + const SUPPORTED_VIDEO_MIME_TYPES = [ 70 + 'video/mp4', 71 + 'video/mpeg', 72 + 'video/webm', 73 + 'video/quicktime', 74 + ] as const 75 + type SupportedVideoMimeType = (typeof SUPPORTED_VIDEO_MIME_TYPES)[number] 76 + function isSupportedVideoMimeType( 77 + mimeType: string, 78 + ): mimeType is SupportedVideoMimeType { 79 + return SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType) 80 + } 81 + 82 + /** 83 + * Supported image mime types. 84 + */ 85 + const SUPPORTED_IMAGE_MIME_TYPES = ( 86 + [ 87 + 'image/gif', 88 + 'image/jpeg', 89 + 'image/png', 90 + 'image/svg+xml', 91 + 'image/webp', 92 + 'image/avif', 93 + isNative && 'image/heic', 94 + ] as const 95 + ).filter(Boolean) 96 + type SupportedImageMimeType = Exclude< 97 + (typeof SUPPORTED_IMAGE_MIME_TYPES)[number], 98 + boolean 99 + > 100 + function isSupportedImageMimeType( 101 + mimeType: string, 102 + ): mimeType is SupportedImageMimeType { 103 + return SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType as SupportedImageMimeType) 104 + } 105 + 106 + /** 107 + * This is a last-ditch effort type thing here, try not to rely on this. 108 + */ 109 + const extensionToMimeType: Record< 110 + string, 111 + SupportedVideoMimeType | SupportedImageMimeType 112 + > = { 113 + mp4: 'video/mp4', 114 + mov: 'video/quicktime', 115 + webm: 'video/webm', 116 + webp: 'image/webp', 117 + gif: 'image/gif', 118 + jpg: 'image/jpeg', 119 + jpeg: 'image/jpeg', 120 + png: 'image/png', 121 + svg: 'image/svg+xml', 122 + heic: 'image/heic', 123 + } 124 + 125 + /** 126 + * Attempts to bucket the given asset into one of our known types based on its 127 + * `mimeType`. If `mimeType` is not available, we try to infer it through 128 + * various means. 129 + */ 130 + function classifyImagePickerAsset(asset: ImagePickerAsset): 131 + | { 132 + success: true 133 + type: AssetType 134 + mimeType: string 135 + } 136 + | { 137 + success: false 138 + type: undefined 139 + mimeType: undefined 140 + } { 141 + /* 142 + * Try to use the `mimeType` reported by `expo-image-picker` first. 143 + */ 144 + let mimeType = asset.mimeType 145 + 146 + if (!mimeType) { 147 + /* 148 + * We can try to infer this from the data-uri. 149 + */ 150 + const maybeMimeType = extractDataUriMime(asset.uri) 151 + 152 + if ( 153 + maybeMimeType.startsWith('image/') || 154 + maybeMimeType.startsWith('video/') 155 + ) { 156 + mimeType = maybeMimeType 157 + } else if (maybeMimeType.startsWith('file/')) { 158 + /* 159 + * On the off-chance we get a `file/*` mime, try to infer from the 160 + * extension. 161 + */ 162 + const extension = asset.uri.split('.').pop()?.toLowerCase() 163 + mimeType = extensionToMimeType[extension || ''] 164 + } 165 + } 166 + 167 + if (!mimeType) { 168 + return { 169 + success: false, 170 + type: undefined, 171 + mimeType: undefined, 172 + } 173 + } 174 + 175 + /* 176 + * Distill this down into a type "class". 177 + */ 178 + let type: AssetType | undefined 179 + if (mimeType === 'image/gif') { 180 + type = 'gif' 181 + } else if (mimeType?.startsWith('video/')) { 182 + type = 'video' 183 + } else if (mimeType?.startsWith('image/')) { 184 + type = 'image' 185 + } 186 + 187 + /* 188 + * If we weren't able to find a valid type, we don't support this asset. 189 + */ 190 + if (!type) { 191 + return { 192 + success: false, 193 + type: undefined, 194 + mimeType: undefined, 195 + } 196 + } 197 + 198 + return { 199 + success: true, 200 + type, 201 + mimeType, 202 + } 203 + } 204 + 205 + /** 206 + * Takes in raw assets from `expo-image-picker` and applies validation. Returns 207 + * the dominant `AssetType`, any valid assets, and any errors encountered along 208 + * the way. 209 + */ 210 + async function processImagePickerAssets( 211 + assets: ImagePickerAsset[], 212 + { 213 + selectionCountRemaining, 214 + allowedAssetTypes, 215 + }: { 216 + selectionCountRemaining: number 217 + allowedAssetTypes: AssetType | undefined 218 + }, 219 + ) { 220 + /* 221 + * A deduped set of error codes, which we'll use later 222 + */ 223 + const errors = new Set<SelectedAssetError>() 224 + 225 + /* 226 + * We only support selecting a single type of media at a time, so this gets 227 + * set to whatever the first valid asset type is, OR to whatever 228 + * `allowedAssetTypes` is set to. 229 + */ 230 + let selectableAssetType: AssetType | undefined 231 + 232 + /* 233 + * This will hold the assets that we can actually use, after filtering 234 + */ 235 + let supportedAssets: ValidatedImagePickerAsset[] = [] 236 + 237 + for (const asset of assets) { 238 + const {success, type, mimeType} = classifyImagePickerAsset(asset) 239 + 240 + if (!success) { 241 + errors.add(SelectedAssetError.Unsupported) 242 + continue 243 + } 244 + 245 + /* 246 + * If we have an `allowedAssetTypes` prop, constrain to that. Otherwise, 247 + * set this to the first valid asset type we see, and then use that to 248 + * constrain all remaining selected assets. 249 + */ 250 + selectableAssetType = allowedAssetTypes || selectableAssetType || type 251 + 252 + // ignore mixed types 253 + if (type !== selectableAssetType) { 254 + errors.add(SelectedAssetError.MixedTypes) 255 + continue 256 + } 257 + 258 + if (type === 'video') { 259 + /** 260 + * We don't care too much about mimeType at this point on native, 261 + * since the `processVideo` step later on will convert to `.mp4`. 262 + */ 263 + if (isWeb && !isSupportedVideoMimeType(mimeType)) { 264 + errors.add(SelectedAssetError.Unsupported) 265 + continue 266 + } 267 + 268 + /* 269 + * Filesize appears to be stable across all platforms, so we can use it 270 + * to filter out large files on web. On native, we compress these anyway, 271 + * so we only check on web. 272 + */ 273 + if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) { 274 + errors.add(SelectedAssetError.FileTooBig) 275 + continue 276 + } 277 + } 278 + 279 + if (type === 'image') { 280 + if (!isSupportedImageMimeType(mimeType)) { 281 + errors.add(SelectedAssetError.Unsupported) 282 + continue 283 + } 284 + } 285 + 286 + if (type === 'gif') { 287 + /* 288 + * Filesize appears to be stable across all platforms, so we can use it 289 + * to filter out large files on web. On native, we compress GIFs as 290 + * videos anyway, so we only check on web. 291 + */ 292 + if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) { 293 + errors.add(SelectedAssetError.FileTooBig) 294 + continue 295 + } 296 + } 297 + 298 + /* 299 + * All validations passed, we have an asset! 300 + */ 301 + supportedAssets.push({ 302 + mimeType, 303 + ...asset, 304 + /* 305 + * In `expo-image-picker` >= v17, `uri` is now a `blob:` URL, not a 306 + * data-uri. Our handling elsewhere in the app (for web) relies on the 307 + * base64 data-uri, so we construct it here for web only. 308 + */ 309 + uri: 310 + isWeb && asset.base64 311 + ? `data:${mimeType};base64,${asset.base64}` 312 + : asset.uri, 313 + }) 314 + } 315 + 316 + if (supportedAssets.length > 0) { 317 + if (selectableAssetType === 'image') { 318 + if (supportedAssets.length > selectionCountRemaining) { 319 + errors.add(SelectedAssetError.MaxImages) 320 + supportedAssets = supportedAssets.slice(0, selectionCountRemaining) 321 + } 322 + } else if (selectableAssetType === 'video') { 323 + if (supportedAssets.length > 1) { 324 + errors.add(SelectedAssetError.MaxVideos) 325 + supportedAssets = supportedAssets.slice(0, 1) 326 + } 327 + 328 + if (supportedAssets[0].duration) { 329 + if (isWeb) { 330 + /* 331 + * Web reports duration as seconds 332 + */ 333 + supportedAssets[0].duration = supportedAssets[0].duration * 1000 334 + } 335 + 336 + if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) { 337 + errors.add(SelectedAssetError.VideoTooLong) 338 + supportedAssets = [] 339 + } 340 + } else { 341 + errors.add(SelectedAssetError.Unsupported) 342 + supportedAssets = [] 343 + } 344 + } else if (selectableAssetType === 'gif') { 345 + if (supportedAssets.length > 1) { 346 + errors.add(SelectedAssetError.MaxGIFs) 347 + supportedAssets = supportedAssets.slice(0, 1) 348 + } 349 + } 350 + } 351 + 352 + return { 353 + type: selectableAssetType!, // set above 354 + assets: supportedAssets, 355 + errors, 356 + } 357 + } 358 + 359 + export function SelectMediaButton({ 360 + disabled, 361 + allowedAssetTypes, 362 + selectedAssetsCount, 363 + onSelectAssets, 364 + }: SelectMediaButtonProps) { 365 + const {_} = useLingui() 366 + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 367 + const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() 368 + const sheetWrapper = useSheetWrapper() 369 + const t = useTheme() 370 + 371 + const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount 372 + 373 + const processSelectedAssets = useCallback( 374 + async (rawAssets: ImagePickerAsset[]) => { 375 + const { 376 + type, 377 + assets, 378 + errors: errorCodes, 379 + } = await processImagePickerAssets(rawAssets, { 380 + selectionCountRemaining, 381 + allowedAssetTypes, 382 + }) 383 + 384 + /* 385 + * Convert error codes to user-friendly messages. 386 + */ 387 + const errors = Array.from(errorCodes).map(error => { 388 + return { 389 + [SelectedAssetError.Unsupported]: _( 390 + msg`One or more of your selected files are not supported.`, 391 + ), 392 + [SelectedAssetError.MixedTypes]: _( 393 + msg`Selecting multiple media types is not supported.`, 394 + ), 395 + [SelectedAssetError.MaxImages]: _( 396 + msg({ 397 + message: `You can select up to ${plural(MAX_IMAGES, { 398 + other: '# images', 399 + })} in total.`, 400 + comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`, 401 + }), 402 + ), 403 + [SelectedAssetError.MaxVideos]: _( 404 + msg`You can only select one video at a time.`, 405 + ), 406 + [SelectedAssetError.VideoTooLong]: _( 407 + msg`Videos must be less than 3 minutes long.`, 408 + ), 409 + [SelectedAssetError.MaxGIFs]: _( 410 + msg`You can only select one GIF at a time.`, 411 + ), 412 + [SelectedAssetError.FileTooBig]: _( 413 + msg`One or more of your selected files is too large. Maximum size is 100 MB.`, 414 + ), 415 + }[error] 416 + }) 417 + 418 + /* 419 + * Report the selected assets and any errors back to the 420 + * composer. 421 + */ 422 + onSelectAssets({ 423 + type, 424 + assets, 425 + errors, 426 + }) 427 + }, 428 + [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes], 429 + ) 430 + 431 + const onPressSelectMedia = useCallback(async () => { 432 + if (isNative) { 433 + const [photoAccess, videoAccess] = await Promise.all([ 434 + requestPhotoAccessIfNeeded(), 435 + requestVideoAccessIfNeeded(), 436 + ]) 437 + 438 + if (!photoAccess && !videoAccess) { 439 + toast.show(_(msg`You need to allow access to your media library.`), { 440 + type: 'error', 441 + }) 442 + return 443 + } 444 + } 445 + 446 + if (isNative && Keyboard.isVisible()) { 447 + Keyboard.dismiss() 448 + } 449 + 450 + const {assets, canceled} = await sheetWrapper( 451 + launchImageLibraryAsync({ 452 + exif: false, 453 + mediaTypes: ['images', 'videos'], 454 + quality: 1, 455 + allowsMultipleSelection: true, 456 + legacy: true, 457 + base64: isWeb, 458 + selectionLimit: isIOS ? selectionCountRemaining : undefined, 459 + preferredAssetRepresentationMode: 460 + UIImagePickerPreferredAssetRepresentationMode.Current, 461 + videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000, 462 + }), 463 + ) 464 + 465 + if (canceled) return 466 + 467 + await processSelectedAssets(assets) 468 + }, [ 469 + _, 470 + requestPhotoAccessIfNeeded, 471 + requestVideoAccessIfNeeded, 472 + sheetWrapper, 473 + processSelectedAssets, 474 + selectionCountRemaining, 475 + ]) 476 + 477 + return ( 478 + <Button 479 + testID="openMediaBtn" 480 + onPress={onPressSelectMedia} 481 + label={_( 482 + msg({ 483 + message: `Add media to post`, 484 + comment: `Accessibility label for button in composer to add photos or a video to a post`, 485 + }), 486 + )} 487 + accessibilityHint={ 488 + isNative 489 + ? _( 490 + msg({ 491 + message: `Opens device gallery to select up to ${plural( 492 + MAX_IMAGES, 493 + { 494 + other: '# images', 495 + }, 496 + )}, or a single video.`, 497 + 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.`, 498 + }), 499 + ) 500 + : _( 501 + msg({ 502 + message: `Opens device gallery to select up to ${plural( 503 + MAX_IMAGES, 504 + { 505 + other: '# images', 506 + }, 507 + )}, or a single video or GIF.`, 508 + 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.`, 509 + }), 510 + ) 511 + } 512 + style={a.p_sm} 513 + variant="ghost" 514 + shape="round" 515 + color="primary" 516 + disabled={disabled}> 517 + <ImageIcon 518 + size="lg" 519 + style={disabled && t.atoms.text_contrast_low} 520 + accessibilityIgnoresInvertColors={true} 521 + /> 522 + </Button> 523 + ) 524 + }
+2 -3
src/view/com/composer/photos/ImageAltTextDialog.tsx
··· 96 96 <View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}> 97 97 <Image 98 98 style={imageStyle} 99 - source={{ 100 - uri: (image.transformed ?? image.source).path, 101 - }} 99 + source={{uri: (image.transformed ?? image.source).path}} 102 100 contentFit="contain" 103 101 accessible={true} 104 102 accessibilityIgnoresInvertColors 105 103 enableLiveTextInteraction 104 + autoplay={false} 106 105 /> 107 106 </View> 108 107 </View>
-60
src/view/com/composer/photos/SelectPhotoBtn.tsx
··· 1 - /* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */ 2 - import {useCallback} from 'react' 3 - import {msg} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - 6 - import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' 7 - import {openPicker} from '#/lib/media/picker' 8 - import {isNative} from '#/platform/detection' 9 - import {ComposerImage, createComposerImage} from '#/state/gallery' 10 - import {atoms as a, useTheme} from '#/alf' 11 - import {Button} from '#/components/Button' 12 - import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 13 - import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' 14 - 15 - type Props = { 16 - size: number 17 - disabled?: boolean 18 - onAdd: (next: ComposerImage[]) => void 19 - } 20 - 21 - export function SelectPhotoBtn({size, disabled, onAdd}: Props) { 22 - const {_} = useLingui() 23 - const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 24 - const t = useTheme() 25 - const sheetWrapper = useSheetWrapper() 26 - 27 - const onPressSelectPhotos = useCallback(async () => { 28 - if (isNative && !(await requestPhotoAccessIfNeeded())) { 29 - return 30 - } 31 - 32 - const images = await sheetWrapper( 33 - openPicker({ 34 - selectionLimit: 4 - size, 35 - allowsMultipleSelection: true, 36 - }), 37 - ) 38 - 39 - const results = await Promise.all( 40 - images.map(img => createComposerImage(img)), 41 - ) 42 - 43 - onAdd(results) 44 - }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper]) 45 - 46 - return ( 47 - <Button 48 - testID="openGalleryBtn" 49 - onPress={onPressSelectPhotos} 50 - label={_(msg`Gallery`)} 51 - accessibilityHint={_(msg`Opens device photo gallery`)} 52 - style={a.p_sm} 53 - variant="ghost" 54 - shape="round" 55 - color="primary" 56 - disabled={disabled}> 57 - <Image size="lg" style={disabled && t.atoms.text_contrast_low} /> 58 - </Button> 59 - ) 60 - }
-88
src/view/com/composer/videos/SelectVideoBtn.tsx
··· 1 - import {useCallback} from 'react' 2 - import {type ImagePickerAsset} from 'expo-image-picker' 3 - import {msg} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - 6 - import { 7 - SUPPORTED_MIME_TYPES, 8 - type SupportedMimeTypes, 9 - VIDEO_MAX_DURATION_MS, 10 - } from '#/lib/constants' 11 - import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' 12 - import {isWeb} from '#/platform/detection' 13 - import {isNative} from '#/platform/detection' 14 - import {atoms as a, useTheme} from '#/alf' 15 - import {Button} from '#/components/Button' 16 - import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' 17 - import {pickVideo} from './pickVideo' 18 - 19 - type Props = { 20 - onSelectVideo: (video: ImagePickerAsset) => void 21 - disabled?: boolean 22 - setError: (error: string) => void 23 - } 24 - 25 - export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { 26 - const {_} = useLingui() 27 - const t = useTheme() 28 - const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() 29 - 30 - const onPressSelectVideo = useCallback(async () => { 31 - if (isNative && !(await requestVideoAccessIfNeeded())) { 32 - return 33 - } 34 - 35 - const response = await pickVideo() 36 - if (response.assets && response.assets.length > 0) { 37 - const asset = response.assets[0] 38 - try { 39 - if (isWeb) { 40 - // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) 41 - if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) { 42 - throw Error(_(msg`Videos must be less than 3 minutes long`)) 43 - } 44 - // compression step on native converts to mp4, so no need to check there 45 - if ( 46 - !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes) 47 - ) { 48 - throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) 49 - } 50 - } else { 51 - if (typeof asset.duration !== 'number') { 52 - throw Error('Asset is not a video') 53 - } 54 - if (asset.duration > VIDEO_MAX_DURATION_MS) { 55 - throw Error(_(msg`Videos must be less than 3 minutes long`)) 56 - } 57 - } 58 - onSelectVideo(asset) 59 - } catch (err) { 60 - if (err instanceof Error) { 61 - setError(err.message) 62 - } else { 63 - setError(_(msg`An error occurred while selecting the video`)) 64 - } 65 - } 66 - } 67 - }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo]) 68 - 69 - return ( 70 - <> 71 - <Button 72 - testID="openGifBtn" 73 - onPress={onPressSelectVideo} 74 - label={_(msg`Select video`)} 75 - accessibilityHint={_(msg`Opens video picker`)} 76 - style={a.p_sm} 77 - variant="ghost" 78 - shape="round" 79 - color="primary" 80 - disabled={disabled}> 81 - <VideoClipIcon 82 - size="lg" 83 - style={disabled && t.atoms.text_contrast_low} 84 - /> 85 - </Button> 86 - </> 87 - ) 88 - }
+22 -9
src/view/com/composer/videos/VideoPreview.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {ImagePickerAsset} from 'expo-image-picker' 3 + import {Image} from 'expo-image' 4 + import {type ImagePickerAsset} from 'expo-image-picker' 4 5 import {BlueskyVideoView} from '@haileyok/bluesky-video' 5 6 6 - import {CompressedVideo} from '#/lib/media/video/types' 7 + import {type CompressedVideo} from '#/lib/media/video/types' 7 8 import {clamp} from '#/lib/numbers' 8 9 import {useAutoplayDisabled} from '#/state/preferences' 9 10 import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' ··· 48 49 <VideoTranscodeBackdrop uri={asset.uri} /> 49 50 </View> 50 51 {isActivePost && ( 51 - <BlueskyVideoView 52 - url={video.uri} 53 - autoplay={!autoplayDisabled} 54 - beginMuted={true} 55 - forceTakeover={true} 56 - ref={playerRef} 57 - /> 52 + <> 53 + {video.mimeType === 'image/gif' ? ( 54 + <Image 55 + style={[a.flex_1]} 56 + autoplay={!autoplayDisabled} 57 + source={{uri: video.uri}} 58 + accessibilityIgnoresInvertColors 59 + cachePolicy="none" 60 + /> 61 + ) : ( 62 + <BlueskyVideoView 63 + url={video.uri} 64 + autoplay={!autoplayDisabled} 65 + beginMuted={true} 66 + forceTakeover={true} 67 + ref={playerRef} 68 + /> 69 + )} 70 + </> 58 71 )} 59 72 <ExternalEmbedRemoveBtn onRemove={clear} /> 60 73 {autoplayDisabled && (
+10 -5
yarn.lock
··· 11288 11288 resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-5.1.0.tgz#f7d65f9b9a9714eaaf5d50a406cb34cb25262153" 11289 11289 integrity sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q== 11290 11290 11291 + expo-image-loader@~6.0.0: 11292 + version "6.0.0" 11293 + resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072" 11294 + integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ== 11295 + 11291 11296 expo-image-manipulator@~13.1.7: 11292 11297 version "13.1.7" 11293 11298 resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-13.1.7.tgz#e891ce9b49d75962eafdf5b7d670116583379e76" ··· 11295 11300 dependencies: 11296 11301 expo-image-loader "~5.1.0" 11297 11302 11298 - expo-image-picker@~16.1.4: 11299 - version "16.1.4" 11300 - resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-16.1.4.tgz#d4ac2d1f64f6ec9347c3f64f8435b40e6e4dcc40" 11301 - integrity sha512-bTmmxtw1AohUT+HxEBn2vYwdeOrj1CLpMXKjvi9FKSoSbpcarT4xxI0z7YyGwDGHbrJqyyic3I9TTdP2J2b4YA== 11303 + expo-image-picker@^17.0.2: 11304 + version "17.0.2" 11305 + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.2.tgz#79af7192b2947e54686d0ece6ccbb5f6a178a809" 11306 + integrity sha512-O74FIrc37KB4ZxC/BMUL3fEZwdmIB60As0q5XczRlzPvWismBl7GG3pPy+o5SGUI2jcepTvQAa2PcNcMbUZNYg== 11302 11307 dependencies: 11303 - expo-image-loader "~5.1.0" 11308 + expo-image-loader "~6.0.0" 11304 11309 11305 11310 expo-image@^2.4.0: 11306 11311 version "2.4.0"