diff options
author | Eric Bailey <git@esb.lol> | 2025-08-18 18:28:01 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-08-18 18:28:01 -0500 |
commit | 122a46891a7f912c8e2777bae00c4b1f64154257 (patch) | |
tree | 94c4c55584b3dc923c7661f1a1e97191f8e29e39 /src/view/com/composer/Composer.tsx | |
parent | cced762a7fb7a2729b63922abc34ae5406a58bce (diff) | |
download | voidsky-122a46891a7f912c8e2777bae00c4b1f64154257.tar.zst |
[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>
Diffstat (limited to 'src/view/com/composer/Composer.tsx')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 268 |
1 files changed, 175 insertions, 93 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} |