Skip to content

Fix attachment modal for broken images #61459

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

10 changes: 8 additions & 2 deletions src/components/AttachmentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -213,6 +214,8 @@ function AttachmentModal({
const transactionID = (isMoneyRequestAction(parentReportAction) && getOriginalMessage(parentReportAction)?.IOUTransactionID) || CONST.DEFAULT_NUMBER_ID;
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {canBeMissing: false});
const [currentAttachmentLink, setCurrentAttachmentLink] = useState(attachmentLink);
const attachmentErrors = useAttachmentErrors();
const {clearAttachmentErrors, isErrorInAttachment} = attachmentErrors;

const [file, setFile] = useState<FileObject | undefined>(
originalFileName
Expand Down Expand Up @@ -490,7 +493,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(
Expand All @@ -503,8 +506,9 @@ function AttachmentModal({
onTap: () => {},
onScaleChanged: () => {},
onSwipeDown: closeModal,
attachmentErrors,
}),
[closeModal, nope, sourceForAttachmentView],
[closeModal, attachmentErrors, nope, sourceForAttachmentView],
);

const submitRef = useRef<View | HTMLElement>(null);
Expand All @@ -524,6 +528,7 @@ function AttachmentModal({
onModalHide();
}
setShouldLoadAttachment(false);
clearAttachmentErrors();
if (isPDFLoadError.current) {
setIsAttachmentInvalid(true);
setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError');
Expand Down Expand Up @@ -597,6 +602,7 @@ function AttachmentModal({
attachmentID={attachmentID}
report={report}
onNavigate={onNavigate}
attachmentErrors={attachmentErrors}
onClose={closeModal}
source={source}
setDownloadButtonVisibility={setDownloadButtonVisibility}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {createContext} from 'react';
import type {GestureType} from 'react-native-gesture-handler';
import type PagerView from 'react-native-pager-view';
import type {SharedValue} from 'react-native-reanimated';
import type {UseAttachmentErrors} from '@components/Attachments/AttachmentView/useAttachmentErrors';
import type {AttachmentSource} from '@components/Attachments/types';

/** The pager items array is used within the pager to render and navigate between the images */
Expand Down Expand Up @@ -44,6 +45,9 @@ type AttachmentCarouselPagerContextValue = {

/** Function to call after a swipe down event */
onSwipeDown: () => void;

/** Optional property providing methods to manage error states for attachments. */
attachmentErrors?: UseAttachmentErrors;
};

const AttachmentCarouselPagerContext = createContext<AttachmentCarouselPagerContextValue | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Animated, {useAnimatedProps, useSharedValue} from 'react-native-reanimated';
import CarouselItem from '@components/Attachments/AttachmentCarousel/CarouselItem';
import useCarouselContextEvents from '@components/Attachments/AttachmentCarousel/useCarouselContextEvents';
import type {UseAttachmentErrors} from '@components/Attachments/AttachmentView/useAttachmentErrors';
import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import useThemeStyles from '@hooks/useThemeStyles';
import shouldUseNewPager from '@libs/shouldUseNewPager';
Expand All @@ -17,7 +18,7 @@

const WrappedPagerView = createNativeWrapper(PagerView) as React.ForwardRefExoticComponent<
PagerViewProps &
NativeViewGestureHandlerProps &

Check failure on line 21 in src/components/Attachments/AttachmentCarousel/Pager/index.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'NativeViewGestureHandlerProps' is deprecated. NativeViewGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Native()` instead
React.RefAttributes<React.Component<PagerViewProps>> & {
useNext: boolean;
}
Expand Down Expand Up @@ -55,10 +56,13 @@

/** The reportID related to the attachment */
reportID?: string;

/** Optional property providing methods to manage error states for attachments. */
attachmentErrors?: UseAttachmentErrors;
};

function AttachmentCarouselPager(
{items, activeAttachmentID, initialPage, setShouldShowArrows, onPageSelected, onClose, reportID}: AttachmentCarouselPagerProps,
{items, activeAttachmentID, initialPage, setShouldShowArrows, onPageSelected, onClose, reportID, attachmentErrors}: AttachmentCarouselPagerProps,
ref: ForwardedRef<AttachmentCarouselPagerHandle>,
) {
const {handleTap, handleScaleChange, isScrollEnabled} = useCarouselContextEvents(setShouldShowArrows);
Expand Down Expand Up @@ -100,8 +104,9 @@
onTap: handleTap,
onSwipeDown: onClose,
onScaleChanged: handleScaleChange,
attachmentErrors,
}),
[pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, onClose, handleScaleChange],
[pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, onClose, handleScaleChange, attachmentErrors],
);

const animatedProps = useAnimatedProps(() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, attachmentErrors}: AttachmentCarouselProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const pagerRef = useRef<AttachmentCarouselPagerHandle>(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<number>();
const [attachments, setAttachments] = useState<Attachment[]>([]);
const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows();
Expand Down Expand Up @@ -144,6 +144,7 @@ function AttachmentCarousel({report, source, attachmentID, onNavigate, setDownlo
<AttachmentCarouselPager
items={attachments}
initialPage={page}
attachmentErrors={attachmentErrors}
activeAttachmentID={activeAttachmentID}
setShouldShowArrows={setShouldShowArrows}
onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)}
Expand Down
9 changes: 5 additions & 4 deletions src/components/Attachments/AttachmentCarousel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function DeviceAwareGestureDetector({canUseTouchScreen, gesture, children}: Devi
return canUseTouchScreen ? <GestureDetector gesture={gesture}>{children}</GestureDetector> : children;
}

function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose, attachmentLink}: AttachmentCarouselProps) {
function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose, attachmentLink, attachmentErrors}: AttachmentCarouselProps) {
const theme = useTheme();
const {translate} = useLocalize();
const {windowWidth} = useWindowDimensions();
Expand All @@ -61,8 +61,8 @@ function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownlo
const scrollRef = useAnimatedRef<Animated.FlatList<ListRenderItemInfo<Attachment>>>();
const isPagerScrolling = useSharedValue(false);
const pagerRef = useRef<GestureType>(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);
Expand Down Expand Up @@ -226,8 +226,9 @@ function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownlo
onTap: handleTap,
onScaleChanged: handleScaleChange,
onSwipeDown: onClose,
attachmentErrors,
}),
[source, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange, onClose],
[source, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange, onClose, attachmentErrors],
);

/** Defines how a single attachment should be rendered */
Expand Down
4 changes: 4 additions & 0 deletions src/components/Attachments/AttachmentCarousel/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {ViewToken} from 'react-native';
import type {ValueOf} from 'type-fest';
import type {UseAttachmentErrors} from '@components/Attachments/AttachmentView/useAttachmentErrors';
import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import type CONST from '@src/CONST';
import type {Report} from '@src/types/onyx';
Expand Down Expand Up @@ -34,6 +35,9 @@ type AttachmentCarouselProps = {
onClose: () => void;

attachmentLink?: string;

/** Optional property providing methods to manage error states for attachments. */
attachmentErrors?: UseAttachmentErrors;
};

export type {AttachmentCarouselProps, UpdatePageProps};
11 changes: 9 additions & 2 deletions src/components/Attachments/AttachmentView/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -119,10 +120,13 @@ 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 {attachmentErrors} = attachmentCarouselPagerContext ?? {};
const {setAttachmentError} = attachmentErrors ?? {};
const theme = useTheme();
const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings();
const styles = useThemeStyles();
Expand Down Expand Up @@ -243,6 +247,7 @@ function AttachmentView({

if (isFileImage) {
if (imageError && (typeof fallbackSource === 'number' || typeof fallbackSource === 'function')) {
setAttachmentError?.(source);
return (
<View style={[styles.flexColumn, styles.alignItemsCenter, styles.justifyContentCenter]}>
<Icon
Expand Down Expand Up @@ -294,6 +299,8 @@ function AttachmentView({
if (isOffline) {
return;
}
setAttachmentError?.(imageSource);

setImageError(true);
}}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {useCallback, useState} from 'react';

type AttachmentErrors = Record<string, boolean> | Record<string, never>;

/**
* A custom React hook that provides functionalities to manage attachment errors.
* - `setAttachmentError(key, state)`: 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<AttachmentErrors>({});

const setAttachmentError = useCallback(
(key: unknown, state = true) => {
const url = JSON.stringify(key);
const attachmentError = attachmentErrors[url];
// eslint-disable-next-line eqeqeq
if (attachmentError == state) {
return;
}

setAttachmentErrors({
...attachmentErrors,
[url]: state,
});
},
[attachmentErrors],
);

const clearAttachmentErrors = useCallback(() => {
setAttachmentErrors({});
}, []);

const isErrorInAttachment = useCallback((key: unknown) => attachmentErrors?.[JSON.stringify(key)], [attachmentErrors]);

return {setAttachmentError, clearAttachmentErrors, isErrorInAttachment};
}

type UseAttachmentErrors = ReturnType<typeof useAttachmentErrors>;

export default useAttachmentErrors;
export type {UseAttachmentErrors};
Loading