From 965451f405b992d9552cad94d7124fce1dcd65e7 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Mon, 26 Jan 2026 19:12:36 +0300 Subject: [PATCH 1/4] implement scroll and highlight new added expense --- .../MoneyRequestReportPreviewContent.tsx | 46 ++++++++++++++++++- .../MoneyRequestReportPreview/index.tsx | 7 +++ .../MoneyRequestReportPreview/types.ts | 3 ++ .../TransactionPreviewContent.tsx | 16 +++++-- .../TransactionPreview/index.tsx | 3 ++ .../TransactionPreview/types.ts | 6 +++ src/hooks/useAnimatedHighlightStyle/index.ts | 7 ++- src/styles/index.ts | 4 ++ 8 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index d36c1dad5985c..8dde52b40887e 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'; @@ -96,6 +97,7 @@ const reportAttributesSelector = (c: OnyxEntry) => function MoneyRequestReportPreviewContent({ iouReportID, + newTransactionIDs, chatReportID, action, containerStyles, @@ -423,7 +425,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(() => { @@ -449,6 +451,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]), + ); const onViewableItemsChanged = useRef(({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { const newIndex = viewableItems.at(0)?.index; @@ -829,6 +872,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 2e73a191d4c79..371774f63516a 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(); @@ -221,10 +224,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} @@ -235,7 +245,7 @@ function TransactionPreviewContent({ shouldDisableOpacity={isDeleted} shouldHideOnDelete={shouldHideOnDelete} > - + - + ); } diff --git a/src/components/ReportActionItem/TransactionPreview/index.tsx b/src/components/ReportActionItem/TransactionPreview/index.tsx index 86f58da2c425f..0965dfda77966 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; @@ -128,6 +129,7 @@ function TransactionPreview(props: TransactionPreviewProps) { walletTermsErrors={walletTerms?.errors} routeName={route.name} isReviewDuplicateTransactionPage={isReviewDuplicateTransactionPage} + shouldHighlight={shouldHighlight} /> ); @@ -152,6 +154,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 886dcf25a185e..2a94f87012611 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 158b6fa667c03..bfe03956dc2e9 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 e94d9a270788a..26528ed0c9ee3 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4262,6 +4262,10 @@ const staticStyles = (theme: ThemeColors) => backgroundColor: theme.cardBG, }, + reportPreviewBoxHoverBorderColor: { + borderColor: theme.cardBG, + }, + reportContainerBorderRadius: { borderRadius: variables.componentBorderRadiusLarge, }, From 940adc55d2b1bd354f7d388d97a9a544f52b09c7 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 5 Feb 2026 00:59:43 +0300 Subject: [PATCH 2/4] avoid highlight if screen not focused --- .../ReportActionItem/MoneyRequestReportPreview/index.tsx | 5 ++++- src/hooks/useNewTransactions.ts | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx index 5725b646c0bd0..7f5ad78d654f6 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx @@ -1,3 +1,4 @@ +import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, ListRenderItem} from 'react-native'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -123,7 +124,9 @@ function MoneyRequestReportPreview({ }, [iouReportID, isSmallScreenWidth]); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${chatReportID}`, {canBeMissing: true}); const newTransactions = useNewTransactions(reportMetadata?.hasOnceLoadedReportActions, transactions); - const newTransactionIDs = newTransactions.map((transaction) => transaction.transactionID); + const isFocused = useIsFocused(); + // We only want to highlight the new expenses if the screen is focused. + const newTransactionIDs = isFocused ? newTransactions.map((transaction) => transaction.transactionID) : []; const renderItem: ListRenderItem = ({item}) => ( !prevTransactions?.some((prevTransaction) => prevTransaction.transactionID === transaction.transactionID)); - // Depending only on transactions is enough because prevTransactions is a helper object. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [transactions]); + }, [transactions, prevTransactions]); // In case when we have loaded the report, but there were no transactions in it, then we need to explicitly set skipFirstTransactionsChange to false, as it will be not set in the useMemo above. useEffect(() => { From bd59c42371af18647cca9d5ace6e9cac27395734 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 5 Feb 2026 01:13:44 +0300 Subject: [PATCH 3/4] fix lint --- .../MoneyRequestReportPreviewContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 1baf0ddb92ede..deb2dc5b1c4a5 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -498,7 +498,7 @@ function MoneyRequestReportPreviewContent({ 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 + // eslint-disable-next-line react-hooks/exhaustive-deps }, [newTransactionIDs]), ); From 87088548c5d5da5c1f75bd9fe191de319680cf5e Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 5 Feb 2026 01:28:46 +0300 Subject: [PATCH 4/4] minor fixes --- .../MoneyRequestReportPreviewContent.tsx | 8 +++++--- .../ReportActionItem/MoneyRequestReportPreview/index.tsx | 4 ++-- .../ReportActionItem/MoneyRequestReportPreview/types.ts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index deb2dc5b1c4a5..1a97288b103ff 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -94,6 +94,8 @@ import EmptyMoneyRequestReportPreview from './EmptyMoneyRequestReportPreview'; import type {MoneyRequestReportPreviewContentProps} from './types'; const reportAttributesSelector = (c: OnyxEntry) => c?.reports; +const MAX_SCROLL_TO_INDEX_RETRIES = 5; +const SCROLL_TO_INDEX_RETRY_DELAY = 100; function MoneyRequestReportPreviewContent({ iouReportID, @@ -463,14 +465,14 @@ function MoneyRequestReportPreviewContent({ 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) { + if (numberOfScrollToIndexFailed.current >= MAX_SCROLL_TO_INDEX_RETRIES) { 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); + }, SCROLL_TO_INDEX_RETRY_DELAY); numberOfScrollToIndexFailed.current++; }; @@ -482,7 +484,7 @@ function MoneyRequestReportPreviewContent({ useFocusEffect( useCallback(() => { - const index = carouselTransactions.findIndex((transaction) => newTransactionIDs?.includes(transaction.transactionID)); + const index = carouselTransactions.findIndex((transaction) => newTransactionIDs?.has(transaction.transactionID)); if (index < 0) { return; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx index 7f5ad78d654f6..6163cab1eddd6 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx @@ -126,7 +126,7 @@ function MoneyRequestReportPreview({ const newTransactions = useNewTransactions(reportMetadata?.hasOnceLoadedReportActions, transactions); const isFocused = useIsFocused(); // We only want to highlight the new expenses if the screen is focused. - const newTransactionIDs = isFocused ? newTransactions.map((transaction) => transaction.transactionID) : []; + const newTransactionIDs = isFocused ? new Set(newTransactions.map((transaction) => transaction.transactionID)) : undefined; const renderItem: ListRenderItem = ({item}) => ( ); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts index 326e1779f4352..56ab408fa0eca 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts @@ -105,7 +105,7 @@ type MoneyRequestReportPreviewContentProps = MoneyRequestReportPreviewContentOny onPress: () => void; /** IDs of newly added transactions */ - newTransactionIDs?: string[]; + newTransactionIDs?: Set; }; export type {MoneyRequestReportPreviewContentProps, MoneyRequestReportPreviewProps, MoneyRequestReportPreviewStyleType};