Gallery Improvements (#4837)

* Open/Close Gallery transitions

* Include postId in file info & disable opening the gallery in some cases

* Add progress bar component

* Transition to gallery based on platform

* Improve getting the filename for non attachments

* Patch RNFetchBlob TS definition

* Add gallery types

* fix unit tests for message and post attachment

* add getLocalPath method

* Add react-native-share dependency

* Re-style gallery footer

* Refactor Gallery screen

* Double tap zoom in/out and translate

* Make android activity "transparent"

* Do not animate to height on dismissing gallery

* Open gallery for file uploads

* Fix borderRadius for gallery action button

* Use progress bar for file uploads

* Replace progress bar for file attachment document

* Upgrade RNN to 7.1.0 to fix share elements transitions

* Fix Gallery unit tests

* translate down when popping screen on iOS

* Swipe, Pan & Tap fixes

* fix gallery footer avatar eslint

* Fix gallery snapshot tests

* Use CompassIcon in Gallery

* Feedback from UX review

* Fix gallery UI for other file types

* Set other file type gallery button to 48pt
This commit is contained in:
Elias Nahum 2020-11-06 21:17:27 -03:00 committed by GitHub
parent b05b797b06
commit dbd7bc8a51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
105 changed files with 4077 additions and 4037 deletions

View file

@ -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.

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="white">#FFFFFF</color>
<color name="transparent">#00000000</color>
</resources>

View file

@ -3,6 +3,8 @@
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowBackground">@android:color/transparent</item>
</style>
</resources>

View file

@ -1,76 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FileAttachment should match snapshot 1`] = `
<View
<TouchableWithFeedbackIOS
onPress={[Function]}
style={
Array [
Object {
"borderColor": "rgba(61,60,64,0.4)",
"borderRadius": 5,
"borderWidth": 1,
"flex": 1,
"flexDirection": "row",
"marginTop": 10,
},
]
Object {
"width": undefined,
}
}
type="opacity"
>
<View
style={
<FileAttachmentImage
file={
Object {
"marginBottom": 8.2,
"marginLeft": 8,
"marginRight": 6,
"marginTop": 7.8,
"create_at": 1546893090093,
"data": Object {
"mime_type": "image/png",
},
"delete_at": 0,
"extension": "png",
"has_preview_image": true,
"height": 171,
"id": "fileId",
"name": "image.png",
"post_id": "postId",
"size": 14894,
"update_at": 1546893090093,
"user_id": "userId",
"width": 425,
}
}
>
<TouchableWithFeedbackIOS
onPress={[Function]}
type="opacity"
>
<FileAttachmentIcon
defaultImage={false}
failed={false}
file={
Object {
"mime_type": "image/png",
}
}
iconSize={48}
onCaptureRef={[Function]}
smallImage={false}
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",
}
}
/>
</TouchableWithFeedbackIOS>
</View>
</View>
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",
}
}
/>
</TouchableWithFeedbackIOS>
`;

View file

@ -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 {

View file

@ -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 (
<TouchableWithFeedback
onPress={this.handlePreviewPress}
onPress={this.handlePress}
onLongPress={onLongPress}
type={'opacity'}
style={style.attachmentContainer}
@ -81,7 +85,7 @@ export default class FileAttachment extends PureComponent {
ellipsizeMode='tail'
style={style.fileName}
>
{file.caption.trim()}
{file.name.trim()}
</Text>
<View style={style.fileDownloadContainer}>
<Text
@ -89,7 +93,7 @@ export default class FileAttachment extends PureComponent {
ellipsizeMode='tail'
style={style.fileInfo}
>
{`${Utils.getFormattedFileSize(data)}`}
{`${Utils.getFormattedFileSize(file)}`}
</Text>
</View>
</React.Fragment>
@ -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 = (
<TouchableWithFeedback
@ -162,17 +165,17 @@ export default class FileAttachment extends PureComponent {
style={{width: imageDimensions?.width}}
>
<FileAttachmentImage
file={data || {}}
onCaptureRef={this.handleCaptureRef}
theme={theme}
isSingleImage={isSingleImage}
imageDimensions={imageDimensions}
file={file || {}}
inViewPort={inViewPort}
imageDimensions={imageDimensions}
isSingleImage={isSingleImage}
resizeMode={this.state.resizeMode}
theme={theme}
/>
{this.renderMoreImagesOverlay(nonVisibleImagesCount, theme)}
</TouchableWithFeedback>
);
} else if (isDocument(data)) {
} else if (isDocument(file)) {
fileAttachmentComponent = (
<View style={[style.fileWrapper]}>
<View style={style.iconWrapper}>
@ -197,8 +200,7 @@ export default class FileAttachment extends PureComponent {
type={'opacity'}
>
<FileAttachmentIcon
file={data}
onCaptureRef={this.handleCaptureRef}
file={file}
theme={theme}
/>
</TouchableWithFeedback>

View file

@ -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 (
<FileAttachmentIcon
backgroundColor={backgroundColor}
file={file.data}
file={file}
theme={theme}
/>
);
}
renderDownloadProgres = () => {
const {theme} = this.props;
return (
<Text style={{fontSize: 10, color: theme.centerChannelColor, fontWeight: '600'}}>
{`${this.state.progress}%`}
</Text>
);
};
render() {
const {onLongPress, theme} = this.props;
const {downloading, progress} = this.state;
let fileAttachmentComponent;
if (downloading) {
fileAttachmentComponent = (
<View style={[style.circularProgressContent]}>
<CircularProgress
size={40}
fill={progress}
width={circularProgressWidth}
backgroundColor={changeOpacity(theme.centerChannelColor, 0.5)}
tintColor={theme.linkColor}
rotation={0}
>
{this.renderDownloadProgres}
</CircularProgress>
</View>
<>
{this.renderFileAttachmentIcon()}
<View style={[StyleSheet.absoluteFill, styles.progress]}>
<ProgressBar
progress={progress || 0.1}
color={theme.buttonBg}
/>
</View>
</>
);
} 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,
},
});

View file

@ -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);

View file

@ -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 (
<View
ref={this.handleCaptureRef}
style={[styles.fileIconWrapper, {backgroundColor: bgColor}]}
>
<View style={[styles.fileIconWrapper, {backgroundColor: bgColor}]}>
<CompassIcon
name={iconName}
size={iconSize}
@ -101,7 +85,6 @@ export default class FileAttachmentIcon extends PureComponent {
const styles = StyleSheet.create({
fileIconWrapper: {
flex: 1,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',

View file

@ -33,7 +33,6 @@ export default class FileAttachmentImage extends PureComponent {
IMAGE_SIZE.Thumbnail,
]),
imageWidth: PropTypes.number,
onCaptureRef: PropTypes.func,
theme: PropTypes.object,
resizeMode: PropTypes.string,
resizeMethod: PropTypes.string,
@ -59,14 +58,6 @@ export default class FileAttachmentImage extends PureComponent {
return (<View style={style.boxPlaceholder}/>);
};
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 (
<View
ref={this.handleCaptureRef}
style={[
wrapperStyle,
style.smallImageBorder,
@ -116,6 +106,7 @@ export default class FileAttachmentImage extends PureComponent {
{this.boxPlaceholder()}
<View style={style.smallImageOverlay}>
<ProgressiveImage
id={file.id}
style={{height: file.height, width: file.width}}
tintDefaultSource={!file.localPath && !this.state.failed}
filename={file.name}
@ -158,11 +149,11 @@ export default class FileAttachmentImage extends PureComponent {
return (
<View
ref={this.handleCaptureRef}
style={style.fileImageWrapper}
>
{this.boxPlaceholder()}
<ProgressiveImage
id={file.id}
style={[this.props.isSingleImage ? null : style.imagePreview, imageDimensions]}
tintDefaultSource={!file.localPath && !this.state.failed}
filename={file.name}

View file

@ -7,8 +7,8 @@ import {StyleSheet, View, DeviceEventEmitter} from 'react-native';
import ImageViewPort from '@components/image_viewport';
import {Client4} from '@mm-redux/client';
import {isDocument, isGif, isVideo} from '@utils/file';
import {getViewPortWidth, previewImageAtIndex} from '@utils/images';
import {isDocument, isGif, isImage, isVideo} from '@utils/file';
import {getViewPortWidth, openGalleryAtIndex} from '@utils/images';
import {preventDoubleTap} from '@utils/tap';
import FileAttachment from './file_attachment';
@ -33,10 +33,11 @@ export default class FileAttachmentList extends ImageViewPort {
constructor(props) {
super(props);
this.state = {
inViewPort: false,
};
this.items = [];
this.filesForGallery = this.getFilesForGallery(props);
this.buildGalleryFiles().then((results) => {
@ -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 {
<FileAttachment
key={file.id}
canDownloadFiles={canDownloadFiles}
file={f}
file={file}
id={file.id}
index={this.attachmentIndex(file.id)}
onCaptureRef={this.handleCaptureRef}
onPreviewPress={this.handlePreviewPress}
onLongPress={onLongPress}
theme={theme}

View file

@ -53,6 +53,7 @@ export default class Markdown extends PureComponent {
onHashtagPress: PropTypes.func,
onPermalinkPress: PropTypes.func,
onPostPress: PropTypes.func,
postId: PropTypes.string,
textStyles: PropTypes.object,
theme: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
@ -60,6 +61,7 @@ export default class Markdown extends PureComponent {
disableAtMentions: PropTypes.bool,
disableChannelLink: PropTypes.bool,
disableAtChannelMentionHighlight: PropTypes.bool,
disableGallery: PropTypes.bool,
};
static defaultProps = {
@ -70,6 +72,7 @@ export default class Markdown extends PureComponent {
disableAtMentions: false,
disableChannelLink: false,
disableAtChannelMentionHighlight: false,
disableGallery: false,
};
constructor(props) {
@ -179,9 +182,10 @@ export default class Markdown extends PureComponent {
// We have enough problems rendering images as is, so just render a link inside of a table
return (
<MarkdownTableImage
disable={this.props.disableGallery}
imagesMetadata={this.props.imagesMetadata}
postId={this.props.postId}
source={src}
textStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.link]}
>
{reactChildren}
</MarkdownTableImage>
@ -190,11 +194,13 @@ export default class Markdown extends PureComponent {
return (
<MarkdownImage
disable={this.props.disableGallery}
errorTextStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.error]}
linkDestination={linkDestination}
imagesMetadata={this.props.imagesMetadata}
isReplyPost={this.props.isReplyPost}
postId={this.props.postId}
source={src}
errorTextStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.error]}
>
{reactChildren}
</MarkdownImage>

View file

@ -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 = (
<CompassIcon
name='jumbo-attachment-image-broken'
size={24}
/>
);
} 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}}
>
<ProgressiveImage
ref={this.setImageRef}
id={fileInfo.id}
defaultSource={source}
resizeMode='contain'
style={{width, height}}
@ -221,13 +224,6 @@ export default class MarkdownImage extends ImageViewPort {
</TouchableWithFeedback>
);
}
} else if (this.state.failed) {
image = (
<CompassIcon
name='jumbo-attachment-image-broken'
size={24}
/>
);
}
if (image && this.props.linkDestination) {
@ -242,10 +238,7 @@ export default class MarkdownImage extends ImageViewPort {
}
return (
<View
ref={this.setItemRef}
style={style.container}
>
<View style={style.container}>
{image}
</View>
);

View file

@ -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 (
<View style={cellStyle}>
<Text style={textStyle}>
{this.props.children}
</Text>
<View style={[cellStyle, textStyle]}>
{this.props.children}
</View>
);
}
@ -55,10 +53,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
borderRightWidth: 1,
},
alignCenter: {
textAlign: 'center',
alignItems: 'center',
},
alignRight: {
textAlign: 'right',
alignItems: 'flex-end',
},
};
});

View file

@ -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 (
<Text
onPress={this.handlePress}
style={this.props.textStyle}
>
{this.props.children}
</Text>
);
}
}

View file

@ -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<string, PostImage>;
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 = (
<CompassIcon
name='jumbo-attachment-image-broken'
size={24}
/>
);
} else {
const {height, width} = calculateDimensions(metadata.height, metadata.width, 100, 100);
image = (
<TouchableWithFeedback
onPress={handlePreviewImage}
style={{width, height}}
>
<ProgressiveImage
id={fileId}
defaultSource={{uri: source}}
onError={onLoadFailed}
resizeMode='contain'
style={{width, height}}
/>
</TouchableWithFeedback>
);
}
return (
<View style={styles.container}>
{image}
</View>
);
};
export default MarkTableImage;

View file

@ -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}

View file

@ -32,6 +32,7 @@ exports[`AttachmentImage it matches snapshot 1`] = `
}
>
<Connect(ProgressiveImage)
id="123"
imageStyle={
Object {
"marginBottom": 5,

View file

@ -5,10 +5,11 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import ProgressiveImage from 'app/components/progressive_image';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {isGifTooLarge, previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {generateId} from '@utils/file';
import {isGifTooLarge, openGalleryAtIndex, calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const VIEWPORT_IMAGE_OFFSET = 100;
const VIEWPORT_IMAGE_CONTAINER_OFFSET = 10;
@ -19,12 +20,14 @@ export default class AttachmentImage extends PureComponent {
deviceWidth: PropTypes.number.isRequired,
imageMetadata: PropTypes.object,
imageUrl: PropTypes.string,
postId: PropTypes.string,
theme: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.fileId = generateId();
this.state = {
hasImage: Boolean(props.imageUrl),
imageUri: null,
@ -51,16 +54,8 @@ export default class AttachmentImage extends PureComponent {
}
}
setImageRef = (ref) => {
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
ref={this.setImageRef}
id={this.fileId}
imageStyle={style.attachmentMargin}
style={{height, width}}
imageUri={imageUri}
@ -151,7 +149,6 @@ export default class AttachmentImage extends PureComponent {
type={'none'}
>
<View
ref={this.setItemRef}
style={[style.imageContainer, {width, height}]}
>
{progressiveImage}

View file

@ -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}

View file

@ -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}

View file

@ -71,6 +71,7 @@ export default class AttachmentTitle extends PureComponent {
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
autolinkedUrlSchemes={[]}
mentionKeys={[]}
theme={theme}

View file

@ -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}
/>
}

View file

@ -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'}
>
<View ref={this.image}>
<View>
<ProgressiveImage
id={this.props.id}
style={[styles.image, {width: this.props.width, height: this.props.height}]}
imageUri={this.props.uri}
resizeMode='contain'

View file

@ -95,6 +95,7 @@ exports[`PostAttachmentOpenGraph should match snapshot, without image and descri
type="none"
>
<FastImage
nativeID="image-123"
resizeMode="contain"
source={
Object {
@ -275,6 +276,7 @@ exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage
type="none"
>
<FastImage
nativeID="image-123"
resizeMode="contain"
source={
Object {

View file

@ -5,11 +5,13 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Linking, Text, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import parseUrl from 'url-parse';
import {TABLET_WIDTH} from '@components/sidebars/drawer_layout';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {DeviceTypes} from '@constants';
import {previewImageAtIndex, calculateDimensions} from '@utils/images';
import {generateId} from '@utils/file';
import {openGalleryAtIndex, calculateDimensions} from '@utils/images';
import {getNearestPoint} from '@utils/opengraph';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -25,12 +27,14 @@ export default class PostAttachmentOpenGraph extends PureComponent {
isReplyPost: PropTypes.bool,
link: PropTypes.string.isRequired,
openGraphData: PropTypes.object,
postId: PropTypes.string,
theme: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.fileId = generateId();
this.state = this.getBestImageUrlAndDimensions(props.openGraphData);
}
@ -46,10 +50,6 @@ export default class PostAttachmentOpenGraph extends PureComponent {
this.mounted = false;
}
setItemRef = (ref) => {
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 (
<View
ref={this.setItemRef}
style={[style.imageContainer, {width, height}]}
>
<View style={[style.imageContainer, {width, height}]}>
<TouchableWithFeedback
onPress={this.handlePreviewImage}
type={'none'}
@ -229,6 +227,7 @@ export default class PostAttachmentOpenGraph extends PureComponent {
style={[style.image, {width, height}]}
source={source}
resizeMode='contain'
nativeID={`image-${this.fileId}`}
/>
</TouchableWithFeedback>
</View>

View file

@ -99,6 +99,7 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = `
<Connect(Markdown)
baseTextStyle={Object {}}
disableAtChannelMentionHighlight={true}
disableGallery={true}
onPostPress={[MockFunction]}
textStyles={Object {}}
value="{username} archived the channel"

View file

@ -342,6 +342,7 @@ export default class PostBody extends PureComponent {
onHashtagPress,
onPermalinkPress,
onPress,
post,
postProps,
postType,
replyBarStyle,
@ -415,6 +416,7 @@ export default class PostBody extends PureComponent {
onHashtagPress={onHashtagPress}
onPermalinkPress={onPermalinkPress}
onPostPress={onPress}
postId={post.id}
textStyles={textStyles}
value={message}
mentionKeys={mentionKeys}

View file

@ -31,6 +31,7 @@ const renderMessage = (postBodyProps, styles, intl, localeHolder, values, skipMa
<Markdown
baseTextStyle={messageStyle}
disableAtChannelMentionHighlight={true}
disableGallery={true}
onPostPress={onPress}
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}

View file

@ -13,6 +13,7 @@ import {
} from 'react-native';
import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube';
import {intlShape} from 'react-intl';
import parseUrl from 'url-parse';
import ImageViewPort from '@components/image_viewport';
import PostAttachmentImage from '@components/post_attachment_image';
@ -20,7 +21,8 @@ import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import CustomPropTypes from '@constants/custom_prop_types';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {calculateDimensions, getViewPortWidth, previewImageAtIndex} from '@utils/images';
import {generateId} from '@utils/file';
import {calculateDimensions, getViewPortWidth, openGalleryAtIndex} from '@utils/images';
import {getYouTubeVideoId, isImageLink, isYoutubeLink} from '@utils/url';
const MAX_YOUTUBE_IMAGE_HEIGHT = 202;
@ -73,6 +75,7 @@ export default class PostBodyAdditionalContent extends ImageViewPort {
}
}
this.fileId = generateId();
this.state = {
linkLoadError: false,
linkLoaded: false,
@ -111,6 +114,36 @@ export default class PostBodyAdditionalContent extends ImageViewPort {
return null;
};
getFileInfo = () => {
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 (
<PostAttachmentImage
id={fileInfo.id}
height={height || MAX_IMAGE_HEIGHT}
imageMetadata={imageMetadata}
onImagePress={this.handlePreviewImage}
@ -350,7 +364,7 @@ export default class PostBodyAdditionalContent extends ImageViewPort {
};
renderOpenGraph = (isYouTube, isImage) => {
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}
/>

View file

@ -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 (
<View>
<Text style={styles.progressText}>
{`${realFill}%`}
</Text>
</View>
);
};
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 = (
<View style={styles.filePreview}>
<FileAttachmentImage
file={file}
theme={theme}
resizeMode='center'
backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)}
/>
</View>
<TouchableOpacity onPress={this.handlePress}>
<View style={styles.filePreview}>
<FileAttachmentImage
file={file}
theme={theme}
resizeMode='cover'
/>
</View>
</TouchableOpacity>
);
} else {
filePreviewComponent = (
<View style={styles.filePreview}>
<FileAttachmentIcon
file={file}
theme={theme}
backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)}
/>
</View>
<TouchableOpacity onPress={this.handlePress}>
<View style={styles.filePreview}>
<FileAttachmentIcon
file={file}
theme={theme}
backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)}
/>
</View>
</TouchableOpacity>
);
}
@ -220,17 +206,11 @@ export default class UploadItem extends PureComponent {
/>
}
{file.loading && !file.failed &&
<View style={styles.progressCircleContent}>
<AnimatedCircularProgress
size={36}
fill={progress}
width={2}
backgroundColor='rgba(255, 255, 255, 0.5)'
tintColor='white'
rotation={0}
>
{this.renderProgress}
</AnimatedCircularProgress>
<View style={styles.progress}>
<ProgressBar
progress={progress}
color={this.props.theme.buttonBg}
/>
</View>
}
</View>
@ -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,

View file

@ -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({

View file

@ -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<ViewStyle>;
}
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 (
<View
onLayout={onLayout}
style={[styles.container, style]}
>
<Animated.View
style={[
styles.progressBar, {
backgroundColor: color,
width,
transform: [
{translateX},
{scaleX},
],
},
]}
/>
</View>
);
};
export default ProgressBar;

View file

@ -48,6 +48,7 @@ exports[`ProgressiveImage should match snapshot for Default Image 1`] = `
}
>
<ForwardRef(AnimatedComponentWrapper)
nativeID="image-undefined"
onError={[MockFunction]}
resizeMethod="auto"
resizeMode="contain"
@ -105,6 +106,7 @@ exports[`ProgressiveImage should match snapshot for Image 1`] = `
testID="progressive_image.thumbnail"
/>
<ForwardRef(AnimatedComponentWrapper)
nativeID="image-undefined"
onError={[MockFunction]}
onLoadEnd={[Function]}
resizeMethod="auto"

View file

@ -15,6 +15,7 @@ const AnimatedFastImage = Animated.createAnimatedComponent(FastImage);
export default class ProgressiveImage extends PureComponent {
static propTypes = {
id: PropTypes.string,
isBackgroundImage: PropTypes.bool,
children: CustomPropTypes.Children,
defaultSource: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.number]), // this should be provided by the component
@ -68,6 +69,7 @@ export default class ProgressiveImage extends PureComponent {
render() {
const {
defaultSource,
id,
imageStyle,
imageUri,
isBackgroundImage,
@ -88,7 +90,7 @@ export default class ProgressiveImage extends PureComponent {
DefaultComponent = ImageBackground;
ImageComponent = AnimatedImageBackground;
} else {
DefaultComponent = Animated.Image;
DefaultComponent = AnimatedFastImage;
ImageComponent = AnimatedFastImage;
}
@ -117,6 +119,7 @@ export default class ProgressiveImage extends PureComponent {
resizeMode={resizeMode}
resizeMethod={resizeMethod}
onError={onError}
nativeID={`image-${id}`}
>
{this.props.children}
</DefaultComponent>
@ -162,6 +165,7 @@ export default class ProgressiveImage extends PureComponent {
if (showHighResImage) {
image = (
<ImageComponent
nativeID={`image-${id}`}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
onError={onError}
@ -192,6 +196,7 @@ export default class ProgressiveImage extends PureComponent {
image = (
<ImageComponent
nativeID={`image-${id}`}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
onError={onError}

View file

@ -1,333 +0,0 @@
/* eslint-disable header/header */
// Copyright (c) 2016-2017 Charles.
// Modified work: 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 {
ActivityIndicator,
Animated,
AppState,
TouchableOpacity,
StyleSheet,
Text,
View,
} from 'react-native';
import Slider from 'react-native-slider';
import CompassIcon from '@components/compass_icon';
export const PLAYER_STATE = {
PLAYING: 0,
PAUSED: 1,
ENDED: 2,
};
export default class VideoControls extends PureComponent {
static propTypes = {
duration: PropTypes.number,
isLoading: PropTypes.bool,
isFullScreen: PropTypes.bool,
mainColor: PropTypes.string,
controlButtonBg: PropTypes.string,
onFullScreen: PropTypes.func,
onPaused: PropTypes.func,
onReplay: PropTypes.func,
onSeek: PropTypes.func,
onSeeking: PropTypes.func,
playerState: PropTypes.number,
progress: PropTypes.number,
};
static defaultProps = {
duration: 0,
mainColor: 'rgba(12, 83, 175, 0.9)',
};
constructor(props) {
super(props);
this.state = {
opacity: new Animated.Value(1),
isVisible: true,
isSeeking: false,
};
}
componentDidMount() {
AppState.addEventListener('change', this.handleAppStateChange);
}
componentDidUpdate(prevProps) {
if (this.props.playerState === PLAYER_STATE.ENDED || this.props.isLoading ||
(this.props.playerState === PLAYER_STATE.PAUSED && prevProps.playerState === PLAYER_STATE.PLAYING)) {
this.fadeInControls(false);
}
}
componentWillUnmount() {
AppState.removeEventListener('change', this.handleAppStateChange);
}
cancelAnimation = () => {
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 (
<View style={styles.container}>
<View style={[styles.controlsRow, StyleSheet.absoluteFill]}>
{
this.props.isLoading ? this.setLoadingView() : this.setPlayerControls(this.props.playerState)
}
</View>
<View style={[styles.controlsRow, styles.progressContainer]}>
<View style={styles.progressColumnContainer}>
<View style={[styles.timerLabelsContainer]}>
<Text style={styles.timerLabel}>
{this.humanizeVideoDuration(this.props.progress)}
</Text>
<Text style={styles.timerLabel}>
{this.humanizeVideoDuration(this.props.duration)}
</Text>
</View>
<Slider
style={styles.progressSlider}
onSlidingComplete={this.seekVideoEnd}
onValueChange={this.seekVideo}
onSlidingStart={this.seekVideoStart}
maximumValue={Math.floor(this.props.duration)}
value={Math.floor(this.props.progress)}
trackStyle={styles.track}
thumbStyle={[styles.thumb, {borderColor: this.props.mainColor}]}
minimumTrackTintColor={this.props.mainColor}
/>
</View>
<TouchableOpacity
style={styles.fullScreenContainer}
onPress={this.props.onFullScreen}
>
<CompassIcon
name='arrow-expand'
size={20}
color='#fff'
/>
</TouchableOpacity>
</View>
</View>
);
}
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 (
<TouchableOpacity
onPress={pressAction}
style={[styles.controlButton, {backgroundColor: this.props.controlButtonBg}]}
>
<CompassIcon
name={iconName}
size={72}
color='#fff'
/>
</TouchableOpacity>
);
};
setLoadingView = () => {
return (
<ActivityIndicator
size='large'
color='#FFF'
/>
);
};
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 (
<Animated.View style={[styles.container, {opacity: this.state.opacity}]}>
{this.renderControls()}
</Animated.View>
);
}
}
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,
},
});

View file

@ -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;

View file

@ -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,

View file

@ -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<FileInfo>;

View file

@ -37,6 +37,8 @@ export type PostEmbed = {
export type PostImage = {
height: number;
width: number;
format?: string;
frame_count?: number;
};
export type PostMetadata = {

View file

@ -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={

View file

@ -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}

View file

@ -73,6 +73,7 @@ export default class ExpandedAnnouncementBanner extends React.PureComponent {
<Markdown
baseTextStyle={style.baseTextStyle}
blockStyles={getMarkdownBlockStyles(theme)}
disableGallery={true}
onChannelLinkPress={this.handleChannelLinkPress}
textStyles={getMarkdownTextStyles(theme)}
value={this.props.bannerText}

View file

@ -0,0 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Gallery should match snapshot 1`] = `
<React.Fragment>
<GalleryViewer
files={
Array [
Object {
"caption": "Caption 1",
"data": "data",
"source": "source",
},
Object {
"caption": "Caption 2",
"data": "data",
"source": "source",
},
]
}
footerVisible={true}
height={400}
initialIndex={0}
isLandscape={false}
onClose={[Function]}
onPageSelected={[Function]}
onTap={[Function]}
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",
}
}
width={300}
/>
<InjectIntl(Footer)
file={
Object {
"caption": "Caption 1",
"data": "data",
"source": "source",
}
}
/>
</React.Fragment>
`;

View file

@ -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<State>, numberOfPointers: Animated.Node<number>) =>
(Platform.OS === 'android' ?
or(eq(state, State.END), lessThan(numberOfPointers, 2)) :
eq(state, State.END)),
);
const decay = (
position: Animated.Adaptable<number>,
velocity: Animated.Adaptable<number>,
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<Clock>,
) => {
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<typeof panGestureHandler>;
pinch: ReturnType<typeof pinchGestureHandler>;
maxVec: Vector;
minVec: Vector;
maxImgVec: Vector;
minImgVec: Vector;
scale: Animated.Value<number>;
translate: Vector<Animated.Value<number>>;
translationX: Animated.Value<number>;
translationY: Animated.Value<number>;
}
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],
);
};

View file

@ -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<FooterRef, FooterProps>((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<PrepareFileRef>(null);
const toastRef = useRef<ToastRef>();
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<string | undefined> => {
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 (
<>
<Animated.View style={[{transform: [{translateY}], opacity}, styles.footer]}>
<Summary
copyPublicLink={copyPublicLink}
dowloadFile={dowloadFile}
file={props.file}
/>
<Toast ref={toastRef}/>
</Animated.View>
<Animated.View style={[{transform: [{translateY: downloadingY}], opacity: downloadingOpacitity}, styles.footer]}>
<PrepareFile
ref={prepareRef}
intl={props.intl}
/>
</Animated.View>
</>
);
});
Footer.displayName = 'Footer';
export default injectIntl(Footer, {withRef: true});

View file

@ -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);

View file

@ -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<PrepareFileRef, PrepareFileProps>(({intl, isLandscape, theme}: PrepareFileProps, ref) => {
const containerStyles: Array<ViewStyle> = [styles.container];
let downloadTask = useRef<StatefulPromise<FetchBlobResponse>>().current;
const [progress, setProgress] = useState(0);
const [visible, setVisible] = useState(false);
const start = async (file: FileInfo, share = true): Promise<string | undefined> => {
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 = <Text style={styles.saving}>{`${progress * 100}%`}</Text>;
if (progress >= 1) {
label = (
<CompassIcon
name='check'
size={24}
color='white'
style={{fontWeight: '600', top: -5}}
/>
);
}
return (
<View style={[containerStyles, paddingHorizontal(isLandscape)]}>
<FormattedText
id='mobile.prepare_file.text'
defaultMessage='Preparing'
style={styles.saving}
/>
<View style={{marginTop: 10, flex: 1, marginHorizontal: 7, alignItems: 'flex-start'}}>
<ProgressBar
progress={progress}
color={theme.buttonBg}
/>
</View>
<View style={{alignItems: 'center'}}>
{label}
</View>
</View>
);
});
PrepareFile.displayName = 'PrepareFile';
export default PrepareFile;

View file

@ -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 (
<Pressable
android_ripple={androidRippleConfig}
disabled={disabled}
hitSlop={24}
onPress={onPress}
style={(pressed) => [
pressedStyle(pressed),
style,
]}
>
{children}
</Pressable>
);
};
export default Action;

View file

@ -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<ManagedConfig|null>(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 (
<View style={styles.contaier}>
<Action
action={props.linkAction}
visible={linkActionVisible}
>
<CompassIcon
color='#FFFFFF'
name='link-variant'
size={24}
/>
</Action>
<Action
action={props.downloadAction}
style={styles.download}
visible={props.canDownloadFiles}
>
<CompassIcon
color='#FFFFFF'
name='export-variant'
size={24}
/>
</Action>
</View>
);
};
export default Actions;

View file

@ -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);

View file

@ -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 = (
<FastImage
source={{uri: avatarUri}}
style={[styles.avatar, styles.avatarRadius]}
/>
);
} else {
element = (
<CompassIcon
name='account-outline'
size={32}
color={changeOpacity(theme.centerChannelColor, 0.48)}
/>
);
}
return (
<View style={[styles.avatarContainer, styles.avatarRadius]}>
{element}
</View>
);
};
export default Avatar;

View file

@ -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 = (
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.userText}
>
{user}
</Text>
);
if (ownPost) {
userElement = (
<FormattedText
id='channel_header.directchannel.you'
defaultMessage='{displayname} (you)'
ellipsizeMode='tail'
numberOfLines={1}
style={styles.userText}
values={{displayname: user}}
/>
);
} else if (!user) {
userElement = (
<FormattedText
id='channel_loader.someone'
defaultMessage='Someone'
ellipsizeMode='tail'
numberOfLines={1}
style={styles.userText}
/>
);
}
return (
<View style={styles.container}>
{userElement}
<FormattedText
id='gallery.footer.channel_name'
defaultMessage='Shared in {channelName}'
ellipsizeMode='tail'
numberOfLines={1}
style={styles.chanelText}
values={{channelName: `${prefix}${channel}`}}
/>
</View>
);
};
export default Details;

View file

@ -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);

View file

@ -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<ViewStyle> = [styles.container];
if (props.isLandscape) {
containerStyles.push(styles.containerLandscape);
}
return (
<View style={[containerStyles, paddingHorizontal(props.isLandscape)]}>
<View style={styles.details}>
<Avatar
avatarUri={props.avatarUri}
theme={props.theme}
/>
<Details
channel={props.channelName}
isDirect={props.isDirectChannel}
ownPost={props.ownPost}
user={props.displayName}
/>
</View>
<View style={styles.actions}>
<Actions
file={props.file}
linkAction={props.copyPublicLink}
downloadAction={props.dowloadFile}
/>
</View>
</View>
);
};
export default Summary;

View file

@ -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);

View file

@ -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<ToastRef, ToastProps>(({theme}: ToastProps, ref) => {
const opacity = useRef(new Animated.Value(0)).current;
const [visible, setVisible] = useState(false);
const [state, setState] = useState<ToastState>({});
const [text, setText] = useState<string>();
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 (
<Animated.View
style={[styles.container, {opacity}]}
pointerEvents='none'
>
<View style={[{backgroundColor: theme.onlineIndicator}, styles.toast]}>
<CompassIcon
color={theme.sidebarText}
name='check'
size={20}
/>
<View>
<Text style={[styles.text, {color: theme.sidebarText}]}>{text}</Text>
</View>
</View>
</Animated.View>
);
});
Toast.displayName = 'Toast';
export default Toast;

View file

@ -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 (
<>
<GalleryViewer
key={`gallery-${deviceWidth}`}
files={files}
footerVisible={footerVisible}
width={deviceWidth}
height={deviceHeight}
initialIndex={index}
isLandscape={deviceWidth > deviceHeight}
onClose={this.close}
onPageSelected={this.handlePageSelected}
onTap={this.handleTapped}
theme={theme}
/>
<Footer
ref={this.footer}
file={files[index]}
/>
</>
);
}
}

View file

@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallowWithIntl} from 'test/intl-test-helper';
import Preferences from '@mm-redux/constants/preferences';
import * as NavigationActions from 'app/actions/navigation';
import Gallery from './gallery';
jest.useFakeTimers();
jest.mock('react-native-file-viewer', () => ({
open: jest.fn(),
}));
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock');
// The mock for `call` immediately calls the callback which is incorrect
// So we override it with a no-op
Reanimated.default.call = () => jest.fn();
return Reanimated;
});
describe('Gallery', () => {
const baseProps = {
canDownloadFiles: true,
deviceHeight: 400,
deviceWidth: 300,
files: [
{caption: 'Caption 1', source: 'source', data: 'data'},
{caption: 'Caption 2', source: 'source', data: 'data'},
],
index: 0,
theme: Preferences.THEMES.default,
componentId: 'component-id',
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(
<Gallery {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match state on handleChangeImage', () => {
const wrapper = shallowWithIntl(
<Gallery {...baseProps}/>,
);
wrapper.setState({index: 0});
wrapper.instance().handlePageSelected(1);
expect(wrapper.state('index')).toEqual(1);
});
test('should match call popTopScreen & mergeNavigationOptions on close', () => {
const mergeNavigationOptions = jest.spyOn(NavigationActions, 'mergeNavigationOptions');
const popTopScreen = jest.spyOn(NavigationActions, 'popTopScreen');
const wrapper = shallowWithIntl(
<Gallery
{...baseProps}
/>,
);
wrapper.instance().close();
expect(mergeNavigationOptions).toHaveBeenCalledTimes(1);
expect(popTopScreen).toHaveBeenCalled();
expect(mergeNavigationOptions).toHaveBeenCalledWith(
baseProps.componentId,
{
layout: {
backgroundColor: '#000',
componentBackgroundColor: '#000',
},
topBar: {
visible: true,
},
},
);
});
});

View file

@ -0,0 +1,177 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {injectIntl} from 'react-intl';
import {Alert, Appearance, Platform, Text, StatusBar, StatusBarStyle, View} from 'react-native';
import {TapGestureHandler} from 'react-native-gesture-handler';
import FileViewer from 'react-native-file-viewer';
import tinyColor from 'tinycolor2';
import FileIcon from '@components/file_attachment_list/file_attachment_icon';
import Touchable from '@components/touchable_with_feedback';
import {ATTACHMENT_DOWNLOAD} from '@constants/attachment';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {isDocument} from '@utils/file';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {GalleryItemProps} from 'types/screens/gallery';
interface GalleryFileProps extends GalleryItemProps {
canDownloadFiles: boolean;
}
const getStyles = makeStyleSheetFromTheme(({deviceHeight, deviceWidth, theme}: GalleryItemProps) => ({
container: {
alignItems: 'center',
justifyContent: 'center',
top: Platform.select({
android: (deviceWidth > deviceHeight) ? -15 : 0,
}),
},
filename: {
color: '#FFFFFF',
fontFamily: 'Open Sans',
fontSize: 16,
fontWeight: '600',
lineHeight: 24,
marginVertical: 8,
paddingHorizontal: 16,
},
button: {
alignItems: 'center',
backgroundColor: theme?.buttonBg,
borderRadius: 4,
height: 48,
justifyContent: 'center',
marginTop: 16,
},
buttonShape: {
paddingHorizontal: 20,
},
buttonText: {
color: theme?.buttonColor,
fontFamily: 'Open Sans',
fontSize: 16,
fontWeight: '600',
lineHeight: 24,
},
unsupported: {
color: '#FFFFFF',
fontFamily: 'Open Sans',
fontSize: 12,
fontWeight: '600',
lineHeight: 26,
marginTop: 10,
paddingHorizontal: 16,
opacity: 0.64,
},
}));
const GalleryVideo = (props: GalleryFileProps) => {
const {canDownloadFiles, file, intl, theme} = props;
const [enabled, setEnabled] = useState(true);
const styles = getStyles(props);
const action = useCallback(preventDoubleTap(() => {
EventEmitter.emit(ATTACHMENT_DOWNLOAD, !isDocument(file), actionCallback);
}), []);
const actionCallback = (path?: string) => {
if (isDocument(file) && path) {
open(path);
}
setEnabled(true);
};
const open = (path: string) => {
statusBar(true);
FileViewer.open(path, {
displayName: file.name,
onDismiss: () => statusBar(),
showOpenWithDialog: true,
showAppsSuggestions: true,
}).catch(() => {
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.toLowerCase(),
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}],
);
statusBar();
});
};
const statusBar = (appearance?: boolean) => {
if (appearance) {
const style = Appearance.getColorScheme() === 'dark' ? 'light-content' : 'dark-content';
StatusBar.setBarStyle(style, true);
} else {
const headerColor = tinyColor(props.theme?.sidebarHeaderBg);
let barStyle: StatusBarStyle = 'light-content';
if (headerColor.isLight()) {
barStyle = 'dark-content';
}
StatusBar.setBarStyle(barStyle, true);
}
};
let option;
let unsupported;
if (isDocument(file)) {
option = intl.formatMessage({id: 'gallery.open_file', defaultMessage: 'Open file'});
} else {
option = intl.formatMessage({id: 'gallery.download_file', defaultMessage: 'Download file'});
unsupported = intl.formatMessage({id: 'gallery.unsuppored', defaultMessage: 'Preview is not supported for this file type'});
}
return (
<TapGestureHandler>
<View style={styles.container}>
<FileIcon
file={file}
iconSize={120}
iconColor={theme?.buttonBg}
/>
<Text
numberOfLines={1}
style={styles.filename}
>
{file.name}
</Text>
{Boolean(unsupported) &&
<Text style={styles.unsupported}>{unsupported}</Text>
}
{canDownloadFiles &&
<View style={styles.button}>
<Touchable
disabled={!enabled}
onPress={action}
type={Platform.OS === 'android' ? 'native' : 'opacity'}
>
<View style={styles.buttonShape}>
<Text style={styles.buttonText}>{option}</Text>
</View>
</Touchable>
</View>
}
</View>
</TapGestureHandler>
);
};
export default injectIntl(GalleryVideo);

View file

@ -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 GalleryFile from './gallery_file';
function mapStateToProps(state: GlobalState) {
return {
canDownloadFiles: canDownloadFilesOnMobile(state),
};
}
export default connect(mapStateToProps)(GalleryFile);

View file

@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import FastImage from 'react-native-fast-image';
import Animated from 'react-native-reanimated';
import {DeviceTypes} from '@constants';
import {calculateDimensions} from '@utils/images';
import {GalleryItemProps} from 'types/screens/gallery';
const AnimatedImage = Animated.createAnimatedComponent(FastImage);
const GalleryImage = ({file, deviceHeight, deviceWidth, style}: GalleryItemProps) => {
const {height, width, uri: imageUri, localPath} = file;
const statusBar = DeviceTypes.IS_IPHONE_WITH_INSETS ? 60 : 20;
const calculatedDimensions = calculateDimensions(height, width, deviceWidth, deviceHeight - statusBar);
const uri = localPath || imageUri;
return (
<AnimatedImage
source={{uri}}
style={[{...calculatedDimensions}, style]}
nativeID={`gallery-${file.id}`}
/>
);
};
export default GalleryImage;

View file

@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {injectIntl} from 'react-intl';
import {Alert, Platform, View} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import {TapGestureHandler, State, TapGestureHandlerStateChangeEvent} from 'react-native-gesture-handler';
import Video, {OnLoadData, OnProgressData} from 'react-native-video';
import {DeviceTypes} from '@constants';
import {Client4} from '@mm-redux/client';
import {getLocalPath} from '@utils/file';
import {GalleryItemProps} from 'types/screens/gallery';
import VideoControls, {VideoControlsRef} from './video_controls';
const GalleryVideo = ({file, deviceHeight, deviceWidth, intl, isActive, onDoubleTap, theme}: GalleryItemProps) => {
const statusBar = DeviceTypes.IS_IPHONE_WITH_INSETS ? 0 : 20;
const width = deviceWidth;
const height = deviceHeight - statusBar;
const [uri, setUri] = useState(file.localPath);
const [paused, setPaused] = useState(true);
const videoRef = useRef<Video>(null);
const controlsRef = useRef<VideoControlsRef>(null);
const doubleTapRef = useRef<TapGestureHandler>(null);
const doubleTap = (e: TapGestureHandlerStateChangeEvent) => {
if (e.nativeEvent.state === State.ACTIVE) {
if (onDoubleTap) {
onDoubleTap();
}
}
};
const onPlayPause = () => {
setPaused(!paused);
};
const onSeek = (seek: number) => {
videoRef.current?.seek(seek);
controlsRef.current?.showControls(!paused);
};
const singleTap = (e: TapGestureHandlerStateChangeEvent) => {
if (e.nativeEvent.state === State.ACTIVE) {
controlsRef.current?.showControls(!paused);
}
};
const videoEnded = () => {
setPaused(true);
controlsRef.current?.showControls(false);
const requested = requestAnimationFrame(() => {
videoRef.current?.seek(0);
cancelAnimationFrame(requested);
});
};
const videoError = useCallback(() => {
Alert.alert(
intl.formatMessage({
id: 'mobile.video_playback.failed_title',
defaultMessage: 'Video playback failed',
}),
intl.formatMessage({
id: 'mobile.video_playback.failed_description',
defaultMessage: 'An error occurred while trying to play the video.\n',
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}],
);
}, []);
const videoLoaded = (data: OnLoadData) => {
controlsRef.current?.videoDuration(data.duration);
};
const videoProgress = (data: OnProgressData | number) => {
let progress: number;
if (typeof data === 'object') {
progress = data.currentTime;
} else {
progress = data;
}
controlsRef.current?.videoProgress(progress);
};
useEffect(() => {
const getUriFromFile = async () => {
if (!uri) {
const localPath = getLocalPath(file);
const prefix = Platform.OS === 'android' ? 'file:/' : '';
const exist = await RNFetchBlob.fs.exists(`${prefix}${localPath}`);
setUri(exist ? localPath : Client4.getFileUrl(file.id, Date.now()));
}
};
getUriFromFile();
}, []);
useEffect(() => {
if (!isActive) {
setPaused(true);
}
}, [isActive]);
if (!uri || !isActive) {
return null;
}
return (
<>
<TapGestureHandler
numberOfTaps={1}
onHandlerStateChange={singleTap}
waitFor={doubleTapRef}
>
<View>
<TapGestureHandler
ref={doubleTapRef}
numberOfTaps={2}
onHandlerStateChange={doubleTap}
>
<View>
<Video
ref={videoRef}
style={{width, height}}
resizeMode='contain'
source={{uri}}
volume={1.0}
paused={paused}
controls={false}
onEnd={videoEnded}
onLoad={videoLoaded}
onProgress={videoProgress}
onError={videoError}
/>
</View>
</TapGestureHandler>
</View>
</TapGestureHandler>
<VideoControls
ref={controlsRef}
mainColor={theme?.buttonBg}
isLandscape={width > height}
paused={paused}
onPlayPause={onPlayPause}
onSeek={onSeek}
/>
</>
);
};
export default injectIntl(GalleryVideo);

View file

@ -0,0 +1,305 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {Platform, StyleSheet, View} from 'react-native';
import {PanGestureHandler, PinchGestureHandler, State, TapGestureHandler, TapGestureHandlerStateChangeEvent} from 'react-native-gesture-handler';
import Animated, {abs, add, and, call, clockRunning, cond, divide, eq, floor, greaterOrEq, greaterThan, multiply, neq, not, onChange, set, sub, useCode} from 'react-native-reanimated';
import {clamp, snapPoint, timing, useClock, usePanGestureHandler, usePinchGestureHandler, useTapGestureHandler, useValue, vec} from 'react-native-redash';
import {isImage, isVideo} from '@utils/file';
import {calculateDimensions} from '@utils/images';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {GalleryProps} from 'types/screens/gallery';
import {usePinch} from './animation_helper';
import GalleryFile from './gallery_file';
import GalleryImage from './gallery_image';
import GalleryVideo from './gallery_video';
const itemTopStyle = (props: GalleryProps): number => {
if (Platform.OS === 'android' && props.footerVisible) {
return props.isLandscape ? -64 : -99;
}
return 0;
};
const getStyles = makeStyleSheetFromTheme((props: GalleryProps) => ({
container: {
...StyleSheet.absoluteFillObject,
},
items: {
width: (props.width * props.files.length),
height: props.height,
flexDirection: 'row',
alignItems: 'center',
},
item: {
alignItems: 'center',
height: props.height,
justifyContent: 'center',
overflow: 'hidden',
width: props.width,
},
center: {
justifyContent: 'center',
},
}));
const GalleryViewer = (props: GalleryProps) => {
const {files, height, initialIndex, width} = props;
const [enabled, setEnabled] = useState(false);
const [tapEnabled, setTapEnabled] = useState(true);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const styles = getStyles(props);
const topValue = itemTopStyle(props);
const top = useRef(useValue(topValue)).current;
const canvas = useMemo(() => vec.create(width, height), [width]);
const center = useMemo(() => vec.divide(canvas, 2), [canvas]);
const snapPoints = useMemo(() => files.map((_, i) => {
return (i * -width);
}), []);
const pinchRef = useRef<PinchGestureHandler>(null);
const panRef = useRef<PanGestureHandler>(null);
const doubleTapRef = useRef<TapGestureHandler>(null);
const pan = useRef(usePanGestureHandler()).current;
const pinch = useRef(usePinchGestureHandler()).current;
const doubleTap = useRef(useTapGestureHandler()).current;
const scale = useRef(useValue(1)).current;
const translate = useRef(vec.createValue(0, 0)).current;
const clock = useRef(useClock()).current;
const zoomClock = useRef(useClock()).current;
const index = useRef(useValue(initialIndex)).current;
const offsetX = useRef(useValue(snapPoints[initialIndex])).current;
const translationX = useRef(useValue(snapPoints[initialIndex])).current;
const translateX = useRef(useValue(snapPoints[initialIndex])).current;
const translationY = useRef(useValue(0)).current;
const translateY = useRef(useValue(0)).current;
const zoomed = useRef(useValue(0)).current;
const minVec = vec.min(vec.multiply(-0.5, canvas, sub(scale, 1)), 0);
const maxVec = vec.max(vec.minus(minVec), 0);
const currentFile = files[currentIndex];
const imgHeight = currentFile.height || height;
const imgWidth = currentFile.width || width;
const calculatedDimensions = calculateDimensions(imgHeight, imgWidth, width, height);
const imgCanvas = vec.create(calculatedDimensions.width, calculatedDimensions.height);
const minImgVec = vec.min(vec.multiply(-0.5, imgCanvas, sub(scale, 1)), 0);
const maxImgVec = vec.max(vec.minus(minImgVec), 0);
const snapTo = useMemo(() => clamp(
snapPoint(translateX, pan.velocity.x, snapPoints),
multiply(add(index, 1), -width),
multiply(sub(index, 1), -width),
), [width]);
let previousIndex = currentIndex;
const indexChanged = ([idx]: readonly number[]) => {
if (idx !== previousIndex) {
shouldEnableGestures([idx]);
setCurrentIndex(idx);
props.onPageSelected(idx);
previousIndex = idx;
}
};
let previousEnabled = enabled;
const shouldEnableGestures = ([idx]: readonly number[]) => {
const gestrues = isImage(files[idx]);
if (gestrues !== previousEnabled) {
setEnabled(gestrues);
previousEnabled = gestrues;
}
if (isVideo(files[idx])) {
setTapEnabled(false);
} else {
setTapEnabled(true);
}
};
const renderItems = files.map((file, i) => {
const isActive = eq(index, i);
const itemProps = {
file,
deviceWidth: width,
deviceHeight: height,
};
if (isImage(file)) {
return (
<View
key={file.id}
style={styles.item}
>
<GalleryImage
style={{
transform: [
{translateX: cond(isActive, translate.x, 0)},
{translateY: cond(isActive, translate.y, 0)},
{scale: cond(isActive, scale, 1)},
],
}}
{...itemProps}
/>
</View>
);
} else if (isVideo(file)) {
return (
<View
key={file.id}
style={styles.item}
>
<GalleryVideo
isActive={currentIndex === i}
onDoubleTap={props.onTap}
theme={props.theme}
{...itemProps}
/>
</View>
);
}
return (
<View
key={file.id}
style={styles.item}
>
<GalleryFile
theme={props.theme}
{...itemProps}
/>
</View>
);
});
const handleTapGesture = (event: TapGestureHandlerStateChangeEvent) => {
if (event.nativeEvent.state === State.ACTIVE) {
props.onTap();
}
};
useEffect(() => {
shouldEnableGestures([initialIndex]);
}, []);
usePinch({center, pan, pinch, translate, scale, minVec, maxVec, minImgVec, maxImgVec, translationX, translationY});
useCode(
() => [
onChange(
translationX,
cond(eq(pan.state, State.ACTIVE), [
set(translateX, add(offsetX, translationX)),
]),
),
onChange(
translationY,
cond(and(eq(pan.state, State.ACTIVE), neq(pan.translation.y, 0)), [
set(translateY, translationY),
]),
),
cond(and(eq(pan.state, State.END), neq(translateY, 0)), [
cond(greaterOrEq(abs(translateY), 50), [
cond(not(clockRunning(clock)), call([], props.onClose)),
], set(translateY, timing({from: translateY, to: 0}))),
]),
cond(and(eq(pan.state, State.END), neq(translationX, 0)), [
set(translateX, timing({clock, from: translateX, to: snapTo, duration: 250})),
set(offsetX, translateX),
cond(not(clockRunning(clock)), [
vec.set(translate, 0),
set(index, floor(divide(translateX, -width))),
call([abs(index)], indexChanged),
]),
]),
],
[index],
);
useCode(() => [
cond(eq(doubleTap.state, State.BEGAN), [
set(zoomed, greaterThan(scale, 1)),
]),
cond(eq(doubleTap.state, State.END), [
cond(eq(zoomed, 1), [
set(scale, timing({clock: zoomClock, from: scale, to: 1})),
cond(not(clockRunning(zoomClock)), [
vec.set(translate, 0),
set(doubleTap.state, 0),
]),
]),
cond(eq(zoomed, 0), [
set(scale, timing({clock: zoomClock, from: scale, to: 3})),
cond(not(clockRunning(zoomClock)), [
vec.set(translate, 0),
set(doubleTap.state, 0),
]),
]),
]),
], []);
useCode(() => [
set(top, timing({from: top, to: topValue})),
], [topValue]);
return (
<PinchGestureHandler
ref={pinchRef}
simultaneousHandlers={panRef}
{...pinch.gestureHandler}
enabled={enabled}
>
<Animated.View style={StyleSheet.absoluteFill}>
<PanGestureHandler
ref={panRef}
minDist={5}
avgTouches={true}
simultaneousHandlers={pinchRef}
{...pan.gestureHandler}
>
<Animated.View style={styles.container}>
<TapGestureHandler
maxDist={10}
numberOfTaps={1}
onHandlerStateChange={handleTapGesture}
simultaneousHandlers={[panRef, pinchRef]}
waitFor={doubleTapRef}
enabled={tapEnabled}
>
<View>
<TapGestureHandler
numberOfTaps={2}
simultaneousHandlers={[panRef, pinchRef]}
{...doubleTap.gestureHandler}
shouldCancelWhenOutside={true}
ref={doubleTapRef}
enabled={enabled}
maxDist={10}
maxDelayMs={200}
>
<Animated.View
style={[styles.items, {transform: [{translateX}, {translateY}], top}]}
>
{renderItems}
</Animated.View>
</TapGestureHandler>
</View>
</TapGestureHandler>
</Animated.View>
</PanGestureHandler>
</Animated.View>
</PinchGestureHandler>
);
};
export default GalleryViewer;

View file

@ -4,13 +4,15 @@
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getDimensions} from '@selectors/device';
import TextPreview from './text_preview';
import Gallery from './gallery';
function mapStateToProps(state) {
return {
...getDimensions(state),
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(TextPreview);
export default connect(mapStateToProps)(Gallery);

View file

@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {Animated, Platform, StyleSheet, Text, TouchableOpacity, View} from 'react-native';
import Slider from 'react-native-slider';
import CompassIcon from '@components/compass_icon';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {CallbackFunctionWithoutArguments} from 'types/screens/gallery';
export enum VIDEO_PLAYER_STATE {
PLAYING = 'PLAYING',
PAUSED = 'PAUSED',
}
interface VideoControlsProps {
isLandscape: boolean;
mainColor?: string;
paused: boolean;
onPlayPause(): void;
onSeek(value: number): void;
}
export interface VideoControlsRef {
showControls(playing: boolean): void;
videoDuration(duration: number): void;
videoProgress(progress: number): void;
}
const getStyles = makeStyleSheetFromTheme((isLandscape: boolean) => ({
controlsRow: {
alignItems: 'center',
justifyContent: 'center',
},
controlButton: {
justifyContent: 'center',
alignItems: 'center',
width: 72,
height: 72,
},
controlIcon: {
height: 32,
},
progressContainer: {
position: 'absolute',
flexDirection: 'row',
bottom: Platform.select({
android: isLandscape ? 79 : 69,
ios: isLandscape ? 64 : 92,
}),
marginHorizontal: 16,
},
progressColumnContainer: {
flex: 1,
},
timerLabelsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: -7,
},
timerLabel: {
fontSize: 12,
color: 'white',
textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: {width: -1, height: 1},
textShadowRadius: 10,
},
thumb: {
width: 16,
height: 16,
backgroundColor: '#FFF',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 6,
},
shadowOpacity: 0.24,
shadowRadius: 14,
elevation: 5,
},
}));
const getControlIconName = (paused: boolean) => {
if (!paused) {
return 'pause';
}
return 'play';
};
const humanizeVideoDuration = (seconds: number) => {
const [begin, end] = seconds >= 3600 ? [11, 8] : [14, 5];
const date = new Date(0);
date.setSeconds(seconds);
return date.toISOString().substr(begin, end);
};
const VideoControls = forwardRef<VideoControlsRef, VideoControlsProps>((props: VideoControlsProps, ref) => {
const opacity = useRef(new Animated.Value(0)).current;
const [duration, setDuration] = useState(0);
const [progress, setProgress] = useState(0);
const [visible, setVisible] = useState(true);
const styles = getStyles(props.isLandscape);
const fadeControls = (toValue: number, delay = 0, callback?: CallbackFunctionWithoutArguments) => {
Animated.timing(opacity, {
toValue,
duration: 250,
delay,
useNativeDriver: true,
}).start(callback);
};
useEffect(() => {
opacity.setValue(1);
}, []);
useImperativeHandle(ref, () => ({
showControls,
videoDuration,
videoProgress,
}), []);
const showControls = (playing: boolean) => {
setVisible(true);
if (playing) {
fadeControls(1, 0, () => {
fadeControls(0, 1000, () => setVisible(false));
});
} else {
fadeControls(1, 0);
}
};
const playPause = () => {
if (props.paused) {
fadeControls(0, 250, () => setVisible(false));
} else {
fadeControls(1, 250, () => setVisible(true));
}
props.onPlayPause();
};
const seekEnd = (value: number) => {
props.onSeek(value);
setProgress(value);
if (!props.paused) {
fadeControls(0, 1000, () => setVisible(false));
}
};
const seeking = (value: number) => {
setProgress(value);
};
const seekStart = () => {
opacity.stopAnimation();
setVisible(true);
};
const videoDuration = (value: number) => {
setDuration(value);
};
const videoProgress = (value: number) => {
setProgress(value);
};
if (!visible) {
return null;
}
const iconName = getControlIconName(props.paused);
return (
<Animated.View
pointerEvents='box-none'
style={[StyleSheet.absoluteFill, {opacity, backgroundColor: 'rgba(0, 0, 0, 0.3)'}]}
>
<View style={[styles.controlsRow, StyleSheet.absoluteFill]}>
<TouchableOpacity
style={styles.controlButton}
onPress={playPause}
>
<CompassIcon
name={iconName}
size={72}
color='#fff'
/>
</TouchableOpacity>
</View>
<View style={[styles.controlsRow, styles.progressContainer]}>
<View style={styles.progressColumnContainer}>
<View style={[styles.timerLabelsContainer]}>
<Text style={styles.timerLabel}>
{humanizeVideoDuration(progress)}
</Text>
<Text style={styles.timerLabel}>
{humanizeVideoDuration(duration)}
</Text>
</View>
<Slider
onSlidingComplete={seekEnd}
onValueChange={seeking}
onSlidingStart={seekStart}
maximumValue={Math.floor(duration)}
value={Math.floor(progress)}
thumbStyle={[styles.thumb]}
minimumTrackTintColor={props.mainColor}
/>
</View>
</View>
</Animated.View>
);
});
VideoControls.displayName = 'VideoControls';
export default VideoControls;

View file

@ -1,93 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DownloaderBottomContent should match snapshot for downloaded file 1`] = `
<View
style={
Object {
"alignItems": "center",
"marginTop": 10,
}
}
>
<FormattedText
defaultMessage="Download complete"
id="mobile.downloader.complete"
style={
Object {
"color": "white",
"fontSize": 16,
"fontWeight": "600",
}
}
/>
</View>
`;
exports[`DownloaderBottomContent should match snapshot for downloaded file and is not video 1`] = `
<View
style={
Object {
"alignItems": "center",
"marginTop": 10,
}
}
>
<FormattedText
defaultMessage="Image Saved"
id="mobile.downloader.image_saved"
style={
Object {
"color": "white",
"fontSize": 16,
"fontWeight": "600",
}
}
/>
</View>
`;
exports[`DownloaderBottomContent should match snapshot for downloaded file and isVideo 1`] = `
<View
style={
Object {
"alignItems": "center",
"marginTop": 10,
}
}
>
<FormattedText
defaultMessage="Video Saved"
id="mobile.downloader.video_saved"
style={
Object {
"color": "white",
"fontSize": 16,
"fontWeight": "600",
}
}
/>
</View>
`;
exports[`DownloaderBottomContent should match snapshot for downloading 1`] = `
<View
style={
Object {
"alignItems": "center",
"marginTop": 10,
}
}
>
<FormattedText
defaultMessage="Downloading..."
id="mobile.downloader.downloading"
style={
Object {
"color": "white",
"fontSize": 16,
"fontWeight": "600",
}
}
/>
</View>
`;

View file

@ -1,263 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImagePreview should match snapshot 1`] = `
<ForwardRef(AnimatedComponentWrapper)
style={
Array [
Object {
"backgroundColor": "#000",
"flex": 1,
},
Object {
"opacity": NaN,
},
]
}
>
<ForwardRef(AnimatedComponentWrapper)
style={
Object {
"backgroundColor": "#000",
"flex": 1,
}
}
>
<Gallery
errorComponent={[Function]}
flatListProps={
Object {
"windowSize": 3,
}
}
imageComponent={[Function]}
images={
Array [
Object {
"caption": "Caption 1",
"data": "data",
"source": "source",
},
Object {
"caption": "Caption 2",
"data": "data",
"source": "source",
},
]
}
initialPage={0}
onPageSelected={[Function]}
onSingleTapConfirmed={[Function]}
onSwipedVertical={[Function]}
pageMargin={2}
removeClippedSubviews={true}
scrollViewStyle={Object {}}
style={
Object {
"flex": 1,
}
}
/>
<ForwardRef(AnimatedComponentWrapper)
style={
Array [
Object {
"height": 48,
"overflow": "hidden",
"position": "absolute",
"width": "100%",
},
Object {
"opacity": 1,
"top": 0,
},
]
}
>
<View
style={
Object {
"backgroundColor": "rgba(0, 0, 0, 0.8)",
"height": 48,
"left": 0,
"position": "absolute",
"top": 0,
"width": "100%",
}
}
>
<View
style={
Object {
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-around",
}
}
>
<ForwardRef
onPress={[Function]}
style={
Object {
"alignItems": "center",
"height": 44,
"justifyContent": "center",
"width": 48,
}
}
>
<CompassIcon
color="#fff"
name="close"
size={24}
/>
</ForwardRef>
<Text
style={
Object {
"color": "white",
"flex": 1,
"fontSize": 15,
"marginHorizontal": 10,
"textAlign": "center",
}
}
>
1/2
</Text>
<ForwardRef
onPress={[Function]}
style={
Object {
"alignItems": "center",
"height": 44,
"justifyContent": "center",
"width": 48,
}
}
>
<CompassIcon
color="#fff"
name="download-outline"
size={24}
/>
</ForwardRef>
</View>
</View>
</ForwardRef(AnimatedComponentWrapper)>
<ForwardRef(AnimatedComponentWrapper)
style={
Array [
Object {
"bottom": 0,
"opacity": 1,
},
Object {
"justifyContent": "center",
"maxHeight": 64,
"minHeight": 32,
"overflow": "hidden",
"position": "absolute",
"width": "100%",
},
]
}
>
<LinearGradient
colors={
Array [
"transparent",
"#000000",
]
}
end={
Object {
"x": 0,
"y": 0.9,
}
}
pointerEvents="none"
start={
Object {
"x": 0,
"y": 0,
}
}
style={
Object {
"justifyContent": "flex-end",
"maxHeight": 64,
"paddingBottom": 0,
"paddingHorizontal": 24,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={2}
style={
Object {
"color": "white",
"fontSize": 14,
"marginBottom": 10,
}
}
>
Caption 1
</Text>
</LinearGradient>
</ForwardRef(AnimatedComponentWrapper)>
</ForwardRef(AnimatedComponentWrapper)>
<Downloader
deviceHeight={400}
deviceWidth={300}
file={
Object {
"caption": "Caption 1",
"data": "data",
"source": "source",
}
}
force={false}
onCancelPress={[Function]}
onDownloadCancel={[Function]}
onDownloadStart={[Function]}
onDownloadSuccess={[Function]}
prompt={false}
saveToCameraRoll={true}
show={false}
/>
</ForwardRef(AnimatedComponentWrapper)>
`;
exports[`ImagePreview should match snapshot and not renderDownloadButton for local files 1`] = `
<View
style={
Object {
"alignItems": "center",
"height": 44,
"justifyContent": "center",
"width": 48,
}
}
/>
`;
exports[`ImagePreview should match snapshot, renderDownloadButton 1`] = `
<ForwardRef
onPress={[Function]}
style={
Object {
"alignItems": "center",
"height": 44,
"justifyContent": "center",
"width": 48,
}
}
>
<CompassIcon
color="#fff"
name="download-outline"
size={24}
/>
</ForwardRef>
`;

View file

@ -1,195 +0,0 @@
// 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 {
PermissionsAndroid,
StyleSheet,
ToastAndroid,
TouchableOpacity,
View,
} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import {intlShape} from 'react-intl';
import {Client4} from '@mm-redux/client';
import {DeviceTypes} from 'app/constants/';
import FormattedText from 'app/components/formatted_text';
import {getLocalFilePathFromFile, isDocument, isVideo} from 'app/utils/file';
import {emptyFunction} from 'app/utils/general';
const {DOCUMENTS_PATH, VIDEOS_PATH} = DeviceTypes;
const EXTERNAL_STORAGE_PERMISSION = 'android.permission.WRITE_EXTERNAL_STORAGE';
const HEADER_HEIGHT = 46;
const OPTION_LIST_HEIGHT = 39;
export default class Downloader extends PureComponent {
static propTypes = {
file: PropTypes.object.isRequired,
onDownloadCancel: PropTypes.func,
onDownloadStart: PropTypes.func,
onDownloadSuccess: PropTypes.func,
show: PropTypes.bool,
};
static defaultProps = {
onCancelPress: emptyFunction,
onDownloadStart: emptyFunction,
onDownloadSuccess: emptyFunction,
show: false,
};
static contextTypes = {
intl: intlShape,
};
checkForPermissions = async () => {
const canWriteToStorage = await PermissionsAndroid.check(EXTERNAL_STORAGE_PERMISSION);
if (!canWriteToStorage) {
const {intl} = this.context;
const description = intl.formatMessage({
id: 'mobile.downloader.android_permission',
defaultMessage: 'We need access to the downloads folder to save files.',
});
const permissionRequest = await PermissionsAndroid.request(EXTERNAL_STORAGE_PERMISSION, description);
return permissionRequest === 'granted';
}
return true;
};
handleDownload = async () => {
const {file, onDownloadCancel, onDownloadStart, onDownloadSuccess} = this.props;
const {intl} = this.context;
const {data} = file;
const canWriteToStorage = await this.checkForPermissions();
if (!canWriteToStorage) {
onDownloadCancel();
return;
}
try {
const started = intl.formatMessage({
id: 'mobile.downloader.android_started',
defaultMessage: 'Download started',
});
const complete = intl.formatMessage({
id: 'mobile.downloader.android_complete',
defaultMessage: 'Download complete',
});
ToastAndroid.show(started, ToastAndroid.SHORT);
onDownloadStart();
let dest = `${RNFetchBlob.fs.dirs.DownloadDir}/${data.id}-${data.name}`;
let downloadFile = true;
if (data.localPath) {
const exists = await RNFetchBlob.fs.exists(data.localPath);
if (exists) {
downloadFile = false;
await RNFetchBlob.fs.cp(data.localPath, dest);
}
} else if (isVideo(data)) {
const path = getLocalFilePathFromFile(VIDEOS_PATH, file);
const exists = await RNFetchBlob.fs.exists(path);
if (exists) {
downloadFile = false;
dest = getLocalFilePathFromFile(RNFetchBlob.fs.dirs.DownloadDir, file);
await RNFetchBlob.fs.cp(path, dest);
}
} else if (isDocument(data)) {
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, file);
const exists = await RNFetchBlob.fs.exists(path);
if (exists) {
downloadFile = false;
await RNFetchBlob.fs.cp(path, dest);
}
}
if (downloadFile) {
const imageUrl = Client4.getFileUrl(data.id);
const task = RNFetchBlob.config({
fileCache: true,
addAndroidDownloads: {
useDownloadManager: true,
notification: true,
path: dest,
title: `${file.caption}`,
mime: data.mime_type,
description: data.name,
mediaScannable: true,
},
}).fetch('GET', imageUrl, {
Authorization: `Bearer ${Client4.token}`,
'X-Requested-With': 'XMLHttpRequest',
});
await task;
}
ToastAndroid.show(complete, ToastAndroid.SHORT);
onDownloadSuccess();
} catch (error) {
const failed = intl.formatMessage({
id: 'mobile.downloader.android_failed',
defaultMessage: 'Download failed',
});
ToastAndroid.show(failed, ToastAndroid.SHORT);
onDownloadCancel();
}
};
render() {
const {show} = this.props;
if (!show) {
return null;
}
return (
<View style={styles.wrapper}>
<TouchableOpacity
style={styles.downloadButton}
onPress={this.handleDownload}
>
<FormattedText
id='file_attachment.download'
defaultMessage='Download'
style={styles.downloadButtonText}
/>
</TouchableOpacity>
</View>
);
}
}
const styles = StyleSheet.create({
downloadButton: {
flex: 1,
justifyContent: 'center',
},
downloadButtonText: {
color: 'white',
fontSize: 15,
paddingLeft: 15,
},
wrapper: {
position: 'absolute',
backgroundColor: '#575757',
height: OPTION_LIST_HEIGHT,
top: HEADER_HEIGHT,
right: 0,
width: 150,
marginRight: 5,
},
});

View file

@ -1,449 +0,0 @@
// 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 {Alert, Animated, InteractionManager, StyleSheet, Text, TouchableOpacity, View} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import {CircularProgress} from 'react-native-circular-progress';
import {intlShape} from 'react-intl';
import CameraRoll from '@react-native-community/cameraroll';
import {Client4} from '@mm-redux/client';
import mattermostBucket from 'app/mattermost_bucket';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {getLocalFilePathFromFile} from '@utils/file';
import {emptyFunction} from '@utils/general';
import DownloaderBottomContent from './downloader_bottom_content.js';
const {View: AnimatedView} = Animated;
export default class Downloader extends PureComponent {
static propTypes = {
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
downloadPath: PropTypes.string,
file: PropTypes.object.isRequired,
onDownloadCancel: PropTypes.func,
onDownloadSuccess: PropTypes.func,
prompt: PropTypes.bool,
show: PropTypes.bool,
saveToCameraRoll: PropTypes.bool,
};
static defaultProps = {
onCancelPress: emptyFunction,
onDownloadCancel: emptyFunction,
onDownloadSuccess: emptyFunction,
prompt: false,
show: false,
force: false,
saveToCameraRoll: true,
};
static contextTypes = {
intl: intlShape,
};
static getDerivedStateFromProps(props) {
if (!props.show) {
return {
didCancel: false,
progress: 0,
};
}
return null;
}
constructor(props) {
super(props);
this.state = {
downloaderTop: new Animated.Value(props.deviceHeight),
progress: 0,
started: false,
};
}
componentDidMount() {
this.mounted = true;
if (this.props.show) {
InteractionManager.runAfterInteractions(() => {
this.toggleDownloader(true);
});
}
}
componentWillUnmount() {
this.mounted = false;
if (this.downloadTask) {
this.downloadTask.cancel();
}
}
componentDidUpdate(prevProps) {
if (this.props.show !== prevProps.show) {
this.toggleDownloader(this.props.show);
} else if (this.props.deviceHeight !== prevProps.deviceHeight) {
this.recenterDownloader(this.props);
}
}
downloadDidCancel = () => {
if (this.mounted) {
this.setState({
didCancel: true,
progress: 0,
started: false,
});
}
if (this.downloadTask) {
this.downloadTask.cancel();
}
this.props.onDownloadCancel();
};
recenterDownloader = (props) => {
const {deviceHeight, show} = props;
const top = show ? (deviceHeight / 2) - 100 : deviceHeight;
Animated.sequence([
Animated.delay(200),
Animated.spring(this.state.downloaderTop, {
toValue: top,
tension: 8,
friction: 5,
}),
]).start();
};
renderProgress = (fill) => {
const {isVideo} = this.state;
const realFill = Number(fill.toFixed(0));
let component;
if (realFill === 100) {
component = (
<CompassIcon
name='check'
size={64}
color='white'
/>
);
} else {
component = (
<View style={styles.progressCirclePercentage}>
<Text style={styles.progressText}>
{`${fill.toFixed(0)}%`}
</Text>
{!isVideo &&
<TouchableOpacity
style={styles.cancelButton}
onPress={this.downloadDidCancel}
>
<FormattedText
id='channel_modal.cancel'
defaultMessage='Cancel'
style={styles.cancelText}
/>
</TouchableOpacity>
}
</View>
);
}
return (
<View>
{component}
</View>
);
};
renderStartDownload = () => {
return (
<View>
<TouchableOpacity onPress={this.startDownload}>
<View style={styles.manualDownloadContainer}>
<CompassIcon
name='download-outline'
size={48}
color='white'
/>
<View style={styles.downloadTextContainer}>
<FormattedText
id='file_attachment.download'
defaultMessage='Download'
style={styles.cancelText}
/>
</View>
</View>
</TouchableOpacity>
</View>
);
};
saveVideo = (videoPath) => {
const {deviceHeight} = this.props;
const top = (deviceHeight / 2) - 100;
this.setState({
progress: 100,
started: true,
force: true,
isVideo: true,
});
Animated.spring(this.state.downloaderTop, {
toValue: top,
tension: 8,
friction: 5,
}).start(async () => {
await CameraRoll.save(videoPath, {type: 'video'});
this.props.onDownloadSuccess();
InteractionManager.runAfterInteractions(() => {
this.setState({force: false, isVideo: false});
});
});
};
showDownloadFailedAlert = () => {
const {intl} = this.context;
Alert.alert(
intl.formatMessage({
id: 'mobile.downloader.failed_title',
defaultMessage: 'Download failed',
}),
intl.formatMessage({
id: 'mobile.downloader.failed_description',
defaultMessage: 'An error occurred while downloading the file. Please check your internet connection and try again.\n',
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
onPress: () => this.downloadDidCancel(),
}],
);
};
startDownload = async () => {
const {file, downloadPath, prompt, saveToCameraRoll} = this.props;
const {data} = file;
try {
if (this.state.didCancel) {
this.setState({didCancel: false});
}
const certificate = await mattermostBucket.getPreference('cert');
const imageUrl = Client4.getFileUrl(data.id);
const options = {
session: data.id,
timeout: 10000,
indicator: true,
overwrite: true,
certificate,
};
if (downloadPath && prompt) {
const isDir = await RNFetchBlob.fs.isDir(downloadPath);
if (!isDir) {
try {
await RNFetchBlob.fs.mkdir(downloadPath);
} catch (error) {
this.showDownloadFailedAlert();
return;
}
}
options.path = getLocalFilePathFromFile(downloadPath, file);
} else {
options.fileCache = true;
options.appendExt = data.extension;
}
this.downloadTask = RNFetchBlob.config(options).fetch('GET', imageUrl);
this.downloadTask.progress((received, total) => {
const progress = (received / total) * 100;
if (this.mounted) {
this.setState({
progress,
started: true,
});
}
});
const res = await this.downloadTask;
let path = res.path();
if (saveToCameraRoll) {
path = await CameraRoll.save(path, {type: 'photo'});
}
if (this.mounted) {
this.setState({
progress: 100,
}, async () => {
if (this.state.didCancel) {
try {
await RNFetchBlob.fs.unlink(path);
} finally {
this.downloadDidCancel();
}
} else {
this.props.onDownloadSuccess();
}
});
}
if (saveToCameraRoll && res) {
res.flush(); // remove the temp file
}
this.downloadTask = null;
} catch (error) {
// cancellation throws so we need to catch
if (downloadPath) {
RNFetchBlob.fs.unlink(getLocalFilePathFromFile(downloadPath, file)).catch(() => {
// do nothing
});
}
if (error.message !== 'canceled' && this.mounted) {
this.showDownloadFailedAlert();
} else {
this.downloadDidCancel();
}
}
};
toggleDownloader = (show = true) => {
const {deviceHeight, prompt} = this.props;
const top = show ? (deviceHeight / 2) - 100 : deviceHeight;
Animated.spring(this.state.downloaderTop, {
toValue: top,
tension: 8,
friction: 5,
}).start(() => {
if (show && !prompt) {
this.startDownload();
}
});
};
render() {
const {show, downloadPath, saveToCameraRoll} = this.props;
if (!show && !this.state.force) {
return null;
}
const {progress, started, isVideo} = this.state;
const containerHeight = show ? '100%' : 0;
let component;
if (downloadPath && !started) {
component = this.renderStartDownload;
} else {
component = this.renderProgress;
}
return (
<View style={[styles.container, {height: containerHeight}]}>
<AnimatedView style={[styles.downloader, {top: this.state.downloaderTop}]}>
<View style={styles.progressCircleContent}>
<CircularProgress
size={120}
fill={progress}
width={4}
backgroundColor='rgba(255, 255, 255, 0.5)'
tintColor='white'
rotation={0}
>
{component}
</CircularProgress>
{ !this.state.didCancel && (
<DownloaderBottomContent
saveToCameraRoll={saveToCameraRoll}
isVideo={isVideo}
progressPercent={progress}
/>
)}
</View>
</AnimatedView>
</View>
);
}
}
const styles = StyleSheet.create({
bottomContent: {
alignItems: 'center',
marginTop: 10,
},
bottomText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
cancelButton: {
height: 30,
width: 60,
alignItems: 'center',
justifyContent: 'center',
marginTop: 5,
},
cancelText: {
color: 'white',
fontSize: 12,
},
container: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
},
downloader: {
alignItems: 'center',
alignSelf: 'center',
height: 220,
width: 236,
borderRadius: 8,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
},
progressContainer: {
flex: 1,
},
progressCircle: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
progressCircleContent: {
width: 200,
height: 200,
alignItems: 'center',
justifyContent: 'flex-start',
paddingTop: 40,
},
progressCirclePercentage: {
flex: 1,
alignItems: 'center',
marginTop: 40,
},
progressText: {
color: 'white',
fontSize: 18,
},
manualDownloadContainer: {
alignItems: 'center',
justifyContent: 'center',
},
downloadTextContainer: {
marginTop: 5,
},
});

View file

@ -1,75 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import FormattedText from 'app/components/formatted_text';
import {View, StyleSheet} from 'react-native';
import PropTypes from 'prop-types';
const DownloaderBottomContent = ({saveToCameraRoll, isVideo, progressPercent}) => {
const realFill = Number(progressPercent.toFixed(0));
if (realFill === 0) {
return null;
}
let savedComponent;
if (realFill < 100) {
savedComponent = (
<FormattedText
id='mobile.downloader.downloading'
defaultMessage='Downloading...'
style={styles.bottomText}
/>
);
} else if (saveToCameraRoll && isVideo) {
savedComponent = (
<FormattedText
id='mobile.downloader.video_saved'
defaultMessage='Video Saved'
style={styles.bottomText}
/>
);
} else if (saveToCameraRoll) {
savedComponent = (
<FormattedText
id='mobile.downloader.image_saved'
defaultMessage='Image Saved'
style={styles.bottomText}
/>
);
} else {
savedComponent = (
<FormattedText
id='mobile.downloader.complete'
defaultMessage='Download complete'
style={styles.bottomText}
/>
);
}
return (
<View style={styles.bottomContent}>
{savedComponent}
</View>
);
};
DownloaderBottomContent.propTypes = {
saveToCameraRoll: PropTypes.bool,
isVideo: PropTypes.bool,
progressPercent: PropTypes.number,
};
const styles = StyleSheet.create({
bottomContent: {
alignItems: 'center',
marginTop: 10,
},
bottomText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
});
export default DownloaderBottomContent;

View file

@ -1,58 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import DownloaderBottomContent from './downloader_bottom_content';
describe('DownloaderBottomContent', () => {
const baseProps = {
progressPercent: 10,
isVideo: false,
saveToCameraRoll: true,
};
test('should match snapshot for downloading', () => {
const wrapper = shallow(
<DownloaderBottomContent {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for downloaded file and isVideo', () => {
const wrapper = shallow(
<DownloaderBottomContent
{...baseProps}
isVideo={true}
progressPercent={100}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for downloaded file and is not video', () => {
const wrapper = shallow(
<DownloaderBottomContent
{...baseProps}
progressPercent={100}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for downloaded file', () => {
const wrapper = shallow(
<DownloaderBottomContent
{...baseProps}
saveToCameraRoll={false}
progressPercent={100}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -1,655 +0,0 @@
// 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 {
Alert,
Animated,
Platform,
SafeAreaView,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
findNodeHandle,
} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import LinearGradient from 'react-native-linear-gradient';
import {intlShape} from 'react-intl';
import Permissions from 'react-native-permissions';
import Gallery from 'react-native-image-gallery';
import DeviceInfo from 'react-native-device-info';
import FastImage from 'react-native-fast-image';
import EventEmitter from '@mm-redux/utils/event_emitter';
import CompassIcon from '@components/compass_icon';
import FileAttachmentDocument from '@components/file_attachment_list/file_attachment_document';
import FileAttachmentIcon from '@components/file_attachment_list/file_attachment_icon';
import {DeviceTypes, NavigationTypes} from '@constants';
import {getLocalFilePathFromFile, isDocument, isVideo} from '@utils/file';
import {emptyFunction} from '@utils/general';
import {calculateDimensions} from '@utils/images';
import {t} from '@utils/i18n';
import BottomSheet from '@utils/bottom_sheet';
import {
dismissModal,
mergeNavigationOptions,
} from '@actions/navigation';
import Downloader from './downloader';
import VideoPreview from './video_preview';
const {VIDEOS_PATH} = DeviceTypes;
const {View: AnimatedView} = Animated;
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
const HEADER_HEIGHT = 48;
const ANIM_CONFIG = {duration: 300, userNativeDriver: true};
export default class ImagePreview extends PureComponent {
static propTypes = {
componentId: PropTypes.string.isRequired,
canDownloadFiles: PropTypes.bool.isRequired,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
files: PropTypes.array,
getItemMeasures: PropTypes.func.isRequired,
index: PropTypes.number.isRequired,
origin: PropTypes.object,
target: PropTypes.object,
theme: PropTypes.object.isRequired,
};
static defaultProps = {
files: [],
};
static contextTypes = {
intl: intlShape,
};
constructor(props) {
super(props);
const options = {
layout: {
backgroundColor: '#000',
},
};
mergeNavigationOptions(props.componentId, options);
this.openAnim = new Animated.Value(0);
this.headerFooterAnim = new Animated.Value(1);
this.documents = [];
this.state = {
index: props.index,
origin: props.origin,
showDownloader: false,
target: props.target,
};
this.headerRef = React.createRef();
}
componentDidMount() {
this.startOpenAnimation();
}
componentWillUnmount() {
StatusBar.setHidden(false, 'fade');
}
setDocumentRef = (ref) => {
this.documents[this.state.index] = ref;
}
setDownloaderRef = (ref) => {
this.downloaderRef = ref;
}
animateOpenAnimToValue = (toValue, onComplete) => {
Animated.timing(this.openAnim, {
...ANIM_CONFIG,
toValue,
}).start(() => {
this.setState({animating: false});
if (onComplete) {
onComplete();
}
});
};
close = () => {
const {getItemMeasures, componentId} = this.props;
const {index} = this.state;
this.setState({animating: true});
const options = {
layout: {
backgroundColor: 'transparent',
},
};
mergeNavigationOptions(componentId, options);
getItemMeasures(index, (origin) => {
if (origin) {
this.setState(origin);
}
this.animateOpenAnimToValue(0, () => {
dismissModal();
});
});
};
handleChangeImage = (index) => {
this.setState({index});
};
handleSwipedVertical = (evt, gestureState) => {
if (Math.abs(gestureState.dy) > 150) {
this.close();
}
};
handleTapped = () => {
const {showHeaderFooter} = this.state;
this.setHeaderAndFooterVisible(!showHeaderFooter);
};
hideDownloader = (hideFileInfo = true) => {
this.setState({showDownloader: false});
if (hideFileInfo) {
this.setHeaderAndFooterVisible(true);
}
};
getCurrentFile = () => {
const {files} = this.props;
const {index} = this.state;
const file = files[index];
return file;
};
getFullscreenOpacity = () => {
const {target} = this.props;
return {
opacity: this.openAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, target.opacity],
}),
};
};
getHeaderFooterStyle = () => {
return {
start: this.headerFooterAnim.interpolate({
inputRange: [0, 1],
outputRange: [-80, 0],
}),
opacity: this.headerFooterAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
}),
};
};
renderAttachmentDocument = (file) => {
const {canDownloadFiles, theme} = this.props;
return (
<View style={[style.flex, style.center]}>
<FileAttachmentDocument
ref={this.setDocumentRef}
backgroundColor='transparent'
canDownloadFiles={canDownloadFiles}
file={file}
theme={theme}
/>
</View>
);
};
renderAttachmentIcon = (file) => {
return (
<View style={[style.flex, style.center]}>
<FileAttachmentIcon
backgroundColor='transparent'
file={file}
theme={this.props.theme}
iconSize={120}
/>
</View>
);
};
renderDownloadButton = () => {
const {canDownloadFiles} = this.props;
const file = this.getCurrentFile();
if (file?.data?.localPath) {
// We already have the file locally so we don't need to download it
return <View style={style.headerIcon}/>;
}
if (file) {
let icon;
let action = emptyFunction;
if (canDownloadFiles) {
action = this.showDownloadOptions;
if (Platform.OS === 'android') {
icon = (
<CompassIcon
name='dots-vertical'
size={24}
color='#fff'
/>
);
} else if (file.source || isVideo(file.data)) {
icon = (
<CompassIcon
name='download-outline'
size={24}
color='#fff'
/>
);
}
}
return (
<TouchableOpacity
ref={this.headerRef}
onPress={action}
style={style.headerIcon}
>
{icon}
</TouchableOpacity>
);
}
return null;
};
renderDownloader = () => {
const {deviceHeight, deviceWidth} = this.props;
const file = this.getCurrentFile();
return (
<Downloader
ref={this.setDownloaderRef}
show={this.state.showDownloader}
file={file}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
onDownloadCancel={this.hideDownloader}
onDownloadStart={this.hideDownloader}
onDownloadSuccess={this.hideDownloader}
/>
);
};
renderFooter = () => {
const {files} = this.props;
const {index} = this.state;
const footer = this.getHeaderFooterStyle();
return (
<Animated.View style={[{bottom: footer.start, opacity: footer.opacity}, style.footerContainer]}>
<LinearGradient
style={style.footer}
start={{x: 0.0, y: 0.0}}
end={{x: 0.0, y: 0.9}}
colors={['transparent', '#000000']}
pointerEvents='none'
>
<Text
style={style.filename}
numberOfLines={2}
ellipsizeMode='tail'
>
{(files[index] && files[index].caption) || ''}
</Text>
</LinearGradient>
</Animated.View>
);
};
renderGallery = () => {
return (
<Gallery
errorComponent={this.renderOtherItems}
imageComponent={this.renderImageComponent}
images={this.props.files}
initialPage={this.state.index}
onPageSelected={this.handleChangeImage}
onSingleTapConfirmed={this.handleTapped}
onSwipedVertical={this.handleSwipedVertical}
pageMargin={2}
style={style.flex}
/>
);
};
renderHeader = () => {
const {files} = this.props;
const {index} = this.state;
const header = this.getHeaderFooterStyle();
return (
<AnimatedView style={[style.headerContainer, {top: header.start, opacity: header.opacity}]}>
<View style={style.header}>
<View style={style.headerControls}>
<TouchableOpacity
onPress={this.close}
style={style.headerIcon}
>
<CompassIcon
name='close'
size={24}
color='#fff'
/>
</TouchableOpacity>
<Text style={style.title}>
{`${index + 1}/${files.length}`}
</Text>
{this.renderDownloadButton()}
</View>
</View>
</AnimatedView>
);
};
renderImageComponent = (imageProps, imageDimensions) => {
if (imageDimensions) {
const {deviceHeight, deviceWidth} = this.props;
const {height, width} = imageDimensions;
const {style, source} = imageProps;
const statusBar = DeviceTypes.IS_IPHONE_WITH_INSETS ? 0 : 20;
const flattenStyle = StyleSheet.flatten(style);
const calculatedDimensions = calculateDimensions(height, width, deviceWidth, deviceHeight - statusBar);
const imageStyle = {...flattenStyle, ...calculatedDimensions};
return (
<View style={[style, {justifyContent: 'center', alignItems: 'center'}]}>
<FastImage
source={source}
style={imageStyle}
/>
</View>
);
}
return null;
};
renderOtherItems = (index) => {
const {files} = this.props;
const file = files[index];
if (file.data) {
if (isDocument(file.data)) {
return this.renderAttachmentDocument(file);
} else if (isVideo(file.data)) {
return this.renderVideoPreview(file);
}
return this.renderAttachmentIcon(file.data);
}
return <View/>;
};
renderVideoPreview = (file) => {
const {deviceHeight, deviceWidth, theme} = this.props;
const statusBar = DeviceTypes.IS_IPHONE_WITH_INSETS ? 0 : 20;
return (
<VideoPreview
file={file}
onFullScreen={this.setHeaderAndFooterVisible}
deviceHeight={deviceHeight - statusBar}
deviceWidth={deviceWidth}
theme={theme}
/>
);
};
saveVideoIOS = () => {
const file = this.getCurrentFile();
if (this.downloaderRef) {
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
this.downloaderRef.saveVideo(getLocalFilePathFromFile(VIDEOS_PATH, file));
}
};
setHeaderAndFooterVisible = (show) => {
const toValue = show ? 1 : 0;
if (!show) {
this.hideDownloader();
}
this.setState({showHeaderFooter: show});
if (Platform.OS === 'ios') {
StatusBar.setHidden(!show, 'slide');
}
Animated.timing(this.headerFooterAnim, {
...ANIM_CONFIG,
toValue,
}).start();
};
showDownloader = () => {
EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL);
this.setState({
showDownloader: true,
});
};
showDownloadOptions = () => {
if (Platform.OS === 'android') {
if (this.state.showDownloader) {
this.hideDownloader();
} else {
this.showDownloader();
}
} else {
this.showDownloadOptionsIOS();
}
};
showDownloadOptionsIOS = async () => {
const {formatMessage} = this.context.intl;
const file = this.getCurrentFile();
const options = [];
const actions = [];
let permissionRequest;
const photo = Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY;
const hasPermissionToStorage = await Permissions.check(photo);
switch (hasPermissionToStorage) {
case Permissions.RESULTS.DENIED:
permissionRequest = await Permissions.request(photo);
if (permissionRequest !== Permissions.RESULTS.GRANTED) {
return;
}
break;
case Permissions.RESULTS.BLOCKED: {
const grantOption = {
text: formatMessage({id: 'mobile.permission_denied_retry', defaultMessage: 'Settings'}),
onPress: () => Permissions.openSettings(),
};
const applicationName = DeviceInfo.getApplicationName();
Alert.alert(
formatMessage({
id: 'mobile.photo_library_permission_denied_title',
defaultMessage: '{applicationName} would like to access your photo library',
}, {applicationName}),
formatMessage({
id: 'mobile.photo_library_permission_denied_description',
defaultMessage: 'To save images and videos to your library, please change your permission settings.',
}),
[
grantOption,
{text: formatMessage({id: 'mobile.permission_denied_dismiss', defaultMessage: 'Don\'t Allow'})},
],
);
return;
}
}
if (isVideo(file.data)) {
const path = getLocalFilePathFromFile(VIDEOS_PATH, file);
const exist = await RNFetchBlob.fs.exists(path);
if (exist) {
options.push(formatMessage({
id: t('mobile.image_preview.save_video'),
defaultMessage: 'Save Video',
}));
actions.push(this.saveVideoIOS);
} else {
this.showVideoDownloadRequiredAlertIOS();
}
} else {
options.push(formatMessage({
id: t('mobile.image_preview.save'),
defaultMessage: 'Save Image',
}));
actions.push(this.showDownloader);
}
if (options.length) {
options.push(formatMessage({
id: 'mobile.post.cancel',
defaultMessage: 'Cancel',
}));
actions.push(emptyFunction);
BottomSheet.showBottomSheetWithOptions({
options,
cancelButtonIndex: options.length - 1,
title: file.caption,
anchor: this.headerRef.current ? findNodeHandle(this.headerRef.current) : null,
}, (buttonIndex) => {
actions[buttonIndex]();
});
}
};
showVideoDownloadRequiredAlertIOS = () => {
const {intl} = this.context;
Alert.alert(
intl.formatMessage({
id: 'mobile.video.save_error_title',
defaultMessage: 'Save Video Error',
}),
intl.formatMessage({
id: 'mobile.video.save_error_message',
defaultMessage: 'To save the video file you need to download it first.',
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}],
);
};
startOpenAnimation = () => {
this.animateOpenAnimToValue(1);
};
render() {
const opacity = this.getFullscreenOpacity();
return (
<AnimatedSafeAreaView style={[style.container, opacity]}>
<AnimatedView style={style.container}>
{this.renderGallery()}
{this.renderHeader()}
{this.renderFooter()}
</AnimatedView>
{this.renderDownloader()}
</AnimatedSafeAreaView>
);
}
}
const style = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
flex: {
flex: 1,
},
center: {
alignItems: 'center',
justifyContent: 'center',
},
fullWidth: {
width: '100%',
},
headerContainer: {
position: 'absolute',
height: HEADER_HEIGHT,
width: '100%',
overflow: 'hidden',
},
header: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
height: HEADER_HEIGHT,
position: 'absolute',
top: 0,
left: 0,
width: '100%',
},
headerControls: {
alignItems: 'center',
justifyContent: 'space-around',
flexDirection: 'row',
},
headerIcon: {
height: 44,
width: 48,
alignItems: 'center',
justifyContent: 'center',
},
title: {
flex: 1,
marginHorizontal: 10,
color: 'white',
fontSize: 15,
textAlign: 'center',
},
footerContainer: {
minHeight: 32,
maxHeight: 64,
justifyContent: 'center',
overflow: 'hidden',
position: 'absolute',
width: '100%',
},
footer: {
maxHeight: 64,
justifyContent: 'flex-end',
paddingHorizontal: 24,
paddingBottom: 0,
},
filename: {
color: 'white',
fontSize: 14,
marginBottom: 10,
},
});

View file

@ -1,219 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {TouchableOpacity} from 'react-native';
// import RNFetchBlob from 'rn-fetch-blob';
import Preferences from '@mm-redux/constants/preferences';
import * as NavigationActions from 'app/actions/navigation';
// import BottomSheet from 'app/utils/bottom_sheet';
import ImagePreview from './image_preview';
jest.useFakeTimers();
jest.mock('react-intl');
jest.mock('react-native-file-viewer', () => ({
open: jest.fn(),
}));
describe('ImagePreview', () => {
const baseProps = {
canDownloadFiles: true,
deviceHeight: 400,
deviceWidth: 300,
files: [
{caption: 'Caption 1', source: 'source', data: 'data'},
{caption: 'Caption 2', source: 'source', data: 'data'},
],
getItemMeasures: jest.fn(),
index: 0,
origin: {},
target: {},
theme: Preferences.THEMES.default,
componentId: 'component-id',
};
test('should match snapshot', () => {
const wrapper = shallow(
<ImagePreview {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find(TouchableOpacity).first().exists()).toEqual(true);
});
test('should match snapshot, renderDownloadButton', () => {
const wrapper = shallow(
<ImagePreview {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.instance().renderDownloadButton()).toMatchSnapshot();
expect(wrapper.find({name: 'download-outline'}).exists()).toBe(true);
});
test('should match snapshot and not renderDownloadButton for local files', () => {
const props = {
...baseProps,
files: [{caption: 'Caption 1', source: 'source', data: {localPath: 'path'}}],
};
const wrapper = shallow(
<ImagePreview {...props}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.instance().renderDownloadButton()).toMatchSnapshot();
expect(wrapper.find({name: 'download-outline'}).exists()).toBe(false);
});
test('should match state on handleChangeImage', () => {
const wrapper = shallow(
<ImagePreview {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
wrapper.setState({index: 0});
wrapper.instance().handleChangeImage(1);
expect(wrapper.state('index')).toEqual(1);
});
test('should match call getItemMeasures & mergeNavigationOptions on close', () => {
const mergeNavigationOptions = jest.spyOn(NavigationActions, 'mergeNavigationOptions');
const getItemMeasures = jest.fn();
const wrapper = shallow(
<ImagePreview
{...baseProps}
getItemMeasures={getItemMeasures}
/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
wrapper.instance().close();
expect(mergeNavigationOptions).toHaveBeenCalledTimes(2);
expect(mergeNavigationOptions).toHaveBeenCalledWith(
baseProps.componentId,
{
layout: {
backgroundColor: 'transparent',
},
},
);
});
// test('should show bottom sheet when showDownloadOptionsIOS is called for existing video', async () => {
// BottomSheet.showBottomSheetWithOptions = jest.fn();
// RNFetchBlob.fs.exists = jest.fn().mockReturnValue(true);
// const formatMessage = jest.fn(({defaultMessage}) => {
// return defaultMessage;
// });
// const index = 0;
// const files = [{
// caption: 'caption',
// data: {
// mime_type: 'video/mp4',
// },
// }];
// const props = {
// ...baseProps,
// index,
// files,
// };
// const wrapper = shallow(
// <ImagePreview
// {...props}
// />,
// {context: {intl: {formatMessage}}},
// );
// const instance = wrapper.instance();
// await instance.showDownloadOptionsIOS();
// const expectedOptions = {
// options: ['Save Video', 'Cancel'],
// cancelButtonIndex: 1,
// anchor: null,
// title: files[index].caption,
// };
// expect(BottomSheet.showBottomSheetWithOptions).
// toHaveBeenCalledWith(expectedOptions, expect.any(Function));
// });
// test('should not show bottom sheet when showDownloadOptionsIOS is called for non-existing video', async () => {
// BottomSheet.showBottomSheetWithOptions = jest.fn();
// RNFetchBlob.fs.exists = jest.fn().mockReturnValue(false);
// const index = 0;
// const files = [{
// caption: 'caption',
// data: {
// mime_type: 'video/mp4',
// },
// }];
// const props = {
// ...baseProps,
// index,
// files,
// };
// const wrapper = shallow(
// <ImagePreview
// {...props}
// />,
// {context: {intl: {formatMessage: jest.fn()}}},
// );
// const instance = wrapper.instance();
// await instance.showDownloadOptionsIOS();
// expect(BottomSheet.showBottomSheetWithOptions).not.toHaveBeenCalled();
// });
// test('should show bottom sheet when showDownloadOptionsIOS is called for image', async () => {
// BottomSheet.showBottomSheetWithOptions = jest.fn();
// RNFetchBlob.fs.exists = jest.fn().mockReturnValue(true);
// const formatMessage = jest.fn(({defaultMessage}) => {
// return defaultMessage;
// });
// const index = 0;
// const files = [{
// caption: 'caption',
// data: {
// mime_type: 'image/jpeg',
// },
// }];
// const props = {
// ...baseProps,
// index,
// files,
// };
// const wrapper = shallow(
// <ImagePreview
// {...props}
// />,
// {context: {intl: {formatMessage}}},
// );
// const instance = wrapper.instance();
// await instance.showDownloadOptionsIOS();
// const expectedOptions = {
// options: ['Save Image', 'Cancel'],
// cancelButtonIndex: 1,
// anchor: null,
// title: files[index].caption,
// };
// expect(BottomSheet.showBottomSheetWithOptions).
// toHaveBeenCalledWith(expectedOptions, expect.any(Function));
// });
});

View file

@ -1,21 +0,0 @@
// 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 {canDownloadFilesOnMobile} from '@mm-redux/selectors/entities/general';
import {getDimensions} from 'app/selectors/device';
import ImagePreview from './image_preview';
function mapStateToProps(state) {
return {
...getDimensions(state),
canDownloadFiles: canDownloadFilesOnMobile(state),
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(ImagePreview);

View file

@ -1,252 +0,0 @@
// 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 {
Alert,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
import Video from 'react-native-video';
import RNFetchBlob from 'rn-fetch-blob';
import {intlShape} from 'react-intl';
import EventEmitter from '@mm-redux/utils/event_emitter';
import VideoControls, {PLAYER_STATE} from 'app/components/video_controls';
import {DeviceTypes} from 'app/constants/';
import {getLocalFilePathFromFile} from 'app/utils/file';
import Downloader from './downloader.ios';
const {VIDEOS_PATH} = DeviceTypes;
export default class VideoPreview extends PureComponent {
static propTypes = {
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
file: PropTypes.object.isRequired,
onFullScreen: PropTypes.func.isRequired,
onSeeking: PropTypes.func,
theme: PropTypes.object.isRequired,
};
static contextTypes = {
intl: intlShape,
};
constructor(props) {
super(props);
let path = null;
const {file} = props;
if (file && file.data && file.data.localPath) {
path = file.data.localPath;
}
this.state = {
isLoading: true,
isFullScreen: false,
playerState: PLAYER_STATE.PAUSED,
paused: true,
currentTime: 0,
duration: 0,
path,
showDownloader: true,
};
}
componentDidMount() {
EventEmitter.on('stop-video-playback', this.stopPlayback);
this.initializeComponent();
}
componentWillUnmount() {
EventEmitter.off('stop-video-playback', this.stopPlayback);
}
setVideoPlayerRef = (ref) => {
this.videoPlayerRef = ref;
}
setControlsRef = (ref) => {
this.controlsRef = ref;
}
async initializeComponent() {
const {file} = this.props;
const prefix = Platform.OS === 'android' ? 'file:/' : '';
const path = getLocalFilePathFromFile(VIDEOS_PATH, file);
const exist = await RNFetchBlob.fs.exists(`${prefix}${path}`);
if (exist) {
this.setState({path});
}
}
exitFullScreen = () => {
this.setState({isFullScreen: false});
this.props.onFullScreen(true);
};
enterFullScreen = () => {
this.setState({isFullScreen: true});
this.props.onFullScreen(false);
};
onDownloadSuccess = () => {
const {file} = this.props;
const path = file.data.localPath || getLocalFilePathFromFile(VIDEOS_PATH, file);
this.setState({showDownloader: false, path});
};
onEnd = () => {
if (this.state.isFullScreen) {
this.props.onFullScreen(true);
}
this.setState({playerState: PLAYER_STATE.ENDED, isFullScreen: false, paused: true});
};
onError = () => {
const {intl} = this.context;
Alert.alert(
intl.formatMessage({
id: 'mobile.video_playback.failed_title',
defaultMessage: 'Video playback failed',
}),
intl.formatMessage({
id: 'mobile.video_playback.failed_description',
defaultMessage: 'An error occurred while trying to play the video.\n',
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}],
);
};
onFullScreen = () => {
if (this.state.isFullScreen) {
this.exitFullScreen();
} else {
this.enterFullScreen();
}
};
onLoad = (data) => {
this.setState({duration: data.duration, isLoading: false});
};
onLoadStart = () => {
this.setState({isLoading: true});
};
onPaused = () => {
this.setState({
paused: !this.state.paused,
playerState: this.state.paused ? PLAYER_STATE.PLAYING : PLAYER_STATE.PAUSED,
});
};
onProgress = (data) => {
if (this.state.isLoading || this.state.playerState !== PLAYER_STATE.PLAYING) {
return;
}
this.setState({currentTime: data.currentTime});
};
onReplay = () => {
if (this.videoPlayerRef) {
this.setState({playerState: PLAYER_STATE.PLAYING, paused: false});
this.videoPlayerRef.seek(0);
}
};
onSeek = (seek) => {
if (this.videoPlayerRef) {
this.setState({currentTime: seek}, () => {
this.videoPlayerRef.seek(seek);
});
}
};
stopPlayback = () => {
if (!this.state.paused) {
this.onPaused();
}
};
render() {
const {
deviceHeight,
deviceWidth,
file,
onSeeking,
theme,
} = this.props;
const {currentTime, duration, isFullScreen, isLoading, path, paused, playerState, showDownloader} = this.state;
if (!path) {
return (
<Downloader
show={showDownloader}
file={file}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
downloadPath={VIDEOS_PATH}
prompt={true}
saveToCameraRoll={false}
onDownloadSuccess={this.onDownloadSuccess}
/>
);
}
return (
<View style={StyleSheet.absoluteFill}>
<TouchableOpacity
style={StyleSheet.absoluteFill}
activeOpacity={1}
onPress={() => this.controlsRef?.fadeInControls()}
>
<Video
ref={this.setVideoPlayerRef}
style={[StyleSheet.absoluteFill, {position: 'absolute'}]}
resizeMode='contain'
source={{uri: path}}
volume={1.0}
paused={paused}
onEnd={this.onEnd}
onLoad={this.onLoad}
onLoadStart={this.onLoadStart}
onProgress={this.onProgress}
onError={this.onError}
/>
</TouchableOpacity>
<VideoControls
ref={this.setControlsRef}
mainColor={theme.linkColor}
playerState={playerState}
isFullScreen={isFullScreen}
isLoading={isLoading}
progress={currentTime}
duration={duration}
onPaused={this.onPaused}
onSeek={this.onSeek}
onSeeking={onSeeking}
onReplay={this.onReplay}
onFullScreen={this.onFullScreen}
controlButtonBg={theme.buttonBg}
/>
</View>
);
}
}

View file

@ -4,92 +4,216 @@
import React from 'react';
import {Platform} from 'react-native';
import {ThemeProvider} from 'react-native-elements';
import {Provider} from 'react-redux';
import {Navigation} from 'react-native-navigation';
import {gestureHandlerRootHOC} from 'react-native-gesture-handler';
let Root;
export function registerScreens(store, Provider) {
// TODO consolidate this with app/utils/wrap_context_provider
import RootWrapper from '@components/root';
let store;
if (!Root) {
Root = require('app/components/root').default;
}
const wrapper = (Comp, excludeEvents = true) => (props) => ( // eslint-disable-line react/display-name
<Provider store={store}>
<ThemeProvider>
<Root excludeEvents={excludeEvents}>
<Comp {...props}/>
</Root>
</ThemeProvider>
</Provider>
);
Navigation.registerComponent('About', () => wrapper(require('app/screens/about').default), () => require('app/screens/about').default);
Navigation.registerComponent('AddReaction', () => wrapper(require('app/screens/add_reaction').default), () => require('app/screens/add_reaction').default);
Navigation.registerComponent('AdvancedSettings', () => wrapper(require('app/screens/settings/advanced_settings').default), () => require('app/screens/settings/advanced_settings').default);
Navigation.registerComponent('Channel', () => wrapper(require('app/screens/channel').default, false), () => require('app/screens/channel').default);
Navigation.registerComponent('ChannelAddMembers', () => wrapper(require('app/screens/channel_add_members').default), () => require('app/screens/channel_add_members').default);
Navigation.registerComponent('ChannelInfo', () => wrapper(require('app/screens/channel_info').default), () => require('app/screens/channel_info').default);
Navigation.registerComponent('ChannelMembers', () => wrapper(require('app/screens/channel_members').default), () => require('app/screens/channel_members').default);
Navigation.registerComponent('ChannelNotificationPreference', () => wrapper(require('app/screens/channel_notification_preference').default), () => require('app/screens/channel_notification_preference').default);
Navigation.registerComponent('ClientUpgrade', () => wrapper(require('app/screens/client_upgrade').default), () => require('app/screens/client_upgrade').default);
Navigation.registerComponent('ClockDisplaySettings', () => wrapper(require('app/screens/settings/clock_display').default), () => require('app/screens/settings/clock_display').default);
Navigation.registerComponent('Code', () => wrapper(require('app/screens/code').default), () => require('app/screens/code').default);
Navigation.registerComponent('CreateChannel', () => wrapper(require('app/screens/create_channel').default), () => require('app/screens/create_channel').default);
Navigation.registerComponent('DisplaySettings', () => wrapper(require('app/screens/settings/display_settings').default), () => require('app/screens/settings/display_settings').default);
Navigation.registerComponent('EditChannel', () => wrapper(require('app/screens/edit_channel').default), () => require('app/screens/edit_channel').default);
Navigation.registerComponent('EditPost', () => wrapper(require('app/screens/edit_post').default), () => require('app/screens/edit_post').default);
Navigation.registerComponent('EditProfile', () => wrapper(require('app/screens/edit_profile').default), () => require('app/screens/edit_profile').default);
Navigation.registerComponent('ExpandedAnnouncementBanner', () => wrapper(require('app/screens/expanded_announcement_banner').default), () => require('app/screens/expanded_announcement_banner').default);
Navigation.registerComponent('FlaggedPosts', () => wrapper(require('app/screens/flagged_posts').default), () => require('app/screens/flagged_posts').default);
Navigation.registerComponent('ForgotPassword', () => wrapper(require('app/screens/forgot_password').default), () => require('app/screens/forgot_password').default);
Navigation.registerComponent('ImagePreview', () => wrapper(require('app/screens/image_preview').default), () => require('app/screens/image_preview').default);
Navigation.registerComponent('InteractiveDialog', () => wrapper(require('app/screens/interactive_dialog').default), () => require('app/screens/interactive_dialog').default);
Navigation.registerComponent('Login', () => wrapper(require('app/screens/login').default), () => require('app/screens/login').default);
Navigation.registerComponent('LoginOptions', () => wrapper(require('app/screens/login_options').default), () => require('app/screens/login_options').default);
Navigation.registerComponent('LongPost', () => wrapper(require('app/screens/long_post').default), () => require('app/screens/long_post').default);
Navigation.registerComponent('MFA', () => wrapper(require('app/screens/mfa').default), () => require('app/screens/mfa').default);
Navigation.registerComponent('MoreChannels', () => wrapper(require('app/screens/more_channels').default), () => require('app/screens/more_channels').default);
Navigation.registerComponent('MoreDirectMessages', () => wrapper(require('app/screens/more_dms').default), () => require('app/screens/more_dms').default);
const withGestures = (screen) => {
if (Platform.OS === 'android') {
Navigation.registerComponent('Notification', () => gestureHandlerRootHOC(wrapper(require('app/screens/notification/index.tsx').default), {flex: undefined, height: 100}), () => require('app/screens/notification/index.tsx').default);
} else {
Navigation.registerComponent('Notification', () => wrapper(require('app/screens/notification/index.tsx').default), () => require('app/screens/notification/index.tsx').default);
return gestureHandlerRootHOC(screen);
}
Navigation.registerComponent('NotificationSettings', () => wrapper(require('app/screens/settings/notification_settings').default), () => require('app/screens/settings/notification_settings').default);
Navigation.registerComponent('NotificationSettingsAutoResponder', () => wrapper(require('app/screens/settings/notification_settings_auto_responder').default), () => require('app/screens/settings/notification_settings_auto_responder').default);
Navigation.registerComponent('NotificationSettingsEmail', () => wrapper(require('app/screens/settings/notification_settings_email').default), () => require('app/screens/settings/notification_settings_email').default);
Navigation.registerComponent('NotificationSettingsMentions', () => wrapper(require('app/screens/settings/notification_settings_mentions').default), () => require('app/screens/settings/notification_settings_mentions').default);
Navigation.registerComponent('NotificationSettingsMentionsKeywords', () => wrapper(require('app/screens/settings/notification_settings_mentions_keywords').default), () => require('app/screens/settings/notification_settings_mentions_keywords').default);
Navigation.registerComponent('NotificationSettingsMobile', () => wrapper(require('app/screens/settings/notification_settings_mobile').default), () => require('app/screens/settings/notification_settings_mobile').default);
Navigation.registerComponent('OptionsModal', () => wrapper(require('app/screens/options_modal').default), () => require('app/screens/options_modal').default);
Navigation.registerComponent('Permalink', () => wrapper(require('app/screens/permalink').default), () => require('app/screens/permalink').default);
Navigation.registerComponent('PinnedPosts', () => wrapper(require('app/screens/pinned_posts').default), () => require('app/screens/pinned_posts').default);
Navigation.registerComponent('PostOptions', () => gestureHandlerRootHOC(wrapper(require('app/screens/post_options').default)), () => require('app/screens/post_options').default);
Navigation.registerComponent('ReactionList', () => gestureHandlerRootHOC(wrapper(require('app/screens/reaction_list').default)), () => require('app/screens/reaction_list').default);
Navigation.registerComponent('RecentMentions', () => wrapper(require('app/screens/recent_mentions').default), () => require('app/screens/recent_mentions').default);
Navigation.registerComponent('Search', () => wrapper(require('app/screens/search').default), () => require('app/screens/search').default);
Navigation.registerComponent('SelectorScreen', () => wrapper(require('app/screens/selector_screen').default), () => require('app/screens/selector_screen').default);
Navigation.registerComponent('SelectServer', () => wrapper(require('app/screens/select_server').default, false), () => require('app/screens/select_server').default);
Navigation.registerComponent('SelectTeam', () => wrapper(require('app/screens/select_team').default), () => require('app/screens/select_team').default);
Navigation.registerComponent('SelectTimezone', () => wrapper(require('app/screens/settings/timezone/select_timezone').default), () => require('app/screens/settings/timezone/select_timezone').default);
Navigation.registerComponent('Settings', () => wrapper(require('app/screens/settings/general').default), () => require('app/screens/settings/general').default);
Navigation.registerComponent('SidebarSettings', () => wrapper(require('app/screens/settings/sidebar').default), () => require('app/screens/settings/sidebar').default);
Navigation.registerComponent('SSO', () => wrapper(require('app/screens/sso').default), () => require('app/screens/sso').default);
Navigation.registerComponent('Table', () => wrapper(require('app/screens/table').default), () => require('app/screens/table').default);
Navigation.registerComponent('TableImage', () => wrapper(require('app/screens/table_image').default), () => require('app/screens/table_image').default);
Navigation.registerComponent('TermsOfService', () => wrapper(require('app/screens/terms_of_service').default), () => require('app/screens/terms_of_service').default);
Navigation.registerComponent('TextPreview', () => wrapper(require('app/screens/text_preview').default), () => require('app/screens/text_preview').default);
Navigation.registerComponent('ThemeSettings', () => wrapper(require('app/screens/settings/theme').default), () => require('app/screens/settings/theme').default);
Navigation.registerComponent('Thread', () => wrapper(require('app/screens/thread').default), () => require('app/screens/thread').default);
Navigation.registerComponent('TimezoneSettings', () => wrapper(require('app/screens/settings/timezone').default), () => require('app/screens/settings/timezone').default);
Navigation.registerComponent('ErrorTeamsList', () => wrapper(require('app/screens/error_teams_list').default), () => require('app/screens/error_teams_list').default);
Navigation.registerComponent('UserProfile', () => wrapper(require('app/screens/user_profile').default), () => require('app/screens/user_profile').default);
if (Platform.OS === 'android') {
Navigation.registerComponentWithRedux('MainSidebar', () => require('app/components/sidebars/main').default, Provider, store);
Navigation.registerComponentWithRedux('SettingsSidebar', () => require('app/components/sidebars/settings').default, Provider, store);
return screen;
};
// eslint-disable-next-line react/display-name
const withReduxProvider = (Screen, excludeEvents = true) => (props) => (
<Provider store={store}>
<ThemeProvider>
<RootWrapper excludeEvents={excludeEvents}>
<Screen {...props}/>
</RootWrapper>
</ThemeProvider>
</Provider>
);
Navigation.setLazyComponentRegistrator((screenName) => {
let screen;
let extraStyles;
switch (screenName) {
case 'About':
screen = require('@screens/about').default;
break;
case 'AddReaction':
screen = require('@screens/add_reaction').default;
break;
case 'AdvancedSettings':
screen = require('@screens/settings/advanced_settings').default;
break;
case 'ChannelAddMembers':
screen = require('@screens/channel_add_members').default;
break;
case 'ChannelInfo':
screen = require('@screens/channel_info').default;
break;
case 'ChannelMembers':
screen = require('@screens/channel_members').default;
break;
case 'ChannelNotificationPreference':
screen = require('@screens/channel_notification_preference').default;
break;
case 'ClientUpgrade':
screen = require('@screens/client_upgrade').default;
break;
case 'ClockDisplaySettings':
screen = require('@screens/settings/clock_display').default;
break;
case 'Code':
screen = require('@screens/code').default;
break;
case 'CreateChannel':
screen = require('@screens/create_channel').default;
break;
case 'DisplaySettings':
screen = require('@screens/settings/display_settings').default;
break;
case 'EditChannel':
screen = require('@screens/edit_channel').default;
break;
case 'EditPost':
screen = require('@screens/edit_post').default;
break;
case 'EditProfile':
screen = require('@screens/edit_profile').default;
break;
case 'ErrorTeamsList':
screen = require('@screens/error_teams_list').default;
break;
case 'ExpandedAnnouncementBanner':
screen = require('@screens/expanded_announcement_banner').default;
break;
case 'FlaggedPosts':
screen = require('@screens/flagged_posts').default;
break;
case 'ForgotPassword':
screen = require('@screens/forgot_password').default;
break;
case 'Gallery':
screen = require('@screens/gallery').default;
break;
case 'InteractiveDialog':
screen = require('@screens/interactive_dialog').default;
break;
case 'Login':
screen = require('@screens/login').default;
break;
case 'LoginOptions':
screen = require('@screens/login_options').default;
break;
case 'LongPost':
screen = require('@screens/long_post').default;
break;
case 'MainSidebar':
screen = require('app/components/sidebars/main').default;
break;
case 'MFA':
screen = require('@screens/mfa').default;
break;
case 'MoreChannels':
screen = require('@screens/more_channels').default;
break;
case 'MoreDirectMessages':
screen = require('@screens/more_dms').default;
break;
case 'Notification':
extraStyles = Platform.select({android: {flex: undefined, height: 100}});
screen = require('@screens/notification/index.tsx').default;
break;
case 'NotificationSettings':
screen = require('@screens/settings/notification_settings').default;
break;
case 'NotificationSettingsAutoResponder':
screen = require('@screens/settings/notification_settings_auto_responder').default;
break;
case 'NotificationSettingsEmail':
screen = require('@screens/settings/notification_settings_email').default;
break;
case 'NotificationSettingsMentions':
screen = require('@screens/settings/notification_settings_mentions').default;
break;
case 'NotificationSettingsMentionsKeywords':
screen = require('@screens/settings/notification_settings_mentions_keywords').default;
break;
case 'NotificationSettingsMobile':
screen = require('@screens/settings/notification_settings_mobile').default;
break;
case 'OptionsModal':
screen = require('@screens/options_modal').default;
break;
case 'Permalink':
screen = require('@screens/permalink').default;
break;
case 'PinnedPosts':
screen = require('@screens/pinned_posts').default;
break;
case 'PostOptions':
screen = require('@screens/post_options').default;
break;
case 'ReactionList':
screen = require('@screens/reaction_list').default;
break;
case 'RecentMentions':
screen = require('@screens/recent_mentions').default;
break;
case 'Search':
screen = require('@screens/search').default;
break;
case 'SelectorScreen':
screen = require('@screens/selector_screen').default;
break;
case 'SelectTeam':
screen = require('@screens/select_team').default;
break;
case 'SelectTimezone':
screen = require('@screens/settings/timezone/select_timezone').default;
break;
case 'Settings':
screen = require('@screens/settings/general').default;
break;
case 'SettingsSidebar':
screen = require('app/components/sidebars/settings').default;
break;
case 'SidebarSettings':
screen = require('@screens/settings/sidebar').default;
break;
case 'SSO':
screen = require('@screens/sso').default;
break;
case 'Table':
screen = require('@screens/table').default;
break;
case 'TermsOfService':
screen = require('@screens/terms_of_service').default;
break;
case 'ThemeSettings':
screen = require('@screens/settings/theme').default;
break;
case 'Thread':
screen = require('@screens/thread').default;
break;
case 'TimezoneSettings':
screen = require('@screens/settings/timezone').default;
break;
case 'UserProfile':
screen = require('@screens/user_profile').default;
break;
}
if (screen) {
Navigation.registerComponent(screenName, () => withGestures(withReduxProvider(screen)), extraStyles, () => screen);
}
});
export function registerScreens(reduxStore) {
store = reduxStore;
const channelScreen = require('@screens/channel').default;
const serverScreen = require('@screens/select_server').default;
Navigation.registerComponent('Channel', () => withReduxProvider(channelScreen, false), () => channelScreen);
Navigation.registerComponent('SelectServer', () => withReduxProvider(serverScreen, false), () => serverScreen);
}

View file

@ -36,6 +36,7 @@ exports[`DialogIntroductionText should render the introduction text correctly 1`
}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
disableHashtags={true}
textStyles={
Object {

View file

@ -34,6 +34,7 @@ export default class DialogIntroductionText extends PureComponent {
<View style={[style.introductionTextView, padding(isLandscape)]}>
<Markdown
baseTextStyle={style.introductionText}
disableGallery={true}
textStyles={textStyles}
blockStyles={blockStyles}
value={value}

View file

@ -1,16 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getDimensions} from 'app/selectors/device';
import TableImage from './table_image';
function mapStateToProps(state) {
return {
deviceWidth: getDimensions(state).deviceWidth,
};
}
export default connect(mapStateToProps)(TableImage);

View file

@ -1,107 +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 {
ActivityIndicator,
ScrollView,
StyleSheet,
View,
} from 'react-native';
import FastImage from 'react-native-fast-image';
export default class TableImage extends React.PureComponent {
static propTypes = {
deviceWidth: PropTypes.number.isRequired,
imagesMetadata: PropTypes.object,
imageSource: PropTypes.string.isRequired,
};
constructor(props) {
super(props);
const dimensions = props.imagesMetadata?.[props.imageSource];
this.state = {
imageSource: '',
width: dimensions?.width || -1,
height: dimensions?.height || -1,
};
}
static getDerivedStateFromProps(nextProps, state) {
if (nextProps.imageSource !== state.imageSource) {
return {
width: -1,
height: -1,
};
}
return null;
}
componentDidUpdate(prevProps) {
if (prevProps.imageSource !== this.props.imageSource) {
this.getImageSize();
}
}
getImageSize = () => {
const {imageSource, imagesMetadata} = this.props;
const dimensions = imagesMetadata?.[imageSource];
this.setState({
width: dimensions?.width || -1,
height: dimensions?.height || -1,
});
}
render() {
let {width, height} = this.state;
if (width === -1 || height === -1) {
return (
<View style={style.loadingContainer}>
<ActivityIndicator
animating={true}
size='large'
/>
</View>
);
}
if (width > this.props.deviceWidth) {
height = (height / width) * this.props.deviceWidth;
width = this.props.deviceWidth;
}
return (
<ScrollView
style={style.scrollContainer}
contentContainerStyle={style.container}
>
<FastImage
style={[style.image, {width, height}]}
source={{uri: this.props.imageSource}}
/>
</ScrollView>
);
}
}
const style = StyleSheet.create({
scrollContainer: {
flex: 1,
},
container: {
flex: 1,
flexDirection: 'column',
},
image: {
resizeMode: 'contain',
},
loadingContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
});

View file

@ -42,6 +42,7 @@ exports[`TermsOfService should enable/disable navigator buttons on setNavigatorB
}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
disableHashtags={true}
textStyles={
Object {
@ -176,6 +177,7 @@ exports[`TermsOfService should enable/disable navigator buttons on setNavigatorB
}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
disableHashtags={true}
textStyles={
Object {
@ -370,6 +372,7 @@ exports[`TermsOfService should match snapshot on enableNavigatorLogout 1`] = `
}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
disableHashtags={true}
textStyles={
Object {

View file

@ -261,6 +261,7 @@ export default class TermsOfService extends PureComponent {
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
/>
</ScrollView>
</React.Fragment>

View file

@ -1,151 +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 {
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import {getCodeFont} from 'app/utils/markdown';
import {
changeOpacity,
makeStyleSheetFromTheme,
getKeyboardAppearanceFromTheme,
} from 'app/utils/theme';
export default class TextPreview extends React.PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired,
content: PropTypes.string.isRequired,
};
countLines = (content) => {
return content.split('\n').length;
};
render() {
const style = getStyleSheet(this.props.theme);
const numberOfLines = this.countLines(this.props.content);
let lineNumbers = '1';
for (let i = 1; i < numberOfLines; i++) {
const line = (i + 1).toString();
lineNumbers += '\n' + line;
}
let lineNumbersStyle;
if (numberOfLines >= 10) {
lineNumbersStyle = [style.lineNumbers, style.lineNumbersRight];
} else {
lineNumbersStyle = style.lineNumbers;
}
let textComponent;
if (Platform.OS === 'ios') {
textComponent = (
<TextInput
editable={false}
multiline={true}
value={this.props.content}
style={[style.codeText]}
keyboardAppearance={getKeyboardAppearanceFromTheme(this.props.theme)}
/>
);
} else {
textComponent = (
<Text
selectable={true}
style={style.codeText}
>
{this.props.content}
</Text>
);
}
return (
<ScrollView
style={style.scrollContainer}
contentContainerStyle={style.container}
>
<View style={lineNumbersStyle}>
<Text style={style.lineNumbersText}>
{lineNumbers}
</Text>
</View>
<ScrollView
style={style.codeContainer}
horizontal={true}
contentContainerStyle={style.code}
>
{textComponent}
</ScrollView>
</ScrollView>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
scrollContainer: {
flex: 1,
},
container: {
minHeight: '100%',
flexDirection: 'row',
},
lineNumbers: {
alignItems: 'center',
backgroundColor: changeOpacity(theme.centerChannelColor, 0.05),
borderRightColor: changeOpacity(theme.centerChannelColor, 0.15),
borderRightWidth: StyleSheet.hairlineWidth,
flexDirection: 'column',
justifyContent: 'flex-start',
paddingHorizontal: 6,
paddingVertical: 4,
},
lineNumbersRight: {
alignItems: 'flex-end',
},
lineNumbersText: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 12,
lineHeight: 18,
textAlign: 'right',
},
codeContainer: {
flexGrow: 0,
flexShrink: 1,
width: '100%',
},
code: {
paddingHorizontal: 6,
...Platform.select({
android: {
paddingVertical: 4,
},
ios: {
top: -4,
},
}),
},
codeText: {
color: changeOpacity(theme.centerChannelColor, 0.65),
fontFamily: getCodeFont(),
fontSize: 12,
lineHeight: 18,
...Platform.select({
ios: {
margin: 0,
padding: 0,
},
}),
},
};
});

View file

@ -5,13 +5,13 @@ import {Platform} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import mimeDB from 'mime-db';
import {lookupMimeType} from '@mm-redux/utils/file_utils';
import {DeviceTypes} from '@constants';
const EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/;
const CONTENT_DISPOSITION_REGEXP = /inline;filename=".*\.([a-z]+)";/i;
const DEFAULT_SERVER_MAX_FILE_SIZE = 50 * 1024 * 1024;// 50 Mb
export const SUPPORTED_DOCS_FORMAT = [
export const GENERAL_SUPPORTED_DOCS_FORMAT = [
'application/json',
'application/msword',
'application/pdf',
@ -27,6 +27,16 @@ export const SUPPORTED_DOCS_FORMAT = [
'text/plain',
];
const SUPPORTED_DOCS_FORMAT = Platform.select({
android: GENERAL_SUPPORTED_DOCS_FORMAT,
ios: [
...GENERAL_SUPPORTED_DOCS_FORMAT,
'application/vnd.apple.pages',
'application/vnd.apple.numbers',
'application/vnd.apple.keynote',
],
});
const SUPPORTED_VIDEO_FORMAT = Platform.select({
ios: ['video/mp4', 'video/x-m4v', 'video/quicktime'],
android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm'],
@ -115,6 +125,15 @@ export async function deleteFileCache() {
return true;
}
export function lookupMimeType(filename) {
if (!Object.keys(extensions).length) {
populateMaps();
}
const ext = filename.split('.').pop()?.toLowerCase();
return types[ext] || 'application/octet-stream';
}
export function buildFileUploadData(file) {
const re = /heic/i;
const uri = file.uri;
@ -145,27 +164,35 @@ export const getAllowedServerMaxFileSize = (config) => {
};
export const isGif = (file) => {
let mime = file.mime_type || file.type || '';
let mime = file?.mime_type || file?.type || '';
if (mime && mime.includes(';')) {
mime = mime.split(';')[0];
} else if (!mime && file?.name) {
mime = lookupMimeType(file.name);
}
return mime === 'image/gif';
};
export const isImage = (file) => (file?.has_preview_image || isGif(file) || file?.type?.startsWith('image/'));
export const isDocument = (file) => {
let mime = file.mime_type || file.type || '';
let mime = file?.mime_type || file?.type || '';
if (mime && mime.includes(';')) {
mime = mime.split(';')[0];
} else if (!mime && file?.name) {
mime = lookupMimeType(file.name);
}
return SUPPORTED_DOCS_FORMAT.includes(mime);
};
export const isVideo = (file) => {
let mime = file.mime_type || file.type || '';
let mime = file?.mime_type || file?.type || '';
if (mime && mime.includes(';')) {
mime = mime.split(';')[0];
} else if (!mime && file?.name) {
mime = lookupMimeType(file.name);
}
return SUPPORTED_VIDEO_FORMAT.includes(mime);
@ -237,12 +264,45 @@ function populateMaps() {
});
}
export function getLocalFilePathFromFile(dir, file) {
if (dir && file && file.data && file.data.id && file.data.extension) {
return `${dir}/${file.data.id}.${file.data.extension}`;
export const hashCode = (str) => {
let hash = 0;
let i;
let chr;
if (!str || str.length === 0) {
return hash;
}
return null;
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
};
export function getLocalFilePathFromFile(dir, file) {
if (dir) {
if (file?.name) {
const [filename, extension] = file.name.split('.');
return `${dir}/${filename}-${hashCode(file.id)}.${extension}`;
} else if (file?.id && file?.extension) {
return `${dir}/${file.id}.${file.extension}`;
}
}
return undefined;
}
export function getLocalPath(file) {
if (file.localPath) {
return file.localPath;
} else if (isVideo(file)) {
return getLocalFilePathFromFile(DeviceTypes.VIDEOS_PATH, file);
} else if (isImage(file)) {
return getLocalFilePathFromFile(DeviceTypes.IMAGES_PATH, file);
}
return getLocalFilePathFromFile(DeviceTypes.DOCUMENTS_PATH, file);
}
export function getExtensionFromContentDisposition(contentDisposition) {

View file

@ -3,10 +3,14 @@
import {
generateId,
getLocalPath,
getLocalFilePathFromFile,
getExtensionFromContentDisposition,
hashCode,
} from 'app/utils/file';
import {DeviceTypes} from 'app/constants';
describe('getExtensionFromContentDisposition', () => {
it('should return the extracted the extension', () => {
const exts = [
@ -50,32 +54,76 @@ describe('getExtensionFromContentDisposition', () => {
});
it('should return the path for the file', () => {
const data = {
const file = {
id: generateId(),
name: 'Some Video file.mp4',
extension: 'mp4',
};
const localFile = getLocalFilePathFromFile('Videos', {data});
expect(localFile).toBe(`Videos/${data.id}.${data.extension}`);
const localFile = getLocalFilePathFromFile('Videos', file);
const [filename] = file.name.split('.');
expect(localFile).toBe(`Videos/${filename}-${hashCode(file.id)}.${file.extension}`);
});
it('should return the null as the path if it does not have an id set', () => {
const data = {
it('should return the undefined as the path if it does not have an id and name set', () => {
const file = {
extension: 'mp4',
};
const localFile = getLocalFilePathFromFile('Videos', file);
expect(localFile).toBeUndefined();
});
it('should return the undefined as the path if it does not have an extension set', () => {
const file = {
id: generateId(),
};
const localFile = getLocalFilePathFromFile('Videos', file);
expect(localFile).toBeUndefined();
});
it('should return the same path as the local file', () => {
const file = {
localPath: 'some/path/filename.mp4',
};
const localFile = getLocalPath(file);
expect(localFile).toEqual(file.localPath);
});
it('should return the path for the video file', () => {
const file = {
id: generateId(),
name: 'Some Video file.mp4',
extension: 'mp4',
};
const localFile = getLocalFilePathFromFile('Videos', {data});
expect(localFile).toBeNull();
const localFile = getLocalPath(file);
const [filename] = file.name.split('.');
expect(localFile).toBe(`${DeviceTypes.VIDEOS_PATH}/${filename}-${hashCode(file.id)}.${file.extension}`);
});
it('should return the null as the path if it does not have an extension set', () => {
const data = {
it('should return the path for the image file', () => {
const file = {
id: generateId(),
name: 'Some Video file.mp4',
name: 'Some animated image.gif',
extension: 'gif',
};
const localFile = getLocalFilePathFromFile('Videos', {data});
expect(localFile).toBeNull();
const localFile = getLocalPath(file);
const [filename] = file.name.split('.');
expect(localFile).toBe(`${DeviceTypes.IMAGES_PATH}/${filename}-${hashCode(file.id)}.${file.extension}`);
});
it('should return the path for the document file', () => {
const file = {
id: generateId(),
name: 'Some other doocument.txt',
extension: 'txt',
};
const localFile = getLocalPath(file);
const [filename] = file.name.split('.');
expect(localFile).toBe(`${DeviceTypes.DOCUMENTS_PATH}/${filename}-${hashCode(file.id)}.${file.extension}`);
});
});

View file

@ -14,6 +14,7 @@ import {DeviceTypes} from 'app/constants';
import {
getExtensionFromMime,
getExtensionFromContentDisposition,
hashCode,
} from 'app/utils/file';
import mattermostBucket from 'app/mattermost_bucket';
@ -157,20 +158,3 @@ const notifyAll = (uri, path) => {
}
});
};
// taken from https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript-jquery
export const hashCode = (str) => {
let hash = 0;
let i;
let chr;
if (!str || str.length === 0) {
return hash;
}
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
};

View file

@ -5,7 +5,7 @@
import RNFetchBlob from 'rn-fetch-blob';
import ImageCacheManager, {getCacheFile, hashCode} from 'app/utils/image_cache_manager';
import ImageCacheManager, {getCacheFile} from 'app/utils/image_cache_manager';
import {emptyFunction} from 'app/utils/general';
import * as fileUtils from 'app/utils/file';
import mattermostBucket from 'app/mattermost_bucket';
@ -151,7 +151,7 @@ describe('ImageCacheManager.cache', () => {
RNFetchBlob.fs.exists.mockReturnValueOnce(false);
RNFetchBlob.fs.existsWithDiffExt.mockReturnValueOnce(null);
const ext = 'jpg';
const ext = 'jpeg';
const headers = {'Content-Type': 'image/jpeg'};
RNFetchBlob.fetch.mockReturnValueOnce({respInfo: {respType: '', headers}});
fileUtils.getExtensionFromMime.
@ -301,8 +301,8 @@ describe('ImageCacheManager.cache', () => {
const expectedFileUri = 'https://file-uri/ABC123';
const fileName = null;
const incorrectHash = hashCode(fileUri);
const expectedHash = hashCode(expectedFileUri);
const incorrectHash = fileUtils.hashCode(fileUri);
const expectedHash = fileUtils.hashCode(expectedFileUri);
expect(incorrectHash).not.toBe(expectedHash);
RNFetchBlob.fs.exists.mockReturnValueOnce(false);

View file

@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Dimensions, Keyboard} from 'react-native';
import {Dimensions, Keyboard, Platform} from 'react-native';
import {showModalOverCurrentContext} from '@actions/navigation';
import {goToScreen} from '@actions/navigation';
import {DeviceTypes} from '@constants';
import {
IMAGE_MAX_HEIGHT,
@ -12,8 +12,7 @@ import {
VIEWPORT_IMAGE_OFFSET,
VIEWPORT_IMAGE_REPLY_OFFSET,
} from '@constants/image';
let previewComponents;
import {isImage} from './file';
export const calculateDimensions = (height, width, viewPortWidth = 0, viewPortHeight = 0) => {
if (!height || !width) {
@ -77,39 +76,80 @@ export function getViewPortWidth(isReplyPost, permanentSidebar = false) {
return portraitPostWidth;
}
export function previewImageAtIndex(components, index, files) {
previewComponents = components;
const component = components[index];
if (component) {
component.measure((rx, ry, width, height, x, y) => {
Keyboard.dismiss();
requestAnimationFrame(() => {
const screen = 'ImagePreview';
const passProps = {
index,
origin: {x, y, width, height},
target: {x: 0, y: 0, opacity: 1},
files,
getItemMeasures,
};
showModalOverCurrentContext(screen, passProps);
export function openGalleryAtIndex(index, files) {
Keyboard.dismiss();
requestAnimationFrame(() => {
const screen = 'Gallery';
const passProps = {
index,
files,
};
const windowHeight = Dimensions.get('window').height;
const sharedElementTransitions = [];
const contentPush = {};
const contentPop = {};
const file = files[index];
if (isImage(file)) {
sharedElementTransitions.push({
fromId: `image-${file.id}`,
toId: `gallery-${file.id}`,
interpolation: 'overshoot',
});
});
}
}
} else {
contentPush.y = {
from: windowHeight,
to: 0,
duration: 300,
interpolation: 'decelerate',
};
function getItemMeasures(index, cb) {
const activeComponent = previewComponents[index];
if (Platform.OS === 'ios') {
contentPop.translationY = {
from: 0,
to: windowHeight,
duration: 300,
};
} else {
contentPop.y = {
from: 0,
to: windowHeight,
duration: 300,
};
contentPop.alpha = {
from: 1,
to: 0,
duration: 100,
};
}
}
if (!activeComponent) {
cb(null);
return;
}
const options = {
layout: {
backgroundColor: '#000',
componentBackgroundColor: '#000',
},
topBar: {
background: {
color: '#000',
},
visible: Platform.OS === 'android',
},
animations: {
push: {
waitForRender: true,
sharedElementTransitions,
...Platform.select({ios: {
content: contentPush,
}}),
},
pop: {
content: contentPop,
},
},
};
activeComponent.measure((rx, ry, width, height, x, y) => {
cb({
origin: {x, y, width, height},
});
goToScreen(screen, '', passProps, options);
});
}

View file

@ -2875,6 +2875,20 @@
"search": [
"radiobox-blank"
]
},
{
"uid": "53586a0ccc205f12eb53587faa230a71",
"css": "export-variant",
"code": 986003,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M500 41.7L333.3 208.3H458.3V583.3H541.7V208.3H666.7M750 958.3H250C203.7 958.3 166.7 920.8 166.7 875V375A83.3 83.3 0 0 1 250 291.7H375V375H250V875H750V375H625V291.7H750A83.3 83.3 0 0 1 833.3 375V875A83.3 83.3 0 0 1 750 958.3Z",
"width": 1000
},
"search": [
"export-variant"
]
}
]
}

View file

@ -89,8 +89,11 @@
"date_separator.yesterday": "Yesterday",
"edit_post.editPost": "Edit the post...",
"edit_post.save": "Save",
"file_attachment.download": "Download",
"file_upload.fileAbove": "Files must be less than {max}",
"gallery.download_file": "Download file",
"gallery.footer.channel_name": "Shared in {channelName}",
"gallery.open_file": "Open file",
"gallery.unsuppored": "Preview is not supported for this file type",
"get_post_link_modal.title": "Copy Link",
"integrations.add": "Add",
"intro_messages.anyMember": " Any member can join and read this channel.",
@ -229,18 +232,10 @@
"mobile.display_settings.theme": "Theme",
"mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n",
"mobile.document_preview.failed_title": "Open Document failed",
"mobile.downloader.android_complete": "Download complete",
"mobile.downloader.android_failed": "Download failed",
"mobile.downloader.android_permission": "We need access to the downloads folder to save files.",
"mobile.downloader.android_started": "Download started",
"mobile.downloader.complete": "Download complete",
"mobile.downloader.disabled_description": "File downloads are disabled on this server. Please contact your System Admin for more details.\n",
"mobile.downloader.disabled_title": "Download disabled",
"mobile.downloader.downloading": "Downloading...",
"mobile.downloader.failed_description": "An error occurred while downloading the file. Please check your internet connection and try again.\n",
"mobile.downloader.failed_title": "Download failed",
"mobile.downloader.image_saved": "Image Saved",
"mobile.downloader.video_saved": "Video Saved",
"mobile.drawer.teamsTitle": "Teams",
"mobile.edit_channel": "Save",
"mobile.edit_post.title": "Editing Message",
@ -287,9 +282,8 @@
"mobile.files_paste.error_title": "Paste failed",
"mobile.flagged_posts.empty_description": "Saved messages are only visible to you. Mark messages for follow-up or save something for later by long-pressing a message and choosing Save from the menu.",
"mobile.flagged_posts.empty_title": "No Saved messages yet",
"mobile.gallery.title": "{index} of {total}",
"mobile.help.title": "Help",
"mobile.image_preview.save": "Save Image",
"mobile.image_preview.save_video": "Save Video",
"mobile.intro_messages.default_message": "This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.",
"mobile.intro_messages.default_welcome": "Welcome to {name}!",
"mobile.intro_messages.DM": "This is the start of your direct message history with {teammate}. Direct messages and files shared here are not shown to people outside this area.",
@ -377,8 +371,6 @@
"mobile.open_unknown_channel.error": "Unable to join the channel. Please reset the cache and try again.",
"mobile.permission_denied_dismiss": "Don't Allow",
"mobile.permission_denied_retry": "Settings",
"mobile.photo_library_permission_denied_description": "To save images and videos to your library, please change your permission settings.",
"mobile.photo_library_permission_denied_title": "{applicationName} would like to access your photo library",
"mobile.pinned_posts.empty_description": "Pin important messages which are visible to the whole channel. Long press on a message and choose Pin to Channel to save it here.",
"mobile.pinned_posts.empty_title": "No Pinned messages yet",
"mobile.post_info.add_reaction": "Add Reaction",
@ -412,7 +404,11 @@
"mobile.post.failed_title": "Unable to send your message",
"mobile.post.retry": "Refresh",
"mobile.posts_view.moreMsg": "More New Messages Above",
"mobile.prepare_file.failed_description": "An error occurred while preparing the file. Please try again.\n",
"mobile.prepare_file.failed_title": "Preparing failed",
"mobile.prepare_file.text": "Preparing",
"mobile.privacy_link": "Privacy Policy",
"mobile.public_link.copied": "Public link copied",
"mobile.push_notification_reply.button": "Send",
"mobile.push_notification_reply.placeholder": "Write a reply...",
"mobile.push_notification_reply.title": "Reply",
@ -452,7 +448,6 @@
"mobile.routes.settings": "Settings",
"mobile.routes.sso": "Single Sign-On",
"mobile.routes.table": "Table",
"mobile.routes.tableImage": "Image",
"mobile.routes.thread": "{channelName} Thread",
"mobile.routes.thread_dm": "Direct Message Thread",
"mobile.routes.user_profile": "Profile",
@ -530,8 +525,6 @@
"mobile.user.settings.notifications.email.fifteenMinutes": "Every 15 minutes",
"mobile.video_playback.failed_description": "An error occurred while trying to play the video.\n",
"mobile.video_playback.failed_title": "Video playback failed",
"mobile.video.save_error_message": "To save the video file you need to download it first.",
"mobile.video.save_error_title": "Save Video Error",
"mobile.youtube_playback_error.description": "An error occurred while trying to play the YouTube video.\nDetails: {details}",
"mobile.youtube_playback_error.title": "YouTube playback error",
"modal.manual_status.auto_responder.message_": "Would you like to switch your status to \"{status}\" and disable Automatic Replies?",

Binary file not shown.

View file

@ -23,6 +23,19 @@ NSString* const NOTIFICATION_UPDATE_BADGE_ACTION = @"update_badge";
os_log(OS_LOG_DEFAULT, "Mattermost session ATTACHED from handleEventsForBackgroundURLSession!! identifier=%{public}@", identifier);
}
- (NSArray<id<RCTBridgeModule>> *)extraModulesForBridge:(RCTBridge *)bridge {
return [ReactNativeNavigation extraModulesForBridge:bridge];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// Clear keychain on first run in case of reinstallation
@ -42,8 +55,7 @@ NSString* const NOTIFICATION_UPDATE_BADGE_ACTION = @"update_badge";
[[NSUserDefaults standardUserDefaults] synchronize];
}
NSURL *jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
[ReactNativeNavigation bootstrap:jsCodeLocation launchOptions:launchOptions];
[ReactNativeNavigation bootstrapWithDelegate:self launchOptions:launchOptions];
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error: nil];

View file

@ -304,15 +304,11 @@ PODS:
- React
- ReactNativeKeyboardTrackingView (5.7.0):
- React
- ReactNativeNavigation (6.12.2):
- React
- React-RCTImage
- React-RCTText
- ReactNativeNavigation/Core (= 6.12.2)
- ReactNativeNavigation/Core (6.12.2):
- React
- ReactNativeNavigation (7.1.0):
- React-Core
- React-RCTImage
- React-RCTText
- ReactNativeNavigation/Core (= 7.1.0)
- rn-fetch-blob (0.12.0):
- React-Core
- RNCAsyncStorage (1.12.1):
@ -353,6 +349,8 @@ PODS:
- RNSentry (1.9.0):
- React-Core
- Sentry (~> 5.2.0)
- RNShare (3.8.1):
- React-Core
- RNSVG (12.1.0):
- React
- RNVectorIcons (7.1.0):
@ -439,6 +437,7 @@ DEPENDENCIES:
- "RNRudderSdk (from `../node_modules/@rudderstack/rudder-sdk-react-native/ios`)"
- RNScreens (from `../node_modules/react-native-screens`)
- "RNSentry (from `../node_modules/@sentry/react-native`)"
- RNShare (from `../node_modules/react-native-share`)
- RNSVG (from `../node_modules/react-native-svg`)
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
- Swime (= 3.0.6)
@ -588,6 +587,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-screens"
RNSentry:
:path: "../node_modules/@sentry/react-native"
RNShare:
:path: "../node_modules/react-native-share"
RNSVG:
:path: "../node_modules/react-native-svg"
RNVectorIcons:
@ -646,7 +647,7 @@ SPEC CHECKSUMS:
ReactCommon: 4167844018c9ed375cc01a843e9ee564399e53c3
ReactNativeExceptionHandler: 8025d98049c25f186835a3af732dd7c9974d6dce
ReactNativeKeyboardTrackingView: 02137fac3b2ebd330d74fa54ead48b14750a2306
ReactNativeNavigation: aefc8debafb4a374575adafb44a3352b9d5b618a
ReactNativeNavigation: 65025dab27b404053678593b2450ed7a022e3173
rn-fetch-blob: 17961aec08caae68bb8fc0e5b40f93b3acfa6932
RNCAsyncStorage: cb9a623793918c6699586281f0b51cbc38f046f9
RNCClipboard: 8f9f12fabf3c06e976f19f87a62c89e28dfedfca
@ -664,6 +665,7 @@ SPEC CHECKSUMS:
RNRudderSdk: 5d99b1a5a582ab55d6213b38018d35e98818af63
RNScreens: 0e91da98ab26d5d04c7b59a9b6bd694124caf88c
RNSentry: 1adaa43b01c6a3ab5091d4d1add66b7c58558898
RNShare: 26eb3a3a3a83f059318cafb0b09547e099969058
RNSVG: ce9d996113475209013317e48b05c21ee988d42e
RNVectorIcons: bc69e6a278b14842063605de32bec61f0b251a59
Rudder: 57a48709b714c4746a96f4d4d00f33e78a62a5ee

117
package-lock.json generated
View file

@ -9237,6 +9237,16 @@
"@types/react": "*"
}
},
"@types/react-native-share": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@types/react-native-share/-/react-native-share-3.3.0.tgz",
"integrity": "sha512-qcmtUNOhIO0tlz7RVQR2FtWzFlblge8gsJED6dT16HdaHq+8ssm+onTVup7UYcE5DEvlmGXBuW2kZU41H+d3FA==",
"dev": true,
"requires": {
"@types/react": "*",
"@types/react-native": "*"
}
},
"@types/react-native-vector-icons": {
"version": "6.4.5",
"resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.5.tgz",
@ -9288,6 +9298,15 @@
"@types/react": "*"
}
},
"@types/react-test-renderer": {
"version": "16.9.3",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.9.3.tgz",
"integrity": "sha512-wJ7IlN5NI82XMLOyHSa+cNN4Z0I+8/YaLl04uDgcZ+W+ExWCmCiVTLT/7fRNqzy4OhStZcUwIqLNF7q+AdW43Q==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/react-textarea-autosize": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@types/react-textarea-autosize/-/react-textarea-autosize-4.3.5.tgz",
@ -9329,6 +9348,12 @@
"integrity": "sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA==",
"dev": true
},
"@types/tinycolor2": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.2.tgz",
"integrity": "sha512-PeHg/AtdW6aaIO2a+98Xj7rWY4KC1E6yOy7AFknJQ7VXUGNrMlyxDFxJo7HqLtjQms/ZhhQX52mLVW/EX3JGOw==",
"dev": true
},
"@types/uglify-js": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.11.0.tgz",
@ -9346,6 +9371,12 @@
}
}
},
"@types/url-parse": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/url-parse/-/url-parse-1.4.3.tgz",
"integrity": "sha512-4kHAkbV/OfW2kb5BLVUuUMoumB3CP8rHqlw48aHvFy5tf9ER0AfOonBlX29l/DD68G70DmyhRlSYfQPSYpC5Vw==",
"dev": true
},
"@types/webpack": {
"version": "4.41.22",
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.22.tgz",
@ -9718,6 +9749,11 @@
"event-target-shim": "^5.0.0"
}
},
"abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha1-32Acjo0roQ1KdtYl4japo5wnI78="
},
"absolute-path": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/absolute-path/-/absolute-path-0.0.0.tgz",
@ -23839,6 +23875,14 @@
"integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=",
"dev": true
},
"normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"requires": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"npm-run-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
@ -24544,6 +24588,11 @@
"integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=",
"dev": true
},
"parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha1-en7A0esG+lMlx9PgCbhZoJtdSes="
},
"parse5": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz",
@ -25527,15 +25576,6 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-2.0.0.tgz",
"integrity": "sha512-txfpPCQYiazVdcbMRhatqWKcAxJweUu2wDXvts5/7Wyp6+Y9cHojqXHsLPEckzutfHlxZhG8Oiundbmp8Fd6eQ=="
},
"react-mixin": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/react-mixin/-/react-mixin-3.1.1.tgz",
"integrity": "sha512-z9fZ0aCRDjlgxLdMeWkJ9TwhmVLhQ09r8RFpin/cEPA2T6jsb7YHNWcIe0Oii+hhJNyMymdy91CSya5mRkuCkg==",
"requires": {
"object-assign": "^4.0.1",
"smart-mixin": "^2.0.0"
}
},
"react-native": {
"version": "0.63.3",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.63.3.tgz",
@ -25931,14 +25971,6 @@
}
}
},
"react-native-circular-progress": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/react-native-circular-progress/-/react-native-circular-progress-1.3.6.tgz",
"integrity": "sha512-0P+RIZ1SdufLNI9oXhZCx1QBwvdkgJeyLHv+243jSvqrRVcfp6E31ktrdZr8QOpI2sG3g3n4NRrtjMxHKL7ryw==",
"requires": {
"prop-types": "^15.7.2"
}
},
"react-native-cookies": {
"version": "github:mattermost/react-native-cookies#b35bafc388ae09c83bd875e887daf6a0755e0b40",
"from": "github:mattermost/react-native-cookies#b35bafc388ae09c83bd875e887daf6a0755e0b40",
@ -26051,15 +26083,6 @@
"resolved": "https://registry.npmjs.org/react-native-hw-keyboard-event/-/react-native-hw-keyboard-event-0.0.4.tgz",
"integrity": "sha512-G8qp0nm17PHigLb/axgdF9xg51BKCG2p1AGeq//J/luLp5zNczIcQJh+nm02R1MeEUE3e53wqO4LMe0MV3raZg=="
},
"react-native-image-gallery": {
"version": "github:mattermost/react-native-image-gallery#c1a9f7118e90cc87d47620bc0584c9cac4b0cf38",
"from": "github:mattermost/react-native-image-gallery#c1a9f7118e90cc87d47620bc0584c9cac4b0cf38",
"requires": {
"prop-types": "^15.6.0",
"react-mixin": "^3.0.5",
"react-timer-mixin": "^0.13.3"
}
},
"react-native-image-picker": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-2.3.4.tgz",
@ -26135,9 +26158,9 @@
}
},
"react-native-navigation": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/react-native-navigation/-/react-native-navigation-6.12.2.tgz",
"integrity": "sha512-MwUDHebTeaAoBBkRYkLfjAB9Hx5UfbykXTlll8uLWD9oTc0fO7MZtOOal2WIwbZoBeniJvXjgQmiA4grsH6mzw==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/react-native-navigation/-/react-native-navigation-7.1.0.tgz",
"integrity": "sha512-Ub5eegREee2kyI8OXAzPXAe9dJgVBpj8k3Ni4HfzA2D3+xVGwvskCmKkIJm2xU/i1GcM9TdzoloB1JHsAZl6Qw==",
"requires": {
"hoist-non-react-statics": "3.x.x",
"lodash": "4.17.x",
@ -26209,6 +26232,16 @@
"fbjs": "^1.0.0"
}
},
"react-native-redash": {
"version": "14.2.4",
"resolved": "https://registry.npmjs.org/react-native-redash/-/react-native-redash-14.2.4.tgz",
"integrity": "sha512-/1R9UxXv3ffKcrbxolqa2B247Cgd3ikyEm2q1VlBng77Es6PAD4LAOXQ83pBavvwNfOsbhF3ebkbMpJcLaVt3Q==",
"requires": {
"abs-svg-path": "^0.1.1",
"normalize-svg-path": "^1.0.1",
"parse-svg-path": "^0.1.2"
}
},
"react-native-safe-area": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/react-native-safe-area/-/react-native-safe-area-0.5.1.tgz",
@ -26243,6 +26276,11 @@
"resolved": "https://registry.npmjs.org/react-native-section-list-get-item-layout/-/react-native-section-list-get-item-layout-2.2.3.tgz",
"integrity": "sha512-fzCW5SiYP6qCZyDHebaElHonIFr8NFrZK9JDkxFLnpxMJih4d+HQ4rHyOs0Z4Gb/FjyCVbRH7RtEnjeQ0XffMg=="
},
"react-native-share": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-3.8.1.tgz",
"integrity": "sha512-LYTiwpdB2OksgfGM2xFwpk6mUUD2vyU4AOh3D29vHESHIedfRU+8CBBdZaqYWY04NM9GrM4W6S3u3iaHSKO59g=="
},
"react-native-slider": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-native-slider/-/react-native-slider-0.11.0.tgz",
@ -26773,11 +26811,6 @@
"use-latest": "^1.0.0"
}
},
"react-timer-mixin": {
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/react-timer-mixin/-/react-timer-mixin-0.13.4.tgz",
"integrity": "sha512-4+ow23tp/Tv7hBM5Az5/Be/eKKF7DIvJ09voz5LyHGQaqqz9WV8YMs31eFvcYQs7d451LSg7kDJV70XYN/Ug/Q=="
},
"react-transition-group": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
@ -27788,9 +27821,9 @@
}
},
"shaka-player": {
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/shaka-player/-/shaka-player-2.5.15.tgz",
"integrity": "sha512-c8NS338zotjYMVJVy2aRvfPdpn/JDN8g76L25AvwQCn/QXbMg7wJEcqdiJSl2PEI7fsm8hFvI59C8liLKbF+Lw==",
"version": "2.5.16",
"resolved": "https://registry.npmjs.org/shaka-player/-/shaka-player-2.5.16.tgz",
"integrity": "sha512-VlR8SMD/PADmUMea8wJPWW3rOu7E608cZCpcymqNlpjOH55Djgzp/24oXJFmeu929vWBR2UuFsqN3fShjI3OAw==",
"requires": {
"eme-encryption-scheme-polyfill": "^2.0.1"
}
@ -27944,11 +27977,6 @@
"resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz",
"integrity": "sha1-VusCfWW00tzmyy4tMsTUr8nh1wc="
},
"smart-mixin": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/smart-mixin/-/smart-mixin-2.0.0.tgz",
"integrity": "sha1-o0oQVeMqdbMNK048oyPcmctT9Dc="
},
"snapdragon": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
@ -28621,6 +28649,11 @@
}
}
},
"svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
},
"symbol-observable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz",

