From dbd7bc8a5136c2610207d9e0785f24ee5b25947f Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Fri, 6 Nov 2020 21:17:27 -0300 Subject: [PATCH] Gallery Improvements (#4837) * Open/Close Gallery transitions * Include postId in file info & disable opening the gallery in some cases * Add progress bar component * Transition to gallery based on platform * Improve getting the filename for non attachments * Patch RNFetchBlob TS definition * Add gallery types * fix unit tests for message and post attachment * add getLocalPath method * Add react-native-share dependency * Re-style gallery footer * Refactor Gallery screen * Double tap zoom in/out and translate * Make android activity "transparent" * Do not animate to height on dismissing gallery * Open gallery for file uploads * Fix borderRadius for gallery action button * Use progress bar for file uploads * Replace progress bar for file attachment document * Upgrade RNN to 7.1.0 to fix share elements transitions * Fix Gallery unit tests * translate down when popping screen on iOS * Swipe, Pan & Tap fixes * fix gallery footer avatar eslint * Fix gallery snapshot tests * Use CompassIcon in Gallery * Feedback from UX review * Fix gallery UI for other file types * Set other file type gallery button to 48pt --- NOTICE.txt | 41 -- .../src/main/assets/fonts/compass-icons.ttf | Bin 60308 -> 61240 bytes android/app/src/main/res/values/colors.xml | 1 + android/app/src/main/res/values/styles.xml | 2 + .../file_attachment.test.js.snap | 123 ++-- .../file_attachment_list.test.js.snap | 518 ++++++-------- .../file_attachment_list/file_attachment.js | 46 +- .../file_attachment_document.js | 197 ++---- .../file_attachment_document/index.js | 8 - .../file_attachment_icon.js | 23 +- .../file_attachment_image.js | 13 +- .../file_attachment_list.js | 41 +- app/components/markdown/markdown.js | 10 +- .../markdown/markdown_image/markdown_image.js | 115 ++- .../markdown_table_cell.js | 12 +- .../markdown_table_image.js | 67 -- .../markdown_table_image.tsx | 122 ++++ .../message_attachments/attachment_fields.js | 1 + .../__snapshots__/index.test.js.snap | 1 + .../attachment_image/index.js | 53 +- .../message_attachments/attachment_pretext.js | 1 + .../message_attachments/attachment_text.js | 1 + .../message_attachments/attachment_title.js | 1 + .../message_attachments/message_attachment.js | 1 + app/components/post_attachment_image/index.js | 12 +- .../post_attachment_opengraph.test.js.snap | 2 + .../post_attachment_opengraph.js | 47 +- .../system_message_helpers.test.js.snap | 1 + app/components/post_body/post_body.js | 2 + .../post_body/system_message_helpers.js | 1 + .../post_body_additional_content.js | 67 +- .../uploads/upload_item/upload_item.js | 104 ++- app/components/post_draft/uploads/uploads.js | 8 + app/components/progress_bar.tsx | 77 ++ .../progressive_image.test.js.snap | 2 + .../progressive_image/progressive_image.js | 7 +- app/components/video_controls.js | 333 --------- app/constants/attachment.js | 1 + app/constants/index.js | 2 + app/mm-redux/types/files.ts | 4 + app/mm-redux/types/posts.ts | 2 + .../channel_info_header.test.js.snap | 6 + .../channel_info/channel_info_header.js | 1 + .../expanded_announcement_banner.js | 1 + .../__snapshots__/gallery.test.js.snap | 68 ++ app/screens/gallery/animation_helper.ts | 199 ++++++ app/screens/gallery/footer/index.tsx | 167 +++++ .../gallery/footer/prepare_file/index.ts | 20 + .../footer/prepare_file/prepare_file.tsx | 188 +++++ .../gallery/footer/summary/actions/action.tsx | 50 ++ .../footer/summary/actions/actions.tsx | 75 ++ .../gallery/footer/summary/actions/index.ts | 18 + app/screens/gallery/footer/summary/avatar.tsx | 52 ++ .../gallery/footer/summary/details.tsx | 84 +++ app/screens/gallery/footer/summary/index.ts | 56 ++ .../gallery/footer/summary/summary.tsx | 70 ++ app/screens/gallery/footer/toast/index.ts | 18 + app/screens/gallery/footer/toast/toast.tsx | 124 ++++ app/screens/gallery/gallery.js | 174 +++++ app/screens/gallery/gallery.test.js | 88 +++ .../gallery/gallery_file/gallery_file.tsx | 177 +++++ app/screens/gallery/gallery_file/index.ts | 18 + app/screens/gallery/gallery_image.tsx | 30 + app/screens/gallery/gallery_video.tsx | 161 +++++ app/screens/gallery/gallery_viewer.tsx | 305 ++++++++ .../{text_preview => gallery}/index.js | 6 +- app/screens/gallery/video_controls.tsx | 221 ++++++ .../downloader_bottom_content.test.js.snap | 93 --- .../__snapshots__/image_preview.test.js.snap | 263 ------- .../image_preview/downloader.android.js | 195 ------ app/screens/image_preview/downloader.ios.js | 449 ------------ .../downloader_bottom_content.js | 75 -- .../downloader_bottom_content.test.js | 58 -- app/screens/image_preview/image_preview.js | 655 ------------------ .../image_preview/image_preview.test.js | 219 ------ app/screens/image_preview/index.js | 21 - app/screens/image_preview/video_preview.js | 252 ------- app/screens/index.js | 282 +++++--- .../dialog_introduction_text.test.js.snap | 1 + .../dialog_introduction_text.js | 1 + app/screens/table_image/index.js | 16 - app/screens/table_image/table_image.js | 107 --- .../terms_of_service.test.js.snap | 3 + .../terms_of_service/terms_of_service.js | 1 + app/screens/text_preview/text_preview.js | 151 ---- app/utils/file.js | 78 ++- app/utils/file.test.js | 72 +- app/utils/image_cache_manager.js | 18 +- app/utils/image_cache_manager.test.js | 8 +- app/utils/images.js | 106 ++- assets/base/compass-icons.json | 14 + assets/base/i18n/en.json | 25 +- assets/fonts/compass-icons.ttf | Bin 60308 -> 61240 bytes ios/Mattermost/AppDelegate.m | 16 +- ios/Podfile.lock | 18 +- package-lock.json | 117 ++-- package.json | 10 +- patches/mime-db+1.44.0.patch | 13 + patches/react-native-fast-image+8.3.2.patch | 19 +- ...ch => react-native-navigation+7.1.0.patch} | 27 +- patches/rn-fetch-blob+0.12.0.patch | 13 + test/setup.js | 20 +- tsconfig.json | 1 + types/modules/react-native-slider.d.ts | 134 ++++ types/screens/gallery.d.ts | 116 ++++ 105 files changed, 4077 insertions(+), 4037 deletions(-) rename app/components/file_attachment_list/{file_attachment_document => }/file_attachment_document.js (55%) delete mode 100644 app/components/file_attachment_list/file_attachment_document/index.js delete mode 100644 app/components/markdown/markdown_table_image/markdown_table_image.js create mode 100644 app/components/markdown/markdown_table_image/markdown_table_image.tsx create mode 100644 app/components/progress_bar.tsx delete mode 100644 app/components/video_controls.js create mode 100644 app/screens/gallery/__snapshots__/gallery.test.js.snap create mode 100644 app/screens/gallery/animation_helper.ts create mode 100644 app/screens/gallery/footer/index.tsx create mode 100644 app/screens/gallery/footer/prepare_file/index.ts create mode 100644 app/screens/gallery/footer/prepare_file/prepare_file.tsx create mode 100644 app/screens/gallery/footer/summary/actions/action.tsx create mode 100644 app/screens/gallery/footer/summary/actions/actions.tsx create mode 100644 app/screens/gallery/footer/summary/actions/index.ts create mode 100644 app/screens/gallery/footer/summary/avatar.tsx create mode 100644 app/screens/gallery/footer/summary/details.tsx create mode 100644 app/screens/gallery/footer/summary/index.ts create mode 100644 app/screens/gallery/footer/summary/summary.tsx create mode 100644 app/screens/gallery/footer/toast/index.ts create mode 100644 app/screens/gallery/footer/toast/toast.tsx create mode 100644 app/screens/gallery/gallery.js create mode 100644 app/screens/gallery/gallery.test.js create mode 100644 app/screens/gallery/gallery_file/gallery_file.tsx create mode 100644 app/screens/gallery/gallery_file/index.ts create mode 100644 app/screens/gallery/gallery_image.tsx create mode 100644 app/screens/gallery/gallery_video.tsx create mode 100644 app/screens/gallery/gallery_viewer.tsx rename app/screens/{text_preview => gallery}/index.js (65%) create mode 100644 app/screens/gallery/video_controls.tsx delete mode 100644 app/screens/image_preview/__snapshots__/downloader_bottom_content.test.js.snap delete mode 100644 app/screens/image_preview/__snapshots__/image_preview.test.js.snap delete mode 100644 app/screens/image_preview/downloader.android.js delete mode 100644 app/screens/image_preview/downloader.ios.js delete mode 100644 app/screens/image_preview/downloader_bottom_content.js delete mode 100644 app/screens/image_preview/downloader_bottom_content.test.js delete mode 100644 app/screens/image_preview/image_preview.js delete mode 100644 app/screens/image_preview/image_preview.test.js delete mode 100644 app/screens/image_preview/index.js delete mode 100644 app/screens/image_preview/video_preview.js delete mode 100644 app/screens/table_image/index.js delete mode 100644 app/screens/table_image/table_image.js delete mode 100644 app/screens/text_preview/text_preview.js create mode 100644 patches/mime-db+1.44.0.patch rename patches/{react-native-navigation+6.12.2.patch => react-native-navigation+7.1.0.patch} (76%) create mode 100644 types/modules/react-native-slider.d.ts create mode 100644 types/screens/gallery.d.ts diff --git a/NOTICE.txt b/NOTICE.txt index e147a7b94..149c2ecc1 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1521,47 +1521,6 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- -## react-native-circular-progress - -This product contains 'react-native-circular-progress' by Bart Gryszko. - -React Native component for creating animated, circular progress with react-native-svg - -* HOMEPAGE: - * https://github.com/bgryszko/react-native-circular-progress - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2015 Bart Gryszko - - - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - - - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - - - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - ## react-native-cookies This product contains a modified version of 'react-native-cookies' by Joseph P. Ferraro. diff --git a/android/app/src/main/assets/fonts/compass-icons.ttf b/android/app/src/main/assets/fonts/compass-icons.ttf index 106fc3a685181bef76ee51ddffa65814d7b4f3a1..2a2743fa9a281f7445bc982b106ea1fdf544386a 100644 GIT binary patch delta 17886 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 delta 16835 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 X_*pjvZZ #FFFFFF + #00000000 \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 319eb0ca1..8918ad4c7 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -3,6 +3,8 @@ diff --git a/app/components/file_attachment_list/__snapshots__/file_attachment.test.js.snap b/app/components/file_attachment_list/__snapshots__/file_attachment.test.js.snap index f7306fc2c..9b1c1e786 100644 --- a/app/components/file_attachment_list/__snapshots__/file_attachment.test.js.snap +++ b/app/components/file_attachment_list/__snapshots__/file_attachment.test.js.snap @@ -1,76 +1,67 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`FileAttachment should match snapshot 1`] = ` - - - - - - - + imageDimensions={null} + resizeMethod="resize" + resizeMode="cover" + theme={ + Object { + "awayIndicator": "#ffbc42", + "buttonBg": "#166de0", + "buttonColor": "#ffffff", + "centerChannelBg": "#ffffff", + "centerChannelColor": "#3d3c40", + "codeTheme": "github", + "dndIndicator": "#f74343", + "errorTextColor": "#fd5960", + "linkColor": "#2389d7", + "mentionBg": "#ffffff", + "mentionBj": "#ffffff", + "mentionColor": "#145dbf", + "mentionHighlightBg": "#ffe577", + "mentionHighlightLink": "#166de0", + "newMessageSeparator": "#ff8800", + "onlineIndicator": "#06d6a0", + "sidebarBg": "#145dbf", + "sidebarHeaderBg": "#1153ab", + "sidebarHeaderTextColor": "#ffffff", + "sidebarText": "#ffffff", + "sidebarTextActiveBorder": "#579eff", + "sidebarTextActiveColor": "#ffffff", + "sidebarTextHoverBg": "#4578bf", + "sidebarUnreadText": "#ffffff", + "type": "Mattermost", + } + } + /> + `; diff --git a/app/components/file_attachment_list/__snapshots__/file_attachment_list.test.js.snap b/app/components/file_attachment_list/__snapshots__/file_attachment_list.test.js.snap index be75c5be8..7b42c8e02 100644 --- a/app/components/file_attachment_list/__snapshots__/file_attachment_list.test.js.snap +++ b/app/components/file_attachment_list/__snapshots__/file_attachment_list.test.js.snap @@ -33,29 +33,25 @@ exports[`FileAttachmentList should match snapshot with a single image file 1`] = canDownloadFiles={true} file={ Object { - "caption": "image01.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 171, - "id": "fileId", - "mime_type": "image/png", - "name": "image01.png", - "post_id": "postId", - "size": 14894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 425, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 171, + "id": "fileId", + "mime_type": "image/png", + "name": "image01.png", + "post_id": "postId", + "size": 14894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 425, } } id="fileId" inViewPort={false} index={0} isSingleImage={true} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -126,29 +122,25 @@ exports[`FileAttachmentList should match snapshot with combination of image and canDownloadFiles={true} file={ Object { - "caption": "image01.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 171, - "id": "fileId", - "mime_type": "image/png", - "name": "image01.png", - "post_id": "postId", - "size": 14894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 425, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 171, + "id": "fileId", + "mime_type": "image/png", + "name": "image01.png", + "post_id": "postId", + "size": 14894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 425, } } id="fileId" inViewPort={false} index={0} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -198,29 +190,25 @@ exports[`FileAttachmentList should match snapshot with combination of image and canDownloadFiles={true} file={ Object { - "caption": "image02.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "otherFileId", - "mime_type": "image/png", - "name": "image02.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "otherFileId", + "mime_type": "image/png", + "name": "image02.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="otherFileId" inViewPort={false} index={1} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -266,23 +254,18 @@ exports[`FileAttachmentList should match snapshot with combination of image and canDownloadFiles={true} file={ Object { - "caption": "file01.other", - "data": Object { - "extension": "other", - "id": "fileId", - "mime_type": "other/type", - "name": "file01.other", - "post_id": "postId", - "size": 14894, - "user_id": "userId", - }, + "extension": "other", + "id": "fileId", + "mime_type": "other/type", + "name": "file01.other", + "post_id": "postId", + "size": 14894, + "user_id": "userId", } } id="fileId" inViewPort={false} index={0} - isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -352,29 +335,25 @@ exports[`FileAttachmentList should match snapshot with four image files 1`] = ` canDownloadFiles={true} file={ Object { - "caption": "image01.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 171, - "id": "fileId", - "mime_type": "image/png", - "name": "image01.png", - "post_id": "postId", - "size": 14894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 425, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 171, + "id": "fileId", + "mime_type": "image/png", + "name": "image01.png", + "post_id": "postId", + "size": 14894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 425, } } id="fileId" inViewPort={false} index={0} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -424,29 +403,25 @@ exports[`FileAttachmentList should match snapshot with four image files 1`] = ` canDownloadFiles={true} file={ Object { - "caption": "image02.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "otherFileId", - "mime_type": "image/png", - "name": "image02.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "otherFileId", + "mime_type": "image/png", + "name": "image02.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="otherFileId" inViewPort={false} index={1} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -496,29 +471,25 @@ exports[`FileAttachmentList should match snapshot with four image files 1`] = ` canDownloadFiles={true} file={ Object { - "caption": "image03.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "thirdFileId", - "mime_type": "image/png", - "name": "image03.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "thirdFileId", + "mime_type": "image/png", + "name": "image03.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="thirdFileId" inViewPort={false} index={2} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -568,29 +539,25 @@ exports[`FileAttachmentList should match snapshot with four image files 1`] = ` canDownloadFiles={true} file={ Object { - "caption": "image04.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "fourthFileId", - "mime_type": "image/png", - "name": "image04.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "fourthFileId", + "mime_type": "image/png", + "name": "image04.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="fourthFileId" inViewPort={false} index={3} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -661,29 +628,25 @@ exports[`FileAttachmentList should match snapshot with more than four image file canDownloadFiles={true} file={ Object { - "caption": "image01.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 171, - "id": "fileId", - "mime_type": "image/png", - "name": "image01.png", - "post_id": "postId", - "size": 14894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 425, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 171, + "id": "fileId", + "mime_type": "image/png", + "name": "image01.png", + "post_id": "postId", + "size": 14894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 425, } } id="fileId" inViewPort={false} index={0} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -733,29 +696,25 @@ exports[`FileAttachmentList should match snapshot with more than four image file canDownloadFiles={true} file={ Object { - "caption": "image02.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "otherFileId", - "mime_type": "image/png", - "name": "image02.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "otherFileId", + "mime_type": "image/png", + "name": "image02.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="otherFileId" inViewPort={false} index={1} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -805,29 +764,25 @@ exports[`FileAttachmentList should match snapshot with more than four image file canDownloadFiles={true} file={ Object { - "caption": "image03.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "thirdFileId", - "mime_type": "image/png", - "name": "image03.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "thirdFileId", + "mime_type": "image/png", + "name": "image03.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="thirdFileId" inViewPort={false} index={2} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -877,22 +832,19 @@ exports[`FileAttachmentList should match snapshot with more than four image file canDownloadFiles={true} file={ Object { - "caption": "image04.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "fourthFileId", - "mime_type": "image/png", - "name": "image04.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "fourthFileId", + "mime_type": "image/png", + "name": "image04.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="fourthFileId" @@ -900,7 +852,6 @@ exports[`FileAttachmentList should match snapshot with more than four image file index={3} isSingleImage={false} nonVisibleImagesCount={2} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -957,23 +908,18 @@ exports[`FileAttachmentList should match snapshot with non-image attachment 1`] canDownloadFiles={true} file={ Object { - "caption": "file01.other", - "data": Object { - "extension": "other", - "id": "fileId", - "mime_type": "other/type", - "name": "file01.other", - "post_id": "postId", - "size": 14894, - "user_id": "userId", - }, + "extension": "other", + "id": "fileId", + "mime_type": "other/type", + "name": "file01.other", + "post_id": "postId", + "size": 14894, + "user_id": "userId", } } id="fileId" inViewPort={false} index={0} - isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -1043,29 +989,25 @@ exports[`FileAttachmentList should match snapshot with three image files 1`] = ` canDownloadFiles={true} file={ Object { - "caption": "image01.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 171, - "id": "fileId", - "mime_type": "image/png", - "name": "image01.png", - "post_id": "postId", - "size": 14894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 425, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 171, + "id": "fileId", + "mime_type": "image/png", + "name": "image01.png", + "post_id": "postId", + "size": 14894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 425, } } id="fileId" inViewPort={false} index={0} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -1115,29 +1057,25 @@ exports[`FileAttachmentList should match snapshot with three image files 1`] = ` canDownloadFiles={true} file={ Object { - "caption": "image02.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "otherFileId", - "mime_type": "image/png", - "name": "image02.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "otherFileId", + "mime_type": "image/png", + "name": "image02.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="otherFileId" inViewPort={false} index={1} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -1187,29 +1125,25 @@ exports[`FileAttachmentList should match snapshot with three image files 1`] = ` canDownloadFiles={true} file={ Object { - "caption": "image03.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "thirdFileId", - "mime_type": "image/png", - "name": "image03.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "thirdFileId", + "mime_type": "image/png", + "name": "image03.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="thirdFileId" inViewPort={false} index={2} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -1280,29 +1214,25 @@ exports[`FileAttachmentList should match snapshot with two image files 1`] = ` canDownloadFiles={true} file={ Object { - "caption": "image01.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 171, - "id": "fileId", - "mime_type": "image/png", - "name": "image01.png", - "post_id": "postId", - "size": 14894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 425, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 171, + "id": "fileId", + "mime_type": "image/png", + "name": "image01.png", + "post_id": "postId", + "size": 14894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 425, } } id="fileId" inViewPort={false} index={0} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { @@ -1352,29 +1282,25 @@ exports[`FileAttachmentList should match snapshot with two image files 1`] = ` canDownloadFiles={true} file={ Object { - "caption": "image02.png", - "data": Object { - "create_at": 1546893090093, - "delete_at": 0, - "extension": "png", - "has_preview_image": true, - "height": 800, - "id": "otherFileId", - "mime_type": "image/png", - "name": "image02.png", - "post_id": "postId", - "size": 24894, - "update_at": 1546893090093, - "user_id": "userId", - "width": 555, - }, + "create_at": 1546893090093, + "delete_at": 0, + "extension": "png", + "has_preview_image": true, + "height": 800, + "id": "otherFileId", + "mime_type": "image/png", + "name": "image02.png", + "post_id": "postId", + "size": 24894, + "update_at": 1546893090093, + "user_id": "userId", + "width": 555, } } id="otherFileId" inViewPort={false} index={1} isSingleImage={false} - onCaptureRef={[Function]} onPreviewPress={[Function]} theme={ Object { diff --git a/app/components/file_attachment_list/file_attachment.js b/app/components/file_attachment_list/file_attachment.js index 8e358b11a..0f9f96a9e 100644 --- a/app/components/file_attachment_list/file_attachment.js +++ b/app/components/file_attachment_list/file_attachment.js @@ -14,7 +14,7 @@ import { import * as Utils from '@mm-redux/utils/file_utils'; import TouchableWithFeedback from 'app/components/touchable_with_feedback'; -import {isDocument, isGif} from 'app/utils/file'; +import {isDocument, isImage} from 'app/utils/file'; import {calculateDimensions} from 'app/utils/images'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; @@ -28,7 +28,6 @@ export default class FileAttachment extends PureComponent { file: PropTypes.object.isRequired, id: PropTypes.string.isRequired, index: PropTypes.number.isRequired, - onCaptureRef: PropTypes.func, onLongPress: PropTypes.func, onPreviewPress: PropTypes.func, theme: PropTypes.object.isRequired, @@ -43,12 +42,18 @@ export default class FileAttachment extends PureComponent { wrapperWidth: 300, }; - handleCaptureRef = (ref) => { - const {onCaptureRef, index} = this.props; + state = { + resizeMode: 'cover', + }; - if (onCaptureRef) { - onCaptureRef(ref, index); + componentWillUnmount() { + if (this.transition) { + clearTimeout(this.transition); } + } + + handlePress = () => { + this.props.onPreviewPress(this.props.index); }; handlePreviewPress = () => { @@ -61,16 +66,15 @@ export default class FileAttachment extends PureComponent { renderFileInfo() { const {file, onLongPress, theme} = this.props; - const {data} = file; const style = getStyleSheet(theme); - if (!data || !data.id) { + if (!file?.id) { return null; } return ( - {file.caption.trim()} + {file.name.trim()} - {`${Utils.getFormattedFileSize(data)}`} + {`${Utils.getFormattedFileSize(file)}`} @@ -146,12 +150,11 @@ export default class FileAttachment extends PureComponent { nonVisibleImagesCount, inViewPort, } = this.props; - const {data} = file; const style = getStyleSheet(theme); let fileAttachmentComponent; - if ((data && data.has_preview_image) || file.loading || isGif(data)) { - const imageDimensions = this.getImageDimensions(data); + if (isImage(file) || file.loading) { + const imageDimensions = this.getImageDimensions(file); fileAttachmentComponent = ( {this.renderMoreImagesOverlay(nonVisibleImagesCount, theme)} ); - } else if (isDocument(data)) { + } else if (isDocument(file)) { fileAttachmentComponent = ( @@ -197,8 +200,7 @@ export default class FileAttachment extends PureComponent { type={'opacity'} > diff --git a/app/components/file_attachment_list/file_attachment_document/file_attachment_document.js b/app/components/file_attachment_list/file_attachment_document.js similarity index 55% rename from app/components/file_attachment_list/file_attachment_document/file_attachment_document.js rename to app/components/file_attachment_list/file_attachment_document.js index 1a867d49c..cedf3f7aa 100644 --- a/app/components/file_attachment_list/file_attachment_document/file_attachment_document.js +++ b/app/components/file_attachment_list/file_attachment_document.js @@ -8,31 +8,22 @@ import { Platform, StatusBar, StyleSheet, - Text, View, } from 'react-native'; import FileViewer from 'react-native-file-viewer'; import RNFetchBlob from 'rn-fetch-blob'; -import {CircularProgress} from 'react-native-circular-progress'; import {intlShape} from 'react-intl'; import tinyColor from 'tinycolor2'; +import FileAttachmentIcon from '@components/file_attachment_list/file_attachment_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import ProgressBar from '@components/progress_bar'; +import {DeviceTypes} from '@constants/'; import {getFileUrl} from '@mm-redux/utils/file_utils'; - -import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon'; -import TouchableWithFeedback from 'app/components/touchable_with_feedback'; -import {DeviceTypes} from 'app/constants/'; +import {getLocalFilePathFromFile} from '@utils/file'; import mattermostBucket from 'app/mattermost_bucket'; -import {changeOpacity} from 'app/utils/theme'; -import {goToScreen} from 'app/actions/navigation'; const {DOCUMENTS_PATH} = DeviceTypes; -const TEXT_PREVIEW_FORMATS = [ - 'application/json', - 'application/x-x509-ca-cert', - 'text/plain', -]; -const circularProgressWidth = 4; export default class FileAttachmentDocument extends PureComponent { static propTypes = { @@ -89,8 +80,7 @@ export default class FileAttachmentDocument extends PureComponent { }; downloadAndPreviewFile = async (file) => { - const {data} = file; - const path = `${DOCUMENTS_PATH}/${data.id}-${file.caption}`; + const path = getLocalFilePathFromFile(DOCUMENTS_PATH, file); this.setState({didCancel: false}); @@ -107,7 +97,7 @@ export default class FileAttachmentDocument extends PureComponent { } const options = { - session: data.id, + session: file.id, timeout: 10000, indicator: true, overwrite: true, @@ -115,20 +105,14 @@ export default class FileAttachmentDocument extends PureComponent { certificate, }; - const mime = data.mime_type.split(';')[0]; - let openDocument = this.openDocument; - if (TEXT_PREVIEW_FORMATS.includes(mime)) { - openDocument = this.previewTextFile; - } - const exist = await RNFetchBlob.fs.exists(path); if (exist) { - openDocument(file, 0); + this.openDocument(file); } else { this.setState({downloading: true}); - this.downloadTask = RNFetchBlob.config(options).fetch('GET', getFileUrl(data.id)); + this.downloadTask = RNFetchBlob.config(options).fetch('GET', getFileUrl(file.id)); this.downloadTask.progress((received, total) => { - const progress = Math.round((received / total) * 100); + const progress = parseFloat((received / total).toFixed(1)); if (this.mounted) { this.setState({progress}); } @@ -137,10 +121,9 @@ export default class FileAttachmentDocument extends PureComponent { await this.downloadTask; if (this.mounted) { this.setState({ - progress: 100, + progress: 1, }, () => { - // need to wait a bit for the progress circle UI to update to the give progress - openDocument(file); + this.openDocument(file); }); } } @@ -165,7 +148,7 @@ export default class FileAttachmentDocument extends PureComponent { return; } - if (downloading && progress < 100) { + if (downloading && progress < 1) { this.cancelDownload(); } else if (downloading) { this.resetViewState(); @@ -174,29 +157,6 @@ export default class FileAttachmentDocument extends PureComponent { } }; - previewTextFile = (file, delay = 2000) => { - const {data} = file; - const prefix = Platform.OS === 'android' ? 'file:/' : ''; - const path = `${DOCUMENTS_PATH}/${data.id}-${file.caption}`; - const readFile = RNFetchBlob.fs.readFile(`${prefix}${path}`, 'utf8'); - setTimeout(async () => { - try { - this.setState({downloading: false, progress: 0}); - - const content = await readFile; - const screen = 'TextPreview'; - const title = file.caption; - const passProps = { - content, - }; - - goToScreen(screen, title, passProps); - } catch (error) { - RNFetchBlob.fs.unlink(path); - } - }, delay); - }; - onDonePreviewingFile = () => { if (this.mounted) { this.setState({progress: 0, downloading: false, preview: false}); @@ -204,50 +164,44 @@ export default class FileAttachmentDocument extends PureComponent { this.setStatusBarColor(); }; - openDocument = (file, delay = 2000) => { - // The animation for the progress circle takes about 2 seconds to finish - // therefore we are delaying the opening of the document to have the UI - // shown nicely and smooth - setTimeout(() => { - if (!this.state.didCancel && !this.state.preview && this.mounted) { - const {data} = file; - const path = `${DOCUMENTS_PATH}/${data.id}-${file.caption}`; - this.setState({preview: true}); - this.setStatusBarColor('dark-content'); - FileViewer.open(path, { - displayName: file.caption, - onDismiss: this.onDonePreviewingFile, - showOpenWithDialog: true, - showAppsSuggestions: true, - }).then(() => { - if (this.mounted) { - this.setState({downloading: false, progress: 0}); - } - }).catch(() => { - const {intl} = this.context; - Alert.alert( - intl.formatMessage({ - id: 'mobile.document_preview.failed_title', - defaultMessage: 'Open Document failed', + openDocument = (file) => { + if (!this.state.didCancel && !this.state.preview && this.mounted) { + const path = getLocalFilePathFromFile(DOCUMENTS_PATH, file); + this.setState({preview: true}); + this.setStatusBarColor('dark-content'); + FileViewer.open(path, { + displayName: file.name, + onDismiss: this.onDonePreviewingFile, + showOpenWithDialog: true, + showAppsSuggestions: true, + }).then(() => { + if (this.mounted) { + this.setState({downloading: false, progress: 0}); + } + }).catch(() => { + const {intl} = this.context; + Alert.alert( + intl.formatMessage({ + id: 'mobile.document_preview.failed_title', + defaultMessage: 'Open Document failed', + }), + intl.formatMessage({ + id: 'mobile.document_preview.failed_description', + defaultMessage: 'An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n', + }, { + fileType: file.extension.toUpperCase(), + }), + [{ + text: intl.formatMessage({ + id: 'mobile.server_upgrade.button', + defaultMessage: 'OK', }), - intl.formatMessage({ - id: 'mobile.document_preview.failed_description', - defaultMessage: 'An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n', - }, { - fileType: data.extension.toUpperCase(), - }), - [{ - text: intl.formatMessage({ - id: 'mobile.server_upgrade.button', - defaultMessage: 'OK', - }), - }], - ); - this.onDonePreviewingFile(); - RNFetchBlob.fs.unlink(path); - }); - } - }, delay); + }], + ); + this.onDonePreviewingFile(); + RNFetchBlob.fs.unlink(path); + }); + } }; resetViewState = () => { @@ -255,13 +209,7 @@ export default class FileAttachmentDocument extends PureComponent { this.setState({ progress: 0, didCancel: true, - }, () => { - // need to wait a bit for the progress circle UI to update to the give progress - setTimeout(() => { - if (this.mounted) { - this.setState({downloading: false}); - } - }, 2000); + downloading: false, }); } }; @@ -314,39 +262,27 @@ export default class FileAttachmentDocument extends PureComponent { return ( ); } - renderDownloadProgres = () => { - const {theme} = this.props; - return ( - - {`${this.state.progress}%`} - - ); - }; - render() { const {onLongPress, theme} = this.props; const {downloading, progress} = this.state; let fileAttachmentComponent; if (downloading) { fileAttachmentComponent = ( - - - {this.renderDownloadProgres} - - + <> + {this.renderFileAttachmentIcon()} + + + + ); } else { fileAttachmentComponent = this.renderFileAttachmentIcon(); @@ -364,11 +300,12 @@ export default class FileAttachmentDocument extends PureComponent { } } -const style = StyleSheet.create({ - circularProgressContent: { - left: -(circularProgressWidth - 2), - top: 4, - width: 36, +const styles = StyleSheet.create({ + progress: { + justifyContent: 'flex-end', height: 48, + left: 2, + top: 5, + width: 44, }, }); diff --git a/app/components/file_attachment_list/file_attachment_document/index.js b/app/components/file_attachment_list/file_attachment_document/index.js deleted file mode 100644 index ff81e105d..000000000 --- a/app/components/file_attachment_list/file_attachment_document/index.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; - -import FileAttachmentDocument from './file_attachment_document'; - -export default connect(null, null, null, {forwardRef: true})(FileAttachmentDocument); diff --git a/app/components/file_attachment_list/file_attachment_icon.js b/app/components/file_attachment_list/file_attachment_icon.js index 423928fbd..c63e059df 100644 --- a/app/components/file_attachment_list/file_attachment_icon.js +++ b/app/components/file_attachment_list/file_attachment_icon.js @@ -3,14 +3,10 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import { - View, - StyleSheet, -} from 'react-native'; - -import * as Utils from '@mm-redux/utils/file_utils'; +import {View, StyleSheet} from 'react-native'; import CompassIcon from '@components/compass_icon'; +import * as Utils from '@mm-redux/utils/file_utils'; const BLUE_ICON = '#338AFF'; const RED_ICON = '#ED522A'; @@ -40,7 +36,6 @@ export default class FileAttachmentIcon extends PureComponent { defaultImage: PropTypes.bool, smallImage: PropTypes.bool, file: PropTypes.object, - onCaptureRef: PropTypes.func, iconColor: PropTypes.string, iconSize: PropTypes.number, theme: PropTypes.object, @@ -70,14 +65,6 @@ export default class FileAttachmentIcon extends PureComponent { return ICON_NAME_AND_COLOR_FROM_FILE_TYPE[fileType] || ICON_NAME_AND_COLOR_FROM_FILE_TYPE.other; } - handleCaptureRef = (ref) => { - const {onCaptureRef} = this.props; - - if (onCaptureRef) { - onCaptureRef(ref); - } - }; - render() { const {backgroundColor, file, iconSize, theme, iconColor} = this.props; const [iconName, defaultIconColor] = this.getFileIconNameAndColor(file); @@ -85,10 +72,7 @@ export default class FileAttachmentIcon extends PureComponent { const bgColor = backgroundColor || theme?.centerChannelBg || 'transparent'; return ( - + ); }; - handleCaptureRef = (ref) => { - const {onCaptureRef} = this.props; - - if (onCaptureRef) { - onCaptureRef(ref); - } - }; - handleError = () => { this.setState({failed: true}); } @@ -103,7 +94,6 @@ export default class FileAttachmentImage extends PureComponent { return ( {this.boxPlaceholder()} { @@ -75,7 +76,7 @@ export default class FileAttachmentList extends ImageViewPort { attachmentManifest = (attachments) => { return attachments.reduce((info, file) => { - if (this.isImage(file)) { + if (isImage(file)) { info.imageAttachments.push(file); } else { info.nonImageAttachments.push(file); @@ -90,13 +91,8 @@ export default class FileAttachmentList extends ImageViewPort { if (this.filesForGallery && this.filesForGallery.length) { for (let i = 0; i < this.filesForGallery.length; i++) { const file = this.filesForGallery[i]; - const caption = file.name; - - if (isDocument(file) || isVideo(file) || (!file.has_preview_image && !isGif(file))) { - results.push({ - caption, - data: file, - }); + if (isDocument(file) || isVideo(file) || (!isImage(file))) { + results.push(file); continue; } @@ -108,9 +104,8 @@ export default class FileAttachmentList extends ImageViewPort { } results.push({ - caption, - source: {uri}, - data: file, + ...file, + uri, }); } } @@ -132,17 +127,11 @@ export default class FileAttachmentList extends ImageViewPort { return results; }; - handleCaptureRef = (ref, idx) => { - this.items[idx] = ref; - }; - handlePreviewPress = preventDoubleTap((idx) => { - previewImageAtIndex(this.items, idx, this.galleryFiles); + openGalleryAtIndex(idx, this.galleryFiles); }); - isImage = (file) => (file.has_preview_image || isGif(file)); - - isSingleImage = (files) => (files.length === 1 && this.isImage(files[0])); + isSingleImage = (files) => (files.length === 1 && isImage(files[0])); renderItems = (items, moreImagesCount, includeGutter = false) => { const {canDownloadFiles, isReplyPost, onLongPress, theme} = this.props; @@ -152,11 +141,6 @@ export default class FileAttachmentList extends ImageViewPort { const containerWithGutter = [container, styles.gutter]; return items.map((file, idx) => { - const f = { - caption: file.name, - data: file, - }; - if (moreImagesCount && idx === MAX_VISIBLE_ROW_IMAGES - 1) { nonVisibleImagesCount = moreImagesCount; } @@ -173,10 +157,9 @@ export default class FileAttachmentList extends ImageViewPort { {reactChildren} @@ -190,11 +194,13 @@ export default class Markdown extends PureComponent { return ( {reactChildren} diff --git a/app/components/markdown/markdown_image/markdown_image.js b/app/components/markdown/markdown_image/markdown_image.js index 0f4a5d9fc..0b5a1e38e 100644 --- a/app/components/markdown/markdown_image/markdown_image.js +++ b/app/components/markdown/markdown_image/markdown_image.js @@ -13,6 +13,7 @@ import { View, } from 'react-native'; import Clipboard from '@react-native-community/clipboard'; +import parseUrl from 'url-parse'; import CompassIcon from '@components/compass_icon'; import ImageViewPort from '@components/image_viewport'; @@ -22,7 +23,8 @@ import TouchableWithFeedback from '@components/touchable_with_feedback'; import {CustomPropTypes} from '@constants'; import EphemeralStore from '@store/ephemeral_store'; import BottomSheet from '@utils/bottom_sheet'; -import {calculateDimensions, getViewPortWidth, isGifTooLarge, previewImageAtIndex} from '@utils/images'; +import {generateId} from '@utils/file'; +import {calculateDimensions, getViewPortWidth, isGifTooLarge, openGalleryAtIndex} from '@utils/images'; import {normalizeProtocol} from '@utils/url'; import mattermostManaged from 'app/mattermost_managed'; @@ -33,11 +35,13 @@ const ANDROID_MAX_WIDTH = 4096; export default class MarkdownImage extends ImageViewPort { static propTypes = { children: PropTypes.node, - imagesMetadata: PropTypes.object, - linkDestination: PropTypes.string, - isReplyPost: PropTypes.bool, - source: PropTypes.string.isRequired, + disable: PropTypes.bool, errorTextStyle: CustomPropTypes.Style, + imagesMetadata: PropTypes.object, + isReplyPost: PropTypes.bool, + linkDestination: PropTypes.string, + postId: PropTypes.string, + source: PropTypes.string.isRequired, }; static contextTypes = { @@ -47,22 +51,39 @@ export default class MarkdownImage extends ImageViewPort { constructor(props) { super(props); - const dimensions = props.imagesMetadata?.[props.source]; + const metadata = props.imagesMetadata?.[props.source]; + this.fileId = generateId(); this.state = { - originalHeight: dimensions?.height || 0, - originalWidth: dimensions?.width || 0, - failed: false, + originalHeight: metadata?.height || 0, + originalWidth: metadata?.width || 0, + failed: isGifTooLarge(metadata), uri: null, }; } - setImageRef = (ref) => { - this.imageRef = ref; - } + getFileInfo = () => { + const {originalHeight, originalWidth} = this.state; + const link = decodeURIComponent(this.getSource()); + let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', ''); + let extension = filename.split('.').pop(); - setItemRef = (ref) => { - this.itemRef = ref; - } + if (extension === filename) { + const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.')); + filename = `${filename}${ext}`; + extension = ext; + } + + return { + id: this.fileId, + name: filename, + extension, + has_preview_image: true, + post_id: this.props.postId, + uri: link, + width: originalWidth, + height: originalHeight, + }; + }; getSource = () => { let uri = this.props.source; @@ -143,45 +164,27 @@ export default class MarkdownImage extends ImageViewPort { }; handlePreviewImage = () => { - const { - originalHeight, - originalWidth, - } = this.state; - const link = this.getSource(); - let filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?')); - const extension = filename.split('.').pop(); - - if (extension === filename) { - const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.')); - filename = `${filename}${ext}`; + if (this.props.disable) { + return; } - const files = [{ - caption: filename, - dimensions: { - width: originalWidth, - height: originalHeight, - }, - source: {link}, - data: { - localPath: link, - }, - }]; - - previewImageAtIndex([this.itemRef], 0, files); + const files = [this.getFileInfo()]; + openGalleryAtIndex(0, files); }; render() { - if (isGifTooLarge(this.props.imagesMetadata?.[this.props.source])) { - return null; - } - let image = null; - const {originalHeight, originalWidth} = this.state; - const uri = this.getSource(); - const {height, width} = calculateDimensions(originalHeight, originalWidth, getViewPortWidth(this.props.isReplyPost, this.hasPermanentSidebar())); + const fileInfo = this.getFileInfo(); + const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, getViewPortWidth(this.props.isReplyPost, this.hasPermanentSidebar())); - if (width && height) { + if (this.state.failed) { + image = ( + + ); + } else if (width && height) { if (Platform.OS === 'android' && (width > ANDROID_MAX_WIDTH || height > ANDROID_MAX_HEIGHT)) { // Android has a cap on the max image size that can be displayed @@ -202,8 +205,8 @@ export default class MarkdownImage extends ImageViewPort { } else { // React Native complains if we try to pass resizeMode as a style let source = null; - if (uri) { - source = {uri}; + if (fileInfo.uri) { + source = {uri: fileInfo.uri}; } image = ( @@ -213,7 +216,7 @@ export default class MarkdownImage extends ImageViewPort { style={{width, height}} > ); } - } else if (this.state.failed) { - image = ( - - ); } if (image && this.props.linkDestination) { @@ -242,10 +238,7 @@ export default class MarkdownImage extends ImageViewPort { } return ( - + {image} ); diff --git a/app/components/markdown/markdown_table_cell/markdown_table_cell.js b/app/components/markdown/markdown_table_cell/markdown_table_cell.js index b5ac917de..51ab89e6f 100644 --- a/app/components/markdown/markdown_table_cell/markdown_table_cell.js +++ b/app/components/markdown/markdown_table_cell/markdown_table_cell.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; -import {Text, View} from 'react-native'; +import {View} from 'react-native'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; @@ -34,10 +34,8 @@ export default class MarkdownTableCell extends React.PureComponent { } return ( - - - {this.props.children} - + + {this.props.children} ); } @@ -55,10 +53,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { borderRightWidth: 1, }, alignCenter: { - textAlign: 'center', + alignItems: 'center', }, alignRight: { - textAlign: 'right', + alignItems: 'flex-end', }, }; }); diff --git a/app/components/markdown/markdown_table_image/markdown_table_image.js b/app/components/markdown/markdown_table_image/markdown_table_image.js deleted file mode 100644 index 90a18f1cf..000000000 --- a/app/components/markdown/markdown_table_image/markdown_table_image.js +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {PropTypes} from 'prop-types'; -import React from 'react'; -import {intlShape} from 'react-intl'; -import {Text} from 'react-native'; - -import CustomPropTypes from 'app/constants/custom_prop_types'; -import {getCurrentServerUrl} from 'app/init/credentials'; -import {preventDoubleTap} from 'app/utils/tap'; -import {goToScreen} from 'app/actions/navigation'; - -export default class MarkdownTableImage extends React.PureComponent { - static propTypes = { - children: PropTypes.node.isRequired, - imagesMetadata: PropTypes.object, - source: PropTypes.string.isRequired, - textStyle: CustomPropTypes.Style.isRequired, - serverURL: PropTypes.string, - }; - - static contextTypes = { - intl: intlShape.isRequired, - }; - - handlePress = preventDoubleTap(() => { - const {intl} = this.context; - const screen = 'TableImage'; - const title = intl.formatMessage({ - id: 'mobile.routes.tableImage', - defaultMessage: 'Image', - }); - const passProps = { - imagesMetadata: this.props.imagesMetadata, - imageSource: this.getImageSource(), - }; - - goToScreen(screen, title, passProps); - }); - - getImageSource = async () => { - let source = this.props.source; - let serverUrl = this.props.serverURL; - - if (!serverUrl) { - serverUrl = await getCurrentServerUrl(); - } - - if (source.startsWith('/')) { - source = serverUrl + source; - } - - return source; - }; - - render() { - return ( - - {this.props.children} - - ); - } -} diff --git a/app/components/markdown/markdown_table_image/markdown_table_image.tsx b/app/components/markdown/markdown_table_image/markdown_table_image.tsx new file mode 100644 index 000000000..5d4159489 --- /dev/null +++ b/app/components/markdown/markdown_table_image/markdown_table_image.tsx @@ -0,0 +1,122 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useRef, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; +import parseUrl from 'url-parse'; + +import CompassIcon from '@components/compass_icon'; +import ProgressiveImage from '@components/progressive_image'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import EphemeralStore from '@store/ephemeral_store'; +import {calculateDimensions, isGifTooLarge, openGalleryAtIndex} from '@utils/images'; +import {generateId} from '@utils/file'; + +import type {PostImage} from '@mm-redux/types/posts'; + +type MarkdownTableImageProps = { + disable: boolean; + imagesMetadata: Record; + postId: string; + serverURL?: string; + source: string; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + }, +}); + +const MarkTableImage = ({disable, imagesMetadata, postId, serverURL, source}: MarkdownTableImageProps) => { + const metadata = imagesMetadata[source]; + const fileId = useRef(generateId()).current; + const [failed, setFailed] = useState(isGifTooLarge(metadata)); + + const getImageSource = () => { + let uri = source; + let server = serverURL; + + if (!serverURL) { + server = EphemeralStore.currentServerUrl; + } + + if (uri.startsWith('/')) { + uri = server + uri; + } + + return uri; + }; + + const getFileInfo = () => { + const {height, width} = metadata; + const link = decodeURIComponent(getImageSource()); + let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', ''); + let extension = filename.split('.').pop(); + + if (extension === filename) { + const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.')); + filename = `${filename}${ext}`; + extension = ext; + } + + return { + id: fileId, + name: filename, + extension, + has_preview_image: true, + post_id: postId, + uri: link, + width, + height, + }; + }; + + const handlePreviewImage = useCallback(() => { + if (disable) { + return; + } + + const files = [getFileInfo()]; + openGalleryAtIndex(0, files); + }, []); + + const onLoadFailed = useCallback(() => { + setFailed(true); + }, []); + + let image; + if (failed) { + image = ( + + ); + } else { + const {height, width} = calculateDimensions(metadata.height, metadata.width, 100, 100); + image = ( + + + + ); + } + + return ( + + {image} + + ); +}; + +export default MarkTableImage; diff --git a/app/components/message_attachments/attachment_fields.js b/app/components/message_attachments/attachment_fields.js index 355c3e126..422faac4b 100644 --- a/app/components/message_attachments/attachment_fields.js +++ b/app/components/message_attachments/attachment_fields.js @@ -84,6 +84,7 @@ export default class AttachmentFields extends PureComponent { baseTextStyle={baseTextStyle} textStyles={textStyles} blockStyles={blockStyles} + disableGallery={true} imagesMetadata={metadata?.images} value={(field.value || '')} onPermalinkPress={onPermalinkPress} diff --git a/app/components/message_attachments/attachment_image/__snapshots__/index.test.js.snap b/app/components/message_attachments/attachment_image/__snapshots__/index.test.js.snap index e4b92da1f..a790d791b 100644 --- a/app/components/message_attachments/attachment_image/__snapshots__/index.test.js.snap +++ b/app/components/message_attachments/attachment_image/__snapshots__/index.test.js.snap @@ -32,6 +32,7 @@ exports[`AttachmentImage it matches snapshot 1`] = ` } > { - this.imageRef = ref; - } - - setItemRef = (ref) => { - this.itemRef = ref; - } - - handlePreviewImage = () => { - const {imageUrl} = this.props; + getFileInfo = () => { + const {imageUrl, postId} = this.props; const { imageUri: uri, originalHeight, @@ -75,18 +70,21 @@ export default class AttachmentImage extends PureComponent { filename = `${filename}${ext}`; } - const files = [{ - caption: filename, - dimensions: { - height: originalHeight, - width: originalWidth, - }, - source: {uri}, - data: { - localPath: uri, - }, - }]; - previewImageAtIndex([this.itemRef], 0, files); + return { + id: this.fileId, + name: filename, + extension, + has_preview_image: true, + post_id: postId, + uri, + width: originalWidth, + height: originalHeight, + }; + } + + handlePreviewImage = () => { + const files = [this.getFileInfo()]; + openGalleryAtIndex(0, files); }; setImageDimensions = (imageUri, dimensions, originalWidth, originalHeight) => { @@ -133,7 +131,7 @@ export default class AttachmentImage extends PureComponent { if (imageUri) { progressiveImage = ( {progressiveImage} diff --git a/app/components/message_attachments/attachment_pretext.js b/app/components/message_attachments/attachment_pretext.js index 827af5ca7..940d7772b 100644 --- a/app/components/message_attachments/attachment_pretext.js +++ b/app/components/message_attachments/attachment_pretext.js @@ -38,6 +38,7 @@ export default class AttachmentPreText extends PureComponent { baseTextStyle={baseTextStyle} textStyles={textStyles} blockStyles={blockStyles} + disableGallery={true} imagesMetadata={metadata?.images} value={value} onPermalinkPress={onPermalinkPress} diff --git a/app/components/message_attachments/attachment_text.js b/app/components/message_attachments/attachment_text.js index 7e64b2460..728aee5e6 100644 --- a/app/components/message_attachments/attachment_text.js +++ b/app/components/message_attachments/attachment_text.js @@ -95,6 +95,7 @@ export default class AttachmentText extends PureComponent { baseTextStyle={baseTextStyle} textStyles={textStyles} blockStyles={blockStyles} + disableGallery={true} imagesMetadata={metadata?.images} value={value} onPermalinkPress={onPermalinkPress} diff --git a/app/components/message_attachments/attachment_title.js b/app/components/message_attachments/attachment_title.js index 3f544b76b..7b2ad49da 100644 --- a/app/components/message_attachments/attachment_title.js +++ b/app/components/message_attachments/attachment_title.js @@ -71,6 +71,7 @@ export default class AttachmentTitle extends PureComponent { disableHashtags={true} disableAtMentions={true} disableChannelLink={true} + disableGallery={true} autolinkedUrlSchemes={[]} mentionKeys={[]} theme={theme} diff --git a/app/components/message_attachments/message_attachment.js b/app/components/message_attachments/message_attachment.js index 5839e3cb5..17b9c30fc 100644 --- a/app/components/message_attachments/message_attachment.js +++ b/app/components/message_attachments/message_attachment.js @@ -118,6 +118,7 @@ export default class MessageAttachment extends PureComponent { deviceWidth={deviceWidth} imageUrl={attachment.image_url} imageMetadata={metadata?.images?.[attachment.image_url]} + postId={postId} theme={theme} /> } diff --git a/app/components/post_attachment_image/index.js b/app/components/post_attachment_image/index.js index d45c0441f..3d998b713 100644 --- a/app/components/post_attachment_image/index.js +++ b/app/components/post_attachment_image/index.js @@ -12,6 +12,7 @@ import {isGifTooLarge} from 'app/utils/images'; export default class PostAttachmentImage extends React.PureComponent { static propTypes = { height: PropTypes.number.isRequired, + id: PropTypes.string, imageMetadata: PropTypes.object, onError: PropTypes.func.isRequired, onImagePress: PropTypes.func.isRequired, @@ -23,14 +24,8 @@ export default class PostAttachmentImage extends React.PureComponent { frameCount: 0, }; - constructor(props) { - super(props); - - this.image = React.createRef(); - } - handlePress = () => { - this.props.onImagePress(this.image.current); + this.props.onImagePress(); }; render() { @@ -46,8 +41,9 @@ export default class PostAttachmentImage extends React.PureComponent { style={[styles.imageContainer, {height: this.props.height}]} type={'none'} > - + { - this.itemRef = ref; - }; - getBestImageUrlAndDimensions = (data) => { if (!data || !data.images) { return { @@ -93,8 +93,9 @@ export default class PostAttachmentOpenGraph extends PureComponent { }; }; - getFilename = (link) => { - let filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?')); + getFilename = (uri) => { + const link = decodeURIComponent(uri); + let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', ''); const extension = filename.split('.').pop(); if (extension === filename) { @@ -163,20 +164,20 @@ export default class PostAttachmentOpenGraph extends PureComponent { originalHeight, } = this.state; const filename = this.getFilename(link); + const extension = filename.split('.').pop(); const files = [{ - caption: filename, - dimensions: { - width: originalWidth, - height: originalHeight, - }, - source: {uri}, - data: { - localPath: uri, - }, + id: this.fileId, + name: filename, + extension, + has_preview_image: true, + post_id: this.props.postId, + uri, + width: originalWidth, + height: originalHeight, }]; - previewImageAtIndex([this.itemRef], 0, files); + openGalleryAtIndex(0, files); }; renderDescription = () => { @@ -205,22 +206,19 @@ export default class PostAttachmentOpenGraph extends PureComponent { return null; } - const {height, imageUrl, width} = this.state; + const {height, openGraphImageUrl, width} = this.state; let source; - if (imageUrl) { + if (openGraphImageUrl) { source = { - uri: imageUrl, + uri: openGraphImageUrl, }; } const style = getStyleSheet(this.props.theme); return ( - + diff --git a/app/components/post_body/__snapshots__/system_message_helpers.test.js.snap b/app/components/post_body/__snapshots__/system_message_helpers.test.js.snap index 1cabffe76..ad2c93f85 100644 --- a/app/components/post_body/__snapshots__/system_message_helpers.test.js.snap +++ b/app/components/post_body/__snapshots__/system_message_helpers.test.js.snap @@ -99,6 +99,7 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = ` { + let {link} = this.props; + const {originalHeight, originalWidth, uri} = this.state; + const {expandedLink, postId} = this.props; + if (expandedLink) { + link = expandedLink; + } + + const url = decodeURIComponent(link); + let filename = parseUrl(url.substr(url.lastIndexOf('/'))).pathname.replace('/', ''); + let extension = filename.split('.').pop(); + + if (extension === filename) { + const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.')); + filename = `${filename}${ext}`; + extension = ext; + } + + return { + id: this.fileId, + name: filename, + extension, + has_preview_image: true, + post_id: postId, + uri, + width: originalWidth, + height: originalHeight, + }; + }; + getImageSize = (path) => { const {link, metadata} = this.props; let img; @@ -172,31 +205,10 @@ export default class PostBodyAdditionalContent extends ImageViewPort { this.setState({linkLoadError: true}); }; - handlePreviewImage = (imageRef) => { - let {link} = this.props; - const {expandedLink} = this.props; - if (expandedLink) { - link = expandedLink; - } - const { - originalHeight, - originalWidth, - uri, - } = this.state; - const filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?')); - const files = [{ - caption: filename, - source: {uri}, - dimensions: { - width: originalWidth, - height: originalHeight, - }, - data: { - localPath: uri, - }, - }]; + handlePreviewImage = () => { + const files = [this.getFileInfo()]; - previewImageAtIndex([imageRef], 0, files); + openGalleryAtIndex(0, files); }; isImage = (specificLink) => { @@ -290,6 +302,7 @@ export default class PostBodyAdditionalContent extends ImageViewPort { renderImage = (link) => { const imageMetadata = this.props.metadata?.images?.[link]; + const fileInfo = this.getFileInfo(); const {width, height, uri} = this.state; if (!imageMetadata) { @@ -298,6 +311,7 @@ export default class PostBodyAdditionalContent extends ImageViewPort { return ( { - const {isReplyPost, link, metadata, openGraphData, showLinkPreviews, theme} = this.props; + const {isReplyPost, link, metadata, openGraphData, postId, showLinkPreviews, theme} = this.props; if (isYouTube || (isImage && !openGraphData)) { return null; @@ -375,6 +389,7 @@ export default class PostBodyAdditionalContent extends ImageViewPort { isReplyPost={isReplyPost} link={link} openGraphData={openGraphData} + postId={postId} imagesMetadata={metadata.images} theme={theme} /> diff --git a/app/components/post_draft/uploads/upload_item/upload_item.js b/app/components/post_draft/uploads/upload_item/upload_item.js index f89c0690d..f6f48a8a1 100644 --- a/app/components/post_draft/uploads/upload_item/upload_item.js +++ b/app/components/post_draft/uploads/upload_item/upload_item.js @@ -3,16 +3,16 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {StyleSheet, Text, View} from 'react-native'; +import {StyleSheet, TouchableOpacity, View} from 'react-native'; import RNFetchBlob from 'rn-fetch-blob'; -import {AnimatedCircularProgress} from 'react-native-circular-progress'; import {Client4} from '@mm-redux/client'; import mattermostBucket from 'app/mattermost_bucket'; import FileAttachmentImage from '@components/file_attachment_list/file_attachment_image'; import FileAttachmentIcon from '@components/file_attachment_list/file_attachment_icon'; -import {buildFileUploadData, encodeHeaderURIStringToUTF8} from '@utils/file'; +import ProgressBar from '@components/progress_bar'; +import {buildFileUploadData, encodeHeaderURIStringToUTF8, isImage} from '@utils/file'; import {emptyFunction} from '@utils/general'; import ImageCacheManager from '@utils/image_cache_manager'; import {changeOpacity} from '@utils/theme'; @@ -26,6 +26,7 @@ export default class UploadItem extends PureComponent { channelId: PropTypes.string.isRequired, file: PropTypes.object.isRequired, handleRemoveFile: PropTypes.func.isRequired, + onPress: PropTypes.func, retryFileUpload: PropTypes.func.isRequired, rootId: PropTypes.string, theme: PropTypes.object.isRequired, @@ -53,6 +54,10 @@ export default class UploadItem extends PureComponent { } } + handlePress = () => { + this.props.onPress(this.props.file); + } + handleRetryFileUpload = (file) => { if (!file.failed) { return; @@ -99,18 +104,8 @@ export default class UploadItem extends PureComponent { }; handleUploadProgress = (loaded, total) => { - this.setState({progress: Math.floor((loaded / total) * 100)}); - }; - - isImageType = () => { - const {file} = this.props; - - if (file.has_preview_image || file.mime_type === 'image/gif' || - (file.localPath && file.type && file.type.includes('image'))) { - return true; - } - - return false; + const progress = parseFloat((loaded / total).toFixed(1)); + this.setState({progress}); }; downloadAndUploadFile = async (file) => { @@ -161,18 +156,6 @@ export default class UploadItem extends PureComponent { this.uploadPromise.then(this.handleUploadCompleted).catch(this.handleUploadError); }; - renderProgress = (fill) => { - const realFill = Number(fill.toFixed(0)); - - return ( - - - {`${realFill}%`} - - - ); - }; - render() { const { channelId, @@ -183,26 +166,29 @@ export default class UploadItem extends PureComponent { const {progress} = this.state; let filePreviewComponent; - if (this.isImageType()) { + if (isImage(file)) { filePreviewComponent = ( - - - + + + + + ); } else { filePreviewComponent = ( - - - + + + + + ); } @@ -220,17 +206,11 @@ export default class UploadItem extends PureComponent { /> } {file.loading && !file.failed && - - - {this.renderProgress} - + + } @@ -256,19 +236,15 @@ const styles = StyleSheet.create({ width: 56, borderRadius: 4, }, - progressCircleContent: { + progress: { alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.7)', - height: 56, - width: 56, - justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.1)', + height: 53, + width: 53, + justifyContent: 'flex-end', position: 'absolute', borderRadius: 4, - }, - progressText: { - color: 'white', - fontSize: 9, - fontWeight: 'bold', + paddingLeft: 3, }, filePreview: { width: 56, diff --git a/app/components/post_draft/uploads/uploads.js b/app/components/post_draft/uploads/uploads.js index d4e8f6a9a..8033d8e79 100644 --- a/app/components/post_draft/uploads/uploads.js +++ b/app/components/post_draft/uploads/uploads.js @@ -20,6 +20,7 @@ import {MAX_FILE_COUNT, MAX_FILE_COUNT_WARNING, UPLOAD_FILES, PASTE_FILES} from import EventEmitter from '@mm-redux/utils/event_emitter'; import {getFormattedFileSize} from '@mm-redux/utils/file_utils'; import EphemeralStore from '@store/ephemeral_store'; +import {openGalleryAtIndex} from '@utils/images'; import {makeStyleSheetFromTheme} from '@utils/theme'; import UploadItem from './upload_item'; @@ -96,6 +97,7 @@ export default class Uploads extends PureComponent { key={file.clientId} channelId={this.props.channelId} file={file} + onPress={this.onPress} rootId={this.props.rootId} theme={this.props.theme} /> @@ -103,6 +105,12 @@ export default class Uploads extends PureComponent { }); }; + onPress = (file) => { + const {files} = this.props; + const index = files.indexOf(file); + openGalleryAtIndex(index, files.filter((f) => !f.failed && !f.loading)); + } + clearErrorsFromState = (delay) => { setTimeout(() => { this.setState({ diff --git a/app/components/progress_bar.tsx b/app/components/progress_bar.tsx new file mode 100644 index 000000000..911fc531c --- /dev/null +++ b/app/components/progress_bar.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {Animated, LayoutChangeEvent, StyleSheet, StyleProp, View, ViewStyle} from 'react-native'; + +type ProgressBarProps = { + color: string; + progress: number; + style?: StyleProp; +} + +const styles = StyleSheet.create({ + container: { + height: 4, + borderRadius: 2, + backgroundColor: 'rgba(255, 255, 255, 0.16)', + overflow: 'hidden', + width: '100%', + }, + progressBar: { + flex: 1, + }, +}); + +const ProgressBar = ({color, progress, style}: ProgressBarProps) => { + const timer = useRef(new Animated.Value(progress)).current; + const [width, setWidth] = useState(0); + + useEffect(() => { + const animation = Animated.timing(timer, { + duration: 200, + useNativeDriver: true, + isInteraction: false, + toValue: progress, + }); + + animation.start(); + + return animation.stop; + }, [progress]); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + setWidth(e.nativeEvent.layout.width); + }, []); + + const translateX = timer.interpolate({ + inputRange: [0, 1], + outputRange: [(-0.5 * width), 0], + }); + const scaleX = timer.interpolate({ + inputRange: [0, 1], + outputRange: [0.0001, 1], + }); + + return ( + + + + ); +}; + +export default ProgressBar; diff --git a/app/components/progressive_image/__snapshots__/progressive_image.test.js.snap b/app/components/progressive_image/__snapshots__/progressive_image.test.js.snap index 90687592a..f8c2b340b 100644 --- a/app/components/progressive_image/__snapshots__/progressive_image.test.js.snap +++ b/app/components/progressive_image/__snapshots__/progressive_image.test.js.snap @@ -48,6 +48,7 @@ exports[`ProgressiveImage should match snapshot for Default Image 1`] = ` } > {this.props.children} @@ -162,6 +165,7 @@ export default class ProgressiveImage extends PureComponent { if (showHighResImage) { image = ( { - this.state.opacity.stopAnimation(() => { - this.setState({isVisible: true}); - }); - }; - - fadeInControls = (loop = true) => { - this.setState({isVisible: true}); - Animated.timing(this.state.opacity, { - toValue: 1, - duration: 250, - delay: 0, - useNativeDriver: true, - }).start(() => { - if (loop) { - this.fadeOutControls(2000); - } - }); - }; - - fadeOutControls = (delay = 0) => { - Animated.timing(this.state.opacity, { - toValue: 0, - duration: 250, - delay, - useNativeDriver: true, - }).start((result) => { - if (result.finished) { - this.setState({isVisible: false}); - } - }); - }; - - getControlIconName = (playerState) => { - switch (playerState) { - case PLAYER_STATE.PLAYING: - return 'pause'; - case PLAYER_STATE.ENDED: - return 'refresh'; - } - - return 'play'; - } - - handleAppStateChange = (nextAppState) => { - if (nextAppState !== 'active' && this.props.playerState === PLAYER_STATE.PLAYING) { - this.onPause(); - } - }; - - humanizeVideoDuration = (seconds) => { - const [begin, end] = seconds >= 3600 ? [11, 8] : [14, 5]; - const date = new Date(null); - date.setSeconds(seconds); - return date.toISOString().substr(begin, end); - }; - - onPause = () => { - if (this.props.playerState === PLAYER_STATE.PLAYING) { - this.cancelAnimation(); - } - if (this.props.playerState === PLAYER_STATE.PAUSED) { - this.fadeOutControls(250); - } - this.props.onPaused(); - }; - - onReplay = () => { - this.fadeOutControls(500); - this.props.onReplay(); - }; - - renderControls() { - return ( - - - { - this.props.isLoading ? this.setLoadingView() : this.setPlayerControls(this.props.playerState) - } - - - - - - {this.humanizeVideoDuration(this.props.progress)} - - - {this.humanizeVideoDuration(this.props.duration)} - - - - - - - - - - ); - } - - seekVideo = (value) => { - this.setState({isSeeking: true}); - this.props.onSeek(value); - }; - - seekVideoEnd = (value) => { - this.setState({isSeeking: false}); - if (this.props.playerState === PLAYER_STATE.PLAYING) { - this.toggleControls(); - } - this.props.onSeek(value); - if (this.props.onSeeking) { - this.props.onSeeking(true); - } - }; - - seekVideoStart = () => { - this.setState({isSeeking: true}); - this.cancelAnimation(); - if (this.props.onSeeking) { - this.props.onSeeking(false); - } - }; - - setPlayerControls = (playerState) => { - const iconName = this.getControlIconName(playerState); - const pressAction = playerState === PLAYER_STATE.ENDED ? this.onReplay : this.onPause; - return ( - - - - ); - }; - - setLoadingView = () => { - return ( - - ); - }; - - toggleControls = () => { - this.state.opacity.stopAnimation( - (value) => { - this.setState({isVisible: Boolean(value)}); - if (value) { - this.fadeOutControls(); - } else { - this.fadeInControls(this.props.playerState === PLAYER_STATE.PLAYING); - } - }); - }; - - render() { - if (!this.state.isVisible) { - return null; - } - - return ( - - {this.renderControls()} - - ); - } -} - -const styles = StyleSheet.create({ - container: { - position: 'absolute', - flex: 1, - paddingHorizontal: 20, - paddingVertical: 13, - flexDirection: 'column', - alignItems: 'center', - backgroundColor: 'transparent', - justifyContent: 'space-between', - top: 0, - left: 0, - bottom: 0, - right: 0, - }, - controlsRow: { - alignItems: 'center', - justifyContent: 'center', - alignSelf: 'stretch', - }, - timeRow: { - alignSelf: 'stretch', - }, - progressContainer: { - position: 'absolute', - flexDirection: 'row', - justifyContent: 'flex-end', - bottom: 25, - marginLeft: 16, - }, - progressColumnContainer: { - flex: 1, - }, - controlButton: { - justifyContent: 'center', - alignItems: 'center', - }, - fullScreenContainer: { - alignSelf: 'stretch', - alignItems: 'center', - justifyContent: 'center', - paddingLeft: 10, - paddingTop: 8, - }, - progressSlider: { - alignSelf: 'stretch', - }, - timerLabelsContainer: { - alignSelf: 'stretch', - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: -7, - }, - timerLabel: { - fontSize: 12, - color: 'white', - }, - track: { - height: 5, - borderRadius: 1, - }, - thumb: { - width: 20, - height: 20, - borderRadius: 50, - backgroundColor: 'white', - borderWidth: 3, - }, -}); diff --git a/app/constants/attachment.js b/app/constants/attachment.js index 7f234bf52..80f945ae4 100644 --- a/app/constants/attachment.js +++ b/app/constants/attachment.js @@ -1,4 +1,5 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +export const ATTACHMENT_DOWNLOAD = 'attachment_download'; export const MAX_ATTACHMENT_FOOTER_LENGTH = 300; diff --git a/app/constants/index.js b/app/constants/index.js index cdd661b49..e06653d6e 100644 --- a/app/constants/index.js +++ b/app/constants/index.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import AttachmentTypes from './attachment'; import CustomPropTypes from './custom_prop_types'; import DeepLinkTypes from './deep_linking'; import DeviceTypes from './device'; @@ -11,6 +12,7 @@ import ViewTypes, {UpgradeTypes} from './view'; import WebsocketEvents from './websocket'; export { + AttachmentTypes, CustomPropTypes, DeepLinkTypes, DeviceTypes, diff --git a/app/mm-redux/types/files.ts b/app/mm-redux/types/files.ts index 38cf1a639..590641e16 100644 --- a/app/mm-redux/types/files.ts +++ b/app/mm-redux/types/files.ts @@ -6,6 +6,7 @@ import {Dictionary} from './utilities'; export type FileInfo = { id: string; user_id: string; + post_id: string; create_at: number; update_at: number; delete_at: number; @@ -17,6 +18,9 @@ export type FileInfo = { height: number; has_preview_image: boolean; clientId: string; + localPath?: string; + uri?: string; + loading?: boolean; }; export type FilesState = { files: Dictionary; diff --git a/app/mm-redux/types/posts.ts b/app/mm-redux/types/posts.ts index 36bd476f2..9ae5a4587 100644 --- a/app/mm-redux/types/posts.ts +++ b/app/mm-redux/types/posts.ts @@ -37,6 +37,8 @@ export type PostEmbed = { export type PostImage = { height: number; width: number; + format?: string; + frame_count?: number; }; export type PostMetadata = { diff --git a/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap index 92d4c5aa0..1c261dc5b 100644 --- a/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap +++ b/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap @@ -184,6 +184,7 @@ exports[`channel_info_header should match snapshot 1`] = ` } } disableAtChannelMentionHighlight={true} + disableGallery={true} onChannelLinkPress={[MockFunction]} onPermalinkPress={[MockFunction]} textStyles={ @@ -525,6 +526,7 @@ exports[`channel_info_header should match snapshot when DM and hasGuests and is } } disableAtChannelMentionHighlight={true} + disableGallery={true} onChannelLinkPress={[MockFunction]} onPermalinkPress={[MockFunction]} textStyles={ @@ -835,6 +837,7 @@ exports[`channel_info_header should match snapshot when DM and hasGuests but its } } disableAtChannelMentionHighlight={true} + disableGallery={true} onChannelLinkPress={[MockFunction]} onPermalinkPress={[MockFunction]} textStyles={ @@ -1176,6 +1179,7 @@ exports[`channel_info_header should match snapshot when GM and hasGuests 1`] = ` } } disableAtChannelMentionHighlight={true} + disableGallery={true} onChannelLinkPress={[MockFunction]} onPermalinkPress={[MockFunction]} textStyles={ @@ -1486,6 +1490,7 @@ exports[`channel_info_header should match snapshot when is group constrained 1`] } } disableAtChannelMentionHighlight={true} + disableGallery={true} onChannelLinkPress={[MockFunction]} onPermalinkPress={[MockFunction]} textStyles={ @@ -1849,6 +1854,7 @@ exports[`channel_info_header should match snapshot when public channel and hasGu } } disableAtChannelMentionHighlight={true} + disableGallery={true} onChannelLinkPress={[MockFunction]} onPermalinkPress={[MockFunction]} textStyles={ diff --git a/app/screens/channel_info/channel_info_header.js b/app/screens/channel_info/channel_info_header.js index 3c427f386..e49854009 100644 --- a/app/screens/channel_info/channel_info_header.js +++ b/app/screens/channel_info/channel_info_header.js @@ -211,6 +211,7 @@ export default class ChannelInfoHeader extends React.PureComponent { baseTextStyle={baseTextStyle} textStyles={textStyles} blockStyles={blockStyles} + disableGallery={true} value={header} onChannelLinkPress={popToRoot} disableAtChannelMentionHighlight={true} diff --git a/app/screens/expanded_announcement_banner/expanded_announcement_banner.js b/app/screens/expanded_announcement_banner/expanded_announcement_banner.js index 5bedc9ad1..f5524afab 100644 --- a/app/screens/expanded_announcement_banner/expanded_announcement_banner.js +++ b/app/screens/expanded_announcement_banner/expanded_announcement_banner.js @@ -73,6 +73,7 @@ export default class ExpandedAnnouncementBanner extends React.PureComponent { + + + +`; diff --git a/app/screens/gallery/animation_helper.ts b/app/screens/gallery/animation_helper.ts new file mode 100644 index 000000000..ef84aa3d2 --- /dev/null +++ b/app/screens/gallery/animation_helper.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import Animated, { + Clock, + Value, + abs, + and, + block, + clockRunning, + cond, + diff, + eq, + greaterThan, + lessThan, + multiply, + neq, + not, + or, + proc, + decay as reDecay, + set, + startClock, + stopClock, + sub, + useCode, +} from 'react-native-reanimated'; +import { + Vector, + panGestureHandler, + pinchActive, + pinchBegan, + pinchGestureHandler, + useValue, + vec, +} from 'react-native-redash'; +import {Platform} from 'react-native'; +import {State} from 'react-native-gesture-handler'; + +const pinchEnd = proc( + (state: Animated.Node, numberOfPointers: Animated.Node) => + (Platform.OS === 'android' ? + or(eq(state, State.END), lessThan(numberOfPointers, 2)) : + eq(state, State.END)), +); + +const decay = ( + position: Animated.Adaptable, + velocity: Animated.Adaptable, + clock: Animated.Clock, +) => { + const state = { + finished: new Value(0), + position: new Value(0), + time: new Value(0), + velocity: new Value(0), + }; + const config = {deceleration: 0.993}; + return block([ + cond(not(clockRunning(clock)), [ + set(state.finished, 0), + set(state.position, position), + set(state.velocity, velocity), + set(state.time, 0), + startClock(clock), + ]), + reDecay(clock, state, config), + state.position, + ]); +}; + +const decayVector = ( + position: Vector, + velocity: Vector, + clock: Vector, +) => { + const x = decay(position.x, velocity.x, clock.x); + const y = decay(position.y, velocity.y, clock.y); + return { + x, + y, + }; +}; + +interface UsePinchParams { + center: Vector; + pan: ReturnType; + pinch: ReturnType; + maxVec: Vector; + minVec: Vector; + maxImgVec: Vector; + minImgVec: Vector; + scale: Animated.Value; + translate: Vector>; + translationX: Animated.Value; + translationY: Animated.Value; +} + +export const usePinch = ({ + center, + maxVec, + minVec, + maxImgVec, + minImgVec, + pinch, + pan, + scale, + translate, + translationX, + translationY, +}: UsePinchParams) => { + const shouldDecay = useValue(0); + const clock = vec.create(new Clock(), new Clock()); + const offset = vec.createValue(0, 0); + const scaleOffset = new Value(1); + const origin = vec.createValue(0, 0); + const translation = vec.createValue(0, 0); + const adjustedFocal = vec.sub(pinch.focal, vec.add(center, offset)); + const clamped = vec.sub( + vec.clamp(vec.add(offset, pan.translation), minVec, maxVec), + offset, + ); + const isPinchBegan = pinchBegan(pinch.state); + const isPinchActive = pinchActive(pinch.state, pinch.numberOfPointers); + const isPinchEnd = pinchEnd(pinch.state, pinch.numberOfPointers); + useCode( + () => [ + cond( + and( + eq(pan.state, State.ACTIVE), + or(eq(pinch.state, State.UNDETERMINED), isPinchEnd), + ), [ + cond(and(eq(scaleOffset, 1), lessThan(abs(pan.translation.y), 80)), [ + set(translationX, sub(pan.translation.x, clamped.x)), + vec.set(translation, clamped), + ], set(translationX, 0)), + cond(and(eq(scaleOffset, 1), lessThan(abs(translationX), 10)), [ + set(translationX, 0), + set(translationY, pan.translation.y), + ]), + cond(greaterThan(scaleOffset, 1), [ + vec.set(translation, pan.translation), + ]), + ], + ), + cond(isPinchBegan, vec.set(origin, adjustedFocal)), + cond(isPinchActive, [ + vec.set( + translation, + vec.add( + vec.sub(adjustedFocal, origin), + origin, + vec.multiply(-1, pinch.scale, origin), + ), + ), + ]), + cond( + and( + or(eq(pinch.state, State.UNDETERMINED), isPinchEnd), + or(eq(pan.state, State.UNDETERMINED), eq(pan.state, State.END)), + ), + [ + cond(greaterThan(scale, 3), [ + set(scale, 3), + vec.set(translation, 0), + vec.set(pinch.focal, 0), + ]), + cond(lessThan(scale, 1), [set(scale, 1)]), + vec.set(offset, vec.add(offset, translation)), + set(scaleOffset, scale), + set(pinch.scale, 1), + vec.set(translation, 0), + vec.set(pinch.focal, 0), + ], + ), + cond(or(eq(pan.state, State.ACTIVE), isPinchActive), [ + stopClock(clock.x), + stopClock(clock.y), + set(shouldDecay, 0), + ]), + cond( + and( + neq(diff(pan.state), 0), + eq(pan.state, State.END), + not(isPinchActive), + ), + set(shouldDecay, 1), + ), + cond(shouldDecay, [ + vec.set( + offset, + vec.clamp(decayVector(offset, pan.velocity, clock), minImgVec, maxImgVec), + ), + ]), + set(scale, multiply(pinch.scale, scaleOffset)), + vec.set(translate, vec.add(translation, offset)), + ], + [minImgVec], + ); +}; diff --git a/app/screens/gallery/footer/index.tsx b/app/screens/gallery/footer/index.tsx new file mode 100644 index 000000000..40d4ba59e --- /dev/null +++ b/app/screens/gallery/footer/index.tsx @@ -0,0 +1,167 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {forwardRef, useEffect, useRef, useState, useImperativeHandle} from 'react'; +import {Animated, StyleSheet} from 'react-native'; +import {injectIntl} from 'react-intl'; +import Clipboard from '@react-native-community/clipboard'; + +import {ATTACHMENT_DOWNLOAD} from '@constants/attachment'; +import {Client4} from '@mm-redux/client'; +import EventEmitter from '@mm-redux/utils/event_emitter'; + +import type {CallbackFunctionWithoutArguments, PrepareFileRef, FooterProps, FooterRef, ShowToast, ToastRef} from 'types/screens/gallery'; + +import PrepareFile from './prepare_file'; +import Summary from './summary'; +import Toast from './toast'; + +const styles = StyleSheet.create({ + footer: { + position: 'absolute', + width: '100%', + bottom: 0, + }, +}); + +const Footer = forwardRef((props: FooterProps, ref) => { + const [visible, setVisible] = useState(true); + const [downloading, setDownloading] = useState(false); + const opacity = useRef(new Animated.Value(1)).current; + const downloadingOpacitity = useRef(new Animated.Value(0)).current; + const prepareRef = useRef(null); + const toastRef = useRef(); + + const animate = (value: Animated.Value, show: boolean, callback?: () => void): Animated.CompositeAnimation => { + const animation = Animated.timing(value, { + toValue: show ? 1 : 0, + duration: 250, + useNativeDriver: true, + }); + + animation.start(callback); + + return animation; + }; + + const copyPublicLink = async (callback: CallbackFunctionWithoutArguments) => { + try { + const {formatMessage} = props.intl; + const message = formatMessage({id: 'mobile.public_link.copied', defaultMessage: 'Public link copied'}); + const res = await Client4.getFilePublicLink(props.file.id); + Clipboard.setString(res.link); + showToast(message, undefined, callback); + } catch (e) { + // eslint-disable-next-line no-console + console.log('An error occurred, we should show a different toast', e); + callback(); + } + }; + + const dowloadFile = (callback: CallbackFunctionWithoutArguments) => { + setDownloading(true); + callback(); + }; + + const isVisible = () => visible; + + const showToast: ShowToast = (text, duration, callback) => { + toastRef.current?.show(text, duration, callback); + }; + + const openOrShare = (share: boolean, callback: (path?: string) => void) => { + animate(opacity, false, () => { + animate(downloadingOpacitity, true, async () => { + const path = await prepareRef.current?.start(props.file, share); + animate(opacity, true); + callback(path); + }); + }); + }; + + const startDownload = async (): Promise => { + let path; + if (prepareRef.current) { + path = await prepareRef.current.start(props.file); + } + setDownloading(false); + + return path; + }; + + const toggle = () => { + if (!downloading) { + setVisible(!visible); + return !visible; + } + + return false; + }; + + useEffect(() => { + const animation = animate(opacity, visible); + + return animation.stop; + }, [visible]); + + useEffect(() => { + let animation: Animated.CompositeAnimation; + if (downloading) { + animate(opacity, false, () => { + animation = animate(downloadingOpacitity, true, startDownload); + }); + } else { + animate(downloadingOpacitity, false, () => { + animation = animate(opacity, true); + }); + } + + return () => animation?.stop(); + }, [downloading]); + + useEffect(() => { + EventEmitter.on(ATTACHMENT_DOWNLOAD, openOrShare); + + return () => { + EventEmitter.off(ATTACHMENT_DOWNLOAD, openOrShare); + }; + }); + + useImperativeHandle(ref, () => ({ + isVisible, + toggle, + }), [visible]); + + const translateY = opacity.interpolate({ + inputRange: [0, 1], + outputRange: [99, 0], + }); + + const downloadingY = downloadingOpacitity.interpolate({ + inputRange: [0, 1], + outputRange: [99, 0], + }); + + return ( + <> + + + + + + + + + ); +}); + +Footer.displayName = 'Footer'; + +export default injectIntl(Footer, {withRef: true}); diff --git a/app/screens/gallery/footer/prepare_file/index.ts b/app/screens/gallery/footer/prepare_file/index.ts new file mode 100644 index 000000000..0ef0d5c1c --- /dev/null +++ b/app/screens/gallery/footer/prepare_file/index.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {isLandscape} from '@selectors/device'; + +import type {GlobalState} from '@mm-redux/types/store'; + +import PrepareFile from './prepare_file'; + +function mapStateToProps(state: GlobalState) { + return { + isLandscape: isLandscape(state), + theme: getTheme(state), + }; +} + +export default connect(mapStateToProps, null, null, {forwardRef: true})(PrepareFile); diff --git a/app/screens/gallery/footer/prepare_file/prepare_file.tsx b/app/screens/gallery/footer/prepare_file/prepare_file.tsx new file mode 100644 index 000000000..41b64abc3 --- /dev/null +++ b/app/screens/gallery/footer/prepare_file/prepare_file.tsx @@ -0,0 +1,188 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {forwardRef, useEffect, useRef, useState, useImperativeHandle} from 'react'; +import {intlShape} from 'react-intl'; +import {Alert, Platform, StyleSheet, Text, View, ViewStyle} from 'react-native'; +import RNFetchBlob, {FetchBlobResponse, RNFetchBlobConfig, StatefulPromise} from 'rn-fetch-blob'; +import Share from 'react-native-share'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import ProgressBar from '@components/progress_bar'; +import {paddingHorizontal} from '@components/safe_area_view/iphone_x_spacing'; +import {Client4} from '@mm-redux/client'; +import {getLocalPath} from '@utils/file'; +import mattermostBucket from 'app/mattermost_bucket'; + +import type {FileInfo} from '@mm-redux/types/files'; +import {Theme} from '@mm-redux/types/preferences'; +import type {PrepareFileRef} from 'types/screens/gallery'; + +type PrepareFileProps = { + intl: typeof intlShape; + isLandscape: boolean; + theme: Theme, +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#000000', + flexDirection: 'row', + height: Platform.select({ios: 99, android: 85}), + paddingHorizontal: 12, + paddingTop: 20, + }, + containerLandscape: { + height: 64, + }, + saving: { + color: '#FFFFFF', + fontFamily: 'Open Sans', + fontSize: 16, + fontWeight: '600', + lineHeight: 20, + }, +}); + +const PrepareFile = forwardRef(({intl, isLandscape, theme}: PrepareFileProps, ref) => { + const containerStyles: Array = [styles.container]; + let downloadTask = useRef>().current; + const [progress, setProgress] = useState(0); + const [visible, setVisible] = useState(false); + const start = async (file: FileInfo, share = true): Promise => { + const localPath = getLocalPath(file); + let uri; + let certificate; + + if (file.id.startsWith('uid') && file.uri) { + uri = file.uri; + } else { + uri = Client4.getFileUrl(file.id, Date.now()); + certificate = await mattermostBucket.getPreference('cert'); + } + + const options: RNFetchBlobConfig = { + session: file.id, + appendExt: file.extension, + timeout: 10000, + indicator: true, + overwrite: true, + path: localPath, + certificate, + }; + + let path; + try { + const prefix = Platform.OS === 'android' ? 'file:/' : ''; + const exist = await RNFetchBlob.fs.exists(`${prefix}${localPath}`); + if (exist) { + path = localPath; + } else { + setVisible(true); + downloadTask = RNFetchBlob.config(options).fetch('GET', uri); + downloadTask.progress((received: number, total: number) => { + setProgress(parseFloat((received / total).toFixed(1))); + }); + const response = await downloadTask; + path = response.path(); + file.localPath = path; + } + } catch (e) { + if (downloadTask) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.prepare_file.failed_title', + defaultMessage: 'Preparing failed', + }), + intl.formatMessage({ + id: 'mobile.prepare_file.failed_description', + defaultMessage: 'An error occurred while preparing the file. Please try again.\n', + }), + [{ + text: intl.formatMessage({ + id: 'mobile.server_upgrade.button', + defaultMessage: 'OK', + }), + }], + ); + + if (path) { + RNFetchBlob.fs.unlink(path); + file.localPath = undefined; + } + path = undefined; + } + } finally { + setVisible(false); + downloadTask = undefined; + } + + if (path && share) { + Share.open({ + url: `file://${path}`, + showAppsToView: true, + }).catch(() => { + // do nothing + }); + } + + return path; + }; + + useEffect(() => { + return () => { + if (downloadTask) { + downloadTask.cancel(); + setVisible(false); + } + }; + }, []); + + useImperativeHandle(ref, () => ({ + start, + })); + + if (isLandscape) { + containerStyles.push(styles.containerLandscape); + } + + if (!visible) { + return null; + } + + let label = {`${progress * 100}%`}; + if (progress >= 1) { + label = ( + + ); + } + + return ( + + + + + + + {label} + + + ); +}); + +PrepareFile.displayName = 'PrepareFile'; + +export default PrepareFile; diff --git a/app/screens/gallery/footer/summary/actions/action.tsx b/app/screens/gallery/footer/summary/actions/action.tsx new file mode 100644 index 000000000..06cf7248a --- /dev/null +++ b/app/screens/gallery/footer/summary/actions/action.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState} from 'react'; +import {Platform, Pressable, PressableStateCallbackType} from 'react-native'; + +import type {ActionProps} from 'types/screens/gallery'; + +const pressedStyle = ({pressed}: PressableStateCallbackType) => { + let opacity = 1; + if (Platform.OS === 'ios' && pressed) { + opacity = 0.5; + } + + return [{opacity}]; +}; + +const androidRippleConfig = {borderless: false, radius: 10}; + +const Action = ({action, children, visible, style}: ActionProps) => { + const [disabled, setDisabled] = useState(false); + + const onPress = useCallback(async () => { + setDisabled(true); + action(() => { + setDisabled(false); + }); + }, []); + + if (!visible) { + return null; + } + + return ( + [ + pressedStyle(pressed), + style, + ]} + > + {children} + + ); +}; + +export default Action; diff --git a/app/screens/gallery/footer/summary/actions/actions.tsx b/app/screens/gallery/footer/summary/actions/actions.tsx new file mode 100644 index 000000000..22f3872b6 --- /dev/null +++ b/app/screens/gallery/footer/summary/actions/actions.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import type {ActionsProps, ManagedConfig} from 'types/screens/gallery'; + +import mattermostManaged from 'app/mattermost_managed'; + +import Action from './action'; + +const styles = StyleSheet.create({ + contaier: { + flexDirection: 'row', + marginRight: 8, + }, + download: { + marginLeft: 24, + }, +}); + +const Actions = (props: ActionsProps) => { + const [managedConfig, setManagedConfig] = useState(null); + + const configChanged = (config: ManagedConfig) => { + setManagedConfig(config); + }; + + useEffect(() => { + mattermostManaged.getConfig().then(configChanged); + }, []); + + useEffect(() => { + const listener = mattermostManaged.addEventListener('managedConfigDidChange', configChanged); + + return () => { + mattermostManaged.removeEventListener(listener); + }; + }, []); + + let linkActionVisible = !props.file.id.startsWith('uid'); + if (managedConfig?.copyPasteProtection === 'true') { + linkActionVisible = false; + } + + return ( + + + + + + + + + ); +}; + +export default Actions; diff --git a/app/screens/gallery/footer/summary/actions/index.ts b/app/screens/gallery/footer/summary/actions/index.ts new file mode 100644 index 000000000..d48e33985 --- /dev/null +++ b/app/screens/gallery/footer/summary/actions/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {canDownloadFilesOnMobile} from '@mm-redux/selectors/entities/general'; + +import type {GlobalState} from '@mm-redux/types/store'; + +import Actions from './actions'; + +function mapStateToProps(state: GlobalState) { + return { + canDownloadFiles: canDownloadFilesOnMobile(state), + }; +} + +export default connect(mapStateToProps)(Actions); diff --git a/app/screens/gallery/footer/summary/avatar.tsx b/app/screens/gallery/footer/summary/avatar.tsx new file mode 100644 index 000000000..bc32a611b --- /dev/null +++ b/app/screens/gallery/footer/summary/avatar.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import FastImage from 'react-native-fast-image'; + +import CompassIcon from '@components/compass_icon'; +import {changeOpacity} from '@mm-redux/utils/theme_utils'; +import type {AvatarProps} from 'types/screens/gallery'; + +const styles = StyleSheet.create({ + avatarContainer: { + borderWidth: 1, + borderColor: 'rgba(61, 60, 64, 0.08)', + }, + avatar: { + height: 32, + width: 32, + }, + avatarRadius: { + borderRadius: 16, + }, +}); + +const Avatar = ({avatarUri, theme}: AvatarProps) => { + let element; + if (avatarUri) { + element = ( + + ); + } else { + element = ( + + ); + } + + return ( + + {element} + + ); +}; + +export default Avatar; diff --git a/app/screens/gallery/footer/summary/details.tsx b/app/screens/gallery/footer/summary/details.tsx new file mode 100644 index 000000000..1d599240a --- /dev/null +++ b/app/screens/gallery/footer/summary/details.tsx @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; + +import type {DetailsProps} from 'types/screens/gallery'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'column', + marginHorizontal: 12, + }, + chanelText: { + color: '#FFFFFF', + fontFamily: 'Open Sans', + fontSize: 12, + lineHeight: 12, + marginTop: 3, + opacity: 0.56, + }, + userText: { + color: '#FFFFFF', + fontFamily: 'Open Sans', + fontSize: 16, + fontWeight: '600', + lineHeight: 20, + }, +}); + +const Details = ({channel, isDirect, ownPost, user}: DetailsProps) => { + const prefix = isDirect ? '@' : '~'; + let userElement = ( + + {user} + + ); + + if (ownPost) { + userElement = ( + + ); + } else if (!user) { + userElement = ( + + ); + } + + return ( + + {userElement} + + + ); +}; + +export default Details; diff --git a/app/screens/gallery/footer/summary/index.ts b/app/screens/gallery/footer/summary/index.ts new file mode 100644 index 000000000..97d74bb93 --- /dev/null +++ b/app/screens/gallery/footer/summary/index.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {Client4} from '@mm-redux/client'; +import {General} from '@mm-redux/constants'; +import {getChannel, getCurrentChannel} from '@mm-redux/selectors/entities/channels'; +import {getConfig} from '@mm-redux/selectors/entities/general'; +import {getPost} from '@mm-redux/selectors/entities/posts'; +import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; +import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences'; +import {isFromWebhook} from '@mm-redux/utils/post_utils'; +import {displayUsername} from '@mm-redux/utils/user_utils'; +import {isLandscape} from '@selectors/device'; + +import type {GlobalState} from '@mm-redux/types/store'; +import type {FooterProps} from 'types/screens/gallery'; + +import Summary from './summary'; + +function mapStateToProps(state: GlobalState, ownProps: FooterProps) { + const config = getConfig(state); + const currentUserId = getCurrentUserId(state); + const post = getPost(state, ownProps.file.post_id); + const user = getUser(state, post?.user_id || ownProps.file.user_id); + const channel = post ? getChannel(state, post.channel_id) : getCurrentChannel(state); + const ownPost = user?.id === currentUserId; + const teammateNameDisplay = getTeammateNameDisplaySetting(state); + const channelName = channel.display_name; + const isDirectChannel = [General.DM_CHANNEL, General.GM_CHANNEL].includes(channel.type); + let avatarUri = Client4.getProfilePictureUrl(user.id, user.last_picture_update); + let displayName = displayUsername(user, teammateNameDisplay || ''); + + if (post) { + if (isFromWebhook(post) && post.props?.override_username && config.EnablePostUsernameOverride === 'true') { + displayName = post.props.override_username; + } + + if (config.EnablePostIconOverride === 'true' && post?.props?.use_user_icon !== 'true' && post?.props?.override_icon_url) { + avatarUri = Client4.getAbsoluteUrl(post.props.override_icon_url); + } + } + + return { + avatarUri, + channelName, + isDirectChannel, + displayName, + isLandscape: isLandscape(state), + ownPost, + theme: getTheme(state), + }; +} + +export default connect(mapStateToProps)(Summary); diff --git a/app/screens/gallery/footer/summary/summary.tsx b/app/screens/gallery/footer/summary/summary.tsx new file mode 100644 index 000000000..f25230dd1 --- /dev/null +++ b/app/screens/gallery/footer/summary/summary.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Platform, StyleSheet, View, ViewStyle} from 'react-native'; + +import {paddingHorizontal} from '@components/safe_area_view/iphone_x_spacing'; + +import type {SummaryProps} from 'types/screens/gallery'; + +import Actions from './actions'; +import Avatar from './avatar'; +import Details from './details'; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#000', + flexDirection: 'row', + height: Platform.select({ios: 99, android: 85}), + padding: 12, + }, + containerLandscape: { + height: 64, + }, + details: { + flex: 3, + flexDirection: 'row', + justifyContent: 'flex-start', + }, + actions: { + flex: 1, + flexDirection: 'row', + justifyContent: 'flex-end', + paddingTop: 5, + }, +}); + +const Summary = (props: SummaryProps) => { + const containerStyles: Array = [styles.container]; + + if (props.isLandscape) { + containerStyles.push(styles.containerLandscape); + } + + return ( + + + +
+ + + + + + ); +}; + +export default Summary; diff --git a/app/screens/gallery/footer/toast/index.ts b/app/screens/gallery/footer/toast/index.ts new file mode 100644 index 000000000..c31d8b63f --- /dev/null +++ b/app/screens/gallery/footer/toast/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from '@mm-redux/selectors/entities/preferences'; + +import type {GlobalState} from '@mm-redux/types/store'; + +import Toast from './toast'; + +function mapStateToProps(state: GlobalState) { + return { + theme: getTheme(state), + }; +} + +export default connect(mapStateToProps, null, null, {forwardRef: true})(Toast); diff --git a/app/screens/gallery/footer/toast/toast.tsx b/app/screens/gallery/footer/toast/toast.tsx new file mode 100644 index 000000000..0abe68d76 --- /dev/null +++ b/app/screens/gallery/footer/toast/toast.tsx @@ -0,0 +1,124 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useImperativeHandle, useState, useRef, forwardRef} from 'react'; +import {Animated, StyleSheet, Text, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import type {ToastProps, ToastRef, ToastState, ShowToast} from 'types/screens/gallery'; + +const DEFAULT_DURATION = 1000; +const FADE_DURATION = 400; +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + elevation: 999, + top: 10, + zIndex: 1000, + }, + toast: { + borderRadius: 4, + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 10, + }, + text: { + flex: 1, + fontFamily: 'Open Sans', + fontSize: 14, + fontWeight: '600', + lineHeight: 20, + marginLeft: 7, + }, +}); + +const Toast = forwardRef(({theme}: ToastProps, ref) => { + const opacity = useRef(new Animated.Value(0)).current; + const [visible, setVisible] = useState(false); + const [state, setState] = useState({}); + const [text, setText] = useState(); + + useEffect(() => { + const unmount = () => { + if (state.animation) { + state.animation.stop(); + } + }; + + return unmount; + }, []); + + useEffect(() => { + if (visible) { + state.animation = Animated.timing(opacity, { + toValue: 1, + duration: state.duration, + useNativeDriver: true, + }); + setState(state); + + state.animation.start(close); + } + }, [visible]); + + const close = () => { + if (!visible) { + return; + } + + state.animation = Animated.timing(opacity, { + toValue: 0, + delay: DEFAULT_DURATION, + duration: state.duration, + useNativeDriver: true, + }); + setState(state); + + state.animation.start(() => { + setVisible(false); + setText(undefined); + if (typeof state.callback === 'function') { + state.callback(); + } + }); + }; + + const show: ShowToast = (txt, dur, cb) => { + state.callback = cb; + state.duration = dur || FADE_DURATION; + setState(state); + setVisible(true); + setText(txt); + }; + + useImperativeHandle(ref, () => ({ + show, + }), [visible]); + + if (!visible) { + return null; + } + + return ( + + + + + {text} + + + + ); +}); + +Toast.displayName = 'Toast'; + +export default Toast; diff --git a/app/screens/gallery/gallery.js b/app/screens/gallery/gallery.js new file mode 100644 index 000000000..4b35ca844 --- /dev/null +++ b/app/screens/gallery/gallery.js @@ -0,0 +1,174 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {Platform, StatusBar} from 'react-native'; +import {intlShape} from 'react-intl'; + +import {mergeNavigationOptions, popTopScreen} from '@actions/navigation'; +import CompassIcon from '@components/compass_icon'; +import {isImage} from '@utils/file'; + +import Footer from './footer'; +import GalleryViewer from './gallery_viewer'; + +export default class Gallery extends PureComponent { + static propTypes = { + componentId: PropTypes.string.isRequired, + deviceHeight: PropTypes.number.isRequired, + deviceWidth: PropTypes.number.isRequired, + files: PropTypes.array, + index: PropTypes.number.isRequired, + theme: PropTypes.object.isRequired, + }; + + static defaultProps = { + files: [], + }; + + static contextTypes = { + intl: intlShape, + }; + + constructor(props) { + super(props); + + this.state = { + index: props.index, + footerVisible: true, + }; + + this.footer = React.createRef(); + } + + componentDidMount() { + this.cancelTopBar = setTimeout(() => { + this.initHeader(); + }, Platform.OS === 'ios' ? 250 : 0); + } + + componentWillUnmount() { + StatusBar.setHidden(false, 'fade'); + if (this.cancelTopBar) { + clearTimeout(this.cancelTopBar); + } + } + + initHeader = async (idx = this.state.index) => { + const {formatMessage} = this.context.intl; + const {files} = this.props; + const index = idx; + const closeButton = await CompassIcon.getImageSource('close', 24, '#ffffff'); + const sharedElementTransitions = []; + const file = files[index]; + + if (isImage(file) && index < 4) { + sharedElementTransitions.push({ + fromId: `gallery-${file.id}`, + toId: `image-${file.id}`, + interpolation: 'accelerateDecelerate', + }); + } + + let title; + if (files.length > 1) { + title = formatMessage({id: 'mobile.gallery.title', defaultMessage: '{index} of {total}'}, { + index: index + 1, + total: files.length, + }); + } + const options = { + layout: { + backgroundColor: '#000', + componentBackgroundColor: '#000', + }, + topBar: { + visible: this.footer.current?.getWrappedInstance().isVisible(), + background: { + color: '#000', + }, + title: { + text: title, + }, + backButton: { + visible: true, + icon: closeButton, + }, + }, + animations: { + pop: { + sharedElementTransitions, + }, + }, + }; + + mergeNavigationOptions(this.props.componentId, options); + } + + close = () => { + const {componentId} = this.props; + const color = Platform.select({android: 'transparent', ios: '#000'}); + const options = { + layout: { + backgroundColor: color, + componentBackgroundColor: color, + }, + topBar: { + visible: true, + }, + }; + + mergeNavigationOptions(componentId, options); + StatusBar.setHidden(false, 'fade'); + popTopScreen(componentId); + }; + + handlePageSelected = (index) => { + this.setState({index}, this.initHeader); + }; + + handleTapped = () => { + const visible = this.footer.current?.getWrappedInstance()?.toggle(); + const options = { + topBar: { + background: { + color: '#000', + }, + visible, + }, + }; + if (Platform.OS === 'ios') { + StatusBar.setHidden(!visible, 'slide'); + } + this.setState({footerVisible: visible}); + mergeNavigationOptions(this.props.componentId, options); + }; + + render() { + const {deviceHeight, deviceWidth, files, theme} = this.props; + const {index, footerVisible} = this.state; + + return ( + <> + deviceHeight} + onClose={this.close} + onPageSelected={this.handlePageSelected} + onTap={this.handleTapped} + theme={theme} + /> +