diff --git a/src/components/AttachmentComposerModal.tsx b/src/components/AttachmentComposerModal.tsx new file mode 100644 index 000000000000..a317716f74cf --- /dev/null +++ b/src/components/AttachmentComposerModal.tsx @@ -0,0 +1,338 @@ +import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; +import type {View} from 'react-native'; +import {InteractionManager} from 'react-native'; +import {GestureHandlerRootView} from 'react-native-gesture-handler'; +import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'; +import type {ValueOf} from 'type-fest'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {cleanFileName, getFileValidationErrorText} from '@libs/fileDownload/FileUtils'; +import CONST from '@src/CONST'; +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 ConfirmModal from './ConfirmModal'; +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[]) => 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; +}; + +function AttachmentComposerModal({onConfirm, onModalShow = () => {}, onModalHide = () => {}, headerTitle, children, shouldDisableSendButton = false}: 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 [fileError, setFileError] = useState | null>(null); + const [isFileErrorModalVisible, setIsFileErrorModalVisible] = 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); + + // TODO: remove unnecessary logic, ideally in a follow-up PR to avoid breaking changes/regressions + /** + * 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]); + + const closeConfirmModal = useCallback(() => { + setIsFileErrorModalVisible(false); + }, []); + + // TODO: Check if this function is still needed, as it doesn't work. + const isDirectoryCheck = useCallback((data: FileObject) => { + if ('webkitGetAsEntry' in data && (data as DataTransferItem).webkitGetAsEntry()?.isDirectory) { + setFileError(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); + setIsFileErrorModalVisible(true); + return false; + } + return true; + }, []); + + /** + * 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 && !fileError) { + // 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); + } + }, [fileError, validFilesToUpload, convertFileToAttachment, getModalType]); + + const {ErrorModal, validateFiles, PDFValidationComponent} = useFilesValidation(setValidFilesToUpload); + + const confirmAndContinue = () => { + if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { + validateFiles(validFilesToUpload); + } + setIsFileErrorModalVisible(false); + InteractionManager.runAfterInteractions(() => { + setFileError(null); + }); + }; + + const validateAndDisplayMultipleFilesToUpload = useCallback( + (data: FileObject[]) => { + if (!data?.length) { + return; + } + + const fileObjects = data + .map((item) => { + let fileObject = item; + if ('getAsFile' in item && typeof item.getAsFile === 'function') { + fileObject = item.getAsFile() as FileObject; + } + return cleanFileObjectName(fileObject); + }) + .filter((fileObject): fileObject is FileObject => fileObject !== null); + + if (!fileObjects.length || fileObjects.some((fileObject) => !isDirectoryCheck(fileObject))) { + return; + } + validateFiles(fileObjects); + }, + [cleanFileObjectName, isDirectoryCheck, validateFiles], + ); + + const closeModal = useCallback(() => { + setIsModalOpen(false); + }, []); + + const closeAndResetModal = useCallback(() => { + closeConfirmModal(); + closeModal(); + InteractionManager.runAfterInteractions(() => { + setFileError(null); + setValidFilesToUpload([]); + }); + }, [closeConfirmModal, closeModal]); + + 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}) => ( + +