View file

@ -38,7 +38,6 @@
"react-native-animatable": "1.3.3",
"react-native-button": "3.0.1",
"react-native-calendars": "1.403.0",
"react-native-circular-progress": "1.3.6",
"react-native-cookies": "github:mattermost/react-native-cookies#b35bafc388ae09c83bd875e887daf6a0755e0b40",
"react-native-device-info": "7.0.2",
"react-native-document-picker": "4.0.0",
@ -49,7 +48,6 @@
"react-native-gesture-handler": "1.8.0",
"react-native-haptic-feedback": "1.11.0",
"react-native-hw-keyboard-event": "0.0.4",
"react-native-image-gallery": "github:mattermost/react-native-image-gallery#c1a9f7118e90cc87d47620bc0584c9cac4b0cf38",
"react-native-image-picker": "2.3.4",
"react-native-keyboard-aware-scrollview": "2.1.0",
"react-native-keyboard-tracking-view": "5.7.0",
@ -58,15 +56,17 @@
"react-native-local-auth": "1.6.0",
"react-native-localize": "1.4.2",
"react-native-mmkv-storage": "0.3.7",
"react-native-navigation": "6.12.2",
"react-native-navigation": "7.1.0",
"react-native-notifications": "2.1.7",
"react-native-passcode-status": "1.1.2",
"react-native-permissions": "2.2.2",
"react-native-reanimated": "1.13.1",
"react-native-redash": "14.2.4",
"react-native-safe-area": "0.5.1",
"react-native-safe-area-context": "3.1.8",
"react-native-screens": "2.11.0",
"react-native-section-list-get-item-layout": "2.2.3",
"react-native-share": "3.8.1",
"react-native-slider": "0.11.0",
"react-native-status-bar-size": "0.3.3",
"react-native-svg": "12.1.0",
@ -108,9 +108,13 @@
"@types/moment-timezone": "0.5.30",
"@types/react": "16.9.53",
"@types/react-native": "0.63.30",
"@types/react-native-share": "3.3.0",
"@types/react-native-video": "5.0.3",
"@types/react-redux": "7.1.9",
"@types/react-test-renderer": "16.9.3",
"@types/shallow-equals": "1.0.0",
"@types/tinycolor2": "1.4.2",
"@types/url-parse": "1.4.3",
"@typescript-eslint/eslint-plugin": "3.10.1",
"@typescript-eslint/parser": "3.10.1",
"babel-eslint": "10.1.0",

