diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 5be6c8ce835e..11a9ed74bc4c 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -1,3 +1,4 @@ +import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState} from 'react'; import {FlatList, View} from 'react-native'; import type {ListRenderItemInfo, ViewToken} from 'react-native'; @@ -95,6 +96,7 @@ const reportAttributesSelector = (c: OnyxEntry) => function MoneyRequestReportPreviewContent({ iouReportID, + newTransactionIDs, chatReportID, action, containerStyles, @@ -414,7 +416,7 @@ function MoneyRequestReportPreviewContent({ thumbsUpScale.set(isApprovedAnimationRunning ? withDelay(CONST.ANIMATION_THUMBS_UP_DELAY, withSpring(1, {duration: CONST.ANIMATION_THUMBS_UP_DURATION})) : 1); }, [isApproved, isApprovedAnimationRunning, thumbsUpScale]); - const carouselTransactions = shouldShowAccessPlaceHolder ? [] : transactions.slice(0, 11); + const carouselTransactions = useMemo(() => (shouldShowAccessPlaceHolder ? [] : transactions.slice(0, 11)), [shouldShowAccessPlaceHolder, transactions]); const prevCarouselTransactionLength = useRef(0); useEffect(() => { @@ -440,6 +442,47 @@ function MoneyRequestReportPreviewContent({ const viewabilityConfig = useMemo(() => { return {itemVisiblePercentThreshold: 100}; }, []); + const numberOfScrollToIndexFailed = useRef(0); + const onScrollToIndexFailed: (info: {index: number; highestMeasuredFrameIndex: number; averageItemLength: number}) => void = ({index}) => { + // There is a probability of infinite loop so we want to make sure that it is not called more than 5 times. + if (numberOfScrollToIndexFailed.current > 4) { + return; + } + + // Sometimes scrollToIndex might be called before the item is rendered so we will re-call scrollToIndex after a small delay. + setTimeout(() => { + carouselRef.current?.scrollToIndex({index, animated: true, viewOffset: 2 * styles.gap2.gap}); + }, 100); + numberOfScrollToIndexFailed.current++; + }; + + const carouselTransactionsRef = useRef(carouselTransactions); + + useEffect(() => { + carouselTransactionsRef.current = carouselTransactions; + }, [carouselTransactions]); + + useFocusEffect( + useCallback(() => { + const index = carouselTransactions.findIndex((transaction) => newTransactionIDs?.includes(transaction.transactionID)); + + if (index < 0) { + return; + } + const newTransaction = carouselTransactions.at(index); + setTimeout(() => { + // If the new transaction is not available at the index it was on before the delay, avoid the scrolling + // because we are scrolling to either a wrong or unavailable transaction (which can cause crash). + if (newTransaction?.transactionID !== carouselTransactionsRef.current.at(index)?.transactionID) { + return; + } + numberOfScrollToIndexFailed.current = 0; + carouselRef.current?.scrollToIndex({index, viewOffset: 2 * styles.gap2.gap, animated: true}); + }, CONST.ANIMATED_TRANSITION); + + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [newTransactionIDs]), + ); // eslint-disable-next-line react-compiler/react-compiler const onViewableItemsChanged = useRef(({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { @@ -807,6 +850,7 @@ function MoneyRequestReportPreviewContent({ ) : ( transaction.transactionID); const renderItem: ListRenderItem = ({item}) => ( ); return ( void; + + /** IDs of newly added transactions */ + newTransactionIDs?: string[]; }; export type {MoneyRequestReportPreviewContentProps, MoneyRequestReportPreviewProps, MoneyRequestReportPreviewStyleType}; diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 5f16f61fe2f2..81e71bdefebd 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -1,6 +1,7 @@ import truncate from 'lodash/truncate'; import React, {useMemo} from 'react'; import {View} from 'react-native'; +import Animated from 'react-native-reanimated'; import Button from '@components/Button'; import Icon from '@components/Icon'; // eslint-disable-next-line no-restricted-imports @@ -11,6 +12,7 @@ import ReportActionItemImages from '@components/ReportActionItem/ReportActionIte import UserInfoCellsWithArrow from '@components/SelectionListWithSections/Search/UserInfoCellsWithArrow'; import Text from '@components/Text'; import TransactionPreviewSkeletonView from '@components/TransactionPreviewSkeletonView'; +import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -62,6 +64,7 @@ function TransactionPreviewContent({ shouldShowPayerAndReceiver, navigateToReviewFields, isReviewDuplicateTransactionPage = false, + shouldHighlight = false, }: TransactionPreviewContentProps) { const icons = useMemoizedLazyExpensifyIcons(['Folder', 'Tag']); const theme = useTheme(); @@ -218,10 +221,17 @@ function TransactionPreviewContent({ const previewTextViewGap = (shouldShowCategoryOrTag || !shouldWrapDisplayAmount) && styles.gap2; const previewTextMargin = shouldShowIOUHeader && shouldShowMerchantOrDescription && !isBillSplit && !shouldShowCategoryOrTag && styles.mbn1; + const animatedHighlightStyle = useAnimatedHighlightStyle({ + shouldHighlight, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.cardBG, + shouldApplyOtherStyles: false, + }); + const transactionWrapperStyles = [styles.border, styles.moneyRequestPreviewBox, (isIOUSettled || isApproved) && isSettlementOrApprovalPartial && styles.offlineFeedbackPending]; return ( - + offlineWithFeedbackOnClose} @@ -232,7 +242,7 @@ function TransactionPreviewContent({ shouldDisableOpacity={isDeleted} shouldHideOnDelete={shouldHideOnDelete} > - + - + ); } diff --git a/src/components/ReportActionItem/TransactionPreview/index.tsx b/src/components/ReportActionItem/TransactionPreview/index.tsx index 5a50a494d99a..b0388743d37a 100644 --- a/src/components/ReportActionItem/TransactionPreview/index.tsx +++ b/src/components/ReportActionItem/TransactionPreview/index.tsx @@ -41,6 +41,7 @@ function TransactionPreview(props: TransactionPreviewProps) { iouReportID, transactionID: transactionIDFromProps, onPreviewPressed, + shouldHighlight, reportPreviewAction, contextAction, } = props; @@ -130,6 +131,7 @@ function TransactionPreview(props: TransactionPreviewProps) { walletTermsErrors={walletTerms?.errors} routeName={route.name} isReviewDuplicateTransactionPage={isReviewDuplicateTransactionPage} + shouldHighlight={shouldHighlight} /> ); @@ -154,6 +156,7 @@ function TransactionPreview(props: TransactionPreviewProps) { walletTermsErrors={walletTerms?.errors} routeName={route.name} reportPreviewAction={reportPreviewAction} + shouldHighlight={shouldHighlight} isReviewDuplicateTransactionPage={isReviewDuplicateTransactionPage} /> ); diff --git a/src/components/ReportActionItem/TransactionPreview/types.ts b/src/components/ReportActionItem/TransactionPreview/types.ts index 886dcf25a185..2a94f8701261 100644 --- a/src/components/ReportActionItem/TransactionPreview/types.ts +++ b/src/components/ReportActionItem/TransactionPreview/types.ts @@ -72,6 +72,9 @@ type TransactionPreviewProps = { /** In case we want to override context menu action */ contextAction?: OnyxEntry; + + /** Whether the item should be highlighted */ + shouldHighlight?: boolean; }; type TransactionPreviewContentProps = { @@ -141,6 +144,9 @@ type TransactionPreviewContentProps = { /** Is this component used during duplicate review flow */ isReviewDuplicateTransactionPage?: boolean; + + /** Whether the item should be highlighted */ + shouldHighlight?: boolean; }; export type {TransactionPreviewContentProps, TransactionPreviewProps, TransactionPreviewStyleType}; diff --git a/src/hooks/useAnimatedHighlightStyle/index.ts b/src/hooks/useAnimatedHighlightStyle/index.ts index 45646ad9be03..cbb744e164d9 100644 --- a/src/hooks/useAnimatedHighlightStyle/index.ts +++ b/src/hooks/useAnimatedHighlightStyle/index.ts @@ -33,6 +33,9 @@ type Props = { /** Whether the item should be highlighted */ shouldHighlight: boolean; + /** Whether it should return height and border radius styles */ + shouldApplyOtherStyles?: boolean; + /** The base backgroundColor used for the highlight animation, defaults to theme.appBG * @default theme.appBG */ @@ -63,6 +66,7 @@ export default function useAnimatedHighlightStyle({ height, highlightColor, backgroundColor, + shouldApplyOtherStyles = true, skipInitialFade = false, }: Props) { const [startHighlight, setStartHighlight] = useState(false); @@ -80,9 +84,8 @@ export default function useAnimatedHighlightStyle({ return { backgroundColor: interpolateColor(repeatableValue, [0, 1], [backgroundColor ?? theme.appBG, highlightColor ?? theme.border]), - height: height ? interpolate(nonRepeatableValue, [0, 1], [0, height]) : 'auto', opacity: interpolate(nonRepeatableValue, [0, 1], [0, 1]), - borderRadius, + ...(shouldApplyOtherStyles && {height: height ? interpolate(nonRepeatableValue, [0, 1], [0, height]) : 'auto', borderRadius}), }; }, [borderRadius, height, backgroundColor, highlightColor, theme.appBG, theme.border]); diff --git a/src/styles/index.ts b/src/styles/index.ts index efc5a3d8ac20..8b53b3eb5df3 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4246,6 +4246,10 @@ const staticStyles = (theme: ThemeColors) => backgroundColor: theme.cardBG, }, + reportPreviewBoxHoverBorderColor: { + borderColor: theme.cardBG, + }, + reportContainerBorderRadius: { borderRadius: variables.componentBorderRadiusLarge, },