diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 0707dd8d37bed..1a97288b103ff 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'; @@ -93,9 +94,12 @@ 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, + newTransactionIDs, chatReportID, action, containerStyles, @@ -432,7 +436,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(() => { @@ -458,6 +462,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 >= 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}); + }, SCROLL_TO_INDEX_RETRY_DELAY); + numberOfScrollToIndexFailed.current++; + }; + + const carouselTransactionsRef = useRef(carouselTransactions); + + useEffect(() => { + carouselTransactionsRef.current = carouselTransactions; + }, [carouselTransactions]); + + useFocusEffect( + useCallback(() => { + const index = carouselTransactions.findIndex((transaction) => newTransactionIDs?.has(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-hooks/exhaustive-deps + }, [newTransactionIDs]), + ); const onViewableItemsChanged = useRef(({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { const newIndex = viewableItems.at(0)?.index; @@ -840,6 +885,7 @@ function MoneyRequestReportPreviewContent({ ) : ( transaction.transactionID)) : undefined; const renderItem: ListRenderItem = ({item}) => ( ); return ( void; + + /** IDs of newly added transactions */ + newTransactionIDs?: Set; }; export type {MoneyRequestReportPreviewContentProps, MoneyRequestReportPreviewProps, MoneyRequestReportPreviewStyleType}; diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 4dfc1e70ee99b..4b63f819d160a 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(); @@ -222,10 +225,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} @@ -236,7 +246,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 daefd011c87be..7218595d16ed9 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/hooks/useNewTransactions.ts b/src/hooks/useNewTransactions.ts index 2a10522991fc6..24a1e809c34ef 100644 --- a/src/hooks/useNewTransactions.ts +++ b/src/hooks/useNewTransactions.ts @@ -23,9 +23,7 @@ function useNewTransactions(hasOnceLoadedReportActions: boolean | undefined, tra return CONST.EMPTY_ARRAY as unknown as Transaction[]; } return transactions.filter((transaction) => !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(() => { diff --git a/src/styles/index.ts b/src/styles/index.ts index bc1bb2ce1217f..38fc24b271d5d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4345,6 +4345,10 @@ const staticStyles = (theme: ThemeColors) => backgroundColor: theme.cardBG, }, + reportPreviewBoxHoverBorderColor: { + borderColor: theme.cardBG, + }, + reportContainerBorderRadius: { borderRadius: variables.componentBorderRadiusLarge, },