From c4817bd1d580db57f62bf50a16883fe839e0678b Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 29 Oct 2025 10:05:39 +0100 Subject: [PATCH 01/11] First implementation. --- src/libs/ReportNameUtils.ts | 545 ++++++++++++++++++++++++++++++ src/libs/ReportUtils.ts | 16 + tests/unit/ReportNameUtilsTest.ts | 2 + 3 files changed, 563 insertions(+) create mode 100644 src/libs/ReportNameUtils.ts create mode 100644 tests/unit/ReportNameUtilsTest.ts diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts new file mode 100644 index 0000000000000..e457f83c70159 --- /dev/null +++ b/src/libs/ReportNameUtils.ts @@ -0,0 +1,545 @@ +/** + * This file contains utility functions for managing and computing report names + */ +import { Str } from 'expensify-common'; +import type { OnyxCollection, OnyxEntry } from 'react-native-onyx'; +import { translateLocal } from '@libs/Localize'; +import { getForReportAction, getMovedReportID } from '@libs/ModifiedExpenseMessage'; +import Parser from '@libs/Parser'; +import { getCleanedTagName } from '@libs/PolicyUtils'; +import { getActionableCardFraudAlertResolutionMessage, getCardIssuedMessage, getChangedApproverActionMessage, getIntegrationSyncFailedMessage, getJoinRequestMessage, getMessageOfOldDotReportAction, getOriginalMessage, getPolicyChangeLogDefaultBillableMessage, getPolicyChangeLogDefaultReimbursableMessage, getPolicyChangeLogDefaultTitleEnforcedMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getRenamedAction, getReopenedMessage, getReportActionMessage as getReportActionMessageReportUtils, getRetractedMessage, getTravelUpdateMessage, getWorkspaceCurrencyUpdateMessage, getWorkspaceFrequencyUpdateMessage, getWorkspaceReportFieldAddMessage, getWorkspaceReportFieldDeleteMessage, getWorkspaceReportFieldUpdateMessage, getWorkspaceTagUpdateMessage, getWorkspaceUpdateFieldMessage, isActionableJoinRequest, isActionOfType, isCardIssuedAction, isMarkAsClosedAction, isModifiedExpenseAction, isMoneyRequestAction, isMovedAction, isOldDotReportAction, isRenamedAction, isReportActionAttachment, isTagModificationAction, isTransactionThread, isUnapprovedAction } from '@libs/ReportActionsUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type { PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, Transaction } from '@src/types/onyx'; +import type { ReportNameValuePairsCollectionDataSet } from '@src/types/onyx/ReportNameValuePairs'; +import type {SearchPolicy} from '@src/types/onyx/SearchResults'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import { + formatReportLastMessageText, + getDisplayNameForParticipant, + getDowngradeWorkspaceMessage, + getGroupChatName, + getInvoicesChatName, + getMoneyRequestSpendBreakdown, + getMovedActionMessage, getParentReport, + getPolicyChangeMessage, + getPolicyExpenseChatName, getPolicyName, + getRejectedReportMessage, getReportOrDraftReport, + getTransactionReportName, + getUnreportedTransactionMessage, + getUpgradeWorkspaceMessage, + getWorkspaceNameUpdatedMessage, hasNonReimbursableTransactions, + isAdminRoom, + isArchivedNonExpenseReport, + isCanceledTaskReport, + isChatRoom, + isChatThread, + isClosedExpenseReportWithNoExpenses, + isConciergeChatReport, + isExpenseReport, + isGroupChat, + isInvoiceReport, + isInvoiceRoom, + isMoneyRequestReport, + isNewDotInvoice, isOpenExpenseReport, isOpenInvoiceReport, + isPolicyExpenseChat, isProcessingReport, isReportApproved, + isSelfDM, isSettled, + isTaskReport, + isThread, + isTripRoom, + isUserCreatedPolicyRoom, +} from './ReportUtils'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; +import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; +import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; + +function generateArchivedReportName(reportName: string): string { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return `${reportName} (${translateLocal('common.archived')}) `; +} + +/** + * Generates a report title using the names of participants, excluding the current user. + * This function is useful in contexts such as 1:1 direct messages (DMs) or other group chats. + * It limits to a maximum of 5 participants for the title and uses short names unless there is only one participant. + */ +const buildReportNameFromParticipantNames = ({report, personalDetailsList: personalDetailsData}: {report: OnyxEntry; personalDetailsList?: Partial}) => + Object.keys(report?.participants ?? {}) + .map(Number) + .filter((id) => id !== currentUserAccountID) + .slice(0, 5) + .map((accountID) => ({ + accountID, + name: getDisplayNameForParticipant({ + accountID, + shouldUseShortForm: true, + personalDetailsData, + }), + })) + .filter((participant) => participant.name) + .reduce((formattedNames, {name, accountID}, _, array) => { + // If there is only one participant (if it is 0 or less the function will return empty string), return their full name + if (array.length < 2) { + return getDisplayNameForParticipant({ + accountID, + personalDetailsData, + }); + } + return formattedNames ? `${formattedNames}, ${name}` : name; + }, ''); + +function getInvoiceReportName(report: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry): string { + const moneyRequestReportName = getMoneyRequestReportName({report, policy, invoiceReceiverPolicy}); + const oldDotInvoiceName = report?.reportName ?? moneyRequestReportName; + return isNewDotInvoice(report?.chatReportID) ? moneyRequestReportName : oldDotInvoiceName; +} + + +/** + * Get the invoice payer name based on its type: + * - Individual - a receiver display name. + * - Policy - a receiver policy name. + */ +function getInvoicePayerName(report: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry | SearchPolicy, invoiceReceiverPersonalDetail?: PersonalDetails | null): string { + const invoiceReceiver = report?.invoiceReceiver; + const invoiceReceiverPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiver?.policyID}`] + const isIndividual = invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; + + if (isIndividual) { + return formatPhoneNumber(getDisplayNameOrDefault(invoiceReceiverPersonalDetail ?? allPersonalDetails?.[invoiceReceiver.accountID])); + } + + return getPolicyName({report, policy: invoiceReceiverPolicy}); +} + + +/** + * Get the title for an IOU or expense chat which will be showing the payer and the amount + */ +function getMoneyRequestReportName({report, policy, invoiceReceiverPolicy}: { + report: OnyxEntry; + policy?: OnyxEntry | SearchPolicy; + invoiceReceiverPolicy?: OnyxEntry | SearchPolicy; +}): string { + if (report?.reportName && isExpenseReport(report)) { + return report.reportName; + } + + const moneyRequestTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; + const formattedAmount = convertToDisplayString(moneyRequestTotal, report?.currency); + + let payerOrApproverName; + if (isExpenseReport(report)) { + const parentReport = getParentReport(report); + payerOrApproverName = getPolicyName({report: parentReport ?? report, policy}); + } else if (isInvoiceReport(report)) { + const chatReport = getReportOrDraftReport(report?.chatReportID); + payerOrApproverName = getInvoicePayerName(chatReport, invoiceReceiverPolicy); + } else { + payerOrApproverName = getDisplayNameForParticipant({accountID: report?.managerID}) ?? ''; + } + // eslint-disable-next-line @typescript-eslint/no-deprecated + const payerPaidAmountMessage = translateLocal('iou.payerPaidAmount', { + payer: payerOrApproverName, + amount: formattedAmount, + }); + + if (isReportApproved({report})) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.managerApprovedAmount', { + manager: payerOrApproverName, + amount: formattedAmount, + }); + } + + if (report?.isWaitingOnBankAccount) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return `${payerPaidAmountMessage} ${CONST.DOT_SEPARATOR} ${translateLocal('iou.pending')}`; + } + + if (!isSettled(report?.reportID) && hasNonReimbursableTransactions(report?.reportID)) { + payerOrApproverName = getDisplayNameForParticipant({accountID: report?.ownerAccountID}) ?? ''; + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.payerSpentAmount', {payer: payerOrApproverName, amount: formattedAmount}); + } + + if (isProcessingReport(report) || isOpenExpenseReport(report) || isOpenInvoiceReport(report) || moneyRequestTotal === 0) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.payerOwesAmount', {payer: payerOrApproverName, amount: formattedAmount}); + } + + return payerPaidAmountMessage; +} + +function computeReportNameBasedOnReportAction(parentReportAction?: ReportAction, report?: Report, reportPolicy?: Policy, parentReport?: Report): string | undefined { + if (!parentReportAction) { + return undefined; + } + if ( + isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) || + isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED) || + isMarkAsClosedAction(parentReportAction) + ) { + const harvesting = !isMarkAsClosedAction(parentReportAction) ? (getOriginalMessage(parentReportAction)?.harvesting ?? false) : false; + if (harvesting) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.automaticallySubmitted'); + } + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.submitted', {memo: getOriginalMessage(parentReportAction)?.message}); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) { + const {automaticAction} = getOriginalMessage(parentReportAction) ?? {}; + if (automaticAction) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.automaticallyForwarded'); + } + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.forwarded'); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REJECTED) { + return getRejectedReportMessage(); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RETRACTED) { + return getRetractedMessage(); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REOPENED) { + return getReopenedMessage(); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_UPGRADE) { + return getUpgradeWorkspaceMessage(); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.TEAM_DOWNGRADE) { + return getDowngradeWorkspaceMessage(); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY) { + return getWorkspaceCurrencyUpdateMessage(parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD) { + return getWorkspaceUpdateFieldMessage(parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('systemMessage.mergedWithCashTransaction'); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME) { + return Str.htmlDecode(getWorkspaceNameUpdatedMessage(parentReportAction)); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) { + return getWorkspaceFrequencyUpdateMessage(parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD) { + return getWorkspaceReportFieldAddMessage(parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REPORT_FIELD) { + return getWorkspaceReportFieldUpdateMessage(parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_REPORT_FIELD) { + return getWorkspaceReportFieldDeleteMessage(parentReportAction); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { + return getUnreportedTransactionMessage(); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT)) { + return getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(parentReportAction); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE)) { + return getPolicyChangeLogDefaultBillableMessage(parentReportAction); + } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE)) { + return getPolicyChangeLogDefaultReimbursableMessage(parentReportAction); + } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED)) { + return getPolicyChangeLogDefaultTitleEnforcedMessage(parentReportAction); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT) && getOriginalMessage(parentReportAction)?.resolution) { + return getActionableCardFraudAlertResolutionMessage(parentReportAction); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.paidElsewhere'); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY)) { + return getPolicyChangeMessage(parentReportAction); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL) || isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.REROUTE)) { + return getChangedApproverActionMessage(parentReportAction); + } + + if (parentReportAction?.actionName && isTagModificationAction(parentReportAction?.actionName)) { + return getCleanedTagName(getWorkspaceTagUpdateMessage(parentReportAction) ?? ''); + } + + if (isMovedAction(parentReportAction)) { + return getMovedActionMessage(parentReportAction, parentReport); + } + + if (isMoneyRequestAction(parentReportAction)) { + const originalMessage = getOriginalMessage(parentReportAction); + const last4Digits = reportPolicy?.achAccount?.accountNumber?.slice(-4) ?? ''; + + if (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { + if (originalMessage.paymentType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.paidElsewhere'); + } + if (originalMessage.paymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { + if (originalMessage.automaticAction) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.automaticallyPaidWithBusinessBankAccount', {last4Digits}); + } + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.businessBankAccount', {last4Digits}); + } + if (originalMessage.paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + if (originalMessage.automaticAction) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.automaticallyPaidWithExpensify'); + } + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.paidWithExpensify'); + } + } + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.APPROVED)) { + const {automaticAction} = getOriginalMessage(parentReportAction) ?? {}; + if (automaticAction) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.automaticallyApproved'); + } + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.approvedMessage'); + } + + if (isUnapprovedAction(parentReportAction)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.unapproved'); + } + + if (isActionableJoinRequest(parentReportAction)) { + return getJoinRequestMessage(parentReportAction); + } + + if (isTaskReport(report) && isCanceledTaskReport(report, parentReportAction)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('parentReportAction.deletedTask'); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED)) { + return getIntegrationSyncFailedMessage(parentReportAction, report?.policyID); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.TRAVEL_UPDATE)) { + return getTravelUpdateMessage(parentReportAction); + } + + return undefined; +} + +function computeChatThreadReportName(report: Report, reportNameValuePairs: ReportNameValuePairs, transactions: OnyxCollection, reports: OnyxCollection, parentReportAction?: ReportAction): string | undefined { + if (!isChatThread(report)) { + return undefined; + } + if (!parentReportAction) { + return undefined + } + + const parentReportActionMessage = getReportActionMessageReportUtils(parentReportAction); + const isArchivedNonExpense = isArchivedNonExpenseReport(report, !!reportNameValuePairs?.private_isArchived); + + if (!isEmptyObject(parentReportAction) && isTransactionThread(parentReportAction)) { + let formattedName = getTransactionReportName({reportAction: parentReportAction, transactions, reports}); + + if (isArchivedNonExpense) { + formattedName = generateArchivedReportName(formattedName); + } + return formatReportLastMessageText(formattedName); + } + + if (!isEmptyObject(parentReportAction) && isOldDotReportAction(parentReportAction)) { + return getMessageOfOldDotReportAction(parentReportAction); + } + + if (isRenamedAction(parentReportAction)) { + return getRenamedAction(parentReportAction, isExpenseReport(getReport(report.parentReportID, allReports))); + } + + if (parentReportActionMessage?.isDeletedParentAction) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('parentReportAction.deletedMessage'); + } + + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('violations.resolvedDuplicates'); + } + + const isAttachment = isReportActionAttachment(!isEmptyObject(parentReportAction) ? parentReportAction : undefined); + const reportActionMessage = getReportActionMessage({ + reportAction: parentReportAction, + reportID: report?.parentReportID, + childReportID: report?.reportID, + reports, + personalDetails, + }).replace(/(\n+|\r\n|\n|\r)/gm, ' '); + if (isAttachment && reportActionMessage) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return `[${translateLocal('common.attachment')}]`; + } + if ( + parentReportActionMessage?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_HIDE || + parentReportActionMessage?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_HIDDEN || + parentReportActionMessage?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE + ) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('parentReportAction.hiddenMessage'); + } + if (isAdminRoom(report) || isUserCreatedPolicyRoom(report)) { + return getAdminRoomInvitedParticipants(parentReportAction, reportActionMessage); + } + + if (reportActionMessage && isArchivedNonExpense) { + return generateArchivedReportName(reportActionMessage); + } + if (!isEmptyObject(parentReportAction) && isModifiedExpenseAction(parentReportAction)) { + const policyID = reports?.find((r) => r.reportID === report?.reportID)?.policyID; + + const movedFromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(parentReportAction, CONST.REPORT.MOVE_TYPE.FROM)}`]; + const movedToReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(parentReportAction, CONST.REPORT.MOVE_TYPE.TO)}`]; + const modifiedMessage = getForReportAction({ + reportAction: parentReportAction, + policyID, + movedFromReport, + movedToReport, + }); + return formatReportLastMessageText(modifiedMessage); + } + if (isTripRoom(report) && report?.reportName !== CONST.REPORT.DEFAULT_REPORT_NAME) { + return report?.reportName ?? ''; + } + if (isCardIssuedAction(parentReportAction)) { + return getCardIssuedMessage({reportAction: parentReportAction}); + } + return reportActionMessage; +} + +function computeReportName( + report?: Report, + reports?: OnyxCollection, + policies?: OnyxCollection, + transactions?: OnyxCollection, + reportNameValuePairsList?: ReportNameValuePairsCollectionDataSet, + personalDetailsList?: PersonalDetailsList, + reportActions?: OnyxCollection, +): string { + if (!report || !report.reportID) { + return ''; + } + + const reportNameValuePairs = reportNameValuePairsList?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; + const reportPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + + const isArchivedNonExpense = isArchivedNonExpenseReport(report, !!reportNameValuePairs?.private_isArchived); + + + const parentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + const parentReportAction = isThread(report) ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`]?.[report.parentReportActionID] : undefined; + + const parentReportActionBasedName = computeReportNameBasedOnReportAction(parentReportAction, report, reportPolicy, parentReport); + + if (parentReportActionBasedName) { + return parentReportActionBasedName; + } + + if (isTaskReport(report)) { + return Parser.htmlToText(report?.reportName ?? '').trim(); + } + + const chatThreadReportName = computeChatThreadReportName(report, parentReportAction, reportNameValuePairs); + if (chatThreadReportName) { + return chatThreadReportName; + } + + if (isClosedExpenseReportWithNoExpenses(report, transactions)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('parentReportAction.deletedReport'); + } + + if (isGroupChat(report)) { + return getGroupChatName(undefined, true, report) ?? ''; + } + + let formattedName: string | undefined; + + if (isChatRoom(report)) { + formattedName = report?.reportName; + } + + if (isPolicyExpenseChat(report)) { + formattedName = getPolicyExpenseChatName({report, personalDetailsList}); + } + + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + if (isMoneyRequestReport(report)) { + formattedName = getMoneyRequestReportName({report, policy}); + } + + if (isInvoiceReport(report)) { + formattedName = getInvoiceReportName(report, policy, invoiceReceiverPolicy); + } + + if (isInvoiceRoom(report)) { + formattedName = getInvoicesChatName({report, receiverPolicy: invoiceReceiverPolicy, personalDetails, policies}); + } + + if (isSelfDM(report)) { + formattedName = getDisplayNameForParticipant({accountID: currentUserAccountID, shouldAddCurrentUserPostfix: true, personalDetailsData: personalDetails}); + } + + if (isConciergeChatReport(report)) { + formattedName = CONST.CONCIERGE_DISPLAY_NAME; + } + + if (formattedName) { + return formatReportLastMessageText(isArchivedNonExpense ? generateArchivedReportName(formattedName) : formattedName); + } + + // Not a room or PolicyExpenseChat, generate title from first 5 other participants + formattedName = buildReportNameFromParticipantNames({report, personalDetailsList}); + + const finalName = formattedName ?? (report?.reportName ?? ''); + + return isArchivedNonExpense ? generateArchivedReportName(finalName) : finalName; +} + +/** + * Check for existence of report name in derived values first, then fall back to the report object + * + * @param report + * @param reportAttributesDerivedValue + */ +function getReportName(report?: Report, reportAttributesDerivedValue?: ReportAttributesDerivedValue['reports']): string { + if (!report || !report.reportID) { + return ''; + } + + return reportAttributesDerivedValue?.[report.reportID]?.reportName ?? report.reportName ?? ''; +} + +/** + * Get report name for SearchUI context + */ +function getSearchReportName(): string { + +} + +export {computeReportName, getReportName}; \ No newline at end of file diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d9d755654917f..e8872a18c9c1a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4195,6 +4195,7 @@ function getAvailableReportFields(report: OnyxEntry, policyReportFields: /** * Get the title for an IOU or expense chat which will be showing the payer and the amount + * @deprecated */ function getMoneyRequestReportName({ report, @@ -5250,6 +5251,7 @@ function getAdminRoomInvitedParticipants(parentReportAction: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry | SearchPolicy, invoiceReceiverPersonalDetail?: PersonalDetails | null): string { const invoiceReceiver = report?.invoiceReceiver; @@ -5414,6 +5416,7 @@ function getInvoicesChatName({ * Generates a report title using the names of participants, excluding the current user. * This function is useful in contexts such as 1:1 direct messages (DMs) or other group chats. * It limits to a maximum of 5 participants for the title and uses short names unless there is only one participant. + * @deprecated */ const buildReportNameFromParticipantNames = ({report, personalDetails: personalDetailsData}: {report: OnyxEntry; personalDetails?: Partial}) => Object.keys(report?.participants ?? {}) @@ -5442,6 +5445,7 @@ const buildReportNameFromParticipantNames = ({report, personalDetails: personalD /** * Get the title for a report. + * @deprecated use getReportName from src/libs/ReportNameUtils.ts */ function getReportName( report: OnyxEntry, @@ -5771,6 +5775,10 @@ function getReportName( return isArchivedNonExpense ? generateArchivedReportName(finalName) : finalName; } +/** + * @deprecated + * @param props + */ function getSearchReportName(props: GetReportNameParams): string { const {report, policy} = props; if (isChatThread(report) && policy?.name) { @@ -5790,12 +5798,19 @@ function getSearchReportName(props: GetReportNameParams): string { ); } +/** + * @deprecated + */ function getInvoiceReportName(report: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry): string { const moneyRequestReportName = getMoneyRequestReportName({report, policy, invoiceReceiverPolicy}); const oldDotInvoiceName = report?.reportName ?? moneyRequestReportName; return isNewDotInvoice(report?.chatReportID) ? moneyRequestReportName : oldDotInvoiceName; } +/** + * @deprecated + * @param reportName + */ function generateArchivedReportName(reportName: string): string { // eslint-disable-next-line @typescript-eslint/no-deprecated return `${reportName} (${translateLocal('common.archived')}) `; @@ -12514,6 +12529,7 @@ export { hasOnlyNonReimbursableTransactions, getReportLastMessage, getReportLastVisibleActionCreated, + getMoneyRequestReportName, getMostRecentlyVisitedReport, getSourceIDFromReportAction, getIntegrationNameFromExportMessage, diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts new file mode 100644 index 0000000000000..08cb4af40d49e --- /dev/null +++ b/tests/unit/ReportNameUtilsTest.ts @@ -0,0 +1,2 @@ +import {computeReportName, getReportName} from '@libs/ReportNameUtils'; + From 434dbf0684df99de457aff4e0a125f68035d15a6 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Wed, 5 Nov 2025 00:33:16 +0100 Subject: [PATCH 02/11] updates, refactor --- src/libs/ReportNameUtils.ts | 159 ++++++++++++------ .../OnyxDerived/configs/reportAttributes.ts | 10 +- 2 files changed, 111 insertions(+), 58 deletions(-) diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index e457f83c70159..11356c739f914 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -1,19 +1,60 @@ /** * This file contains utility functions for managing and computing report names */ -import { Str } from 'expensify-common'; -import type { OnyxCollection, OnyxEntry } from 'react-native-onyx'; -import { translateLocal } from '@libs/Localize'; -import { getForReportAction, getMovedReportID } from '@libs/ModifiedExpenseMessage'; -import Parser from '@libs/Parser'; -import { getCleanedTagName } from '@libs/PolicyUtils'; -import { getActionableCardFraudAlertResolutionMessage, getCardIssuedMessage, getChangedApproverActionMessage, getIntegrationSyncFailedMessage, getJoinRequestMessage, getMessageOfOldDotReportAction, getOriginalMessage, getPolicyChangeLogDefaultBillableMessage, getPolicyChangeLogDefaultReimbursableMessage, getPolicyChangeLogDefaultTitleEnforcedMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getRenamedAction, getReopenedMessage, getReportActionMessage as getReportActionMessageReportUtils, getRetractedMessage, getTravelUpdateMessage, getWorkspaceCurrencyUpdateMessage, getWorkspaceFrequencyUpdateMessage, getWorkspaceReportFieldAddMessage, getWorkspaceReportFieldDeleteMessage, getWorkspaceReportFieldUpdateMessage, getWorkspaceTagUpdateMessage, getWorkspaceUpdateFieldMessage, isActionableJoinRequest, isActionOfType, isCardIssuedAction, isMarkAsClosedAction, isModifiedExpenseAction, isMoneyRequestAction, isMovedAction, isOldDotReportAction, isRenamedAction, isReportActionAttachment, isTagModificationAction, isTransactionThread, isUnapprovedAction } from '@libs/ReportActionsUtils'; +import {Str} from 'expensify-common'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type { PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, Transaction } from '@src/types/onyx'; -import type { ReportNameValuePairsCollectionDataSet } from '@src/types/onyx/ReportNameValuePairs'; +import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type {ReportNameValuePairsCollectionDataSet} from '@src/types/onyx/ReportNameValuePairs'; import type {SearchPolicy} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {convertToDisplayString} from './CurrencyUtils'; +import {formatPhoneNumber} from './LocalePhoneNumber'; +import {translateLocal} from './Localize'; +import {getForReportAction, getMovedReportID} from './ModifiedExpenseMessage'; +import Parser from './Parser'; +import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; +import {getCleanedTagName} from './PolicyUtils'; +import { + getActionableCardFraudAlertResolutionMessage, + getCardIssuedMessage, + getChangedApproverActionMessage, + getIntegrationSyncFailedMessage, + getJoinRequestMessage, + getMessageOfOldDotReportAction, + getOriginalMessage, + getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultReimbursableMessage, + getPolicyChangeLogDefaultTitleEnforcedMessage, + getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getRenamedAction, + getReopenedMessage, + getReportActionMessage as getReportActionMessageFromActionsUtils, + getReportActionText, + getRetractedMessage, + getTravelUpdateMessage, + getWorkspaceCurrencyUpdateMessage, + getWorkspaceFrequencyUpdateMessage, + getWorkspaceReportFieldAddMessage, + getWorkspaceReportFieldDeleteMessage, + getWorkspaceReportFieldUpdateMessage, + getWorkspaceTagUpdateMessage, + getWorkspaceUpdateFieldMessage, + isActionableJoinRequest, + isActionOfType, + isCardIssuedAction, + isMarkAsClosedAction, + isModifiedExpenseAction, + isMoneyRequestAction, + isMovedAction, + isOldDotReportAction, + isRenamedAction, + isReportActionAttachment, + isTagModificationAction, + isTransactionThread, + isUnapprovedAction, +} from './ReportActionsUtils'; import { formatReportLastMessageText, getDisplayNameForParticipant, @@ -21,14 +62,18 @@ import { getGroupChatName, getInvoicesChatName, getMoneyRequestSpendBreakdown, - getMovedActionMessage, getParentReport, + getMovedActionMessage, + getParentReport, getPolicyChangeMessage, - getPolicyExpenseChatName, getPolicyName, - getRejectedReportMessage, getReportOrDraftReport, + getPolicyExpenseChatName, + getPolicyName, + getRejectedReportMessage, + getReportOrDraftReport, getTransactionReportName, getUnreportedTransactionMessage, getUpgradeWorkspaceMessage, - getWorkspaceNameUpdatedMessage, hasNonReimbursableTransactions, + getWorkspaceNameUpdatedMessage, + hasNonReimbursableTransactions, isAdminRoom, isArchivedNonExpenseReport, isCanceledTaskReport, @@ -41,17 +86,19 @@ import { isInvoiceReport, isInvoiceRoom, isMoneyRequestReport, - isNewDotInvoice, isOpenExpenseReport, isOpenInvoiceReport, - isPolicyExpenseChat, isProcessingReport, isReportApproved, - isSelfDM, isSettled, + isNewDotInvoice, + isOpenExpenseReport, + isOpenInvoiceReport, + isPolicyExpenseChat, + isProcessingReport, + isReportApproved, + isSelfDM, + isSettled, isTaskReport, isThread, isTripRoom, isUserCreatedPolicyRoom, } from './ReportUtils'; -import {convertToDisplayString} from '@libs/CurrencyUtils'; -import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; -import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; function generateArchivedReportName(reportName: string): string { // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -66,7 +113,7 @@ function generateArchivedReportName(reportName: string): string { const buildReportNameFromParticipantNames = ({report, personalDetailsList: personalDetailsData}: {report: OnyxEntry; personalDetailsList?: Partial}) => Object.keys(report?.participants ?? {}) .map(Number) - .filter((id) => id !== currentUserAccountID) + .filter((id) => id !== report?.ownerAccountID) .slice(0, 5) .map((accountID) => ({ accountID, @@ -94,7 +141,6 @@ function getInvoiceReportName(report: OnyxEntry, policy?: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry | SearchPolicy, invoiceReceiverPersonalDetail?: PersonalDetails | null): string { const invoiceReceiver = report?.invoiceReceiver; - const invoiceReceiverPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiver?.policyID}`] const isIndividual = invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; if (isIndividual) { - return formatPhoneNumber(getDisplayNameOrDefault(invoiceReceiverPersonalDetail ?? allPersonalDetails?.[invoiceReceiver.accountID])); + return formatPhoneNumber(getDisplayNameOrDefault(invoiceReceiverPersonalDetail ?? undefined)); } return getPolicyName({report, policy: invoiceReceiverPolicy}); } - /** * Get the title for an IOU or expense chat which will be showing the payer and the amount */ -function getMoneyRequestReportName({report, policy, invoiceReceiverPolicy}: { +function getMoneyRequestReportName({ + report, + policy, + invoiceReceiverPolicy, +}: { report: OnyxEntry; policy?: OnyxEntry | SearchPolicy; invoiceReceiverPolicy?: OnyxEntry | SearchPolicy; @@ -345,19 +393,19 @@ function computeReportNameBasedOnReportAction(parentReportAction?: ReportAction, return undefined; } -function computeChatThreadReportName(report: Report, reportNameValuePairs: ReportNameValuePairs, transactions: OnyxCollection, reports: OnyxCollection, parentReportAction?: ReportAction): string | undefined { +function computeChatThreadReportName(report: Report, reportNameValuePairs: ReportNameValuePairs, reports: OnyxCollection, parentReportAction?: ReportAction): string | undefined { if (!isChatThread(report)) { return undefined; } if (!parentReportAction) { - return undefined + return undefined; } - const parentReportActionMessage = getReportActionMessageReportUtils(parentReportAction); + const parentReportActionMessage = getReportActionMessageFromActionsUtils(parentReportAction); const isArchivedNonExpense = isArchivedNonExpenseReport(report, !!reportNameValuePairs?.private_isArchived); if (!isEmptyObject(parentReportAction) && isTransactionThread(parentReportAction)) { - let formattedName = getTransactionReportName({reportAction: parentReportAction, transactions, reports}); + let formattedName = getTransactionReportName({reportAction: parentReportAction}); if (isArchivedNonExpense) { formattedName = generateArchivedReportName(formattedName); @@ -370,7 +418,8 @@ function computeChatThreadReportName(report: Report, reportNameValuePairs: Repor } if (isRenamedAction(parentReportAction)) { - return getRenamedAction(parentReportAction, isExpenseReport(getReport(report.parentReportID, allReports))); + const parent = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + return getRenamedAction(parentReportAction, isExpenseReport(parent)); } if (parentReportActionMessage?.isDeletedParentAction) { @@ -384,13 +433,7 @@ function computeChatThreadReportName(report: Report, reportNameValuePairs: Repor } const isAttachment = isReportActionAttachment(!isEmptyObject(parentReportAction) ? parentReportAction : undefined); - const reportActionMessage = getReportActionMessage({ - reportAction: parentReportAction, - reportID: report?.parentReportID, - childReportID: report?.reportID, - reports, - personalDetails, - }).replace(/(\n+|\r\n|\n|\r)/gm, ' '); + const reportActionMessage = getReportActionText(parentReportAction).replace(/(\n+|\r\n|\n|\r)/gm, ' '); if (isAttachment && reportActionMessage) { // eslint-disable-next-line @typescript-eslint/no-deprecated return `[${translateLocal('common.attachment')}]`; @@ -404,17 +447,17 @@ function computeChatThreadReportName(report: Report, reportNameValuePairs: Repor return translateLocal('parentReportAction.hiddenMessage'); } if (isAdminRoom(report) || isUserCreatedPolicyRoom(report)) { - return getAdminRoomInvitedParticipants(parentReportAction, reportActionMessage); + return reportActionMessage; } if (reportActionMessage && isArchivedNonExpense) { return generateArchivedReportName(reportActionMessage); } if (!isEmptyObject(parentReportAction) && isModifiedExpenseAction(parentReportAction)) { - const policyID = reports?.find((r) => r.reportID === report?.reportID)?.policyID; + const policyID = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`]?.policyID; - const movedFromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(parentReportAction, CONST.REPORT.MOVE_TYPE.FROM)}`]; - const movedToReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(parentReportAction, CONST.REPORT.MOVE_TYPE.TO)}`]; + const movedFromReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(parentReportAction, CONST.REPORT.MOVE_TYPE.FROM)}`]; + const movedToReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(parentReportAction, CONST.REPORT.MOVE_TYPE.TO)}`]; const modifiedMessage = getForReportAction({ reportAction: parentReportAction, policyID, @@ -450,7 +493,6 @@ function computeReportName( const isArchivedNonExpense = isArchivedNonExpenseReport(report, !!reportNameValuePairs?.private_isArchived); - const parentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; const parentReportAction = isThread(report) ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`]?.[report.parentReportActionID] : undefined; @@ -464,12 +506,13 @@ function computeReportName( return Parser.htmlToText(report?.reportName ?? '').trim(); } - const chatThreadReportName = computeChatThreadReportName(report, parentReportAction, reportNameValuePairs); + const chatThreadReportName = computeChatThreadReportName(report, reportNameValuePairs ?? {}, reports ?? {}, parentReportAction); if (chatThreadReportName) { return chatThreadReportName; } - if (isClosedExpenseReportWithNoExpenses(report, transactions)) { + const transactionsArray = transactions ? (Object.values(transactions).filter(Boolean) as Array>) : undefined; + if (isClosedExpenseReportWithNoExpenses(report, transactionsArray)) { // eslint-disable-next-line @typescript-eslint/no-deprecated return translateLocal('parentReportAction.deletedReport'); } @@ -494,15 +537,28 @@ function computeReportName( } if (isInvoiceReport(report)) { + const chatReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; + let chatReceiverPolicyID: string | undefined; + const chatReceiver = chatReport?.invoiceReceiver as unknown; + if (chatReceiver && typeof chatReceiver === 'object' && 'policyID' in chatReceiver) { + chatReceiverPolicyID = (chatReceiver as {policyID: string}).policyID; + } + const invoiceReceiverPolicy = chatReceiverPolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${chatReceiverPolicyID}`] : undefined; formattedName = getInvoiceReportName(report, policy, invoiceReceiverPolicy); } if (isInvoiceRoom(report)) { - formattedName = getInvoicesChatName({report, receiverPolicy: invoiceReceiverPolicy, personalDetails, policies}); + let receiverPolicyID: string | undefined; + const receiver = report?.invoiceReceiver as unknown; + if (receiver && typeof receiver === 'object' && 'policyID' in receiver) { + receiverPolicyID = (receiver as {policyID: string}).policyID; + } + const invoiceReceiverPolicy = receiverPolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${receiverPolicyID}`] : undefined; + formattedName = getInvoicesChatName({report, receiverPolicy: invoiceReceiverPolicy, personalDetails: personalDetailsList}); } if (isSelfDM(report)) { - formattedName = getDisplayNameForParticipant({accountID: currentUserAccountID, shouldAddCurrentUserPostfix: true, personalDetailsData: personalDetails}); + formattedName = getDisplayNameForParticipant({accountID: report?.ownerAccountID, shouldAddCurrentUserPostfix: true, personalDetailsData: personalDetailsList}); } if (isConciergeChatReport(report)) { @@ -516,7 +572,7 @@ function computeReportName( // Not a room or PolicyExpenseChat, generate title from first 5 other participants formattedName = buildReportNameFromParticipantNames({report, personalDetailsList}); - const finalName = formattedName ?? (report?.reportName ?? ''); + const finalName = formattedName ?? report?.reportName ?? ''; return isArchivedNonExpense ? generateArchivedReportName(finalName) : finalName; } @@ -535,11 +591,4 @@ function getReportName(report?: Report, reportAttributesDerivedValue?: ReportAtt return reportAttributesDerivedValue?.[report.reportID]?.reportName ?? report.reportName ?? ''; } -/** - * Get report name for SearchUI context - */ -function getSearchReportName(): string { - -} - -export {computeReportName, getReportName}; \ No newline at end of file +export {computeReportName, getReportName}; diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index 576e35a2319b5..9dfc6262c7c8a 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -1,5 +1,6 @@ import type {OnyxEntry} from 'react-native-onyx'; -import {generateIsEmptyReport, generateReportAttributes, getReportName, isArchivedReport, isValidReport} from '@libs/ReportUtils'; +import {computeReportName} from '@libs/ReportNameUtils'; +import {generateIsEmptyReport, generateReportAttributes, isArchivedReport, isValidReport} from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; import {hasKeyTriggeredCompute} from '@userActions/OnyxDerived/utils'; @@ -70,7 +71,10 @@ export default createOnyxDerivedValueConfig({ ONYXKEYS.COLLECTION.POLICY, ONYXKEYS.COLLECTION.REPORT_METADATA, ], - compute: ([reports, preferredLocale, transactionViolations, reportActions, reportNameValuePairs, transactions, personalDetails], {currentValue, sourceValues, areAllConnectionsSet}) => { + compute: ( + [reports, preferredLocale, transactionViolations, reportActions, reportNameValuePairs, transactions, personalDetails, policies], + {currentValue, sourceValues, areAllConnectionsSet}, + ) => { if (!areAllConnectionsSet) { return { reports: {}, @@ -200,7 +204,7 @@ export default createOnyxDerivedValueConfig({ } acc[report.reportID] = { - reportName: report ? getReportName(report, undefined, undefined, undefined, undefined, undefined, undefined, isReportArchived) : '', + reportName: report ? computeReportName(report, reports, policies, transactions, reportNameValuePairs, personalDetails, reportActions) : '', isEmpty: generateIsEmptyReport(report, isReportArchived), brickRoadStatus, requiresAttention, From e2eedd5151a4e44b15abe198d39f9dd9f308fd55 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Fri, 7 Nov 2025 00:48:38 +0100 Subject: [PATCH 03/11] fix ts --- src/libs/ReportNameUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index 11356c739f914..aefe6fb760869 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -480,7 +480,7 @@ function computeReportName( reports?: OnyxCollection, policies?: OnyxCollection, transactions?: OnyxCollection, - reportNameValuePairsList?: ReportNameValuePairsCollectionDataSet, + reportNameValuePairsList?: OnyxCollection, personalDetailsList?: PersonalDetailsList, reportActions?: OnyxCollection, ): string { From 0437dee0cc7bc52be03a794a664b7f584050b0b7 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Fri, 7 Nov 2025 17:59:47 +0100 Subject: [PATCH 04/11] updates --- src/libs/ReportNameUtils.ts | 1 - tests/unit/ReportNameUtilsTest.ts | 314 +++++++++++++++++++++++++++++- 2 files changed, 313 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index aefe6fb760869..e088d2c7c6489 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -6,7 +6,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, Transaction} from '@src/types/onyx'; -import type {ReportNameValuePairsCollectionDataSet} from '@src/types/onyx/ReportNameValuePairs'; import type {SearchPolicy} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {convertToDisplayString} from './CurrencyUtils'; diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index 08cb4af40d49e..a6d7065fb20ee 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -1,2 +1,314 @@ -import {computeReportName, getReportName} from '@libs/ReportNameUtils'; +import Onyx from 'react-native-onyx'; +import {translate} from '@libs/Localize'; +import {computeReportName, getReportName as getSimpleReportName} from '@libs/ReportNameUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy, Report, ReportAction, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs} from '@src/types/onyx'; +import {createAdminRoom, createPolicyExpenseChat, createRegularChat, createRegularTaskReport, createSelfDM, createWorkspaceThread} from '../utils/collections/reports'; +describe('ReportNameUtils', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + }); + + // moved lower after constants + const currentUserAccountID = 5; + const participantsPersonalDetails: PersonalDetailsList = [ + { + accountID: 1, + displayName: 'Ragnar Lothbrok', + firstName: 'Ragnar', + login: 'ragnar@vikings.net', + }, + { + accountID: 2, + login: 'floki@vikings.net', + displayName: 'floki@vikings.net', + }, + { + accountID: 3, + displayName: 'Lagertha Lothbrok', + firstName: 'Lagertha', + login: 'lagertha@vikings.net', + pronouns: 'She/her', + }, + { + accountID: 4, + login: '+18332403627@expensify.sms', + displayName: '(833) 240-3627', + }, + { + accountID: 5, + displayName: 'Lagertha Lothbrok', + firstName: 'Lagertha', + login: 'lagertha2@vikings.net', + pronouns: 'She/her', + }, + ].reduce((acc, detail) => { + // eslint-disable-next-line no-param-reassign + acc[String(detail.accountID)] = detail; + return acc; + }, {} as PersonalDetailsList); + + const emptyCollections = { + reports: {} as Record, + policies: {} as Record, + transactions: {} as Record, + reportNameValuePairs: {} as Record, + reportActions: {} as Record, + }; + + describe('computeReportName - DMs and Group chats', () => { + test('1:1 DM with displayName', () => { + const report: Report = { + ...createRegularChat(1, [currentUserAccountID, 1]), + ownerAccountID: currentUserAccountID, + }; + + const name = computeReportName(report, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, emptyCollections.reportActions); + expect(name).toBe('Ragnar Lothbrok'); + }); + + test('1:1 DM without displayName uses login', () => { + const report: Report = { + ...createRegularChat(2, [currentUserAccountID, 2]), + ownerAccountID: currentUserAccountID, + }; + + const name = computeReportName(report, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, emptyCollections.reportActions); + expect(name).toBe('floki@vikings.net'); + }); + + test('1:1 DM SMS uses formatted phone', () => { + const report: Report = { + ...createRegularChat(3, [currentUserAccountID, 4]), + ownerAccountID: currentUserAccountID, + }; + + const name = computeReportName(report, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, emptyCollections.reportActions); + expect(name).toBe('(833) 240-3627'); + }); + + test('Group DM uses up to 5 participant short names', async () => { + const report: Report = { + ...createRegularChat(4, [currentUserAccountID, 1, 2, 3, 4]), + ownerAccountID: currentUserAccountID, + reportName: undefined, + }; + + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, participantsPersonalDetails); + const name = computeReportName(report, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, emptyCollections.reportActions); + expect(name).toBe('Ragnar, floki@vikings.net, Lagertha, (833) 240-3627'); + }); + }); + + describe('computeReportName - Admin room', () => { + test('Active admin room', () => { + const report = createAdminRoom(10); + const name = computeReportName(report, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, emptyCollections.reportActions); + expect(name).toBe('#admins'); + }); + + test('Archived admin room in EN and ES', async () => { + const report = createAdminRoom(11); + const reportNameValuePairs = { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]: {private_isArchived: 'true'}, + } as Record; + + const nameEn = computeReportName( + report, + emptyCollections.reports, + emptyCollections.policies, + undefined, + reportNameValuePairs, + participantsPersonalDetails, + emptyCollections.reportActions, + ); + expect(nameEn).toBe('#admins (archived)'); + + await IntlStore.load(CONST.LOCALES.ES); + const nameEs = computeReportName( + report, + emptyCollections.reports, + emptyCollections.policies, + undefined, + reportNameValuePairs, + participantsPersonalDetails, + emptyCollections.reportActions, + ); + expect(nameEs).toBe('#admins (archivado)'); + + await IntlStore.load(CONST.LOCALES.EN); + }); + }); + + describe('computeReportName - Policy expense chat', () => { + test('Returns policy expense chat name for own PEC', async () => { + const report: Report = { + ...createPolicyExpenseChat(20, true), + ownerAccountID: 1, + }; + + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, participantsPersonalDetails); + const name = computeReportName(report, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, emptyCollections.reportActions); + expect(name).toBe("Ragnar Lothbrok's expenses"); + }); + }); + + describe('computeReportName - Self DM', () => { + test('Returns self DM with postfix', async () => { + const report: Report = { + ...createSelfDM(30, currentUserAccountID), + ownerAccountID: currentUserAccountID, + }; + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID, email: 'lagertha2@vikings.net', authTokenType: CONST.AUTH_TOKEN_TYPES.SUPPORT}); + const name = computeReportName(report, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, emptyCollections.reportActions); + expect(name).toBe('Lagertha Lothbrok (you)'); + }); + }); + + describe('computeReportName - Task report', () => { + test('Extracts plain text from HTML title', () => { + const htmlTaskTitle = '

