From ef0274cad85c664b835222b1674a733c3dbf42ee Mon Sep 17 00:00:00 2001 From: Amit Uttam Date: Thu, 16 Jan 2020 22:17:03 -0300 Subject: [PATCH] [MM-16263] UI/UX Improvements to the mobile post draft area (#3807) * Adding base button functionality Moving file upload previews to be under textbox * Ensuring textbox is scrollable when in landscape mode * Updated image picker to use mixed camera option * Added unit tests, fixed other tests affected by dependency update * Updated patch for react-native-image-picker to 1.1.0 * Fixing incorrect import of DocumentPicker * MM-20989: Ensuring keyboard doesn't dismiss while submitting post (#3758) * Ensuring keyboard doesn't dismiss while submitting post * Update snapshot * Preventing the @ icon from being repeatedly tappable (#3777) * Fix snapshot from merge * MM-21736 Select/Take images and videos for Android * MM-21737 Fix attachment error message position on iOS * Remove FileUploadPreview from the iOS Thread screen * Fix android camera permissions * Fix post input box sizing and disable scrollview * Fix iOS photo gallery videos Co-authored-by: Andre Vasconcelos Co-authored-by: Elias Nahum --- .../__snapshots__/send_button.test.js.snap | 12 +- .../attachment_button/index.test.js | 3 + .../file_attachment_image.js | 6 + .../file_upload_item/file_upload_item.js | 1 + .../file_upload_preview.js | 63 ++-- .../file_upload_preview/file_upload_remove.js | 58 ++-- app/components/pasteable_text_input/index.js | 6 +- .../__snapshots__/post_textbox.test.js.snap | 269 ++++++++++++++---- .../post_textbox/components/cameraButton.js | 207 ++++++++++++++ .../components/fileUploadButton.js | 204 +++++++++++++ .../components/imageUploadButton.js | 224 +++++++++++++++ .../post_textbox/post_textbox.android.js | 6 - .../post_textbox/post_textbox.test.js | 71 ++++- .../post_textbox/post_textbox_base.js | 217 +++++++++++--- app/components/profile_picture_button.test.js | 4 + app/components/send_button.js | 4 +- app/constants/post_textbox.js | 1 - app/screens/channel/channel.ios.js | 2 - app/screens/edit_profile/edit_profile.test.js | 3 + .../__snapshots__/thread.ios.test.js.snap | 3 - app/screens/thread/thread.ios.js | 4 - app/screens/thread/thread.ios.test.js | 3 + .../base/images/icons/slash-forward-box.png | Bin 0 -> 894 bytes ios/Podfile.lock | 16 +- package-lock.json | 6 +- package.json | 2 +- .../react-native-image-picker+0.28.1.patch | 92 ------ patches/react-native-image-picker+2.0.0.patch | 184 ++++++++++++ 28 files changed, 1373 insertions(+), 298 deletions(-) create mode 100644 app/components/post_textbox/components/cameraButton.js create mode 100644 app/components/post_textbox/components/fileUploadButton.js create mode 100644 app/components/post_textbox/components/imageUploadButton.js create mode 100644 assets/base/images/icons/slash-forward-box.png delete mode 100644 patches/react-native-image-picker+0.28.1.patch create mode 100644 patches/react-native-image-picker+2.0.0.patch diff --git a/app/components/__snapshots__/send_button.test.js.snap b/app/components/__snapshots__/send_button.test.js.snap index 4a52d463d..ba764cb7d 100644 --- a/app/components/__snapshots__/send_button.test.js.snap +++ b/app/components/__snapshots__/send_button.test.js.snap @@ -16,11 +16,11 @@ exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = ` Object { "alignItems": "center", "backgroundColor": "#166de0", - "borderRadius": 18, + "borderRadius": 4, "height": 28, "justifyContent": "center", "paddingLeft": 3, - "width": 28, + "width": 72, }, Object { "backgroundColor": "rgba(22,109,224,0.3)", @@ -54,11 +54,11 @@ exports[`SendButton should match snapshot 1`] = ` Object { "alignItems": "center", "backgroundColor": "#166de0", - "borderRadius": 18, + "borderRadius": 4, "height": 28, "justifyContent": "center", "paddingLeft": 3, - "width": 28, + "width": 72, } } > @@ -88,11 +88,11 @@ exports[`SendButton should render theme backgroundColor 1`] = ` Object { "alignItems": "center", "backgroundColor": "#166de0", - "borderRadius": 18, + "borderRadius": 4, "height": 28, "justifyContent": "center", "paddingLeft": 3, - "width": 28, + "width": 72, } } > diff --git a/app/components/attachment_button/index.test.js b/app/components/attachment_button/index.test.js index 3b876bc7b..13b86c975 100644 --- a/app/components/attachment_button/index.test.js +++ b/app/components/attachment_button/index.test.js @@ -14,6 +14,9 @@ import {PermissionTypes} from 'app/constants'; import AttachmentButton from './index'; jest.mock('react-intl'); +jest.mock('react-native-image-picker', () => ({ + launchCamera: jest.fn(), +})); describe('AttachmentButton', () => { const formatMessage = jest.fn(); diff --git a/app/components/file_attachment_list/file_attachment_image.js b/app/components/file_attachment_list/file_attachment_image.js index b37afe6fa..521edef76 100644 --- a/app/components/file_attachment_list/file_attachment_image.js +++ b/app/components/file_attachment_list/file_attachment_image.js @@ -186,6 +186,12 @@ const style = StyleSheet.create({ smallImageOverlay: { ...StyleSheet.absoluteFill, justifyContent: 'center', + borderRadius: 4, + }, + loaderContainer: { + position: 'absolute', + height: '100%', + width: '100%', alignItems: 'center', }, singleSmallImageWrapper: { diff --git a/app/components/file_upload_preview/file_upload_item/file_upload_item.js b/app/components/file_upload_preview/file_upload_item/file_upload_item.js index 079f34ae7..df3ff436d 100644 --- a/app/components/file_upload_preview/file_upload_item/file_upload_item.js +++ b/app/components/file_upload_preview/file_upload_item/file_upload_item.js @@ -234,6 +234,7 @@ export default class FileUploadItem extends PureComponent { } - + + {this.buildFilePreviews()} + + {showFileMaxWarning && ( { + return { + fileContainer: { + display: 'flex', + flexDirection: 'row', + }, + errorContainer: { + height: 18, + }, + previewContainer: { + display: 'flex', + flexDirection: 'column', + }, + scrollView: { + flex: 1, + marginBottom: 10, + }, + scrollViewContent: { + alignItems: 'flex-end', + marginLeft: 14, + }, + warning: { + color: theme.errorTextColor, + marginLeft: 14, + marginBottom: Platform.select({ + android: 14, + ios: 0, + }), + }, + }; }); diff --git a/app/components/file_upload_preview/file_upload_remove.js b/app/components/file_upload_preview/file_upload_remove.js index 944352e03..fa0abd42f 100644 --- a/app/components/file_upload_preview/file_upload_remove.js +++ b/app/components/file_upload_preview/file_upload_remove.js @@ -3,8 +3,9 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {Platform, StyleSheet} from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; +import {Platform} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {makeStyleSheetFromTheme} from 'app/utils/theme'; import TouchableWithFeedback from 'app/components/touchable_with_feedback'; @@ -14,6 +15,7 @@ export default class FileUploadRemove extends PureComponent { clientId: PropTypes.string, onPress: PropTypes.func.isRequired, rootId: PropTypes.string, + theme: PropTypes.object.isRequired, }; handleOnPress = () => { @@ -23,6 +25,7 @@ export default class FileUploadRemove extends PureComponent { }; render() { + const style = getStyleSheet(this.props.theme); return ( @@ -40,28 +43,27 @@ export default class FileUploadRemove extends PureComponent { } } -const style = StyleSheet.create({ - removeButtonIcon: Platform.select({ - ios: { - marginTop: 2, +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + removeButtonIcon: Platform.select({ + ios: { + marginTop: 2, + }, + }), + removeButtonWrapper: { + alignItems: 'center', + justifyContent: 'center', + position: 'absolute', + overflow: 'hidden', + elevation: 11, + top: 7, + right: 7, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: theme.centerChannelBg, + borderWidth: 2, + borderColor: theme.centerChannelBg, }, - android: { - marginLeft: 1, - }, - }), - removeButtonWrapper: { - alignItems: 'center', - justifyContent: 'center', - position: 'absolute', - overflow: 'hidden', - elevation: 11, - top: 7, - right: 7, - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: '#000', - borderWidth: 1, - borderColor: '#fff', - }, + }; }); diff --git a/app/components/pasteable_text_input/index.js b/app/components/pasteable_text_input/index.js index ab10541ae..04b748fe4 100644 --- a/app/components/pasteable_text_input/index.js +++ b/app/components/pasteable_text_input/index.js @@ -11,7 +11,7 @@ import {ViewTypes} from 'app/constants'; const {OnPasteEventManager} = NativeModules; const OnPasteEventEmitter = new NativeEventEmitter(OnPasteEventManager); -export class PasteableTextInput extends React.Component { +export class PasteableTextInput extends React.PureComponent { static propTypes = { ...TextInput.PropTypes, onPaste: PropTypes.func, @@ -42,8 +42,8 @@ export class PasteableTextInput extends React.Component { const {height} = event.nativeEvent.contentSize; const {style} = this.props; const {inputHeight} = this.state; - const newHeight = height > style.maxHeight ? inputHeight : height + ViewTypes.INPUT_VERTICAL_PADDING; - const transitionSpeed = height === ViewTypes.INPUT_LINE_HEIGHT ? 500 : 100; + const newHeight = Math.min(style.maxHeight, height + ViewTypes.INPUT_VERTICAL_PADDING); + const transitionSpeed = height === ViewTypes.INPUT_LINE_HEIGHT ? 500 : 1; Animated.timing(inputHeight, { toValue: newHeight, diff --git a/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap b/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap index 5479bc000..0fea5b5ec 100644 --- a/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap +++ b/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap @@ -20,60 +20,26 @@ exports[`PostTextBox should match, full snapshot 1`] = ` ] } > - - - + + + + + + + + + + + + - - + + `; diff --git a/app/components/post_textbox/components/cameraButton.js b/app/components/post_textbox/components/cameraButton.js new file mode 100644 index 000000000..77008fbe0 --- /dev/null +++ b/app/components/post_textbox/components/cameraButton.js @@ -0,0 +1,207 @@ +// 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 {intlShape} from 'react-intl'; +import { + Alert, + Platform, + StyleSheet, +} from 'react-native'; +import RNFetchBlob from 'rn-fetch-blob'; +import DeviceInfo from 'react-native-device-info'; + +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import ImagePicker from 'react-native-image-picker'; +import Permissions from 'react-native-permissions'; + +import {lookupMimeType} from 'mattermost-redux/utils/file_utils'; + +import TouchableWithFeedback from 'app/components/touchable_with_feedback'; +import {PermissionTypes} from 'app/constants'; + +export default class AttachmentButton extends PureComponent { + static propTypes = { + validMimeTypes: PropTypes.array, + fileCount: PropTypes.number, + maxFileCount: PropTypes.number.isRequired, + maxFileSize: PropTypes.number.isRequired, + onShowFileMaxWarning: PropTypes.func, + onShowFileSizeWarning: PropTypes.func, + onShowUnsupportedMimeTypeWarning: PropTypes.func, + theme: PropTypes.object.isRequired, + uploadFiles: PropTypes.func.isRequired, + }; + + static defaultProps = { + validMimeTypes: [], + canTakePhoto: true, + canTakeVideo: true, + maxFileCount: 5, + }; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + getPermissionDeniedMessage = () => { + const {formatMessage} = this.context.intl; + const applicationName = DeviceInfo.getApplicationName(); + return { + title: formatMessage({ + id: 'mobile.camera_photo_permission_denied_title', + defaultMessage: '{applicationName} would like to access your camera', + }, {applicationName}), + text: formatMessage({ + id: 'mobile.camera_photo_permission_denied_description', + defaultMessage: 'Take photos and upload them to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your camera.', + }), + }; + } + + attachFileFromCamera = async () => { + const {formatMessage} = this.context.intl; + const { + fileCount, + maxFileCount, + onShowFileMaxWarning, + } = this.props; + + const {title, text} = this.getPermissionDeniedMessage(); + + if (fileCount === maxFileCount) { + onShowFileMaxWarning(); + return; + } + + const options = { + quality: 0.8, + videoQuality: 'high', + noData: true, + mediaType: 'mixed', + storageOptions: { + cameraRoll: true, + waitUntilSaved: true, + }, + permissionDenied: { + title, + text, + reTryTitle: formatMessage({ + id: 'mobile.permission_denied_retry', + defaultMessage: 'Settings', + }), + okTitle: formatMessage({id: 'mobile.permission_denied_dismiss', defaultMessage: 'Don\'t Allow'}), + }, + }; + + const hasCameraPermission = await this.hasCameraPermission(); + + if (hasCameraPermission) { + ImagePicker.launchCamera(options, (response) => { + if (response.error || response.didCancel) { + return; + } + + this.uploadFiles([response]); + }); + } + }; + + hasCameraPermission = async () => { + if (Platform.OS === 'ios') { + const {formatMessage} = this.context.intl; + let permissionRequest; + const targetSource = 'camera'; + const hasPermissionToStorage = await Permissions.check(targetSource); + + switch (hasPermissionToStorage) { + case PermissionTypes.UNDETERMINED: + permissionRequest = await Permissions.request(targetSource); + if (permissionRequest !== PermissionTypes.AUTHORIZED) { + return false; + } + break; + case PermissionTypes.DENIED: { + const canOpenSettings = await Permissions.canOpenSettings(); + let grantOption = null; + if (canOpenSettings) { + grantOption = { + text: formatMessage({ + id: 'mobile.permission_denied_retry', + defaultMessage: 'Settings', + }), + onPress: () => Permissions.openSettings(), + }; + } + + const {title, text} = this.getPermissionDeniedMessage(); + + Alert.alert( + title, + text, + [ + grantOption, + { + text: formatMessage({ + id: 'mobile.permission_denied_dismiss', + defaultMessage: 'Don\'t Allow', + }), + }, + ], + ); + return false; + } + } + } + + return true; + }; + + uploadFiles = async (files) => { + const file = files[0]; + if (!file.fileSize | !file.fileName) { + const path = (file.path || file.uri).replace('file://', ''); + const fileInfo = await RNFetchBlob.fs.stat(path); + file.fileSize = fileInfo.size; + file.fileName = fileInfo.filename; + } + + if (!file.type) { + file.type = lookupMimeType(file.fileName); + } + + const {validMimeTypes} = this.props; + if (validMimeTypes.length && !validMimeTypes.includes(file.type)) { + this.props.onShowUnsupportedMimeTypeWarning(); + } else if (file.fileSize > this.props.maxFileSize) { + this.props.onShowFileSizeWarning(file.fileName); + } else { + this.props.uploadFiles(files); + } + }; + + render() { + const {theme} = this.props; + + return ( + + + + ); + } +} + +const style = StyleSheet.create({ + buttonContainer: { + paddingLeft: 10, + paddingRight: 10, + }, +}); \ No newline at end of file diff --git a/app/components/post_textbox/components/fileUploadButton.js b/app/components/post_textbox/components/fileUploadButton.js new file mode 100644 index 000000000..8daa8182c --- /dev/null +++ b/app/components/post_textbox/components/fileUploadButton.js @@ -0,0 +1,204 @@ +// 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 {intlShape} from 'react-intl'; +import { + Alert, + NativeModules, + Platform, + StyleSheet, +} from 'react-native'; +import RNFetchBlob from 'rn-fetch-blob'; +import DeviceInfo from 'react-native-device-info'; +import AndroidOpenSettings from 'react-native-android-open-settings'; + +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import DocumentPicker from 'react-native-document-picker'; +import Permissions from 'react-native-permissions'; + +import {lookupMimeType} from 'mattermost-redux/utils/file_utils'; + +import TouchableWithFeedback from 'app/components/touchable_with_feedback'; +import {PermissionTypes} from 'app/constants'; + +const ShareExtension = NativeModules.MattermostShare; + +export default class FileUploadButton extends PureComponent { + static propTypes = { + blurTextBox: PropTypes.func.isRequired, + browseFileTypes: PropTypes.string, + validMimeTypes: PropTypes.array, + fileCount: PropTypes.number, + maxFileCount: PropTypes.number.isRequired, + maxFileSize: PropTypes.number.isRequired, + onShowFileMaxWarning: PropTypes.func, + onShowFileSizeWarning: PropTypes.func, + onShowUnsupportedMimeTypeWarning: PropTypes.func, + theme: PropTypes.object.isRequired, + uploadFiles: PropTypes.func.isRequired, + }; + + static defaultProps = { + browseFileTypes: Platform.OS === 'ios' ? 'public.item' : '*/*', + validMimeTypes: [], + canBrowseFiles: true, + canBrowsePhotoLibrary: true, + canBrowseVideoLibrary: true, + canTakePhoto: true, + canTakeVideo: true, + maxFileCount: 5, + extraOptions: null, + }; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + getPermissionDeniedMessage = () => { + const {formatMessage} = this.context.intl; + const applicationName = DeviceInfo.getApplicationName(); + return { + title: formatMessage({ + id: 'mobile.storage_permission_denied_title', + defaultMessage: '{applicationName} would like to access your files', + }, {applicationName}), + text: formatMessage({ + id: 'mobile.storage_permission_denied_description', + defaultMessage: 'Upload files to your Mattermost instance. Open Settings to grant Mattermost Read and Write access to files on this device.', + }), + }; + } + + attachFileFromFiles = async () => { + const {browseFileTypes} = this.props; + const hasPermission = await this.hasStoragePermission(); + + if (hasPermission) { + try { + const res = await DocumentPicker.pick({type: [browseFileTypes]}); + if (Platform.OS === 'android') { + // For android we need to retrieve the realPath in case the file being imported is from the cloud + const newUri = await ShareExtension.getFilePath(res.uri); + if (newUri.filePath) { + res.uri = newUri.filePath; + } else { + return; + } + } + + // Decode file uri to get the actual path + res.uri = decodeURIComponent(res.uri); + + this.uploadFiles([res]); + } catch (error) { + // Do nothing + } + } + }; + + hasStoragePermission = async () => { + if (Platform.OS === 'android') { + const {formatMessage} = this.context.intl; + let permissionRequest; + const hasPermissionToStorage = await Permissions.check('storage'); + + switch (hasPermissionToStorage) { + case PermissionTypes.UNDETERMINED: + permissionRequest = await Permissions.request('storage'); + if (permissionRequest !== PermissionTypes.AUTHORIZED) { + return false; + } + break; + case PermissionTypes.DENIED: { + const {title, text} = this.getPermissionDeniedMessage(); + + Alert.alert( + title, + text, + [ + { + text: formatMessage({ + id: 'mobile.permission_denied_dismiss', + defaultMessage: 'Don\'t Allow', + }), + }, + { + text: formatMessage({ + id: 'mobile.permission_denied_retry', + defaultMessage: 'Settings', + }), + onPress: () => AndroidOpenSettings.appDetailsSettings(), + }, + ] + ); + return false; + } + } + } + + return true; + }; + + uploadFiles = async (files) => { + const file = files[0]; + if (!file.fileSize | !file.fileName) { + const path = (file.path || file.uri).replace('file://', ''); + const fileInfo = await RNFetchBlob.fs.stat(path); + file.fileSize = fileInfo.size; + file.fileName = fileInfo.filename; + } + + if (!file.type) { + file.type = lookupMimeType(file.fileName); + } + + const {validMimeTypes} = this.props; + if (validMimeTypes.length && !validMimeTypes.includes(file.type)) { + this.props.onShowUnsupportedMimeTypeWarning(); + } else if (file.fileSize > this.props.maxFileSize) { + this.props.onShowFileSizeWarning(file.fileName); + } else { + this.props.uploadFiles(files); + } + }; + + handleButtonPress = () => { + const { + fileCount, + maxFileCount, + onShowFileMaxWarning, + } = this.props; + + if (fileCount === maxFileCount) { + onShowFileMaxWarning(); + return; + } + this.props.blurTextBox(); + this.attachFileFromFiles(); + }; + + render() { + const {theme} = this.props; + return ( + + + + ); + } +} + +const style = StyleSheet.create({ + buttonContainer: { + paddingLeft: 10, + paddingRight: 10, + }, +}); \ No newline at end of file diff --git a/app/components/post_textbox/components/imageUploadButton.js b/app/components/post_textbox/components/imageUploadButton.js new file mode 100644 index 000000000..459f2b9d8 --- /dev/null +++ b/app/components/post_textbox/components/imageUploadButton.js @@ -0,0 +1,224 @@ +// 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 {intlShape} from 'react-intl'; +import { + Alert, + Platform, + StyleSheet, +} from 'react-native'; +import RNFetchBlob from 'rn-fetch-blob'; +import DeviceInfo from 'react-native-device-info'; + +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import ImagePicker from 'react-native-image-picker'; +import Permissions from 'react-native-permissions'; + +import {lookupMimeType} from 'mattermost-redux/utils/file_utils'; + +import TouchableWithFeedback from 'app/components/touchable_with_feedback'; +import {PermissionTypes} from 'app/constants'; + +export default class ImageUploadButton extends PureComponent { + static propTypes = { + blurTextBox: PropTypes.func.isRequired, + validMimeTypes: PropTypes.array, + fileCount: PropTypes.number, + maxFileCount: PropTypes.number.isRequired, + maxFileSize: PropTypes.number.isRequired, + onShowFileMaxWarning: PropTypes.func, + onShowFileSizeWarning: PropTypes.func, + onShowUnsupportedMimeTypeWarning: PropTypes.func, + theme: PropTypes.object.isRequired, + uploadFiles: PropTypes.func.isRequired, + }; + + static defaultProps = { + browseFileTypes: Platform.OS === 'ios' ? 'public.item' : '*/*', + validMimeTypes: [], + canBrowseFiles: true, + canBrowsePhotoLibrary: true, + canBrowseVideoLibrary: true, + canTakePhoto: true, + canTakeVideo: true, + maxFileCount: 5, + extraOptions: null, + }; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + getPermissionDeniedMessage = () => { + const {formatMessage} = this.context.intl; + const applicationName = DeviceInfo.getApplicationName(); + if (Platform.OS === 'android') { + return { + title: formatMessage({ + id: 'mobile.android.photos_permission_denied_title', + defaultMessage: '{applicationName} would like to access your photos', + }, {applicationName}), + text: formatMessage({ + id: 'mobile.android.photos_permission_denied_description', + defaultMessage: 'Upload photos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo library.', + }), + }; + } + + return { + title: formatMessage({ + id: 'mobile.ios.photos_permission_denied_title', + defaultMessage: '{applicationName} would like to access your photos', + }, {applicationName}), + text: formatMessage({ + id: 'mobile.ios.photos_permission_denied_description', + defaultMessage: 'Upload photos and videos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo and video library.', + }), + }; + } + + attachFileFromLibrary = async () => { + const {formatMessage} = this.context.intl; + const {title, text} = this.getPermissionDeniedMessage(); + const options = { + quality: 0.8, + mediaType: 'mixed', + noData: true, + permissionDenied: { + title, + text, + reTryTitle: formatMessage({ + id: 'mobile.permission_denied_retry', + defaultMessage: 'Settings', + }), + okTitle: formatMessage({id: 'mobile.permission_denied_dismiss', defaultMessage: 'Don\'t Allow'}), + }, + }; + + const hasPhotoPermission = await this.hasPhotoPermission(); + + if (hasPhotoPermission) { + ImagePicker.launchImageLibrary(options, (response) => { + if (response.error || response.didCancel) { + return; + } + + this.uploadFiles([response]); + }); + } + }; + + hasPhotoPermission = async () => { + if (Platform.OS === 'ios') { + const {formatMessage} = this.context.intl; + let permissionRequest; + const targetSource = 'photo'; + const hasPermissionToStorage = await Permissions.check(targetSource); + + switch (hasPermissionToStorage) { + case PermissionTypes.UNDETERMINED: + permissionRequest = await Permissions.request(targetSource); + if (permissionRequest !== PermissionTypes.AUTHORIZED) { + return false; + } + break; + case PermissionTypes.DENIED: { + const canOpenSettings = await Permissions.canOpenSettings(); + let grantOption = null; + if (canOpenSettings) { + grantOption = { + text: formatMessage({ + id: 'mobile.permission_denied_retry', + defaultMessage: 'Settings', + }), + onPress: () => Permissions.openSettings(), + }; + } + + const {title, text} = this.getPermissionDeniedMessage(); + + Alert.alert( + title, + text, + [ + grantOption, + { + text: formatMessage({ + id: 'mobile.permission_denied_dismiss', + defaultMessage: 'Don\'t Allow', + }), + }, + ], + ); + return false; + } + } + } + + return true; + }; + + uploadFiles = async (files) => { + const file = files[0]; + if (!file.fileSize | !file.fileName) { + const path = (file.path || file.uri).replace('file://', ''); + const fileInfo = await RNFetchBlob.fs.stat(path); + file.fileSize = fileInfo.size; + file.fileName = fileInfo.filename; + } + + if (!file.type) { + file.type = lookupMimeType(file.fileName); + } + + const {validMimeTypes} = this.props; + if (validMimeTypes.length && !validMimeTypes.includes(file.type)) { + this.props.onShowUnsupportedMimeTypeWarning(); + } else if (file.fileSize > this.props.maxFileSize) { + this.props.onShowFileSizeWarning(file.fileName); + } else { + this.props.uploadFiles(files); + } + }; + + handleButtonPress = () => { + const { + fileCount, + maxFileCount, + onShowFileMaxWarning, + } = this.props; + + if (fileCount === maxFileCount) { + onShowFileMaxWarning(); + return; + } + + this.props.blurTextBox(); + this.attachFileFromLibrary(); + }; + + render() { + const {theme} = this.props; + return ( + + + + ); + } +} + +const style = StyleSheet.create({ + buttonContainer: { + paddingLeft: 10, + paddingRight: 10, + }, +}); \ No newline at end of file diff --git a/app/components/post_textbox/post_textbox.android.js b/app/components/post_textbox/post_textbox.android.js index e7db1b94b..17094bf78 100644 --- a/app/components/post_textbox/post_textbox.android.js +++ b/app/components/post_textbox/post_textbox.android.js @@ -4,7 +4,6 @@ import React from 'react'; import Autocomplete from 'app/components/autocomplete'; -import FileUploadPreview from 'app/components/file_upload_preview'; import Typing from './components/typing'; import PostTextBoxBase from './post_textbox_base'; @@ -16,7 +15,6 @@ export default class PostTextBoxAndroid extends PostTextBoxBase { render() { const { deactivatedChannel, - files, rootId, } = this.props; @@ -29,10 +27,6 @@ export default class PostTextBoxAndroid extends PostTextBoxBase { return ( - ({ + launchCamera: jest.fn(), +})); + describe('PostTextBox', () => { const baseProps = { actions: { @@ -270,7 +278,6 @@ describe('PostTextBox', () => { , ); - expect(wrapper.find(Fade).prop('visible')).toBe(false); expect(wrapper.find(SendButton).prop('disabled')).toBe(true); }); @@ -284,7 +291,6 @@ describe('PostTextBox', () => { , ); - expect(wrapper.find(Fade).prop('visible')).toBe(true); expect(wrapper.find(SendButton).prop('disabled')).toBe(true); }); @@ -298,7 +304,6 @@ describe('PostTextBox', () => { , ); - expect(wrapper.find(Fade).prop('visible')).toBe(true); expect(wrapper.find(SendButton).prop('disabled')).toBe(false); }); @@ -312,7 +317,6 @@ describe('PostTextBox', () => { , ); - expect(wrapper.find(Fade).prop('visible')).toBe(true); expect(wrapper.find(SendButton).prop('disabled')).toBe(false); }); @@ -328,7 +332,6 @@ describe('PostTextBox', () => { wrapper.setState({sendingMessage: true}); - expect(wrapper.find(Fade).prop('visible')).toBe(true); expect(wrapper.find(SendButton).prop('disabled')).toBe(true); }); }); @@ -464,12 +467,58 @@ describe('PostTextBox', () => { expect(baseProps.actions.initUploadFiles).not.toHaveBeenCalled(); }); - test('should change state value on props change', () => { + test('should render all quick action icons', () => { const wrapper = shallowWithIntl(); - expect(wrapper.state('value')).toEqual(''); - wrapper.setProps({value: 'value', channelId: 'channel-id2'}); - expect(wrapper.state('value')).toEqual('value'); + + // @ button + expect(wrapper.find(MaterialCommunityIcons).exists()).toBe(true); + + // slash command button + expect(wrapper.find(Image).exists()).toBe(true); + + expect(wrapper.find(FileUploadButton).exists()).toBe(true); + expect(wrapper.find(ImageUploadButton).exists()).toBe(true); + expect(wrapper.find(CameraButton).exists()).toBe(true); }); + + test('should trigger text change when @ icon is tapped', () => { + const wrapper = shallowWithIntl(); + const instance = wrapper.instance(); + instance.handleTextChange = jest.fn(); + + wrapper.find(MaterialCommunityIcons).parent().props().onPress(); + expect(instance.handleTextChange).toHaveBeenCalledWith(`${instance.state.value}@`, true); + }); + + test('should disable slash icon if textbox value is NOT empty', () => { + const wrapper = shallowWithIntl(); + const instance = wrapper.instance(); + instance.setState({value: 'Test'}); + expect(wrapper.find(Image).parent().props().disabled).toBe(true); + }); + + test('should NOT render file upload icons when server forbids it', () => { + const props = { + ...baseProps, + canUploadFiles: false, + }; + + const wrapper = shallowWithIntl(); + + expect(wrapper.find(MaterialCommunityIcons).exists()).toBe(true); + expect(wrapper.find(Image).exists()).toBe(true); + + expect(wrapper.find(FileUploadButton).exists()).toBe(false); + expect(wrapper.find(ImageUploadButton).exists()).toBe(false); + expect(wrapper.find(CameraButton).exists()).toBe(false); + }); + }); + + test('should change state value on props change', () => { + const wrapper = shallowWithIntl(); + expect(wrapper.state('value')).toEqual(''); + wrapper.setProps({value: 'value', channelId: 'channel-id2'}); + expect(wrapper.state('value')).toEqual('value'); }); }); diff --git a/app/components/post_textbox/post_textbox_base.js b/app/components/post_textbox/post_textbox_base.js index 631e4596c..6c2afad21 100644 --- a/app/components/post_textbox/post_textbox_base.js +++ b/app/components/post_textbox/post_textbox_base.js @@ -8,28 +8,37 @@ import { AppState, BackHandler, findNodeHandle, + Image, InteractionManager, Keyboard, NativeModules, Platform, Text, + TouchableOpacity, + ScrollView, View, } from 'react-native'; import {intlShape} from 'react-intl'; import Button from 'react-native-button'; +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import slashForwardBoxIcon from 'assets/images/icons/slash-forward-box.png'; + import {General, RequestStatus} from 'mattermost-redux/constants'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; import {getFormattedFileSize} from 'mattermost-redux/utils/file_utils'; -import AttachmentButton from 'app/components/attachment_button'; -import Fade from 'app/components/fade'; +import FileUploadButton from './components/fileUploadButton'; +import ImageUploadButton from './components/imageUploadButton'; +import CameraButton from './components/cameraButton'; import FormattedMarkdownText from 'app/components/formatted_markdown_text'; import FormattedText from 'app/components/formatted_text'; import PasteableTextInput from 'app/components/pasteable_text_input'; import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing'; import SendButton from 'app/components/send_button'; -import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_CONTENT_HEIGHT, MAX_FILE_COUNT} from 'app/constants/post_textbox'; +import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_FILE_COUNT} from 'app/constants/post_textbox'; import {NOTIFY_ALL_MEMBERS} from 'app/constants/view'; +import FileUploadPreview from 'app/components/file_upload_preview'; + import EphemeralStore from 'app/store/ephemeral_store'; import {t} from 'app/utils/i18n'; import {confirmOutOfOfficeDisabled} from 'app/utils/status'; @@ -159,6 +168,12 @@ export default class PostTextBoxBase extends PureComponent { } }; + focus = () => { + if (this.input.current) { + this.input.current.focus(); + } + } + numberOfTimezones = async () => { const {data} = await this.props.actions.getChannelTimezones(this.props.channelId); return data?.length || 0; @@ -224,37 +239,109 @@ export default class PostTextBoxBase extends PureComponent { } }; - getAttachmentButton = () => { + getTextInputButton = (actionType) => { + const {channelIsReadOnly, theme} = this.props; + const style = getStyleSheet(theme); + + let button = null; + const buttonStyle = []; + let iconColor = theme.centerChannelColor; + let isDisabled = false; + + if (!channelIsReadOnly) { + switch (actionType) { + case 'at': + isDisabled = this.state.value[this.state.value.length - 1] === '@'; + if (isDisabled) { + iconColor = changeOpacity(theme.centerChannelColor, 0.6); + } + button = ( + { + this.handleTextChange(`${this.state.value}@`, true); + this.focus(); + }} + style={style.iconWrapper} + > + + + ); + break; + case 'slash': + isDisabled = this.state.value.length > 0; + buttonStyle.push(style.slashIcon); + if (isDisabled) { + buttonStyle.push(style.iconDisabled); + } + + button = ( + { + this.handleTextChange('/', true); + this.focus(); + }} + style={style.iconWrapper} + > + + + ); + break; + } + } + + return button; + } + + getMediaButton = (actionType) => { const {canUploadFiles, channelIsReadOnly, files, maxFileSize, theme} = this.props; - let attachmentButton = null; + let button = null; + const props = { + blurTextBox: this.blur, + fileCount: files.length, + maxFileCount: MAX_FILE_COUNT, + onShowFileMaxWarning: this.onShowFileMaxWarning, + onShowFileSizeWarning: this.onShowFileSizeWarning, + uploadFiles: this.handleUploadFiles, + maxFileSize, + theme, + }; if (canUploadFiles && !channelIsReadOnly) { - attachmentButton = ( - - ); + switch (actionType) { + case 'file': + button = ( + + ); + break; + case 'image': + button = ( + + ); + break; + case 'camera': + button = ( + + ); + } } - return attachmentButton; - }; + return button; + } getInputContainerStyle = () => { - const {canUploadFiles, channelIsReadOnly, theme} = this.props; + const {channelIsReadOnly, theme} = this.props; const style = getStyleSheet(theme); const inputContainerStyle = [style.inputContainer]; - if (!canUploadFiles) { - inputContainerStyle.push(style.inputContainerWithoutFileUpload); - } - if (channelIsReadOnly) { inputContainerStyle.push(style.readonlyContainer); } @@ -737,7 +824,7 @@ export default class PostTextBoxBase extends PureComponent { renderTextBox = () => { const {intl} = this.context; - const {channelDisplayName, channelIsArchived, channelIsLoading, channelIsReadOnly, theme, isLandscape} = this.props; + const {channelDisplayName, channelIsArchived, channelIsLoading, channelIsReadOnly, theme, isLandscape, files, rootId} = this.props; const style = getStyleSheet(theme); if (channelIsArchived) { @@ -753,8 +840,17 @@ export default class PostTextBoxBase extends PureComponent { style={[style.inputWrapper, padding(isLandscape)]} onLayout={this.handleLayout} > - {this.getAttachmentButton()} - + - + + + + + + + {this.getTextInputButton('at')} + + {this.getTextInputButton('slash')} + + {this.getMediaButton('file')} + + {this.getMediaButton('image')} + + {this.getMediaButton('camera')} + + - - + + ); }; @@ -788,34 +903,48 @@ export default class PostTextBoxBase extends PureComponent { const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { + buttonsContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + slashIcon: { + width: 20, + height: 20, + opacity: 1, + tintColor: theme.centerChannelColor, + }, + iconDisabled: { + tintColor: changeOpacity(theme.centerChannelColor, 0.6), + }, + iconWrapper: { + paddingLeft: 10, + paddingRight: 10, + }, + quickActionsContainer: { + display: 'flex', + flexDirection: 'row', + }, input: { color: theme.centerChannelColor, - flex: 1, fontSize: 14, - maxHeight: MAX_CONTENT_HEIGHT, paddingBottom: 8, paddingLeft: 12, paddingRight: 12, paddingTop: 8, - }, - hidden: { - position: 'absolute', - top: 10000, // way off screen - left: 10000, // way off screen - backgroundColor: 'transparent', - borderColor: 'transparent', - color: 'transparent', + maxHeight: 150, }, inputContainer: { flex: 1, - flexDirection: 'row', + flexDirection: 'column', backgroundColor: theme.centerChannelBg, - alignItems: 'stretch', marginRight: 10, - }, - inputContainerWithoutFileUpload: { marginLeft: 10, }, + inputContentContainer: { + alignItems: 'stretch', + }, inputWrapper: { alignItems: 'flex-end', flexDirection: 'row', diff --git a/app/components/profile_picture_button.test.js b/app/components/profile_picture_button.test.js index 44237c1a0..874c27bdd 100644 --- a/app/components/profile_picture_button.test.js +++ b/app/components/profile_picture_button.test.js @@ -9,6 +9,10 @@ import ProfilePictureButton from './profile_picture_button.js'; import {Client4} from 'mattermost-redux/client'; +jest.mock('react-native-image-picker', () => ({ + launchCamera: jest.fn(), +})); + describe('profile_picture_button', () => { const baseProps = { theme: Preferences.THEMES.default, diff --git a/app/components/send_button.js b/app/components/send_button.js index ec5e312ac..bf4cee0e4 100644 --- a/app/components/send_button.js +++ b/app/components/send_button.js @@ -70,9 +70,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { }, sendButton: { backgroundColor: theme.buttonBg, - borderRadius: 18, + borderRadius: 4, height: 28, - width: 28, + width: 72, alignItems: 'center', justifyContent: 'center', paddingLeft: 3, diff --git a/app/constants/post_textbox.js b/app/constants/post_textbox.js index 3cbeddde0..462a4406d 100644 --- a/app/constants/post_textbox.js +++ b/app/constants/post_textbox.js @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -export const MAX_CONTENT_HEIGHT = 100; export const MAX_FILE_COUNT = 5; export const IS_REACTION_REGEX = /(^\+:([^:\s]*):)$/i; export const INSERT_TO_DRAFT = 'insert_to_draft'; diff --git a/app/screens/channel/channel.ios.js b/app/screens/channel/channel.ios.js index c9ee1a9ae..f2b016ba9 100644 --- a/app/screens/channel/channel.ios.js +++ b/app/screens/channel/channel.ios.js @@ -7,7 +7,6 @@ import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view'; import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from 'app/components/autocomplete'; import ChannelLoader from 'app/components/channel_loader'; -import FileUploadPreview from 'app/components/file_upload_preview'; import NetworkIndicator from 'app/components/network_indicator'; import PostTextbox from 'app/components/post_textbox'; import SafeAreaView from 'app/components/safe_area_view'; @@ -57,7 +56,6 @@ export default class ChannelIOS extends ChannelBase { updateNativeScrollView={this.updateNativeScrollView} /> - { changeOpacity: jest.fn(), }; }); +jest.mock('react-native-image-picker', () => ({ + launchCamera: jest.fn(), +})); describe('edit_profile', () => { const actions = { diff --git a/app/screens/thread/__snapshots__/thread.ios.test.js.snap b/app/screens/thread/__snapshots__/thread.ios.test.js.snap index dc90b29bd..50d65dffa 100644 --- a/app/screens/thread/__snapshots__/thread.ios.test.js.snap +++ b/app/screens/thread/__snapshots__/thread.ios.test.js.snap @@ -42,9 +42,6 @@ exports[`thread should match snapshot, has root post 1`] = ` - - ({ + launchCamera: jest.fn(), +})); describe('thread', () => { const baseProps = { diff --git a/assets/base/images/icons/slash-forward-box.png b/assets/base/images/icons/slash-forward-box.png new file mode 100644 index 0000000000000000000000000000000000000000..3afb396df9029e0a06c488128383c0440bcc676e GIT binary patch literal 894 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCmUKs7M+SzC{oH>NS%G}U;vjb? zhIQv;UIIA^$sR$z3=CCj3=9n|3=F@3LJcn%7)pVryh>nTu$sZZAYL$MSD+10f-TA0 z-33Sk!B6Mi^+1ZVz$3C4NPB>>+sSM@1_ma3PZ!6KjC*fqSZ9bjinQ%7c2sog`Qi0& zv4D^A5w{D6?5-ufT`a~O%`M-0T`B2++d;1_msq0X*CvVkDF0ZYvOu#b@`Bof{E6B; zlkc8@T4Sfz5SNz%Cb?Grnu8m4X2#dclHGoN^< zF?`(*#?7He*txa2Or9Kwf6y{h*Q(N=@x62UbFSQzjmLq~<*U2oyV!P}llN@jGv|4m zZ`cy!Wz5BC-|h0U@Ly!Lp=?^EOceDAp zS!j1Lr|-34SNXf{Wrh*E9fSU!t+x;KMTp=1;#_v?%sorXWsR;|$Lqw|I5HrJLOM=Ivc&@O;}}PF+U-JzJ$s44#KA^pjp|`#el2>Vlgy>l#Mu zRdP`(kYX@0FtpS)G|)A)2r)3QGP1NXGSCJx z3=9^}d2WoNAvZrIGp!Q0hD!(I`+*uXKsFSYrCTK>7N_c#7G*M|W#*(>>Fev4Yl1)IM&uT=! permissions = new ArrayList(); -+ if (requestCode == REQUEST_PERMISSIONS_FOR_CAMERA ) -+ { -+ permissions.add(Manifest.permission.CAMERA); -+ permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); -+ } -+ if (requestCode == REQUEST_PERMISSIONS_FOR_LIBRARY ) -+ { -+ permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); -+ } -+ -+ String[] PERMISSIONS = permissions.toArray(new String[0]); - if (activity instanceof ReactActivity) - { - ((ReactActivity) activity).requestPermissions(PERMISSIONS, requestCode, listener); -diff --git a/node_modules/react-native-image-picker/ios/ImagePickerManager.m b/node_modules/react-native-image-picker/ios/ImagePickerManager.m -index 28d5870..3f70983 100644 ---- a/node_modules/react-native-image-picker/ios/ImagePickerManager.m -+++ b/node_modules/react-native-image-picker/ios/ImagePickerManager.m -@@ -455,12 +455,19 @@ - (void)imagePickerController:(UIImagePickerController *)picker didFinishPicking - } - - if (videoURL) { // Protect against reported crash -- NSError *error = nil; -- [fileManager moveItemAtURL:videoURL toURL:videoDestinationURL error:&error]; -- if (error) { -- self.callback(@[@{@"error": error.localizedFailureReason}]); -- return; -- } -+ NSError *error = nil; -+ -+ // If we have write access to the source file, move it. Otherwise use copy. -+ if ([fileManager isWritableFileAtPath:[videoURL path]]) { -+ [fileManager moveItemAtURL:videoURL toURL:videoDestinationURL error:&error]; -+ } else { -+ [fileManager copyItemAtURL:videoURL toURL:videoDestinationURL error:&error]; -+ } -+ -+ if (error) { -+ self.callback(@[@{@"error": error.localizedFailureReason}]); -+ return; -+ } - } - } - diff --git a/patches/react-native-image-picker+2.0.0.patch b/patches/react-native-image-picker+2.0.0.patch new file mode 100644 index 000000000..bac6d1467 --- /dev/null +++ b/patches/react-native-image-picker+2.0.0.patch @@ -0,0 +1,184 @@ +diff --git a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java +index ef62bed..7379605 100644 +--- a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java ++++ b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java +@@ -49,6 +49,7 @@ import java.io.InputStream; + import java.io.OutputStream; + import java.lang.ref.WeakReference; + import java.util.List; ++import java.util.ArrayList; + + import com.facebook.react.modules.core.PermissionListener; + import com.facebook.react.modules.core.PermissionAwareActivity; +@@ -69,6 +70,7 @@ public class ImagePickerModule extends ReactContextBaseJavaModule + public static final int REQUEST_LAUNCH_IMAGE_LIBRARY = 13002; + public static final int REQUEST_LAUNCH_VIDEO_LIBRARY = 13003; + public static final int REQUEST_LAUNCH_VIDEO_CAPTURE = 13004; ++ public static final int REQUEST_LAUNCH_MIXED_CAPTURE = 13005; + public static final int REQUEST_PERMISSIONS_FOR_CAMERA = 14001; + public static final int REQUEST_PERMISSIONS_FOR_LIBRARY = 14002; + +@@ -266,26 +268,23 @@ public class ImagePickerModule extends ReactContextBaseJavaModule + cameraIntent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, videoDurationLimit); + } + } ++ else if (pickBoth) { ++ Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); ++ this.setImageCaptureUri(takePictureIntent); ++ Intent takeVideoIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); ++ cameraIntent = new Intent(Intent.ACTION_CHOOSER); ++ Intent contentSelectionIntent = new Intent(Intent.ACTION_GET_CONTENT); ++ Intent[] intentArray = new Intent[]{takePictureIntent,takeVideoIntent}; ++ cameraIntent.putExtra(Intent.EXTRA_INTENT, contentSelectionIntent); ++ cameraIntent.putExtra(Intent.EXTRA_TITLE, "Choose an action"); ++ cameraIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentArray); ++ requestCode = REQUEST_LAUNCH_MIXED_CAPTURE; ++ } + else + { + requestCode = REQUEST_LAUNCH_IMAGE_CAPTURE; + cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); +- +- final File original = createNewFile(reactContext, this.options, false); +- imageConfig = imageConfig.withOriginalFile(original); +- +- if (imageConfig.original != null) { +- cameraCaptureURI = RealPathUtil.compatUriFromFile(reactContext, imageConfig.original); +- }else { +- responseHelper.invokeError(callback, "Couldn't get file path for photo"); +- return; +- } +- if (cameraCaptureURI == null) +- { +- responseHelper.invokeError(callback, "Couldn't get file path for photo"); +- return; +- } +- cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, cameraCaptureURI); ++ this.setImageCaptureUri(cameraIntent); + } + + if (cameraIntent.resolveActivity(reactContext.getPackageManager()) == null) +@@ -444,14 +443,20 @@ public class ImagePickerModule extends ReactContextBaseJavaModule + callback = null; + return; + ++ case REQUEST_LAUNCH_MIXED_CAPTURE: + case REQUEST_LAUNCH_VIDEO_CAPTURE: +- final String path = getRealPathFromURI(data.getData()); +- responseHelper.putString("uri", data.getData().toString()); +- responseHelper.putString("path", path); +- fileScan(reactContext, path); +- responseHelper.invokeResponse(callback); +- callback = null; +- return; ++ if (data == null || data.getData() == null) { ++ uri = cameraCaptureURI; ++ break; ++ } else { ++ final String path = getRealPathFromURI(data.getData()); ++ responseHelper.putString("uri", data.getData().toString()); ++ responseHelper.putString("path", path); ++ fileScan(reactContext, path); ++ responseHelper.invokeResponse(callback); ++ callback = null; ++ return; ++ } + } + + final ReadExifResult result = readExifInterface(responseHelper, imageConfig); +@@ -551,7 +556,8 @@ public class ImagePickerModule extends ReactContextBaseJavaModule + { + return callback == null || (cameraCaptureURI == null && requestCode == REQUEST_LAUNCH_IMAGE_CAPTURE) + || (requestCode != REQUEST_LAUNCH_IMAGE_CAPTURE && requestCode != REQUEST_LAUNCH_IMAGE_LIBRARY +- && requestCode != REQUEST_LAUNCH_VIDEO_LIBRARY && requestCode != REQUEST_LAUNCH_VIDEO_CAPTURE); ++ && requestCode != REQUEST_LAUNCH_VIDEO_LIBRARY && requestCode != REQUEST_LAUNCH_VIDEO_CAPTURE ++ && requestCode != REQUEST_LAUNCH_MIXED_CAPTURE); + } + + private void updatedResultResponse(@Nullable final Uri uri, +@@ -571,22 +577,23 @@ public class ImagePickerModule extends ReactContextBaseJavaModule + @NonNull final Callback callback, + @NonNull final int requestCode) + { +- final int writePermission = ActivityCompat +- .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE); +- final int cameraPermission = ActivityCompat +- .checkSelfPermission(activity, Manifest.permission.CAMERA); +- +- boolean permissionsGranted = false; +- ++ int selfCheckResult = 0; + switch (requestCode) { + case REQUEST_PERMISSIONS_FOR_LIBRARY: +- permissionsGranted = writePermission == PackageManager.PERMISSION_GRANTED; +- break; ++ selfCheckResult = ActivityCompat ++ .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE); ++ break; + case REQUEST_PERMISSIONS_FOR_CAMERA: +- permissionsGranted = cameraPermission == PackageManager.PERMISSION_GRANTED; ++ selfCheckResult = ActivityCompat ++ .checkSelfPermission(activity, Manifest.permission.CAMERA); ++ if (selfCheckResult == PackageManager.PERMISSION_GRANTED) { ++ selfCheckResult = ActivityCompat ++ .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE); ++ } + break; + } + ++ final boolean permissionsGranted = selfCheckResult == PackageManager.PERMISSION_GRANTED; + if (!permissionsGranted) + { + final Boolean dontAskAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) && ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.CAMERA); +@@ -641,7 +648,7 @@ public class ImagePickerModule extends ReactContextBaseJavaModule + PERMISSIONS = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; + break; + case REQUEST_PERMISSIONS_FOR_CAMERA: +- PERMISSIONS = new String[]{Manifest.permission.CAMERA}; ++ PERMISSIONS = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA}; + break; + default: + PERMISSIONS = new String[]{}; +@@ -781,4 +788,22 @@ public class ImagePickerModule extends ReactContextBaseJavaModule + videoDurationLimit = options.getInt("durationLimit"); + } + } ++ ++ private void setImageCaptureUri(Intent cameraIntent) { ++ final File original = createNewFile(reactContext, this.options, false); ++ imageConfig = imageConfig.withOriginalFile(original); ++ ++ if (imageConfig.original != null) { ++ cameraCaptureURI = RealPathUtil.compatUriFromFile(reactContext, imageConfig.original); ++ }else { ++ responseHelper.invokeError(callback, "Couldn't get file path for photo"); ++ return; ++ } ++ if (cameraCaptureURI == null) ++ { ++ responseHelper.invokeError(callback, "Couldn't get file path for photo"); ++ return; ++ } ++ cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, cameraCaptureURI); ++ } + } +diff --git a/node_modules/react-native-image-picker/ios/ImagePickerManager.m b/node_modules/react-native-image-picker/ios/ImagePickerManager.m +index 46b2c11..70bb8a5 100644 +--- a/node_modules/react-native-image-picker/ios/ImagePickerManager.m ++++ b/node_modules/react-native-image-picker/ios/ImagePickerManager.m +@@ -460,7 +460,14 @@ - (void)imagePickerController:(UIImagePickerController *)picker didFinishPicking + + if (videoURL) { // Protect against reported crash + NSError *error = nil; +- [fileManager moveItemAtURL:videoURL toURL:videoDestinationURL error:&error]; ++ ++ // If we have write access to the source file, move it. Otherwise use copy. ++ if ([fileManager isWritableFileAtPath:[videoURL path]]) { ++ [fileManager moveItemAtURL:videoURL toURL:videoDestinationURL error:&error]; ++ } else { ++ [fileManager copyItemAtURL:videoURL toURL:videoDestinationURL error:&error]; ++ } ++ + if (error) { + self.callback(@[@{@"error": error.localizedFailureReason}]); + return;