diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index c10fb9e875b02..c359ab10f1bb2 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -5,7 +5,6 @@ import {FlashList} from '@shopify/flash-list'; import type {ReactElement} from 'react'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type {BlockingViewProps} from '@components/BlockingViews/BlockingView'; import BlockingView from '@components/BlockingViews/BlockingView'; import Icon from '@components/Icon'; @@ -24,8 +23,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; -import {getLastVisibleActionIncludingTransactionThread, getOriginalMessage, isActionableTrackExpense, isInviteOrRemovedAction} from '@libs/ReportActionsUtils'; -import {canUserPerformWriteAction as canUserPerformWriteActionUtil} from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -50,15 +47,11 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const reportAttributes = useReportAttributes(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); - const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const [policy] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); - const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [isFullscreenVisible] = useOnyx(ONYXKEYS.FULLSCREEN_VISIBILITY); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const theme = useTheme(); @@ -160,12 +153,8 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const renderItem = useCallback( ({item, index}: RenderItemProps): ReactElement => { const reportID = item.reportID; - const itemReportAttributes = reportAttributes?.[reportID]; const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`]; const itemReportNameValuePairs = reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; - const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`]; - const itemParentReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${item?.parentReportID}`]; - const itemParentReportAction = item?.parentReportActionID ? itemParentReportActions?.[item?.parentReportActionID] : undefined; let invoiceReceiverPolicyID = '-1'; if (item?.invoiceReceiver && 'policyID' in item.invoiceReceiver) { @@ -177,54 +166,19 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`]; const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item?.policyID}`]; - const hasDraftComment = - !!draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] && - !draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]?.match(CONST.REGEX.EMPTY_COMMENT); - - const isReportArchived = !!itemReportNameValuePairs?.private_isArchived; - const canUserPerformWrite = canUserPerformWriteActionUtil(item, isReportArchived); - - const lastAction = getLastVisibleActionIncludingTransactionThread( - reportID, - canUserPerformWrite, - reportActions, - visibleReportActionsData, - itemOneTransactionThreadReport?.reportID, - ); - - // Only override lastMessageTextFromReport when a track expense whisper's transaction has been deleted, to prevent showing stale text. - let lastMessageTextFromReport: string | undefined; - if (isActionableTrackExpense(lastAction)) { - const whisperTransactionID = getOriginalMessage(lastAction)?.transactionID; - if (whisperTransactionID && !transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${whisperTransactionID}`]) { - lastMessageTextFromReport = ''; - } - } const shouldShowRBRorGBRTooltip = firstReportIDWithGBRorRBR === reportID; - let lastActionReport: OnyxEntry | undefined; - if (isInviteOrRemovedAction(lastAction)) { - const lastActionOriginalMessage = lastAction?.actionName ? getOriginalMessage(lastAction) : null; - lastActionReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${lastActionOriginalMessage?.reportID}`]; - } - return ( ); }, [ - reportAttributes, reports, reportNameValuePairs, - reportActions, policy, - transactions, - draftComments, personalDetails, firstReportIDWithGBRorRBR, isFullscreenVisible, @@ -263,45 +210,13 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio isScreenFocused, localeCompare, translate, - visibleReportActionsData, currentUserAccountID, ], ); const extraData = useMemo( - () => [ - reportActions, - reportAttributes, - reports, - reportAttributes, - reportNameValuePairs, - policy, - personalDetails, - data.length, - optionMode, - transactions, - draftComments, - isOffline, - isScreenFocused, - isReportsSplitNavigatorLast, - visibleReportActionsData, - ], - [ - reportActions, - reportAttributes, - reports, - reportNameValuePairs, - policy, - personalDetails, - data.length, - optionMode, - transactions, - draftComments, - isOffline, - isScreenFocused, - isReportsSplitNavigatorLast, - visibleReportActionsData, - ], + () => [reports, reportNameValuePairs, policy, personalDetails, data.length, optionMode, isOffline, isScreenFocused, isReportsSplitNavigatorLast], + [reports, reportNameValuePairs, policy, personalDetails, data.length, optionMode, isOffline, isScreenFocused, isReportsSplitNavigatorLast], ); const previousOptionMode = usePrevious(optionMode); @@ -356,14 +271,13 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio if (shouldShowEmptyLHN) { Log.info('Woohoo! All caught up. Was rendered', false, { reportsCount: Object.keys(reports ?? {}).length, - reportActionsCount: Object.keys(reportActions ?? {}).length, policyCount: Object.keys(policy ?? {}).length, personalDetailsCount: Object.keys(personalDetails ?? {}).length, route, reportsIDsFromUseReportsCount: data.length, }); } - }, [data.length, shouldShowEmptyLHN, route, reports, reportActions, policy, personalDetails]); + }, [data.length, shouldShowEmptyLHN, route, reports, policy, personalDetails]); return ( diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index 32b2417010847..fa0c33618a99f 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -1,14 +1,20 @@ -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; import useReportPreviewSenderID from '@components/ReportActionAvatars/useReportPreviewSenderID'; import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useGetExpensifyCardFromReportAction from '@hooks/useGetExpensifyCardFromReportAction'; import useOnyx from '@hooks/useOnyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getIOUReportIDOfLastAction} from '@libs/OptionsListUtils'; +import {getLastVisibleActionIncludingTransactionThread, getOriginalMessage, isActionableTrackExpense, isInviteOrRemovedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {canUserPerformWriteAction as canUserPerformWriteActionUtil} from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import {getMovedReportID} from '@src/libs/ModifiedExpenseMessage'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportActions as ReportActionsType, VisibleReportActionsDerivedValue} from '@src/types/onyx'; +import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import OptionRowLHN from './OptionRowLHN'; import type {OptionRowLHNDataProps} from './types'; @@ -22,26 +28,109 @@ import type {OptionRowLHNDataProps} from './types'; function OptionRowLHNData({ isOptionFocused = false, fullReport, - reportAttributes, - reportAttributesDerived, - oneTransactionThreadReport, reportNameValuePairs, personalDetails = {}, policy, invoiceReceiverPolicy, - parentReportAction, - lastMessageTextFromReport, localeCompare, translate, - isReportArchived = false, - lastAction, - lastActionReport, currentUserAccountID, ...propsToForward }: OptionRowLHNDataProps) { const reportID = propsToForward.reportID; const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); const isReportFocused = isOptionFocused && currentReportIDValue === reportID; + // Per-item scoped subscriptions + const reportAttributesSelector = useCallback((data: ReportAttributesDerivedValue | undefined) => data?.reports?.[reportID], [reportID]); + const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportAttributesSelector}); + + const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); + const hasDraftComment = !!draftComment && !draftComment.match(CONST.REGEX.EMPTY_COMMENT); + + // Use the derived thread ID directly — available even when the child report object isn't hydrated yet + const oneTransactionThreadReportID = reportAttributes?.oneTransactionThreadReportID; + + // Full report object needed only for SidebarUtils.getOptionData + const [oneTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(oneTransactionThreadReportID)}`); + + // Per-item report actions subscriptions (scoped by specific report ID) + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(fullReport?.parentReportID)}`); + const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(oneTransactionThreadReportID)}`); + + // Scoped VISIBLE_REPORT_ACTIONS selector — only picks entries for this report and its transaction thread. + // Onyx uses deepEqual internally for selector output comparison, so creating a new object is fine. + const visibleActionsSelector = useCallback( + (data: VisibleReportActionsDerivedValue | undefined) => { + if (!data) { + return undefined; + } + const result: VisibleReportActionsDerivedValue = {}; + const reportEntry = data[reportID]; + if (reportEntry) { + result[reportID] = reportEntry; + } + if (oneTransactionThreadReportID) { + const txThreadEntry = data[oneTransactionThreadReportID]; + if (txThreadEntry) { + result[oneTransactionThreadReportID] = txThreadEntry; + } + } + return result; + }, + [reportID, oneTransactionThreadReportID], + ); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {selector: visibleActionsSelector}); + + const [reportNameValuePairsEntry] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); + + const parentReportAction = fullReport?.parentReportActionID ? parentReportActions?.[fullReport.parentReportActionID] : undefined; + + const transactionID = isMoneyRequestAction(parentReportAction) ? (getOriginalMessage(parentReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID !== CONST.DEFAULT_NUMBER_ID ? String(transactionID) : undefined)}`); + + const isReportArchived = !!(reportNameValuePairsEntry ?? reportNameValuePairs)?.private_isArchived; + const canUserPerformWrite = canUserPerformWriteActionUtil(fullReport, isReportArchived); + + const lastAction = useMemo(() => { + const actionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: reportActions ?? undefined, + }; + if (oneTransactionThreadReportID) { + actionsCollection[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneTransactionThreadReportID}`] = transactionThreadReportActions ?? undefined; + } + return getLastVisibleActionIncludingTransactionThread(reportID, canUserPerformWrite, actionsCollection, visibleReportActionsData, oneTransactionThreadReportID); + }, [reportID, canUserPerformWrite, reportActions, transactionThreadReportActions, visibleReportActionsData, oneTransactionThreadReportID]); + + const iouReportIDOfLastAction = useMemo( + () => getIOUReportIDOfLastAction(fullReport, (reportNameValuePairsEntry ?? reportNameValuePairs)?.private_isArchived, visibleReportActionsData, lastAction), + [fullReport, reportNameValuePairsEntry, reportNameValuePairs, visibleReportActionsData, lastAction], + ); + const [iouReportReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportIDOfLastAction)}`); + + const lastReportActionTransactionID = isMoneyRequestAction(lastAction) ? (getOriginalMessage(lastAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; + const [lastReportActionTransaction] = useOnyx( + `${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(lastReportActionTransactionID !== CONST.DEFAULT_NUMBER_ID ? String(lastReportActionTransactionID) : undefined)}`, + ); + + const whisperTransactionID = isActionableTrackExpense(lastAction) ? getOriginalMessage(lastAction)?.transactionID : undefined; + const [whisperTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(whisperTransactionID)}`); + + const lastMessageTextFromReport = useMemo(() => { + if (whisperTransactionID && !whisperTransaction) { + return ''; + } + return undefined; + }, [whisperTransactionID, whisperTransaction]); + + const lastActionReportID = useMemo(() => { + if (isInviteOrRemovedAction(lastAction)) { + const lastActionOriginalMessage = lastAction?.actionName ? getOriginalMessage(lastAction) : null; + return lastActionOriginalMessage?.reportID; + } + return undefined; + }, [lastAction]); + const [lastActionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(lastActionReportID ? String(lastActionReportID) : undefined)}`); const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`); @@ -59,34 +148,71 @@ function OptionRowLHNData({ chatReport: chatReportForIOU, }); - // React Compiler auto-memoizes each expression in OptionRowLHN independently, - // so there is no need to stabilize the optionItem reference with deepEqual. - // When getOptionData returns a fresh object with the same content, the Compiler - // ensures that only expressions whose inputs actually changed recompute. - const optionItem = SidebarUtils.getOptionData({ - report: fullReport, - reportAttributes, - oneTransactionThreadReport, - reportNameValuePairs, - personalDetails, - policy, - parentReportAction, - conciergeReportID, - lastMessageTextFromReport, - invoiceReceiverPolicy, - card, - lastAction, - translate, - localeCompare, - isReportArchived, - lastActionReport, - movedFromReport, - movedToReport, - currentUserAccountID, - reportAttributesDerived, - policyTags, - currentUserLogin: login ?? '', - }); + const reportAttributesDerived = useMemo(() => { + if (!reportAttributes) { + return undefined; + } + return {[reportID]: reportAttributes} as ReportAttributesDerivedValue['reports']; + }, [reportID, reportAttributes]); + + const optionItem = useMemo( + () => + SidebarUtils.getOptionData({ + report: fullReport, + reportAttributes, + oneTransactionThreadReport, + reportNameValuePairs, + personalDetails, + policy, + parentReportAction, + conciergeReportID, + lastMessageTextFromReport, + invoiceReceiverPolicy, + card, + lastAction, + translate, + localeCompare, + isReportArchived, + lastActionReport, + movedFromReport, + movedToReport, + currentUserAccountID, + reportAttributesDerived, + policyTags, + currentUserLogin: login ?? '', + }), + // These subscriptions don't appear in getOptionData params but trigger recomputation + // when the underlying data changes (e.g. transaction amount update, IOU report actions change). + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + fullReport, + reportAttributes, + oneTransactionThreadReport, + reportNameValuePairs, + personalDetails, + policy, + parentReportAction, + conciergeReportID, + lastMessageTextFromReport, + invoiceReceiverPolicy, + card, + lastAction, + translate, + localeCompare, + isReportArchived, + lastActionReport, + movedFromReport, + movedToReport, + currentUserAccountID, + reportAttributesDerived, + policyTags, + login, + transaction, + iouReportReportActions, + lastReportActionTransaction, + reportActions, + ], + ); // For single-sender IOUs, trim to the sender's avatar to match the header. // The header uses reportPreviewSenderID as accountID for its primary avatar, @@ -107,6 +233,7 @@ function OptionRowLHNData({ isOptionFocused={isReportFocused} optionItem={finalOptionItem} report={fullReport} + hasDraftComment={hasDraftComment} conciergeReportID={conciergeReportID} /> ); diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index f2eb64cefe861..eeafd63ccd5e2 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -5,8 +5,7 @@ import type {ValueOf} from 'type-fest'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import type CONST from '@src/CONST'; import type {OptionData} from '@src/libs/ReportUtils'; -import type {Onboarding, OnboardingPurpose, PersonalDetailsList, Policy, Report, ReportAction, ReportNameValuePairs} from '@src/types/onyx'; -import type {ReportAttributes, ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; +import type {Onboarding, OnboardingPurpose, PersonalDetailsList, Policy, Report, ReportNameValuePairs} from '@src/types/onyx'; type OptionMode = ValueOf; @@ -57,9 +56,6 @@ type OptionRowLHNDataProps = { /** The full data of the report */ fullReport: OnyxEntry; - /** The transaction thread report associated with the current report, if any */ - oneTransactionThreadReport: OnyxEntry; - /** Array of report name value pairs for this report */ reportNameValuePairs: OnyxEntry; @@ -69,33 +65,18 @@ type OptionRowLHNDataProps = { /** Invoice receiver policy */ invoiceReceiverPolicy?: OnyxEntry; - /** The action from the parent report */ - parentReportAction?: OnyxEntry; - - /** Whether a report contains a draft */ - hasDraftComment: boolean; - /** The reportID of the report */ reportID: string; /** Toggle between compact and default view */ viewMode?: OptionMode; - /** The last message text from the report */ - lastMessageTextFromReport?: string; - /** A function that is called when an option is selected. Selected option is passed as a param */ onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject) => void; /** Callback to execute when the OptionList lays out */ onLayout?: (event: LayoutChangeEvent) => void; - /** The report attributes for the report */ - reportAttributes: OnyxEntry; - - /** The derived report attributes for all reports */ - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; - /** Whether to show the educational tooltip for the GBR or RBR */ shouldShowRBRorGBRTooltip: boolean; @@ -111,14 +92,6 @@ type OptionRowLHNDataProps = { /** TestID of the row, indicating order */ testID: number; - /** Whether the report is archived */ - isReportArchived: boolean; - - /** The last action should be displayed */ - lastAction: ReportAction | undefined; - - lastActionReport: OnyxEntry | undefined; - /** The current user's account ID */ currentUserAccountID: number; };