about summary refs log tree commit diff
path: root/src/view/com/composer/Composer.tsx
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-08-18 18:28:01 -0500
committerGitHub <noreply@github.com>2025-08-18 18:28:01 -0500
commit122a46891a7f912c8e2777bae00c4b1f64154257 (patch)
tree94c4c55584b3dc923c7661f1a1e97191f8e29e39 /src/view/com/composer/Composer.tsx
parentcced762a7fb7a2729b63922abc34ae5406a58bce (diff)
downloadvoidsky-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.tsx268
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}