Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 6 additions & 1 deletion src/libs/PersonalDetailOptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/pages/iou/request/step/IOURequestStepConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}) ?? [],
Expand Down
81 changes: 81 additions & 0 deletions tests/unit/OptionsListUtilsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getLastActorDisplayName,
getLastActorDisplayNameFromLastVisibleActions,
getLastMessageTextForReport,
getParticipantsOption,
getPolicyExpenseReportOption,
getReportDisplayOption,
getReportOption,
Expand Down Expand Up @@ -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 () => {
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/PersonalDetailOptionsListUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading