diff --git a/.gitignore b/.gitignore index 7233b35472..0bef5d88a1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ buck-out/ # Ruby / CocoaPods /ios/Pods/ /vendor/bundle/ +Gemfile.lock # Testing coverage/ diff --git a/package.json b/package.json index ee5d276f4a..0f3f685713 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "expo-image": "^2.4.0", "expo-image-crop-tool": "^0.1.8", "expo-image-manipulator": "~13.1.7", - "expo-image-picker": "~16.1.4", + "expo-image-picker": "^17.0.2", "expo-intent-launcher": "^12.1.5", "expo-linear-gradient": "~14.1.5", "expo-linking": "~7.1.5", diff --git a/patches/expo-image-picker+16.1.4.patch b/patches/expo-image-picker+16.1.4.patch deleted file mode 100644 index 0396fecbc1..0000000000 --- a/patches/expo-image-picker+16.1.4.patch +++ /dev/null @@ -1,38 +0,0 @@ -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 -index c863fb8..cde8859 100644 ---- 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 -@@ -101,16 +101,30 @@ internal class MediaHandler( - val fileData = getAdditionalFileData(sourceUri) - val mimeType = getType(context.contentResolver, sourceUri) - -+ // Extract basic metadata -+ var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) -+ var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) -+ val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) -+ -+ // Android returns the encoded width/height which do not take the display rotation into -+ // account. For videos recorded in portrait mode the encoded dimensions are often landscape -+ // (e.g. 1920x1080) paired with a 90°/270° rotation flag. iOS adjusts these values before -+ // reporting them, so to keep the behaviour consistent across platforms we swap the width -+ // and height when the rotation indicates the video should be displayed in portrait. -+ if (rotation % 180 != 0) { -+ width = height.also { height = width } -+ } -+ - return ImagePickerAsset( - type = MediaType.VIDEO, - uri = outputUri.toString(), -- width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH), -- height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT), -+ width = width, -+ height = height, - fileName = fileData?.fileName, - fileSize = fileData?.fileSize, - mimeType = mimeType, - duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION), -- rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION), -+ rotation = rotation, - assetId = sourceUri.getMediaStoreAssetId() - ) - } catch (cause: FailedToExtractVideoMetadataException) { diff --git a/patches/expo-image-picker+16.1.4.patch.md b/patches/expo-image-picker+16.1.4.patch.md deleted file mode 100644 index 7855e86212..0000000000 --- a/patches/expo-image-picker+16.1.4.patch.md +++ /dev/null @@ -1,5 +0,0 @@ -# Expo Image Picker patch - -Cherry-picked https://github.com/expo/expo/pull/37849 - -Remove when we update to a version that includes this commit. diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx index d84a90fa62..e4814462f7 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx @@ -146,6 +146,8 @@ export function Scrubber({ const progress = scrubberActive ? seekPosition : currentTime const progressPercent = (progress / duration) * 100 + if (duration < 3) return null + return ( - - {formatTime(currentTime)} / {formatTime(duration)} - + {Math.round(duration) > 0 && ( + + {formatTime(currentTime)} / {formatTime(duration)} + + )} {hasSubtitleTrack && ( 4) { - Toast.show(t`You may only select up to 4 images`, 'exclamation-circle') - } - return (response.assets ?? []) - .slice(0, 4) .filter(asset => { if (asset.mimeType?.startsWith('image/')) return true Toast.show(t`Only image files are supported`, 'exclamation-circle') diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index c2d1470c63..1d00bfcea1 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -1,8 +1,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor' -import {ImagePickerAsset} from 'expo-image-picker' +import {type ImagePickerAsset} from 'expo-image-picker' -import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' -import {CompressedVideo} from './types' +import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants' +import {type CompressedVideo} from './types' import {extToMime} from './util' const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb @@ -20,6 +20,13 @@ export async function compressVideo( file.mimeType as SupportedMimeTypes, ) + if (file.mimeType === 'image/gif') { + // let's hope they're small enough that they don't need compression! + // this compression library doesn't support gifs + // worst case - server rejects them. I think that's fine -sfn + return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'} + } + const minimumFileSizeForCompress = isAcceptableFormat ? MIN_SIZE_FOR_COMPRESSION : 0 diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 9d96abb7a5..94bb386baf 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -153,7 +153,7 @@ msgstr "" msgid "{0} joined this week" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:202 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:204 msgid "{0} of {1}" msgstr "" @@ -687,7 +687,7 @@ msgstr "" msgid "Add another account" msgstr "" -#: src/view/com/composer/Composer.tsx:780 +#: src/view/com/composer/Composer.tsx:793 msgid "Add another post" msgstr "" @@ -705,6 +705,11 @@ msgstr "" msgid "Add emoji reaction" msgstr "" +#. Accessibility label for button in composer to add photos or a video to a post +#: src/view/com/composer/SelectMediaButton.tsx:482 +msgid "Add media to post" +msgstr "" + #: src/components/moderation/ReportDialog/index.tsx:403 #: src/components/moderation/ReportDialog/index.tsx:407 msgid "Add more details (optional)" @@ -718,7 +723,7 @@ msgstr "" msgid "Add muted words and tags" msgstr "" -#: src/view/com/composer/Composer.tsx:1344 +#: src/view/com/composer/Composer.tsx:1426 msgid "Add new post" msgstr "" @@ -788,7 +793,7 @@ msgstr "" msgid "Adult Content" msgstr "" -#: src/screens/Moderation/index.tsx:404 +#: src/screens/Moderation/index.tsx:423 msgid "Adult content can only be enabled via the Web at <0>bsky.app." msgstr "" @@ -801,7 +806,7 @@ msgstr "" msgid "Adult Content labels" msgstr "" -#: src/screens/Moderation/index.tsx:454 +#: src/screens/Moderation/index.tsx:473 msgid "Advanced" msgstr "" @@ -888,7 +893,7 @@ msgstr "" #: src/screens/Settings/AccessibilitySettings.tsx:54 #: src/view/com/composer/GifAltText.tsx:154 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:118 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:117 #: src/view/com/composer/videos/SubtitleDialog.tsx:40 #: src/view/com/composer/videos/SubtitleDialog.tsx:55 #: src/view/com/composer/videos/SubtitleDialog.tsx:101 @@ -905,7 +910,7 @@ msgid "Alt text describes images for blind and low-vision users, and helps give msgstr "" #: src/view/com/composer/GifAltText.tsx:179 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:139 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:138 msgid "Alt text will be truncated. {MAX_ALT_TEXT, plural, other {Limit: {0} characters.}}" msgstr "" @@ -917,7 +922,7 @@ msgstr "" msgid "An error has occurred" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:420 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:422 msgid "An error occurred" msgstr "" @@ -945,10 +950,6 @@ msgstr "" msgid "An error occurred while saving the QR code!" msgstr "" -#: src/view/com/composer/videos/SelectVideoBtn.tsx:63 -msgid "An error occurred while selecting the video" -msgstr "" - #: src/screens/StarterPack/StarterPackScreen.tsx:352 #: src/screens/StarterPack/StarterPackScreen.tsx:374 msgid "An error occurred while trying to follow all" @@ -1164,11 +1165,11 @@ msgstr "" msgid "Are you sure you want to remove this from your feeds?" msgstr "" -#: src/view/com/composer/Composer.tsx:729 +#: src/view/com/composer/Composer.tsx:741 msgid "Are you sure you'd like to discard this draft?" msgstr "" -#: src/view/com/composer/Composer.tsx:914 +#: src/view/com/composer/Composer.tsx:932 msgid "Are you sure you'd like to discard this post?" msgstr "" @@ -1337,7 +1338,7 @@ msgstr "" msgid "Blocked" msgstr "" -#: src/screens/Moderation/index.tsx:282 +#: src/screens/Moderation/index.tsx:301 msgid "Blocked accounts" msgstr "" @@ -1557,8 +1558,8 @@ msgstr "" #: src/screens/Settings/Settings.tsx:289 #: src/screens/Takendown.tsx:99 #: src/screens/Takendown.tsx:102 -#: src/view/com/composer/Composer.tsx:969 -#: src/view/com/composer/Composer.tsx:980 +#: src/view/com/composer/Composer.tsx:987 +#: src/view/com/composer/Composer.tsx:998 #: src/view/com/composer/photos/EditImageDialog.web.tsx:43 #: src/view/com/composer/photos/EditImageDialog.web.tsx:52 #: src/view/com/modals/ChangePassword.tsx:279 @@ -1904,7 +1905,7 @@ msgstr "" msgid "Close image" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:109 +#: src/view/com/lightbox/Lightbox.web.tsx:110 msgid "Close image viewer" msgstr "" @@ -1922,7 +1923,7 @@ msgstr "" msgid "Closes password update alert" msgstr "" -#: src/view/com/composer/Composer.tsx:977 +#: src/view/com/composer/Composer.tsx:995 msgid "Closes post composer and discards post draft" msgstr "" @@ -1980,7 +1981,7 @@ msgstr "" msgid "Compose new post" msgstr "" -#: src/view/com/composer/Composer.tsx:878 +#: src/view/com/composer/Composer.tsx:896 msgid "Compose posts up to {0, plural, other {# characters}} in length" msgstr "" @@ -1988,7 +1989,7 @@ msgstr "" msgid "Compose reply" msgstr "" -#: src/view/com/composer/Composer.tsx:1738 +#: src/view/com/composer/Composer.tsx:1820 msgid "Compressing video..." msgstr "" @@ -2015,11 +2016,11 @@ msgstr "" msgid "Confirm delete account" msgstr "" -#: src/screens/Moderation/index.tsx:330 +#: src/screens/Moderation/index.tsx:349 msgid "Confirm your age:" msgstr "" -#: src/screens/Moderation/index.tsx:321 +#: src/screens/Moderation/index.tsx:340 msgid "Confirm your birthdate" msgstr "" @@ -2072,8 +2073,8 @@ msgstr "" msgid "Content Blocked" msgstr "" -#: src/screens/Moderation/index.tsx:317 -#: src/screens/Moderation/index.tsx:351 +#: src/screens/Moderation/index.tsx:336 +#: src/screens/Moderation/index.tsx:370 msgid "Content filters" msgstr "" @@ -2518,7 +2519,7 @@ msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:678 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:680 -#: src/view/com/composer/Composer.tsx:888 +#: src/view/com/composer/Composer.tsx:906 msgid "Delete post" msgstr "" @@ -2566,7 +2567,7 @@ msgid "Description" msgstr "" #: src/view/com/composer/GifAltText.tsx:150 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:114 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:113 msgid "Descriptive alt text" msgstr "" @@ -2620,7 +2621,7 @@ msgstr "" msgid "Disable haptic feedback" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:386 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:388 msgid "Disable subtitles" msgstr "" @@ -2629,13 +2630,13 @@ msgstr "" #: src/lib/moderation/useLabelBehaviorDescription.ts:68 #: src/screens/Messages/Settings.tsx:155 #: src/screens/Messages/Settings.tsx:158 -#: src/screens/Moderation/index.tsx:394 +#: src/screens/Moderation/index.tsx:413 msgid "Disabled" msgstr "" #: src/screens/Profile/Header/EditProfileDialog.tsx:89 -#: src/view/com/composer/Composer.tsx:731 -#: src/view/com/composer/Composer.tsx:921 +#: src/view/com/composer/Composer.tsx:743 +#: src/view/com/composer/Composer.tsx:939 msgid "Discard" msgstr "" @@ -2643,11 +2644,11 @@ msgstr "" msgid "Discard changes?" msgstr "" -#: src/view/com/composer/Composer.tsx:728 +#: src/view/com/composer/Composer.tsx:740 msgid "Discard draft?" msgstr "" -#: src/view/com/composer/Composer.tsx:913 +#: src/view/com/composer/Composer.tsx:931 msgid "Discard post?" msgstr "" @@ -2673,7 +2674,7 @@ msgstr "" msgid "Dismiss" msgstr "" -#: src/view/com/composer/Composer.tsx:1662 +#: src/view/com/composer/Composer.tsx:1744 msgid "Dismiss error" msgstr "" @@ -3002,7 +3003,7 @@ msgstr "" msgid "Enable {0} only" msgstr "" -#: src/screens/Moderation/index.tsx:381 +#: src/screens/Moderation/index.tsx:400 msgid "Enable adult content" msgstr "" @@ -3028,7 +3029,7 @@ msgstr "" msgid "Enable push notifications" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:387 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:389 msgid "Enable subtitles" msgstr "" @@ -3048,7 +3049,7 @@ msgstr "" #: src/screens/Messages/Settings.tsx:146 #: src/screens/Messages/Settings.tsx:149 -#: src/screens/Moderation/index.tsx:392 +#: src/screens/Moderation/index.tsx:411 msgid "Enabled" msgstr "" @@ -3074,7 +3075,7 @@ msgstr "" msgid "Enter code" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:405 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:407 msgid "Enter fullscreen" msgstr "" @@ -3119,7 +3120,7 @@ msgstr "" msgid "Entertainment" msgstr "" -#: src/view/com/composer/Composer.tsx:1747 +#: src/view/com/composer/Composer.tsx:1829 #: src/view/com/util/error/ErrorScreen.tsx:42 msgid "Error" msgstr "" @@ -3194,7 +3195,7 @@ msgstr "" msgid "Excludes users you follow" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:404 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:406 msgid "Exit fullscreen" msgstr "" @@ -3206,11 +3207,11 @@ msgstr "" msgid "Exits image cropping process" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:110 +#: src/view/com/lightbox/Lightbox.web.tsx:111 msgid "Exits image view" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:184 +#: src/view/com/lightbox/Lightbox.web.tsx:185 msgid "Expand alt text" msgstr "" @@ -3810,10 +3811,6 @@ msgctxt "from-feed" msgid "From <0/>" msgstr "" -#: src/view/com/composer/photos/SelectPhotoBtn.tsx:50 -msgid "Gallery" -msgstr "" - #: src/components/StarterPack/ProfileStarterPacks.tsx:307 msgid "Generate a starter pack" msgstr "" @@ -3967,6 +3964,7 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceAdmonition.tsx:89 #: src/components/ageAssurance/AgeRestrictedScreen.tsx:75 #: src/components/ageAssurance/AgeRestrictedScreen.tsx:84 +#: src/screens/Moderation/index.tsx:214 msgid "Go to account settings" msgstr "" @@ -4176,7 +4174,7 @@ msgstr "" msgid "Hmm, we're having trouble finding this feed. It may have been deleted." msgstr "" -#: src/screens/Moderation/index.tsx:59 +#: src/screens/Moderation/index.tsx:60 msgid "Hmmmm, it seems we're having trouble loading this data. See below for more details. If this issue persists, please contact us." msgstr "" @@ -4236,7 +4234,7 @@ msgstr "" msgid "I understand" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:186 +#: src/view/com/lightbox/Lightbox.web.tsx:187 msgid "If alt text is long, toggles alt text expanded state" msgstr "" @@ -4382,7 +4380,7 @@ msgstr "" msgid "Interaction limited" msgstr "" -#: src/screens/Moderation/index.tsx:222 +#: src/screens/Moderation/index.tsx:241 msgid "Interaction settings" msgstr "" @@ -4451,7 +4449,7 @@ msgstr "" msgid "It's just you right now! Add more people to your starter pack by searching above." msgstr "" -#: src/view/com/composer/Composer.tsx:1681 +#: src/view/com/composer/Composer.tsx:1763 msgid "Job ID: {0}" msgstr "" @@ -4927,7 +4925,7 @@ msgstr "" msgid "Manage saved feeds" msgstr "" -#: src/screens/Moderation/index.tsx:292 +#: src/screens/Moderation/index.tsx:311 msgid "Manage verification settings" msgstr "" @@ -5045,7 +5043,7 @@ msgid "Misleading Post" msgstr "" #: src/Navigation.tsx:176 -#: src/screens/Moderation/index.tsx:99 +#: src/screens/Moderation/index.tsx:100 #: src/screens/Settings/Settings.tsx:188 #: src/screens/Settings/Settings.tsx:191 msgid "Moderation" @@ -5079,7 +5077,7 @@ msgctxt "toast" msgid "Moderation list updated" msgstr "" -#: src/screens/Moderation/index.tsx:252 +#: src/screens/Moderation/index.tsx:271 msgid "Moderation lists" msgstr "" @@ -5096,7 +5094,7 @@ msgstr "" msgid "Moderation states" msgstr "" -#: src/screens/Moderation/index.tsx:206 +#: src/screens/Moderation/index.tsx:225 msgid "Moderation tools" msgstr "" @@ -5211,7 +5209,7 @@ msgstr "" msgid "Mute words & tags" msgstr "" -#: src/screens/Moderation/index.tsx:267 +#: src/screens/Moderation/index.tsx:286 msgid "Muted accounts" msgstr "" @@ -5228,7 +5226,7 @@ msgstr "" msgid "Muted by \"{0}\"" msgstr "" -#: src/screens/Moderation/index.tsx:237 +#: src/screens/Moderation/index.tsx:256 msgid "Muted words & tags" msgstr "" @@ -5446,7 +5444,7 @@ msgstr "" msgid "Next" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:169 +#: src/view/com/lightbox/Lightbox.web.tsx:170 msgid "Next image" msgstr "" @@ -5722,15 +5720,23 @@ msgstr "" msgid "Onboarding reset" msgstr "" -#: src/view/com/composer/Composer.tsx:347 +#: src/view/com/composer/Composer.tsx:355 msgid "One or more GIFs is missing alt text." msgstr "" -#: src/view/com/composer/Composer.tsx:344 +#: src/view/com/composer/Composer.tsx:352 msgid "One or more images is missing alt text." msgstr "" -#: src/view/com/composer/Composer.tsx:354 +#: src/view/com/composer/SelectMediaButton.tsx:390 +msgid "One or more of your selected files are not supported." +msgstr "" + +#: src/view/com/composer/SelectMediaButton.tsx:413 +msgid "One or more of your selected files is too large. Maximum size is 100 MB." +msgstr "" + +#: src/view/com/composer/Composer.tsx:362 msgid "One or more videos is missing alt text." msgstr "" @@ -5748,7 +5754,7 @@ msgstr "" msgid "Only followers who I follow" msgstr "" -#: src/lib/media/picker.shared.ts:32 +#: src/lib/media/picker.shared.ts:28 msgid "Only image files are supported" msgstr "" @@ -5787,7 +5793,7 @@ msgid "Open drawer menu" msgstr "" #: src/screens/Messages/components/MessageInput.web.tsx:181 -#: src/view/com/composer/Composer.tsx:1329 +#: src/view/com/composer/Composer.tsx:1411 msgid "Open emoji picker" msgstr "" @@ -5816,7 +5822,7 @@ msgstr "" msgid "Open moderation debug page" msgstr "" -#: src/screens/Moderation/index.tsx:233 +#: src/screens/Moderation/index.tsx:252 msgid "Open muted words and tags settings" msgstr "" @@ -5886,11 +5892,17 @@ msgstr "" msgid "Opens composer" msgstr "" -#: src/view/com/composer/photos/SelectPhotoBtn.tsx:51 -msgid "Opens device photo gallery" +#. 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. +#: src/view/com/composer/SelectMediaButton.tsx:501 +msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video or GIF." msgstr "" -#: src/view/com/composer/Composer.tsx:1330 +#. 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. +#: src/view/com/composer/SelectMediaButton.tsx:490 +msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video." +msgstr "" + +#: src/view/com/composer/Composer.tsx:1412 msgid "Opens emoji picker" msgstr "" @@ -5933,10 +5945,6 @@ msgstr "" msgid "Opens this profile" msgstr "" -#: src/view/com/composer/videos/SelectVideoBtn.tsx:75 -msgid "Opens video picker" -msgstr "" - #: src/components/dms/ReportDialog.tsx:221 #: src/components/ReportDialog/SubmitView.tsx:168 msgid "Optionally provide additional information below:" @@ -6310,12 +6318,12 @@ msgctxt "description" msgid "Post" msgstr "" -#: src/view/com/composer/Composer.tsx:1040 +#: src/view/com/composer/Composer.tsx:1058 msgctxt "action" msgid "Post" msgstr "" -#: src/view/com/composer/Composer.tsx:1038 +#: src/view/com/composer/Composer.tsx:1056 msgctxt "action" msgid "Post All" msgstr "" @@ -6443,7 +6451,7 @@ msgstr "" msgid "Press to view followers of this account that you also follow" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:150 +#: src/view/com/lightbox/Lightbox.web.tsx:151 msgid "Previous image" msgstr "" @@ -6490,7 +6498,7 @@ msgstr "" msgid "Privacy Policy" msgstr "" -#: src/view/com/composer/Composer.tsx:1744 +#: src/view/com/composer/Composer.tsx:1826 msgid "Processing video..." msgstr "" @@ -6529,22 +6537,22 @@ msgid "Public, sharable lists which can be used to drive feeds." msgstr "" #. Accessibility label for button to publish a single post -#: src/view/com/composer/Composer.tsx:1020 +#: src/view/com/composer/Composer.tsx:1038 msgid "Publish post" msgstr "" #. Accessibility label for button to publish multiple posts in a thread -#: src/view/com/composer/Composer.tsx:1013 +#: src/view/com/composer/Composer.tsx:1031 msgid "Publish posts" msgstr "" #. Accessibility label for button to publish multiple replies in a thread -#: src/view/com/composer/Composer.tsx:998 +#: src/view/com/composer/Composer.tsx:1016 msgid "Publish replies" msgstr "" #. Accessibility label for button to publish a single reply -#: src/view/com/composer/Composer.tsx:1005 +#: src/view/com/composer/Composer.tsx:1023 msgid "Publish reply" msgstr "" @@ -6911,7 +6919,7 @@ msgstr "" msgid "Replies to this post are disabled." msgstr "" -#: src/view/com/composer/Composer.tsx:1036 +#: src/view/com/composer/Composer.tsx:1054 msgctxt "action" msgid "Reply" msgstr "" @@ -7239,8 +7247,8 @@ msgstr "" #: src/view/com/composer/GifAltText.tsx:202 #: src/view/com/composer/photos/EditImageDialog.web.tsx:62 #: src/view/com/composer/photos/EditImageDialog.web.tsx:75 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:153 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:163 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:152 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:162 #: src/view/com/modals/CreateOrEditList.tsx:315 #: src/view/screens/SavedFeeds.tsx:117 msgid "Save" @@ -7440,7 +7448,7 @@ msgstr "" msgid "See this guide" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:195 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:197 msgid "Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause" msgstr "" @@ -7539,10 +7547,6 @@ msgstr "" msgid "Select the moderation service(s) to report to" msgstr "" -#: src/view/com/composer/videos/SelectVideoBtn.tsx:74 -msgid "Select video" -msgstr "" - #: src/components/dialogs/MutedWords.tsx:242 msgid "Select what content this mute word should apply to." msgstr "" @@ -7572,6 +7576,10 @@ msgstr "" msgid "Select your preferred notification channels" msgstr "" +#: src/view/com/composer/SelectMediaButton.tsx:393 +msgid "Selecting multiple media types is not supported." +msgstr "" + #: src/view/com/util/forms/DropdownButton.tsx:302 msgid "Selects option {0} of {numItems}" msgstr "" @@ -7649,7 +7657,7 @@ msgstr "" msgid "Set app icon to {0}" msgstr "" -#: src/screens/Moderation/index.tsx:333 +#: src/screens/Moderation/index.tsx:352 msgid "Set birthdate" msgstr "" @@ -8077,7 +8085,7 @@ msgid "Something went wrong, please try again" msgstr "" #: src/components/ReportDialog/index.tsx:54 -#: src/screens/Moderation/index.tsx:111 +#: src/screens/Moderation/index.tsx:112 #: src/screens/Profile/Sections/Labels.tsx:184 msgid "Something went wrong, please try again." msgstr "" @@ -8482,7 +8490,7 @@ msgstr "" msgid "The author of this thread has hidden this reply." msgstr "" -#: src/screens/Moderation/index.tsx:407 +#: src/screens/Moderation/index.tsx:426 msgid "The Bluesky web application" msgstr "" @@ -8840,7 +8848,7 @@ msgstr "" msgid "This post will be hidden from feeds and threads. This cannot be undone." msgstr "" -#: src/view/com/composer/Composer.tsx:463 +#: src/view/com/composer/Composer.tsx:471 msgid "This post's author has disabled quote posts." msgstr "" @@ -8974,7 +8982,7 @@ msgstr "" msgid "Toggle dropdown" msgstr "" -#: src/screens/Moderation/index.tsx:384 +#: src/screens/Moderation/index.tsx:403 msgid "Toggle to enable or disable adult content" msgstr "" @@ -9256,12 +9264,8 @@ msgstr "" msgid "Unsubscribed from list" msgstr "" -#: src/view/com/composer/Composer.tsx:818 -msgid "Unsupported video type" -msgstr "" - -#: src/view/com/composer/videos/SelectVideoBtn.tsx:48 -msgid "Unsupported video type: {0}" +#: src/view/com/composer/Composer.tsx:834 +msgid "Unsupported video type: {mimeType}" msgstr "" #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:77 @@ -9346,7 +9350,7 @@ msgstr "" msgid "Uploading link thumbnail..." msgstr "" -#: src/view/com/composer/Composer.tsx:1741 +#: src/view/com/composer/Composer.tsx:1823 msgid "Uploading video..." msgstr "" @@ -9482,7 +9486,7 @@ msgstr "" msgid "Verification failed, please try again." msgstr "" -#: src/screens/Moderation/index.tsx:297 +#: src/screens/Moderation/index.tsx:316 msgid "Verification settings" msgstr "" @@ -9608,7 +9612,7 @@ msgstr "" msgid "Video settings" msgstr "" -#: src/view/com/composer/Composer.tsx:1751 +#: src/view/com/composer/Composer.tsx:1833 msgid "Video uploaded" msgstr "" @@ -9620,9 +9624,8 @@ msgstr "" msgid "Videos" msgstr "" -#: src/view/com/composer/videos/SelectVideoBtn.tsx:42 -#: src/view/com/composer/videos/SelectVideoBtn.tsx:55 -msgid "Videos must be less than 3 minutes long" +#: src/view/com/composer/SelectMediaButton.tsx:407 +msgid "Videos must be less than 3 minutes long." msgstr "" #: src/screens/Profile/Header/Shell.tsx:229 @@ -9707,11 +9710,11 @@ msgstr "" msgid "View video" msgstr "" -#: src/screens/Moderation/index.tsx:277 +#: src/screens/Moderation/index.tsx:296 msgid "View your blocked accounts" msgstr "" -#: src/screens/Moderation/index.tsx:217 +#: src/screens/Moderation/index.tsx:236 msgid "View your default post interaction settings" msgstr "" @@ -9720,11 +9723,11 @@ msgstr "" msgid "View your feeds and explore more" msgstr "" -#: src/screens/Moderation/index.tsx:247 +#: src/screens/Moderation/index.tsx:266 msgid "View your moderation lists" msgstr "" -#: src/screens/Moderation/index.tsx:262 +#: src/screens/Moderation/index.tsx:281 msgid "View your muted accounts" msgstr "" @@ -9829,7 +9832,7 @@ msgstr "" msgid "We were unable to load your birth date preferences. Please try again." msgstr "" -#: src/screens/Moderation/index.tsx:464 +#: src/screens/Moderation/index.tsx:483 msgid "We were unable to load your configured labelers at this time." msgstr "" @@ -9894,7 +9897,7 @@ msgstr "" msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." msgstr "" -#: src/view/com/composer/Composer.tsx:460 +#: src/view/com/composer/Composer.tsx:468 msgid "We're sorry! The post you are replying to has been deleted." msgstr "" @@ -9945,7 +9948,7 @@ msgstr "" #: src/view/com/auth/SplashScreen.tsx:38 #: src/view/com/auth/SplashScreen.web.tsx:99 -#: src/view/com/composer/Composer.tsx:781 +#: src/view/com/composer/Composer.tsx:794 msgid "What's up?" msgstr "" @@ -10027,11 +10030,11 @@ msgstr "" msgid "Write a message" msgstr "" -#: src/view/com/composer/Composer.tsx:876 +#: src/view/com/composer/Composer.tsx:894 msgid "Write post" msgstr "" -#: src/view/com/composer/Composer.tsx:779 +#: src/view/com/composer/Composer.tsx:792 #: src/view/com/post-thread/PostThreadComposePrompt.tsx:90 msgid "Write your reply" msgstr "" @@ -10170,10 +10173,23 @@ msgstr "" msgid "You can now sign in with your new password." msgstr "" +#: src/view/com/composer/SelectMediaButton.tsx:410 +msgid "You can only select one GIF at a time." +msgstr "" + +#: src/view/com/composer/SelectMediaButton.tsx:404 +msgid "You can only select one video at a time." +msgstr "" + #: src/screens/Deactivated.tsx:133 msgid "You can reactivate your account to continue logging in. Your profile and posts will be visible to other users." msgstr "" +#. Error message for maximum number of images that can be selected to add to a post, currently 4 but may change. +#: src/view/com/composer/SelectMediaButton.tsx:396 +msgid "You can select up to {MAX_IMAGES, plural, other {# images}} in total." +msgstr "" + #: src/components/dialogs/PostInteractionSettingsDialog.tsx:85 msgid "You can set default interaction settings in <0>Settings → Moderation → Interaction settings." msgstr "" @@ -10316,10 +10332,6 @@ msgstr "" msgid "You may only add up to 3 feeds" msgstr "" -#: src/lib/media/picker.shared.ts:25 -msgid "You may only select up to 4 images" -msgstr "" - #: src/screens/Signup/StepInfo/Policies.tsx:136 msgid "You must be 13 years of age or older to create an account." msgstr "" @@ -10328,7 +10340,7 @@ msgstr "" msgid "You must be following at least seven other people to generate a starter pack." msgstr "" -#: src/screens/Moderation/index.tsx:355 +#: src/screens/Moderation/index.tsx:374 msgid "You must complete age assurance in order to access the settings below." msgstr "" @@ -10349,6 +10361,10 @@ msgstr "" msgid "You must sign in to view this post." msgstr "" +#: src/view/com/composer/SelectMediaButton.tsx:439 +msgid "You need to allow access to your media library." +msgstr "" + #: src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx:23 msgid "You need to verify your email address before you can enable email 2FA." msgstr "" @@ -10524,6 +10540,10 @@ msgstr "" msgid "Your current handle <0>{0} will automatically remain reserved for you. You can switch back to it at any time from this account." msgstr "" +#: src/screens/Moderation/index.tsx:208 +msgid "Your declared age is under 18. Some settings below may be disabled. If this was a mistake, you may edit your bithdate in your <0>account settings." +msgstr "" + #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:253 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:257 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:258 @@ -10586,11 +10606,11 @@ msgstr "" msgid "Your password must be at least 8 characters long." msgstr "" -#: src/view/com/composer/Composer.tsx:522 +#: src/view/com/composer/Composer.tsx:530 msgid "Your post has been published" msgstr "" -#: src/view/com/composer/Composer.tsx:519 +#: src/view/com/composer/Composer.tsx:527 msgid "Your posts have been published" msgstr "" @@ -10606,7 +10626,7 @@ msgstr "" msgid "Your profile, posts, feeds, and lists will no longer be visible to other Bluesky users. You can reactivate your account at any time by logging in." msgstr "" -#: src/view/com/composer/Composer.tsx:521 +#: src/view/com/composer/Composer.tsx:529 msgid "Your reply has been published" msgstr "" diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index 1517792a19..e829abd473 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -22,6 +22,7 @@ import { import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {useSetMinimalShellMode} from '#/state/shell' import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' +import {Admonition} from '#/components/Admonition' import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -201,6 +202,24 @@ export function ModerationScreenInner({ return ( + {isDeclaredUnderage && ( + + + + Your declared age is under 18. Some settings below may be + disabled. If this was a mistake, you may edit your bithdate in + your{' '} + + account settings + + . + + + + )} + Moderation tools diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts index 2809d32e98..2e7693fab3 100644 --- a/src/state/queries/usePostThread/traversal.ts +++ b/src/state/queries/usePostThread/traversal.ts @@ -307,9 +307,16 @@ export function sortAndAnnotateThreadItems( metadata.isPartOfLastBranchFromDepth = metadata.depth /** - * If the parent is part of the last branch of the sub-tree, so is the child. + * If the parent is part of the last branch of the sub-tree, so + * is the child. However, if the child is also a last sibling, + * then we need to start tracking `isPartOfLastBranchFromDepth` + * from this point onwards, always updating it to the depth of + * the last sibling as we go down. */ - if (metadata.parentMetadata.isPartOfLastBranchFromDepth) { + if ( + !metadata.isLastSibling && + metadata.parentMetadata.isPartOfLastBranchFromDepth + ) { metadata.isPartOfLastBranchFromDepth = metadata.parentMetadata.isPartOfLastBranchFromDepth } diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts index 2f370b0ab7..5df7c2e42e 100644 --- a/src/state/queries/usePostThread/types.ts +++ b/src/state/queries/usePostThread/types.ts @@ -151,8 +151,8 @@ export type TraversalMetadata = { */ isLastChild: boolean /** - * Indicates if the post is the left/lower-most branch of the reply tree. - * Value corresponds to the depth at which this branch started. + * Indicates if the post is the left-most AND lower-most branch of the reply + * tree. Value corresponds to the depth at which this branch started. */ isPartOfLastBranchFromDepth?: number /** diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2965453530..c3e0526b9a 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,8 +129,14 @@ 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, @@ -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 ( - - - 1} - publishingStage={publishingStage} - topBarAnimatedStyle={topBarAnimatedStyle} - onCancel={onPressCancel} - onPublish={onPressPublish}> - {missingAltError && } - setError('')} - clearVideo={ - erroredVideoPostId - ? () => clearVideo(erroredVideoPostId) - : () => {} - } - /> - - - - {replyTo ? : undefined} - {thread.posts.map((post, index) => ( - - 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} + + + + + 1} + publishingStage={publishingStage} + topBarAnimatedStyle={topBarAnimatedStyle} + onCancel={onPressCancel} + onPublish={onPressPublish}> + {missingAltError && } + setError('')} + clearVideo={ + erroredVideoPostId + ? () => clearVideo(erroredVideoPostId) + : () => {} + } /> - {isWebFooterSticky && post.id === activePost.id && ( - {footer} - )} - - ))} - - {!isWebFooterSticky && footer} - + - - + + {replyTo ? : undefined} + {thread.posts.map((post, index) => ( + + 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 && ( + {footer} + )} + + ))} + + {!isWebFooterSticky && footer} + + + + + + ) } @@ -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( + 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 ( ) : ( - - onSelectVideo(post.id, asset)} - disabled={!!media} - setError={onError} + 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 & { + 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() + + /* + * 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 ( + + ) +} diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx index 724149937c..b356cde9b7 100644 --- a/src/view/com/composer/photos/ImageAltTextDialog.tsx +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -96,13 +96,12 @@ const ImageAltTextInner = ({ diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx deleted file mode 100644 index f4c6aa328a..0000000000 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */ -import {useCallback} from 'react' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' -import {openPicker} from '#/lib/media/picker' -import {isNative} from '#/platform/detection' -import {ComposerImage, createComposerImage} from '#/state/gallery' -import {atoms as a, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' -import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' - -type Props = { - size: number - disabled?: boolean - onAdd: (next: ComposerImage[]) => void -} - -export function SelectPhotoBtn({size, disabled, onAdd}: Props) { - const {_} = useLingui() - const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() - const t = useTheme() - const sheetWrapper = useSheetWrapper() - - const onPressSelectPhotos = useCallback(async () => { - if (isNative && !(await requestPhotoAccessIfNeeded())) { - return - } - - const images = await sheetWrapper( - openPicker({ - selectionLimit: 4 - size, - allowsMultipleSelection: true, - }), - ) - - const results = await Promise.all( - images.map(img => createComposerImage(img)), - ) - - onAdd(results) - }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper]) - - return ( - - ) -} diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx deleted file mode 100644 index 96715955f0..0000000000 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import {useCallback} from 'react' -import {type ImagePickerAsset} from 'expo-image-picker' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import { - SUPPORTED_MIME_TYPES, - type SupportedMimeTypes, - VIDEO_MAX_DURATION_MS, -} from '#/lib/constants' -import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' -import {isWeb} from '#/platform/detection' -import {isNative} from '#/platform/detection' -import {atoms as a, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' -import {pickVideo} from './pickVideo' - -type Props = { - onSelectVideo: (video: ImagePickerAsset) => void - disabled?: boolean - setError: (error: string) => void -} - -export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { - const {_} = useLingui() - const t = useTheme() - const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() - - const onPressSelectVideo = useCallback(async () => { - if (isNative && !(await requestVideoAccessIfNeeded())) { - return - } - - const response = await pickVideo() - if (response.assets && response.assets.length > 0) { - const asset = response.assets[0] - try { - if (isWeb) { - // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) - if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) { - throw Error(_(msg`Videos must be less than 3 minutes long`)) - } - // compression step on native converts to mp4, so no need to check there - if ( - !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes) - ) { - throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) - } - } else { - if (typeof asset.duration !== 'number') { - throw Error('Asset is not a video') - } - if (asset.duration > VIDEO_MAX_DURATION_MS) { - throw Error(_(msg`Videos must be less than 3 minutes long`)) - } - } - onSelectVideo(asset) - } catch (err) { - if (err instanceof Error) { - setError(err.message) - } else { - setError(_(msg`An error occurred while selecting the video`)) - } - } - } - }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo]) - - return ( - <> - - - ) -} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 255174beab..84cb1dba75 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -1,9 +1,10 @@ import React from 'react' import {View} from 'react-native' -import {ImagePickerAsset} from 'expo-image-picker' +import {Image} from 'expo-image' +import {type ImagePickerAsset} from 'expo-image-picker' import {BlueskyVideoView} from '@haileyok/bluesky-video' -import {CompressedVideo} from '#/lib/media/video/types' +import {type CompressedVideo} from '#/lib/media/video/types' import {clamp} from '#/lib/numbers' import {useAutoplayDisabled} from '#/state/preferences' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' @@ -48,13 +49,25 @@ export function VideoPreview({ {isActivePost && ( - + <> + {video.mimeType === 'image/gif' ? ( + + ) : ( + + )} + )} {autoplayDisabled && ( diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 97811da7f1..ab50fbcf05 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -76,6 +76,7 @@ function LightboxInner({ const onKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { + e.preventDefault() onClose() } else if (e.key === 'ArrowLeft') { onPressLeft() diff --git a/yarn.lock b/yarn.lock index dc6489bf85..9145826a4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11288,6 +11288,11 @@ expo-image-loader@~5.1.0: resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-5.1.0.tgz#f7d65f9b9a9714eaaf5d50a406cb34cb25262153" integrity sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q== +expo-image-loader@~6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072" + integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ== + expo-image-manipulator@~13.1.7: version "13.1.7" resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-13.1.7.tgz#e891ce9b49d75962eafdf5b7d670116583379e76" @@ -11295,12 +11300,12 @@ expo-image-manipulator@~13.1.7: dependencies: expo-image-loader "~5.1.0" -expo-image-picker@~16.1.4: - version "16.1.4" - resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-16.1.4.tgz#d4ac2d1f64f6ec9347c3f64f8435b40e6e4dcc40" - integrity sha512-bTmmxtw1AohUT+HxEBn2vYwdeOrj1CLpMXKjvi9FKSoSbpcarT4xxI0z7YyGwDGHbrJqyyic3I9TTdP2J2b4YA== +expo-image-picker@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.2.tgz#79af7192b2947e54686d0ece6ccbb5f6a178a809" + integrity sha512-O74FIrc37KB4ZxC/BMUL3fEZwdmIB60As0q5XczRlzPvWismBl7GG3pPy+o5SGUI2jcepTvQAa2PcNcMbUZNYg== dependencies: - expo-image-loader "~5.1.0" + expo-image-loader "~6.0.0" expo-image@^2.4.0: version "2.4.0"