diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2ee1363b9ed8..040302efb0b5 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -930,6 +930,11 @@ const ROUTES = { `create/${iouType as string}/start/${transactionID}/${reportID}/per-diem/${backToReport ?? ''}` as const, }, + MONEY_REQUEST_RECEIPT_VIEW_MODAL: { + route: 'receipt-view-modal/:transactionID', + getRoute: (transactionID: string, backTo: string) => getUrlWithBackToParam(`receipt-view-modal/${transactionID}`, backTo), + } as const, + MONEY_REQUEST_STATE_SELECTOR: { route: 'submit/state', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 703c0333709d..926e5490ddff 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -253,6 +253,7 @@ const SCREENS = { STEP_WAYPOINT: 'Money_Request_Step_Waypoint', STEP_TAX_AMOUNT: 'Money_Request_Step_Tax_Amount', STEP_TAX_RATE: 'Money_Request_Step_Tax_Rate', + RECEIPT_VIEW_MODAL: 'Money_Request_Receipt_View_Modal', STEP_SPLIT_PAYER: 'Money_Request_Step_Split_Payer', STEP_SEND_FROM: 'Money_Request_Step_Send_From', STEP_COMPANY_INFO: 'Money_Request_Step_Company_Info', diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx new file mode 100644 index 000000000000..85dcc5927ec6 --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx @@ -0,0 +1,118 @@ +import React, {useCallback, useRef, useState} from 'react'; +import {Keyboard, View} from 'react-native'; +import CarouselButtons from '@components/Attachments/AttachmentCarousel/CarouselButtons'; +import AttachmentCarouselPager from '@components/Attachments/AttachmentCarousel/Pager'; +import type {AttachmentCarouselPagerHandle} from '@components/Attachments/AttachmentCarousel/Pager'; +import type {AttachmentSource} from '@components/Attachments/types'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; +import variables from '@styles/variables'; +import type AttachmentCarouselViewProps from './types'; + +function AttachmentCarouselView({ + page, + attachments, + shouldShowArrows, + source, + report, + autoHideArrows, + cancelAutoHideArrow, + setShouldShowArrows, + onAttachmentError, + onNavigate, + onClose, + setPage, + attachmentID, +}: AttachmentCarouselViewProps) { + const {translate} = useLocalize(); + const canUseTouchScreen = canUseTouchScreenUtil(); + const styles = useThemeStyles(); + const [activeAttachmentID, setActiveAttachmentID] = useState(attachmentID ?? source); + + const pagerRef = useRef(null); + + /** Updates the page state when the user navigates between attachments */ + const updatePage = useCallback( + (newPageIndex: number) => { + Keyboard.dismiss(); + setShouldShowArrows(true); + + const item = attachments.at(newPageIndex); + + setPage(newPageIndex); + if (newPageIndex >= 0 && item) { + setActiveAttachmentID(item.attachmentID ?? item.source); + if (onNavigate) { + onNavigate(item); + } + onNavigate?.(item); + } + }, + [setShouldShowArrows, attachments, setPage, onNavigate], + ); + + /** + * Increments or decrements the index to get another selected item + * @param {Number} deltaSlide + */ + const cycleThroughAttachments = useCallback( + (deltaSlide: number) => { + if (page === undefined) { + return; + } + const nextPageIndex = page + deltaSlide; + updatePage(nextPageIndex); + pagerRef.current?.setPage(nextPageIndex); + + autoHideArrows(); + }, + [autoHideArrows, page, updatePage], + ); + + return ( + !canUseTouchScreen && setShouldShowArrows(true)} + onMouseLeave={() => !canUseTouchScreen && setShouldShowArrows(false)} + > + {page === -1 ? ( + + ) : ( + <> + cycleThroughAttachments(-1)} + onForward={() => cycleThroughAttachments(1)} + autoHideArrow={autoHideArrows} + cancelAutoHideArrow={cancelAutoHideArrow} + /> + updatePage(newPage)} + onClose={onClose} + ref={pagerRef} + reportID={report?.reportID} + /> + + )} + + ); +} + +AttachmentCarouselView.displayName = 'AttachmentCarouselView'; + +export default AttachmentCarouselView; diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx new file mode 100644 index 000000000000..40d78b1104ea --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx @@ -0,0 +1,292 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {RefObject} from 'react'; +import type {ListRenderItemInfo} from 'react-native'; +import {Keyboard, PixelRatio, View} from 'react-native'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import type {ComposedGesture, GestureType} from 'react-native-gesture-handler'; +import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; +import CarouselActions from '@components/Attachments/AttachmentCarousel/CarouselActions'; +import CarouselButtons from '@components/Attachments/AttachmentCarousel/CarouselButtons'; +import CarouselItem from '@components/Attachments/AttachmentCarousel/CarouselItem'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import type {UpdatePageProps} from '@components/Attachments/AttachmentCarousel/types'; +import useCarouselContextEvents from '@components/Attachments/AttachmentCarousel/useCarouselContextEvents'; +import type {Attachment, AttachmentSource} from '@components/Attachments/types'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import * as Illustrations from '@components/Icon/Illustrations'; +import {useFullScreenContext} from '@components/VideoPlayerContexts/FullScreenContext'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type AttachmentCarouselViewProps from './types'; + +const viewabilityConfig = { + // To facilitate paging through the attachments, we want to consider an item "viewable" when it is + // more than 95% visible. When that happens we update the page index in the state. + itemVisiblePercentThreshold: 95, +}; + +const MIN_FLING_VELOCITY = 500; + +type DeviceAwareGestureDetectorProps = { + canUseTouchScreen: boolean; + gesture: ComposedGesture | GestureType; + children: React.ReactNode; +}; + +function DeviceAwareGestureDetector({canUseTouchScreen, gesture, children}: DeviceAwareGestureDetectorProps) { + // Don't render GestureDetector on non-touchable devices to prevent unexpected pointer event capture. + // This issue is left out on touchable devices since finger touch works fine. + // See: https://github.com/Expensify/App/issues/51246 + return canUseTouchScreen ? {children} : children; +} + +function AttachmentCarouselView({ + page, + attachments, + shouldShowArrows, + source, + report, + autoHideArrows, + cancelAutoHideArrow, + setShouldShowArrows, + onAttachmentError, + onNavigate, + onClose, + setPage, + attachmentID, +}: AttachmentCarouselViewProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const canUseTouchScreen = canUseTouchScreenUtil(); + const {isFullScreenRef} = useFullScreenContext(); + const isPagerScrolling = useSharedValue(false); + const {handleTap, handleScaleChange, isScrollEnabled} = useCarouselContextEvents(setShouldShowArrows); + + const [activeAttachmentID, setActiveAttachmentID] = useState(attachmentID ?? source); + + const pagerRef = useRef(null); + const scrollRef = useAnimatedRef>>(); + + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const modalStyles = styles.centeredModalStyles(shouldUseNarrowLayout, true); + const {windowWidth} = useWindowDimensions(); + + const cellWidth = useMemo( + () => PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2), + [modalStyles.borderWidth, modalStyles.marginHorizontal, windowWidth], + ); + + /** Updates the page state when the user navigates between attachments */ + const updatePage = useCallback( + ({viewableItems}: UpdatePageProps) => { + if (isFullScreenRef.current) { + return; + } + + Keyboard.dismiss(); + + // Since we can have only one item in view at a time, we can use the first item in the array + // to get the index of the current page + const entry = viewableItems.at(0); + if (!entry) { + setActiveAttachmentID(null); + return; + } + + const item = entry.item as Attachment; + if (entry.index !== null) { + setPage(entry.index); + setActiveAttachmentID(item.attachmentID ?? item.source); + } + + if (onNavigate) { + onNavigate(item); + } + }, + [isFullScreenRef, onNavigate, setPage, setActiveAttachmentID], + ); + + /** Increments or decrements the index to get another selected item */ + const cycleThroughAttachments = useCallback( + (deltaSlide: number) => { + if (isFullScreenRef.current) { + return; + } + + const nextIndex = page + deltaSlide; + const nextItem = attachments.at(nextIndex); + + if (!nextItem || nextIndex < 0 || !scrollRef.current) { + return; + } + + scrollRef.current.scrollToIndex({index: nextIndex, animated: canUseTouchScreen}); + }, + [attachments, canUseTouchScreen, isFullScreenRef, page, scrollRef], + ); + + const extractItemKey = useCallback( + (item: Attachment) => + !!item.attachmentID || (typeof item.source !== 'string' && typeof item.source !== 'number') + ? `attachmentID-${item.attachmentID}` + : `source-${item.source}|${item.attachmentLink}`, + [], + ); + + /** Calculate items layout information to optimize scrolling performance */ + const getItemLayout = useCallback( + (data: ArrayLike | null | undefined, index: number) => ({ + length: cellWidth, + offset: cellWidth * index, + index, + }), + [cellWidth], + ); + + const context = useMemo( + () => ({ + pagerItems: [{source, index: 0, isActive: true}], + activePage: 0, + pagerRef, + isPagerScrolling, + isScrollEnabled, + onTap: handleTap, + onScaleChanged: handleScaleChange, + onSwipeDown: onClose, + onAttachmentError, + }), + [onAttachmentError, source, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange, onClose], + ); + + /** Defines how a single attachment should be rendered */ + const renderItem = useCallback( + ({item}: ListRenderItemInfo) => ( + + + + ), + [activeAttachmentID, canUseTouchScreen, cellWidth, handleTap, report?.reportID, shouldShowArrows, styles.h100], + ); + /** Pan gesture handing swiping through attachments on touch screen devices */ + const pan = useMemo( + () => + Gesture.Pan() + .enabled(canUseTouchScreen) + .onUpdate(({translationX}) => { + if (!isScrollEnabled.get()) { + return; + } + + if (translationX !== 0) { + isPagerScrolling.set(true); + } + + scrollTo(scrollRef, page * cellWidth - translationX, 0, false); + }) + .onEnd(({translationX, velocityX}) => { + if (!isScrollEnabled.get()) { + return; + } + + let newIndex; + if (velocityX > MIN_FLING_VELOCITY) { + // User flung to the right + newIndex = Math.max(0, page - 1); + } else if (velocityX < -MIN_FLING_VELOCITY) { + // User flung to the left + newIndex = Math.min(attachments.length - 1, page + 1); + } else { + // snap scroll position to the nearest cell (making sure it's within the bounds of the list) + const delta = Math.round(-translationX / cellWidth); + newIndex = Math.min(attachments.length - 1, Math.max(0, page + delta)); + } + + isPagerScrolling.set(false); + scrollTo(scrollRef, newIndex * cellWidth, 0, true); + }) + // eslint-disable-next-line react-compiler/react-compiler + .withRef(pagerRef as RefObject), + [attachments.length, canUseTouchScreen, cellWidth, page, isScrollEnabled, scrollRef, isPagerScrolling], + ); + + // Scroll position is affected when window width is resized, so we readjust it on width changes + useEffect(() => { + if (attachments.length === 0 || scrollRef.current == null) { + return; + } + + scrollRef.current.scrollToIndex({index: page, animated: false}); + // The hook is not supposed to run on page change, so we keep the page out of the dependencies + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [cellWidth]); + + return ( + !canUseTouchScreen && setShouldShowArrows(true)} + onMouseLeave={() => !canUseTouchScreen && setShouldShowArrows(false)} + > + {page === -1 ? ( + + ) : ( + <> + cycleThroughAttachments(-1)} + onForward={() => cycleThroughAttachments(1)} + autoHideArrow={autoHideArrows} + cancelAutoHideArrow={cancelAutoHideArrow} + /> + + + + + + + + )} + + ); +} + +AttachmentCarouselView.displayName = 'AttachmentCarouselView'; + +export default AttachmentCarouselView; diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/types.ts b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/types.ts new file mode 100644 index 000000000000..ed7f98a1a858 --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/types.ts @@ -0,0 +1,33 @@ +import type {Attachment, AttachmentSource} from '@components/Attachments/types'; +import type {Report} from '@src/types/onyx'; + +type AttachmentCarouselViewProps = { + /** Where the arrows should be visible */ + shouldShowArrows: boolean; + /** The current page index */ + page: number; + /** The attachments from the carousel */ + attachments: Attachment[]; + /** The id of the current active attachment */ + attachmentID?: string; + /** Source is used to determine the starting index in the array of attachments */ + source: AttachmentSource; + /** Callback for auto hiding carousel button arrows */ + autoHideArrows: () => void; + /** Sets the visibility of the arrows. */ + setShouldShowArrows: (show?: React.SetStateAction) => void; + /** Callback for cancelling auto hiding of carousel button arrows */ + cancelAutoHideArrow: () => void; + /** A callback that is called when swipe-down-to-close gesture happens */ + onClose?: () => void; + /** Sets current page */ + setPage: (page: number) => void; + /** The report currently being looked at */ + report?: Report; + /** Callback for attachment errors */ + onAttachmentError?: (source: AttachmentSource, state?: boolean) => void; + /** Callback to update the parent modal's state with a source and name from the attachments array */ + onNavigate?: (attachment: Attachment) => void; +}; + +export default AttachmentCarouselViewProps; diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx deleted file mode 100644 index a625b4215d52..000000000000 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {Keyboard, View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import type {Attachment, AttachmentSource} from '@components/Attachments/types'; -import BlockingView from '@components/BlockingViews/BlockingView'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import * as Illustrations from '@components/Icon/Illustrations'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import CarouselButtons from './CarouselButtons'; -import extractAttachments from './extractAttachments'; -import type {AttachmentCarouselPagerHandle} from './Pager'; -import AttachmentCarouselPager from './Pager'; -import type {AttachmentCarouselProps} from './types'; -import useCarouselArrows from './useCarouselArrows'; - -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, 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(); - const [activeAttachmentID, setActiveAttachmentID] = useState(attachmentID ?? source); - const compareImage = useCallback((attachment: Attachment) => (attachmentID ? attachment.attachmentID === attachmentID : attachment.source === source), [attachmentID, source]); - - useEffect(() => { - const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; - let newAttachments: Attachment[] = []; - if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, report}); - } else if (type === CONST.ATTACHMENT_TYPE.ONBOARDING) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.ONBOARDING, {parentReportAction, reportActions: reportActions ?? undefined, report}); - } else { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions, report}); - } - - let newIndex = newAttachments.findIndex(compareImage); - const index = attachments.findIndex(compareImage); - - // If newAttachments includes an attachment with the same index, update newIndex to that index. - // Previously, uploading an attachment offline would dismiss the modal when the image was previewed and the connection was restored. - // Now, instead of dismissing the modal, we replace it with the new attachment that has the same index. - if (newIndex === -1 && index !== -1 && newAttachments.at(index)) { - newIndex = index; - } - - // If no matching attachment with the same index, dismiss the modal - if (newIndex === -1 && index !== -1 && attachments.at(index)) { - Navigation.dismissModal(); - } else { - setPage(newIndex); - setAttachments(newAttachments); - - // Update the download button visibility in the parent modal - if (setDownloadButtonVisibility) { - setDownloadButtonVisibility(newIndex !== -1); - } - - const attachment = newAttachments.at(newIndex); - // Update the parent modal's state with the source and name from the mapped attachments - if (newIndex !== -1 && attachment !== undefined && onNavigate) { - onNavigate(attachment); - } - } - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [reportActions, compareImage, report]); - - /** Updates the page state when the user navigates between attachments */ - const updatePage = useCallback( - (newPageIndex: number) => { - Keyboard.dismiss(); - setShouldShowArrows(true); - - const item = attachments.at(newPageIndex); - - setPage(newPageIndex); - if (newPageIndex >= 0 && item) { - setActiveAttachmentID(item.attachmentID ?? item.source); - if (onNavigate) { - onNavigate(item); - } - onNavigate?.(item); - } - }, - [setShouldShowArrows, attachments, onNavigate], - ); - - /** - * Increments or decrements the index to get another selected item - * @param {Number} deltaSlide - */ - const cycleThroughAttachments = useCallback( - (deltaSlide: number) => { - if (page === undefined) { - return; - } - const nextPageIndex = page + deltaSlide; - updatePage(nextPageIndex); - pagerRef.current?.setPage(nextPageIndex); - - autoHideArrows(); - }, - [autoHideArrows, page, updatePage], - ); - - const containerStyles = [styles.flex1, styles.attachmentCarouselContainer]; - - if (page == null) { - return ( - - - - ); - } - - return ( - - {page === -1 ? ( - - ) : ( - <> - cycleThroughAttachments(-1)} - onForward={() => cycleThroughAttachments(1)} - autoHideArrow={autoHideArrows} - cancelAutoHideArrow={cancelAutoHideArrows} - /> - - updatePage(newPage)} - onClose={onClose} - ref={pagerRef} - reportID={report.reportID} - /> - - )} - - ); -} - -AttachmentCarousel.displayName = 'AttachmentCarousel'; - -export default AttachmentCarousel; diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index b3d81544a1bd..6e93b9dbe065 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -1,80 +1,28 @@ import {deepEqual} from 'fast-equals'; -import type {MutableRefObject} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {ListRenderItemInfo} from 'react-native'; -import {Keyboard, PixelRatio, View} from 'react-native'; -import type {ComposedGesture, GestureType} from 'react-native-gesture-handler'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; -import type {Attachment, AttachmentSource} from '@components/Attachments/types'; -import BlockingView from '@components/BlockingViews/BlockingView'; -import {ToddBehindCloud} from '@components/Icon/Illustrations'; -import {useFullScreenContext} from '@components/VideoPlayerContexts/FullScreenContext'; -import useLocalize from '@hooks/useLocalize'; +import React, {useCallback, useEffect, useState} from 'react'; +import {View} from 'react-native'; +import type {Attachment} from '@components/Attachments/types'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; -import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import CarouselActions from './CarouselActions'; -import CarouselButtons from './CarouselButtons'; -import CarouselItem from './CarouselItem'; +import AttachmentCarouselView from './AttachmentCarouselView'; import extractAttachments from './extractAttachments'; -import AttachmentCarouselPagerContext from './Pager/AttachmentCarouselPagerContext'; -import type {AttachmentCarouselProps, UpdatePageProps} from './types'; +import type {AttachmentCarouselProps} from './types'; import useCarouselArrows from './useCarouselArrows'; -import useCarouselContextEvents from './useCarouselContextEvents'; - -const viewabilityConfig = { - // To facilitate paging through the attachments, we want to consider an item "viewable" when it is - // more than 95% visible. When that happens we update the page index in the state. - itemVisiblePercentThreshold: 95, -}; - -const MIN_FLING_VELOCITY = 500; - -type DeviceAwareGestureDetectorProps = { - canUseTouchScreen: boolean; - gesture: ComposedGesture | GestureType; - children: React.ReactNode; -}; - -function DeviceAwareGestureDetector({canUseTouchScreen, gesture, children}: DeviceAwareGestureDetectorProps) { - // Don't render GestureDetector on non-touchable devices to prevent unexpected pointer event capture. - // This issue is left out on touchable devices since finger touch works fine. - // See: https://github.com/Expensify/App/issues/51246 - return canUseTouchScreen ? {children} : children; -} function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose, attachmentLink, onAttachmentError}: AttachmentCarouselProps) { - const theme = useTheme(); - const {translate} = useLocalize(); - const {windowWidth} = useWindowDimensions(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const styles = useThemeStyles(); - const {isFullScreenRef} = useFullScreenContext(); - const scrollRef = useAnimatedRef>>(); - const isPagerScrolling = useSharedValue(false); - const pagerRef = useRef(null); 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 styles = useThemeStyles(); - const modalStyles = styles.centeredModalStyles(shouldUseNarrowLayout, true); - const cellWidth = useMemo( - () => PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2), - [modalStyles.borderWidth, modalStyles.marginHorizontal, windowWidth], - ); - const [page, setPage] = useState(0); + const [page, setPage] = useState(); const [attachments, setAttachments] = useState([]); - const [activeAttachmentID, setActiveAttachmentID] = useState(attachmentID ?? source); const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows(); - const {handleTap, handleScaleChange, isScrollEnabled} = useCarouselContextEvents(setShouldShowArrows); useEffect(() => { if (!canUseTouchScreen) { @@ -138,211 +86,29 @@ function AttachmentCarousel({report, attachmentID, source, onNavigate, setDownlo } }, [reportActions, parentReportActions, compareImage, attachments, setDownloadButtonVisibility, onNavigate, accountID, type, report]); - // Scroll position is affected when window width is resized, so we readjust it on width changes - useEffect(() => { - if (attachments.length === 0 || scrollRef.current == null) { - return; - } - - scrollRef.current.scrollToIndex({index: page, animated: false}); - // The hook is not supposed to run on page change, so we keep the page out of the dependencies - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [cellWidth]); - - /** Updates the page state when the user navigates between attachments */ - const updatePage = useCallback( - ({viewableItems}: UpdatePageProps) => { - if (isFullScreenRef.current) { - return; - } - - Keyboard.dismiss(); - - // Since we can have only one item in view at a time, we can use the first item in the array - // to get the index of the current page - const entry = viewableItems.at(0); - if (!entry) { - setActiveAttachmentID(null); - return; - } - - const item = entry.item as Attachment; - if (entry.index !== null) { - setPage(entry.index); - setActiveAttachmentID(item.attachmentID ?? item.source); - } - - if (onNavigate) { - onNavigate(item); - } - }, - [isFullScreenRef, onNavigate], - ); - - /** Increments or decrements the index to get another selected item */ - const cycleThroughAttachments = useCallback( - (deltaSlide: number) => { - if (isFullScreenRef.current) { - return; - } - - const nextIndex = page + deltaSlide; - const nextItem = attachments.at(nextIndex); - - if (!nextItem || nextIndex < 0 || !scrollRef.current) { - return; - } - - scrollRef.current.scrollToIndex({index: nextIndex, animated: canUseTouchScreen}); - }, - [attachments, canUseTouchScreen, isFullScreenRef, page, scrollRef], - ); - - const extractItemKey = useCallback( - (item: Attachment) => - !!item.attachmentID || (typeof item.source !== 'string' && typeof item.source !== 'number') - ? `attachmentID-${item.attachmentID}` - : `source-${item.source}|${item.attachmentLink}`, - [], - ); - - /** Calculate items layout information to optimize scrolling performance */ - const getItemLayout = useCallback( - (data: ArrayLike | null | undefined, index: number) => ({ - length: cellWidth, - offset: cellWidth * index, - index, - }), - [cellWidth], - ); - - const context = useMemo( - () => ({ - pagerItems: [{source, index: 0, isActive: true}], - activePage: 0, - pagerRef, - isPagerScrolling, - isScrollEnabled, - onTap: handleTap, - onScaleChanged: handleScaleChange, - onSwipeDown: onClose, - onAttachmentError, - }), - [onAttachmentError, source, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange, onClose], - ); - - /** Defines how a single attachment should be rendered */ - const renderItem = useCallback( - ({item}: ListRenderItemInfo) => ( - - + if (page == null) { + return ( + + - ), - [activeAttachmentID, canUseTouchScreen, cellWidth, handleTap, report.reportID, shouldShowArrows, styles.h100], - ); - /** Pan gesture handing swiping through attachments on touch screen devices */ - const pan = useMemo( - () => - Gesture.Pan() - .enabled(canUseTouchScreen) - .onUpdate(({translationX}) => { - if (!isScrollEnabled.get()) { - return; - } - - if (translationX !== 0) { - isPagerScrolling.set(true); - } - - scrollTo(scrollRef, page * cellWidth - translationX, 0, false); - }) - .onEnd(({translationX, velocityX}) => { - if (!isScrollEnabled.get()) { - return; - } - - let newIndex; - if (velocityX > MIN_FLING_VELOCITY) { - // User flung to the right - newIndex = Math.max(0, page - 1); - } else if (velocityX < -MIN_FLING_VELOCITY) { - // User flung to the left - newIndex = Math.min(attachments.length - 1, page + 1); - } else { - // snap scroll position to the nearest cell (making sure it's within the bounds of the list) - const delta = Math.round(-translationX / cellWidth); - newIndex = Math.min(attachments.length - 1, Math.max(0, page + delta)); - } - - isPagerScrolling.set(false); - scrollTo(scrollRef, newIndex * cellWidth, 0, true); - }) - // eslint-disable-next-line react-compiler/react-compiler - .withRef(pagerRef as MutableRefObject), - [attachments.length, canUseTouchScreen, cellWidth, page, isScrollEnabled, scrollRef, isPagerScrolling], - ); + ); + } return ( - !canUseTouchScreen && setShouldShowArrows(true)} - onMouseLeave={() => !canUseTouchScreen && setShouldShowArrows(false)} - > - {page === -1 ? ( - - ) : ( - <> - cycleThroughAttachments(-1)} - onForward={() => cycleThroughAttachments(1)} - autoHideArrow={autoHideArrows} - cancelAutoHideArrow={cancelAutoHideArrows} - /> - - - - - - - - - )} - + ); } diff --git a/src/components/Attachments/AttachmentCarousel/types.ts b/src/components/Attachments/AttachmentCarousel/types.ts index 337c4d2391e8..5487c5d880df 100644 --- a/src/components/Attachments/AttachmentCarousel/types.ts +++ b/src/components/Attachments/AttachmentCarousel/types.ts @@ -12,6 +12,9 @@ type AttachmentCarouselProps = { /** Source is used to determine the starting index in the array of attachments */ source: AttachmentSource; + /** The report currently being looked at */ + report: Report; + /** The id of the current active attachment */ attachmentID?: string; @@ -21,9 +24,6 @@ type AttachmentCarouselProps = { /** Function to change the download button Visibility */ setDownloadButtonVisibility?: (isButtonVisible: boolean) => void; - /** The report currently being looked at */ - report: Report; - /** The type of the attachment */ type?: ValueOf; diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index a047ea411f5f..d5506d84c6bf 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -89,6 +89,7 @@ type AttachmentViewProps = Attachment & { function checkIsFileImage(source: string | number | ImageURISource | ImageURISource[], fileName: string | undefined) { const isSourceImage = typeof source === 'number' || (typeof source === 'string' && Str.isImage(source)); + const isFileNameImage = fileName && Str.isImage(fileName); return isSourceImage || isFileNameImage; @@ -249,9 +250,14 @@ function AttachmentView({ // For this check we use both source and file.name since temporary file source is a blob // both PDFs and images will appear as images when pasted into the text field. // We also check for numeric source since this is how static images (used for preview) are represented in RN. + + // isLocalSource checks if the source is blob as that's the type of the temp image coming from mobile web const isFileImage = checkIsFileImage(source, file?.name); + const isLocalSourceImage = typeof source === 'string' && source.startsWith('blob:'); + + const isImage = isFileImage ?? isLocalSourceImage; - if (isFileImage) { + if (isImage) { if (imageError && (typeof fallbackSource === 'number' || typeof fallbackSource === 'function')) { return ( @@ -298,7 +304,7 @@ function AttachmentView({ file={file} isAuthTokenRequired={isAuthTokenRequired} loadComplete={loadComplete} - isImage={isFileImage} + isImage={isImage} onPress={onPress} onError={() => { if (isOffline) { diff --git a/src/components/Attachments/types.ts b/src/components/Attachments/types.ts index 617b9e80c3af..0f6c14cb3d2e 100644 --- a/src/components/Attachments/types.ts +++ b/src/components/Attachments/types.ts @@ -4,6 +4,9 @@ import type IconAsset from '@src/types/utils/IconAsset'; type AttachmentSource = string | IconAsset | number; type Attachment = { + /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ + source: AttachmentSource; + /** Report action ID of the attachment */ reportActionID?: string; @@ -13,9 +16,6 @@ type Attachment = { /** Whether source url requires authentication */ isAuthTokenRequired?: boolean; - /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ - source: AttachmentSource; - /** URL to preview-sized attachment that is also used for the thumbnail */ previewSource?: AttachmentSource; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 8c50df7665ca..ed2f102c3055 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -124,6 +124,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepDestination').default, [SCREENS.MONEY_REQUEST.STEP_TIME_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepTime').default, [SCREENS.MONEY_REQUEST.STEP_SUBRATE_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepSubrate').default, + [SCREENS.MONEY_REQUEST.RECEIPT_VIEW_MODAL]: () => require('../../../../pages/iou/request/step/IOURequestStepScan/ReceiptViewModal').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE]: () => require('../../../../pages/iou/SplitExpensePage').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: () => require('../../../../pages/iou/SplitExpenseEditPage').default, }); diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index d24f0215ccda..9c5cbc519edf 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1360,6 +1360,7 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: ROUTES.MONEY_REQUEST_STEP_MERCHANT.route, [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.route, [SCREENS.MONEY_REQUEST.STEP_SCAN]: ROUTES.MONEY_REQUEST_STEP_SCAN.route, + [SCREENS.MONEY_REQUEST.RECEIPT_VIEW_MODAL]: ROUTES.MONEY_REQUEST_RECEIPT_VIEW_MODAL.route, [SCREENS.MONEY_REQUEST.STEP_TAG]: ROUTES.MONEY_REQUEST_STEP_TAG.route, [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: ROUTES.MONEY_REQUEST_STEP_WAYPOINT.route, [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index f2df578e8815..b5de5bf78131 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1420,6 +1420,10 @@ type MoneyRequestNavigatorParamList = { backTo: Routes; backToReport?: string; }; + [SCREENS.MONEY_REQUEST.RECEIPT_VIEW_MODAL]: { + transactionID: string; + backTo: Routes; + }; [SCREENS.MONEY_REQUEST.STEP_CURRENCY]: { action: IOUAction; iouType: IOUType; diff --git a/src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/index.tsx index 4bf746ffd6dd..97cf087bcf9c 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/index.tsx @@ -13,9 +13,11 @@ import usePrevious from '@hooks/usePrevious'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import Navigation from '@libs/Navigation/Navigation'; import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {Receipt} from '@src/types/onyx/Transaction'; import SubmitButtonShadow from './SubmitButtonShadow'; @@ -34,6 +36,7 @@ function ReceiptPreviews({submit, isMultiScanEnabled}: ReceiptPreviewsProps) { const theme = useTheme(); const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); + const backTo = Navigation.getActiveRoute(); const isPreviewsVisible = useSharedValue(false); const previewsHeight = styles.receiptPlaceholder.height + styles.pv2.paddingVertical * 2; const previewItemWidth = styles.receiptPlaceholder.width + styles.receiptPlaceholder.marginRight; @@ -89,8 +92,7 @@ function ReceiptPreviews({submit, isMultiScanEnabled}: ReceiptPreviewsProps) { accessible accessibilityLabel={translate('common.receipt')} accessibilityRole={CONST.ROLE.BUTTON} - // TODO: open ReceiptViewModal when implemented https://github.com/Expensify/App/issues/61182 - onPress={() => {}} + onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_RECEIPT_VIEW_MODAL.getRoute(item.transactionID, backTo))} > (); + const [page, setPage] = useState(-1); + const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); + + const [receipts = []] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, { + selector: (items) => + Object.values(items ?? {}) + .map((transaction) => (transaction?.receipt ? {...transaction?.receipt, transactionID: transaction.transactionID} : undefined)) + .filter((receipt): receipt is ReceiptWithTransactionIDAndSource => !!receipt), + canBeMissing: true, + }); + + useEffect(() => { + if (!receipts || receipts.length === 0) { + return; + } + + const activeReceipt = receipts.find((receipt) => receipt.transactionID === route?.params?.transactionID); + const activeReceiptIndex = receipts.findIndex((receipt) => receipt.transactionID === activeReceipt?.transactionID); + + setCurrentReceipt(activeReceipt); + setPage(activeReceiptIndex); + }, [receipts, route?.params?.transactionID]); + + const handleDeleteReceipt = useCallback(() => { + if (!currentReceipt) { + return; + } + + InteractionManager.runAfterInteractions(() => { + removeTransactionReceipt(currentReceipt.transactionID); + }); + + Navigation.goBack(); + }, [currentReceipt]); + + const handleCloseConfirmModal = () => { + setIsDeleteReceiptConfirmModalVisible(false); + }; + + const deleteReceipt = useCallback(() => { + handleCloseConfirmModal(); + handleDeleteReceipt(); + }, [handleDeleteReceipt]); + + const handleGoBack = useCallback(() => { + Navigation.goBack(route.params.backTo); + }, [route.params.backTo]); + + return ( + + +