From 6701013e9f5c5d37bb8af20b34c2926fc8e1a0e9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 1 Feb 2025 22:54:19 +0100 Subject: [PATCH 01/48] feat: add AttachmentModalScreen component --- .../Navigation/AppNavigator/AuthScreens.tsx | 2 +- src/libs/Navigation/NavigationRoot.tsx | 7 + src/pages/media/AttachmentModalScreen.tsx | 680 ++++++++++++++++++ 3 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 src/pages/media/AttachmentModalScreen.tsx diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 9968251e226a..2a058f8bbe71 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -89,7 +89,7 @@ type AuthScreensProps = { initialLastUpdateIDAppliedToClient: OnyxEntry; }; -const loadReportAttachments = () => require('../../../pages/home/report/ReportAttachments').default; +const loadReportAttachments = () => require('../../../pages/media/AttachmentModalScreen').default; const loadValidateLoginPage = () => require('../../../pages/ValidateLoginPage').default; const loadLogOutPreviousUserPage = () => require('../../../pages/LogOutPreviousUserPage').default; const loadConciergePage = () => require('../../../pages/ConciergePage').default; diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index df42aa04a12e..1bf2d024082d 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -11,6 +11,7 @@ import useTheme from '@hooks/useTheme'; import useThemePreference from '@hooks/useThemePreference'; import Firebase from '@libs/Firebase'; import {FSPage} from '@libs/Fullstory'; +import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} from '@libs/onboardingSelectors'; import {getPathFromURL} from '@libs/Url'; @@ -56,6 +57,12 @@ function parseAndLogRoute(state: NavigationState) { return; } + if (getPlatform() === CONST.PLATFORM.WEB) { + console.log({state}); + } else { + console.log(JSON.stringify(state, null, 2)); + } + const currentPath = customGetPathFromState(state, linkingConfig.config); const focusedRoute = findFocusedRoute(state); diff --git a/src/pages/media/AttachmentModalScreen.tsx b/src/pages/media/AttachmentModalScreen.tsx new file mode 100644 index 000000000000..37b22ba8760d --- /dev/null +++ b/src/pages/media/AttachmentModalScreen.tsx @@ -0,0 +1,680 @@ +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 AttachmentCarousel from '@components/Attachments/AttachmentCarousel'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import AttachmentView from '@components/Attachments/AttachmentView'; +import type {Attachment} from '@components/Attachments/types'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import Button from '@components/Button'; +import ConfirmModal from '@components/ConfirmModal'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderGap from '@components/HeaderGap'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import * as Illustrations from '@components/Icon/Illustrations'; +import SafeAreaConsumer from '@components/SafeAreaConsumer'; +import ScreenWrapper from '@components/ScreenWrapper'; +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 type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; +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 SCREENS from '@src/SCREENS'; +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'; + +/** + * 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; + + shouldDisableSendButton?: boolean; + + attachmentLink?: string; +}; +type AttachmentModalScreenProps = PlatformStackScreenProps & { + route: { + params: AttachmentModalProps; + }; +}; + +function AttachmentModalScreen({route, navigation}: AttachmentModalScreenProps) { + const { + onConfirm, + originalFileName = '', + allowDownload = false, + isTrackExpenseAction = false, + // onModalShow = () => {}, + // onModalHide = () => {}, + // onCarouselAttachmentChange = () => {}, + isReceiptAttachment = false, + isWorkspaceAvatar = false, + maybeIcon = false, + headerTitle, + children, + fallbackSource, + canEditReceipt = false, + // shouldShowNotFoundPage = false, + shouldDisableSendButton = false, + } = route.params; + + const reportID = route.params.reportID; + const type = route.params.type; + const accountID = route.params.accountID; + const isAuthTokenRequired = route.params.isAuthTokenRequired ?? false; + const attachmentLink = route.params.attachmentLink; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const fileName = route.params?.fileName; + // In native the imported images sources are of type number. Ref: https://reactnative.dev/docs/image#imagesource + const source = Number(route.params.source) || route.params.source; + + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + const {windowWidth} = useWindowDimensions(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + 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 isLocalSource = typeof sourceState === 'string' && /^file:|^blob:/.test(sourceState); + 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 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 onModalClose = Navigation.goBack; + const shouldShowNotFoundPage = !isLoadingApp && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID; + const [isLoading, setIsLoading] = useState(isLoadingApp ?? true); + + const onCarouselAttachmentChange = useCallback( + (attachment: Attachment) => { + const routeToNavigate = ROUTES.ATTACHMENTS.getRoute( + reportID, + type, + String(attachment.source), + Number(accountID), + attachment?.isAuthTokenRequired, + attachment?.file?.name, + attachment?.attachmentLink, + ); + Navigation.navigate(routeToNavigate); + }, + [reportID, type, accountID], + ); + + /** + * 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) => { + if (shouldCallDirectly) { + onModalClose(); + return; + } + attachmentModalHandler.handleModalClose(onModalClose); + + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, + [onModalClose], + ); + + 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 ?? ''); + }, []); + + /** + * 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 (isConfirmButtonDisabled) { + return; + } + + if (onConfirm) { + onConfirm(Object.assign(file ?? {}, {source: sourceState} as FileObject)); + } + + closeModal(); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [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); + setIsLoading(false); + setSourceState(inputSource); + setFile(updatedFile); + setModalType(inputModalType); + } else if (fileObject.uri) { + const inputModalType = getModalType(fileObject.uri, fileObject); + setIsLoading(false); + setSourceState(fileObject.uri); + setFile(fileObject); + setModalType(inputModalType); + } + }); + }, + [isValidFile, getModalType, isDirectoryCheck], + ); + + /** + * 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) && canEditReceipt && !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 */ true && 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} + 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; + closeModal(); + }} + 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}) => ( + +