diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1eb8ae65e6c88..77b7544169b02 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2002,18 +2002,23 @@ const CONST = { VIDEO: /\.(mov|mp4)$/, }, - FILE_VALIDATION_ERRORS: { + SINGLE_ATTACHMENT_FILE_VALIDATION_ERRORS: { + NO_FILE_PROVIDED: 'noFileProvided', + FILE_INVALID: 'fileInvalid', WRONG_FILE_TYPE: 'wrongFileType', - WRONG_FILE_TYPE_MULTIPLE: 'wrongFileTypeMultiple', FILE_TOO_LARGE: 'fileTooLarge', - FILE_TOO_LARGE_MULTIPLE: 'fileTooLargeMultiple', FILE_TOO_SMALL: 'fileTooSmall', FILE_CORRUPTED: 'fileCorrupted', - FOLDER_NOT_ALLOWED: 'folderNotAllowed', - MAX_FILE_LIMIT_EXCEEDED: 'fileLimitExceeded', PROTECTED_FILE: 'protectedFile', }, + MULTIPLE_ATTACHMENT_FILES_VALIDATION_ERRORS: { + WRONG_FILE_TYPE: 'multipleAttachmentsWrongFileType', + FILE_TOO_LARGE: 'multipleAttachmentsFileTooLarge', + FOLDER_NOT_ALLOWED: 'multipleAttachmentsFolderNotAllowed', + MAX_FILE_LIMIT_EXCEEDED: 'multipleAttachmentsMaxFileLimitExceeded', + }, + IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied', ADD_PAYMENT_MENU_POSITION_Y: 226, ADD_PAYMENT_MENU_POSITION_X: 356, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f599663ad3e20..242f78fbb238e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -476,16 +476,7 @@ const ROUTES = { return getUrlWithBackToParam(`${baseRoute}${queryString}` as const, backTo); }, }, - REPORT_AVATAR: { - route: 'r/:reportID/avatar', - getRoute: (reportID: string, policyID?: string) => { - if (policyID) { - return `r/${reportID}/avatar?policyID=${policyID}` as const; - } - return `r/${reportID}/avatar` as const; - }, - }, - ATTACHMENTS: { + REPORT_ATTACHMENTS: { route: 'attachment', getRoute: (params?: ReportAttachmentsRouteParams) => getAttachmentModalScreenRoute('attachment', params), }, @@ -495,6 +486,15 @@ const ROUTES = { return getAttachmentModalScreenRoute(`r/${reportID}/attachment/add`, params); }, }, + REPORT_AVATAR: { + route: 'r/:reportID/avatar', + getRoute: (reportID: string, policyID?: string) => { + if (policyID) { + return `r/${reportID}/avatar?policyID=${policyID}` as const; + } + return `r/${reportID}/avatar` as const; + }, + }, EDIT_CURRENCY_REQUEST: { route: 'r/:threadReportID/edit/currency', getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}` as const, @@ -3317,11 +3317,11 @@ const SHARED_ROUTE_PARAMS: Partial> = { export {PUBLIC_SCREENS_ROUTES, SHARED_ROUTE_PARAMS, VERIFY_ACCOUNT}; export default ROUTES; -type ReportAttachmentsRoute = typeof ROUTES.ATTACHMENTS.route; +type ReportAttachmentsRoute = typeof ROUTES.REPORT_ATTACHMENTS.route; type ReportAddAttachmentRoute = `r/${string}/attachment/add`; type AttachmentRoutes = ReportAttachmentsRoute | ReportAddAttachmentRoute; -type ReportAttachmentsRouteParams = RootNavigatorParamList[typeof SCREENS.ATTACHMENTS]; +type ReportAttachmentsRouteParams = RootNavigatorParamList[typeof SCREENS.REPORT_ATTACHMENTS]; type ReportAddAttachmentRouteParams = RootNavigatorParamList[typeof SCREENS.REPORT_ADD_ATTACHMENT]; function getAttachmentModalScreenRoute(url: AttachmentRoutes, params?: ReportAttachmentsRouteParams | ReportAddAttachmentRouteParams) { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 60e8139be4ebb..53ca71a02410f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -7,7 +7,7 @@ import type DeepValueOf from './types/utils/DeepValueOf'; const PROTECTED_SCREENS = { HOME: 'Home', CONCIERGE: 'Concierge', - ATTACHMENTS: 'Attachments', + REPORT_ATTACHMENTS: 'ReportAttachments', REPORT_ADD_ATTACHMENT: 'ReportAddAttachment', TRACK_EXPENSE: 'TrackExpense', SUBMIT_EXPENSE: 'SubmitExpense', diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx deleted file mode 100644 index d7b50ee8e06b1..0000000000000 --- a/src/components/AttachmentModal.tsx +++ /dev/null @@ -1,607 +0,0 @@ -import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {InteractionManager, Keyboard, View} from 'react-native'; -import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import type {OnyxEntry} from 'react-native-onyx'; -import Animated, {FadeIn, LayoutAnimationConfig, useSharedValue} from 'react-native-reanimated'; -import type {ValueOf} from 'type-fest'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; -import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import attachmentModalHandler from '@libs/AttachmentModalHandler'; -import fileDownload from '@libs/fileDownload'; -import {getFileName} from '@libs/fileDownload/FileUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isReceiptBeingScanned} from '@libs/TransactionUtils'; -import type {AvatarSource} from '@libs/UserUtils'; -import variables from '@styles/variables'; -import {detachReceipt} from '@userActions/IOU'; -import type {IOUAction, IOUType} from '@src/CONST'; -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 {isEmptyObject} from '@src/types/utils/EmptyObject'; -import viewRef from '@src/types/utils/viewRef'; -import AttachmentCarousel from './Attachments/AttachmentCarousel'; -import AttachmentCarouselPagerContext from './Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; -import AttachmentView from './Attachments/AttachmentView'; -import useAttachmentErrors from './Attachments/AttachmentView/useAttachmentErrors'; -import type {Attachment} from './Attachments/types'; -import BlockingView from './BlockingViews/BlockingView'; -import Button from './Button'; -import ConfirmModal from './ConfirmModal'; -import FullScreenLoadingIndicator from './FullscreenLoadingIndicator'; -import HeaderGap from './HeaderGap'; -import HeaderWithBackButton from './HeaderWithBackButton'; -import * as Expensicons from './Icon/Expensicons'; -import * as Illustrations from './Icon/Illustrations'; -import Modal from './Modal'; -import SafeAreaConsumer from './SafeAreaConsumer'; - -/** - * 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. - */ - -type ImagePickerResponse = { - height?: number; - name: string; - size?: number | null; - type: string; - uri: string; - width?: number; -}; - -type FileObject = Partial; - -type ChildrenProps = { - show: () => void; -}; - -type AttachmentModalProps = { - /** Optional source (URL, SVG function) for the image shown. If not passed in via props must be specified when modal is opened. */ - source?: AvatarSource; - - /** The id of the attachment. */ - attachmentID?: string; - - /** Optional callback to fire when we want to preview an image and approve it for use. */ - onConfirm?: ((file: FileObject) => void) | null; - - /** Whether the modal should be open by default */ - defaultOpen?: boolean; - - /** Trigger when we explicity click close button in ProfileAttachment modal */ - onModalClose?: () => void; - - /** Optional original filename when uploading */ - originalFileName?: string; - - /** Whether source url requires authentication */ - isAuthTokenRequired?: boolean; - - /** Determines if download Button should be shown or not */ - allowDownload?: boolean; - - /** Determines if the receipt comes from track expense action */ - isTrackExpenseAction?: boolean; - - /** Title shown in the header of the modal */ - headerTitle?: string; - - /** The report that has this attachment */ - report?: OnyxEntry; - - /** The ID of the current report */ - reportID?: string; - - /** The type of the attachment */ - type?: ValueOf; - - /** If the attachment originates from a note, the accountID will represent the author of that note. */ - accountID?: number; - - /** 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?: boolean; - - /** Should display not found page or not */ - shouldShowNotFoundPage?: boolean; - - /** 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?: boolean; - - /** Denotes whether it can be an icon (ex: SVG) */ - maybeIcon?: boolean; - - /** Whether it is a receipt attachment or not */ - isReceiptAttachment?: boolean; - - /** A function as a child to pass modal launching methods to */ - children?: React.FC; - - /** The iou action of the expense creation flow of which we are displaying the receipt for. */ - iouAction?: IOUAction; - - /** The iou type of the expense creation flow of which we are displaying the receipt for. */ - iouType?: IOUType; - - /** The id of the draft transaction linked to the receipt. */ - draftTransactionID?: string; - - fallbackSource?: AvatarSource; - - canEditReceipt?: boolean; - - canDeleteReceipt?: boolean; - - shouldDisableSendButton?: boolean; - - attachmentLink?: string; - - shouldHandleNavigationBack?: boolean; -}; - -function AttachmentModal({ - source = '', - onConfirm, - defaultOpen = false, - originalFileName = '', - isAuthTokenRequired = false, - allowDownload = false, - isTrackExpenseAction = false, - report, - reportID, - onModalShow = () => {}, - onModalHide = () => {}, - onCarouselAttachmentChange = () => {}, - isReceiptAttachment = false, - isWorkspaceAvatar = false, - maybeIcon = false, - headerTitle, - children, - fallbackSource, - canEditReceipt = false, - canDeleteReceipt = false, - onModalClose = () => {}, - isLoading = false, - shouldShowNotFoundPage = false, - type = undefined, - attachmentID, - accountID = undefined, - shouldDisableSendButton = false, - draftTransactionID, - iouAction, - iouType: iouTypeProp, - attachmentLink = '', - shouldHandleNavigationBack, -}: AttachmentModalProps) { - const styles = useThemeStyles(); - const [isModalOpen, setIsModalOpen] = useState(defaultOpen); - const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); - const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); - const [isAuthTokenRequiredState, setIsAuthTokenRequiredState] = useState(isAuthTokenRequired); - const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(null); - const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); - const [sourceState, setSourceState] = useState(() => source); - const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); - const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = React.useState(true); - const isPDFLoadError = useRef(false); - const isReplaceReceipt = useRef(false); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const nope = useSharedValue(false); - const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && isAttachmentInvalid); - const iouType = useMemo(() => iouTypeProp ?? (isTrackExpenseAction ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseAction, iouTypeProp]); - const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const transactionID = (isMoneyRequestAction(parentReportAction) && getOriginalMessage(parentReportAction)?.IOUTransactionID) || CONST.DEFAULT_NUMBER_ID; - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {canBeMissing: true}); - const [currentAttachmentLink, setCurrentAttachmentLink] = useState(attachmentLink); - const {setAttachmentError, isErrorInAttachment, clearAttachmentErrors} = useAttachmentErrors(); - - const [file, setFile] = useState( - originalFileName - ? { - name: originalFileName, - } - : undefined, - ); - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - - const isLocalSource = typeof sourceState === 'string' && /^file:|^blob:/.test(sourceState); - - useEffect(() => { - setFile(originalFileName ? {name: originalFileName} : undefined); - }, [originalFileName]); - - /** - * Keeps the attachment source in sync with the attachment displayed currently in the carousel. - */ - const onNavigate = useCallback( - (attachment: Attachment) => { - setSourceState(attachment.source); - setFile(attachment.file); - setIsAuthTokenRequiredState(attachment.isAuthTokenRequired ?? false); - onCarouselAttachmentChange(attachment); - setCurrentAttachmentLink(attachment?.attachmentLink ?? ''); - }, - [onCarouselAttachmentChange], - ); - - const setDownloadButtonVisibility = useCallback( - (isButtonVisible: boolean) => { - if (isDownloadButtonReadyToBeShown === isButtonVisible) { - return; - } - setIsDownloadButtonReadyToBeShown(isButtonVisible); - }, - [isDownloadButtonReadyToBeShown], - ); - - /** - * Download the currently viewed attachment. - */ - const downloadAttachment = useCallback(() => { - let sourceURL = sourceState; - if (isAuthTokenRequiredState && typeof sourceURL === 'string') { - sourceURL = addEncryptedAuthTokenToURL(sourceURL); - } - - if (typeof sourceURL === 'string') { - const fileName = type === CONST.ATTACHMENT_TYPE.SEARCH ? getFileName(`${sourceURL}`) : file?.name; - fileDownload(sourceURL, fileName ?? '', undefined, undefined, undefined, undefined, undefined, !draftTransactionID); - } - - // 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(); - }, [isAuthTokenRequiredState, sourceState, file, type, draftTransactionID]); - - /** - * Execute the onConfirm callback and close the modal. - */ - const submitAndClose = useCallback(() => { - // If the modal has already been closed or the confirm button is disabled - // do not submit. - if (!isModalOpen || isConfirmButtonDisabled) { - return; - } - - if (onConfirm) { - onConfirm(Object.assign(file ?? {}, {source: sourceState} as FileObject)); - } - - setIsModalOpen(false); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [isModalOpen, isConfirmButtonDisabled, onConfirm, file, sourceState]); - - /** - * Close the confirm modals. - */ - const closeConfirmModal = useCallback(() => { - setIsAttachmentInvalid(false); - setIsDeleteReceiptConfirmModalVisible(false); - }, []); - - /** - * Detach the receipt and close the modal. - */ - const deleteAndCloseModal = useCallback(() => { - detachReceipt(transaction?.transactionID); - setIsDeleteReceiptConfirmModalVisible(false); - Navigation.goBack(); - }, [transaction]); - - /** - * Closes the modal. - * @param {boolean} [shouldCallDirectly] If true, directly calls `onModalClose`. - * This is useful when you plan to continue navigating to another page after closing the modal, to avoid freezing the app due to navigating to another page first and dismissing the modal later. - * If `shouldCallDirectly` is false or undefined, it calls `attachmentModalHandler.handleModalClose` to close the modal. - * This ensures smooth modal closing behavior without causing delays in closing. - */ - const closeModal = useCallback( - (shouldCallDirectly?: boolean) => { - setIsModalOpen(false); - - if (typeof onModalClose === 'function') { - if (shouldCallDirectly) { - onModalClose(); - return; - } - attachmentModalHandler.handleModalClose(onModalClose); - } - - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, - [onModalClose], - ); - - /** - * open the modal - */ - const openModal = useCallback(() => { - setIsModalOpen(true); - }, []); - - useEffect(() => { - setSourceState(() => source); - }, [source]); - - useEffect(() => { - setIsAuthTokenRequiredState(isAuthTokenRequired); - }, [isAuthTokenRequired]); - - const sourceForAttachmentView = sourceState || source; - - const threeDotsMenuItems = useMemo(() => { - if (!isReceiptAttachment) { - return []; - } - - const menuItems = []; - if (canEditReceipt) { - // linter keep complain about accessing ref during render - // eslint-disable-next-line react-compiler/react-compiler - menuItems.push({ - icon: Expensicons.Camera, - text: translate('common.replace'), - onSelected: () => { - closeModal(true); - // Set the ref to true, so when the modal is hidden, we will navigate to the scan receipt screen - isReplaceReceipt.current = true; - }, - }); - } - if ((!isOffline && allowDownload && !isLocalSource) || !!draftTransactionID) { - menuItems.push({ - icon: Expensicons.Download, - text: translate('common.download'), - onSelected: () => downloadAttachment(), - }); - } - - const hasOnlyEReceipt = hasEReceipt(transaction) && !hasReceiptSource(transaction); - if (!hasOnlyEReceipt && hasReceipt(transaction) && !isReceiptBeingScanned(transaction) && canDeleteReceipt && !hasMissingSmartscanFields(transaction)) { - menuItems.push({ - icon: Expensicons.Trashcan, - text: translate('receipt.deleteReceipt'), - onSelected: () => { - setIsDeleteReceiptConfirmModalVisible(true); - }, - shouldCallAfterModalHide: true, - }); - } - return menuItems; - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [isReceiptAttachment, transaction, file, sourceState, iouType]); - - const headerTitleNew = headerTitle ?? translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); - const shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; - let shouldShowDownloadButton = false; - if ((!isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH) && !isErrorInAttachment(sourceState)) { - shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline && !isLocalSource; - } - const context = useMemo( - () => ({ - pagerItems: [{source: sourceForAttachmentView, index: 0, isActive: true}], - activePage: 0, - pagerRef: undefined, - isPagerScrolling: nope, - isScrollEnabled: nope, - onTap: () => {}, - onScaleChanged: () => {}, - onSwipeDown: closeModal, - onAttachmentError: setAttachmentError, - }), - [closeModal, setAttachmentError, nope, sourceForAttachmentView], - ); - - const submitRef = useRef(null); - - return ( - <> - { - onModalShow(); - setShouldLoadAttachment(true); - }} - onModalHide={() => { - if (!isPDFLoadError.current) { - onModalHide(); - } - setShouldLoadAttachment(false); - clearAttachmentErrors(); - if (isPDFLoadError.current) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); - setAttachmentInvalidReason('attachmentPicker.errorWhileSelectingCorruptedAttachment'); - return; - } - - if (isReplaceReceipt.current) { - // eslint-disable-next-line deprecation/deprecation - InteractionManager.runAfterInteractions(() => { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( - iouAction ?? CONST.IOU.ACTION.EDIT, - iouType, - draftTransactionID ?? transaction?.transactionID, - report?.reportID, - Navigation.getActiveRoute(), - ), - ); - }); - } - }} - initialFocus={() => { - if (!submitRef.current) { - return false; - } - return submitRef.current; - }} - shouldHandleNavigationBack={shouldHandleNavigationBack} - > - - {shouldUseNarrowLayout && } - downloadAttachment()} - shouldShowCloseButton={!shouldUseNarrowLayout} - shouldShowBackButton={shouldUseNarrowLayout} - onBackButtonPress={closeModal} - onCloseButtonPress={closeModal} - shouldShowThreeDotsButton={shouldShowThreeDotsButton} - threeDotsAnchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, - }} - shouldSetModalVisibility={false} - threeDotsMenuItems={threeDotsMenuItems} - shouldOverlayDots - subTitleLink={currentAttachmentLink ?? ''} - shouldDisplayHelpButton={false} - /> - - {isLoading && } - {shouldShowNotFoundPage && !isLoading && ( - Navigation.dismissModal()} - /> - )} - {!shouldShowNotFoundPage && - !isLoading && - // We shouldn't show carousel arrow in search result attachment - (!isEmptyObject(report) && !isReceiptAttachment && type !== CONST.ATTACHMENT_TYPE.SEARCH ? ( - - ) : ( - !!sourceForAttachmentView && - shouldLoadAttachment && ( - - { - isPDFLoadError.current = true; - closeModal(); - }} - isWorkspaceAvatar={isWorkspaceAvatar} - maybeIcon={maybeIcon} - fallbackSource={fallbackSource} - isUsedInAttachmentModal - transactionID={transaction?.transactionID} - isUploaded={!isEmptyObject(report)} - reportID={reportID ?? (!isEmptyObject(report) ? report.reportID : undefined)} - /> - - ) - ))} - - {/* If we have an onConfirm method show a confirmation button */} - - {!!onConfirm && !isConfirmButtonDisabled && ( - - {({safeAreaPaddingBottomStyle}) => ( - -