diff --git a/src/CONST.js b/src/CONST.js index f8fbc930c9a3..33fcd8f58dad 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -12,8 +12,17 @@ const CONST = { ANDROID_PACKAGE_NAME, ANIMATED_TRANSITION: 300, - // 50 megabytes in bytes - API_MAX_ATTACHMENT_SIZE: 52428800, + API_ATTACHMENT_VALIDATIONS: { + // Same as the PHP layer allows + ALLOWED_EXTENSIONS: ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'html', 'txt', 'rtf', 'doc', 'docx', 'htm', 'tiff', 'tif', 'xml'], + + // 50 megabytes in bytes + MAX_SIZE: 52428800, + + // An arbitrary size, but the same minimum as in the PHP layer + MIN_SIZE: 240, + }, + AVATAR_MAX_ATTACHMENT_SIZE: 6291456, NEW_EXPENSIFY_URL: ACTIVE_EXPENSIFY_URL, APP_DOWNLOAD_LINKS: { diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 4bb62c44d0cc..b67e88f79ee3 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -3,7 +3,8 @@ import PropTypes from 'prop-types'; import {View} from 'react-native'; import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; -import _ from 'lodash'; +import lodashExtend from 'lodash/extend'; +import _ from 'underscore'; import CONST from '../CONST'; import Modal from './Modal'; import AttachmentView from './AttachmentView'; @@ -71,7 +72,9 @@ class AttachmentModal extends PureComponent { this.state = { isModalOpen: false, - isConfirmModalOpen: false, + isAttachmentInvalid: false, + attachmentInvalidReasonTitle: null, + attachmentInvalidReason: null, file: null, sourceURL: props.sourceURL, modalType: CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE, @@ -79,7 +82,7 @@ class AttachmentModal extends PureComponent { this.submitAndClose = this.submitAndClose.bind(this); this.closeConfirmModal = this.closeConfirmModal.bind(this); - this.isValidSize = this.isValidSize.bind(this); + this.validateAndDisplayFileToUpload = this.validateAndDisplayFileToUpload.bind(this); } /** @@ -89,22 +92,30 @@ class AttachmentModal extends PureComponent { * @returns {String} */ getModalType(sourceUrl, file) { - const modalType = (sourceUrl - && (Str.isPDF(sourceUrl) || (file && Str.isPDF(file.name || this.props.translate('attachmentView.unknownFilename'))))) + return ( + sourceUrl + && ( + Str.isPDF(sourceUrl) + || ( + file + && Str.isPDF(file.name || this.props.translate('attachmentView.unknownFilename')) + ) + ) + ) ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.CENTERED; - return modalType; } /** * Returns the filename split into fileName and fileExtension + * + * @param {String} fullFileName * @returns {Object} */ - splitExtensionFromFileName() { - const fullFileName = this.props.originalFileName ? this.props.originalFileName.trim() : lodashGet(this.state, 'file.name', '').trim(); - const splittedFileName = fullFileName.split('.'); - const fileExtension = splittedFileName.pop(); - const fileName = splittedFileName.join('.'); + splitExtensionFromFileName(fullFileName) { + const fileName = fullFileName.trim(); + const splitFileName = fileName.split('.'); + const fileExtension = splitFileName.pop(); return {fileName, fileExtension}; } @@ -118,7 +129,7 @@ class AttachmentModal extends PureComponent { } if (this.props.onConfirm) { - this.props.onConfirm(_.extend(this.state.file, {source: this.state.sourceURL})); + this.props.onConfirm(lodashExtend(this.state.file, {source: this.state.sourceURL})); } this.setState({isModalOpen: false}); @@ -128,16 +139,70 @@ class AttachmentModal extends PureComponent { * Close the confirm modal. */ closeConfirmModal() { - this.setState({isConfirmModalOpen: false}); + this.setState({isAttachmentInvalid: false}); } /** - * Check if the attachment size is less than the API size limit. * @param {Object} file * @returns {Boolean} */ - isValidSize(file) { - return !file || lodashGet(file, 'size', 0) < CONST.API_MAX_ATTACHMENT_SIZE; + isValidFile(file) { + if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + this.setState({ + isAttachmentInvalid: true, + attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.attachmentTooLarge'), + attachmentInvalidReason: this.props.translate('attachmentPicker.sizeExceeded'), + }); + return false; + } + + if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + this.setState({ + isAttachmentInvalid: true, + attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.attachmentTooSmall'), + attachmentInvalidReason: this.props.translate('attachmentPicker.sizeNotMet'), + }); + return false; + } + + const {fileExtension} = this.splitExtensionFromFileName(lodashGet(file, 'name', '')); + if (!_.contains(CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_EXTENSIONS, fileExtension)) { + const invalidReason = `${this.props.translate('attachmentPicker.notAllowedExtension')} ${CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_EXTENSIONS.join(', ')}`; + this.setState({ + isAttachmentInvalid: true, + attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.wrongFileType'), + attachmentInvalidReason: invalidReason, + }); + return false; + } + + return true; + } + + /** + * @param {Object} file + */ + validateAndDisplayFileToUpload(file) { + if (!file) { + return; + } + + if (!this.isValidFile(file)) { + return; + } + + if (file instanceof File) { + const source = URL.createObjectURL(file); + const modalType = this.getModalType(source, file); + this.setState({ + isModalOpen: true, sourceURL: source, file, modalType, + }); + } else { + const modalType = this.getModalType(file.uri, file); + this.setState({ + isModalOpen: true, sourceURL: file.uri, file, modalType, + }); + } } render() { @@ -149,7 +214,7 @@ class AttachmentModal extends PureComponent { ? [styles.imageModalImageCenterContainer] : [styles.imageModalImageCenterContainer, styles.p5]; - const {fileName, fileExtension} = this.splitExtensionFromFileName(); + const {fileName, fileExtension} = this.splitExtensionFromFileName(this.props.originalFileName || lodashGet(this.state, 'file.name', '')); return ( <> @@ -197,34 +262,19 @@ class AttachmentModal extends PureComponent { /> )} + + {this.props.children({ - displayFileInModal: ({file}) => { - if (!this.isValidSize(file)) { - this.setState({isConfirmModalOpen: true}); - return; - } - if (file instanceof File) { - const source = URL.createObjectURL(file); - const modalType = this.getModalType(source, file); - this.setState({ - isModalOpen: true, sourceURL: source, file, modalType, - }); - } else { - const modalType = this.getModalType(file.uri, file); - this.setState({ - isModalOpen: true, sourceURL: file.uri, file, modalType, - }); - } - }, + displayFileInModal: this.validateAndDisplayFileToUpload, show: () => { this.setState({isModalOpen: true}); }, diff --git a/src/components/ConfirmModal.js b/src/components/ConfirmModal.js index 5c89135bef7b..bf7d1e2fffda 100755 --- a/src/components/ConfirmModal.js +++ b/src/components/ConfirmModal.js @@ -7,7 +7,7 @@ import ConfirmContent from './ConfirmContent'; const propTypes = { /** Title of the modal */ - title: PropTypes.string.isRequired, + title: PropTypes.string, /** A callback to call when the form has been submitted */ onConfirm: PropTypes.func.isRequired, @@ -54,6 +54,7 @@ const defaultProps = { onCancel: () => {}, shouldShowCancelButton: true, shouldSetModalVisibility: true, + title: '', onModalHide: () => {}, }; diff --git a/src/languages/en.js b/src/languages/en.js index fa86c37a450d..4cb0a8029e92 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -119,6 +119,10 @@ export default { chooseDocument: 'Choose document', attachmentTooLarge: 'Attachment too large', sizeExceeded: 'Attachment size is larger than 50 MB limit.', + attachmentTooSmall: 'Attachment too small', + sizeNotMet: 'Attachment size must be greater than 240 bytes', + wrongFileType: 'Attachment is the wrong type', + notAllowedExtension: 'Attachments must be one of the following types: ', }, composer: { noExtentionFoundForMimeType: 'No extension found for mime type', diff --git a/src/languages/es.js b/src/languages/es.js index 934df4f03e1c..661c1793c6b1 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -119,6 +119,10 @@ export default { chooseDocument: 'Elegir documento', attachmentTooLarge: 'Archivo adjunto demasiado grande', sizeExceeded: 'El archivo adjunto supera el límite de 50 MB.', + attachmentTooSmall: 'Archivo adjunto demasiado pequeño', + sizeNotMet: 'El archivo adjunto debe ser mas grande que 240 bytes', + wrongFileType: 'El tipo del archivo adjunto es incorrecto', + notAllowedExtension: 'Los archivos adjuntos deben ser de uno de los siguientes tipos: ', }, composer: { noExtentionFoundForMimeType: 'No se encontró una extension para este tipo de contenido', diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index 2565c4575997..4f22eb497350 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -241,19 +241,19 @@ function validateLogin(accountID, validateCode) { * Checks the blockedFromConcierge object to see if it has an expiresAt key, * and if so whether the expiresAt date of a user's ban is before right now * - * @param {Object} blockedFromConcierge + * @param {Object} blockedFromConciergeNVP * @returns {Boolean} */ -function isBlockedFromConcierge(blockedFromConcierge) { - if (_.isEmpty(blockedFromConcierge)) { +function isBlockedFromConcierge(blockedFromConciergeNVP) { + if (_.isEmpty(blockedFromConciergeNVP)) { return false; } - if (!blockedFromConcierge.expiresAt) { + if (!blockedFromConciergeNVP.expiresAt) { return false; } - return moment().isBefore(moment(blockedFromConcierge.expiresAt), 'day'); + return moment().isBefore(moment(blockedFromConciergeNVP.expiresAt), 'day'); } /** diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index fd45bd61626b..6e58ae1f5715 100755 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -124,12 +124,14 @@ class ReportActionCompose extends React.Component { this.setIsFullComposerAvailable = this.setIsFullComposerAvailable.bind(this); this.focus = this.focus.bind(this); this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this); - this.comment = props.comment; - this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); this.onSelectionChange = this.onSelectionChange.bind(this); this.setTextInputRef = this.setTextInputRef.bind(this); this.getInputPlaceholder = this.getInputPlaceholder.bind(this); this.getIOUOptions = this.getIOUOptions.bind(this); + this.addAttachment = this.addAttachment.bind(this); + + this.comment = props.comment; + this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); this.state = { isFocused: this.shouldFocusInputOnScreenFocus, @@ -447,6 +449,15 @@ class ReportActionCompose extends React.Component { return trimmedComment; } + /** + * @param {Object} file + */ + addAttachment(file) { + const comment = this.prepareCommentAndResetComposer(); + Report.addAttachment(this.props.reportID, file, comment); + this.setTextInputShouldClear(false); + } + /** * Add a new comment to this chat * @@ -500,11 +511,7 @@ class ReportActionCompose extends React.Component { > { - const comment = this.prepareCommentAndResetComposer(); - Report.addAttachment(this.props.reportID, file, comment); - this.setTextInputShouldClear(false); - }} + onConfirm={this.addAttachment} > {({displayFileInModal}) => ( <> @@ -572,9 +579,7 @@ class ReportActionCompose extends React.Component { text: this.props.translate('reportActionCompose.addAttachment'), onSelected: () => { openPicker({ - onPicked: (file) => { - displayFileInModal({file}); - }, + onPicked: displayFileInModal, }); }, }, @@ -616,7 +621,7 @@ class ReportActionCompose extends React.Component { return; } - displayFileInModal({file}); + displayFileInModal(file); this.setState({isDraggingOver: false}); }} style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} @@ -624,7 +629,7 @@ class ReportActionCompose extends React.Component { maxLines={this.state.maxLines} onFocus={() => this.setIsFocused(true)} onBlur={() => this.setIsFocused(false)} - onPasteFile={file => displayFileInModal({file})} + onPasteFile={displayFileInModal} shouldClear={this.state.textInputShouldClear} onClear={() => this.setTextInputShouldClear(false)} isDisabled={isComposeDisabled || isBlockedFromConcierge} diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 10e5b58de3cb..f36440dcabaf 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -28,6 +28,8 @@ import {withNetwork, withReportActionsDrafts} from '../../../components/OnyxProv import RenameAction from '../../../components/ReportActionItem/RenameAction'; import InlineSystemMessage from '../../../components/InlineSystemMessage'; import styles from '../../../styles/styles'; +import * as User from '../../../libs/actions/User'; +import * as ReportUtils from '../../../libs/ReportUtils'; const propTypes = { /** The ID of the report this action is on. */ @@ -139,16 +141,20 @@ class ReportActionItem extends Component { ); } else { children = !this.props.draftMessage - ? - : ( + ? ( + + ) : ( this.textInput = el} - report={this.props.report} - blockedFromConcierge={this.props.blockedFromConcierge} + action={this.props.action} + draftMessage={this.props.draftMessage} + reportID={this.props.reportID} + index={this.props.index} + ref={el => this.textInput = el} + report={this.props.report} + shouldDisableEmojiPicker={ + (ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge)) + || ReportUtils.isArchivedRoom(this.props.report) + } /> ); } diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index c2b270c9085a..3e64cfaaf09c 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -16,10 +16,8 @@ import Button from '../../../components/Button'; import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; import compose from '../../../libs/compose'; import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton'; -import * as ReportUtils from '../../../libs/ReportUtils'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import VirtualKeyboard from '../../../libs/VirtualKeyboard'; -import * as User from '../../../libs/actions/User'; const propTypes = { /** All the data of the action */ @@ -43,11 +41,8 @@ const propTypes = { participants: PropTypes.arrayOf(PropTypes.string), }), - // The NVP describing a user's block status - blockedFromConcierge: PropTypes.shape({ - // The date that the user will be unblocked - expiresAt: PropTypes.string, - }), + // Whether or not the emoji picker is disabled + shouldDisableEmojiPicker: PropTypes.bool, /** Window Dimensions Props */ ...windowDimensionsPropTypes, @@ -59,7 +54,7 @@ const propTypes = { const defaultProps = { forwardedRef: () => {}, report: {}, - blockedFromConcierge: {}, + shouldDisableEmojiPicker: false, }; class ReportActionItemMessageEdit extends React.Component { @@ -190,10 +185,6 @@ class ReportActionItemMessageEdit extends React.Component { } render() { - const shouldDisableEmojiPicker = (ReportUtils.chatIncludesConcierge(this.props.report) - && User.isBlockedFromConcierge(this.props.blockedFromConcierge)) - || ReportUtils.isArchivedRoom(this.props.report); - return ( @@ -225,7 +216,7 @@ class ReportActionItemMessageEdit extends React.Component { /> InteractionManager.runAfterInteractions(() => this.textInput.focus())} onEmojiSelected={this.addEmojiToTextBox} />