diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.tsx similarity index 62% rename from src/components/AttachmentModal.js rename to src/components/AttachmentModal.tsx index 46ce4cf63f26..f3e8ed316c52 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.tsx @@ -1,32 +1,32 @@ import Str from 'expensify-common/lib/str'; -import lodashExtend from 'lodash/extend'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {Animated, Keyboard, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import compose from '@libs/compose'; import fileDownload from '@libs/fileDownload'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import useNativeDriver from '@libs/useNativeDriver'; -import reportPropTypes from '@pages/reportPropTypes'; +import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {EmptyObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type ModalType from '@src/types/utils/ModalType'; import AttachmentCarousel from './Attachments/AttachmentCarousel'; import AttachmentView from './Attachments/AttachmentView'; import BlockingView from './BlockingViews/BlockingView'; @@ -37,127 +37,156 @@ import HeaderGap from './HeaderGap'; import HeaderWithBackButton from './HeaderWithBackButton'; import * as Expensicons from './Icon/Expensicons'; import * as Illustrations from './Icon/Illustrations'; -import sourcePropTypes from './Image/sourcePropTypes'; import Modal from './Modal'; import SafeAreaConsumer from './SafeAreaConsumer'; -import transactionPropTypes from './transactionPropTypes'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; /** * Modal render prop component that exposes modal launching triggers that can be used * to display a full size image or PDF modally with optional confirmation button. */ -const propTypes = { +type AttachmentModalOnyxProps = { + /** The transaction associated with the receipt attachment, if any */ + transaction: OnyxEntry; + + /** The report associated with the receipt attachment, if any */ + parentReport: OnyxEntry; + + /** The policy associated with the receipt attachment, if any */ + policy: OnyxEntry; + + /** The list of report actions associated with the receipt attachment, if any */ + parentReportActions: OnyxEntry; +}; + +type Attachment = { + source: AvatarSource; + isAuthTokenRequired: boolean; + file: FileObject; + isReceipt: boolean; + hasBeenFlagged?: boolean; + reportActionID?: string; +}; + +type ImagePickerResponse = { + height: number; + name: string; + size: number; + type: string; + uri: string; + width: number; +}; + +type FileObject = File | ImagePickerResponse; + +type ChildrenProps = { + displayFileInModal: (data: FileObject) => void; + show: () => void; +}; + +type AttachmentModalProps = AttachmentModalOnyxProps & { /** Optional source (URL, SVG function) for the image shown. If not passed in via props must be specified when modal is opened. */ - source: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]), + source?: AvatarSource; /** Optional callback to fire when we want to preview an image and approve it for use. */ - onConfirm: PropTypes.func, + onConfirm?: ((file: Partial) => void) | null; /** Whether the modal should be open by default */ - defaultOpen: PropTypes.bool, - - /** Optional callback to fire when we want to do something after modal show. */ - onModalShow: PropTypes.func, - - /** Optional callback to fire when we want to do something after modal hide. */ - onModalHide: PropTypes.func, + defaultOpen?: boolean; /** Trigger when we explicity click close button in ProfileAttachment modal */ - onModalClose: PropTypes.func, - - /** Optional callback to fire when we want to do something after attachment carousel changes. */ - onCarouselAttachmentChange: PropTypes.func, + onModalClose?: () => void; /** Optional original filename when uploading */ - originalFileName: PropTypes.string, - - /** A function as a child to pass modal launching methods to */ - children: PropTypes.func, + originalFileName?: string; /** Whether source url requires authentication */ - isAuthTokenRequired: PropTypes.bool, + isAuthTokenRequired?: boolean; /** Determines if download Button should be shown or not */ - allowDownload: PropTypes.bool, + allowDownload?: boolean; /** Title shown in the header of the modal */ - headerTitle: PropTypes.string, + headerTitle?: string; /** The report that has this attachment */ - report: reportPropTypes, + report?: OnyxEntry | EmptyObject; - /** The transaction associated with the receipt attachment, if any */ - transaction: transactionPropTypes, + /** Optional callback to fire when we want to do something after modal show. */ + onModalShow?: () => void; + + /** Optional callback to fire when we want to do something after modal hide. */ + onModalHide?: () => void; /** The data is loading or not */ - isLoading: PropTypes.bool, + isLoading?: boolean; /** Should display not found page or not */ - shouldShowNotFoundPage: PropTypes.bool, - - ...withLocalizePropTypes, + shouldShowNotFoundPage?: boolean; - ...windowDimensionsPropTypes, + /** Optional callback to fire when we want to do something after attachment carousel changes. */ + onCarouselAttachmentChange?: (attachment: Attachment) => void; /** Denotes whether it is a workspace avatar or not */ - isWorkspaceAvatar: PropTypes.bool, + isWorkspaceAvatar?: boolean; /** Whether it is a receipt attachment or not */ - isReceiptAttachment: PropTypes.bool, + isReceiptAttachment?: boolean; - /** Whether the receipt can be replaced */ - canEditReceipt: PropTypes.bool, -}; + /** A function as a child to pass modal launching methods to */ + children?: React.FC; -const defaultProps = { - source: '', - onConfirm: null, - defaultOpen: false, - originalFileName: '', - children: null, - isAuthTokenRequired: false, - allowDownload: false, - headerTitle: null, - report: {}, - transaction: {}, - onModalShow: () => {}, - onModalHide: () => {}, - onCarouselAttachmentChange: () => {}, - isWorkspaceAvatar: false, - onModalClose: () => {}, - isLoading: false, - shouldShowNotFoundPage: false, - isReceiptAttachment: false, - canEditReceipt: false, + fallbackSource?: AvatarSource; + + canEditReceipt?: boolean; }; -function AttachmentModal(props) { - const theme = useTheme(); +function AttachmentModal({ + source = '', + onConfirm, + defaultOpen = false, + originalFileName = '', + isAuthTokenRequired = false, + allowDownload = false, + report, + onModalShow = () => {}, + onModalHide = () => {}, + onCarouselAttachmentChange = () => {}, + isReceiptAttachment = false, + isWorkspaceAvatar = false, + transaction, + parentReport, + parentReportActions, + headerTitle, + policy, + children, + fallbackSource, + canEditReceipt = false, + onModalClose = () => {}, + isLoading = false, + shouldShowNotFoundPage = false, +}: AttachmentModalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const [isModalOpen, setIsModalOpen] = useState(props.defaultOpen); + const [isModalOpen, setIsModalOpen] = useState(defaultOpen); const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); - const [isAuthTokenRequired, setIsAuthTokenRequired] = useState(props.isAuthTokenRequired); - const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); - const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); - const [source, setSource] = useState(() => props.source); - const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); + const [isAuthTokenRequiredState, setIsAuthTokenRequiredState] = useState(isAuthTokenRequired); + const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(null); + const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); + const [sourceState, setSourceState] = useState(() => source); + const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); const [confirmButtonFadeAnimation] = useState(() => new Animated.Value(1)); const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = React.useState(true); - const {windowWidth} = useWindowDimensions(); - - const isOverlayModalVisible = (props.isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!props.isReceiptAttachment && isAttachmentInvalid); + const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); + const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && isAttachmentInvalid); - const [file, setFile] = useState( - props.originalFileName + const [file, setFile] = useState | undefined>( + originalFileName ? { - name: props.originalFileName, + name: originalFileName, } : undefined, ); @@ -165,41 +194,35 @@ function AttachmentModal(props) { const {isOffline} = useNetwork(); useEffect(() => { - setFile(props.originalFileName ? {name: props.originalFileName} : undefined); - }, [props.originalFileName]); - - const onCarouselAttachmentChange = props.onCarouselAttachmentChange; + setFile(originalFileName ? {name: originalFileName} : undefined); + }, [originalFileName]); /** * Keeps the attachment source in sync with the attachment displayed currently in the carousel. - * @param {{ source: String, isAuthTokenRequired: Boolean, file: { name: string }, isReceipt: Boolean }} attachment */ const onNavigate = useCallback( - (attachment) => { - setSource(attachment.source); + (attachment: Attachment) => { + setSourceState(attachment.source); setFile(attachment.file); - setIsAuthTokenRequired(attachment.isAuthTokenRequired); + setIsAuthTokenRequiredState(attachment.isAuthTokenRequired); onCarouselAttachmentChange(attachment); }, [onCarouselAttachmentChange], ); /** - * If our attachment is a PDF, return the unswipeable Modal type. - * @param {String} sourceURL - * @param {Object} _file - * @returns {String} + * If our attachment is a PDF, return the unswipeablge Modal type. */ const getModalType = useCallback( - (sourceURL, _file) => - sourceURL && (Str.isPDF(sourceURL) || (_file && Str.isPDF(_file.name || translate('attachmentView.unknownFilename')))) + (sourceURL: string, fileObject: FileObject) => + sourceURL && (Str.isPDF(sourceURL) || (fileObject && Str.isPDF(fileObject.name || translate('attachmentView.unknownFilename')))) ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.CENTERED, [translate], ); const setDownloadButtonVisibility = useCallback( - (isButtonVisible) => { + (isButtonVisible: boolean) => { if (isDownloadButtonReadyToBeShown === isButtonVisible) { return; } @@ -212,17 +235,19 @@ function AttachmentModal(props) { * Download the currently viewed attachment. */ const downloadAttachment = useCallback(() => { - let sourceURL = source; - if (isAuthTokenRequired) { + let sourceURL = sourceState; + if (isAuthTokenRequiredState && typeof sourceURL === 'string') { sourceURL = addEncryptedAuthTokenToURL(sourceURL); } - fileDownload(sourceURL, lodashGet(file, 'name', '')); + if (typeof sourceURL === 'string') { + fileDownload(sourceURL, file?.name ?? ''); + } // At ios, if the keyboard is open while opening the attachment, then after downloading // the attachment keyboard will show up. So, to fix it we need to dismiss the keyboard. Keyboard.dismiss(); - }, [isAuthTokenRequired, source, file]); + }, [isAuthTokenRequiredState, sourceState, file]); /** * Execute the onConfirm callback and close the modal. @@ -234,13 +259,13 @@ function AttachmentModal(props) { return; } - if (props.onConfirm) { - props.onConfirm(lodashExtend(file, {source})); + if (onConfirm) { + onConfirm(Object.assign(file ?? {}, {source: sourceState})); } setIsModalOpen(false); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isModalOpen, isConfirmButtonDisabled, props.onConfirm, file, source]); + }, [isModalOpen, isConfirmButtonDisabled, onConfirm, file, sourceState]); /** * Close the confirm modals. @@ -254,24 +279,20 @@ function AttachmentModal(props) { * Detach the receipt and close the modal. */ const deleteAndCloseModal = useCallback(() => { - IOU.detachReceipt(props.transaction.transactionID, props.report.reportID); + IOU.detachReceipt(transaction?.transactionID); setIsDeleteReceiptConfirmModalVisible(false); - Navigation.dismissModal(props.report.reportID); - }, [props.transaction, props.report]); + Navigation.dismissModal(report?.reportID); + }, [transaction, report]); - /** - * @param {Object} _file - * @returns {Boolean} - */ - const isValidFile = useCallback((_file) => { - if (lodashGet(_file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + const isValidFile = useCallback((fileObject: FileObject) => { + if (fileObject.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { setIsAttachmentInvalid(true); setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooLarge'); setAttachmentInvalidReason('attachmentPicker.sizeExceeded'); return false; } - if (lodashGet(_file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + if (fileObject.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { setIsAttachmentInvalid(true); setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooSmall'); setAttachmentInvalidReason('attachmentPicker.sizeNotMet'); @@ -280,12 +301,9 @@ function AttachmentModal(props) { return true; }, []); - /** - * @param {Object} _data - * @returns {Boolean} - */ - const isDirectoryCheck = useCallback((_data) => { - if (typeof _data.webkitGetAsEntry === 'function' && _data.webkitGetAsEntry().isDirectory) { + + const isDirectoryCheck = useCallback((data: FileObject) => { + if ('webkitGetAsEntry' in data && typeof data.webkitGetAsEntry === 'function' && data.webkitGetAsEntry().isDirectory) { setIsAttachmentInvalid(true); setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); setAttachmentInvalidReason('attachmentPicker.folderNotAllowedMessage'); @@ -294,17 +312,14 @@ function AttachmentModal(props) { return true; }, []); - /** - * @param {Object} _data - */ const validateAndDisplayFileToUpload = useCallback( - (_data) => { - if (!isDirectoryCheck(_data)) { + (data: FileObject) => { + if (!isDirectoryCheck(data)) { return; } - let fileObject = _data; - if (typeof _data.getAsFile === 'function') { - fileObject = _data.getAsFile(); + let fileObject = data; + if ('getAsFile' in data && typeof data.getAsFile === 'function') { + fileObject = data.getAsFile(); } if (!fileObject) { return; @@ -327,13 +342,13 @@ function AttachmentModal(props) { const inputSource = URL.createObjectURL(updatedFile); const inputModalType = getModalType(inputSource, updatedFile); setIsModalOpen(true); - setSource(inputSource); + setSourceState(inputSource); setFile(updatedFile); setModalType(inputModalType); } else { const inputModalType = getModalType(fileObject.uri, fileObject); setIsModalOpen(true); - setSource(fileObject.uri); + setSourceState(fileObject.uri); setFile(fileObject); setModalType(inputModalType); } @@ -347,10 +362,10 @@ function AttachmentModal(props) { * we're only updating the opacity of the confirm button, we must also conditionally * disable it. * - * @param {Boolean} shouldFadeOut If true, fade out confirm button. Otherwise fade in. + * @param shouldFadeOut If true, fade out confirm button. Otherwise fade in. */ const updateConfirmButtonVisibility = useCallback( - (shouldFadeOut) => { + (shouldFadeOut: boolean) => { setIsConfirmButtonDisabled(shouldFadeOut); const toValue = shouldFadeOut ? 0 : 1; @@ -369,12 +384,12 @@ function AttachmentModal(props) { const closeModal = useCallback(() => { setIsModalOpen(false); - if (typeof props.onModalClose === 'function') { - props.onModalClose(); + if (typeof onModalClose === 'function') { + onModalClose(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.onModalClose]); + }, [onModalClose]); /** * open the modal @@ -384,33 +399,33 @@ function AttachmentModal(props) { }, []); useEffect(() => { - setSource(() => props.source); - }, [props.source]); + setSourceState(() => source); + }, [source]); useEffect(() => { - setIsAuthTokenRequired(props.isAuthTokenRequired); - }, [props.isAuthTokenRequired]); + setIsAuthTokenRequiredState(isAuthTokenRequired); + }, [isAuthTokenRequired]); - const sourceForAttachmentView = source || props.source; + const sourceForAttachmentView = sourceState || source; const threeDotsMenuItems = useMemo(() => { - if (!props.isReceiptAttachment || !props.parentReport || !props.parentReportActions) { + if (!isReceiptAttachment || !parentReport || !parentReportActions) { return []; } const menuItems = []; - if (props.canEditReceipt) { + if (canEditReceipt) { menuItems.push({ icon: Expensicons.Camera, - text: props.translate('common.replace'), + text: translate('common.replace'), onSelected: () => { closeModal(); Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, - props.transaction.transactionID, - props.report.reportID, + transaction?.transactionID ?? '', + report?.reportID ?? '', Navigation.getActiveRouteWithoutParams(), ), ); @@ -420,14 +435,14 @@ function AttachmentModal(props) { if (!isOffline) { menuItems.push({ icon: Expensicons.Download, - text: props.translate('common.download'), - onSelected: () => downloadAttachment(source), + text: translate('common.download'), + onSelected: () => downloadAttachment(), }); } - if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && props.canEditReceipt) { + if (TransactionUtils.hasReceipt(transaction) && !TransactionUtils.isReceiptBeingScanned(transaction) && canEditReceipt) { menuItems.push({ icon: Expensicons.Trashcan, - text: props.translate('receipt.deleteReceipt'), + text: translate('receipt.deleteReceipt'), onSelected: () => { setIsDeleteReceiptConfirmModalVisible(true); }, @@ -435,17 +450,17 @@ function AttachmentModal(props) { } return menuItems; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isReceiptAttachment, props.parentReport, props.parentReportActions, props.policy, props.transaction, file, source]); + }, [isReceiptAttachment, parentReport, parentReportActions, policy, transaction, file, sourceState]); // There are a few things that shouldn't be set until we absolutely know if the file is a receipt or an attachment. // props.isReceiptAttachment will be null until its certain what the file is, in which case it will then be true|false. - let headerTitle = props.headerTitle; + let headerTitleNew = headerTitle; let shouldShowDownloadButton = false; let shouldShowThreeDotsButton = false; - if (!_.isEmpty(props.report)) { - headerTitle = translate(props.isReceiptAttachment ? 'common.receipt' : 'common.attachment'); - shouldShowDownloadButton = props.allowDownload && isDownloadButtonReadyToBeShown && !props.isReceiptAttachment && !isOffline; - shouldShowThreeDotsButton = props.isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; + if (!isEmptyObject(report)) { + headerTitleNew = translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); + shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !isReceiptAttachment && !isOffline; + shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; } return ( @@ -455,26 +470,25 @@ function AttachmentModal(props) { onSubmit={submitAndClose} onClose={isOverlayModalVisible ? closeConfirmModal : closeModal} isVisible={isModalOpen} - backgroundColor={theme.componentBG} onModalShow={() => { - props.onModalShow(); + onModalShow(); setShouldLoadAttachment(true); }} - onModalHide={(e) => { - props.onModalHide(e); + onModalHide={() => { + onModalHide(); setShouldLoadAttachment(false); }} propagateSwipe > - {props.isSmallScreenWidth && } + {isSmallScreenWidth && } downloadAttachment(source)} - shouldShowCloseButton={!props.isSmallScreenWidth} - shouldShowBackButton={props.isSmallScreenWidth} + onDownloadButtonPress={() => downloadAttachment()} + shouldShowCloseButton={!isSmallScreenWidth} + shouldShowBackButton={isSmallScreenWidth} onBackButtonPress={closeModal} onCloseButtonPress={closeModal} shouldShowThreeDotsButton={shouldShowThreeDotsButton} @@ -483,8 +497,8 @@ function AttachmentModal(props) { shouldOverlayDots /> - {props.isLoading && } - {props.shouldShowNotFoundPage && !props.isLoading && ( + {isLoading && } + {shouldShowNotFoundPage && !isLoading && ( Navigation.dismissModal()} /> )} - {!_.isEmpty(props.report) && !props.isReceiptAttachment ? ( + {!isEmptyObject(report) && !isReceiptAttachment ? ( ) : ( - Boolean(sourceForAttachmentView) && + !!sourceForAttachmentView && shouldLoadAttachment && - !props.isLoading && - !props.shouldShowNotFoundPage && ( + !isLoading && + !shouldShowNotFoundPage && ( ) )} {/* If we have an onConfirm method show a confirmation button */} - {Boolean(props.onConfirm) && ( + {!!onConfirm && ( {({safeAreaPaddingBottomStyle}) => (