diff --git a/src/App.tsx b/src/App.tsx index 3513cb23953b..2c47aacbf187 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,7 +38,7 @@ import {CurrentReportIDContextProvider} from './hooks/useCurrentReportID'; import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import {ReportIDsContextProvider} from './hooks/useReportIDs'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; -import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; +import {AttachmentModalProvider} from './pages/media/AttachmentModalScreen/AttachmentModalContext'; import type {Route} from './ROUTES'; import './setup/backgroundTask'; import {SplashScreenStateContextProvider} from './SplashScreenStateContext'; @@ -85,7 +85,7 @@ function App({url}: AppProps) { PopoverContextProvider, CurrentReportIDContextProvider, ScrollOffsetContextProvider, - ReportAttachmentsProvider, + AttachmentModalProvider, PickerStateProvider, EnvironmentProvider, CustomStatusBarAndBackgroundContextProvider, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b01ecf9058b7..0072cfe8383d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -353,22 +353,42 @@ const ROUTES = { }, ATTACHMENTS: { route: 'attachment', - getRoute: ( - reportID: string | undefined, - type: ValueOf, - url: string, - accountID?: number, - isAuthTokenRequired?: boolean, - fileName?: string, - attachmentLink?: string, - ) => { - const reportParam = reportID ? `&reportID=${reportID}` : ''; - const accountParam = accountID ? `&accountID=${accountID}` : ''; + getRoute: ({ + fileName, + reportID, + type, + accountID, + isAuthTokenRequired, + attachmentLink, + ...restParams + }: { + type?: ValueOf; + reportID?: string | number; + accountID?: number; + isAuthTokenRequired?: boolean; + fileName?: string; + attachmentLink?: string; + } & ( + | { + attachmentId: string; + } + | { + source: string; + } + )) => { + if ('attachmentId' in restParams) { + return `attachment?attachmentId=${encodeURIComponent(restParams.attachmentId)}` as const; + } + + const sourceParam = `?source=${encodeURIComponent(restParams.source)}`; + const typeParam = type ? `&type=${type as string}` : ''; + const reportIDParam = reportID ? `&reportID=${reportID}` : ''; + const accountIDParam = accountID ? `&accountID=${accountID}` : ''; const authTokenParam = isAuthTokenRequired ? '&isAuthTokenRequired=true' : ''; const fileNameParam = fileName ? `&fileName=${fileName}` : ''; const attachmentLinkParam = attachmentLink ? `&attachmentLink=${attachmentLink}` : ''; - return `attachment?source=${encodeURIComponent(url)}&type=${type as string}${reportParam}${accountParam}${authTokenParam}${fileNameParam}${attachmentLinkParam}` as const; + return `attachment${sourceParam}${typeParam}${reportIDParam}${accountIDParam}${authTokenParam}${fileNameParam}${attachmentLinkParam}` as const; }, }, REPORT_PARTICIPANTS: { @@ -389,7 +409,7 @@ const ROUTES = { }, REPORT_WITH_ID_DETAILS: { route: 'r/:reportID/details', - getRoute: (reportID: string | undefined, backTo?: string) => { + getRoute: (reportID: string | number | undefined, backTo?: string) => { if (!reportID) { Log.warn('Invalid reportID is used to build the REPORT_WITH_ID_DETAILS route'); } diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx deleted file mode 100644 index 4286c6c834eb..000000000000 --- a/src/components/AttachmentModal.tsx +++ /dev/null @@ -1,659 +0,0 @@ -import {Str} from 'expensify-common'; -import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {Keyboard, View} from 'react-native'; -import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import {useOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; -import Animated, {FadeIn, useSharedValue} from 'react-native-reanimated'; -import type {ValueOf} from 'type-fest'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import attachmentModalHandler from '@libs/AttachmentModalHandler'; -import fileDownload from '@libs/fileDownload'; -import {cleanFileName, getFileName, validateImageForCorruption} 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 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 type ModalType from '@src/types/utils/ModalType'; -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 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 = { - displayFileInModal: (data: FileObject) => void; - 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; - - /** 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 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; - - fallbackSource?: AvatarSource; - - canEditReceipt?: boolean; - - canDeleteReceipt?: boolean; - - shouldDisableSendButton?: boolean; - - attachmentLink?: string; -}; - -function AttachmentModal({ - source = '', - onConfirm, - defaultOpen = false, - originalFileName = '', - isAuthTokenRequired = false, - allowDownload = false, - isTrackExpenseAction = false, - report, - onModalShow = () => {}, - onModalHide = () => {}, - onCarouselAttachmentChange = () => {}, - isReceiptAttachment = false, - isWorkspaceAvatar = false, - maybeIcon = false, - headerTitle, - children, - fallbackSource, - canEditReceipt = false, - canDeleteReceipt = false, - onModalClose = () => {}, - isLoading = false, - shouldShowNotFoundPage = false, - type = undefined, - accountID = undefined, - shouldDisableSendButton = false, - attachmentLink = '', -}: 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 [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); - const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); - const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = React.useState(true); - const isPDFLoadError = useRef(false); - const {windowWidth} = useWindowDimensions(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const nope = useSharedValue(false); - const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && isAttachmentInvalid); - const iouType = useMemo(() => (isTrackExpenseAction ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseAction]); - 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}`); - const [currentAttachmentLink, setCurrentAttachmentLink] = useState(attachmentLink); - - 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], - ); - - /** - * If our attachment is a PDF, return the unswipeablge Modal type. - */ - const getModalType = useCallback( - (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: 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 ?? ''); - } - - // 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]); - - /** - * 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(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID)); - }, [transaction, report]); - - const isValidFile = useCallback( - (fileObject: FileObject) => - validateImageForCorruption(fileObject) - .then(() => { - if (fileObject.size && fileObject.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooLarge'); - setAttachmentInvalidReason('attachmentPicker.sizeExceeded'); - return false; - } - - if (fileObject.size && fileObject.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooSmall'); - setAttachmentInvalidReason('attachmentPicker.sizeNotMet'); - return false; - } - - return true; - }) - .catch(() => { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); - setAttachmentInvalidReason('attachmentPicker.errorWhileSelectingCorruptedAttachment'); - return false; - }), - [], - ); - - const isDirectoryCheck = useCallback((data: FileObject) => { - if ('webkitGetAsEntry' in data && (data as DataTransferItem).webkitGetAsEntry()?.isDirectory) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); - setAttachmentInvalidReason('attachmentPicker.folderNotAllowedMessage'); - return false; - } - return true; - }, []); - - const validateAndDisplayFileToUpload = useCallback( - (data: FileObject) => { - if (!data || !isDirectoryCheck(data)) { - return; - } - let fileObject = data; - if ('getAsFile' in data && typeof data.getAsFile === 'function') { - fileObject = data.getAsFile() as FileObject; - } - if (!fileObject) { - return; - } - - isValidFile(fileObject).then((isValid) => { - if (!isValid) { - return; - } - if (fileObject instanceof File) { - /** - * Cleaning file name, done here so that it covers all cases: - * upload, drag and drop, copy-paste - */ - let updatedFile = fileObject; - const cleanName = cleanFileName(updatedFile.name); - if (updatedFile.name !== cleanName) { - updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); - } - const inputSource = URL.createObjectURL(updatedFile); - updatedFile.uri = inputSource; - const inputModalType = getModalType(inputSource, updatedFile); - setIsModalOpen(true); - setSourceState(inputSource); - setFile(updatedFile); - setModalType(inputModalType); - } else if (fileObject.uri) { - const inputModalType = getModalType(fileObject.uri, fileObject); - setIsModalOpen(true); - setSourceState(fileObject.uri); - setFile(fileObject); - setModalType(inputModalType); - } - }); - }, - [isValidFile, getModalType, isDirectoryCheck], - ); - - /** - * 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) { - menuItems.push({ - icon: Expensicons.Camera, - text: translate('common.replace'), - onSelected: () => { - closeModal(true); - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, report?.reportID, Navigation.getActiveRouteWithoutParams()), - ); - }, - }); - } - if (!isOffline && allowDownload && !isLocalSource) { - 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]); - - // 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 headerTitleNew = headerTitle; - let shouldShowDownloadButton = false; - let shouldShowThreeDotsButton = false; - if (!isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH) { - headerTitleNew = translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); - shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline && !isLocalSource; - shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; - } - const context = useMemo( - () => ({ - pagerItems: [{source: sourceForAttachmentView, index: 0, isActive: true}], - activePage: 0, - pagerRef: undefined, - isPagerScrolling: nope, - isScrollEnabled: nope, - onTap: () => {}, - onScaleChanged: () => {}, - onSwipeDown: closeModal, - }), - [closeModal, nope, sourceForAttachmentView], - ); - - const submitRef = useRef(null); - - return ( - <> - { - onModalShow(); - setShouldLoadAttachment(true); - }} - onModalHide={() => { - if (!isPDFLoadError.current) { - onModalHide(); - } - setShouldLoadAttachment(false); - if (isPDFLoadError.current) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); - setAttachmentInvalidReason('attachmentPicker.errorWhileSelectingCorruptedAttachment'); - } - }} - propagateSwipe - initialFocus={() => { - if (!submitRef.current) { - return false; - } - return submitRef.current; - }} - > - - {shouldUseNarrowLayout && } - downloadAttachment()} - shouldShowCloseButton={!shouldUseNarrowLayout} - shouldShowBackButton={shouldUseNarrowLayout} - onBackButtonPress={closeModal} - onCloseButtonPress={closeModal} - shouldShowThreeDotsButton={shouldShowThreeDotsButton} - threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)} - threeDotsMenuItems={threeDotsMenuItems} - shouldOverlayDots - subTitleLink={currentAttachmentLink ?? ''} - /> - - {isLoading && } - {shouldShowNotFoundPage && !isLoading && ( - Navigation.dismissModal()} - /> - )} - {!shouldShowNotFoundPage && - (!isEmptyObject(report) && !isReceiptAttachment ? ( - - ) : ( - !!sourceForAttachmentView && - shouldLoadAttachment && - !isLoading && ( - - { - isPDFLoadError.current = true; - setIsModalOpen(false); - }} - isWorkspaceAvatar={isWorkspaceAvatar} - maybeIcon={maybeIcon} - fallbackSource={fallbackSource} - isUsedInAttachmentModal - transactionID={transaction?.transactionID} - isUploaded={!isEmptyObject(report)} - /> - - ) - ))} - - {/* If we have an onConfirm method show a confirmation button */} - {!!onConfirm && !isConfirmButtonDisabled && ( - - {({safeAreaPaddingBottomStyle}) => ( - -