From be9b2b9bdb38a4884a776a63a253ba5161527de8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 29 Aug 2025 11:02:05 +0200 Subject: [PATCH 01/31] refactor: migrate 'Add Attachment' flows to AttachmentModalScreen --- src/CONST/index.ts | 15 +- src/ROUTES.ts | 26 +- src/SCREENS.ts | 1 + src/components/AttachmentComposerModal.tsx | 304 --------------- src/components/Composer/types.ts | 2 +- src/hooks/useFilesValidation.tsx | 29 +- src/libs/AttachmentUtils.ts | 105 ----- src/libs/AttachmentValidation.ts | 172 ++++++++ .../Navigation/AppNavigator/AuthScreens.tsx | 6 + .../GetStateForActionHandlers.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/fileDownload/FileUtils.ts | 30 +- .../AttachmentPickerWithMenuItems.tsx | 8 +- .../ComposerWithSuggestions.tsx | 10 +- .../ReportActionCompose.tsx | 223 +++++------ .../AttachmentModalBaseContent/index.tsx | 369 ++++++++++++++++++ .../AttachmentModalBaseContent/types.ts | 131 +++++++ .../routes/hooks/useDownloadAttachment.ts | 43 ++ .../routes/hooks/useFileUploadValidation.ts | 89 +++++ .../hooks/useNavigateToReportOnRefresh.ts | 30 ++ .../hooks/useReportAttachmentModalType.ts | 35 ++ .../ReportAddAttachmentModalContent.tsx | 254 ++++++++++++ .../AttachmentModalScreen/routes/types.ts | 8 + src/stories/Composer.stories.tsx | 2 +- src/types/utils/Modify.ts | 6 + tests/unit/FileUtilsTest.ts | 10 +- 26 files changed, 1333 insertions(+), 577 deletions(-) delete mode 100644 src/components/AttachmentComposerModal.tsx delete mode 100644 src/libs/AttachmentUtils.ts create mode 100644 src/libs/AttachmentValidation.ts create mode 100644 src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx create mode 100644 src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts create mode 100644 src/pages/media/AttachmentModalScreen/routes/hooks/useDownloadAttachment.ts create mode 100644 src/pages/media/AttachmentModalScreen/routes/hooks/useFileUploadValidation.ts create mode 100644 src/pages/media/AttachmentModalScreen/routes/hooks/useNavigateToReportOnRefresh.ts create mode 100644 src/pages/media/AttachmentModalScreen/routes/hooks/useReportAttachmentModalType.ts create mode 100644 src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent.tsx create mode 100644 src/pages/media/AttachmentModalScreen/routes/types.ts create mode 100644 src/types/utils/Modify.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0ed5e3b21087..8976079f2912 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1960,18 +1960,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 4a99f6e495a9..5b14d7ca518c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -10,8 +10,8 @@ import type CONST from './CONST'; import type {IOUAction, IOUType} from './CONST'; import type {IOURequestType} from './libs/actions/IOU'; import Log from './libs/Log'; +import type {RootNavigatorParamList} from './libs/Navigation/types'; import type {ReimbursementAccountStepToOpen} from './libs/ReimbursementAccountUtils'; -import type {AttachmentModalScreenParams} from './pages/media/AttachmentModalScreen/types'; import SCREENS from './SCREENS'; import type {Screen} from './SCREENS'; import type {ExitReason} from './types/form/ExitSurveyReasonForm'; @@ -450,6 +450,16 @@ const ROUTES = { return `r/${reportID}/avatar` as const; }, }, + ATTACHMENTS: { + route: 'attachment', + getRoute: (params?: ReportAttachmentsRouteParams) => getAttachmentModalScreenRoute('attachment', params), + }, + REPORT_ADD_ATTACHMENT: { + route: 'r/:reportID/attachment/add', + getRoute: (reportID: string, params?: ReportAddAttachmentRouteParams) => { + return getAttachmentModalScreenRoute(`r/${reportID}/attachment/add`, params); + }, + }, 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, @@ -475,10 +485,6 @@ const ROUTES = { return getUrlWithBackToParam(`r/${reportID}/details/shareCode` as const, backTo); }, }, - ATTACHMENTS: { - route: 'attachment', - getRoute: (params?: AttachmentRouteParams) => getAttachmentModalScreenRoute('attachment', params), - }, REPORT_PARTICIPANTS: { route: 'r/:reportID/participants', getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/participants` as const, backTo), @@ -2781,12 +2787,14 @@ const SHARED_ROUTE_PARAMS: Partial> = { export {getUrlWithBackToParam, PUBLIC_SCREENS_ROUTES, SHARED_ROUTE_PARAMS}; export default ROUTES; -type AttachmentsRoute = typeof ROUTES.ATTACHMENTS.route; +type ReportAttachmentsRoute = typeof ROUTES.ATTACHMENTS.route; type ReportAddAttachmentRoute = `r/${string}/attachment/add`; -type AttachmentRoutes = AttachmentsRoute | ReportAddAttachmentRoute; -type AttachmentRouteParams = AttachmentModalScreenParams; +type AttachmentRoutes = ReportAttachmentsRoute | ReportAddAttachmentRoute; + +type ReportAttachmentsRouteParams = RootNavigatorParamList[typeof SCREENS.ATTACHMENTS]; +type ReportAddAttachmentRouteParams = RootNavigatorParamList[typeof SCREENS.REPORT_ADD_ATTACHMENT]; -function getAttachmentModalScreenRoute(url: AttachmentRoutes, params?: AttachmentRouteParams) { +function getAttachmentModalScreenRoute(url: AttachmentRoutes, params?: ReportAttachmentsRouteParams | ReportAddAttachmentRouteParams) { if (!params?.source) { return url; } diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2ba5596eb591..d8648c4f7f47 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -8,6 +8,7 @@ const PROTECTED_SCREENS = { HOME: 'Home', CONCIERGE: 'Concierge', ATTACHMENTS: 'Attachments', + REPORT_ADD_ATTACHMENT: 'ReportAddAttachment', TRACK_EXPENSE: 'TrackExpense', SUBMIT_EXPENSE: 'SubmitExpense', } as const; diff --git a/src/components/AttachmentComposerModal.tsx b/src/components/AttachmentComposerModal.tsx deleted file mode 100644 index 8825746efdb7..000000000000 --- a/src/components/AttachmentComposerModal.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; -import type {View} from 'react-native'; -import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import type {OnyxEntry} from 'react-native-onyx'; -import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'; -import useFilesValidation from '@hooks/useFilesValidation'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {cleanFileName} from '@libs/fileDownload/FileUtils'; -import CONST from '@src/CONST'; -import type * as OnyxTypes from '@src/types/onyx'; -import type ModalType from '@src/types/utils/ModalType'; -import viewRef from '@src/types/utils/viewRef'; -import AttachmentCarouselView from './Attachments/AttachmentCarousel/AttachmentCarouselView'; -import useCarouselArrows from './Attachments/AttachmentCarousel/useCarouselArrows'; -import useAttachmentErrors from './Attachments/AttachmentView/useAttachmentErrors'; -import type {Attachment} from './Attachments/types'; -import Button from './Button'; -import HeaderGap from './HeaderGap'; -import HeaderWithBackButton from './HeaderWithBackButton'; -import Modal from './Modal'; -import SafeAreaConsumer from './SafeAreaConsumer'; - -type ImagePickerResponse = { - height?: number; - name: string; - size?: number | null; - type: string; - uri: string; - width?: number; -}; - -type FileObject = Partial; - -type ChildrenProps = { - displayFilesInModal: (data: FileObject[], items?: DataTransferItem[]) => void; - show: () => void; -}; - -type AttachmentComposerModalProps = { - /** Optional callback to fire when we want to preview an image and approve it for use. */ - onConfirm: ((file: FileObject | FileObject[]) => void) | null; - - /** Title shown in the header of the modal */ - headerTitle: string; - - /** 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; - - /** A function as a child to pass modal launching methods to */ - children: React.FC; - - /** Should disable send button */ - shouldDisableSendButton: boolean; - - /** The report currently being looked at */ - report?: OnyxEntry; -}; - -function AttachmentComposerModal({onConfirm, onModalShow = () => {}, onModalHide = () => {}, headerTitle, children, shouldDisableSendButton = false, report}: AttachmentComposerModalProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {setAttachmentError, clearAttachmentErrors} = useAttachmentErrors(); - const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows(); - - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); - const [validFilesToUpload, setValidFilesToUpload] = useState([]); - const [attachments, setAttachments] = useState([]); - const [page, setPage] = useState(0); - const [currentAttachment, setCurrentAttachment] = useState(null); - - /** - * If our attachment is a PDF, return the unswipeable Modal type. - */ - const getModalType = useCallback( - (sourceURL: string, fileObject: FileObject) => { - const fileName = fileObject?.name ?? translate('attachmentView.unknownFilename'); - return sourceURL && (sourceURL.includes('.pdf') || fileName.includes('.pdf')) ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.CENTERED; - }, - [translate], - ); - - /** - * Execute the onConfirm callback and close the modal. - */ - const submitAndClose = useCallback(() => { - // If the modal has already been closed - if (!isModalOpen) { - return; - } - - if (onConfirm) { - if (validFilesToUpload.length) { - onConfirm(validFilesToUpload); - } else if (currentAttachment) { - onConfirm(currentAttachment.file ?? (currentAttachment as FileObject)); - } - } - - setIsModalOpen(false); - }, [isModalOpen, onConfirm, validFilesToUpload, currentAttachment]); - - /** - * Sanitizes file names and ensures proper URI references for file system compatibility - */ - const cleanFileObjectName = useCallback((fileObject: FileObject): FileObject => { - if (fileObject instanceof File) { - const cleanName = cleanFileName(fileObject.name); - if (fileObject.name !== cleanName) { - const updatedFile = new File([fileObject], cleanName, {type: fileObject.type}); - const inputSource = URL.createObjectURL(updatedFile); - updatedFile.uri = inputSource; - return updatedFile; - } - if (!fileObject.uri) { - const inputSource = URL.createObjectURL(fileObject); - // eslint-disable-next-line no-param-reassign - fileObject.uri = inputSource; - } - } - return fileObject; - }, []); - - const convertFileToAttachment = useCallback((fileObject: FileObject, source: string): Attachment => { - return { - source, - file: fileObject, - }; - }, []); - - useEffect(() => { - if (!validFilesToUpload.length) { - return; - } - - if (validFilesToUpload.length > 0) { - // Convert all files to attachments - const newAttachments = validFilesToUpload.map((fileObject) => { - const source = fileObject.uri ?? ''; - return convertFileToAttachment(fileObject, source); - }); - - const firstAttachment = newAttachments.at(0) ?? null; - setAttachments(newAttachments); - setCurrentAttachment(firstAttachment); - setPage(0); - - if (firstAttachment?.file) { - const inputModalType = getModalType(firstAttachment.source as string, firstAttachment.file); - setModalType(inputModalType); - } - - setIsModalOpen(true); - } - }, [validFilesToUpload, convertFileToAttachment, getModalType]); - - const {ErrorModal, validateFiles, PDFValidationComponent} = useFilesValidation(setValidFilesToUpload, false); - - const validateAndDisplayMultipleFilesToUpload = useCallback( - (data: FileObject[], items?: DataTransferItem[]) => { - if (!data?.length) { - return; - } - - const validIndices: number[] = []; - const fileObjects = data - .map((item, index) => { - let fileObject = item; - if ('getAsFile' in item && typeof item.getAsFile === 'function') { - fileObject = item.getAsFile() as FileObject; - } - const cleanedFileObject = cleanFileObjectName(fileObject); - if (cleanedFileObject !== null) { - validIndices.push(index); - } - return cleanedFileObject; - }) - .filter((fileObject): fileObject is FileObject => fileObject !== null); - - if (!fileObjects.length) { - return; - } - - // Create a filtered items array that matches the fileObjects - const filteredItems = items && validIndices.length > 0 ? validIndices.map((index) => items.at(index) ?? ({} as DataTransferItem)) : undefined; - - validateFiles(fileObjects, filteredItems); - }, - [cleanFileObjectName, validateFiles], - ); - - const closeModal = useCallback(() => { - setIsModalOpen(false); - }, []); - - const openModal = useCallback(() => { - setIsModalOpen(true); - }, []); - - const headerTitleNew = headerTitle ?? translate('reportActionCompose.sendAttachment'); - - const submitRef = useRef(null); - - return ( - <> - {PDFValidationComponent} - { - onModalShow(); - }} - onModalHide={() => { - onModalHide(); - clearAttachmentErrors(); - setValidFilesToUpload([]); - setAttachments([]); - setCurrentAttachment(null); - setPage(0); - }} - propagateSwipe - initialFocus={() => { - if (!submitRef.current) { - return false; - } - return submitRef.current; - }} - shouldHandleNavigationBack - > - - {shouldUseNarrowLayout && } - - {attachments.length > 0 && !!currentAttachment && ( - - )} - - {(validFilesToUpload.length > 0 || !!currentAttachment) && ( - - {({safeAreaPaddingBottomStyle}) => ( - -