heading with link

'; + const report: Report = { + ...createRegularTaskReport(40, currentUserAccountID), + reportName: htmlTaskTitle, + }; + + const name = computeReportName(report, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, emptyCollections.reportActions); + expect(name).toBe('heading with link'); + }); + }); + + describe('computeReportName - Thread report action names', () => { + test('Submitted parent action', () => { + const thread: Report = createWorkspaceThread(50); + const parentAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + reportActionID: String(thread.parentReportActionID), + message: [], + created: '', + lastModified: '', + actorAccountID: 1, + person: [], + originalMessage: { + message: 'via workflow', + }, + } as unknown as ReportAction; + + expect(thread.parentReportID).toBeDefined(); + expect(thread.parentReportActionID).toBeDefined(); + const parentId = String(thread.parentReportID); + const actionId = String(thread.parentReportActionID); + + const reportActionsCollection: Record = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentId}`]: { + [actionId]: parentAction, + }, + }; + + const expected = translate(CONST.LOCALES.EN, 'iou.submitted', {memo: 'via workflow'}); + const name = computeReportName(thread, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, reportActionsCollection); + expect(name).toBe(expected); + }); + + test('Rejected parent action', () => { + const thread: Report = createWorkspaceThread(51); + const parentAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.REJECTED, + reportActionID: String(thread.parentReportActionID), + message: [], + created: '', + lastModified: '', + actorAccountID: 1, + person: [], + } as unknown as ReportAction; + + expect(thread.parentReportID).toBeDefined(); + expect(thread.parentReportActionID).toBeDefined(); + const parentId = String(thread.parentReportID); + const actionId = String(thread.parentReportActionID); + + const reportActionsCollection: Record = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentId}`]: { + [actionId]: parentAction, + }, + }; + + const expected = translate(CONST.LOCALES.EN, 'iou.rejectedThisReport'); + const name = computeReportName(thread, emptyCollections.reports, emptyCollections.policies, undefined, undefined, participantsPersonalDetails, reportActionsCollection); + expect(name).toBe(expected); + }); + }); + + describe('getReportName (derived value vs fallback)', () => { + test('Returns derived value when provided', () => { + const report: Report = { + ...createPolicyExpenseChat(60, true), + reportID: '60', + ownerAccountID: 1, + }; + + const derived: ReportAttributesDerivedValue['reports'] = { + [report.reportID]: { + reportName: "Ragnar Lothbrok's expenses", + isEmpty: false, + brickRoadStatus: undefined, + requiresAttention: false, + reportErrors: {}, + }, + }; + + expect(getSimpleReportName(report, derived)).toBe("Ragnar Lothbrok's expenses"); + }); + + test('Falls back to report.reportName when derived missing', () => { + const report: Report = { + ...createRegularChat(61, [currentUserAccountID, 1]), + reportID: '61', + reportName: 'Custom Report Name', + ownerAccountID: currentUserAccountID, + }; + + expect(getSimpleReportName(report, {} as never)).toBe('Custom Report Name'); + }); + + test('Returns empty string when neither present', () => { + const report: Report = { + ...createRegularChat(62, [currentUserAccountID, 1]), + reportID: '62', + ownerAccountID: currentUserAccountID, + reportName: undefined, + }; + + expect(getSimpleReportName(report, {} as never)).toBe(''); + }); + }); + + describe('computeReportName - reportNameValuePairsList archiving', () => { + test('Regular chat gets archived suffix from reportNameValuePairsList', async () => { + const report: Report = { + ...createRegularChat(70, [currentUserAccountID, 1]), + ownerAccountID: currentUserAccountID, + reportName: undefined, + }; + const reportNameValuePairs = { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]: {private_isArchived: 'true'}, + } as Record; + + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, participantsPersonalDetails); + const name = computeReportName( + report, + emptyCollections.reports, + emptyCollections.policies, + undefined, + reportNameValuePairs, + participantsPersonalDetails, + emptyCollections.reportActions, + ); + expect(name).toBe('Ragnar Lothbrok (archived) '); + }); + }); +}); From ffd9c96bf8c6f2006a04198bb4911eda5b38c8b2 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Wed, 12 Nov 2025 17:38:23 +0100 Subject: [PATCH 05/11] fix nit --- src/libs/ReportNameUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index e088d2c7c6489..5b0887d7207cd 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -479,7 +479,7 @@ function computeReportName( reports?: OnyxCollection, policies?: OnyxCollection, transactions?: OnyxCollection, - reportNameValuePairsList?: OnyxCollection, + allReportNameValuePairs?: OnyxCollection, personalDetailsList?: PersonalDetailsList, reportActions?: OnyxCollection, ): string { @@ -487,7 +487,7 @@ function computeReportName( return ''; } - const reportNameValuePairs = reportNameValuePairsList?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; + const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; const reportPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; const isArchivedNonExpense = isArchivedNonExpenseReport(report, !!reportNameValuePairs?.private_isArchived); From 1a149361d47393618aba8da00f0c7e1df7d08dff Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Wed, 12 Nov 2025 17:38:47 +0100 Subject: [PATCH 06/11] fix SearchPolicy --- src/libs/ReportNameUtils.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index 5b0887d7207cd..695f5e94bebb2 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -6,7 +6,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, Transaction} from '@src/types/onyx'; -import type {SearchPolicy} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {convertToDisplayString} from './CurrencyUtils'; import {formatPhoneNumber} from './LocalePhoneNumber'; @@ -145,7 +144,7 @@ function getInvoiceReportName(report: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry | SearchPolicy, invoiceReceiverPersonalDetail?: PersonalDetails | null): string { +function getInvoicePayerName(report: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry, invoiceReceiverPersonalDetail?: PersonalDetails | null): string { const invoiceReceiver = report?.invoiceReceiver; const isIndividual = invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; @@ -159,15 +158,7 @@ function getInvoicePayerName(report: OnyxEntry, invoiceReceiverPolicy?: /** * Get the title for an IOU or expense chat which will be showing the payer and the amount */ -function getMoneyRequestReportName({ - report, - policy, - invoiceReceiverPolicy, -}: { - report: OnyxEntry; - policy?: OnyxEntry | SearchPolicy; - invoiceReceiverPolicy?: OnyxEntry | SearchPolicy; -}): string { +function getMoneyRequestReportName({report, policy, invoiceReceiverPolicy}: {report: OnyxEntry; policy?: OnyxEntry; invoiceReceiverPolicy?: OnyxEntry}): string { if (report?.reportName && isExpenseReport(report)) { return report.reportName; } From 9fa2cb451b2f057dd8e2ce1c4e2a1d520ca62e89 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Wed, 12 Nov 2025 18:38:13 +0100 Subject: [PATCH 07/11] improve descriptions, move more function --- src/libs/ReportNameUtils.ts | 152 ++++++++++++++++++++++++++++++++++-- src/libs/ReportUtils.ts | 28 +++++-- 2 files changed, 167 insertions(+), 13 deletions(-) diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index 695f5e94bebb2..7a4fd77247035 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -2,10 +2,23 @@ * This file contains utility functions for managing and computing report names */ import {Str} from 'expensify-common'; +import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type { + PersonalDetails, + PersonalDetailsList, + Policy, + Report, + ReportAction, + ReportActions, + ReportAttributesDerivedValue, + ReportMetadata, + ReportNameValuePairs, + Transaction, +} from '@src/types/onyx'; +import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {convertToDisplayString} from './CurrencyUtils'; import {formatPhoneNumber} from './LocalePhoneNumber'; @@ -13,7 +26,7 @@ import {translateLocal} from './Localize'; import {getForReportAction, getMovedReportID} from './ModifiedExpenseMessage'; import Parser from './Parser'; import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; -import {getCleanedTagName} from './PolicyUtils'; +import {getCleanedTagName, getPolicy, isPolicyAdmin} from './PolicyUtils'; import { getActionableCardFraudAlertResolutionMessage, getCardIssuedMessage, @@ -57,15 +70,13 @@ import { formatReportLastMessageText, getDisplayNameForParticipant, getDowngradeWorkspaceMessage, - getGroupChatName, - getInvoicesChatName, getMoneyRequestSpendBreakdown, getMovedActionMessage, getParentReport, getPolicyChangeMessage, - getPolicyExpenseChatName, getPolicyName, getRejectedReportMessage, + getReportMetadata, getReportOrDraftReport, getTransactionReportName, getUnreportedTransactionMessage, @@ -98,6 +109,23 @@ import { isUserCreatedPolicyRoom, } from './ReportUtils'; +let currentUserAccountID: number | undefined; +let allPersonalDetails: OnyxEntry; + +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + currentUserAccountID = value?.accountID; + }, +}); + +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => { + allPersonalDetails = value; + }, +}); + function generateArchivedReportName(reportName: string): string { // eslint-disable-next-line @typescript-eslint/no-deprecated return `${reportName} (${translateLocal('common.archived')}) `; @@ -133,6 +161,107 @@ const buildReportNameFromParticipantNames = ({report, personalDetailsList: perso return formattedNames ? `${formattedNames}, ${name}` : name; }, ''); +/** + * @private + * This is a custom collator only for getGroupChatName function. + * The reason for this is that the computation of default group name should not depend on the locale. + * This is used to ensure that group name stays consistent across locales. + */ +const customCollator = new Intl.Collator('en', {usage: 'sort', sensitivity: 'variant', numeric: true, caseFirst: 'upper'}); + +/** + * Returns the report name if the report is a group chat + */ +function getGroupChatName(participants?: SelectedParticipant[], shouldApplyLimit = false, report?: OnyxEntry, reportMetadataParam?: OnyxEntry): string | undefined { + // If we have a report always try to get the name from the report. + if (report?.reportName) { + return report.reportName; + } + + const reportMetadata = reportMetadataParam ?? getReportMetadata(report?.reportID); + + const pendingMemberAccountIDs = new Set( + reportMetadata?.pendingChatMembers?.filter((member) => member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).map((member) => member.accountID), + ); + let participantAccountIDs = + participants?.map((participant) => participant.accountID) ?? + Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => !pendingMemberAccountIDs.has(accountID.toString())); + const shouldAddEllipsis = participantAccountIDs.length > CONST.DISPLAY_PARTICIPANTS_LIMIT && shouldApplyLimit; + if (shouldApplyLimit) { + participantAccountIDs = participantAccountIDs.slice(0, CONST.DISPLAY_PARTICIPANTS_LIMIT); + } + const isMultipleParticipantReport = participantAccountIDs.length > 1; + + if (isMultipleParticipantReport) { + return participantAccountIDs + .map( + (participantAccountID, index) => + getDisplayNameForParticipant({accountID: participantAccountID, shouldUseShortForm: isMultipleParticipantReport}) || formatPhoneNumber(participants?.[index]?.login ?? ''), + ) + .sort((first, second) => customCollator.compare(first ?? '', second ?? '')) + .filter(Boolean) + .join(', ') + .slice(0, CONST.REPORT_NAME_LIMIT) + .concat(shouldAddEllipsis ? '...' : ''); + } + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('groupChat.defaultReportName', {displayName: getDisplayNameForParticipant({accountID: participantAccountIDs.at(0)})}); +} + +/** + * Get the title for a policy expense chat + */ +function getPolicyExpenseChatName({report, personalDetailsList}: {report: OnyxEntry; personalDetailsList?: Partial}): string | undefined { + const ownerAccountID = report?.ownerAccountID; + const personalDetails = ownerAccountID ? personalDetailsList?.[ownerAccountID] : undefined; + const login = personalDetails ? personalDetails.login : null; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const reportOwnerDisplayName = getDisplayNameForParticipant({accountID: ownerAccountID, shouldRemoveDomain: true}) || login; + + if (reportOwnerDisplayName) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('workspace.common.policyExpenseChatName', {displayName: reportOwnerDisplayName}); + } + + return report?.reportName; +} + +/** + * Get the title for an invoice room. + */ +function getInvoicesChatName({ + report, + receiverPolicy, + personalDetails, + policies, +}: { + report: OnyxEntry; + receiverPolicy: OnyxEntry; + personalDetails?: Partial; + policies?: Policy[]; +}): string { + const invoiceReceiver = report?.invoiceReceiver; + const isIndividual = invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; + const invoiceReceiverAccountID = isIndividual ? invoiceReceiver.accountID : CONST.DEFAULT_NUMBER_ID; + const invoiceReceiverPolicyID = isIndividual ? undefined : invoiceReceiver?.policyID; + // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 + // eslint-disable-next-line @typescript-eslint/no-deprecated + const receiverPolicyResolved = receiverPolicy ?? getPolicy(invoiceReceiverPolicyID); + const isCurrentUserReceiver = (isIndividual && invoiceReceiverAccountID === currentUserAccountID) || (!isIndividual && isPolicyAdmin(receiverPolicyResolved)); + + if (isCurrentUserReceiver) { + return getPolicyName({report, policies}); + } + + if (isIndividual) { + return formatPhoneNumber(getDisplayNameOrDefault((personalDetails ?? allPersonalDetails)?.[invoiceReceiverAccountID])); + } + + return getPolicyName({report, policy: receiverPolicyResolved, policies}); +} + function getInvoiceReportName(report: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry): string { const moneyRequestReportName = getMoneyRequestReportName({report, policy, invoiceReceiverPolicy}); const oldDotInvoiceName = report?.reportName ?? moneyRequestReportName; @@ -581,4 +710,15 @@ function getReportName(report?: Report, reportAttributesDerivedValue?: ReportAtt return reportAttributesDerivedValue?.[report.reportID]?.reportName ?? report.reportName ?? ''; } -export {computeReportName, getReportName}; +export { + computeReportName, + getReportName, + generateArchivedReportName, + getInvoiceReportName, + getMoneyRequestReportName, + buildReportNameFromParticipantNames, + getInvoicePayerName, + getGroupChatName, + getPolicyExpenseChatName, + getInvoicesChatName, +}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f71d44331292f..ed44482a0c237 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3247,6 +3247,8 @@ const customCollator = new Intl.Collator('en', {usage: 'sort', sensitivity: 'var /** * Returns the report name if the report is a group chat + * @deprecated Moved to src/libs/ReportNameUtils.ts. + * Use ReportNameUtils.getGroupChatName instead. */ function getGroupChatName(participants?: SelectedParticipant[], shouldApplyLimit = false, report?: OnyxEntry, reportMetadataParam?: OnyxEntry): string | undefined { // If we have a report always try to get the name from the report. @@ -4096,6 +4098,8 @@ function getMoneyRequestSpendBreakdown(report: OnyxInputOrEntry, searchR /** * Get the title for a policy expense chat + * @deprecated Moved to src/libs/ReportNameUtils.ts. + * Use ReportNameUtils.getPolicyExpenseChatName instead. */ function getPolicyExpenseChatName({report, personalDetailsList}: {report: OnyxEntry; personalDetailsList?: Partial}): string | undefined { const ownerAccountID = report?.ownerAccountID; @@ -4235,7 +4239,8 @@ function getAvailableReportFields(report: OnyxEntry, policyReportFields: /** * Get the title for an IOU or expense chat which will be showing the payer and the amount - * @deprecated + * @deprecated Moved to src/libs/ReportNameUtils.ts. + * Use ReportNameUtils.getMoneyRequestReportName instead. */ function getMoneyRequestReportName({report, policy, invoiceReceiverPolicy}: {report: OnyxEntry; policy?: OnyxEntry; invoiceReceiverPolicy?: OnyxEntry}): string { if (report?.reportName && isExpenseReport(report)) { @@ -5304,7 +5309,8 @@ function getAdminRoomInvitedParticipants(parentReportAction: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry, invoiceReceiverPersonalDetail?: PersonalDetails | null): string { const invoiceReceiver = report?.invoiceReceiver; @@ -5433,6 +5439,8 @@ function getReportActionMessage({ /** * Get the title for an invoice room. + * @deprecated Moved to src/libs/ReportNameUtils.ts. + * Use ReportNameUtils.getInvoicesChatName instead. */ function getInvoicesChatName({ report, @@ -5469,7 +5477,8 @@ function getInvoicesChatName({ * Generates a report title using the names of participants, excluding the current user. * This function is useful in contexts such as 1:1 direct messages (DMs) or other group chats. * It limits to a maximum of 5 participants for the title and uses short names unless there is only one participant. - * @deprecated + * @deprecated Moved to src/libs/ReportNameUtils.ts. + * Use ReportNameUtils.buildReportNameFromParticipantNames instead. */ const buildReportNameFromParticipantNames = ({report, personalDetails: personalDetailsData}: {report: OnyxEntry; personalDetails?: Partial}) => Object.keys(report?.participants ?? {}) @@ -5498,7 +5507,9 @@ const buildReportNameFromParticipantNames = ({report, personalDetails: personalD /** * Get the title for a report. - * @deprecated use getReportName from src/libs/ReportNameUtils.ts + * @deprecated Moved to src/libs/ReportNameUtils.ts. + * Use ReportNameUtils.computeReportName for full name generation. + * For reading a stored name only, use ReportNameUtils.getReportName. */ function getReportName( report: OnyxEntry, @@ -5829,7 +5840,8 @@ function getReportName( } /** - * @deprecated + * @deprecated Moved to src/libs/ReportNameUtils.ts. + * Use ReportNameUtils.computeReportName(...) in search contexts instead. * @param props */ function getSearchReportName(props: GetReportNameParams): string { @@ -5852,7 +5864,8 @@ function getSearchReportName(props: GetReportNameParams): string { } /** - * @deprecated + * @deprecated Moved to src/libs/ReportNameUtils.ts. + * Use ReportNameUtils.getInvoiceReportName(...) instead. */ function getInvoiceReportName(report: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry): string { const moneyRequestReportName = getMoneyRequestReportName({report, policy, invoiceReceiverPolicy}); @@ -5861,7 +5874,8 @@ function getInvoiceReportName(report: OnyxEntry, policy?: OnyxEntry Date: Fri, 14 Nov 2025 17:22:42 +0100 Subject: [PATCH 08/11] fixes --- src/libs/ReportNameUtils.ts | 12 ++- tests/unit/ReportNameUtilsTest.ts | 148 +++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index 7a4fd77247035..aacce210ab7df 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -139,7 +139,7 @@ function generateArchivedReportName(reportName: string): string { const buildReportNameFromParticipantNames = ({report, personalDetailsList: personalDetailsData}: {report: OnyxEntry; personalDetailsList?: Partial}) => Object.keys(report?.participants ?? {}) .map(Number) - .filter((id) => id !== report?.ownerAccountID) + .filter((id) => id !== currentUserAccountID) .slice(0, 5) .map((accountID) => ({ accountID, @@ -278,10 +278,16 @@ function getInvoicePayerName(report: OnyxEntry, invoiceReceiverPolicy?: const isIndividual = invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; if (isIndividual) { - return formatPhoneNumber(getDisplayNameOrDefault(invoiceReceiverPersonalDetail ?? undefined)); + const personalDetail = invoiceReceiverPersonalDetail ?? allPersonalDetails?.[invoiceReceiver.accountID]; + return formatPhoneNumber(getDisplayNameOrDefault(personalDetail ?? undefined)); } - return getPolicyName({report, policy: invoiceReceiverPolicy}); + let policyToUse = invoiceReceiverPolicy; + if (!policyToUse && invoiceReceiver?.policyID) { + policyToUse = getPolicy(invoiceReceiver.policyID); + } + + return getPolicyName({report, policy: policyToUse}); } /** diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index a6d7065fb20ee..6ff59940a3472 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -1,15 +1,23 @@ import Onyx from 'react-native-onyx'; import {translate} from '@libs/Localize'; -import {computeReportName, getReportName as getSimpleReportName} from '@libs/ReportNameUtils'; +import {computeReportName, getGroupChatName, getPolicyExpenseChatName, getReportName as getSimpleReportName} from '@libs/ReportNameUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Policy, Report, ReportAction, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs} from '@src/types/onyx'; import {createAdminRoom, createPolicyExpenseChat, createRegularChat, createRegularTaskReport, createSelfDM, createWorkspaceThread} from '../utils/collections/reports'; +import {fakePersonalDetails} from '../utils/LHNTestUtils'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; describe('ReportNameUtils', () => { beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: participantsPersonalDetails, + [ONYXKEYS.SESSION]: {accountID: currentUserAccountID, email: 'lagertha2@vikings.net'}, + }); await IntlStore.load(CONST.LOCALES.EN); + await waitForBatchedUpdates(); }); // moved lower after constants @@ -311,4 +319,142 @@ describe('ReportNameUtils', () => { expect(name).toBe('Ragnar Lothbrok (archived) '); }); }); + + describe('getPolicyExpenseChatName', () => { + it("returns owner's display name when available", () => { + const report = { + ownerAccountID: 1, + reportName: 'Fallback Report Name', + } as unknown as Report; + + const name = getPolicyExpenseChatName({report, personalDetailsList: participantsPersonalDetails}); + expect(name).toBe(translate(CONST.LOCALES.EN, 'workspace.common.policyExpenseChatName', {displayName: 'Ragnar Lothbrok'})); + }); + + it('falls back to owner login when display name not present', () => { + const report = { + ownerAccountID: 2, + reportName: 'Fallback Report Name', + } as unknown as Report; + + const name = getPolicyExpenseChatName({report, personalDetailsList: participantsPersonalDetails}); + expect(name).toBe(translate(CONST.LOCALES.EN, 'workspace.common.policyExpenseChatName', {displayName: 'floki'})); + }); + + it('returns report name when no personal details or owner', () => { + const report = { + ownerAccountID: undefined, + reportName: 'Fallback Report Name', + } as unknown as Report; + + const name = getPolicyExpenseChatName({report, personalDetailsList: {}}); + expect(name).toBe('Fallback Report Name'); + }); + }); + + describe('getGroupChatName', () => { + afterEach(() => Onyx.clear()); + + const fourParticipants = [ + {accountID: 1, login: 'email1@test.com'}, + {accountID: 2, login: 'email2@test.com'}, + {accountID: 3, login: 'email3@test.com'}, + {accountID: 4, login: 'email4@test.com'}, + ]; + + const eightParticipants = [ + {accountID: 1, login: 'email1@test.com'}, + {accountID: 2, login: 'email2@test.com'}, + {accountID: 3, login: 'email3@test.com'}, + {accountID: 4, login: 'email4@test.com'}, + {accountID: 5, login: 'email5@test.com'}, + {accountID: 6, login: 'email6@test.com'}, + {accountID: 7, login: 'email7@test.com'}, + {accountID: 8, login: 'email8@test.com'}, + ]; + + describe('When participantAccountIDs is passed to getGroupChatName', () => { + it('shows all participants when count <= 5 and shouldApplyLimit is false', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(getGroupChatName(fourParticipants)).toEqual('Four, One, Three, Two'); + }); + + it('shows all participants when count <= 5 and shouldApplyLimit is true', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(getGroupChatName(fourParticipants, true)).toEqual('Four, One, Three, Two'); + }); + + it('shows 5 participants with ellipsis when count > 5 and shouldApplyLimit is true', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(getGroupChatName(eightParticipants, true)).toEqual('Five, Four, One, Three, Two...'); + }); + + it('shows all participants when count > 5 and shouldApplyLimit is false', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(getGroupChatName(eightParticipants, false)).toEqual('Eight, Five, Four, One, Seven, Six, Three, Two'); + }); + + it('uses correct display names for participants', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, participantsPersonalDetails); + expect(getGroupChatName(fourParticipants, true)).toEqual('(833) 240-3627, floki@vikings.net, Lagertha, Ragnar'); + }); + }); + + describe('When participantAccountIDs is not passed and report is provided', () => { + it('uses report name when available (no limit)', async () => { + const report: Report = { + ...createRegularChat(1, [1, 2, 3, 4]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportName: "Let's talk", + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(getGroupChatName(undefined, false, report)).toEqual("Let's talk"); + }); + + it('uses report name when available (limit true)', async () => { + const report: Report = { + ...createRegularChat(1, [1, 2, 3, 4]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportName: "Let's talk", + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(getGroupChatName(undefined, true, report)).toEqual("Let's talk"); + }); + + it('uses report name when >5 participants and limit true', async () => { + const report: Report = { + ...createRegularChat(1, [1, 2, 3, 4, 5, 6, 7, 8]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportName: "Let's talk", + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(getGroupChatName(undefined, true, report)).toEqual("Let's talk"); + }); + + it('uses report name when >5 participants and limit false', async () => { + const report: Report = { + ...createRegularChat(1, [1, 2, 3, 4, 5, 6, 7, 8]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportName: "Let's talk", + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(getGroupChatName(undefined, false, report)).toEqual("Let's talk"); + }); + + it('falls back to participant names when report name is empty', async () => { + const report: Report = { + ...createRegularChat(1, [1, 2, 3, 4, 5, 6, 7, 8]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportName: '', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(getGroupChatName(undefined, false, report)).toEqual('Eight, Five, Four, One, Seven, Six, Three, Two'); + }); + }); + }); }); From bd33929434062066d73afb55d46414e20fd9808a Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Mon, 17 Nov 2025 20:46:09 +0100 Subject: [PATCH 09/11] updates --- src/libs/ReportNameUtils.ts | 7 +------ tests/unit/ReportNameUtilsTest.ts | 6 +++--- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index aacce210ab7df..033df181c31a6 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -282,12 +282,7 @@ function getInvoicePayerName(report: OnyxEntry, invoiceReceiverPolicy?: return formatPhoneNumber(getDisplayNameOrDefault(personalDetail ?? undefined)); } - let policyToUse = invoiceReceiverPolicy; - if (!policyToUse && invoiceReceiver?.policyID) { - policyToUse = getPolicy(invoiceReceiver.policyID); - } - - return getPolicyName({report, policy: policyToUse}); + return getPolicyName({report, policy: invoiceReceiverPolicy}); } /** diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index 6ff59940a3472..77ae25690a3e1 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -152,7 +152,7 @@ describe('ReportNameUtils', () => { }); describe('computeReportName - Policy expense chat', () => { - test('Returns policy expense chat name for own PEC', async () => { + test('Returns policy expense chat name when owner is set', async () => { const report: Report = { ...createPolicyExpenseChat(20, true), ownerAccountID: 1, @@ -295,8 +295,8 @@ describe('ReportNameUtils', () => { }); }); - describe('computeReportName - reportNameValuePairsList archiving', () => { - test('Regular chat gets archived suffix from reportNameValuePairsList', async () => { + describe('computeReportName - reportNameValuePairs archiving', () => { + test('Regular chat gets archived suffix from reportNameValuePairs', async () => { const report: Report = { ...createRegularChat(70, [currentUserAccountID, 1]), ownerAccountID: currentUserAccountID, From 98572ad4ece0bbab60067eacf789eb66f5e3c5e0 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Mon, 17 Nov 2025 20:50:50 +0100 Subject: [PATCH 10/11] fix linter --- tests/unit/ReportNameUtilsTest.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index 77ae25690a3e1..ec43d40c2bd6c 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -10,17 +10,6 @@ import {fakePersonalDetails} from '../utils/LHNTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; describe('ReportNameUtils', () => { - beforeAll(async () => { - Onyx.init({keys: ONYXKEYS}); - await Onyx.multiSet({ - [ONYXKEYS.PERSONAL_DETAILS_LIST]: participantsPersonalDetails, - [ONYXKEYS.SESSION]: {accountID: currentUserAccountID, email: 'lagertha2@vikings.net'}, - }); - await IntlStore.load(CONST.LOCALES.EN); - await waitForBatchedUpdates(); - }); - - // moved lower after constants const currentUserAccountID = 5; const participantsPersonalDetails: PersonalDetailsList = [ { @@ -59,6 +48,16 @@ describe('ReportNameUtils', () => { return acc; }, {} as PersonalDetailsList); + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: participantsPersonalDetails, + [ONYXKEYS.SESSION]: {accountID: currentUserAccountID, email: 'lagertha2@vikings.net'}, + }); + await IntlStore.load(CONST.LOCALES.EN); + await waitForBatchedUpdates(); + }); + const emptyCollections = { reports: {} as Record, policies: {} as Record, From a8c1f30c2989063e8ef9c5c4c01a8a2064852714 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Mon, 17 Nov 2025 21:06:48 +0100 Subject: [PATCH 11/11] fix of the linter --- src/libs/ReportNameUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index 033df181c31a6..1b1dac21cbdd4 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -553,7 +553,7 @@ function computeChatThreadReportName(report: Report, reportNameValuePairs: Repor } const isAttachment = isReportActionAttachment(!isEmptyObject(parentReportAction) ? parentReportAction : undefined); - const reportActionMessage = getReportActionText(parentReportAction).replace(/(\n+|\r\n|\n|\r)/gm, ' '); + const reportActionMessage = getReportActionText(parentReportAction).replaceAll(/(\n+|\r\n|\n|\r)/gm, ' '); if (isAttachment && reportActionMessage) { // eslint-disable-next-line @typescript-eslint/no-deprecated return `[${translateLocal('common.attachment')}]`;