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 000000000..3afb396df Binary files /dev/null and b/assets/base/images/icons/slash-forward-box.png differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9724437ad..be7aa0d7d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -206,16 +206,12 @@ PODS: - React - react-native-document-picker (3.2.4): - React - - react-native-image-picker (0.28.1): - - React - - react-native-local-auth (1.5.0): + - react-native-image-picker (2.0.0): - React - react-native-netinfo (4.4.0): - React - react-native-notifications (2.0.6): - React - - react-native-passcode-status (1.1.2): - - React - react-native-safe-area (0.5.1): - React - react-native-video (5.0.2): @@ -330,10 +326,8 @@ DEPENDENCIES: - react-native-cookies (from `../node_modules/react-native-cookies/ios`) - react-native-document-picker (from `../node_modules/react-native-document-picker`) - react-native-image-picker (from `../node_modules/react-native-image-picker`) - - react-native-local-auth (from `../node_modules/react-native-local-auth`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-notifications (from `../node_modules/react-native-notifications`) - - react-native-passcode-status (from `../node_modules/react-native-passcode-status`) - react-native-safe-area (from `../node_modules/react-native-safe-area`) - react-native-video (from `../node_modules/react-native-video`) - react-native-webview (from `../node_modules/react-native-webview`) @@ -422,14 +416,10 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-document-picker" react-native-image-picker: :path: "../node_modules/react-native-image-picker" - react-native-local-auth: - :path: "../node_modules/react-native-local-auth" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" react-native-notifications: :path: "../node_modules/react-native-notifications" - react-native-passcode-status: - :path: "../node_modules/react-native-passcode-status" react-native-safe-area: :path: "../node_modules/react-native-safe-area" react-native-video: @@ -511,11 +501,9 @@ SPEC CHECKSUMS: react-native-cameraroll: ad20f5a93c25cb83a76455df57a2c62fbb63aaed react-native-cookies: 854d59c4135c70b92a02ca4930e68e4e2eb58150 react-native-document-picker: c36bf5f067a581657ecaf7124dcd921a8be19061 - react-native-image-picker: fd93361c666f397bdf72f9c6c23f13d2685b9173 - react-native-local-auth: 5081a70211643de74bb207e007401a0c81b37a20 + react-native-image-picker: ba7fe85b3373ff33d4827210d989dfcbbd68f7f9 react-native-netinfo: 892a5130be97ff8bb69c523739c424a2ffc296d1 react-native-notifications: d5cb54ef8bf3004dcb56c887650dea08ecbddee7 - react-native-passcode-status: 88c4f6e074328bc278bd127646b6c694ad5a530a react-native-safe-area: e8230b0017d76c00de6b01e2412dcf86b127c6a3 react-native-video: 961749da457e73bf0b5565edfbaffc25abfb8974 react-native-webview: 0d1c2b4e7ffb0543a74fa0512f2f8dc5fb0e49e2 diff --git a/package-lock.json b/package-lock.json index 6db85224b..c286c60ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9937,9 +9937,9 @@ } }, "react-native-image-picker": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-0.28.1.tgz", - "integrity": "sha512-CW2dm+cjsdW2fjBW2WD/cSufNG0x0UpljwGHrjSzyB0TckoW+tjYv44UWtckCWxr1JtCg+QrYDO/MzlRyFcjwQ==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-2.0.0.tgz", + "integrity": "sha512-svmQJ5bBuCgX9RG5DU3A38udgemze1/HtYUXgUAvP2i3LzrzW5ub5GT3L8n87pg/DNj2rRHi1wLtNaNEoV4ZAw==" }, "react-native-iphone-x-helper": { "version": "1.2.1", diff --git a/package.json b/package.json index df11359ae..c4ef3f7a6 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "react-native-fast-image": "7.0.2", "react-native-haptic-feedback": "1.8.2", "react-native-image-gallery": "github:mattermost/react-native-image-gallery#c1a9f7118e90cc87d47620bc0584c9cac4b0cf38", - "react-native-image-picker": "0.28.1", + "react-native-image-picker": "2.0.0", "react-native-keyboard-aware-scroll-view": "0.9.1", "react-native-keyboard-tracking-view": "github:mattermost/react-native-keyboard-tracking-view#169676811a32f82cb674a52d7857a14043f7b8f7", "react-native-keychain": "4.0.1", diff --git a/patches/react-native-image-picker+0.28.1.patch b/patches/react-native-image-picker+0.28.1.patch deleted file mode 100644 index e881691b6..000000000 --- a/patches/react-native-image-picker+0.28.1.patch +++ /dev/null @@ -1,92 +0,0 @@ -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 b4311eb..9d3f3d2 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 -@@ -47,6 +47,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; -@@ -542,14 +543,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 -+ int selfCheckResult = 0; -+ switch (requestCode) { -+ case REQUEST_PERMISSIONS_FOR_CAMERA: -+ selfCheckResult = ActivityCompat - .checkSelfPermission(activity, Manifest.permission.CAMERA); -+ if (selfCheckResult == PackageManager.PERMISSION_GRANTED) { -+ selfCheckResult = ActivityCompat -+ .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE); -+ } -+ break; -+ case REQUEST_PERMISSIONS_FOR_LIBRARY: -+ selfCheckResult = ActivityCompat -+ .checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE); -+ break; -+ } - -- final boolean permissionsGrated = writePermission == PackageManager.PERMISSION_GRANTED && -- cameraPermission == PackageManager.PERMISSION_GRANTED; -- -+ final boolean permissionsGrated = selfCheckResult == PackageManager.PERMISSION_GRANTED; - if (!permissionsGrated) - { - final Boolean dontAskAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) && ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.CAMERA); -@@ -598,7 +608,18 @@ public class ImagePickerModule extends ReactContextBaseJavaModule - } - else - { -- String[] PERMISSIONS = {Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA}; -+ List 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;