diff --git a/src/components/EReceipt.tsx b/src/components/EReceipt.tsx index 61793068dc15..75a156e25a24 100644 --- a/src/components/EReceipt.tsx +++ b/src/components/EReceipt.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import useEReceipt from '@hooks/useEReceipt'; import useLocalize from '@hooks/useLocalize'; @@ -29,11 +29,14 @@ type EReceiptProps = { /** Where it is the preview */ isThumbnail?: boolean; + + /** Callback to be called when the image loads */ + onLoad?: () => void; }; const receiptMCCSize: number = variables.eReceiptMCCHeightWidthMedium; const backgroundImageMinWidth: number = variables.eReceiptBackgroundImageMinWidth; -function EReceipt({transactionID, transactionItem, isThumbnail = false}: EReceiptProps) { +function EReceipt({transactionID, transactionItem, onLoad, isThumbnail = false}: EReceiptProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -43,6 +46,8 @@ function EReceipt({transactionID, transactionItem, isThumbnail = false}: EReceip const {primaryColor, secondaryColor, titleColor, MCCIcon, tripIcon, backgroundImage} = useEReceipt(transactionItem ?? transaction); + const isLoadedRef = useRef(false); + const { amount: transactionAmount, currency: transactionCurrency, @@ -59,6 +64,14 @@ function EReceipt({transactionID, transactionItem, isThumbnail = false}: EReceip const primaryTextColorStyle = primaryColor ? StyleUtils.getColorStyle(primaryColor) : undefined; const titleTextColorStyle = titleColor ? StyleUtils.getColorStyle(titleColor) : undefined; + useEffect(() => { + if (isLoadedRef.current) { + return; + } + isLoadedRef.current = true; + onLoad?.(); + }, [onLoad]); + return ( void; }; const eReceiptAspectRatio = variables.eReceiptBGHWidth / variables.eReceiptBGHeight; diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index a8392282f02e..7964b5349672 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -10,12 +10,14 @@ import type {ImageOnLoadEvent, ImageProps} from './types'; function Image({ source: propsSource, + shouldCalculateAspectRatioForWideImage = false, isAuthTokenRequired = false, onLoad, objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL, style, loadingIconSize, loadingIndicatorStyles, + imageWidthToCalculateHeight, ...forwardedProps }: ImageProps) { const [aspectRatio, setAspectRatio] = useState(null); @@ -24,20 +26,35 @@ function Image({ const {shouldSetAspectRatioInStyle} = useContext(ImageBehaviorContext); + const aspectRatioStyle = useMemo(() => { + if (!shouldSetAspectRatioInStyle || !aspectRatio) { + return {}; + } + + if (!!imageWidthToCalculateHeight && typeof aspectRatio === 'number') { + return { + width: '100%', + height: imageWidthToCalculateHeight / aspectRatio, + }; + } + + return {aspectRatio, height: 'auto'}; + }, [shouldSetAspectRatioInStyle, aspectRatio, imageWidthToCalculateHeight]); + const updateAspectRatio = useCallback( (width: number, height: number) => { if (!isObjectPositionTop) { return; } - if (width > height) { + if (width > height && !shouldCalculateAspectRatioForWideImage) { setAspectRatio(1); return; } setAspectRatio(height ? width / height : 'auto'); }, - [isObjectPositionTop], + [isObjectPositionTop, shouldCalculateAspectRatioForWideImage], ); const handleLoad = useCallback( @@ -146,12 +163,13 @@ function Image({ /> ); } + return ( ); @@ -159,4 +177,7 @@ function Image({ Image.displayName = 'Image'; -export default React.memo(Image, (prevProps: ImageProps, nextProps: ImageProps) => prevProps.source === nextProps.source); +export default React.memo( + Image, + (prevProps: ImageProps, nextProps: ImageProps) => prevProps.source === nextProps.source && prevProps.imageWidthToCalculateHeight === nextProps.imageWidthToCalculateHeight, +); diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts index 5ed7e1986571..7a57250b9848 100644 --- a/src/components/Image/types.ts +++ b/src/components/Image/types.ts @@ -30,6 +30,9 @@ type BaseImageProps = { }; type ImageOwnProps = BaseImageProps & { + /** By default, when the image width is greater than its height, its aspectRatio is set to 1. If you want the aspectRatio to be calculated instead of set to 1 in these cases, set the value of this prop to true */ + shouldCalculateAspectRatioForWideImage?: boolean; + /** Should an auth token be included in the image request */ isAuthTokenRequired?: boolean; @@ -63,6 +66,9 @@ type ImageOwnProps = BaseImageProps & { * cf https://github.com/Expensify/App/issues/51888 */ waitForSession?: () => void; + + /** If you want to calculate the image height dynamically instead of using aspectRatio, pass the width in this property */ + imageWidthToCalculateHeight?: number; }; type ImageProps = ImageOwnProps; diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index c900b9abecf3..d72184121850 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -1,6 +1,6 @@ import delay from 'lodash/delay'; import React, {useEffect, useRef, useState} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -21,9 +21,12 @@ type ImageWithSizeLoadingProps = { /** Whether to show offline indicator */ shouldShowOfflineIndicator?: boolean; + + /** Invoked on mount and layout changes */ + onLayout?: (event: LayoutChangeEvent) => void; } & ImageProps; -function ImageWithSizeCalculation({ +function ImageWithLoading({ onError, containerStyles, shouldShowOfflineIndicator = true, @@ -32,6 +35,7 @@ function ImageWithSizeCalculation({ loadingIndicatorStyles, resizeMode, onLoad, + onLayout, ...rest }: ImageWithSizeLoadingProps) { const styles = useThemeStyles(); @@ -74,7 +78,10 @@ function ImageWithSizeCalculation({ }, [isLoading]); return ( - + ; + + /** Callback to be called when the image loads */ + onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; }; /** @@ -61,6 +64,7 @@ function ImageWithSizeCalculation({ objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL, loadingIconSize, loadingIndicatorStyles, + onLoad, }: ImageWithSizeCalculationProps) { const styles = useThemeStyles(); @@ -85,6 +89,7 @@ function ImageWithSizeCalculation({ width: event.nativeEvent.width, height: event.nativeEvent.height, }); + onLoad?.(event); }} objectPosition={objectPosition} loadingIconSize={loadingIconSize} diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index bdbd72a497a6..ad50659b3472 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -138,6 +138,7 @@ import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu'; import {useSearchContext} from './Search/SearchContext'; import AnimatedSettlementButton from './SettlementButton/AnimatedSettlementButton'; import Text from './Text'; +import {WideRHPContext} from './WideRHPContextProvider'; type MoneyReportHeaderProps = { /** The report currently being looked at */ @@ -303,6 +304,9 @@ function MoneyReportHeader({ const {selectedTransactionIDs, removeTransaction, clearSelectedTransactions, currentSearchQueryJSON, currentSearchKey} = useSearchContext(); + const {wideRHPRouteKeys} = useContext(WideRHPContext); + const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || (wideRHPRouteKeys.length > 0 && !isSmallScreenWidth); + const beginExportWithTemplate = useCallback( (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => { if (!moneyRequestReport) { @@ -1200,7 +1204,7 @@ function MoneyReportHeader({ shouldShowBorderBottom={false} shouldEnableDetailPageNavigation > - {!shouldDisplayNarrowVersion && ( + {shouldDisplayNarrowMoreButton && ( {!!primaryAction && !shouldShowSelectedTransactionsButton && primaryActionsImplementation[primaryAction]} {!!applicableSecondaryActions.length && !shouldShowSelectedTransactionsButton && KYCMoreDropdown} @@ -1218,8 +1222,7 @@ function MoneyReportHeader({ )} - - {shouldDisplayNarrowVersion && + {!shouldDisplayNarrowMoreButton && (shouldShowSelectedTransactionsButton ? ( { markAsCashAction(transaction?.transactionID, reportID); }, [reportID, transaction?.transactionID]); @@ -349,6 +352,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre }; const applicableSecondaryActions = secondaryActions.map((action) => secondaryActionsImplementation[action]); + const shouldDisplayNarrowMoreButton = !shouldUseNarrowLayout || (wideRHPRouteKeys.length > 0 && !isSmallScreenWidth); return ( @@ -372,8 +376,8 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre shouldEnableDetailPageNavigation openParentReportInCurrentTab={shouldOpenParentReportInCurrentTab} > - {!shouldUseNarrowLayout && ( - + {shouldDisplayNarrowMoreButton && ( + {!!primaryAction && primaryActionImplementation[primaryAction]} {!!applicableSecondaryActions.length && ( } - {shouldUseNarrowLayout && ( + {!shouldDisplayNarrowMoreButton && ( {!!primaryAction && {primaryActionImplementation[primaryAction]}} {!!applicableSecondaryActions.length && ( diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index de5d449c02a3..b24dd3c4d17c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,6 +1,6 @@ import {useFocusEffect} from '@react-navigation/native'; import isEmpty from 'lodash/isEmpty'; -import React, {memo, useCallback, useMemo, useState} from 'react'; +import React, {memo, useCallback, useContext, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {TupleToUnion} from 'type-fest'; import Checkbox from '@components/Checkbox'; @@ -11,6 +11,7 @@ import {usePersonalDetails, useSession} from '@components/OnyxListItemProvider'; import {useSearchContext} from '@components/Search/SearchContext'; import type {SearchColumnType, SortOrder} from '@components/Search/types'; import Text from '@components/Text'; +import {WideRHPContext} from '@components/WideRHPContextProvider'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -139,6 +140,7 @@ function MoneyRequestReportTransactionList({ const {translate, localeCompare} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout(); + const {markReportIDAsExpense} = useContext(WideRHPContext); const [isModalVisible, setIsModalVisible] = useState(false); const [selectedTransactionID, setSelectedTransactionID] = useState(''); @@ -229,10 +231,13 @@ function MoneyRequestReportTransactionList({ // to display prev/next arrows in RHP for navigation const sortedSiblingTransactionReportIDs = getThreadReportIDsForTransactions(reportActions, sortedTransactions); setActiveTransactionThreadIDs(sortedSiblingTransactionReportIDs).then(() => { + if (reportIDToNavigate) { + markReportIDAsExpense(reportIDToNavigate); + } Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(routeParams)); }); }, - [report, reportActions, sortedTransactions], + [report, reportActions, sortedTransactions, markReportIDAsExpense], ); const {amountColumnSize, dateColumnSize, taxAmountColumnSize} = useMemo(() => { diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index 1dd4ad4ede4c..c4f2733ce54c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -1,6 +1,7 @@ import {findFocusedRoute} from '@react-navigation/native'; -import React, {useEffect, useMemo} from 'react'; +import React, {useContext, useEffect, useMemo} from 'react'; import PrevNextButtons from '@components/PrevNextButtons'; +import {WideRHPContext} from '@components/WideRHPContextProvider'; import useOnyx from '@hooks/useOnyx'; import {clearActiveTransactionThreadIDs} from '@libs/actions/TransactionThreadNavigation'; import Navigation from '@navigation/Navigation'; @@ -19,6 +20,8 @@ function MoneyRequestReportTransactionsNavigation({currentReportID}: MoneyReques canBeMissing: true, }); + const {markReportIDAsExpense} = useContext(WideRHPContext); + const {prevReportID, nextReportID} = useMemo(() => { if (!reportIDsList || reportIDsList.length < 2) { return {prevReportID: undefined, nextReportID: undefined}; @@ -57,11 +60,17 @@ function MoneyRequestReportTransactionsNavigation({currentReportID}: MoneyReques onNext={(e) => { const backTo = Navigation.getActiveRoute(); e?.preventDefault(); + if (nextReportID) { + markReportIDAsExpense(nextReportID); + } Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: nextReportID, backTo}), {forceReplace: true}); }} onPrevious={(e) => { const backTo = Navigation.getActiveRoute(); e?.preventDefault(); + if (prevReportID) { + markReportIDAsExpense(prevReportID); + } Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: prevReportID, backTo}), {forceReplace: true}); }} /> diff --git a/src/components/ReceiptEmptyState.tsx b/src/components/ReceiptEmptyState.tsx index 7418966ede54..2592d8b09547 100644 --- a/src/components/ReceiptEmptyState.tsx +++ b/src/components/ReceiptEmptyState.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; @@ -26,13 +26,17 @@ type ReceiptEmptyStateProps = { shouldUseFullHeight?: boolean; style?: StyleProp; + + /** Callback to be called when the image loads */ + onLoad?: () => void; }; // Returns an SVG icon indicating that the user should attach a receipt -function ReceiptEmptyState({onPress, disabled = false, isThumbnail = false, isInMoneyRequestView = false, shouldUseFullHeight = false, style}: ReceiptEmptyStateProps) { +function ReceiptEmptyState({onPress, disabled = false, isThumbnail = false, isInMoneyRequestView = false, shouldUseFullHeight = false, style, onLoad}: ReceiptEmptyStateProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const theme = useTheme(); + const isLoadedRef = useRef(false); const Wrapper = onPress ? PressableWithoutFeedback : View; const containerStyle = [ @@ -44,6 +48,14 @@ function ReceiptEmptyState({onPress, disabled = false, isThumbnail = false, isIn style, ]; + useEffect(() => { + if (isLoadedRef.current) { + return; + } + isLoadedRef.current = true; + onLoad?.(); + }, [onLoad]); + return ( void; }; function ReceiptImage({ @@ -134,8 +140,11 @@ function ReceiptImage({ shouldUseFullHeight, loadingIndicatorStyles, thumbnailContainerStyles, + onLoad, }: ReceiptImageProps) { const styles = useThemeStyles(); + const [receiptImageWidth, setReceiptImageWidth] = useState(undefined); + const lastUpdateWidthTimestampRef = useRef(new Date().getTime()); if (isEmptyReceipt) { return ( @@ -144,6 +153,7 @@ function ReceiptImage({ onPress={onPress} disabled={!onPress} shouldUseFullHeight={shouldUseFullHeight} + onLoad={onLoad} /> ); } @@ -153,6 +163,7 @@ function ReceiptImage({ ); } @@ -162,6 +173,8 @@ function ReceiptImage({ ); } @@ -194,18 +207,29 @@ function ReceiptImage({ fallbackIconColor={fallbackIconColor} fallbackIconBackground={fallbackIconBackground} objectPosition={shouldUseInitialObjectPosition ? CONST.IMAGE_OBJECT_POSITION.INITIAL : CONST.IMAGE_OBJECT_POSITION.TOP} + onLoad={onLoad} /> ); } return ( { + if (e.nativeEvent.layout.width !== receiptImageWidth && e.timeStamp - lastUpdateWidthTimestampRef.current > MIN_UPDATE_WIDTH_DIFF) { + setReceiptImageWidth(e.nativeEvent.layout.width); + } + lastUpdateWidthTimestampRef.current = e.timeStamp; + }} source={{uri: source}} style={[style ?? [styles.w100, styles.h100], styles.overflowHidden]} isAuthTokenRequired={!!isAuthTokenRequired} loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} shouldShowOfflineIndicator={false} + objectPosition={shouldUseInitialObjectPosition ? CONST.IMAGE_OBJECT_POSITION.INITIAL : CONST.IMAGE_OBJECT_POSITION.TOP} + onLoad={onLoad} + shouldCalculateAspectRatioForWideImage={shouldUseFullHeight} + imageWidthToCalculateHeight={receiptImageWidth} /> ); } diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index c7d0425db61d..967e6ba4d1c9 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -1,6 +1,7 @@ import mapValues from 'lodash/mapValues'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -64,6 +65,12 @@ type MoneyRequestReceiptViewProps = { /** Merge transaction ID to show in merge transaction flow */ mergeTransactionID?: string; + + /** Whether the receipt view should fill the given space */ + fillSpace?: boolean; + + /** Whether it's displayed in Wide RHP */ + isDisplayedInWideRHP?: boolean; }; const receiptImageViolationNames: OnyxTypes.ViolationName[] = [ @@ -77,7 +84,16 @@ const receiptImageViolationNames: OnyxTypes.ViolationName[] = [ const receiptFieldViolationNames: OnyxTypes.ViolationName[] = [CONST.VIOLATIONS.MODIFIED_AMOUNT, CONST.VIOLATIONS.MODIFIED_DATE]; -function MoneyRequestReceiptView({allReports, report, readonly = false, updatedTransaction, isFromReviewDuplicates = false, mergeTransactionID}: MoneyRequestReceiptViewProps) { +function MoneyRequestReceiptView({ + allReports, + report, + readonly = false, + updatedTransaction, + isFromReviewDuplicates = false, + fillSpace = false, + mergeTransactionID, + isDisplayedInWideRHP = false, +}: MoneyRequestReceiptViewProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -90,6 +106,8 @@ function MoneyRequestReceiptView({allReports, report, readonly = false, updatedT canBeMissing: true, }); + const [isLoading, setIsLoading] = useState(true); + const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined; const isTrackExpense = isTrackExpenseReport(report); const moneyRequestReport = parentReport; @@ -140,7 +158,7 @@ function MoneyRequestReceiptView({allReports, report, readonly = false, updatedT const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => (pendingAction ? undefined : transaction?.pendingFields?.[fieldPath]); const isReceiptAllowed = !isPaidReport && !isInvoice; - const shouldShowReceiptEmptyState = isReceiptAllowed && !hasReceipt; + const shouldShowReceiptEmptyState = isReceiptAllowed && !hasReceipt && !!transaction && !transaction.receipt; const [receiptImageViolations, receiptViolations] = useMemo(() => { const imageViolations = []; const allViolations = []; @@ -173,6 +191,8 @@ function MoneyRequestReceiptView({allReports, report, readonly = false, updatedT ...parentReportAction?.errors, }; + const showReceiptErrorWithEmptyState = shouldShowReceiptEmptyState && !hasReceipt && !isEmptyObject(errors); + const [showConfirmDismissReceiptError, setShowConfirmDismissReceiptError] = useState(false); const dismissReceiptError = useCallback(() => { @@ -202,10 +222,22 @@ function MoneyRequestReceiptView({allReports, report, readonly = false, updatedT clearAllRelatedReportActionErrors(report.reportID, parentReportAction); }, [transaction, chatReport, parentReportAction, linkedTransactionID, report?.reportID]); - const receiptStyle = shouldUseNarrowLayout ? styles.expenseViewImageSmall : styles.expenseViewImage; + let receiptStyle: StyleProp; + if (fillSpace && shouldShowReceiptEmptyState) { + receiptStyle = styles.h100; + } else if (fillSpace) { + receiptStyle = styles.flexibleHeight; + } else { + receiptStyle = shouldUseNarrowLayout ? styles.expenseViewImageSmall : styles.expenseViewImage; + } + + const showBorderlessLoading = isLoading && fillSpace; + + // For empty receipt should be fullHeight + // For the rest, expand to match the content return ( - + {shouldShowReceiptAudit && ( {hasReceipt && ( - + setIsLoading(false)} /> )} + {!!shouldShowAuditMessage && hasReceipt && !isLoading && ( + + + + )} )} {!shouldShowReceiptEmptyState && !hasReceipt && } - {!!shouldShowAuditMessage && } { diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3b1ad6aae3c1..e80ab02f4eec 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1,6 +1,6 @@ import {activePolicySelector} from '@selectors/Policy'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Icon from '@components/Icon'; @@ -13,12 +13,14 @@ import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import Switch from '@components/Switch'; import Text from '@components/Text'; import ViolationMessages from '@components/ViolationMessages'; +import {WideRHPContext} from '@components/WideRHPContextProvider'; import useActiveRoute from '@hooks/useActiveRoute'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -567,6 +569,11 @@ function MoneyRequestView({ const actualParentReport = isFromMergeTransaction ? getReportOrDraftReport(getReportIDForExpense(updatedTransaction)) : parentReport; const shouldShowReport = !!parentReportID || !!actualParentReport; + // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const {wideRHPRouteKeys} = useContext(WideRHPContext); + if (!report?.reportID || !transaction?.transactionID) { return ; } @@ -575,14 +582,16 @@ function MoneyRequestView({ {shouldShowAnimatedBackground && } <> - + {(wideRHPRouteKeys.length === 0 || isSmallScreenWidth) && ( + + )} {isCustomUnitOutOfPolicy && isPerDiemRequest && ( ; + + /** Whether to use the thumbnail image instead of the full image */ + shouldUseThumbnailImage?: boolean; + + /** Callback to be called when the image loads */ + onLoad?: (event?: {nativeEvent: {width: number; height: number}}) => void; }; /** @@ -94,6 +103,9 @@ function ReportActionItemImage({ mergeTransactionID, onPress, shouldUseFullHeight, + report: reportProp, + shouldUseThumbnailImage, + onLoad, }: ReportActionItemImageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -116,33 +128,42 @@ function ReportActionItemImage({ ); } - const attachmentModalSource = tryResolveUrlFromApiRoot(image ?? ''); + const originalImageSource = tryResolveUrlFromApiRoot(image ?? ''); const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail ?? ''); const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); + const isPDF = filename && Str.isPDF(filename); let propsObj: ReceiptImageProps; if (isEReceipt) { - propsObj = {isEReceipt: true, transactionID: transaction.transactionID, iconSize: isSingleImage ? 'medium' : ('small' as IconSize)}; + propsObj = {isEReceipt: true, transactionID: transaction.transactionID, iconSize: isSingleImage ? 'medium' : ('small' as IconSize), shouldUseFullHeight}; } else if (thumbnail && !isLocalFile) { propsObj = { - shouldUseThumbnailImage: true, - source: thumbnailSource, + shouldUseThumbnailImage: shouldUseThumbnailImage ?? true, + + // PDF won't have originalImage that we can use. Use thumbnail instead + // We explicitly want to use || instead of nullish-coalescing because shouldUseThumbnailImage can be false. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + source: shouldUseThumbnailImage || isPDF ? thumbnailSource : originalImageSource, fallbackIcon: Expensicons.Receipt, fallbackIconSize: isSingleImage ? variables.iconSizeSuperLarge : variables.iconSizeExtraLarge, isAuthTokenRequired: true, - shouldUseInitialObjectPosition: isMapDistanceRequest, + + // If the image is full height, use initial position to make sure it will grow properly to fill the container + shouldUseInitialObjectPosition: isMapDistanceRequest && !shouldUseFullHeight, }; - } else if (isLocalFile && filename && Str.isPDF(filename) && typeof attachmentModalSource === 'string') { - propsObj = {isPDFThumbnail: true, source: attachmentModalSource}; + } else if (isLocalFile && isPDF && typeof originalImageSource === 'string') { + propsObj = {isPDFThumbnail: true, source: originalImageSource}; } else { propsObj = { isThumbnail, ...(isThumbnail && {iconSize: (isSingleImage ? 'medium' : 'small') as IconSize, fileExtension}), - shouldUseThumbnailImage: true, + shouldUseThumbnailImage: shouldUseThumbnailImage ?? true, isAuthTokenRequired: false, - source: thumbnail ?? image ?? '', - shouldUseInitialObjectPosition: isMapDistanceRequest, + source: shouldUseThumbnailImage ? (thumbnail ?? image ?? '') : originalImageSource, + + // If the image is full height, use initial position to make sure it will grow properly to fill the container + shouldUseInitialObjectPosition: isMapDistanceRequest && !shouldUseFullHeight, isEmptyReceipt, onPress, }; @@ -159,7 +180,7 @@ function ReportActionItemImage({ onPress={() => Navigation.navigate( ROUTES.TRANSACTION_RECEIPT.getRoute( - transactionThreadReport?.reportID ?? report?.reportID, + transactionThreadReport?.reportID ?? report?.reportID ?? reportProp?.reportID, transaction?.transactionID, readonly, isFromReviewDuplicates, @@ -170,7 +191,11 @@ function ReportActionItemImage({ accessibilityLabel={translate('accessibilityHints.viewAttachment')} accessibilityRole={CONST.ROLE.BUTTON} > - + )} @@ -182,6 +207,7 @@ function ReportActionItemImage({ {...propsObj} shouldUseFullHeight={shouldUseFullHeight} thumbnailContainerStyles={styles.thumbnailImageContainerHover} + onLoad={onLoad} /> ); } diff --git a/src/components/ScreenWrapper/index.tsx b/src/components/ScreenWrapper/index.tsx index 3210f9d96e60..853e72bfc8b8 100644 --- a/src/components/ScreenWrapper/index.tsx +++ b/src/components/ScreenWrapper/index.tsx @@ -21,7 +21,7 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import NarrowPaneContext from '@libs/Navigation/AppNavigator/Navigators/NarrowPaneContext'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {ReportsSplitNavigatorParamList, RootNavigatorParamList} from '@libs/Navigation/types'; +import type {ReportsSplitNavigatorParamList, RootNavigatorParamList, SearchReportParamList} from '@libs/Navigation/types'; import {closeReactNativeApp} from '@userActions/HybridApp'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -50,7 +50,7 @@ type ScreenWrapperProps = Omit & * * This is required because transitionEnd event doesn't trigger in the testing environment. */ - navigation?: PlatformStackNavigationProp | PlatformStackNavigationProp; + navigation?: PlatformStackNavigationProp | PlatformStackNavigationProp | PlatformStackNavigationProp; /** A unique ID to find the screen wrapper in tests */ testID: string; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index c699d11d8b75..9495d2085ef7 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,6 +1,6 @@ import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Animated, {FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; @@ -9,6 +9,7 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOffli import SearchTableHeader, {getExpenseHeaders} from '@components/SelectionListWithSections/SearchTableHeader'; import type {ReportActionListItemType, SearchListItem, SelectionListHandle, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; +import {WideRHPContext} from '@components/WideRHPContextProvider'; import useCardFeedsForDisplay from '@hooks/useCardFeedsForDisplay'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -201,6 +202,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS const {isSmallScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); const navigation = useNavigation>(); const isFocused = useIsFocused(); + const {markReportIDAsExpense} = useContext(WideRHPContext); const { setCurrentSearchHashAndKey, setCurrentSearchQueryJSON, @@ -634,6 +636,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS } const isFromSelfDM = item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const isTask = type === CONST.SEARCH.DATA_TYPES.TASK; const reportID = isTransactionItem && (!item.isFromOneTransactionReport || isFromSelfDM) && item.transactionThreadReportID !== CONST.REPORT.UNREPORTED_REPORT_ID @@ -658,9 +661,15 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS return; } + const isInvoice = item?.report?.type === CONST.REPORT.TYPE.INVOICE; + + if (!isTask && !isInvoice) { + markReportIDAsExpense(reportID); + } + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo})); }, - [isMobileSelectionModeEnabled, toggleTransaction, queryJSON, handleSearch, searchKey, reportActionsArray, hash], + [isMobileSelectionModeEnabled, type, toggleTransaction, reportActionsArray, hash, queryJSON, handleSearch, searchKey, markReportIDAsExpense], ); const currentColumns = useMemo(() => { diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index 68b804af5224..9cec7328b606 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import AnimatedCollapsible from '@components/AnimatedCollapsible'; @@ -21,6 +21,7 @@ import type { } from '@components/SelectionListWithSections/types'; import Text from '@components/Text'; import TransactionItemRow from '@components/TransactionItemRow'; +import {WideRHPContext} from '@components/WideRHPContextProvider'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -128,6 +129,7 @@ function TransactionGroupListItem({ const [isExpanded, setIsExpanded] = useState(false); const isEmpty = transactions.length === 0; + const {markReportIDAsExpense} = useContext(WideRHPContext); // Currently only the transaction report groups have transactions where the empty view makes sense const shouldDisplayEmptyView = isEmpty && isGroupByReports; const isDisabledOrEmpty = isEmpty || isDisabled; @@ -169,6 +171,7 @@ function TransactionGroupListItem({ createAndOpenSearchTransactionThread(transactionItem, iouAction, currentSearchHash, backTo); return; } + markReportIDAsExpense(reportID); Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo})); }; diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 478acdca2db7..ea31ca985a08 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -72,6 +72,9 @@ type ThumbnailImageProps = { /** Callback fired when the image has been measured */ onMeasure?: () => void; + + /** Callback to be called when the image loads */ + onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; }; type UpdateImageSizeParams = { @@ -97,6 +100,7 @@ function ThumbnailImage({ onLoadFailure, onMeasure, loadingIndicatorStyles, + onLoad, }: ThumbnailImageProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -172,6 +176,7 @@ function ThumbnailImage({ objectPosition={objectPosition} loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} + onLoad={onLoad} /> diff --git a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx index 93b4ee2a19dc..8603d5e6a867 100644 --- a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx +++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx @@ -59,7 +59,7 @@ function ReceiptCell({transactionItem, isSelected, style}: {transactionItem: Tra fallbackIconBackground={isSelected ? theme.buttonHoveredBG : undefined} iconSize="x-small" loadingIconSize="small" - loadingIndicatorStyles={styles.bgTransparent} + loadingIndicatorStyles={styles.receiptCellLoadingContainer} transactionItem={transactionItem} shouldUseInitialObjectPosition /> diff --git a/src/components/WideRHPContextProvider/index.tsx b/src/components/WideRHPContextProvider/index.tsx index d0d1fd18f711..498e0e772173 100644 --- a/src/components/WideRHPContextProvider/index.tsx +++ b/src/components/WideRHPContextProvider/index.tsx @@ -1,10 +1,12 @@ import {findFocusedRoute, StackActions, useNavigation, useRoute} from '@react-navigation/native'; +import type {NavigationState, PartialState} from '@react-navigation/native'; import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react'; // We use Animated for all functionality related to wide RHP to make it easier // to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated. // eslint-disable-next-line no-restricted-imports import {Animated, Dimensions, InteractionManager} from 'react-native'; import useRootNavigationState from '@hooks/useRootNavigationState'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import navigationRef from '@libs/Navigation/navigationRef'; import type {NavigationRoute} from '@libs/Navigation/types'; import variables from '@styles/variables'; @@ -19,6 +21,41 @@ const secondOverlayProgress = new Animated.Value(0); const wideRHPMaxWidth = variables.receiptPaneRHPMaxWidth + variables.sideBarWidth; +/** + * Utility function that extracts all unique navigation keys from a React Navigation state. + * Recursively traverses the navigation state tree and collects all route keys. + * + * @param state - The React Navigation state (can be partial or complete) + * @returns Set of unique route keys found in the navigation state + */ +function extractNavigationKeys(state: NavigationState | PartialState | undefined): Set { + if (!state || !state.routes) { + return new Set(); + } + + const keys = new Set(); + const routesToProcess = [...state.routes]; + + while (routesToProcess.length > 0) { + const route = routesToProcess.pop(); + if (!route) { + continue; + } + + // Add the current route key to the set + if (route.key) { + keys.add(route.key); + } + + // If the route has a nested state, add its routes to the processing queue + if (route.state && 'routes' in route.state && Array.isArray(route.state.routes)) { + routesToProcess.push(...route.state.routes); + } + } + + return keys; +} + /** * Calculates the optimal width for the receipt pane RHP based on window width. * Ensures the RHP doesn't exceed maximum width and maintains minimum responsive width. @@ -38,10 +75,44 @@ const receiptPaneRHPWidth = new Animated.Value(calculateReceiptPaneRHPWidth(Dime const WideRHPContext = createContext(defaultWideRHPContextValue); function WideRHPContextProvider({children}: React.PropsWithChildren) { - const [wideRHPRouteKeys, setWideRHPRouteKeys] = useState([]); + // We have a separate containers for allWideRHPRouteKeys and wideRHPRouteKeys because we may have two or more RHPs on the stack. + // For convenience and proper overlay logic wideRHPRouteKeys will show only the keys existing in the last RHP. + const [allWideRHPRouteKeys, setAllWideRHPRouteKeys] = useState([]); const [shouldRenderSecondaryOverlay, setShouldRenderSecondaryOverlay] = useState(false); const [expenseReportIDs, setExpenseReportIDs] = useState>(new Set()); + // Return undefined if RHP is not the last route + const lastVisibleRHPRouteKey = useRootNavigationState((state) => { + const lastFullScreenRouteIndex = state?.routes.findLastIndex((route) => isFullScreenName(route.name)); + const lastRHPRouteIndex = state?.routes.findLastIndex((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + + // Both routes have to be present and the RHP have to be after last full screen for it to be visible. + if (lastFullScreenRouteIndex === -1 || lastRHPRouteIndex === -1 || lastFullScreenRouteIndex > lastRHPRouteIndex) { + return undefined; + } + + return state?.routes.at(lastRHPRouteIndex)?.key; + }); + + const wideRHPRouteKeys = useMemo(() => { + const rootState = navigationRef.getRootState(); + + if (!rootState) { + return []; + } + + const lastRHPRoute = rootState.routes.find((route) => route.key === lastVisibleRHPRouteKey); + + if (!lastRHPRoute) { + return []; + } + + const lastRHPKeys = extractNavigationKeys(lastRHPRoute.state); + const currentKeys = allWideRHPRouteKeys.filter((key) => lastRHPKeys.has(key)); + + return currentKeys; + }, [allWideRHPRouteKeys, lastVisibleRHPRouteKey]); + /** * Determines whether the secondary overlay should be displayed. * Shows second overlay when RHP is open and there is a wide RHP route open but there is another regular route on the top. @@ -75,7 +146,7 @@ function WideRHPContextProvider({children}: React.PropsWithChildren) { const newKey = route.key; // If the key is in the array, don't add it. - setWideRHPRouteKeys((prev) => (prev.includes(newKey) ? prev : [newKey, ...prev])); + setAllWideRHPRouteKeys((prev) => (prev.includes(newKey) ? prev : [newKey, ...prev])); }, []); /** @@ -91,13 +162,13 @@ function WideRHPContextProvider({children}: React.PropsWithChildren) { const keyToRemove = route.key; // Do nothing, the key is not here - if (!wideRHPRouteKeys.includes(keyToRemove)) { + if (!allWideRHPRouteKeys.includes(keyToRemove)) { return; } - setWideRHPRouteKeys((prev) => prev.filter((key) => key !== keyToRemove)); + setAllWideRHPRouteKeys((prev) => (prev.includes(keyToRemove) ? prev.filter((key) => key !== keyToRemove) : prev)); }, - [wideRHPRouteKeys], + [allWideRHPRouteKeys], ); /** @@ -263,4 +334,4 @@ WideRHPContextProvider.displayName = 'WideRHPContextProvider'; export default WideRHPContextProvider; -export {expandedRHPProgress, receiptPaneRHPWidth, secondOverlayProgress, useShowWideRHPVersion, WideRHPContext}; +export {expandedRHPProgress, receiptPaneRHPWidth, secondOverlayProgress, useShowWideRHPVersion, WideRHPContext, extractNavigationKeys}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx index 4d1c27fa7131..eb760709ddbb 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {Animated, View} from 'react-native'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import useLocalize from '@hooks/useLocalize'; +import useSidePanel from '@hooks/useSidePanel'; import useThemeStyles from '@hooks/useThemeStyles'; import type {OverlayStylesParams} from '@styles/index'; import CONST from '@src/CONST'; @@ -26,11 +27,18 @@ function BaseOverlay({onPress, hasMarginRight = false, progress, hasMarginLeft = const styles = useThemeStyles(); const {current} = useCardAnimation(); const {translate} = useLocalize(); + const {sidePanelTranslateX} = useSidePanel(); return ( {/* In the latest Electron version buttons can't be both clickable and draggable. diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index d4298097d784..651d52de942a 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -232,8 +232,8 @@ import type {IOUAction, IOUActionParams, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Accountant, Attendee, Participant, Split} from '@src/types/onyx/IOU'; @@ -911,6 +911,15 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string) { if (isReportOpenInRHP(rootState)) { const rhpKey = rootState.routes.at(-1)?.state?.key; if (rhpKey) { + const hasMultipleTransactions = Object.values(allTransactions).filter((transaction) => transaction?.reportID === reportID).length > 0; + // When a report with one expense is opened in the wide RHP and the user adds another expense, RHP should be dismissed and ROUTES.SEARCH_MONEY_REQUEST_REPORT should be displayed. + if (hasMultipleTransactions && reportID) { + Navigation.dismissModal(); + InteractionManager.runAfterInteractions(() => { + Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID})); + }); + return; + } Navigation.pop(rhpKey); return; } diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 376cb4a0434e..7de1b52ccfd3 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -4,7 +4,10 @@ import {accountIDSelector} from '@selectors/Session'; import {deepEqual} from 'fast-equals'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {FlatList, ViewStyle} from 'react-native'; -import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; +// We use Animated for all functionality related to wide RHP to make it easier +// to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated. +// eslint-disable-next-line no-restricted-imports +import {Animated, DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Banner from '@components/Banner'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -14,8 +17,11 @@ import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestReceiptView'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import {useShowWideRHPVersion} from '@components/WideRHPContextProvider'; import useAppFocusEvent from '@hooks/useAppFocusEvent'; import useCurrentReportID from '@hooks/useCurrentReportID'; import useDeepCompareRef from '@hooks/useDeepCompareRef'; @@ -52,6 +58,7 @@ import { isDeletedParentAction, isMoneyRequestAction, isSentMoneyReportAction, + isTransactionThread, isWhisperAction, shouldReportActionBeVisible, } from '@libs/ReportActionsUtils'; @@ -77,7 +84,7 @@ import { isValidReportIDFromPath, } from '@libs/ReportUtils'; import {isNumeric} from '@libs/ValidationUtils'; -import type {ReportsSplitNavigatorParamList} from '@navigation/types'; +import type {ReportsSplitNavigatorParamList, SearchReportParamList} from '@navigation/types'; import {setShouldShowComposeInput} from '@userActions/Composer'; import { clearDeleteTransactionNavigateBackUrl, @@ -103,7 +110,9 @@ import ReportFooter from './report/ReportFooter'; import type {ActionListContextType, ScrollPosition} from './ReportScreenContext'; import {ActionListContext} from './ReportScreenContext'; -type ReportScreenNavigationProps = PlatformStackScreenProps; +type ReportScreenNavigationProps = + | PlatformStackScreenProps + | PlatformStackScreenProps; type ReportScreenProps = ReportScreenNavigationProps; @@ -141,6 +150,7 @@ function isEmpty(report: OnyxEntry): boolean { function ReportScreen({route, navigation}: ReportScreenProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); const reportActionIDFromRoute = route?.params?.reportActionID; const isFocused = useIsFocused(); @@ -186,7 +196,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { return; } - const lastAccessedReportID = findLastAccessedReport(!isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), !!route.params.openOnAdminRoom)?.reportID; + const lastAccessedReportID = findLastAccessedReport(!isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), 'openOnAdminRoom' in route.params && !!route.params.openOnAdminRoom)?.reportID; // It's possible that reports aren't fully loaded yet // in that case the reportID is undefined @@ -816,6 +826,21 @@ function ReportScreen({route, navigation}: ReportScreenProps) { [reportMetadata?.isLoadingInitialReportActions, reportMetadata?.hasOnceLoadedReportActions], ); + // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + const shouldShowWideRHP = + route.name === SCREENS.SEARCH.REPORT_RHP && + !isSmallScreenWidth && + (isTransactionThread(parentReportAction) || + parentReportAction?.childType === CONST.REPORT.TYPE.EXPENSE || + parentReportAction?.childType === CONST.REPORT.TYPE.IOU || + report?.type === CONST.REPORT.TYPE.EXPENSE || + report?.type === CONST.REPORT.TYPE.IOU); + + useShowWideRHPVersion(shouldShowWideRHP); + // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. // We aim to display a loader first, then fetch relevant reportActions, and finally show them. @@ -869,49 +894,63 @@ function ReportScreen({route, navigation}: ReportScreenProps) { /> )} - - {(!report || shouldWaitForTransactions) && } - {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {isCurrentReportLoadedFromOnyx ? ( - - ) : null} + + {shouldShowWideRHP && ( + + + + + + )} + + {(!report || shouldWaitForTransactions) && } + {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {isCurrentReportLoadedFromOnyx ? ( + + ) : null} + diff --git a/src/styles/index.ts b/src/styles/index.ts index 9c6fa9393662..073bd79fd01f 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type {LineLayerStyleProps} from '@rnmapbox/maps/src/utils/MapboxStyles'; import lodashClamp from 'lodash/clamp'; +import type {RefObject} from 'react'; import type {LineLayer} from 'react-map-gl'; import type {ImageStyle, TextStyle, ViewStyle} from 'react-native'; // eslint-disable-next-line no-restricted-imports @@ -5196,6 +5197,30 @@ const staticStyles = (theme: ThemeColors) => right: 0, width: Animated.add(variables.sideBarWidth, receiptPaneRHPWidth), }, + + flexibleHeight: { + height: 'auto', + minHeight: 200, + }, + + receiptCellLoadingContainer: { + backgroundColor: theme.activeComponentBG, + }, + + wideRHPMoneyRequestReceiptViewContainer: { + backgroundColor: theme.appBG, + width: receiptPaneRHPWidth, + height: '100%', + borderRightWidth: 1, + borderColor: theme.border, + }, + + wideRHPMoneyRequestReceiptViewScrollViewContainer: { + ...spacing.pt3, + ...spacing.pb2, + minHeight: '100%', + }, + uploadFileView: { borderRadius: variables.componentBorderRadiusLarge, borderColor: theme.borderFocus, @@ -5399,11 +5424,21 @@ const dynamicStyles = (theme: ThemeColors) => vertical: windowHeight - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM, }) satisfies AnchorPosition, - overlayStyles: ({progress, hasMarginRight = false, hasMarginLeft = false}: {progress: OverlayStylesParams; hasMarginRight?: boolean; hasMarginLeft?: boolean}) => + overlayStyles: ({ + progress, + hasMarginRight = false, + hasMarginLeft = false, + sidePanelTranslateX, + }: { + progress: OverlayStylesParams; + hasMarginRight?: boolean; + hasMarginLeft?: boolean; + sidePanelTranslateX?: RefObject; + }) => ({ // We need to stretch the overlay to cover the sidebar and the translate animation distance. - left: hasMarginLeft ? variables.receiptPaneRHPMaxWidth : -2 * variables.sideBarWidth, - right: hasMarginRight ? variables.sideBarWidth : 0, + left: hasMarginLeft ? receiptPaneRHPWidth : -2 * variables.sideBarWidth, + right: hasMarginRight ? Animated.add(variables.sideBarWidth, sidePanelTranslateX ? Animated.subtract(variables.sideBarWidth, sidePanelTranslateX.current) : 0) : 0, opacity: progress.interpolate({ inputRange: [0, 1], outputRange: [0, variables.overlayOpacity], @@ -5626,6 +5661,16 @@ const dynamicStyles = (theme: ThemeColors) => maxWidth: shouldUseNarrowLayout ? undefined : 500, }), + getMoneyRequestViewImage: (showBorderless: boolean) => ({ + ...spacing.mh5, + overflow: 'hidden', + borderWidth: showBorderless ? 0 : 1, + borderColor: theme.border, + borderRadius: variables.componentBorderRadiusLarge, + height: 180, + maxWidth: '100%', + }), + getTestToolsNavigatorOuterView: (shouldUseNarrowLayout: boolean) => ({ justifyContent: shouldUseNarrowLayout ? 'flex-end' : 'center', }),