diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 6e0186669458..e412feb7168a 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -28,7 +28,7 @@ import Parser from '@libs/Parser'; import {getAllTaxRates} from '@libs/PolicyUtils'; import {getReportAction} from '@libs/ReportActionsUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {formatReportLastMessageText, getReportOrDraftReport, getReportSubtitlePrefix} from '@libs/ReportUtils'; +import {getReportOrDraftReport} from '@libs/ReportUtils'; import {buildSearchQueryJSON, buildUserReadableQueryString, getQueryWithoutFilters, shouldHighlight} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; import {cancelSpan, endSpan, getSpan} from '@libs/telemetry/activeSpans'; @@ -505,14 +505,12 @@ function SearchAutocompleteList({ const report = getReportOrDraftReport(option.reportID, undefined, undefined, undefined, reports?.[`${ONYXKEYS.COLLECTION.REPORT}${option.reportID}`]); const reportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); const shouldParserToHTML = !!reportAction && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; - const shouldParseAlternateText = report?.lastActionType !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; const keyForList = option.keyForList ?? option.reportID ?? (option.accountID ? String(option.accountID) : undefined); return { ...option, keyForList, pressableStyle: styles.br2, text: StringUtils.lineBreaksToSpaces(shouldParserToHTML ? Parser.htmlToText(option.text ?? '') : (option.text ?? '')), - alternateText: shouldParseAlternateText ? option.alternateText : getReportSubtitlePrefix(report) + formatReportLastMessageText(option.lastMessageText ?? ''), wrapperStyle: [styles.pr3, styles.pl3], } as AutocompleteListItem; }); diff --git a/src/libs/OptionsListUtils/getChatPreviewParts.ts b/src/libs/OptionsListUtils/getChatPreviewParts.ts new file mode 100644 index 000000000000..caf2b36fc6dd --- /dev/null +++ b/src/libs/OptionsListUtils/getChatPreviewParts.ts @@ -0,0 +1,304 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from '@libs/LocalePhoneNumber'; +import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import { + getLastVisibleAction, + getLastVisibleActionIncludingTransactionThread, + getOriginalMessage, + getRenamedAction, + getReportActionActorAccountID, + getReportActionMessageText, + isActionOfType, + isCardIssuedAction, + isInviteOrRemovedAction, + isMovedTransactionAction, + isOldDotReportAction, + isPolicyCopyReportAction, + isRenamedAction, + isReportActionVisibleAsLastAction, + isTaskAction, +} from '@libs/ReportActionsUtils'; +import {getReportName} from '@libs/ReportNameUtils'; +import { + canUserPerformWriteAction, + formatReportLastMessageText, + getReportOrDraftReport, + isChatThread, + isDeprecatedGroupDM, + isDM, + isExpenseReport, + isChatRoom as reportUtilsIsChatRoom, + isGroupChat as reportUtilsIsGroupChat, + isPolicyExpenseChat as reportUtilsIsPolicyExpenseChat, + isSelfDM as reportUtilsIsSelfDM, + isTaskReport as reportUtilsIsTaskReport, + isThread as reportUtilsIsThread, +} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import type {PersonalDetails, PersonalDetailsList, Report, ReportAction, ReportAttributesDerivedValue, VisibleReportActionsDerivedValue} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +function getLastActorDisplayName(lastActorDetails: Partial | null, currentUserAccountID: number, translate: LocalizedTranslate) { + if (!lastActorDetails) { + return ''; + } + + if (lastActorDetails.accountID === CONST.ACCOUNT_ID.CONCIERGE) { + return CONST.CONCIERGE_DISPLAY_NAME; + } + + return lastActorDetails.accountID !== currentUserAccountID + ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + lastActorDetails.firstName || temporaryGetDisplayNameOrDefault({passedPersonalDetails: lastActorDetails, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils}) + : translate('common.you'); +} + +function shouldShowLastActorDisplayName( + report: OnyxEntry, + lastActorDetails: Partial | null, + lastAction: OnyxEntry, + currentUserAccountIDParam: number, + translate: LocalizedTranslate, +) { + // 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; + + if ( + !lastActionName || + !lastActorDetails || + reportUtilsIsSelfDM(report) || + (isDM(report) && lastActorDetails.accountID !== currentUserAccountIDParam) || + lastActionName === CONST.REPORT.ACTIONS.TYPE.IOU + ) { + return false; + } + + const lastActorDisplayName = getLastActorDisplayName(lastActorDetails, currentUserAccountIDParam, translate); + + if (!lastActorDisplayName) { + return false; + } + + return true; +} + +function getLastActorDisplayNameFromLastVisibleActions( + report: OnyxEntry, + lastActorDetails: Partial | null, + currentUserAccountIDParam: number, + personalDetails: OnyxEntry, + privateIsArchived: boolean | undefined, + translate: LocalizedTranslate, + visibleReportActionsData?: VisibleReportActionsDerivedValue, + lastAction?: OnyxEntry, +): string { + const reportID = report?.reportID; + const canUserPerformWrite = canUserPerformWriteAction(report, privateIsArchived); + 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; + + if (!actorDetails && lastReportAction.person?.at(0)?.text) { + actorDetails = { + displayName: lastReportAction.person?.at(0)?.text, + accountID: lastActorAccountID, + }; + } + + if (actorDetails) { + return getLastActorDisplayName(actorDetails, currentUserAccountIDParam, translate); + } + } + + return getLastActorDisplayName(lastActorDetails, currentUserAccountIDParam, translate); +} + +// These POLICY_CHANGE_LOG actions have no custom alternate text branch in SidebarUtils.getOptionData, +// so the LHN renders them with the generic `Name: message` prefix and search must keep the prefix too. +const POLICY_CHANGE_LOG_ACTIONS_WITHOUT_CUSTOM_TEXT = new Set([ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORIES, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REPLACE_CATEGORIES, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_REIMBURSEMENT, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DISABLED_FIELDS, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MULTIPLE_TAGS_APPROVER_RULES, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS, +]); +const POLICY_CHANGE_LOG_ACTION_NAMES = new Set( + Object.values(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG).filter((actionName) => !POLICY_CHANGE_LOG_ACTIONS_WITHOUT_CUSTOM_TEXT.has(actionName)), +); +const ROOM_CHANGE_LOG_ACTION_NAMES = new Set(Object.values(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG)); +const CUSTOM_ALTERNATE_TEXT_ACTION_NAMES = new Set([ + CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED, + CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN, + CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE, + CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.RETRACTED, + CONST.REPORT.ACTIONS.TYPE.REOPENED, + CONST.REPORT.ACTIONS.TYPE.TRAVEL_UPDATE, + CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL, + CONST.REPORT.ACTIONS.TYPE.REROUTE, + CONST.REPORT.ACTIONS.TYPE.REASSIGN_APPROVER, + CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED, +]); + +function isActionWithCustomAlternateText(lastAction: OnyxEntry): boolean { + const actionName = lastAction?.actionName; + if (!lastAction || !actionName) { + return false; + } + return ( + isRenamedAction(lastAction) || + isTaskAction(lastAction) || + isInviteOrRemovedAction(lastAction) || + isCardIssuedAction(lastAction) || + isOldDotReportAction(lastAction) || + isPolicyCopyReportAction(lastAction) || + isMovedTransactionAction(lastAction) || + (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT) && !!getOriginalMessage(lastAction)?.resolution) || + POLICY_CHANGE_LOG_ACTION_NAMES.has(actionName) || + ROOM_CHANGE_LOG_ACTION_NAMES.has(actionName) || + CUSTOM_ALTERNATE_TEXT_ACTION_NAMES.has(actionName) + ); +} + +/** + * Pieces of the chat preview line that the LHN renders for the last message. + */ +type ChatPreviewParts = { + /** The `Name: ` prefix identifying the author of the last message, or an empty string when no prefix should be shown */ + actorPrefix: string; + + /** Replacement preview text for actions whose LHN alternate text embeds the actor (e.g. rename, leave room, invite/remove) */ + customAlternateText?: string; +}; + +/** + * Returns the chat preview pieces that the LHN (SidebarUtils.getOptionData) renders for the last message: + * the `Name: ` actor prefix, plus a replacement text for the actions whose LHN alternate text embeds the actor + * (rename, leave room, invite/remove) — excluding those from the generic prefix alone would drop the actor. + */ +function getChatPreviewParts({ + report, + personalDetails, + isReportArchived, + translate, + visibleReportActionsData, + currentUserAccountID, + sortedActions, + reportAttributesDerived, + oneTransactionThreadReportID, +}: { + report: OnyxEntry; + personalDetails: OnyxEntry; + isReportArchived: boolean | undefined; + translate: LocalizedTranslate; + visibleReportActionsData?: VisibleReportActionsDerivedValue; + currentUserAccountID: number | undefined; + sortedActions?: Record; + reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + oneTransactionThreadReportID?: string; +}): ChatPreviewParts { + if (!report || isReportArchived || currentUserAccountID === undefined) { + return {actorPrefix: ''}; + } + const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); + const sortedActionsForReport = sortedActions?.[report.reportID]; + + const lastAction = sortedActionsForReport + ? sortedActionsForReport.find((action) => isReportActionVisibleAsLastAction(action, canUserPerformWrite, visibleReportActionsData, report.reportID, currentUserAccountID)) + : getLastVisibleActionIncludingTransactionThread(report.reportID, canUserPerformWrite, undefined, visibleReportActionsData, oneTransactionThreadReportID, currentUserAccountID); + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const lastActorAccountID = getReportActionActorAccountID(lastAction, undefined, undefined) || report.lastActorAccountID; + let resolvedLastActorDetails: Partial | null = lastActorAccountID ? (personalDetails?.[lastActorAccountID] ?? null) : null; + if (!resolvedLastActorDetails && lastAction?.person?.at(0)?.text) { + resolvedLastActorDetails = { + displayName: lastAction.person.at(0)?.text, + accountID: report.lastActorAccountID, + }; + } + const lastActorDisplayName = getLastActorDisplayName(resolvedLastActorDetails, currentUserAccountID, translate); + + const isThreadMessage = + reportUtilsIsThread(report) && lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && lastAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const usesChatPrefixRules = + reportUtilsIsChatRoom(report) || + reportUtilsIsPolicyExpenseChat(report) || + isChatThread(report) || + reportUtilsIsTaskReport(report) || + isThreadMessage || + reportUtilsIsGroupChat(report) || + isDeprecatedGroupDM(report, isReportArchived); + + let customAlternateText: string | undefined; + if (usesChatPrefixRules) { + if (isRenamedAction(lastAction)) { + customAlternateText = getRenamedAction(translate, lastAction, isExpenseReport(report), lastActorDisplayName); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM) { + const actionMessage = getReportActionMessageText(lastAction); + customAlternateText = actionMessage ? `${lastActorDisplayName}: ${actionMessage}` : ''; + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM) { + customAlternateText = translate('report.actions.type.leftTheChatWithName', lastActorDisplayName); + } else if (isInviteOrRemovedAction(lastAction)) { + let actorDetails: Partial | undefined; + if (lastAction.actorAccountID) { + actorDetails = personalDetails?.[lastAction.actorAccountID] ?? undefined; + } + let actorDisplayName = lastAction.person?.[0]?.text; + if (!actorDetails && actorDisplayName && lastAction.actorAccountID) { + actorDetails = { + displayName: actorDisplayName, + accountID: lastAction.actorAccountID, + }; + } + actorDisplayName = actorDetails ? getLastActorDisplayName(actorDetails, currentUserAccountID, translate) : undefined; + const lastActionOriginalMessage = getOriginalMessage(lastAction); + const targetAccountIDs = lastActionOriginalMessage?.targetAccountIDs ?? []; + const targetAccountIDsLength = targetAccountIDs.length !== 0 ? targetAccountIDs.length : (report.lastMessageHtml?.match(/]*><\/mention-user>/g)?.length ?? 0); + const isInvite = + lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM || lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INVITE_TO_ROOM; + const verb = isInvite ? translate('workspace.invite.invited') : translate('workspace.invite.removed'); + const users = translate(targetAccountIDsLength > 1 ? 'common.members' : 'common.member')?.toLocaleLowerCase(); + customAlternateText = formatReportLastMessageText(`${actorDisplayName ?? lastActorDisplayName}: ${verb} ${targetAccountIDsLength} ${users}`); + const lastActionReport = lastActionOriginalMessage?.reportID ? getReportOrDraftReport(String(lastActionOriginalMessage.reportID)) : undefined; + const derivedReportName = lastActionReport?.reportID ? reportAttributesDerived?.[lastActionReport.reportID]?.reportName : undefined; + const roomName = getReportName(lastActionReport, derivedReportName) || lastActionOriginalMessage?.roomName; + if (roomName) { + const preposition = isInvite ? ` ${translate('workspace.invite.to')}` : ` ${translate('workspace.invite.from')}`; + customAlternateText += `${preposition} ${roomName}`; + } + } + } + + const shouldShowActorPrefix = usesChatPrefixRules + ? lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && !!lastActorDisplayName && !isActionWithCustomAlternateText(lastAction) + : shouldShowLastActorDisplayName(report, resolvedLastActorDetails, lastAction, currentUserAccountID, translate); + if (!shouldShowActorPrefix) { + return {actorPrefix: '', customAlternateText}; + } + const displayName = + getLastActorDisplayNameFromLastVisibleActions( + report, + resolvedLastActorDetails, + currentUserAccountID, + personalDetails, + isReportArchived, + translate, + visibleReportActionsData, + lastAction, + ) || lastActorDisplayName; + return {actorPrefix: displayName ? `${displayName}: ` : '', customAlternateText}; +} + +export {getChatPreviewParts, getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, shouldShowLastActorDisplayName}; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 532429df3415..44ea8f651c1d 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -67,7 +67,6 @@ import { getRenamedAction, getRenamedCardFeedMessage, getReportAction, - getReportActionActorAccountID, getReportActionMessageText, getRequireCompanyCardsEnabledMessage, getRequiresCategoryMessage, @@ -235,6 +234,7 @@ import type { SectionForSearchTerm, } from './types'; +import {getChatPreviewParts} from './getChatPreviewParts'; import {doesPersonalDetailMatchSearchTerm, getCurrentUserSearchTerms, getPersonalDetailSearchTerms} from './searchMatchUtils'; /** @@ -426,53 +426,6 @@ function uniqFast(items: string[]): string[] { return result; } -function getLastActorDisplayName(lastActorDetails: Partial | null, currentUserAccountID: number, translate: LocalizedTranslate) { - if (!lastActorDetails) { - return ''; - } - - if (lastActorDetails.accountID === CONST.ACCOUNT_ID.CONCIERGE) { - return CONST.CONCIERGE_DISPLAY_NAME; - } - - return lastActorDetails.accountID !== currentUserAccountID - ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - lastActorDetails.firstName || temporaryGetDisplayNameOrDefault({passedPersonalDetails: lastActorDetails, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils}) - : translate('common.you'); -} - -function shouldShowLastActorDisplayName( - report: OnyxEntry, - lastActorDetails: Partial | null, - lastAction: OnyxEntry, - currentUserAccountIDParam: number, - translate: LocalizedTranslate, -) { - // 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; - - if ( - !lastActionName || - !lastActorDetails || - reportUtilsIsSelfDM(report) || - (isDM(report) && lastActorDetails.accountID !== currentUserAccountIDParam) || - lastActionName === CONST.REPORT.ACTIONS.TYPE.IOU - ) { - return false; - } - - const lastActorDisplayName = getLastActorDisplayName(lastActorDetails, currentUserAccountIDParam, translate); - - if (!lastActorDisplayName) { - return false; - } - - return true; -} - type GetAlternateTextConfig = { dateFnsLocale: DateFnsLocale | undefined; isReportArchived: boolean | undefined; @@ -520,8 +473,11 @@ function getAlternateText( const isGroupChat = reportUtilsIsGroupChat(report); const isExpenseThread = isMoneyRequest(report); const translateFn = translate ?? translateLocal; + // Keep Plain comments as they're typed (example: `test` stays `test`). + // Parser.htmlToText would strip it to `test`. Ref: https://github.com/Expensify/App/issues/82036 + const isLastActionAddComment = report?.lastActionType === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; const formattedLastMessageText = - formatReportLastMessageText(Parser.htmlToText(option.lastMessageText ?? '')) || + formatReportLastMessageText(isLastActionAddComment ? (option.lastMessageText ?? '') : Parser.htmlToText(option.lastMessageText ?? '')) || getLastMessageTextForReport({ translate: translateFn, dateFnsLocale, @@ -539,7 +495,23 @@ function getAlternateText( currentUserAccountID, }); const reportPrefix = getReportSubtitlePrefix(report); - const formattedLastMessageTextWithPrefix = reportPrefix + formattedLastMessageText; + + const {actorPrefix, customAlternateText} = + showChatPreviewLine && formattedLastMessageText + ? getChatPreviewParts({ + report, + personalDetails, + isReportArchived, + translate: translateFn, + visibleReportActionsData, + currentUserAccountID, + sortedActions, + reportAttributesDerived, + // eslint-disable-next-line @typescript-eslint/no-deprecated + oneTransactionThreadReportID: report?.reportID ? deprecatedCachedOneTransactionThreadReportIDs[report.reportID] : undefined, + }) + : {actorPrefix: '', customAlternateText: undefined}; + const formattedLastMessageTextWithPrefix = reportPrefix + actorPrefix + (customAlternateText ?? formattedLastMessageText); if (isExpenseThread || option.isMoneyRequestReport) { return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('iou.expense'); @@ -626,40 +598,6 @@ function getExpenseReportPreviewText( return formatReportLastMessageText(translate('iou.expenseAmount', formattedAmount, comment || undefined)); } -function getLastActorDisplayNameFromLastVisibleActions( - report: OnyxEntry, - lastActorDetails: Partial | null, - currentUserAccountIDParam: number, - personalDetails: OnyxEntry, - privateIsArchived: boolean | undefined, - translate: LocalizedTranslate, - visibleReportActionsData?: VisibleReportActionsDerivedValue, - lastAction?: OnyxEntry, -): string { - const reportID = report?.reportID; - const canUserPerformWrite = canUserPerformWriteAction(report, privateIsArchived); - 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; - - if (!actorDetails && lastReportAction.person?.at(0)?.text) { - actorDetails = { - displayName: lastReportAction.person?.at(0)?.text, - accountID: lastActorAccountID, - }; - } - - if (actorDetails) { - return getLastActorDisplayName(actorDetails, currentUserAccountIDParam, translate); - } - } - - return getLastActorDisplayName(lastActorDetails, currentUserAccountIDParam, translate); -} - /** * Get the last message text from the report directly or from other sources for special cases. */ @@ -2631,7 +2569,7 @@ function prepareReportOptionsForDisplay( options: Array>, policiesCollection: OnyxCollection, isOffline: boolean, - config: GetValidReportsConfig & {translate: LocalizedTranslate; dateFnsLocale: DateFnsLocale | undefined}, + config: GetValidReportsConfig & {translate: LocalizedTranslate; dateFnsLocale: DateFnsLocale | undefined; currentUserAccountID?: number}, conciergeReportID: string | undefined, sortedActions: Record | undefined, visibleReportActionsData: VisibleReportActionsDerivedValue = {}, @@ -2653,6 +2591,7 @@ function prepareReportOptionsForDisplay( shouldUnreadBeBold = false, personalDetails, translate, + currentUserAccountID, } = config; const validOptions: Array> = []; @@ -2671,6 +2610,8 @@ 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 lastActorDetails = personalDetails?.[report.lastActorAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? null; + const alternateText = getAlternateText( option, {showChatPreviewLine, forcePolicyNamePreview}, @@ -2679,13 +2620,14 @@ function prepareReportOptionsForDisplay( isReportArchived: !!option.private_isArchived, personalDetails, policy, - lastActorDetails: null, + lastActorDetails, visibleReportActionsData, reportAttributesDerived, policyTags: reportPolicyTags, conciergeReportID, sortedActions, isTrackIntentUser, + currentUserAccountID, }, ); const isSelected = isReportSelected(option, selectedOptions); @@ -2935,6 +2877,7 @@ function getValidOptions( shouldShowGBR, personalDetails, translate, + currentUserAccountID, }, conciergeReportID, sortedActions, @@ -2962,6 +2905,7 @@ function getValidOptions( shouldShowGBR, personalDetails, translate, + currentUserAccountID, }, conciergeReportID, sortedActions, @@ -2985,6 +2929,7 @@ function getValidOptions( shouldShowGBR, personalDetails, translate, + currentUserAccountID, }, conciergeReportID, sortedActions, @@ -3753,8 +3698,6 @@ export { getHeaderMessage, getHeaderMessageForNonUserList, getIOUConfirmationOptionsFromPayeePersonalDetail, - getLastActorDisplayName, - getLastActorDisplayNameFromLastVisibleActions, getLastMessageTextForReport, getNoneOption, getParticipantsOption, @@ -3778,7 +3721,6 @@ export { orderPersonalDetailsOptions, orderWorkspaceOptions, recentReportComparator, - shouldShowLastActorDisplayName, shouldUseBoldText, sortAlphabetically, personalDetailsComparator, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 81e6658600c4..38b3345dadc7 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -5103,6 +5103,7 @@ export { shouldHideNewMarker, shouldReportActionBeVisible, isReportActionVisible, + isReportActionVisibleAsLastAction, wasActionTakenByCurrentUser, isInviteOrRemovedAction, isActionableAddPaymentCard, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 2680cbadcdf6..7ab4e8a78ec1 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -36,13 +36,8 @@ import type {OptionData} from './ReportUtils'; import {isAnonymousUser} from './actions/Session'; import {getAddAgentRuleMessage, getDeleteAgentRuleMessage, getUpdateAgentRuleMessage} from './AgentRuleChangeLogUtils'; import {formatList} from './Localize'; -import { - getLastActorDisplayName, - getLastActorDisplayNameFromLastVisibleActions, - getLastMessageTextForReport, - getPersonalDetailsForAccountIDs, - shouldShowLastActorDisplayName, -} from './OptionsListUtils'; +import {getLastMessageTextForReport, getPersonalDetailsForAccountIDs} from './OptionsListUtils'; +import {getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, shouldShowLastActorDisplayName} from './OptionsListUtils/getChatPreviewParts'; import Parser from './Parser'; import {getPersonalDetailsByID} from './PersonalDetailsUtils'; import {getCleanedTagName} from './PolicyUtils'; diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 372f3dfaec2c..10d70dd9127f 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -27,9 +27,8 @@ import { filterWorkspaceChats, formatMemberForList, formatSectionsFromSearchTerm, + getAlternateText, getIOUConfirmationOptionsFromPayeePersonalDetail, - getLastActorDisplayName, - getLastActorDisplayNameFromLastVisibleActions, getLastMessageTextForReport, getParticipantsOption, getPolicyExpenseReportOption, @@ -46,9 +45,9 @@ import { orderPersonalDetailsOptions, orderWorkspaceOptions, recentReportComparator, - shouldShowLastActorDisplayName, sortAlphabetically, } from '@libs/OptionsListUtils'; +import {getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, shouldShowLastActorDisplayName} from '@libs/OptionsListUtils/getChatPreviewParts'; import {getCurrentUserSearchTerms, getPersonalDetailSearchTerms} from '@libs/OptionsListUtils/searchMatchUtils'; import Parser from '@libs/Parser'; import { @@ -81,6 +80,7 @@ import { isExpensifyOnlyParticipantInReport, } from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; +import SidebarUtils from '@libs/SidebarUtils'; import {isScanning} from '@libs/TransactionUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -102,7 +102,7 @@ import {createRandomReport, createRegularChat} from '../utils/collections/report import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; import {getFakeAdvancedReportAction} from '../utils/LHNTestUtils'; -import {formatPhoneNumber, getCurrencyDecimalsLocal, localeCompare, translateLocal} from '../utils/TestHelper'; +import {convertToDisplayString, formatPhoneNumber, getCurrencyDecimalsLocal, localeCompare, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@rnmapbox/maps', () => { @@ -4693,6 +4693,457 @@ describe('OptionsListUtils', () => { }); }); + describe('getAlternateText()', () => { + const ROOM_REPORT_ID = '9100'; + const DM_REPORT_ID = '9200'; + + const participants = { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }; + + const buildRoomReport = (overrides: Partial = {}): Report => ({ + reportID: ROOM_REPORT_ID, + reportName: '#galaxy', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, + policyID, + lastReadTime: '2024-01-01 10:00:00.000', + lastVisibleActionCreated: '2024-01-01 10:00:00.000', + lastMessageText: 'hello', + lastActionType: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + lastActorAccountID: 3, + participants, + ...overrides, + }); + + const buildDMReport = (overrides: Partial = {}): Report => ({ + reportID: DM_REPORT_ID, + reportName: '', + type: CONST.REPORT.TYPE.CHAT, + lastReadTime: '2024-01-01 10:00:00.000', + lastVisibleActionCreated: '2024-01-01 10:00:00.000', + lastMessageText: 'hello', + lastActionType: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + lastActorAccountID: 3, + participants, + ...overrides, + }); + + const buildAction = (actionName: Parameters[0], actorAccountID = 3, originalMessage?: Record): ReportAction => + ({ + ...getFakeAdvancedReportAction(actionName), + actorAccountID, + ...(originalMessage === undefined ? {} : {originalMessage}), + }) as ReportAction; + + const setReport = async (report: Report) => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await waitForBatchedUpdates(); + }; + + type AlternateTextConfig = Parameters[2]; + + const buildConfig = (lastAction?: ReportAction, reportID: string = ROOM_REPORT_ID, overrides: Partial = {}): AlternateTextConfig => ({ + isReportArchived: false, + personalDetails: PERSONAL_DETAILS, + dateFnsLocale: undefined, + conciergeReportID: undefined, + translate: translateLocal, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + ...(lastAction ? {sortedActions: {[reportID]: [lastAction]}} : {}), + ...overrides, + }); + + it('should keep the raw comment text when the last action is ADD_COMMENT', async () => { + // Given a DM whose last action is a plain comment containing markup typed by the user + const report = buildDMReport({lastMessageText: 'test'}); + await setReport(report); + const option: OptionData = {reportID: DM_REPORT_ID, keyForList: '', lastMessageText: 'test'}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, DM_REPORT_ID)); + + // Then the markup is preserved as typed (https://github.com/Expensify/App/issues/82036) + expect(result).toBe('test'); + }); + + it('should strip HTML from the last message when the last action is not ADD_COMMENT', async () => { + // Given a DM whose last action is not a comment, so the last message is server-built HTML + const report = buildDMReport({lastMessageText: 'test', lastActionType: CONST.REPORT.ACTIONS.TYPE.RENAMED}); + await setReport(report); + const option: OptionData = {reportID: DM_REPORT_ID, keyForList: '', lastMessageText: 'test'}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, DM_REPORT_ID)); + + expect(result).toBe('test'); + }); + + it('should prefix the room preview with the last actor display name', async () => { + await setReport(buildRoomReport()); + const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment)); + + expect(result).toBe('Spider-Man: hello'); + }); + + it('should use "You" as the prefix when the current user sent the last message', async () => { + await setReport(buildRoomReport({lastActorAccountID: CURRENT_USER_ACCOUNT_ID})); + const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, CURRENT_USER_ACCOUNT_ID); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment)); + + expect(result).toBe('You: hello'); + }); + + it('should omit the actor prefix when the report is archived', async () => { + await setReport(buildRoomReport()); + const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment, ROOM_REPORT_ID, {isReportArchived: true})); + + expect(result).toBe('hello'); + }); + + it('should omit the actor prefix when currentUserAccountID is undefined', async () => { + await setReport(buildRoomReport()); + const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment, ROOM_REPORT_ID, {currentUserAccountID: undefined})); + + expect(result).toBe('hello'); + }); + + it('should omit the actor prefix when the last action is a report preview', async () => { + await setReport(buildRoomReport({lastActionType: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, lastMessageText: 'owes $10'})); + const preview = buildAction(CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, 3); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'owes $10', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(preview)); + + expect(result).toBe('owes $10'); + }); + + it('should fall back to the report action person text when the actor is missing from personal details', async () => { + await setReport(buildRoomReport({lastActorAccountID: 999})); + // The fake action carries person: [{text: 'Email One'}] and account 999 is not in PERSONAL_DETAILS + const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 999); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment)); + + expect(result).toBe('Email One: hello'); + }); + + it('should replace the preview with the rename message for a RENAMED last action', async () => { + await setReport(buildRoomReport({lastActionType: CONST.REPORT.ACTIONS.TYPE.RENAMED, lastMessageText: 'renamed this room'})); + const renamed = buildAction(CONST.REPORT.ACTIONS.TYPE.RENAMED, 3, {oldName: 'Old Room', newName: 'New Room'}); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'renamed this room', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(renamed)); + + expect(result).toBe('Spider-Man renamed this room to "New Room" (previously "Old Room")'); + }); + + it('should replace the preview with the leave message for a room LEAVE_ROOM last action', async () => { + await setReport(buildRoomReport({lastMessageText: 'left the chat'})); + const leave = buildAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM, 3); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'left the chat', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(leave)); + + expect(result).toBe('Spider-Man: left the chat'); + }); + + it('should prefix the action message with the actor for a policy LEAVE_ROOM last action', async () => { + await setReport(buildRoomReport({lastMessageText: 'left the workspace'})); + // The fake action's message text is 'hey' + const leave = buildAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM, 3); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'left the workspace', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(leave)); + + expect(result).toBe('Spider-Man: hey'); + }); + + it('should build the invite message with member count and room name', async () => { + await setReport(buildRoomReport({lastMessageText: 'invited'})); + const invite = buildAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, 3, {targetAccountIDs: [4, 5], roomName: '#galaxy'}); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'invited', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(invite)); + + expect(result).toBe('Spider-Man: invited 2 members to #galaxy'); + }); + + it('should build the remove message with a singular member and room name', async () => { + await setReport(buildRoomReport({lastMessageText: 'removed'})); + const remove = buildAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM, 3, {targetAccountIDs: [4], roomName: '#galaxy'}); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'removed', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(remove)); + + expect(result).toBe('Spider-Man: removed 1 member from #galaxy'); + }); + + it('should count invited members from lastMessageHtml mentions when targetAccountIDs is empty', async () => { + await setReport( + buildRoomReport({ + lastMessageText: 'invited', + lastMessageHtml: ' ', + }), + ); + const invite = buildAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, 3, {targetAccountIDs: []}); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'invited', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(invite)); + + expect(result).toBe('Spider-Man: invited 2 members'); + }); + + it.each([CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED, CONST.REPORT.ACTIONS.TYPE.RETRACTED])( + 'should suppress the actor prefix for %s because its text already embeds the actor', + async (actionName) => { + await setReport(buildRoomReport({lastActionType: actionName, lastMessageText: 'issued a new card'})); + const action = buildAction(actionName, 3); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'issued a new card', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(action)); + + expect(result).toBe('issued a new card'); + }, + ); + + it('should skip whisper actions when picking the last visible action from sortedActions', async () => { + await setReport(buildRoomReport()); + // getWhisperedTo prefers message.whisperedTo over originalMessage, so mark the whisper there + const whisper = { + ...buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 4), + message: [{type: 'COMMENT', html: 'psst', text: 'psst', isEdited: false, whisperedTo: [999], isDeletedParentAction: false}], + } as ReportAction; + const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, ROOM_REPORT_ID, {sortedActions: {[ROOM_REPORT_ID]: [whisper, comment]}})); + + expect(result).toBe('Spider-Man: hello'); + }); + + it('should resolve the same last action from Onyx when sortedActions is not provided', async () => { + // Dedicated reportID: module-level report-action caches survive Onyx.clear(), so writing + // REPORT_ACTIONS for the shared room would poison later tests that reuse its reportID. + const onyxRoomReportID = '9150'; + await setReport(buildRoomReport({reportID: onyxRoomReportID})); + const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${onyxRoomReportID}`, {[comment.reportActionID]: comment}); + await waitForBatchedUpdates(); + const option: OptionData = {reportID: onyxRoomReportID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + + const withSortedActions = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment, onyxRoomReportID)); + const fromOnyx = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, onyxRoomReportID)); + + expect(fromOnyx).toBe('Spider-Man: hello'); + expect(fromOnyx).toBe(withSortedActions); + }); + + it('should fall back to type subtitles when showChatPreviewLine is false', async () => { + await setReport(buildRoomReport()); + const roomOption: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true, subtitle: 'Custom subtitle'}; + const threadOption: OptionData = {reportID: '', keyForList: '', isThread: true}; + + expect(getAlternateText(roomOption, {showChatPreviewLine: false}, buildConfig())).toBe('Custom subtitle'); + expect(getAlternateText(threadOption, {showChatPreviewLine: false}, buildConfig())).toBe(translateLocal('threads.thread')); + }); + + it('should thread currentUserAccountID through getValidOptions to build the actor prefix', async () => { + // Given a room whose last visible action is a comment from another user + const report = buildRoomReport(); + await setReport(report); + const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); + + const optionList = createFilteredOptionList(PERSONAL_DETAILS, {[ROOM_REPORT_ID]: report}, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + conciergeReportID: undefined, + isSearching: true, + }); + + const {options} = getValidOptions( + {reports: optionList.reports, personalDetails: []}, + undefined, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + { + dateFnsLocale: undefined, + showChatPreviewLine: true, + includeMultipleParticipantReports: true, + personalDetails: PERSONAL_DETAILS, + sortedActions: {[ROOM_REPORT_ID]: [comment]}, + }, + translateLocal, + ); + + // Then the search option preview matches the LHN format: `Name: message` + const roomOption = options.recentReports.find((option) => option.reportID === ROOM_REPORT_ID); + expect(roomOption?.alternateText).toBe('Spider-Man: hello'); + }); + + it('should match the LHN alternate text from SidebarUtils.getOptionData for the same room and last action', async () => { + const report = buildRoomReport(); + await setReport(report); + // Align the action's own message with report.lastMessageText — the LHN reads the former, search options the latter + const comment = { + ...buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3), + message: [{type: 'COMMENT', html: 'hello', text: 'hello', isEdited: false, whisperedTo: [], isDeletedParentAction: false}], + } as ReportAction; + + const lhnOption = SidebarUtils.getOptionData({ + report, + reportAttributes: undefined, + oneTransactionThreadReport: undefined, + reportNameValuePairs: {}, + personalDetails: PERSONAL_DETAILS, + policy: undefined, + parentReportAction: undefined, + conciergeReportID: undefined, + invoiceReceiverPolicy: undefined, + card: undefined, + lastAction: comment, + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + localeCompare, + isReportArchived: false, + lastActionReport: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + formatPhoneNumber, + }); + + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + const searchAlternateText = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment)); + + expect(lhnOption?.alternateText).toBe('Spider-Man: hello'); + expect(searchAlternateText).toBe(lhnOption?.alternateText); + }); + + it.each([CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT])( + 'should keep the actor prefix for %s to match the generic LHN preview', + async (actionName) => { + // Given a room whose last action is a policy change log action that has no custom + // alternate text branch in SidebarUtils.getOptionData, so the LHN shows `Name: message` + const report = buildRoomReport({lastMessageText: 'updated a custom unit'}); + await setReport(report); + const action = { + ...buildAction(actionName, 3), + message: [{type: 'COMMENT', html: 'updated a custom unit', text: 'updated a custom unit', isEdited: false, whisperedTo: [], isDeletedParentAction: false}], + } as ReportAction; + + const lhnOption = SidebarUtils.getOptionData({ + report, + reportAttributes: undefined, + oneTransactionThreadReport: undefined, + reportNameValuePairs: {}, + personalDetails: PERSONAL_DETAILS, + policy: undefined, + parentReportAction: undefined, + conciergeReportID: undefined, + invoiceReceiverPolicy: undefined, + card: undefined, + lastAction: action, + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + localeCompare, + isReportArchived: false, + lastActionReport: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + formatPhoneNumber, + }); + + const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'updated a custom unit', isChatRoom: true}; + const searchAlternateText = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(action)); + + expect(lhnOption?.alternateText).toBe('Spider-Man: updated a custom unit'); + expect(searchAlternateText).toBe(lhnOption?.alternateText); + }, + ); + + it('should resolve the actor from the transaction thread when its comment is the newest action of a one-transaction report', async () => { + // Given a one-transaction expense report whose newest visible action is a comment in its transaction thread + const EXPENSE_REPORT_ID = '9300'; + const TRANSACTION_THREAD_REPORT_ID = '9301'; + const CHAT_REPORT_ID = '9302'; + + const expenseReport: Report = { + reportID: EXPENSE_REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: CHAT_REPORT_ID, + parentReportID: CHAT_REPORT_ID, + parentReportActionID: '9400', + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + lastReadTime: '2024-01-01 10:00:00.000', + lastVisibleActionCreated: '2024-01-02 10:00:00.000', + lastMessageText: 'thread comment', + lastActionType: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + lastActorAccountID: 3, + participants, + }; + const transactionThreadReport: Report = { + reportID: TRANSACTION_THREAD_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: EXPENSE_REPORT_ID, + parentReportActionID: '9401', + participants, + }; + const iouAction: ReportAction = { + ...buildAction(CONST.REPORT.ACTIONS.TYPE.IOU, CURRENT_USER_ACCOUNT_ID, { + IOUTransactionID: 'txn9300', + IOUReportID: EXPENSE_REPORT_ID, + amount: 100, + currency: 'USD', + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }), + reportActionID: '9401', + reportID: EXPENSE_REPORT_ID, + created: '2024-01-01 10:00:00.000', + childReportID: TRANSACTION_THREAD_REPORT_ID, + }; + const threadComment: ReportAction = { + ...buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3), + reportActionID: '9402', + reportID: TRANSACTION_THREAD_REPORT_ID, + created: '2024-01-02 10:00:00.000', + message: [{type: 'COMMENT', html: 'thread comment', text: 'thread comment', isEdited: false, whisperedTo: [], isDeletedParentAction: false}], + }; + + // Reports must exist before the report actions merge so the one-transaction thread caches resolve the thread ID + await setReport(expenseReport); + await setReport(transactionThreadReport); + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPENSE_REPORT_ID}`]: {[iouAction.reportActionID]: iouAction}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${TRANSACTION_THREAD_REPORT_ID}`]: {[threadComment.reportActionID]: threadComment}, + }); + await waitForBatchedUpdates(); + + const option: OptionData = {reportID: EXPENSE_REPORT_ID, keyForList: '', lastMessageText: 'thread comment', isMoneyRequestReport: true}; + + // When the alternate text is built without sortedActions, forcing the fallback last-action lookup + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, EXPENSE_REPORT_ID)); + + // Then the actor prefix comes from the transaction thread comment, not from the parent report's IOU action + expect(result).toBe('Spider-Man: thread comment'); + }); + }); + describe('createFilteredOptionList()', () => { it('should set private_isArchived on personal details options when privateIsArchivedMap is provided', () => { renderLocaleContextProvider(); diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index e337b1894ff5..7b7de28283a1 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -7,7 +7,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import {generateTransactionID} from '@libs/actions/Transaction'; import DateUtils from '@libs/DateUtils'; -import {getLastActorDisplayName} from '@libs/OptionsListUtils'; +import {getLastActorDisplayName} from '@libs/OptionsListUtils/getChatPreviewParts'; import type * as PolicyUtils from '@libs/PolicyUtils'; import {getOriginalMessage, getReportActionMessageText} from '@libs/ReportActionsUtils'; import {