diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index e38f4736e3f6..f4512fe2dd58 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -332,8 +332,21 @@ function getParticipantsOption(participant: OptionData | Participant, personalDe const detail = participant.accountID ? getPersonalDetailsForAccountIDs([participant.accountID], personalDetails)[participant.accountID] : undefined; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const login = detail?.login || participant.login || ''; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const displayName = participant?.displayName || formatPhoneNumberPhoneUtils(getDisplayNameOrDefault(detail, login || participant.text)); + // When detail has a login the participant is a real Expensify user — use their profile name. + // When detail has no login the participant is either a phone contact (optimistic stub with + // no displayName) or a privacy-hidden user (detail has a displayName but no login). Prefer + // participant.text (the device contact name for phone contacts); falling back through detail + // preserves the displayName for hidden-login users while still resolving to the formatted + // phone number for phone-contact stubs. + let displayName: string; + if (participant?.displayName) { + displayName = participant.displayName; + } else if (detail?.login) { + displayName = formatPhoneNumberPhoneUtils(getDisplayNameOrDefault(detail, login)); + } else { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string from device contacts should fall through to the formatted phone number + displayName = participant?.text || formatPhoneNumberPhoneUtils(getDisplayNameOrDefault(detail, login)); + } return { keyForList: String(detail?.accountID ?? login), diff --git a/src/libs/PersonalDetailOptionsListUtils/index.ts b/src/libs/PersonalDetailOptionsListUtils/index.ts index c49b6f6a719f..f74c29bdae4b 100644 --- a/src/libs/PersonalDetailOptionsListUtils/index.ts +++ b/src/libs/PersonalDetailOptionsListUtils/index.ts @@ -65,7 +65,12 @@ function createOption( result.keyForList = String(personalDetail.accountID); result.alternateText = formatPhoneNumber(personalDetail.login ?? ''); - result.text = getDisplayNameForParticipant({accountID: personalDetail.accountID, formatPhoneNumber}) || formatPhoneNumber(personalDetail.login ?? ''); + result.text = + getDisplayNameForParticipant({ + accountID: personalDetail.accountID, + formatPhoneNumber, + personalDetailsData: {[personalDetail.accountID]: personalDetail}, + }) || formatPhoneNumber(personalDetail.login ?? ''); result.icons = [ { id: personalDetail.accountID, diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index d05a62d8a9a1..15de1ca456a4 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -259,7 +259,10 @@ function IOURequestStepConfirmation({ const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]; const participantReportDraft = reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${participant.reportID}`]; const participantPolicy = participant.policyID ? participantsPolicies[participant.policyID] : policy; - return participant.accountID + // Phone contacts always have an optimistic accountID but no reportID; getReportOption + // is designed for report-backed participants and discards participant.text, so route + // any participant without a reportID to getParticipantsOption instead. + return participant.accountID || !participant.reportID ? getParticipantsOption(participant, personalDetails) : getReportOption(participant, privateIsArchived, participantPolicy, personalDetails, conciergeReportID, reportAttributesDerived, participantReportDraft); }) ?? [], diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 10e30661b568..aa6d28f643a6 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -29,6 +29,7 @@ import { getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, getLastMessageTextForReport, + getParticipantsOption, getPolicyExpenseReportOption, getReportDisplayOption, getReportOption, @@ -4017,6 +4018,86 @@ describe('OptionsListUtils', () => { }); }); + describe('getParticipantsOption', () => { + it('returns the personal-detail display name for a known Expensify user', () => { + const participant: Participant = {accountID: 2, login: 'tonystark@expensify.com'}; + const result = getParticipantsOption(participant, PERSONAL_DETAILS); + + // formatPhoneNumber replaces spaces with non-breaking spaces, so normalize before comparing. + expect(result.text?.replaceAll(String.fromCharCode(0xa0), ' ')).toBe('Iron Man'); + expect(result.login).toBe('tonystark@expensify.com'); + expect(result.accountID).toBe(2); + expect(result.keyForList).toBe('2'); + }); + + it('prefers participant.displayName over the personal-detail name when provided', () => { + const participant: Participant = {accountID: 2, login: 'tonystark@expensify.com', displayName: 'Override Name'}; + const result = getParticipantsOption(participant, PERSONAL_DETAILS); + + // participant.displayName takes precedence and is returned as-is. + expect(result.text).toBe('Override Name'); + }); + + it('falls back to the device-contact name (participant.text) when the personal detail has no login', () => { + // Optimistic accountID for an imported device contact: not in PERSONAL_DETAILS, + // so getPersonalDetailsForAccountIDs returns a stub with no login. + const participant: Participant = { + accountID: 9999999, + login: '+12025550123@expensify.sms', + text: 'John Smith', + }; + + const result = getParticipantsOption(participant, PERSONAL_DETAILS); + + expect(result.text).toBe('John Smith'); + expect(result.login).toBe('+12025550123@expensify.sms'); + }); + + it('falls back to the formatted phone number when neither displayName, personal-detail login, nor participant.text exist', () => { + const participant: Participant = { + accountID: 9999998, + login: '+12025550124@expensify.sms', + }; + + const result = getParticipantsOption(participant, PERSONAL_DETAILS); + + // The display name should be derived from the login (formatted phone), not be empty. + expect(result.text).toBeTruthy(); + expect(result.text).not.toBe(''); + }); + + it('uses participant.login when no accountID is provided', () => { + const participant: Participant = {login: 'guest@example.com'}; + + const result = getParticipantsOption(participant, PERSONAL_DETAILS); + + expect(result.login).toBe('guest@example.com'); + expect(result.keyForList).toBe('guest@example.com'); + }); + + it('returns the avatar, firstName, and lastName from the personal detail when available', () => { + const personalDetails: PersonalDetailsList = { + '42': { + accountID: 42, + login: 'agent@example.com', + displayName: 'Agent Smith', + firstName: 'Agent', + lastName: 'Smith', + avatar: 'https://example.com/avatar.png', + keyForList: 'agent@example.com', + reportID: '', + }, + }; + + const participant: Participant = {accountID: 42}; + const result = getParticipantsOption(participant, personalDetails); + + expect(result.firstName).toBe('Agent'); + expect(result.lastName).toBe('Smith'); + expect(result.icons?.[0]?.source).toBe('https://example.com/avatar.png'); + }); + }); + describe('getLastMessageTextForReport', () => { describe('getReportPreviewMessage', () => { it('should format report preview message correctly for non-policy expense chat with IOU action', async () => { diff --git a/tests/unit/PersonalDetailOptionsListUtilsTest.ts b/tests/unit/PersonalDetailOptionsListUtilsTest.ts index cb57564b4a6e..07d6daebbf7d 100644 --- a/tests/unit/PersonalDetailOptionsListUtilsTest.ts +++ b/tests/unit/PersonalDetailOptionsListUtilsTest.ts @@ -471,6 +471,26 @@ describe('PersonalDetailOptionsListUtils', () => { }); }); + it('should use displayName from personalDetail for optimistic (device-contact) accountIDs not in Onyx', () => { + // Optimistic accountIDs generated by generateAccountID do not exist in allPersonalDetails Onyx store. + // createOption must still resolve to the contact's name, not the raw phone number. + const optimisticAccountID = 999999; + const contactDetail = { + accountID: optimisticAccountID, + displayName: 'John Smith', + login: '+15005550006@expensify.sms', + phoneNumber: '+15005550006', + }; + const option = createOption(contactDetail, undefined, formatPhoneNumber); + expect(option.text).toBe('John Smith'); + expect(option.icons?.[0]?.name).toBe('John Smith'); + }); + + it('should resolve displayName correctly for Onyx-backed personal details (no regression)', () => { + const option = createOption(PERSONAL_DETAILS['7'], REPORTS['7'], formatPhoneNumber); + expect(option.text).toBe('Captain America'); + }); + it('should create an option with isSelected when isSelected is provided', () => { const option = createOption(PERSONAL_DETAILS['1'], undefined, formatPhoneNumber, {isSelected: true}); expect(option).toEqual({