View file

@ -0,0 +1,13 @@
diff --git a/node_modules/mime-db/db.json b/node_modules/mime-db/db.json
index e69f352..b21d9da 100644
--- a/node_modules/mime-db/db.json
+++ b/node_modules/mime-db/db.json
@@ -2004,7 +2004,7 @@
},
"application/vnd.apple.keynote": {
"source": "iana",
- "extensions": ["keynote"]
+ "extensions": ["key"]
},
"application/vnd.apple.mpegurl": {
"source": "iana",

View file

@ -50,10 +50,10 @@ index 0000000..a302394
+ }
+}
diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java
index 2214f04..bfb9895 100644
index e659a61..1bdf34e 100644
--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java
+++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java
@@ -45,6 +45,7 @@ public class FastImageOkHttpProgressGlideModule extends LibraryGlideModule {
@@ -43,6 +43,7 @@ public class FastImageOkHttpProgressGlideModule extends LibraryGlideModule {
OkHttpClient client = OkHttpClientProvider
.getOkHttpClient()
.newBuilder()
@ -61,3 +61,18 @@ index 2214f04..bfb9895 100644
.addInterceptor(createInterceptor(progressListener))
.build();
OkHttpUrlLoader.Factory factory = new OkHttpUrlLoader.Factory(client);
diff --git a/node_modules/react-native-fast-image/dist/index.d.ts b/node_modules/react-native-fast-image/dist/index.d.ts
index 0b1afd5..2226715 100644
--- a/node_modules/react-native-fast-image/dist/index.d.ts
+++ b/node_modules/react-native-fast-image/dist/index.d.ts
@@ -89,6 +89,10 @@ export interface FastImageProps extends AccessibilityProps {
* Render children within the image.
*/
children?: React.ReactNode;
+ /**
+ * Native ID to used with shared elements transition
+ */
+ nativeID?: string;
}
interface FastImageStaticProperties {
resizeMode: typeof resizeMode;

View file

@ -1,8 +1,8 @@
diff --git a/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java b/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java
index 260ed81..f719679 100644
index 3bfbc0b..cdb0d05 100644
--- a/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java
+++ b/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java
@@ -56,14 +56,18 @@ public class NavigationModule extends ReactContextBaseJavaModule {
@@ -55,14 +55,18 @@ public class NavigationModule extends ReactContextBaseJavaModule {
reactContext.addLifecycleEventListener(new LifecycleEventListenerAdapter() {
@Override
public void onHostResume() {
@ -45,13 +45,13 @@ index 260ed81..f719679 100644
});
}
diff --git a/node_modules/react-native-navigation/lib/ios/RNNCommandsHandler.m b/node_modules/react-native-navigation/lib/ios/RNNCommandsHandler.m
index ae8be52..8a8dec5 100644
index f7aba70..f8d3f38 100644
--- a/node_modules/react-native-navigation/lib/ios/RNNCommandsHandler.m
+++ b/node_modules/react-native-navigation/lib/ios/RNNCommandsHandler.m
@@ -300,10 +300,9 @@ - (void)dismissAllModals:(NSDictionary *)mergeOptions commandId:(NSString*)comma
@@ -312,10 +312,9 @@ - (void)dismissAllModals:(NSDictionary *)mergeOptions commandId:(NSString*)comma
[CATransaction begin];
[CATransaction setCompletionBlock:^{
[self->_eventEmitter sendOnNavigationCommandCompletion:dismissAllModals commandId:commandId params:@{}];
[self->_eventEmitter sendOnNavigationCommandCompletion:dismissAllModals commandId:commandId];
- completion();
}];
RNNNavigationOptions* options = [[RNNNavigationOptions alloc] initWithDict:mergeOptions];
@ -60,3 +60,20 @@ index ae8be52..8a8dec5 100644
[CATransaction commit];
}
diff --git a/node_modules/react-native-navigation/lib/ios/RNNViewLocation.m b/node_modules/react-native-navigation/lib/ios/RNNViewLocation.m
index f98f7a1..26ecb83 100644
--- a/node_modules/react-native-navigation/lib/ios/RNNViewLocation.m
+++ b/node_modules/react-native-navigation/lib/ios/RNNViewLocation.m
@@ -31,11 +31,7 @@ - (CGFloat)getCornerRadius:(UIView *)view {
- (CATransform3D)getTransform:(UIView *)view {
if (view) {
- if (!CATransform3DEqualToTransform(view.layer.transform, CATransform3DIdentity)) {
- return view.layer.transform;
- } else {
- return [self getTransform:view.superview];
- }
+ return view.layer.transform;
}
return CATransform3DIdentity;

Some files were not shown because too many files have changed in this diff Show more