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 106fc3a68..2a2743fa9 100644 Binary files a/android/app/src/main/assets/fonts/compass-icons.ttf and b/android/app/src/main/assets/fonts/compass-icons.ttf differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 7dcc846ad..aae649f08 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,5 @@ #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} + /> +