diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 6ebfa560797e..885a6efd1500 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1010,6 +1010,7 @@ const ONYXKEYS = { REPORT_ATTRIBUTES: 'reportAttributes', REPORT_TRANSACTIONS_AND_VIOLATIONS: 'reportTransactionsAndViolations', OUTSTANDING_REPORTS_BY_POLICY_ID: 'outstandingReportsByPolicyID', + VISIBLE_REPORT_ACTIONS: 'visibleReportActions', NON_PERSONAL_AND_WORKSPACE_CARD_LIST: 'nonPersonalAndWorkspaceCardList', PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList', CARD_FEED_ERRORS: 'cardFeedErrors', @@ -1431,6 +1432,7 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: OnyxTypes.ReportAttributesDerivedValue; [ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS]: OnyxTypes.ReportTransactionsAndViolationsDerivedValue; [ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID]: OnyxTypes.OutstandingReportsByPolicyIDDerivedValue; + [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: OnyxTypes.VisibleReportActionsDerivedValue; [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.NonPersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.PersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index 6dffd994d6a7..df0d08946c19 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -3,11 +3,11 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {Attachment} from '@components/Attachments/types'; import {getFileName, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; -import {getHtmlWithAttachmentID, getReportActionHtml, getReportActionMessage, getSortedReportActions, isMoneyRequestAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; +import {getHtmlWithAttachmentID, getReportActionHtml, getReportActionMessage, getSortedReportActions, isMoneyRequestAction, isReportActionVisible} from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction} from '@libs/ReportUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; -import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions, VisibleReportActionsDerivedValue} from '@src/types/onyx'; import type {Note} from '@src/types/onyx/Report'; /** @@ -22,6 +22,7 @@ function extractAttachments( reportActions, report, isReportArchived, + visibleReportActionsData, }: { privateNotes?: Record; accountID?: number; @@ -29,6 +30,7 @@ function extractAttachments( reportActions?: OnyxEntry; report: OnyxEntry; isReportArchived: boolean | undefined; + visibleReportActionsData?: VisibleReportActionsDerivedValue; }, ) { const targetNote = privateNotes?.[Number(accountID)]?.note ?? ''; @@ -115,9 +117,13 @@ function extractAttachments( return attachments.reverse(); } + const reportID = report?.reportID; + if (!reportID) { + return attachments.reverse(); + } const actions = [...(parentReportAction ? [parentReportAction] : []), ...getSortedReportActions(Object.values(reportActions ?? {}))]; - for (const [key, action] of actions.entries()) { - if (!shouldReportActionBeVisible(action, key, canUserPerformAction) || isMoneyRequestAction(action)) { + for (const action of actions) { + if (!isReportActionVisible(action, reportID, canUserPerformAction, visibleReportActionsData) || isMoneyRequestAction(action)) { continue; } diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index fd90762c894a..3f52c7b251de 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -16,7 +16,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; import useReportAttributes from '@hooks/useReportAttributes'; import useRootNavigationState from '@hooks/useRootNavigationState'; @@ -25,15 +24,14 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; -import {getMovedReportID} from '@libs/ModifiedExpenseMessage'; -import {getIOUReportIDOfLastAction, getLastMessageTextForReport} from '@libs/OptionsListUtils'; -import {findLastReportActions, getOriginalMessage, isInviteOrRemovedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getIOUReportIDOfLastAction} from '@libs/OptionsListUtils'; +import {getLastVisibleActionIncludingTransactionThread, getOriginalMessage, isActionableTrackExpense, isInviteOrRemovedAction, isMoneyRequestAction} 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'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Report} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import OptionRowLHNData from './OptionRowLHNData'; import OptionRowRendererComponent from './OptionRowRendererComponent'; @@ -53,7 +51,6 @@ 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 [reportMetadataCollection] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const [policy] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); @@ -64,8 +61,8 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio 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 {policyForMovingExpensesID} = usePolicyForMovingExpenses(); const theme = useTheme(); const styles = useThemeStyles(); @@ -184,9 +181,6 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio } const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`]; - const iouReportIDOfLastAction = getIOUReportIDOfLastAction(item); - const itemIouReportReportActions = iouReportIDOfLastAction ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportIDOfLastAction}`] : undefined; - const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item?.policyID}`]; const transactionID = isMoneyRequestAction(itemParentReportAction) ? (getOriginalMessage(itemParentReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) @@ -198,42 +192,29 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const isReportArchived = !!itemReportNameValuePairs?.private_isArchived; const canUserPerformWrite = canUserPerformWriteActionUtil(item, isReportArchived); - const {lastVisibleAction: lastReportAction, lastActionForDisplay: lastAction} = findLastReportActions(itemReportActions, canUserPerformWrite); - // Get the transaction for the last report action - const lastReportActionTransactionID = isMoneyRequestAction(lastReportAction) - ? (getOriginalMessage(lastReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) - : CONST.DEFAULT_NUMBER_ID; + const lastAction = getLastVisibleActionIncludingTransactionThread( + reportID, + canUserPerformWrite, + reportActions, + visibleReportActionsData, + itemOneTransactionThreadReport?.reportID, + ); + + const iouReportIDOfLastAction = getIOUReportIDOfLastAction(item, visibleReportActionsData, lastAction); + const itemIouReportReportActions = iouReportIDOfLastAction ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportIDOfLastAction}`] : undefined; + + const lastReportActionTransactionID = isMoneyRequestAction(lastAction) ? (getOriginalMessage(lastAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; const lastReportActionTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${lastReportActionTransactionID}`]; - // SidebarUtils.getOptionData in OptionRowLHNData does not get re-evaluated when the linked task report changes, so we have the lastMessageTextFromReport evaluation logic here - let lastActorDetails: Partial | null = item?.lastActorAccountID && personalDetails?.[item.lastActorAccountID] ? personalDetails[item.lastActorAccountID] : null; - if (!lastActorDetails && lastReportAction) { - const lastActorDisplayName = lastReportAction?.person?.[0]?.text; - lastActorDetails = lastActorDisplayName - ? { - displayName: lastActorDisplayName, - accountID: item?.lastActorAccountID, - } - : null; + // 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 movedFromReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastReportAction, CONST.REPORT.MOVE_TYPE.FROM)}`]; - const movedToReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastReportAction, CONST.REPORT.MOVE_TYPE.TO)}`]; - const chatReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item?.chatReportID}`]; - const itemReportMetadata = reportMetadataCollection?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`]; - const lastMessageTextFromReport = getLastMessageTextForReport({ - translate, - report: item, - lastActorDetails, - movedFromReport, - movedToReport, - policy: itemPolicy, - isReportArchived: !!itemReportNameValuePairs?.private_isArchived, - policyForMovingExpensesID, - reportMetadata: itemReportMetadata, - chatReport, - reportAttributesDerived: reportAttributes, - }); const shouldShowRBRorGBRTooltip = firstReportIDWithGBRorRBR === reportID; @@ -289,14 +270,12 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio reports, reportNameValuePairs, reportActions, - reportMetadataCollection, isOffline, reportAttributes, policy, transactions, draftComments, personalDetails, - policyForMovingExpensesID, firstReportIDWithGBRorRBR, optionMode, shouldDisableFocusOptions, @@ -312,6 +291,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio isScreenFocused, localeCompare, translate, + visibleReportActionsData, currentUserAccountID, ], ); @@ -333,6 +313,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio isOffline, isScreenFocused, isReportsSplitNavigatorLast, + visibleReportActionsData, ], [ reportActions, @@ -350,6 +331,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio isOffline, isScreenFocused, isReportsSplitNavigatorLast, + visibleReportActionsData, ], ); diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index b491d5bb8452..917f4023e5ef 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -52,7 +52,6 @@ function OptionRowLHNData({ 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)}`); - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${fullReport?.chatReportID}`); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); // Check the report errors equality to avoid re-rendering when there are no changes const prevReportErrors = usePrevious(reportAttributes?.reportErrors); @@ -82,7 +81,6 @@ function OptionRowLHNData({ movedFromReport, movedToReport, currentUserAccountID, - chatReport, reportAttributesDerived, }); if (deepEqual(item, optionItemRef.current)) { @@ -94,6 +92,7 @@ function OptionRowLHNData({ return item; // Listen parentReportAction to update title of thread report when parentReportAction changed // Listen to transaction to update title of transaction report when transaction changed + // Listen to lastAction to update when action is deleted or gets pendingAction // eslint-disable-next-line react-hooks/exhaustive-deps }, [ fullReport, @@ -115,13 +114,14 @@ function OptionRowLHNData({ invoiceReceiverPolicy, lastMessageTextFromReport, card, + lastAction, + lastActionReport, translate, localeCompare, isReportArchived, movedFromReport, movedToReport, currentUserAccountID, - chatReport, reportAttributesDerived, ]); diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index 515cf8081f6c..55db4a6e6e27 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -123,7 +123,7 @@ type OptionRowLHNDataProps = { viewMode?: OptionMode; /** The last message text from the report */ - lastMessageTextFromReport: string; + 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; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 92330bb39e53..2784f4892746 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -56,7 +56,7 @@ import { isCurrentActionUnread, isDeletedParentAction, isIOUActionMatchingTransactionList, - shouldReportActionBeVisible, + isReportActionVisible, wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction, chatIncludesChronosWithID, getOriginalReportID, getReportLastVisibleActionCreated, isHarvestCreatedExpenseReport, isUnread} from '@libs/ReportUtils'; @@ -186,6 +186,7 @@ function MoneyRequestReportActionsList({ const isReportArchived = useReportIsArchived(reportID); const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); @@ -315,17 +316,29 @@ function MoneyRequestReportActionsList({ const visibleReportActions = useMemo(() => { const filteredActions = reportActions.filter((reportAction) => { const isActionVisibleOnMoneyReport = isActionVisibleOnMoneyRequestReport(reportAction, shouldShowHarvestCreatedAction); + if (!isActionVisibleOnMoneyReport) { + return false; + } - return ( - isActionVisibleOnMoneyReport && - (isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && - shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction) && - isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs) - ); + const passesOfflineCheck = isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors; + if (!passesOfflineCheck) { + return false; + } + + const actionReportID = reportAction.reportID ?? reportID; + if (!isReportActionVisible(reportAction, actionReportID, canPerformWriteAction, visibleReportActionsData)) { + return false; + } + + if (!isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs)) { + return false; + } + + return true; }); return filteredActions.toReversed(); - }, [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, shouldShowHarvestCreatedAction]); + }, [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, shouldShowHarvestCreatedAction, visibleReportActionsData, reportID]); const reportActionSize = useRef(visibleReportActions.length); const lastAction = visibleReportActions.at(-1); diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index c79b353b3554..b62fe4be392f 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -13,7 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation from '@libs/Navigation/Navigation'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; -import {getReportAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; +import {getReportAction, isReportActionVisible} from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction as canUserPerformWriteActionReportUtils, isMoneyRequestReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams} from '@src/languages/params'; @@ -88,6 +88,7 @@ function ParentNavigationSubtitle({ const {translate} = useLocalize(); const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const isReportArchived = useReportIsArchived(report?.reportID); const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); const isReportInRHP = currentRoute.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT; @@ -116,7 +117,7 @@ function ParentNavigationSubtitle({ const onPress = () => { const parentAction = getReportAction(parentReportID, parentReportActionID); - const isVisibleAction = shouldReportActionBeVisible(parentAction, parentAction?.reportActionID ?? CONST.DEFAULT_NUMBER_ID, canUserPerformWriteAction); + const isVisibleAction = isReportActionVisible(parentAction, parentReportID, canUserPerformWriteAction, visibleReportActionsData); const focusedNavigatorState = currentFocusedNavigator?.state; const currentReportIndex = focusedNavigatorState?.index; diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index ff2881aab3cf..1af21e60b5f6 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -176,6 +176,7 @@ function SearchAutocompleteList({ const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; @@ -202,6 +203,7 @@ function SearchAutocompleteList({ shouldShowGBR: false, shouldUnreadBeBold: true, loginList, + visibleReportActionsData, currentUserAccountID, currentUserEmail, policyCollection: policies, @@ -460,6 +462,7 @@ function SearchAutocompleteList({ countryCode, loginList, shouldShowGBR: true, + visibleReportActionsData, policyCollection: policies, currentUserAccountID, currentUserEmail, @@ -495,6 +498,7 @@ function SearchAutocompleteList({ countryCode, loginList, shouldShowGBR: true, + visibleReportActionsData, policyCollection: policies, currentUserAccountID, currentUserEmail, diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index b445e63a4b82..9f2cea9fae5b 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -73,7 +73,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen createOptionFromReport({...reportData, reportID: id}, personalDetails, currentUserAccountID, chatReport, privateIsArchived, reportAttributesDerived), ); const isReportArchived = !!privateIsArchived; - const alternateText = getAlternateText(report, {}, isReportArchived, currentUserAccountID, {}, reportAttributesDerived); + const alternateText = getAlternateText(report, {}, isReportArchived, currentUserAccountID, {}, undefined, undefined, reportAttributesDerived); return {...report, alternateText}; }); diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 8b07c69e81eb..9472e4da1a9e 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -127,8 +127,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, recentAttendees: recentAttendeeLists, includeRecentReports: !shouldAllowNameOnlyOptions, personalDetails, + countryCode, }, - countryCode, ); }, [ areOptionsInitialized, diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index 2367796ef671..5a3f78b2e8c6 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -27,9 +27,9 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? const getSortedAllReportActionsSelector = useCallback( (allReportActions: OnyxEntry): ReportAction[] => { - return getSortedReportActionsForDisplay(allReportActions, hasWriteAccess, true); + return getSortedReportActionsForDisplay(allReportActions, hasWriteAccess, true, undefined, nonEmptyStringReportID); }, - [hasWriteAccess], + [hasWriteAccess, nonEmptyStringReportID], ); const [sortedAllReportActions] = useOnyx( diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 2ef787e971ae..76a4f6dbadaa 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -172,6 +172,7 @@ function useSearchSelectorBase({ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -208,122 +209,87 @@ function useSearchSelectorBase({ includeUserToInvite, countryCode, loginList, + visibleReportActionsData, policyCollection: allPolicies, currentUserAccountID, currentUserEmail, personalDetails, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE: - return getValidOptions( - optionsWithContacts, - allPolicies, - draftComments, - nvpDismissedProductTraining, - loginList, - currentUserAccountID, - currentUserEmail, - { - betas: betas ?? [], - includeP2P: true, - includeSelectedOptions: false, - excludeLogins, - excludeFromSuggestionsOnly, - includeRecentReports, - maxElements: maxResults, - maxRecentReportElements: maxRecentReportsToShow, - searchString: computedSearchTerm, - searchInputValue: trimmedSearchInput, - includeUserToInvite, - personalDetails, - }, + return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { + betas: betas ?? [], + includeP2P: true, + includeSelectedOptions: false, + excludeLogins, + excludeFromSuggestionsOnly, + includeRecentReports, + maxElements: maxResults, + maxRecentReportElements: maxRecentReportsToShow, + searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, + includeUserToInvite, + personalDetails, countryCode, - reportAttributesDerived?.reports, - ); + reportAttributesDerived: reportAttributesDerived?.reports, + }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: - return getValidOptions( - optionsWithContacts, - allPolicies, - draftComments, - nvpDismissedProductTraining, - loginList, - currentUserAccountID, - currentUserEmail, - { - ...getValidOptionsConfig, - betas: betas ?? [], - searchString: computedSearchTerm, - searchInputValue: trimmedSearchInput, - maxElements: maxResults, - maxRecentReportElements: maxRecentReportsToShow, - includeUserToInvite, - excludeLogins, - excludeFromSuggestionsOnly, - personalDetails, - }, + return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { + ...getValidOptionsConfig, + betas: betas ?? [], + searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, + maxElements: maxResults, + maxRecentReportElements: maxRecentReportsToShow, + includeUserToInvite, + excludeLogins, + excludeFromSuggestionsOnly, + personalDetails, countryCode, - reportAttributesDerived?.reports, - ); + reportAttributesDerived: reportAttributesDerived?.reports, + }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SHARE_DESTINATION: - return getValidOptions( - optionsWithContacts, - allPolicies, - draftComments, - nvpDismissedProductTraining, - loginList, - currentUserAccountID, - currentUserEmail, - { - betas, - selectedOptions, - includeMultipleParticipantReports: true, - showChatPreviewLine: true, - forcePolicyNamePreview: true, - includeThreads: true, - includeMoneyRequests: true, - includeTasks: true, - excludeLogins, - loginsToExclude: excludeLogins, - includeOwnedWorkspaceChats: true, - includeSelfDM: true, - searchString: computedSearchTerm, - searchInputValue: trimmedSearchInput, - maxElements: maxResults, - includeUserToInvite, - personalDetails, - }, + return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { + betas, + selectedOptions, + includeMultipleParticipantReports: true, + showChatPreviewLine: true, + forcePolicyNamePreview: true, + includeThreads: true, + includeMoneyRequests: true, + includeTasks: true, + excludeLogins, + loginsToExclude: excludeLogins, + includeOwnedWorkspaceChats: true, + includeSelfDM: true, + searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, + maxElements: maxResults, + includeUserToInvite, + personalDetails, countryCode, - reportAttributesDerived?.reports, - ); + reportAttributesDerived: reportAttributesDerived?.reports, + }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_ATTENDEES: - return getValidOptions( - optionsWithContacts, - allPolicies, - draftComments, - nvpDismissedProductTraining, - loginList, - currentUserAccountID, - currentUserEmail, - { - ...getValidOptionsConfig, - betas: betas ?? [], - includeP2P: true, - includeSelectedOptions: false, - excludeLogins, - excludeFromSuggestionsOnly, - loginsToExclude: excludeLogins, - includeRecentReports, - maxElements: maxResults, - maxRecentReportElements: maxRecentReportsToShow, - searchString: computedSearchTerm, - searchInputValue: trimmedSearchInput, - includeUserToInvite, - includeCurrentUser, - shouldAcceptName: true, - personalDetails, - }, + return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { + ...getValidOptionsConfig, + betas: betas ?? [], + includeP2P: true, + includeSelectedOptions: false, + excludeLogins, + excludeFromSuggestionsOnly, + loginsToExclude: excludeLogins, + includeRecentReports, + maxElements: maxResults, + maxRecentReportElements: maxRecentReportsToShow, + searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, + includeUserToInvite, + includeCurrentUser, + shouldAcceptName: true, + personalDetails, countryCode, - reportAttributesDerived?.reports, - ); + reportAttributesDerived: reportAttributesDerived?.reports, + }); default: return getEmptyOptions(); } @@ -331,6 +297,7 @@ function useSearchSelectorBase({ areOptionsInitialized, searchContext, optionsWithContacts, + allPolicies, draftComments, nvpDismissedProductTraining, betas, @@ -339,7 +306,6 @@ function useSearchSelectorBase({ includeUserToInvite, countryCode, loginList, - allPolicies, excludeLogins, excludeFromSuggestionsOnly, includeRecentReports, @@ -347,6 +313,7 @@ function useSearchSelectorBase({ getValidOptionsConfig, selectedOptions, includeCurrentUser, + visibleReportActionsData, currentUserAccountID, currentUserEmail, personalDetails, diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 186a2c728144..d21afff5e9e3 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -49,6 +49,8 @@ import { getInvoiceCompanyWebsiteUpdateMessage, getIOUReportIDFromReportActionPreview, getJoinRequestMessage, + getLastVisibleAction, + getLastVisibleActionIncludingTransactionThread, getLastVisibleMessage, getMarkedReimbursedMessage, getMentionedAccountIDsFromAction, @@ -76,6 +78,7 @@ import { isActionOfType, isAddCommentAction, isClosedAction, + isCreatedAction, isCreatedTaskReportAction, isDeletedParentAction, isDynamicExternalWorkflowApproveFailedAction, @@ -91,12 +94,11 @@ import { isReimbursementDeQueuedOrCanceledAction, isReimbursementQueuedAction, isRenamedAction, + isReportActionVisible, isReportPreviewAction, isTaskAction, isThreadParentMessage, isUnapprovedAction, - isWhisperAction, - shouldReportActionBeVisible, withDEWRoutedActionsArray, } from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; @@ -172,6 +174,7 @@ import type { ReportAttributesDerivedValue, ReportMetadata, ReportNameValuePairs, + VisibleReportActionsDerivedValue, } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -228,7 +231,6 @@ Onyx.connect({ const lastReportActions: ReportActions = {}; const allSortedReportActions: Record = {}; let allReportActions: OnyxCollection; -const lastVisibleReportActions: ReportActions = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, @@ -239,7 +241,7 @@ Onyx.connect({ allReportActions = actions ?? {}; - // Iterate over the report actions to build the sorted and lastVisible report actions objects + // Iterate over the report actions to build the sorted report actions objects for (const reportActions of Object.entries(allReportActions)) { const reportID = reportActions[0].split('_').at(1); if (!reportID) { @@ -266,26 +268,6 @@ Onyx.connect({ } else { lastReportActions[reportID] = firstReportAction; } - - const reportNameValuePairs = allReportNameValuePairsOnyxConnect?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; - const isReportArchived = !!reportNameValuePairs?.private_isArchived; - const isWriteActionAllowed = canUserPerformWriteAction(report, isReportArchived); - - // The report is only visible if it is the last action not deleted that - // does not match a closed or created state. - const reportActionsForDisplay = sortedReportActions.filter( - (reportAction, actionKey) => - (!(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) || isActionableMentionWhisper(reportAction)) && - shouldReportActionBeVisible(reportAction, actionKey, isWriteActionAllowed) && - reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && - reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ); - const reportActionForDisplay = reportActionsForDisplay.at(0); - if (!reportActionForDisplay) { - delete lastVisibleReportActions[reportID]; - continue; - } - lastVisibleReportActions[reportID] = reportActionForDisplay; } }, }); @@ -413,8 +395,8 @@ function shouldShowLastActorDisplayName( lastAction: OnyxEntry, currentUserAccountIDParam: number, ) { - const reportID = report?.reportID; - const lastReportAction = (reportID ? lastVisibleReportActions[reportID] : undefined) ?? lastAction; + // Use lastAction directly instead of getLastVisibleReportAction to avoid using stale cache data + const lastReportAction = lastAction; // Use report.lastActionType as fallback when report actions aren't loaded yet (e.g., on cold start) const lastActionName = lastReportAction?.actionName ?? report?.lastActionType; @@ -449,6 +431,8 @@ function getAlternateText( isReportArchived: boolean | undefined, currentUserAccountID: number, lastActorDetails: Partial | null = {}, + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, + translate?: LocalizedTranslate, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], ) { const report = getReportOrDraftReport(option.reportID); @@ -458,21 +442,30 @@ function getAlternateText( const isAnnounceRoom = reportUtilsIsAnnounceRoom(report); const isGroupChat = reportUtilsIsGroupChat(report); const isExpenseThread = isMoneyRequest(report); + // eslint-disable-next-line @typescript-eslint/no-deprecated + const translateFn = translate ?? translateLocal; const formattedLastMessageText = formatReportLastMessageText(Parser.htmlToText(option.lastMessageText ?? '')) || - // eslint-disable-next-line @typescript-eslint/no-deprecated - getLastMessageTextForReport({translate: translateLocal, report, lastActorDetails, isReportArchived, chatReport, reportAttributesDerived}); + getLastMessageTextForReport({ + translate: translateFn, + report, + lastActorDetails, + isReportArchived, + chatReport, + visibleReportActionsDataParam: visibleReportActionsData, + reportAttributesDerived, + }); const reportPrefix = getReportSubtitlePrefix(report); const formattedLastMessageTextWithPrefix = reportPrefix + formattedLastMessageText; if (isExpenseThread || option.isMoneyRequestReport) { // eslint-disable-next-line @typescript-eslint/no-deprecated - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateLocal('iou.expense'); + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('iou.expense'); } if (option.isThread) { // eslint-disable-next-line @typescript-eslint/no-deprecated - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateLocal('threads.thread'); + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('threads.thread'); } if (option.isChatRoom && !isAdminRoom && !isAnnounceRoom) { @@ -485,12 +478,12 @@ function getAlternateText( if (option.isTaskReport) { // eslint-disable-next-line @typescript-eslint/no-deprecated - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateLocal('task.task'); + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('task.task'); } if (isGroupChat) { // eslint-disable-next-line @typescript-eslint/no-deprecated - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateLocal('common.group'); + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('common.group'); } return showChatPreviewLine && formattedLastMessageText @@ -536,15 +529,22 @@ function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchV return isSearchStringMatch(searchValue.trim(), memberDetails.toLowerCase()); } -function getIOUReportIDOfLastAction(report: OnyxEntry): string | undefined { +/** + * Get IOU report ID of report last action if the action is report action preview + */ +function getIOUReportIDOfLastAction(report: OnyxEntry, visibleReportActionsData?: VisibleReportActionsDerivedValue, lastAction?: OnyxEntry): string | undefined { if (!report?.reportID) { return; } - const lastAction = lastVisibleReportActions[report.reportID]; - if (!isReportPreviewAction(lastAction)) { + // Use lastAction if available (from useOnyx), otherwise fallback to getLastVisibleAction which uses isReportActionVisibleAsLastAction with proper filters + const reportNameValuePairs = allReportNameValuePairsOnyxConnect?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; + const isReportArchived = !!reportNameValuePairs?.private_isArchived; + const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); + const action = lastAction ?? getLastVisibleAction(report.reportID, canUserPerformWrite, {}, undefined, visibleReportActionsData); + if (!isReportPreviewAction(action)) { return; } - return getReportOrDraftReport(getIOUReportIDFromReportActionPreview(lastAction))?.reportID; + return getReportOrDraftReport(getIOUReportIDFromReportActionPreview(action))?.reportID; } function hasHiddenDisplayNames(accountIDs: number[]) { @@ -555,15 +555,21 @@ function getLastActorDisplayNameFromLastVisibleActions( report: OnyxEntry, lastActorDetails: Partial | null, currentUserAccountIDParam: number, - personalDetails: OnyxEntry, + personalDetails?: OnyxEntry, + visibleReportActionsData?: VisibleReportActionsDerivedValue, + lastAction?: OnyxEntry, ): string { const reportID = report?.reportID; - const lastReportAction = reportID ? lastVisibleReportActions[reportID] : undefined; + // Use lastAction if available (from useOnyx), otherwise fallback to getLastVisibleAction which uses isReportActionVisibleAsLastAction with proper filters + const reportNameValuePairs = reportID ? allReportNameValuePairsOnyxConnect?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`] : undefined; + const isReportArchived = !!reportNameValuePairs?.private_isArchived; + const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); + const lastReportAction = lastAction ?? getLastVisibleAction(reportID, canUserPerformWrite, {}, undefined, visibleReportActionsData); if (lastReportAction) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const lastActorAccountID = getReportActionActorAccountID(lastReportAction, undefined, undefined) || report?.lastActorAccountID; - let actorDetails: Partial | null = lastActorAccountID ? (personalDetails?.[lastActorAccountID] ?? null) : null; + let actorDetails: Partial | null = lastActorAccountID ? ((personalDetails ?? allPersonalDetails)?.[lastActorAccountID] ?? null) : null; if (!actorDetails && lastReportAction.person?.at(0)?.text) { actorDetails = { @@ -594,6 +600,8 @@ function getLastMessageTextForReport({ policyForMovingExpensesID, chatReport, reportMetadata, + visibleReportActionsDataParam, + lastAction, reportAttributesDerived, policyTags, currentUserLogin, @@ -606,15 +614,47 @@ function getLastMessageTextForReport({ policy?: OnyxEntry; isReportArchived?: boolean; policyForMovingExpensesID?: string; - chatReport: OnyxEntry; + chatReport?: OnyxEntry; reportMetadata?: OnyxEntry; + visibleReportActionsDataParam?: VisibleReportActionsDerivedValue; + lastAction?: OnyxEntry; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; policyTags?: OnyxEntry; currentUserLogin?: string; }): string { const reportID = report?.reportID; - const lastReportAction = reportID ? lastVisibleReportActions[reportID] : undefined; - const lastVisibleMessage = getLastVisibleMessage(report?.reportID); + const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); + let lastReportAction = lastAction ?? getLastVisibleAction(reportID, canUserPerformWrite, {}, undefined, visibleReportActionsDataParam); + + const chatReportForTransactionThread = chatReport ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; + const transactionThreadReportID = reportID ? getOneTransactionThreadReportID(report, chatReportForTransactionThread, allSortedReportActions[reportID]) : undefined; + + if (reportID && !lastAction && transactionThreadReportID) { + lastReportAction = + getLastVisibleActionIncludingTransactionThread(reportID, canUserPerformWrite, undefined, visibleReportActionsDataParam, transactionThreadReportID) ?? lastReportAction; + } + + // Compute lastVisibleMessage before IOU filter — it needs IOU CREATE/TRACK for text extraction. + const lastVisibleMessage = getLastVisibleMessage(report?.reportID, undefined, {}, lastReportAction, visibleReportActionsDataParam); + + // For one-transaction reports, filter IOU CREATE/TRACK and fall back to the parent's action (e.g. REPORT_PREVIEW). + if (transactionThreadReportID && lastReportAction && isMoneyRequestAction(lastReportAction)) { + const actionType = getOriginalMessage(lastReportAction)?.type ?? ''; + const isSelfDMReport = reportUtilsIsSelfDM(report); + if (actionType === CONST.IOU.REPORT_ACTION_TYPE.CREATE || (!isSelfDMReport && actionType === CONST.IOU.REPORT_ACTION_TYPE.TRACK)) { + const parentLastAction = getLastVisibleAction(reportID, canUserPerformWrite, {}, undefined, visibleReportActionsDataParam); + if (parentLastAction && isMoneyRequestAction(parentLastAction)) { + const parentActionType = getOriginalMessage(parentLastAction)?.type ?? ''; + if (parentActionType === CONST.IOU.REPORT_ACTION_TYPE.CREATE || (!isSelfDMReport && parentActionType === CONST.IOU.REPORT_ACTION_TYPE.TRACK)) { + lastReportAction = undefined; + } else { + lastReportAction = parentLastAction; + } + } else { + lastReportAction = parentLastAction; + } + } + } // some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action const lastOriginalReportAction = reportID ? lastReportActions[reportID] : undefined; @@ -647,14 +687,21 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = formatReportLastMessageText(Parser.htmlToText(properSchemaForMoneyRequestMessage)); } else if (isReportPreviewAction(lastReportAction)) { const iouReport = getReportOrDraftReport(getIOUReportIDFromReportActionPreview(lastReportAction)); - const lastIOUMoneyReportAction = iouReport?.reportID - ? allSortedReportActions[iouReport.reportID]?.find( - (reportAction, key): reportAction is ReportAction => - shouldReportActionBeVisible(reportAction, key, canUserPerformWriteAction(report, isReportArchived)) && - reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && - isMoneyRequestAction(reportAction), - ) - : undefined; + const iouReportID = iouReport?.reportID; + const reportCache = iouReportID ? visibleReportActionsDataParam?.[iouReportID] : undefined; + const visibleReportActionsForIOUReport = reportCache && Object.keys(reportCache).length > 0 ? visibleReportActionsDataParam : undefined; + const iouReportActions = iouReportID ? allSortedReportActions[iouReportID] : undefined; + const canPerformWrite = canUserPerformWriteAction(report, isReportArchived); + const lastIOUMoneyReportAction = + iouReportID && iouReportActions + ? iouReportActions.find( + (reportAction): reportAction is ReportAction => + isReportActionVisible(reportAction, iouReportID, canPerformWrite, visibleReportActionsForIOUReport) && + reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + isMoneyRequestAction(reportAction), + ) + : undefined; + // For workspace chats, use the report title if (reportUtilsIsPolicyExpenseChat(report) && !isEmptyObject(iouReport)) { const reportName = reportAttributesDerived?.[iouReport.reportID]?.reportName ?? ''; @@ -855,12 +902,9 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = getReportActionMessageText(lastReportAction); } - if (reportID && !lastMessageTextFromReport && lastReportAction) { - // If the report is a one-transaction report, get the last message text from combined report actions so the LHN can display modifications to the transaction thread or the report itself - const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, allSortedReportActions[reportID]); - if (transactionThreadReportID) { - lastMessageTextFromReport = getReportActionMessageText(lastReportAction); - } + // If the report is a one-transaction report, get the last message text from combined report actions so the LHN can display modifications to the transaction thread or the report itself + if (reportID && !lastMessageTextFromReport && lastReportAction && transactionThreadReportID) { + lastMessageTextFromReport = getReportActionMessageText(lastReportAction); } // If the last action is AddComment and no last message text was determined yet, use getLastVisibleMessage to get the preview text @@ -886,6 +930,18 @@ function getLastMessageTextForReport({ return lastVisibleMessage?.lastMessageText ?? ''; } + // When CREATED is the only visible action left (e.g. after cross-device expense + // deletion), return empty string so the LHN shows the welcome message instead of + // stale report.lastMessageText. + if (!lastMessageTextFromReport && isCreatedAction(lastReportAction)) { + return ''; + } + + // Fallback: use the action's own message text if not handled above. + if (!lastMessageTextFromReport && lastReportAction) { + lastMessageTextFromReport = lastVisibleMessage?.lastMessageText ?? ''; + } + return lastMessageTextFromReport || (report?.lastMessageText ?? ''); } @@ -901,6 +957,8 @@ function createOption( config?: PreviewConfig, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], privateIsArchived?: string, + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, + translate?: LocalizedTranslate, ): SearchOptionData { const {showChatPreviewLine = false, forcePolicyNamePreview = false, showPersonalDetails = false, selected, isSelected, isDisabled} = config ?? {}; @@ -976,19 +1034,30 @@ function createOption( // If displaying chat preview line is needed, let's overwrite the default alternate text const lastActorDetails = personalDetails?.[report?.lastActorAccountID ?? String(CONST.DEFAULT_NUMBER_ID)] ?? {}; + // eslint-disable-next-line @typescript-eslint/no-deprecated + const translateFn = translate ?? translateLocal; result.lastMessageText = getLastMessageTextForReport({ - // eslint-disable-next-line @typescript-eslint/no-deprecated - translate: translateLocal, + translate: translateFn, report, lastActorDetails, isReportArchived: !!result.private_isArchived, chatReport, + visibleReportActionsDataParam: visibleReportActionsData, reportAttributesDerived, }); result.alternateText = showPersonalDetails && personalDetail?.login ? personalDetail.login - : getAlternateText(result, {showChatPreviewLine, forcePolicyNamePreview}, !!result.private_isArchived, currentUserAccountID, lastActorDetails, reportAttributesDerived); + : getAlternateText( + result, + {showChatPreviewLine, forcePolicyNamePreview}, + !!result.private_isArchived, + currentUserAccountID, + lastActorDetails, + visibleReportActionsData, + translateFn, + reportAttributesDerived, + ); const computedReportName = getReportName(report, reportAttributesDerived); @@ -1041,6 +1110,7 @@ function getReportOption( personalDetails: OnyxEntry, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], reportDrafts?: OnyxCollection, + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): OptionData { const report = getReportOrDraftReport(participant.reportID, undefined, undefined, reportDrafts); // TODO: This allReports usage is temporary and will be removed once the full Onyx.connect() refactor is complete (https://github.com/Expensify/App/issues/66378) @@ -1059,6 +1129,7 @@ function getReportOption( }, reportAttributesDerived, privateIsArchived, + visibleReportActionsData, ); // Update text & alternateText because createOption returns workspace name only if report is owned by the user @@ -1103,6 +1174,7 @@ function getReportDisplayOption( privateIsArchived: string | undefined, chatReport: OnyxEntry, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): OptionData { const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, true); @@ -1118,6 +1190,7 @@ function getReportDisplayOption( }, reportAttributesDerived, privateIsArchived, + visibleReportActionsData, ); // Update text & alternateText because createOption returns workspace name only if report is owned by the user @@ -1153,6 +1226,7 @@ function getPolicyExpenseReportOption( expenseReport: OnyxEntry, chatReport: OnyxEntry, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): SearchOptionData { const visibleParticipantAccountIDs = Object.entries(expenseReport?.participants ?? {}) .filter(([, reportParticipant]) => reportParticipant && !isHiddenForCurrentUser(reportParticipant.notificationPreference)) @@ -1169,6 +1243,8 @@ function getPolicyExpenseReportOption( forcePolicyNamePreview: false, }, reportAttributesDerived, + undefined, + visibleReportActionsData, ); // Update text & alternateText because createOption returns workspace name only if report is owned by the user @@ -1281,6 +1357,7 @@ function processReport( currentUserAccountID: number, chatReport: OnyxEntry, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): { reportMapEntry?: [number, Report]; // The entry to add to reportMapForAccountIDs if applicable reportOption: SearchOption | null; // The report option to add to allReportOptions if applicable @@ -1304,7 +1381,7 @@ function processReport( reportMapEntry, reportOption: { item: report, - ...createOption(accountIDs, personalDetails, report, currentUserAccountID, chatReport, undefined, reportAttributesDerived, privateIsArchived), + ...createOption(accountIDs, personalDetails, report, currentUserAccountID, chatReport, undefined, reportAttributesDerived, privateIsArchived, visibleReportActionsData), }, }; } @@ -1315,6 +1392,7 @@ function createOptionList( privateIsArchivedMap: PrivateIsArchivedMap, reports: OnyxCollection, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ) { const span = Sentry.startInactiveSpan({name: 'createOptionList'}); @@ -1325,7 +1403,15 @@ function createOptionList( for (const report of Object.values(reports)) { const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`]; const chatReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; - const {reportMapEntry, reportOption} = processReport(report, personalDetails, privateIsArchived, currentUserAccountID, chatReport, reportAttributesDerived); + const {reportMapEntry, reportOption} = processReport( + report, + personalDetails, + privateIsArchived, + currentUserAccountID, + chatReport, + reportAttributesDerived, + visibleReportActionsData, + ); if (reportMapEntry) { const [accountID, reportValue] = reportMapEntry; @@ -1356,6 +1442,7 @@ function createOptionList( }, reportAttributesDerived, privateIsArchived, + visibleReportActionsData, ), }; }); @@ -1397,6 +1484,7 @@ function createFilteredOptionList( searchTerm?: string; betas?: OnyxEntry; } = {}, + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ) { const {maxRecentReports = 500, includeP2P = true, searchTerm = ''} = options; const reportMapForAccountIDs: Record = {}; @@ -1448,7 +1536,7 @@ function createFilteredOptionList( for (const report of limitedReports) { const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; const chatReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; - const {reportMapEntry, reportOption} = processReport(report, personalDetails, privateIsArchived, currentUserAccountID, chatReport, reportAttributesDerived); + const {reportMapEntry, reportOption} = processReport(report, personalDetails, privateIsArchived, currentUserAccountID, chatReport, reportAttributesDerived, visibleReportActionsData); if (reportMapEntry) { const [accountID, reportValue] = reportMapEntry; @@ -1490,6 +1578,7 @@ function createFilteredOptionList( {showPersonalDetails: true}, reportAttributesDerived, privateIsArchived, + visibleReportActionsData, ), }; }) @@ -1509,12 +1598,13 @@ function createOptionFromReport( privateIsArchived: string | undefined, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], config?: PreviewConfig, + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ) { const accountIDs = getParticipantsAccountIDsForDisplay(report); return { item: report, - ...createOption(accountIDs, personalDetails, report, currentUserAccountID, chatReport, config, reportAttributesDerived, privateIsArchived), + ...createOption(accountIDs, personalDetails, report, currentUserAccountID, chatReport, config, reportAttributesDerived, privateIsArchived, visibleReportActionsData), }; } @@ -1806,7 +1896,8 @@ function getUserToInviteOption({ loginList = {}, currentUserEmail, currentUserAccountID, -}: GetUserToInviteConfig): SearchOptionData | null { + visibleReportActionsData = {}, +}: GetUserToInviteConfig & {visibleReportActionsData?: VisibleReportActionsDerivedValue}): SearchOptionData | null { if (!searchValue) { return null; } @@ -1837,9 +1928,17 @@ function getUserToInviteOption({ login: searchValue, }, }; - const userToInvite = createOption([optimisticAccountID], personalDetailsExtended, null, currentUserAccountID, undefined, { - showChatPreviewLine, - }); + const userToInvite = createOption( + [optimisticAccountID], + personalDetailsExtended, + null, + currentUserAccountID, + undefined, + {showChatPreviewLine}, + undefined, + undefined, + visibleReportActionsData, + ); userToInvite.isOptimisticAccount = true; userToInvite.login = searchValue; @@ -2116,6 +2215,7 @@ function prepareReportOptionsForDisplay( policiesCollection: OnyxCollection, currentUserAccountID: number, config: GetValidReportsConfig, + visibleReportActionsData: VisibleReportActionsDerivedValue = {}, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], ): Array> { const { @@ -2147,7 +2247,16 @@ function prepareReportOptionsForDisplay( * By default, generated options does not have the chat preview line enabled. * If showChatPreviewLine or forcePolicyNamePreview are true, let's generate and overwrite the alternate text. */ - const alternateText = getAlternateText(option, {showChatPreviewLine, forcePolicyNamePreview}, !!option.private_isArchived, currentUserAccountID, {}, reportAttributesDerived); + const alternateText = getAlternateText( + option, + {showChatPreviewLine, forcePolicyNamePreview}, + !!option.private_isArchived, + currentUserAccountID, + null, + visibleReportActionsData, + undefined, + reportAttributesDerived, + ); const isSelected = isReportSelected(option, selectedOptions); let isOptionUnread = option.isUnread; @@ -2284,10 +2393,11 @@ function getValidOptions( maxRecentReportElements = undefined, shouldAcceptName = false, personalDetails, + countryCode = CONST.DEFAULT_COUNTRY_CODE, + visibleReportActionsData = {}, + reportAttributesDerived, ...config }: GetOptionsConfig = {}, - countryCode: number = CONST.DEFAULT_COUNTRY_CODE, - reportAttributesDerived?: ReportAttributesDerivedValue['reports'], ): Options { const restrictedLogins = getRestrictedLogins(config, options, canShowManagerMcTest, nvpDismissedProductTraining); @@ -2389,6 +2499,7 @@ function getValidOptions( shouldShowGBR, personalDetails, }, + visibleReportActionsData, reportAttributesDerived, ).at(0); } @@ -2409,6 +2520,7 @@ function getValidOptions( shouldShowGBR, personalDetails, }, + visibleReportActionsData, reportAttributesDerived, ); @@ -2425,6 +2537,7 @@ function getValidOptions( shouldShowGBR, personalDetails, }, + visibleReportActionsData, reportAttributesDerived, ); } else if (recentAttendees && recentAttendees?.length > 0) { @@ -2545,6 +2658,7 @@ type SearchOptionsConfig = { shouldShowGBR?: boolean; shouldUnreadBeBold?: boolean; loginList: OnyxEntry; + visibleReportActionsData?: VisibleReportActionsDerivedValue; policyCollection: OnyxCollection; currentUserAccountID: number; currentUserEmail: string; @@ -2571,45 +2685,38 @@ function getSearchOptions({ shouldShowGBR = false, shouldUnreadBeBold = false, loginList, + visibleReportActionsData = {}, policyCollection, currentUserAccountID, currentUserEmail, reportAttributesDerived, personalDetails, }: SearchOptionsConfig): Options { - const optionList = getValidOptions( - options, - policyCollection, - draftComments, - nvpDismissedProductTraining, - loginList, - currentUserAccountID, - currentUserEmail, - { - betas, - includeRecentReports, - includeMultipleParticipantReports: true, - showChatPreviewLine: isUsedInChatFinder, - includeP2P: true, - includeOwnedWorkspaceChats: true, - includeThreads: true, - includeMoneyRequests: true, - includeTasks: true, - includeReadOnly, - includeSelfDM: true, - shouldBoldTitleByDefault: !isUsedInChatFinder, - excludeHiddenThreads: true, - maxElements: maxResults, - includeCurrentUser, - searchString: searchQuery, - includeUserToInvite, - shouldShowGBR, - shouldUnreadBeBold, - personalDetails, - }, + const optionList = getValidOptions(options, policyCollection, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { + betas, + includeRecentReports, + includeMultipleParticipantReports: true, + showChatPreviewLine: isUsedInChatFinder, + includeP2P: true, + includeOwnedWorkspaceChats: true, + includeThreads: true, + includeMoneyRequests: true, + includeTasks: true, + includeReadOnly, + includeSelfDM: true, + shouldBoldTitleByDefault: !isUsedInChatFinder, + excludeHiddenThreads: true, + maxElements: maxResults, + includeCurrentUser, + searchString: searchQuery, + includeUserToInvite, + shouldShowGBR, + shouldUnreadBeBold, + personalDetails, countryCode, + visibleReportActionsData, reportAttributesDerived, - ); + }); return optionList; } @@ -2727,26 +2834,17 @@ function getMemberInviteOptions( includeSelectedOptions = false, countryCode: number = CONST.DEFAULT_COUNTRY_CODE, ): Options { - return getValidOptions( - {personalDetails, reports: []}, - undefined, - undefined, - nvpDismissedProductTraining, - loginList, - currentUserAccountID, - currentUserEmail, - { - betas, - includeP2P: true, - excludeLogins, - includeSelectedOptions, - includeRecentReports: false, - searchString: '', - maxElements: undefined, - personalDetails: personalDetailsCollection, - }, + return getValidOptions({personalDetails, reports: []}, undefined, undefined, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { + betas, + includeP2P: true, + excludeLogins, + includeSelectedOptions, + includeRecentReports: false, + searchString: '', + maxElements: undefined, + personalDetails: personalDetailsCollection, countryCode, - ); + }); } /** diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index 678bf92d813c..38403f61e2fc 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -3,7 +3,17 @@ import type {Section as SelectionListSection} from '@components/SelectionList/Se import type {OptionData} from '@libs/ReportUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import type {IOUAction} from '@src/CONST'; -import type {Beta, Login, PersonalDetails, PersonalDetailsList, Report, ReportActions, TransactionViolation} from '@src/types/onyx'; +import type { + Beta, + Login, + PersonalDetails, + PersonalDetailsList, + Report, + ReportActions, + ReportAttributesDerivedValue, + TransactionViolation, + VisibleReportActionsDerivedValue, +} from '@src/types/onyx'; import type {Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; /** @@ -204,6 +214,9 @@ type GetOptionsConfig = { maxRecentReportElements?: number; includeUserToInvite?: boolean; shouldAcceptName?: boolean; + countryCode?: number; + visibleReportActionsData?: VisibleReportActionsDerivedValue; + reportAttributesDerived?: ReportAttributesDerivedValue['reports']; } & GetValidReportsConfig; type GetUserToInviteConfig = { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 2f9ba2aaf85d..7ee5e84c9d42 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -15,7 +15,18 @@ import IntlStore from '@src/languages/IntlStore'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Card, CompanyCardFeed, OnyxInputOrEntry, OriginalMessageIOU, PersonalDetails, Policy, PrivatePersonalDetails, ReportMetadata, ReportNameValuePairs} from '@src/types/onyx'; +import type { + Card, + CompanyCardFeed, + OnyxInputOrEntry, + OriginalMessageIOU, + PersonalDetails, + Policy, + PrivatePersonalDetails, + ReportMetadata, + ReportNameValuePairs, + VisibleReportActionsDerivedValue, +} from '@src/types/onyx'; import type { JoinWorkspaceResolution, OriginalMessageChangeLog, @@ -225,7 +236,7 @@ function getHtmlWithAttachmentID(html: string, reportActionID: string | undefine } function getReportActionMessage(reportAction: PartialReportAction) { - return Array.isArray(reportAction?.message) ? reportAction.message.at(0) : reportAction?.message; + return Array.isArray(reportAction?.message) ? reportAction?.message.at(0) : reportAction?.message; } function isDeletedParentAction(reportAction: OnyxInputOrEntry): boolean { @@ -414,7 +425,7 @@ function getOriginalMessage(reportAction: OnyxInputO return reportAction?.message ?? reportAction?.originalMessage; } // eslint-disable-next-line @typescript-eslint/no-deprecated - return reportAction.originalMessage; + return reportAction?.originalMessage; } function getCardConnectionBrokenMessage(card: Card | undefined, originalCardName: string | undefined, translate: LocaleContextProps['translate'], connectionLink?: string) { @@ -1089,7 +1100,7 @@ function isResolvedConciergeDescriptionOptions(reportAction: OnyxEntry, key: string | number, canUserPerformWriteAction?: boolean): boolean { +function shouldReportActionBeVisible(reportAction: OnyxEntry, key: string | number, canUserPerformWriteAction?: boolean, reportsParam?: OnyxCollection): boolean { if (!reportAction) { return false; } @@ -1105,10 +1116,12 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } + const reports = reportsParam ?? allReports; + if (actionName === CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION) { const unreportedTransactionOriginalMessage = getOriginalMessage(reportAction as OnyxEntry>) ?? {}; const {fromReportID} = unreportedTransactionOriginalMessage as OriginalMessageUnreportedTransaction; - const fromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; + const fromReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; return !!fromReport; } @@ -1121,8 +1134,8 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } - const toReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${toReportID}`]; - const fromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; + const toReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${toReportID}`]; + const fromReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; return !!fromReport || !!toReport; } @@ -1177,10 +1190,57 @@ function shouldHideNewMarker(reportAction: OnyxEntry): boolean { } /** - * Checks if a reportAction is fit for display as report last action, meaning that - * it satisfies shouldReportActionBeVisible, it's not whisper action and not deleted. + * Checks if a report action is visible using the pre-computed derived value when available, + * falling back to runtime calculation if not. */ -function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry, canUserPerformWriteAction?: boolean): boolean { +function isReportActionVisible( + reportAction: OnyxEntry, + reportID: string, + canUserPerformWriteAction?: boolean, + visibleReportActions?: VisibleReportActionsDerivedValue, +): boolean { + if (!reportAction?.reportActionID) { + return false; + } + + // Actions with pendingAction are optimistic or in-flight, so their visibility may differ + // from what's cached in visibleReportActions (which reflects persisted Onyx data). + // We must recalculate visibility at runtime to ensure accuracy for these transient states. + if (reportAction.pendingAction) { + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction); + } + + if (visibleReportActions) { + const reportCache = visibleReportActions[reportID]; + if (!reportCache) { + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction); + } + const staticVisibility = reportCache[reportAction.reportActionID]; + // If action is not in derived value cache, fall back to runtime calculation + if (staticVisibility === undefined) { + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction); + } + if (!staticVisibility) { + return false; + } + if (!canUserPerformWriteAction && isActionableWhisperRequiringWritePermission(reportAction)) { + return false; + } + return true; + } + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction); +} + +/** + * Checks if a report action is visible as last action using the pre-computed derived value when available, + * falling back to runtime calculation if not. + */ +function isReportActionVisibleAsLastAction( + reportAction: OnyxInputOrEntry, + canUserPerformWriteAction?: boolean, + visibleReportActions?: VisibleReportActionsDerivedValue, + reportID?: string, +): boolean { if (!reportAction) { return false; } @@ -1189,12 +1249,17 @@ function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry< return false; } - // If a whisper action is the REPORT_PREVIEW action, we are displaying it. - // If the action's message text is empty and it is not a deleted parent with visible child actions, hide it. Else, consider the action to be displayable. + const actionReportID = reportAction.reportID ?? reportID; + if (!actionReportID) { + return false; + } + return ( - shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction) && - (!(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) || isActionableMentionWhisper(reportAction)) && - !(isDeletedAction(reportAction) && !isDeletedParentAction(reportAction) && !isPendingHide(reportAction)) + (!(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction) && !isModifiedExpenseAction(reportAction)) || + isActionableMentionWhisper(reportAction)) && + isReportActionVisible(reportAction, actionReportID, canUserPerformWriteAction, visibleReportActions) && + reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && + reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ); } @@ -1229,6 +1294,7 @@ function getLastVisibleAction( canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}, reportActionsParam: OnyxCollection = allReportActions, + visibleReportActionsData?: VisibleReportActionsDerivedValue, ): OnyxEntry { let reportActions: Array = []; if (!isEmpty(actionsToMerge)) { @@ -1236,9 +1302,13 @@ function getLastVisibleAction( ReportAction | null | undefined >; } else { - reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}); + reportActions = Object.values(reportActionsParam?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}); } - const visibleReportActions = reportActions.filter((action): action is ReportAction => shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction)); + // Pass reportID as fallback for actions that don't have it set (e.g., optimistic actions) + // This is cleaner than mutating the actions array + const visibleReportActions = reportActions.filter((action): action is ReportAction => + isReportActionVisibleAsLastAction(action, canUserPerformWriteAction, visibleReportActionsData, reportID), + ); const sortedReportActions = getSortedReportActions(visibleReportActions, true); if (sortedReportActions.length === 0) { return undefined; @@ -1246,6 +1316,38 @@ function getLastVisibleAction( return sortedReportActions.at(0); } +/** + * Gets the last visible action for a report, including checking transaction thread for one-transaction reports. + * This ensures LHN preview shows the most recent action whether it's on the parent report or transaction thread. + */ +function getLastVisibleActionIncludingTransactionThread( + reportID: string | undefined, + canUserPerformWriteAction?: boolean, + reportActionsParam: OnyxCollection = allReportActions, + visibleReportActionsData?: VisibleReportActionsDerivedValue, + transactionThreadReportID?: string, +): OnyxEntry { + const parentLastAction = getLastVisibleAction(reportID, canUserPerformWriteAction, {}, reportActionsParam, visibleReportActionsData); + + if (!transactionThreadReportID) { + return parentLastAction; + } + + const childLastAction = getLastVisibleAction(transactionThreadReportID, canUserPerformWriteAction, {}, reportActionsParam, visibleReportActionsData); + + if ( + childLastAction && + !isCreatedAction(childLastAction) && + (!parentLastAction || + (childLastAction.created && parentLastAction.created && childLastAction.created > parentLastAction.created) || + (childLastAction.created && !parentLastAction.created)) + ) { + return childLastAction; + } + + return parentLastAction; +} + function formatLastMessageText(lastMessageText: string | undefined) { const trimmedMessage = String(lastMessageText).trim(); @@ -1266,8 +1368,9 @@ function getLastVisibleMessage( canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}, reportAction: OnyxInputOrEntry | undefined = undefined, + visibleReportActionsData?: VisibleReportActionsDerivedValue, ): LastVisibleMessage { - const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, canUserPerformWriteAction, actionsToMerge); + const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, canUserPerformWriteAction, actionsToMerge, undefined, visibleReportActionsData); const message = getReportActionMessage(lastVisibleAction); if (message && isReportMessageAttachment(message)) { @@ -1369,6 +1472,8 @@ function getSortedReportActionsForDisplay( reportActions: OnyxEntry | ReportAction[], canUserPerformWriteAction?: boolean, shouldIncludeInvisibleActions = false, + visibleReportActionsData?: VisibleReportActionsDerivedValue, + reportID?: string, ): ReportAction[] { let filteredReportActions: ReportAction[] = []; if (!reportActions) { @@ -1379,7 +1484,16 @@ function getSortedReportActionsForDisplay( filteredReportActions = Object.values(reportActions).filter(Boolean); } else { filteredReportActions = Object.entries(reportActions) - .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key, canUserPerformWriteAction)) + .filter(([collectionKey, reportAction]) => { + if (isReportActionDeprecated(reportAction, collectionKey)) { + return false; + } + const actionReportID = reportAction?.reportID ?? reportID; + if (!actionReportID) { + return false; + } + return isReportActionVisible(reportAction, actionReportID, canUserPerformWriteAction, visibleReportActionsData); + }) .map(([, reportAction]) => reportAction); } @@ -1446,7 +1560,7 @@ function findLastReportActions(reportActions: OnyxEntry, canUserP } } - if (shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction) && action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { + if (isReportActionVisibleAsLastAction(action, canUserPerformWriteAction) && action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { if (!lastActionForDisplay || isNewerReportAction(action, lastActionForDisplay)) { lastActionForDisplay = action; } @@ -1747,9 +1861,16 @@ function getOneTransactionThreadReportID(...args: Parameters shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction)); + const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => + isReportActionVisibleAsLastAction(action, canUserPerformWriteAction, visibleReportActionsData, reportID), + ); // Exclude the task system message and the created message const visibleReportActionsWithoutTaskSystemMessage = visibleReportActions.filter((action) => !isTaskAction(action) && !isCreatedAction(action)); @@ -1913,7 +2034,7 @@ function getMessageOfOldDotLegacyAction(legacyAction: PartialReportAction) { if (!Array.isArray(legacyAction?.message)) { return getReportActionText(legacyAction); } - if (legacyAction.message.length !== 0) { + if (legacyAction?.message.length !== 0) { // Sometime html can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return legacyAction?.message?.map((element) => getTextFromHtml(element?.html || element?.text)).join('') ?? ''; @@ -2390,6 +2511,22 @@ function isActionableCardFraudAlert(reportAction: OnyxInputOrEntry return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT; } +/** + * Checks if a report action is an actionable whisper that requires write permission to be visible. + */ +function isActionableWhisperRequiringWritePermission(reportAction: OnyxEntry): boolean { + if (!reportAction) { + return false; + } + + return ( + isActionableReportMentionWhisper(reportAction) || + isActionableJoinRequestPendingReportAction(reportAction) || + isActionableMentionWhisper(reportAction) || + isActionableCardFraudAlert(reportAction) + ); +} + function getExportIntegrationLastMessageText(translate: LocalizedTranslate, reportAction: OnyxEntry): string { const fragments = getExportIntegrationActionFragments(translate, reportAction); return fragments.reduce((acc, fragment) => `${acc} ${fragment.text}`, ''); @@ -4209,6 +4346,7 @@ export { getIOUActionForTransactionID, getIOUReportIDFromReportActionPreview, getLastVisibleAction, + getLastVisibleActionIncludingTransactionThread, getLastVisibleMessage, getLatestReportActionFromOnyxData, getLinkedTransactionID, @@ -4246,6 +4384,7 @@ export { isActionableWhisper, isActionableJoinRequest, isActionableJoinRequestPending, + isActionableJoinRequestPendingReportAction, isActionableMentionWhisper, isActionableMentionInviteToSubmitExpenseConfirmWhisper, isActionableReportMentionWhisper, @@ -4301,6 +4440,7 @@ export { isTrackExpenseAction, isTransactionThread, isTripPreview, + isTravelUpdate, isHoldAction, isWhisperAction, isSubmittedAction, @@ -4321,14 +4461,17 @@ export { isTagModificationAction, isIOUActionMatchingTransactionList, isResolvedActionableWhisper, + isVisiblePreviewOrMoneyRequest, isReimbursementDirectionInformationRequiredAction, shouldHideNewMarker, shouldReportActionBeVisible, - shouldReportActionBeVisibleAsLastAction, + isReportActionVisible, + isReportActionVisibleAsLastAction, wasActionTakenByCurrentUser, isInviteOrRemovedAction, isActionableAddPaymentCard, isActionableCardFraudAlert, + isActionableWhisperRequiringWritePermission, getExportIntegrationActionFragments, getExportIntegrationLastMessageText, getExportIntegrationMessageHTML, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9bac157a49b8..753d8072aed7 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -61,6 +61,7 @@ import type { Transaction, TransactionViolation, TransactionViolations, + VisibleReportActionsDerivedValue, } from '@src/types/onyx'; import type {ReportTransactionsAndViolations} from '@src/types/onyx/DerivedValues'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; @@ -225,6 +226,7 @@ import { isRenamedAction, isReopenedAction, isReportActionAttachment, + isReportActionVisible, isReportPreviewAction, isRetractedAction, isReversedTransaction, @@ -237,7 +239,6 @@ import { isTransactionThread, isTripPreview, isWhisperAction, - shouldReportActionBeVisible, wasActionTakenByCurrentUser, } from './ReportActionsUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; @@ -5271,8 +5272,11 @@ function getReportPreviewMessage( const amount = getTransactionAmount(linkedTransaction, !isEmptyObject(report) && isExpenseReport(report), linkedTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) ?? 0; const formattedAmount = convertToDisplayString(amount, getCurrency(linkedTransaction)) ?? ''; + + const merchantOrComment = getMerchantOrDescription(linkedTransaction); + // eslint-disable-next-line @typescript-eslint/no-deprecated - return translateLocal('iou.trackedAmount', formattedAmount, getMerchantOrDescription(linkedTransaction)); + return translateLocal('iou.trackedAmount', formattedAmount, merchantOrComment); } } @@ -6838,7 +6842,7 @@ function getMovedTransactionMessage(translate: LocalizedTranslate, action: Repor // This will be fixed as follow up https://github.com/Expensify/App/pull/75357 // eslint-disable-next-line @typescript-eslint/no-deprecated - const reportName = getReportName(report) ?? report?.reportName ?? ''; + const reportName = Parser.htmlToText(getReportName(report) ?? report?.reportName ?? ''); const reportUrl = getReportURLForCurrentContext(report?.reportID); if (typeof fromReportID === 'undefined') { return translate('iou.movedTransactionTo', reportUrl, reportName); @@ -6854,7 +6858,7 @@ function getUnreportedTransactionMessage(translate: LocalizedTranslate, action: // This will be fixed as follow up https://github.com/Expensify/App/pull/75357 // eslint-disable-next-line @typescript-eslint/no-deprecated - const reportName = getReportName(fromReport) ?? fromReport?.reportName ?? ''; + const reportName = Parser.htmlToText(getReportName(fromReport) ?? fromReport?.reportName ?? ''); let reportUrl = getReportURLForCurrentContext(fromReportID); @@ -8953,14 +8957,22 @@ function isReportNotFound(report: OnyxEntry): boolean { /** * Check if the report is the parent report of the currently viewed report or at least one child report has report action */ -function shouldHideReport(report: OnyxEntry, currentReportId: string | undefined, isReportArchived: boolean | undefined): boolean { +function shouldHideReport( + report: OnyxEntry, + currentReportId: string | undefined, + isReportArchived: boolean | undefined, + visibleReportActionsData?: VisibleReportActionsDerivedValue, +): boolean { const currentReport = getReportOrDraftReport(currentReportId); const parentReport = getParentReport(!isEmptyObject(currentReport) ? currentReport : undefined); const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? {}; - const isChildReportHasComment = Object.values(reportActions ?? {})?.some( - (reportAction) => - (reportAction?.childVisibleActionCount ?? 0) > 0 && shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction(report, isReportArchived)), - ); + const reportID = report?.reportID; + const canPerformWrite = canUserPerformWriteAction(report, isReportArchived); + const isChildReportHasComment = + !!reportID && + Object.values(reportActions ?? {})?.some( + (reportAction) => (reportAction?.childVisibleActionCount ?? 0) > 0 && isReportActionVisible(reportAction, reportID, canPerformWrite, visibleReportActionsData), + ); return parentReport?.reportID !== report?.reportID && !isChildReportHasComment; } @@ -10007,7 +10019,13 @@ function isMoneyRequestReportPendingDeletion(reportOrID: OnyxEntry | str return parentReportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } -function navigateToLinkedReportAction(ancestor: Ancestor, isInNarrowPaneModal: boolean, canUserPerformWrite: boolean | undefined, isOffline: boolean) { +function navigateToLinkedReportAction( + ancestor: Ancestor, + isInNarrowPaneModal: boolean, + canUserPerformWrite: boolean | undefined, + isOffline: boolean, + visibleReportActionsData?: VisibleReportActionsDerivedValue, +) { if (isInNarrowPaneModal) { Navigation.navigate( ROUTES.SEARCH_REPORT.getRoute({ @@ -10022,7 +10040,8 @@ function navigateToLinkedReportAction(ancestor: Ancestor, isInNarrowPaneModal: b // Pop the thread report screen before navigating to the chat report. Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID)); - const isVisibleAction = shouldReportActionBeVisible(ancestor.reportAction, ancestor.reportAction.reportActionID, canUserPerformWrite); + const reportID = ancestor.report.reportID; + const isVisibleAction = !!reportID && isReportActionVisible(ancestor.reportAction, reportID, canUserPerformWrite, visibleReportActionsData); if (isVisibleAction && !isOffline) { // Pop the chat report screen before navigating to the linked report action. diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index d80a5012ed53..e272249aa016 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -106,9 +106,9 @@ import { isDeletedAction, isHoldAction, isMoneyRequestAction, + isReportActionVisible, isResolvedActionableWhisper, isWhisperActionTargetedToOthers, - shouldReportActionBeVisible, } from './ReportActionsUtils'; import {isExportAction} from './ReportPrimaryActionUtils'; import { @@ -485,6 +485,7 @@ type GetSectionsParams = { cardList?: OnyxTypes.CardList; customCardNames?: Record; allTransactionViolations?: OnyxCollection; + visibleReportActionsData?: OnyxTypes.VisibleReportActionsDerivedValue; allReportMetadata: OnyxCollection; }; @@ -2018,7 +2019,7 @@ function createAndOpenSearchTransactionThread( * * Do not use directly, use only via `getSections()` facade. */ -function getReportActionsSections(data: OnyxTypes.SearchResults['data']): [ReportActionListItemType[], number] { +function getReportActionsSections(data: OnyxTypes.SearchResults['data'], visibleReportActionsData?: OnyxTypes.VisibleReportActionsDerivedValue): [ReportActionListItemType[], number] { const reportActionItems: ReportActionListItemType[] = []; const transactions = Object.keys(data) @@ -2037,11 +2038,13 @@ function getReportActionsSections(data: OnyxTypes.SearchResults['data']): [Repor for (const key in data) { if (isReportActionEntry(key)) { + const reportIDFromKey = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); const reportActions = Object.values(data[key]); n += reportActions.length; for (const reportAction of reportActions) { + const reportID = reportAction.reportID ?? reportIDFromKey; const from = reportAction.accountID ? (data.personalDetailsList?.[reportAction.accountID] ?? emptyPersonalDetails) : emptyPersonalDetails; - const report = data[`${ONYXKEYS.COLLECTION.REPORT}${reportAction.reportID}`] ?? {}; + const report = data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? {}; const policy = data[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] ?? {}; const originalMessage = isMoneyRequestAction(reportAction) ? getOriginalMessage(reportAction) : undefined; const isSendingMoney = isMoneyRequestAction(reportAction) && originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && originalMessage?.IOUDetails; @@ -2049,7 +2052,8 @@ function getReportActionsSections(data: OnyxTypes.SearchResults['data']): [Repor const invoiceReceiverPolicy: OnyxTypes.Policy | undefined = report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS ? data[`${ONYXKEYS.COLLECTION.POLICY}${report.invoiceReceiver.policyID}`] : undefined; if ( - !shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction(report, isReportArchived)) || + !reportID || + !isReportActionVisible(reportAction, reportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData) || isDeletedAction(reportAction) || isResolvedActionableWhisper(reportAction) || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED || @@ -2731,11 +2735,12 @@ function getSections({ cardFeeds, customCardNames, allTransactionViolations, + visibleReportActionsData, allReportMetadata, cardList, }: GetSectionsParams) { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { - return getReportActionsSections(data); + return getReportActionsSections(data, visibleReportActionsData); } if (type === CONST.SEARCH.DATA_TYPES.TASK) { return getTaskSections(data, formatPhoneNumber, archivedReportsIDList); diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index bc509a066f2e..ac22d4c53e58 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -5,7 +5,17 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleCon import type {PartialPolicyForSidebar, ReportsToDisplayInLHN} from '@hooks/useSidebarOrderedReports'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Card, PersonalDetails, PersonalDetailsList, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; +import type { + Card, + PersonalDetails, + PersonalDetailsList, + ReportActions, + ReportAttributesDerivedValue, + ReportNameValuePairs, + Transaction, + TransactionViolation, + VisibleReportActionsDerivedValue, +} from '@src/types/onyx'; import type Beta from '@src/types/onyx/Beta'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -677,7 +687,7 @@ function getOptionData({ movedFromReport, movedToReport, currentUserAccountID, - chatReport, + visibleReportActionsData, reportAttributesDerived, }: { report: OnyxEntry; @@ -699,7 +709,7 @@ function getOptionData({ movedFromReport?: OnyxEntry; movedToReport?: OnyxEntry; currentUserAccountID: number; - chatReport: OnyxEntry; + visibleReportActionsData?: VisibleReportActionsDerivedValue; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; }): OptionData | undefined { // When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for @@ -839,8 +849,9 @@ function getOptionData({ movedToReport, policy, isReportArchived, - chatReport, + reportMetadata, reportAttributesDerived, + lastAction, }); } @@ -1015,7 +1026,8 @@ function getOptionData({ result.alternateText = getCardIssuedMessage({reportAction: lastAction, expensifyCard: card, translate}); } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDisplayName && lastMessageTextFromReport) { const displayName = - (lastMessageTextFromReport.length > 0 && getLastActorDisplayNameFromLastVisibleActions(report, lastActorDetails, currentUserAccountID, personalDetails)) || + (lastMessageTextFromReport.length > 0 && + getLastActorDisplayNameFromLastVisibleActions(report, lastActorDetails, currentUserAccountID, personalDetails, visibleReportActionsData, lastAction)) || lastActorDisplayName; result.alternateText = formatReportLastMessageText(`${displayName}: ${lastMessageText}`); } else if (lastAction && isOldDotReportAction(lastAction)) { @@ -1090,7 +1102,7 @@ function getOptionData({ result.alternateText = lastMessageTextFromReport.length > 0 ? formatReportLastMessageText(Parser.htmlToText(lastMessageText)) - : getLastVisibleMessage(report.reportID, result.isAllowedToComment, {}, lastAction)?.lastMessageText; + : getLastVisibleMessage(report.reportID, result.isAllowedToComment, {}, lastAction, visibleReportActionsData)?.lastMessageText; if (!result.alternateText) { result.alternateText = formatReportLastMessageText( @@ -1110,7 +1122,8 @@ function getOptionData({ } if (shouldShowLastActorDisplayName(report, lastActorDetails, lastAction, currentUserAccountID) && !isReportArchived) { const displayName = - (lastMessageTextFromReport.length > 0 && getLastActorDisplayNameFromLastVisibleActions(report, lastActorDetails, currentUserAccountID, personalDetails)) || + (lastMessageTextFromReport.length > 0 && + getLastActorDisplayNameFromLastVisibleActions(report, lastActorDetails, currentUserAccountID, personalDetails, visibleReportActionsData, lastAction)) || lastActorDisplayName; result.alternateText = `${displayName}: ${formatReportLastMessageText(lastMessageText)}`; } else { diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 16e5abcd3348..5ad7a2defcde 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -7,6 +7,7 @@ import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCa import reportAttributesConfig from './configs/reportAttributes'; import reportTransactionsAndViolationsConfig from './configs/reportTransactionsAndViolations'; import todosConfig from './configs/todos'; +import visibleReportActionsConfig from './configs/visibleReportActions'; import type {OnyxDerivedValueConfig} from './types'; /** @@ -17,6 +18,7 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: reportAttributesConfig, [ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS]: reportTransactionsAndViolationsConfig, [ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID]: outstandingReportsByPolicyIDConfig, + [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: visibleReportActionsConfig, [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: nonPersonalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: personalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig, diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts new file mode 100644 index 000000000000..78ddf14fd141 --- /dev/null +++ b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts @@ -0,0 +1,167 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {isActionableWhisperRequiringWritePermission, isConciergeCategoryOptions, isMovedTransactionAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; +import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction, ReportActions} from '@src/types/onyx'; +import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; + +function getOrCreateReportVisibilityRecord(result: VisibleReportActionsDerivedValue, reportID: string): Record { + if (!result[reportID]) { + // Parameter reassignment is necessary here because we are building up the derived value + // object incrementally as we process report actions. Creating a new object would break + // the reference chain and lose previously computed visibility data. + // eslint-disable-next-line no-param-reassign + result[reportID] = {}; + } + return result[reportID]; +} + +function doesActionDependOnReportExistence(action: ReportAction): boolean { + const isUnreportedTransaction = action.actionName === CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION; + const isMovedTransaction = isMovedTransactionAction(action as OnyxEntry); + + return isUnreportedTransaction || isMovedTransaction; +} + +/** + * Returns true if the action's visibility depends on runtime context that can't be cached, + * such as write permissions or policy settings. + */ +function shouldSkipCachingAction(action: ReportAction): boolean { + return isActionableWhisperRequiringWritePermission(action) || isConciergeCategoryOptions(action); +} + +export default createOnyxDerivedValueConfig({ + key: ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, + // Note: REPORT dependency is needed both to trigger recompute when reports change + // (for UNREPORTED_TRANSACTION/MOVED_TRANSACTION visibility) AND to provide the current + // report collection to the visibility check, avoiding stale data from global connections. + // SESSION dependency is needed for whisper targeting when user changes. + // NETWORK is needed to recompute when online/offline status changes (for DELETE action visibility). + dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.SESSION, ONYXKEYS.NETWORK], + compute: ([allReportActions, allReports], {sourceValues, currentValue}): VisibleReportActionsDerivedValue => { + if (!allReportActions) { + return {}; + } + + const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS]; + const reportUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT]; + const sessionUpdates = sourceValues?.[ONYXKEYS.SESSION]; + const networkUpdates = sourceValues?.[ONYXKEYS.NETWORK]; + + // Session change = user changed, need full recompute due to whisper targeting + // Network change = online/offline status changed, need full recompute for DELETE action visibility + if (sessionUpdates || networkUpdates) { + const result: VisibleReportActionsDerivedValue = {}; + + for (const [reportActionsKey, reportActions] of Object.entries(allReportActions)) { + if (!reportActions) { + continue; + } + + const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + const reportVisibility = getOrCreateReportVisibilityRecord(result, reportID); + + for (const [actionID, action] of Object.entries(reportActions)) { + if (action) { + if (actionID !== action.reportActionID) { + continue; + } + if (shouldSkipCachingAction(action)) { + continue; + } + reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined, allReports); + } + } + } + + return result; + } + + const result: VisibleReportActionsDerivedValue = currentValue ? {...currentValue} : {}; + + // Reports changed - recompute actions that depend on report existence + if (reportUpdates) { + for (const [reportActionsKey, reportActions] of Object.entries(allReportActions)) { + if (!reportActions) { + continue; + } + + const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + const reportVisibility = getOrCreateReportVisibilityRecord(result, reportID); + + for (const [actionID, action] of Object.entries(reportActions)) { + if (!action) { + continue; + } + + // Skip deprecated keys (e.g. sequenceNumber-keyed duplicates) so they + // cannot overwrite the canonical entry's visibility with false. + if (actionID !== action.reportActionID) { + delete reportVisibility[actionID]; + continue; + } + + if (doesActionDependOnReportExistence(action)) { + if (shouldSkipCachingAction(action)) { + delete reportVisibility[action.reportActionID]; + continue; + } + reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined, allReports); + } + } + } + + if (!reportActionsUpdates) { + return result; + } + } + + const reportActionsToProcess = reportActionsUpdates ? Object.keys(reportActionsUpdates) : Object.keys(allReportActions); + + for (const reportActionsKey of reportActionsToProcess) { + const reportActions: OnyxEntry = allReportActions[reportActionsKey]; + const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + + if (!reportActions) { + delete result[reportID]; + continue; + } + + const reportVisibility = getOrCreateReportVisibilityRecord(result, reportID); + + const specificUpdates = reportActionsUpdates?.[reportActionsKey]; + const actionIDsToProcess = specificUpdates ? Object.keys(specificUpdates) : Object.keys(reportActions); + + for (const actionID of actionIDsToProcess) { + if (specificUpdates && specificUpdates[actionID] === null) { + delete reportVisibility[actionID]; + continue; + } + + const action = reportActions[actionID]; + if (!action) { + delete reportVisibility[actionID]; + continue; + } + + // Skip deprecated keys (e.g. sequenceNumber-keyed duplicates) so they + // cannot overwrite the canonical entry's visibility with false. + if (actionID !== action.reportActionID) { + delete reportVisibility[actionID]; + continue; + } + + if (shouldSkipCachingAction(action)) { + delete reportVisibility[action.reportActionID]; + continue; + } + + reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined, allReports); + } + } + + return result; + }, +}); diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index e8cbf9d3255c..bd92be1e5f47 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -210,6 +210,7 @@ import type { ReportUserIsTyping, Transaction, TransactionViolations, + VisibleReportActionsDerivedValue, } from '@src/types/onyx'; import type {Decision} from '@src/types/onyx/OriginalMessage'; import type {CurrentUserPersonalDetails, Timezone} from '@src/types/onyx/PersonalDetails'; @@ -2077,6 +2078,7 @@ function deleteReportComment( isReportArchived: boolean | undefined, isOriginalReportArchived: boolean | undefined, currentEmail: string, + visibleReportActionsDataParam?: VisibleReportActionsDerivedValue, ) { const reportID = report?.reportID; const originalReportID = getOriginalReportID(reportID, reportAction, undefined); @@ -2123,7 +2125,7 @@ function deleteReportComment( (action) => action.reportActionID !== reportAction.reportActionID && ReportActionsUtils.didMessageMentionCurrentUser(action, currentEmail) && - ReportActionsUtils.shouldReportActionBeVisible(action, action.reportActionID), + ReportActionsUtils.isReportActionVisible(action, reportID, undefined, visibleReportActionsDataParam), ); optimisticReport.lastMentionedTime = latestMentionedReportAction?.created ?? null; } @@ -2277,6 +2279,7 @@ function editReportComment( isOriginalParentReportArchived: boolean | undefined, currentUserLogin: string, videoAttributeCache?: Record, + visibleReportActionsDataParam?: VisibleReportActionsDerivedValue, ) { const originalReportID = originalReport?.reportID; if (!originalReportID || !originalReportAction) { @@ -2309,7 +2312,7 @@ function editReportComment( // Delete the comment if it's empty if (!htmlForNewComment) { - deleteReportComment(originalReport, originalReportAction, ancestors, isOriginalReportArchived, isOriginalParentReportArchived, currentUserLogin); + deleteReportComment(originalReport, originalReportAction, ancestors, isOriginalReportArchived, isOriginalParentReportArchived, currentUserLogin, visibleReportActionsDataParam); return; } diff --git a/src/libs/registerPaginationConfig.ts b/src/libs/registerPaginationConfig.ts index a05c1474a706..47940e8cb2c8 100644 --- a/src/libs/registerPaginationConfig.ts +++ b/src/libs/registerPaginationConfig.ts @@ -46,7 +46,7 @@ registerPaginationConfig({ const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; const isReportArchived = !!reportNameValuePairs?.private_isArchived; const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); - return getSortedReportActionsForDisplay(reportActions, canUserPerformWriteAction, true); + return getSortedReportActionsForDisplay(reportActions, canUserPerformWriteAction, true, undefined, reportID); }, getItemID: (reportAction) => reportAction.reportActionID, }); diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx index c0858a486141..e9da92529d5d 100644 --- a/src/pages/Debug/Report/DebugReportActions.tsx +++ b/src/pages/Debug/Report/DebugReportActions.tsx @@ -39,9 +39,9 @@ function DebugReportActions({reportID}: DebugReportActionsProps) { const getSortedAllReportActionsSelector = useCallback( (allReportActions: OnyxEntry): ReportAction[] => { - return getSortedReportActionsForDisplay(allReportActions, ifUserCanPerformWriteAction, true); + return getSortedReportActionsForDisplay(allReportActions, ifUserCanPerformWriteAction, true, undefined, reportID); }, - [ifUserCanPerformWriteAction], + [ifUserCanPerformWriteAction, reportID], ); const [sortedAllReportActions] = useOnyx( diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 2c3c96c59188..fbef1787564d 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -110,9 +110,9 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor includeSelfDM: true, shouldAlwaysIncludeDM: true, personalDetails: allPersonalDetails, + countryCode, + reportAttributesDerived, }, - countryCode, - reportAttributesDerived, ); const unselectedOptions = filterSelectedOptions(defaultOptions, new Set(selectedOptions.map(({accountID}) => accountID))); diff --git a/src/pages/Share/ShareTab.tsx b/src/pages/Share/ShareTab.tsx index c2b00f2e6581..7893ce140c64 100644 --- a/src/pages/Share/ShareTab.tsx +++ b/src/pages/Share/ShareTab.tsx @@ -53,6 +53,7 @@ function ShareTab({ref}: ShareTabProps) { const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -81,6 +82,7 @@ function ShareTab({ref}: ShareTabProps) { includeUserToInvite: true, countryCode, loginList, + visibleReportActionsData, currentUserAccountID, currentUserEmail, policyCollection: allPolicies, diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 0a8909dd0a2b..8ea80b0d9055 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -60,10 +60,10 @@ import { isCreatedAction, isDeletedParentAction, isMoneyRequestAction, + isReportActionVisible, isSentMoneyReportAction, isTransactionThread, isWhisperAction, - shouldReportActionBeVisible, } from '@libs/ReportActionsUtils'; import { canEditReportAction, @@ -309,6 +309,7 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const {reportActions: unfilteredReportActions, linkedAction, sortedAllReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionIDFromRoute); // wrapping in useMemo because this is array operation and can cause performance issues const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); @@ -462,10 +463,16 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr const isReportArchived = useReportIsArchived(report?.reportID); const {isEditingDisabled, isCurrentReportLoadedFromOnyx} = useIsReportReadyToDisplay(report, reportIDFromRoute, isReportArchived); - const isLinkedActionDeleted = useMemo( - () => !!linkedAction && !shouldReportActionBeVisible(linkedAction, linkedAction.reportActionID, canUserPerformWriteAction(report, isReportArchived)), - [linkedAction, report, isReportArchived], - ); + const isLinkedActionDeleted = useMemo(() => { + if (!linkedAction) { + return false; + } + const actionReportID = linkedAction.reportID ?? reportID; + if (!actionReportID) { + return true; + } + return !isReportActionVisible(linkedAction, actionReportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData); + }, [linkedAction, report, isReportArchived, reportID, visibleReportActionsData]); const prevIsLinkedActionDeleted = usePrevious(linkedAction ? isLinkedActionDeleted : undefined); diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index dec8f92deaad..930cde61b055 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -87,6 +87,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro const [shouldSwitchPositionIfOverflow, setShouldSwitchPositionIfOverflow] = useState(false); const [isWithoutOverlay, setIsWithoutOverlay] = useState(true); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const contentRef = useRef(null); const anchorRef = useRef(null); @@ -373,7 +374,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro } else if (reportAction) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - deleteReportComment(report, reportAction, ancestorsRef.current, isReportArchived, isOriginalReportArchived, email ?? ''); + deleteReportComment(report, reportAction, ancestorsRef.current, isReportArchived, isOriginalReportArchived, email ?? '', visibleReportActionsData ?? undefined); }); } @@ -397,6 +398,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro reportTransactions, bankAccountList, isOriginalReportArchived, + visibleReportActionsData, ]); const hideDeleteModal = () => { diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index a7656bba2ea9..97e5b5fcff09 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -152,6 +152,7 @@ function ReportActionItemMessageEdit({ const isCommentPendingSaved = useRef(false); const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const isOriginalReportArchived = useReportIsArchived(originalReportID); const originalParentReportID = getOriginalReportID(originalReportID, action, reportActions); const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); @@ -326,6 +327,7 @@ function ReportActionItemMessageEdit({ isOriginalParentReportArchived, email ?? '', Object.fromEntries(draftMessageVideoAttributeCache), + visibleReportActionsData ?? undefined, ); deleteDraft(); }, [ @@ -340,6 +342,7 @@ function ReportActionItemMessageEdit({ isOriginalParentReportArchived, debouncedValidateCommentMaxLength, email, + visibleReportActionsData, ]); /** diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index da31aba6c202..c82e33674af6 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -29,7 +29,7 @@ import { isDeletedParentAction, isIOUActionMatchingTransactionList, isMoneyRequestAction, - shouldReportActionBeVisible, + isReportActionVisible, } from '@libs/ReportActionsUtils'; import {buildOptimisticCreatedReportAction, buildOptimisticIOUReportAction, canUserPerformWriteAction, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; @@ -88,9 +88,9 @@ function ReportActionsView({ const getTransactionThreadReportActions = useCallback( (reportActions: OnyxEntry): OnyxTypes.ReportAction[] => { - return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true); + return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined); }, - [canPerformWriteAction], + [canPerformWriteAction, transactionThreadReportID], ); const [transactionThreadReportActions] = useOnyx( @@ -102,6 +102,7 @@ function ReportActionsView({ ); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const prevTransactionThreadReport = usePrevious(transactionThreadReport); const reportActionID = route?.params?.reportActionID; const prevReportActionID = usePrevious(reportActionID); @@ -218,13 +219,26 @@ function ReportActionsView({ const visibleReportActions = useMemo( () => - reportActions.filter( - (reportAction) => - (isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && - shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction) && - isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs), - ), - [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs], + reportActions.filter((reportAction) => { + const passesOfflineCheck = + isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors; + + if (!passesOfflineCheck) { + return false; + } + + const actionReportID = reportAction.reportID ?? reportID; + if (!isReportActionVisible(reportAction, actionReportID, canPerformWriteAction, visibleReportActionsData)) { + return false; + } + + if (!isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs)) { + return false; + } + + return true; + }), + [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, visibleReportActionsData, reportID], ); const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); @@ -309,7 +323,8 @@ function ReportActionsView({ return ; } - if (!isReportTransactionThread && isMissingReportActions) { + const hasDerivedValueTimingIssue = reportActions.length > 0 && isMissingReportActions; + if (hasDerivedValueTimingIssue || (!isReportTransactionThread && isMissingReportActions)) { return ; } diff --git a/src/pages/iou/request/MoneyRequestAccountantSelector.tsx b/src/pages/iou/request/MoneyRequestAccountantSelector.tsx index 91d27223c2a8..d2e8a79020be 100644 --- a/src/pages/iou/request/MoneyRequestAccountantSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAccountantSelector.tsx @@ -97,8 +97,8 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, action, personalDetails, + countryCode, }, - countryCode, ); const orderedOptions = orderOptions(optionList); diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 15b11f237c37..114c11f0b4e6 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -77,6 +77,11 @@ type ReportTransactionsAndViolationsDerivedValue = Record>; +/** + * The derived value for visible report actions. + */ +type VisibleReportActionsDerivedValue = Record>; + /** * The errors of a card. */ @@ -258,6 +263,7 @@ export type { ReportTransactionsAndViolationsDerivedValue, ReportTransactionsAndViolations, OutstandingReportsByPolicyIDDerivedValue, + VisibleReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index ff81bed733a2..f86ee284b367 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -50,6 +50,7 @@ import type { ReportTransactionsAndViolationsDerivedValue, TodoMetadata, TodosDerivedValue, + VisibleReportActionsDerivedValue, } from './DerivedValues'; import type DeviceBiometrics from './DeviceBiometrics'; import type DismissedProductTraining from './DismissedProductTraining'; @@ -346,6 +347,7 @@ export type { LastSearchParams, ReportTransactionsAndViolationsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, + VisibleReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 7396d36c798d..501ad1041ab6 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -538,6 +538,7 @@ describe('actions/Report', () => { person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], shouldShow: true, created: DateUtils.getDBTime(Date.now() - 3), + reportID: REPORT_ID, }; const optimisticReportActions: OnyxUpdate = { diff --git a/tests/perf-test/SidebarUtils.perf-test.ts b/tests/perf-test/SidebarUtils.perf-test.ts index 348f9d055308..9947b3bdcbbb 100644 --- a/tests/perf-test/SidebarUtils.perf-test.ts +++ b/tests/perf-test/SidebarUtils.perf-test.ts @@ -93,7 +93,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 1, - chatReport: undefined, }), ); }); diff --git a/tests/ui/components/LHNOptionsListTest.tsx b/tests/ui/components/LHNOptionsListTest.tsx index 76b147044f4b..aed04222164c 100644 --- a/tests/ui/components/LHNOptionsListTest.tsx +++ b/tests/ui/components/LHNOptionsListTest.tsx @@ -14,6 +14,36 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, ReportAction} from '@src/types/onyx'; import {getFakeReport} from '../../utils/LHNTestUtils'; +// Mock dynamic imports that break without --experimental-vm-modules +jest.mock('@src/languages/IntlStore', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const en: Record = require('@src/languages/en').default; + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const flattenObject: (obj: Record) => Record = require('@src/languages/flattenObject').default; + + const cache = new Map>([['en', flattenObject(en)]]); + + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: { + getCurrentLocale: () => 'en', + load: () => Promise.resolve(), + get: (key: string, locale?: string) => { + const translations = cache.get(locale ?? 'en'); + return translations?.[key] ?? null; + }, + }, + }; +}); +jest.mock('@assets/emojis', () => ({ + importEmojiLocale: jest.fn(() => Promise.resolve()), + getEmojiCodeWithSkinColor: jest.fn(), +})); +jest.mock('@libs/EmojiTrie', () => ({ + buildEmojisTrie: jest.fn(), +})); + // Mock the context menu jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({ showContextMenu: jest.fn(), @@ -210,12 +240,18 @@ describe('LHNOptionsList', () => { await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [submittedAction.reportActionID]: submittedAction, - }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, }); + + await Onyx.merge(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { + [reportID]: { + [submittedAction.reportActionID]: true, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [submittedAction.reportActionID]: submittedAction, + }); }); // When the LHNOptionsList is rendered @@ -261,12 +297,18 @@ describe('LHNOptionsList', () => { await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [commentAction.reportActionID]: commentAction, - }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, }); + + await Onyx.merge(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { + [reportID]: { + [commentAction.reportActionID]: true, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [commentAction.reportActionID]: commentAction, + }); }); // When the LHNOptionsList is rendered diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 082732450972..655153408f9f 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -4239,8 +4239,8 @@ describe('OptionsListUtils', () => { }); describe('getLastMessageTextForReport', () => { - describe('REPORT_PREVIEW action', () => { - it('should show report preview message for non-policy expense chat', async () => { + describe('getReportPreviewMessage', () => { + it('should format report preview message correctly for non-policy expense chat with IOU action', async () => { const report: Report = { ...createRandomReport(0, undefined), isOwnPolicyExpenseChat: false, @@ -4250,6 +4250,9 @@ describe('OptionsListUtils', () => { isOwnPolicyExpenseChat: false, type: CONST.REPORT.TYPE.IOU, isWaitingOnBankAccount: false, + currency: CONST.CURRENCY.USD, + total: 100, + unheldTotal: 100, }; const reportPreviewAction: ReportAction = { ...createRandomReportAction(1), @@ -4273,6 +4276,7 @@ describe('OptionsListUtils', () => { }; const iouAction: ReportAction = { ...createRandomReportAction(2), + reportID: iouReport.reportID, actionName: CONST.REPORT.ACTIONS.TYPE.IOU, message: [{type: 'COMMENT', text: ''}], originalMessage: { @@ -4289,16 +4293,9 @@ describe('OptionsListUtils', () => { [iouAction.reportActionID]: iouAction, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - const lastMessage = getLastMessageTextForReport({ - translate: translateLocal, - report, - lastActorDetails: null, - isReportArchived: false, - chatReport: undefined, - }); const reportPreviewMessage = getReportPreviewMessage(iouReport, iouAction, true, false, null, true, reportPreviewAction); - const expected = formatReportLastMessageText(Parser.htmlToText(reportPreviewMessage)); - expect(lastMessage).toBe(expected); + const formattedMessage = formatReportLastMessageText(Parser.htmlToText(reportPreviewMessage)); + expect(formattedMessage).toBe('$1.00 for A A A'); }); }); it('MOVED_TRANSACTION action', async () => { diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index f1727bb15593..bbb366f16760 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -34,7 +34,7 @@ import { getUpdateACHAccountMessage, isIOUActionMatchingTransactionList, isNewerReportAction, - shouldReportActionBeVisibleAsLastAction, + isReportActionVisibleAsLastAction, } from '../../src/libs/ReportActionsUtils'; import {buildOptimisticCreatedReportForUnapprovedAction} from '../../src/libs/ReportUtils'; import ONYXKEYS from '../../src/ONYXKEYS'; @@ -588,6 +588,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-13 22:27:01.825', reportActionID: '8401445780099176', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -604,6 +605,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-12 22:27:01.825', reportActionID: '6401435781022176', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, originalMessage: { html: 'Hello world', @@ -620,6 +622,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-11 22:27:01.825', reportActionID: '2962390724708756', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.IOU, originalMessage: { amount: 0, @@ -637,6 +640,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-10 22:27:01.825', reportActionID: '1609646094152486', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.RENAMED, originalMessage: { html: 'Hello world', @@ -655,6 +659,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-09 22:27:01.825', reportActionID: '8049485084562457', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD, originalMessage: {}, message: [{html: 'updated the Approval Mode from "Submit and Approve" to "Submit and Close"', type: 'Action type', text: 'Action text'}], @@ -662,6 +667,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-08 22:27:06.825', reportActionID: '1661970171066216', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_QUEUED, originalMessage: { paymentType: 'ACH', @@ -671,6 +677,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-06 22:27:08.825', reportActionID: '1661970171066220', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.TASK_EDITED, originalMessage: { html: 'Hello world', @@ -692,6 +699,7 @@ describe('ReportActionsUtils', () => { const movedTransactionAction: ReportAction = { created: '2022-11-13 22:27:01.825', reportActionID: '8401445780099177', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, originalMessage: { fromReportID: CONST.REPORT.UNREPORTED_REPORT_ID, @@ -702,6 +710,7 @@ describe('ReportActionsUtils', () => { const addCommentAction: ReportAction = { created: '2022-11-12 22:27:01.825', reportActionID: '6401435781022176', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -727,6 +736,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-13 22:27:01.825', reportActionID: '8401445780099176', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -743,6 +753,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-12 22:27:01.825', reportActionID: '6401435781022176', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, originalMessage: { html: 'Hello world', @@ -759,6 +770,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-11 22:27:01.825', reportActionID: '2962390724708756', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.IOU, originalMessage: { amount: 0, @@ -776,6 +788,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-10 22:27:01.825', reportActionID: '1609646094152486', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.RENAMED, originalMessage: { html: 'Hello world', @@ -794,6 +807,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-09 22:27:01.825', reportActionID: '1661970171066218', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CLOSED, originalMessage: { policyName: 'default', // change to const @@ -822,6 +836,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-13 22:27:01.825', reportActionID: '8401445780099176', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -838,6 +853,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-12 22:27:01.825', reportActionID: '8401445780099175', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -849,6 +865,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-11 22:27:01.825', reportActionID: '8401445780099174', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -871,6 +888,7 @@ describe('ReportActionsUtils', () => { { created: '2024-11-19 08:04:13.728', reportActionID: '1607371725956675966', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: '', @@ -890,6 +908,7 @@ describe('ReportActionsUtils', () => { { created: '2024-11-19 08:00:14.352', reportActionID: '4655978522337302598', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: '#join', @@ -908,6 +927,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-09 22:27:01.825', reportActionID: '8049485084562457', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_REPORT_MENTION_WHISPER, originalMessage: { lastModified: '2024-11-19 08:00:14.353', @@ -925,6 +945,7 @@ describe('ReportActionsUtils', () => { { created: '2022-11-12 22:27:01.825', reportActionID: '6401435781022176', + reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_MENTION_WHISPER, originalMessage: { inviteeAccountIDs: [18414674], @@ -1061,6 +1082,7 @@ describe('ReportActionsUtils', () => { ...LHNTestUtils.getFakeReportAction('email1@test.com', 3), created: '2023-08-01 16:00:00', reportActionID: 'action1', + reportID: '1', actionName: 'ADDCOMMENT', originalMessage: { html: 'Hello world', @@ -1071,6 +1093,7 @@ describe('ReportActionsUtils', () => { ...LHNTestUtils.getFakeReportAction('email2@test.com', 3), created: '2023-08-01 18:00:00', reportActionID: 'action2', + reportID: '1', actionName: 'ADDCOMMENT', originalMessage: { html: 'Hello world', @@ -3563,6 +3586,7 @@ describe('ReportActionsUtils', () => { ({ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, reportActionID: '1', + reportID: 'testReportID', created: '2024-01-01 00:00:00.000', person: [{type: 'TEXT', style: 'strong', text: 'Actor'}], message: [{html: 'hello', text: 'hello', type: 'COMMENT'}], @@ -3662,7 +3686,7 @@ describe('ReportActionsUtils', () => { const actions: ReportActions = {actionA, actionB, actionC}; const {lastActionForDisplay} = findLastReportActions(actions); const fromOldApproach = getSortedReportActions(Object.values(actions)).findLast( - (a) => shouldReportActionBeVisibleAsLastAction(a) && a.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, + (a) => isReportActionVisibleAsLastAction(a) && a.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, ); expect(lastActionForDisplay?.reportActionID).toBe(fromOldApproach?.reportActionID); }); diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index a5b424dbac22..a018ef98ab66 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -356,7 +356,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: undefined, }); const optionDataUnpinned = SidebarUtils.getOptionData({ @@ -376,7 +376,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: undefined, }); @@ -1345,7 +1345,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: undefined, }); @@ -1411,7 +1411,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: undefined, }); @@ -1452,7 +1452,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: undefined, }); @@ -1492,7 +1492,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: undefined, }); @@ -1532,7 +1532,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: undefined, }); @@ -1600,7 +1600,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: undefined, }); @@ -1654,7 +1654,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, }); expect(optionData?.alternateText).toBe(`test message`); @@ -1698,7 +1697,7 @@ describe('SidebarUtils', () => { isReportArchived: true, lastActionReport: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: undefined, }); @@ -1740,7 +1739,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, }); expect(optionData?.alternateText).toBe(`test message`); @@ -1880,7 +1878,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, + reportAttributesDerived: mockReportAttributesDerived, }); @@ -1927,7 +1925,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, }); expect(optionData?.alternateText).toBe(`${policy.name} ${CONST.DOT_SEPARATOR} test message`); @@ -2002,7 +1999,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: session.accountID, - chatReport: undefined, }); // Then the alternate text should be equal to the message of the last action prepended with the last actor display name. @@ -2066,7 +2062,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: session.accountID, - chatReport: undefined, }); expect(result?.alternateText).toBe(`You: moved this report to the Three's Workspace workspace`); @@ -2121,7 +2116,6 @@ describe('SidebarUtils', () => { isReportArchived: undefined, lastMessageTextFromReport: report.lastMessageText, currentUserAccountID: session.accountID, - chatReport: undefined, }); expect(result?.alternateText).toBe('You: someMessage'); @@ -2157,6 +2151,7 @@ describe('SidebarUtils', () => { const lastAction: ReportAction = { ...createRandomReportAction(1), + reportID: iouReportR14932.reportID, message: [ { type: 'COMMENT', @@ -2180,8 +2175,16 @@ describe('SidebarUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportR14932.reportID}`, iouReportR14932); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportR14932.reportID}`, chatReportR14932); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {[lastAction.reportActionID]: lastAction}); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportR14932.reportID}`, {[linkedCreateAction.reportActionID]: linkedCreateAction}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportR14932.reportID}`, { + [linkedCreateAction.reportActionID]: linkedCreateAction, + [lastAction.reportActionID]: lastAction, + }); + await Onyx.set(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { + [iouReportR14932.reportID]: { + [linkedCreateAction.reportActionID]: true, + [lastAction.reportActionID]: true, + }, + }); }); const result = SidebarUtils.getOptionData({ @@ -2201,7 +2204,12 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: session.accountID, - chatReport: undefined, + visibleReportActionsData: { + [iouReportR14932.reportID]: { + [linkedCreateAction.reportActionID]: true, + [lastAction.reportActionID]: true, + }, + }, }); expect(result?.alternateText).toBe(`You: ${getReportActionMessageText(lastAction)}`); @@ -2222,6 +2230,7 @@ describe('SidebarUtils', () => { }; const lastAction: ReportAction = { ...createRandomReportAction(1), + reportID: '1', message: [ { type: 'COMMENT', @@ -2241,6 +2250,7 @@ describe('SidebarUtils', () => { }; const deletedAction: ReportAction = { ...createRandomReportAction(2), + reportID: '1', actionName: 'IOU', actorAccountID: 20337430, automatic: false, @@ -2322,8 +2332,8 @@ describe('SidebarUtils', () => { lastAction, lastActionReport: undefined, isReportArchived: undefined, + lastMessageTextFromReport: 'test action', currentUserAccountID: 0, - chatReport: undefined, }); expect(result?.alternateText).toContain(`${getReportActionMessageText(lastAction)}`); @@ -2412,7 +2422,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, }); expect(result?.alternateText).toBe(`One: submitted`); @@ -2513,7 +2522,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: managerID, - chatReport: undefined, }); const reportPreviewMessage = getReportPreviewMessage(iouReport, iouAction, true, true, null, true, lastReportPreviewAction); @@ -2615,7 +2623,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: managerID, - chatReport: undefined, }); const reportPreviewMessage = getReportPreviewMessage(iouReport, iouAction, true, true, null, true, lastReportPreviewAction); @@ -2649,7 +2656,6 @@ describe('SidebarUtils', () => { invoiceReceiverPolicy: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, }); // Then isConciergeChat should be true @@ -2681,7 +2687,6 @@ describe('SidebarUtils', () => { invoiceReceiverPolicy: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, }); // Then isConciergeChat should be false @@ -2712,7 +2717,6 @@ describe('SidebarUtils', () => { invoiceReceiverPolicy: undefined, isReportArchived: undefined, currentUserAccountID: 0, - chatReport: undefined, }); // Then isConciergeChat should be false