diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index d0a6d0dafc82..fcd5a4a341c8 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -33,6 +33,7 @@ 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'; @@ -213,6 +214,7 @@ function AttachmentModal({ 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 @@ -490,7 +492,7 @@ function AttachmentModal({ 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) { + if ((!isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH) && !isErrorInAttachment(sourceState)) { shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline && !isLocalSource; } const context = useMemo( @@ -503,8 +505,9 @@ function AttachmentModal({ onTap: () => {}, onScaleChanged: () => {}, onSwipeDown: closeModal, + onAttachmentError: setAttachmentError, }), - [closeModal, nope, sourceForAttachmentView], + [closeModal, setAttachmentError, nope, sourceForAttachmentView], ); const submitRef = useRef(null); @@ -524,6 +527,7 @@ function AttachmentModal({ onModalHide(); } setShouldLoadAttachment(false); + clearAttachmentErrors(); if (isPDFLoadError.current) { setIsAttachmentInvalid(true); setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); @@ -603,6 +607,7 @@ function AttachmentModal({ source={source} setDownloadButtonVisibility={setDownloadButtonVisibility} attachmentLink={currentAttachmentLink} + onAttachmentError={setAttachmentError} /> ) : ( !!sourceForAttachmentView && diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts index a9ca8f83697b..5afee6916524 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts @@ -44,8 +44,12 @@ type AttachmentCarouselPagerContextValue = { /** Function to call after a swipe down event */ onSwipeDown: () => void; + + /** Callback for attachment errors */ + onAttachmentError?: (source: AttachmentSource, state?: boolean) => void; }; const AttachmentCarouselPagerContext = createContext(null); export default AttachmentCarouselPagerContext; +export type {AttachmentCarouselPagerContextValue}; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index dac7a71cfd1c..9e98328eb1d1 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -55,10 +55,13 @@ type AttachmentCarouselPagerProps = { /** The reportID related to the attachment */ reportID?: string; + + /** Callback for attachment errors */ + onAttachmentError?: (source: AttachmentSource) => void; }; function AttachmentCarouselPager( - {items, activeAttachmentID, initialPage, setShouldShowArrows, onPageSelected, onClose, reportID}: AttachmentCarouselPagerProps, + {items, activeAttachmentID, initialPage, setShouldShowArrows, onPageSelected, onClose, reportID, onAttachmentError}: AttachmentCarouselPagerProps, ref: ForwardedRef, ) { const {handleTap, handleScaleChange, isScrollEnabled} = useCarouselContextEvents(setShouldShowArrows); @@ -100,8 +103,9 @@ function AttachmentCarouselPager( onTap: handleTap, onSwipeDown: onClose, onScaleChanged: handleScaleChange, + onAttachmentError, }), - [pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, onClose, handleScaleChange], + [pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, onClose, handleScaleChange, onAttachmentError], ); const animatedProps = useAnimatedProps(() => ({ diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index 4d81fda91494..a625b4215d52 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -18,12 +18,12 @@ import AttachmentCarouselPager from './Pager'; import type {AttachmentCarouselProps} from './types'; import useCarouselArrows from './useCarouselArrows'; -function AttachmentCarousel({report, source, attachmentID, onNavigate, setDownloadButtonVisibility, onClose, type, accountID}: AttachmentCarouselProps) { +function AttachmentCarousel({report, source, attachmentID, onNavigate, setDownloadButtonVisibility, onClose, type, accountID, onAttachmentError}: AttachmentCarouselProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const pagerRef = useRef(null); - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false}); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false}); + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false, canBeMissing: true}); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false, canBeMissing: true}); const [page, setPage] = useState(); const [attachments, setAttachments] = useState([]); const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows(); @@ -144,6 +144,7 @@ function AttachmentCarousel({report, source, attachmentID, onNavigate, setDownlo updatePage(newPage)} diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index c6a223ade586..3c883530df4f 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -51,7 +51,7 @@ function DeviceAwareGestureDetector({canUseTouchScreen, gesture, children}: Devi return canUseTouchScreen ? {children} : children; } -function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose, attachmentLink}: AttachmentCarouselProps) { +function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose, attachmentLink, onAttachmentError}: AttachmentCarouselProps) { const theme = useTheme(); const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); @@ -61,8 +61,8 @@ function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownlo const scrollRef = useAnimatedRef>>(); const isPagerScrolling = useSharedValue(false); const pagerRef = useRef(null); - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false}); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false}); + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false, canBeMissing: true}); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false, canBeMissing: true}); const canUseTouchScreen = canUseTouchScreenUtil(); const modalStyles = styles.centeredModalStyles(shouldUseNarrowLayout, true); @@ -226,8 +226,9 @@ function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownlo onTap: handleTap, onScaleChanged: handleScaleChange, onSwipeDown: onClose, + onAttachmentError, }), - [source, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange, onClose], + [onAttachmentError, source, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange, onClose], ); /** Defines how a single attachment should be rendered */ diff --git a/src/components/Attachments/AttachmentCarousel/types.ts b/src/components/Attachments/AttachmentCarousel/types.ts index ad26521e4cd1..f98410d2bdae 100644 --- a/src/components/Attachments/AttachmentCarousel/types.ts +++ b/src/components/Attachments/AttachmentCarousel/types.ts @@ -34,6 +34,9 @@ type AttachmentCarouselProps = { onClose: () => void; attachmentLink?: string; + + /** Callback for attachment errors */ + onAttachmentError?: (source: AttachmentSource, state?: boolean) => void; }; export type {AttachmentCarouselProps, UpdatePageProps}; diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index bc90ba07681e..49556b7f5e02 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -1,7 +1,8 @@ import {Str} from 'expensify-common'; -import React, {memo, useEffect, useState} from 'react'; +import React, {memo, useContext, useEffect, useState} from 'react'; import type {GestureResponderEvent, ImageURISource, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import DistanceEReceipt from '@components/DistanceEReceipt'; import EReceipt from '@components/EReceipt'; @@ -119,10 +120,12 @@ function AttachmentView({ isUploading = false, reportID, }: AttachmentViewProps) { - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {canBeMissing: true}); const {translate} = useLocalize(); const {updateCurrentURLAndReportID} = usePlaybackContext(); + const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); + const {onAttachmentError} = attachmentCarouselPagerContext ?? {}; const theme = useTheme(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); const styles = useThemeStyles(); @@ -151,6 +154,12 @@ function AttachmentView({ }); }, [file]); + useEffect(() => { + const isImageSource = typeof source !== 'function' && !!checkIsFileImage(source, file?.name); + const isErrorInImage = imageError && (typeof fallbackSource === 'number' || typeof fallbackSource === 'function'); + onAttachmentError?.(source, isErrorInImage && isImageSource); + }, [fallbackSource, file?.name, imageError, onAttachmentError, source]); + // Handles case where source is a component (ex: SVG) or a number // Number may represent a SVG or an image if (typeof source === 'function' || (maybeIcon && typeof source === 'number')) { @@ -294,6 +303,7 @@ function AttachmentView({ if (isOffline) { return; } + setImageError(true); }} /> diff --git a/src/components/Attachments/AttachmentView/useAttachmentErrors.ts b/src/components/Attachments/AttachmentView/useAttachmentErrors.ts new file mode 100644 index 000000000000..6405565b3c5c --- /dev/null +++ b/src/components/Attachments/AttachmentView/useAttachmentErrors.ts @@ -0,0 +1,48 @@ +import {useCallback, useState} from 'react'; +import type {AttachmentSource} from '@components/Attachments/types'; + +function convertSourceToString(source: AttachmentSource) { + if (typeof source === 'string' || typeof source === 'number') { + return source.toString(); + } + if (source instanceof Array) { + return source.map((src) => src.uri).join(', '); + } + if ('uri' in source) { + return source.uri ?? ''; + } + + return ''; +} + +/** + * A custom React hook that provides functionalities to manage attachment errors. + * - `setAttachmentError(key)`: Sets or unsets an error for a given key. + * - `clearAttachmentErrors()`: Clears all errors. + * - `isErrorInAttachment(key)`: Checks if there is an error associated with a specific key. + * Errors are indexed by a serialized key - for example url or source object. + */ +function useAttachmentErrors() { + const [attachmentErrors, setAttachmentErrors] = useState>({}); + + const setAttachmentError = useCallback((key: AttachmentSource, state = true) => { + const url = convertSourceToString(key); + if (!url) { + return; + } + setAttachmentErrors((prevState) => ({ + ...prevState, + [url]: state, + })); + }, []); + + const clearAttachmentErrors = useCallback(() => { + setAttachmentErrors({}); + }, []); + + const isErrorInAttachment = useCallback((key: AttachmentSource) => attachmentErrors?.[convertSourceToString(key)], [attachmentErrors]); + + return {setAttachmentError, clearAttachmentErrors, isErrorInAttachment}; +} + +export default useAttachmentErrors;