From 122a46891a7f912c8e2777bae00c4b1f64154257 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Mon, 18 Aug 2025 18:28:01 -0500
Subject: [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>
---
 src/view/com/composer/SelectMediaButton.tsx | 524 ++++++++++++++++++++++++++++
 1 file changed, 524 insertions(+)
 create mode 100644 src/view/com/composer/SelectMediaButton.tsx

(limited to 'src/view/com/composer/SelectMediaButton.tsx')

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>
+  )
+}
-- 
cgit 1.4.1

