From 44bbe9ca7efa2dd469980bc242b3ad4b4cafa343 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Tue, 5 May 2026 13:39:23 +0200 Subject: [PATCH 1/6] decompose ReportActionsList: 2 --- .../MoneyRequestReportActionsList.tsx | 2 +- src/hooks/useLoadReportActions.ts | 20 +-- src/hooks/useReportActionsPagination.ts | 141 +++++++++++++++ src/hooks/useTransactionThread.ts | 79 +++++++++ .../useTransactionsAndViolationsForReport.ts | 2 +- src/pages/inbox/report/ReportActionsView.tsx | 162 ++---------------- tests/ui/ReportActionsViewTest.tsx | 1 + tests/unit/useLoadReportActionsTest.tsx | 8 +- 8 files changed, 255 insertions(+), 160 deletions(-) create mode 100644 src/hooks/useReportActionsPagination.ts create mode 100644 src/hooks/useTransactionThread.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 34c7fad51de2..6c04d05cc5d9 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -216,7 +216,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) reportID, reportActions, allReportActionIDs: reportActionIDs, - transactionThreadReport, + transactionThreadReportID, hasOlderActions, hasNewerActions, newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index d46e3a0031c2..630d74707c37 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -1,9 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; -import type {OnyxEntry} from 'react-native-onyx'; import {getNewerActions, getOlderActions} from '@userActions/Report'; import CONST from '@src/CONST'; -import type {Report, ReportAction} from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ReportAction} from '@src/types/onyx'; import useNetwork from './useNetwork'; type UseLoadReportActionsArguments = { @@ -16,8 +14,8 @@ type UseLoadReportActionsArguments = { /** The IDs of all reportActions linked to the current report (may contain some extra actions) */ allReportActionIDs: string[]; - /** The transaction thread report associated with the current transaction, if any */ - transactionThreadReport: OnyxEntry; + /** The transaction thread report ID associated with the current transaction, if any */ + transactionThreadReportID: string | undefined; /** If the report has newer actions to load */ hasNewerActions: boolean; @@ -37,7 +35,7 @@ function useLoadReportActions({ reportID, reportActions, allReportActionIDs, - transactionThreadReport, + transactionThreadReportID, hasOlderActions, hasNewerActions, newestFetchedReportActionID, @@ -47,7 +45,7 @@ function useLoadReportActions({ const newestReportAction = reportActions?.at(0); const oldestReportAction = reportActions?.at(-1); - const isTransactionThreadReport = !isEmptyObject(transactionThreadReport); + const isTransactionThreadReport = !!transactionThreadReportID; let currentReportNewestAction = null; let currentReportOldestAction = null; @@ -59,7 +57,7 @@ function useLoadReportActions({ for (const action of reportActions) { // Determine which report this action belongs to const isCurrentReport = allReportActionIDsSet.has(action.reportActionID); - const targetReportID = isCurrentReport ? reportID : transactionThreadReport?.reportID; + const targetReportID = isCurrentReport ? reportID : transactionThreadReportID; // Track newest/oldest per report if (targetReportID === reportID) { @@ -69,7 +67,7 @@ function useLoadReportActions({ } // Oldest = last matching action we encounter currentReportOldestAction = action; - } else if (isTransactionThreadReport && transactionThreadReport?.reportID === targetReportID) { + } else if (isTransactionThreadReport && transactionThreadReportID === targetReportID) { // Same logic for transaction thread if (!transactionThreadNewestAction) { transactionThreadNewestAction = action; @@ -95,7 +93,7 @@ function useLoadReportActions({ if (isTransactionThreadReport) { getOlderActions(reportID, currentReportOldestAction?.reportActionID); - getOlderActions(transactionThreadReport?.reportID, transactionThreadOldestAction?.reportActionID); + getOlderActions(transactionThreadReportID, transactionThreadOldestAction?.reportActionID); } else { getOlderActions(reportID, currentReportOldestAction?.reportActionID); } @@ -124,7 +122,7 @@ function useLoadReportActions({ if (isTransactionThreadReport) { getNewerActions(reportID, currentReportNewestAction?.reportActionID); - getNewerActions(transactionThreadReport.reportID, transactionThreadNewestAction?.reportActionID); + getNewerActions(transactionThreadReportID, transactionThreadNewestAction?.reportActionID); } else if (newestReportAction) { getNewerActions(reportID, newestReportAction.reportActionID); } diff --git a/src/hooks/useReportActionsPagination.ts b/src/hooks/useReportActionsPagination.ts new file mode 100644 index 000000000000..189a4cf963e2 --- /dev/null +++ b/src/hooks/useReportActionsPagination.ts @@ -0,0 +1,141 @@ +import {useRoute} from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {getReportPreviewAction} from '@libs/actions/IOU'; +import DateUtils from '@libs/DateUtils'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; +import {rand64} from '@libs/NumberUtils'; +import {getCombinedReportActions, getFilteredReportActionsForReportView, getOriginalMessage, isCreatedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import { + buildOptimisticCreatedReportAction, + buildOptimisticIOUReportAction, + isConciergeChatReport, + isInvoiceReport, + isMoneyRequestReport, + isReportTransactionThread as isReportTransactionThreadUtil, +} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {Report, ReportAction} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import useIsInSidePanel from './useIsInSidePanel'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePaginatedReportActions from './usePaginatedReportActions'; +import useTransactionThread from './useTransactionThread'; + +type UseReportActionsPaginationResult = { + reportActions: ReportAction[]; + allReportActions: ReportAction[]; + allReportActionIDs: string[]; + hasOlderActions: boolean; + hasNewerActions: boolean; + reportActionID: string | undefined; + transactionThreadReportID: string | undefined; + transactionThreadReport: OnyxEntry; + parentReportActionForTransactionThread: ReportAction | undefined; + shouldAddCreatedAction: boolean; +}; + +function useReportActionsPagination(reportID: string | undefined): UseReportActionsPaginationResult { + const route = useRoute>(); + const reportActionID = route?.params?.reportActionID; + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {isOffline} = useNetwork(); + + const {reportActions: unfilteredReportActions, hasOlderActions, hasNewerActions} = usePaginatedReportActions(reportID, reportActionID); + const allReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const thread = useTransactionThread({reportID, report, allReportActions, isOffline}); + + const isInSidePanel = useIsInSidePanel(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); + + const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); + const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; + + const isReportTransactionThread = isReportTransactionThreadUtil(report); + const isInitiallyLoadingTransactionThread = isReportTransactionThread && (!!isLoadingInitialReportActions || (allReportActions ?? [])?.length <= 1); + + const lastAction = allReportActions?.at(-1); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldAddCreatedAction = !isCreatedAction(lastAction) && (isMoneyRequestReport(report) || isInvoiceReport(report) || isInitiallyLoadingTransactionThread || isConciergeSidePanel); + + const reportPreviewAction = getReportPreviewAction(report?.chatReportID, report?.reportID); + + // When we are offline before opening an IOU/Expense report, + // the total of the report and sometimes the expense aren't displayed because these actions aren't returned until `OpenReport` API is complete. + // We generate a fake created action here if it doesn't exist to display the total whenever possible because the total just depends on report data + // and we also generate an expense action if the number of expenses in allReportActions is less than the total number of expenses + // to display at least one expense action to match the total data. + let reportActionsToDisplay: ReportAction[]; + const actions = [...(allReportActions ?? [])]; + + if (shouldAddCreatedAction) { + const createdTime = lastAction?.created && DateUtils.subtractMillisecondsFromDateTime(lastAction.created, 1); + const optimisticCreatedAction = buildOptimisticCreatedReportAction(String(report?.ownerAccountID), createdTime); + optimisticCreatedAction.pendingAction = null; + actions.push(optimisticCreatedAction); + } + + if (!isMoneyRequestReport(report) || !allReportActions?.length) { + reportActionsToDisplay = actions; + } else { + const moneyRequestActions = allReportActions.filter((action) => { + const originalMessage = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; + return ( + isMoneyRequestAction(action) && + originalMessage && + (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || + !!(originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && originalMessage?.IOUDetails) || + originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) + ); + }); + + if (report?.total && moneyRequestActions.length < (reportPreviewAction?.childMoneyRequestCount ?? 0) && isEmptyObject(thread.transactionThreadReport)) { + const optimisticIOUAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 0, + currency: CONST.CURRENCY.USD, + comment: '', + participants: [], + transactionID: rand64(), + iouReportID: report?.reportID, + created: DateUtils.subtractMillisecondsFromDateTime(actions.at(-1)?.created ?? '', 1), + }) as ReportAction; + moneyRequestActions.push(optimisticIOUAction); + actions.splice(actions.length - 1, 0, optimisticIOUAction); + } + + // Update pending action of created action if we have some requests that are pending + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const createdAction = actions.pop()!; + if (moneyRequestActions.filter((action) => !!action.pendingAction).length > 0) { + createdAction.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + } + + reportActionsToDisplay = [...actions, createdAction]; + } + + const reportActions = getCombinedReportActions(reportActionsToDisplay, thread.transactionThreadReportID ?? null, thread.transactionThreadReportActions ?? []); + + const allReportActionIDs = allReportActions.map((action) => action.reportActionID); + + return { + reportActions, + allReportActions, + allReportActionIDs, + hasOlderActions, + hasNewerActions, + reportActionID, + transactionThreadReportID: thread.transactionThreadReportID, + transactionThreadReport: thread.transactionThreadReport, + parentReportActionForTransactionThread: thread.parentReportActionForTransactionThread, + shouldAddCreatedAction, + }; +} + +export default useReportActionsPagination; diff --git a/src/hooks/useTransactionThread.ts b/src/hooks/useTransactionThread.ts new file mode 100644 index 000000000000..16a9074155c9 --- /dev/null +++ b/src/hooks/useTransactionThread.ts @@ -0,0 +1,79 @@ +import {useCallback} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getOneTransactionThreadReportID, getSortedReportActionsForDisplay} from '@libs/ReportActionsUtils'; +import {canUserPerformWriteAction} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import useOnyx from './useOnyx'; +import useReportIsArchived from './useReportIsArchived'; +import useReportTransactionsCollection from './useReportTransactionsCollection'; + +type UseTransactionThreadParams = { + reportID: string | undefined; + report: OnyxEntry; + allReportActions: ReportAction[]; + isOffline: boolean; +}; + +type UseTransactionThreadResult = { + transactionThreadReportID: string | undefined; + transactionThreadReportActions: ReportAction[] | undefined; + transactionThreadReport: OnyxEntry; + parentReportActionForTransactionThread: ReportAction | undefined; +}; + +function selectTransactionThreadReportActions( + canPerformWriteAction: boolean, + transactionThreadReportID: string | undefined, + reportActions: OnyxEntry | undefined, +): ReportAction[] { + return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined); +} + +function useTransactionThread({reportID, report, allReportActions, isOffline}: UseTransactionThreadParams): UseTransactionThreadResult { + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + + const allReportTransactions = useReportTransactionsCollection(reportID); + + const reportTransactionsForThreadID = getAllNonDeletedTransactions(allReportTransactions, allReportActions ?? [], isOffline, true); + + const visibleTransactionsForThreadID = reportTransactionsForThreadID?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + const reportTransactionIDsForThread = visibleTransactionsForThreadID?.map((t) => t.transactionID); + + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, allReportActions ?? [], isOffline, reportTransactionIDsForThread); + + const isReportArchived = useReportIsArchived(reportID); + const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); + + const getTransactionThreadReportActions = useCallback( + (reportActions: OnyxEntry): ReportAction[] => selectTransactionThreadReportActions(!!canPerformWriteAction, transactionThreadReportID, reportActions), + [canPerformWriteAction, transactionThreadReportID], + ); + + const [transactionThreadReportActions] = useOnyx( + `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + { + selector: getTransactionThreadReportActions, + }, + [getTransactionThreadReportActions], + ); + + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); + + const parentReportActionForTransactionThread = isEmptyObject(transactionThreadReportActions) + ? undefined + : allReportActions?.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID); + + return { + transactionThreadReportID, + transactionThreadReportActions, + transactionThreadReport, + parentReportActionForTransactionThread, + }; +} + +export default useTransactionThread; diff --git a/src/hooks/useTransactionsAndViolationsForReport.ts b/src/hooks/useTransactionsAndViolationsForReport.ts index ae2a4397cb05..7e5e9fdbb8cd 100644 --- a/src/hooks/useTransactionsAndViolationsForReport.ts +++ b/src/hooks/useTransactionsAndViolationsForReport.ts @@ -23,7 +23,7 @@ function useTransactionsAndViolationsForReport(reportID?: string) { filteredViolations[transactionViolationKey] = getTransactionViolations(transaction, violations, currentUserDetails.email ?? '', currentUserDetails.accountID, report, policy) ?? []; } - return {transactions, violations: filteredViolations}; + return {transactions, violations: filteredViolations, isLoaded: allReportsTransactionsAndViolations !== undefined}; } export default useTransactionsAndViolationsForReport; diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index a4a02894fc57..e4a200fd1c32 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -1,5 +1,4 @@ -import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useMemo, useRef} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSidePanelReportActions from '@hooks/useConciergeSidePanelReportActions'; @@ -10,48 +9,22 @@ import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import usePrevious from '@hooks/usePrevious'; +import useReportActionsPagination from '@hooks/useReportActionsPagination'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useSidePanelState from '@hooks/useSidePanelState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {getReportPreviewAction} from '@libs/actions/IOU'; import {updateLoadingInitialReportAction} from '@libs/actions/Report'; -import DateUtils from '@libs/DateUtils'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; -import {generateNewRandomInt, rand64} from '@libs/NumberUtils'; -import { - getCombinedReportActions, - getFilteredReportActionsForReportView, - getOneTransactionThreadReportID, - getOriginalMessage, - getSortedReportActionsForDisplay, - isCreatedAction, - isDeletedParentAction, - isIOUActionMatchingTransactionList, - isMoneyRequestAction, - isReportActionVisible, -} from '@libs/ReportActionsUtils'; -import { - buildOptimisticCreatedReportAction, - buildOptimisticIOUReportAction, - canUserPerformWriteAction, - isConciergeChatReport, - isInvoiceReport, - isMoneyRequestReport, - isReportTransactionThread as isReportTransactionThreadUtil, -} from '@libs/ReportUtils'; +import {generateNewRandomInt} from '@libs/NumberUtils'; +import {isCreatedAction, isDeletedParentAction, isIOUActionMatchingTransactionList, isReportActionVisible} from '@libs/ReportActionsUtils'; +import {canUserPerformWriteAction, isConciergeChatReport, isReportTransactionThread as isReportTransactionThreadUtil} from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import ReportActionsList from './ReportActionsList'; import UserTypingEventListener from './UserTypingEventListener'; @@ -70,15 +43,23 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { useCopySelectionHelper(); const {translate} = useLocalize(); usePendingConciergeResponse(reportID); - const route = useRoute>(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const reportActionID = route?.params?.reportActionID; - const {reportActions: unfilteredReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionID); - const allReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); + const { + reportActions, + allReportActions, + allReportActionIDs, + hasOlderActions, + hasNewerActions, + reportActionID, + transactionThreadReportID, + transactionThreadReport, + parentReportActionForTransactionThread, + shouldAddCreatedAction, + } = useReportActionsPagination(reportID); const parentReportAction = useParentReportAction(report); @@ -101,40 +82,9 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const isReportTransactionThread = isReportTransactionThreadUtil(report); - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); - const allReportTransactions = useReportTransactionsCollection(reportID); - const reportTransactionsForThreadID = useMemo( - () => getAllNonDeletedTransactions(allReportTransactions, allReportActions ?? [], isOffline, true), - [allReportTransactions, allReportActions, isOffline], - ); - const visibleTransactionsForThreadID = useMemo( - () => reportTransactionsForThreadID?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), - [reportTransactionsForThreadID, isOffline], - ); - const reportTransactionIDsForThread = useMemo(() => visibleTransactionsForThreadID?.map((t) => t.transactionID), [visibleTransactionsForThreadID]); - const transactionThreadReportID = useMemo( - () => getOneTransactionThreadReportID(report, chatReport, allReportActions ?? [], isOffline, reportTransactionIDsForThread), - [report, chatReport, allReportActions, isOffline, reportTransactionIDsForThread], - ); - const isReportArchived = useReportIsArchived(reportID); const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); - const getTransactionThreadReportActions = useCallback( - (reportActions: OnyxTypes.ReportActions | undefined): OnyxTypes.ReportAction[] => { - return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined); - }, - [canPerformWriteAction, transactionThreadReportID], - ); - - const [transactionThreadReportActions] = useOnyx( - `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - { - selector: getTransactionThreadReportActions, - }, - [getTransactionThreadReportActions], - ); - const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const prevReportActionID = usePrevious(reportActionID); @@ -147,11 +97,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { [reportTransactions, allReportActions], ); - const lastAction = allReportActions?.at(-1); - const isInitiallyLoadingTransactionThread = isReportTransactionThread && (!!isLoadingInitialReportActions || (allReportActions ?? [])?.length <= 1); - - const shouldAddCreatedAction = !isCreatedAction(lastAction) && (isMoneyRequestReport(report) || isInvoiceReport(report) || isInitiallyLoadingTransactionThread || isConciergeSidePanel); - useEffect(() => { // When we linked to message - we do not need to wait for initial actions - they already exists if (!reportActionID || !isOffline) { @@ -171,74 +116,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { return newID; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [route, reportActionID]); - - // When we are offline before opening an IOU/Expense report, - // the total of the report and sometimes the expense aren't displayed because these actions aren't returned until `OpenReport` API is complete. - // We generate a fake created action here if it doesn't exist to display the total whenever possible because the total just depends on report data - // and we also generate an expense action if the number of expenses in allReportActions is less than the total number of expenses - // to display at least one expense action to match the total data. - const reportActionsToDisplay = useMemo(() => { - const actions = [...(allReportActions ?? [])]; - - if (shouldAddCreatedAction) { - const createdTime = lastAction?.created && DateUtils.subtractMillisecondsFromDateTime(lastAction.created, 1); - const optimisticCreatedAction = buildOptimisticCreatedReportAction(String(report?.ownerAccountID), createdTime); - optimisticCreatedAction.pendingAction = null; - actions.push(optimisticCreatedAction); - } - - if (!isMoneyRequestReport(report) || !allReportActions?.length) { - return actions; - } - - const moneyRequestActions = allReportActions.filter((action) => { - const originalMessage = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; - return ( - isMoneyRequestAction(action) && - originalMessage && - (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || - !!(originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && originalMessage?.IOUDetails) || - originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) - ); - }); - - if (report?.total && moneyRequestActions.length < (reportPreviewAction?.childMoneyRequestCount ?? 0) && isEmptyObject(transactionThreadReport)) { - const optimisticIOUAction = buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: 0, - currency: CONST.CURRENCY.USD, - comment: '', - participants: [], - transactionID: rand64(), - iouReportID: report?.reportID, - created: DateUtils.subtractMillisecondsFromDateTime(actions.at(-1)?.created ?? '', 1), - }) as OnyxTypes.ReportAction; - moneyRequestActions.push(optimisticIOUAction); - actions.splice(actions.length - 1, 0, optimisticIOUAction); - } - - // Update pending action of created action if we have some requests that are pending - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const createdAction = actions.pop()!; - if (moneyRequestActions.filter((action) => !!action.pendingAction).length > 0) { - createdAction.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; - } - - return [...actions, createdAction]; - }, [allReportActions, shouldAddCreatedAction, report, reportPreviewAction?.childMoneyRequestCount, transactionThreadReport, lastAction?.created]); - - // Get a sorted array of reportActions for both the current report and the transaction thread report associated with this report (if there is one) - // so that we display transaction-level and report-level report actions in order in the one-transaction view - const reportActions = useMemo( - () => (reportActionsToDisplay ? getCombinedReportActions(reportActionsToDisplay, transactionThreadReportID ?? null, transactionThreadReportActions ?? []) : []), - [reportActionsToDisplay, transactionThreadReportActions, transactionThreadReportID], - ); - - const parentReportActionForTransactionThread = useMemo( - () => (isEmptyObject(transactionThreadReportActions) ? undefined : allReportActions?.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID)), - [allReportActions, transactionThreadReportActions, transactionThreadReport?.parentReportActionID], - ); + }, [reportActionID]); const visibleReportActions = useMemo( () => @@ -269,13 +147,11 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const isReportDataIncomplete = isSingleExpenseReport && isMissingTransactionThreadReportID; const isMissingReportActions = visibleReportActions.length === 0; - const allReportActionIDs = useMemo(() => allReportActions?.map((action) => action.reportActionID) ?? [], [allReportActions]); - const {loadOlderChats, loadNewerChats} = useLoadReportActions({ reportID, reportActions, allReportActionIDs, - transactionThreadReport, + transactionThreadReportID, hasOlderActions, hasNewerActions, }); diff --git a/tests/ui/ReportActionsViewTest.tsx b/tests/ui/ReportActionsViewTest.tsx index c4980df1f1a2..4c378db22605 100644 --- a/tests/ui/ReportActionsViewTest.tsx +++ b/tests/ui/ReportActionsViewTest.tsx @@ -182,6 +182,7 @@ describe('ReportActionsView', () => { mockUseTransactionsAndViolationsForReport.mockReturnValue({ transactions: {}, violations: {}, + isLoaded: true, }); mockUsePaginatedReportActions.mockReturnValue({ diff --git a/tests/unit/useLoadReportActionsTest.tsx b/tests/unit/useLoadReportActionsTest.tsx index 871d57c808a1..81282799fde7 100644 --- a/tests/unit/useLoadReportActionsTest.tsx +++ b/tests/unit/useLoadReportActionsTest.tsx @@ -32,7 +32,7 @@ describe('useLoadReportActions', () => { /* your 4 reportActions array here */ ], allReportActionIDs: ['8759152536123291182', '2034215190990675144', '186758379215594799'], - transactionThreadReport: undefined, + transactionThreadReportID: undefined, hasOlderActions: true, hasNewerActions: false, }; @@ -65,7 +65,7 @@ describe('useLoadReportActions', () => { const propsWithTransaction = { ...baseProps, - transactionThreadReport: {reportID: '186758379215594798'}, + transactionThreadReportID: '186758379215594798', allReportActionIDs: ['8759152536123291182'], // Only first action belongs to main report }; @@ -104,7 +104,7 @@ describe('useLoadReportActions', () => { })); const props = { ...baseProps, - transactionThreadReport: {reportID: 'TRANSACTION_THREAD_REPORT'}, + transactionThreadReportID: 'TRANSACTION_THREAD_REPORT', }; const {result} = renderHook(() => useLoadReportActions(props)); @@ -145,7 +145,7 @@ describe('useLoadReportActions', () => { })); const props = { ...baseProps, - transactionThreadReport: {reportID: 'TRANSACTION_THREAD_REPORT'}, + transactionThreadReportID: 'TRANSACTION_THREAD_REPORT', hasNewerActions: true, }; From e9dae3f470662fe187f258efbe53c2b4391de3c2 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 6 May 2026 11:24:15 +0200 Subject: [PATCH 2/6] Exclude FAKE_REPORT_ID from thread pagination guard --- src/hooks/useLoadReportActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 630d74707c37..519abeafe2de 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -45,7 +45,7 @@ function useLoadReportActions({ const newestReportAction = reportActions?.at(0); const oldestReportAction = reportActions?.at(-1); - const isTransactionThreadReport = !!transactionThreadReportID; + const isTransactionThreadReport = !!transactionThreadReportID && transactionThreadReportID !== CONST.FAKE_REPORT_ID; let currentReportNewestAction = null; let currentReportOldestAction = null; From 3ee002d7c552db133c6e6ddb38155c39491a2b6b Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 7 May 2026 10:48:28 +0200 Subject: [PATCH 3/6] lint: drop unused prefer-nullish-coalescing disable --- src/hooks/useReportActionsPagination.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useReportActionsPagination.ts b/src/hooks/useReportActionsPagination.ts index 75fa5ba7dd4e..4892733fb883 100644 --- a/src/hooks/useReportActionsPagination.ts +++ b/src/hooks/useReportActionsPagination.ts @@ -61,7 +61,6 @@ function useReportActionsPagination(reportID: string | undefined): UseReportActi const isInitiallyLoadingTransactionThread = isReportTransactionThread && (!!isLoadingInitialReportActions || (allReportActions ?? [])?.length <= 1); const lastAction = allReportActions?.at(-1); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const shouldAddCreatedAction = !isCreatedAction(lastAction) && (isMoneyRequestReport(report) || isInvoiceReport(report) || isInitiallyLoadingTransactionThread || isConciergeSidePanel); const reportPreviewAction = getReportPreviewAction(report?.chatReportID, report?.reportID); From ce346cf4ac8a9264b0899a9573f0219c97a08fb6 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 7 May 2026 11:14:58 +0200 Subject: [PATCH 4/6] fix import: getReportPreviewAction now lives in MoneyRequestBuilder --- src/hooks/useReportActionsPagination.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useReportActionsPagination.ts b/src/hooks/useReportActionsPagination.ts index 4892733fb883..fe3f16dc6f90 100644 --- a/src/hooks/useReportActionsPagination.ts +++ b/src/hooks/useReportActionsPagination.ts @@ -1,6 +1,6 @@ import {useRoute} from '@react-navigation/native'; import type {OnyxEntry} from 'react-native-onyx'; -import {getReportPreviewAction} from '@libs/actions/IOU'; +import {getReportPreviewAction} from '@libs/actions/IOU/MoneyRequestBuilder'; import DateUtils from '@libs/DateUtils'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; From d1d830a8402083501a3d023baebed8c49cb0e968 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 13 May 2026 12:19:02 +0200 Subject: [PATCH 5/6] address PR feedback: drop unused isLoaded, revert .claude jest ignore Co-Authored-By: Claude Opus 4.7 (1M context) --- jest.config.js | 2 +- src/hooks/useTransactionsAndViolationsForReport.ts | 2 +- tests/ui/ReportActionsViewTest.tsx | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/jest.config.js b/jest.config.js index 00c9d8e71fc8..2f7c9a810b21 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,7 +22,7 @@ module.exports = { // .worktrees/ holds parallel git worktrees a developer may check out locally. // Each one carries its own modules/hybrid-app/package.json, which trips // jest-haste-map's "duplicate package name" assertion. Skip them entirely. - modulePathIgnorePatterns: ['/.worktrees/', '/.claude/'], + modulePathIgnorePatterns: ['/.worktrees/'], globals: { __DEV__: true, WebSocket: {}, diff --git a/src/hooks/useTransactionsAndViolationsForReport.ts b/src/hooks/useTransactionsAndViolationsForReport.ts index 7e5e9fdbb8cd..ae2a4397cb05 100644 --- a/src/hooks/useTransactionsAndViolationsForReport.ts +++ b/src/hooks/useTransactionsAndViolationsForReport.ts @@ -23,7 +23,7 @@ function useTransactionsAndViolationsForReport(reportID?: string) { filteredViolations[transactionViolationKey] = getTransactionViolations(transaction, violations, currentUserDetails.email ?? '', currentUserDetails.accountID, report, policy) ?? []; } - return {transactions, violations: filteredViolations, isLoaded: allReportsTransactionsAndViolations !== undefined}; + return {transactions, violations: filteredViolations}; } export default useTransactionsAndViolationsForReport; diff --git a/tests/ui/ReportActionsViewTest.tsx b/tests/ui/ReportActionsViewTest.tsx index 4c378db22605..c4980df1f1a2 100644 --- a/tests/ui/ReportActionsViewTest.tsx +++ b/tests/ui/ReportActionsViewTest.tsx @@ -182,7 +182,6 @@ describe('ReportActionsView', () => { mockUseTransactionsAndViolationsForReport.mockReturnValue({ transactions: {}, violations: {}, - isLoaded: true, }); mockUsePaginatedReportActions.mockReturnValue({ From 6fd8bc2dd3fc34459d45c9060ec235d578cce387 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 14 May 2026 13:13:42 +0200 Subject: [PATCH 6/6] address PR feedback: drop useCallback wrapper in useTransactionThread --- src/hooks/useTransactionThread.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/hooks/useTransactionThread.ts b/src/hooks/useTransactionThread.ts index 16a9074155c9..d68ccb9f2312 100644 --- a/src/hooks/useTransactionThread.ts +++ b/src/hooks/useTransactionThread.ts @@ -1,4 +1,3 @@ -import {useCallback} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import {getOneTransactionThreadReportID, getSortedReportActionsForDisplay} from '@libs/ReportActionsUtils'; @@ -49,17 +48,12 @@ function useTransactionThread({reportID, report, allReportActions, isOffline}: U const isReportArchived = useReportIsArchived(reportID); const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); - const getTransactionThreadReportActions = useCallback( - (reportActions: OnyxEntry): ReportAction[] => selectTransactionThreadReportActions(!!canPerformWriteAction, transactionThreadReportID, reportActions), - [canPerformWriteAction, transactionThreadReportID], - ); - const [transactionThreadReportActions] = useOnyx( `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, { - selector: getTransactionThreadReportActions, + selector: (reportActions) => selectTransactionThreadReportActions(!!canPerformWriteAction, transactionThreadReportID, reportActions), }, - [getTransactionThreadReportActions], + [canPerformWriteAction, transactionThreadReportID], ); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`);