Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import UserPills from '@components/UserPills';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {enrichAndSortAttendees} from '@libs/AttendeeUtils';
import Navigation from '@libs/Navigation/Navigation';
import {sortAlphabetically} from '@libs/OptionsListUtils';
import {getAttendees} from '@libs/TransactionUtils';
import {getAttendees, getAttendeesListDisplayString} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import type {IOUAction, IOUType} from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
Expand All @@ -33,35 +33,22 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a
const personalDetailsList = usePersonalDetails();
const shouldDisplayAttendeesError = formError === 'violations.missingAttendees';

const iouAttendees = getAttendees(transaction, currentUserPersonalDetails);
const rawIouAttendees = getAttendees(transaction, currentUserPersonalDetails);
const iouAttendees = enrichAndSortAttendees(rawIouAttendees, personalDetailsList, localeCompare);

return (
<MenuItemWithTopDescription
key="attendees"
shouldShowRightIcon={!isReadOnly}
accessibilityLabel={`${translate('iou.attendees')}, ${iouAttendees?.map((a) => a?.displayName ?? a?.login).join(', ')}`}
accessibilityLabel={`${translate('iou.attendees')}, ${Array.isArray(iouAttendees) ? getAttendeesListDisplayString(iouAttendees) : ''}`}
description={`${translate('iou.attendees')} ${
iouAttendees?.length && iouAttendees.length > 1 && formattedAmountPerAttendee ? `\u00B7 ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : ''
}`}
descriptionTextStyle={styles.textLabelSupportingNormal}
titleComponent={
Array.isArray(iouAttendees) ? (
<UserPills
users={sortAlphabetically(
iouAttendees.map((a) => {
const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined;
const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined;
return {
...a,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
displayName: pd?.displayName || a?.displayName,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
avatarUrl: freshAvatar || a?.avatarUrl,
};
}),
'displayName',
localeCompare,
).map((a) => ({
users={iouAttendees.map((a) => ({
avatar: a?.avatarUrl,
displayName: a?.displayName ?? a?.login ?? a?.email ?? '',
accountID: a?.accountID,
Expand Down
27 changes: 8 additions & 19 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ import type {ViolationField} from '@hooks/useViolations';
import useViolations from '@hooks/useViolations';
import {updateMoneyRequestBillable, updateMoneyRequestReimbursable, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest';
import initSplitExpense from '@libs/actions/SplitExpenses';
import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils';
import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils';
import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from '@libs/CardUtils';
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getRateFromMerchant} from '@libs/MergeTransactionUtils';
import {hasEnabledOptions, sortAlphabetically} from '@libs/OptionsListUtils';
import {hasEnabledOptions} from '@libs/OptionsListUtils';
import Parser from '@libs/Parser';
import {
canSubmitPerDiemExpenseFromWorkspace,
Expand Down Expand Up @@ -82,6 +82,7 @@ import {
} from '@libs/ReportUtils';
import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils';
import {
getAttendeesListDisplayString,
getBillable,
getCurrency,
getDescription,
Expand Down Expand Up @@ -283,7 +284,8 @@ function MoneyRequestView({
const isTransactionScanning = isScanning(updatedTransaction ?? transaction);
const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest);

const actualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees;
const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees;
Comment thread
TaduJR marked this conversation as resolved.
const actualAttendees = enrichAndSortAttendees(rawActualAttendees, personalDetailsList, localeCompare);

// Use the updated transaction amount in merge flow to have correct positive/negative sign
const actualAmount = isFromMergeTransaction && updatedTransaction ? updatedTransaction.amount : transactionAmount;
Expand Down Expand Up @@ -802,7 +804,8 @@ function MoneyRequestView({
const previousTagLength = getLengthOfTag(previousTag ?? '');
const currentTagLength = getLengthOfTag(currentTransactionTag ?? '');

const getAttendeesTitle = Array.isArray(actualAttendees) ? actualAttendees.map((item) => item?.displayName ?? item?.login).join(', ') : '';
// actualAttendees is already sorted by enrichAndSortAttendees above; pass without localeCompare to preserve that order while stripping the SMS domain.
const getAttendeesTitle = Array.isArray(actualAttendees) ? getAttendeesListDisplayString(actualAttendees) : '';
const attendeesCopyValue = !canEdit ? getAttendeesTitle : undefined;

const tagList = policyTagLists.map(({name, orderWeight, tags}, index) => {
Expand Down Expand Up @@ -1171,21 +1174,7 @@ function MoneyRequestView({
titleComponent={
Array.isArray(actualAttendees) ? (
<UserPills
users={sortAlphabetically(
actualAttendees.map((a) => {
const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined;
const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined;
return {
...a,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
displayName: pd?.displayName || a?.displayName,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
avatarUrl: freshAvatar || a?.avatarUrl,
};
}),
'displayName',
localeCompare,
).map((a) => ({
users={actualAttendees.map((a) => ({
avatar: a?.avatarUrl,
displayName: a?.displayName ?? a?.login ?? a?.email ?? '',
accountID: a?.accountID,
Expand Down
40 changes: 38 additions & 2 deletions src/libs/AttendeeUtils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type {OnyxEntry} from 'react-native-onyx';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
import CONST from '@src/CONST';
import type {PolicyCategories, PolicyCategory} from '@src/types/onyx';
import type {PersonalDetailsList, PolicyCategories, PolicyCategory} from '@src/types/onyx';
import type {Attendee} from '@src/types/onyx/IOU';
import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
import {sortAlphabetically} from './OptionsListUtils';

function getNormalizedString(value?: string): string | undefined {
const normalizedValue = value?.trim();
Expand Down Expand Up @@ -133,4 +135,38 @@ function syncMissingAttendeesViolation<T extends {name: string}>(
return violations;
}

export {formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation};
/**
* Enrich each attendee with live `personalDetails` and return them sorted alphabetically by displayName.
*/
function enrichAndSortAttendees(attendees: Attendee[], personalDetailsList: OnyxEntry<PersonalDetailsList>, localeCompare: LocaleContextProps['localeCompare']): Attendee[];
function enrichAndSortAttendees(
attendees: Attendee[] | string | undefined,
personalDetailsList: OnyxEntry<PersonalDetailsList>,
localeCompare: LocaleContextProps['localeCompare'],
): Attendee[] | string | undefined;
function enrichAndSortAttendees(
attendees: Attendee[] | string | undefined,
personalDetailsList: OnyxEntry<PersonalDetailsList>,
localeCompare: LocaleContextProps['localeCompare'],
): Attendee[] | string | undefined {
if (!Array.isArray(attendees)) {
return attendees;
}
return sortAlphabetically(
attendees.map((a) => {
const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined;
const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined;
return {
...a,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
displayName: pd?.displayName || a?.displayName,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
avatarUrl: freshAvatar || a?.avatarUrl,
};
}),
'displayName',
localeCompare,
);
}

export {enrichAndSortAttendees, formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation};
16 changes: 12 additions & 4 deletions src/libs/MergeTransactionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,14 @@ function selectTargetAndSourceTransactionsForMerge(
* @param translate - The translation function
* @returns The formatted display string for the field value
*/
function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy: Policy | undefined, translate: LocaleContextProps['translate'], reports?: Array<OnyxEntry<Report>>): string {
function getDisplayValue(
field: MergeFieldKey,
transaction: Transaction,
policy: Policy | undefined,
translate: LocaleContextProps['translate'],
localeCompare: LocaleContextProps['localeCompare'],
reports?: Array<OnyxEntry<Report>>,
): string {
const fieldValue = getMergeFieldValue(getTransactionDetails(transaction), transaction, field);

if (isEmptyMergeValue(fieldValue) || fieldValue === undefined) {
Expand Down Expand Up @@ -528,7 +535,7 @@ function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy:
return transaction?.reportName ?? getReportName(getReportOrDraftReport(SafeString(fieldValue), reports));
}
if (field === 'attendees') {
return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue) : '';
return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue, localeCompare) : '';
}

if (field === 'taxValue') {
Expand All @@ -554,6 +561,7 @@ function buildMergeFieldsData(
targetTransactionPolicy: Policy | undefined,
sourceTransactionPolicy: Policy | undefined,
translate: LocaleContextProps['translate'],
localeCompare: LocaleContextProps['localeCompare'],
reports: Array<OnyxEntry<Report>> = [],
): MergeFieldData[] {
if (!targetTransaction || !sourceTransaction) {
Expand All @@ -568,12 +576,12 @@ function buildMergeFieldsData(
const options: MergeFieldOption[] = [
{
transaction: targetTransaction,
displayValue: getDisplayValue(field, targetTransaction, targetTransactionPolicy, translate, reports),
displayValue: getDisplayValue(field, targetTransaction, targetTransactionPolicy, translate, localeCompare, reports),
isSelected: selectedTransactionId === targetTransaction.transactionID,
},
{
transaction: sourceTransaction,
displayValue: getDisplayValue(field, sourceTransaction, sourceTransactionPolicy, translate, reports),
displayValue: getDisplayValue(field, sourceTransaction, sourceTransactionPolicy, translate, localeCompare, reports),
isSelected: selectedTransactionId === sourceTransaction.transactionID,
},
];
Expand Down
19 changes: 14 additions & 5 deletions src/libs/TransactionUtils/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {format, isValid, parse} from 'date-fns';
import {Str} from 'expensify-common';
import {deepEqual} from 'fast-equals';
import lodashDeepClone from 'lodash/cloneDeep';
import lodashSet from 'lodash/set';
import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
import type {Coordinate} from '@components/MapView/MapViewTypes';
import utils from '@components/MapView/utils';
import type {UnreportedExpenseListItemType} from '@components/Search/SearchList/ListItem/types';
Expand Down Expand Up @@ -1204,19 +1206,26 @@ function getAttendees(transaction: OnyxInputOrEntry<Transaction>, currentUserPer
}

/**
* Return the list of attendees as a string of display names/logins.
* Returns attendees joined as a display string. Pass `localeCompare` to sort alphabetically (matches the pill sort);
* omit it to keep insertion order — used by non-React callers that don't want to thread the comparator.
* Strips the SMS domain so phone-login attendees render the same as in the rendered pills.
*/
function getAttendeesListDisplayString(attendees: Attendee[]): string {
return attendees.map((item) => item.displayName ?? item.login).join(', ');
function getAttendeesListDisplayString(attendees: Attendee[], localeCompare?: LocaleContextProps['localeCompare']): string {
const getName = (a: Attendee) => Str.removeSMSDomain(a.displayName ?? a.login ?? '');
const ordered = localeCompare
? // Lowercase to match sortAlphabetically (the pill sort) so joined string and pill order never disagree on case.
[...attendees].sort((a, b) => localeCompare(getName(a).toLowerCase(), getName(b).toLowerCase()))
: attendees;
return ordered.map(getName).join(', ');
}

/**
* Return the list of attendees as a string and modified list of attendees as a string if present.
*/
function getFormattedAttendees(modifiedAttendees?: Attendee[], attendees?: Attendee[]): [string, string] {
function getFormattedAttendees(modifiedAttendees?: Attendee[], attendees?: Attendee[], localeCompare?: LocaleContextProps['localeCompare']): [string, string] {
const oldAttendees = modifiedAttendees ?? [];
const newAttendees = attendees ?? [];
return [getAttendeesListDisplayString(oldAttendees), getAttendeesListDisplayString(newAttendees)];
return [getAttendeesListDisplayString(oldAttendees, localeCompare), getAttendeesListDisplayString(newAttendees, localeCompare)];
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/pages/TransactionMerge/DetailsReviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) {
// Build merge fields array with all necessary information
const mergeFields = useMemo(
() =>
buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionPolicy, sourceTransactionPolicy, translate, [
buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionPolicy, sourceTransactionPolicy, translate, localeCompare, [
targetTransactionReport,
sourceTransactionReport,
]),
Expand All @@ -164,6 +164,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) {
targetTransactionPolicy,
sourceTransactionPolicy,
translate,
localeCompare,
],
);

Expand Down
10 changes: 9 additions & 1 deletion tests/perf-test/ModifiedExpenseMessage.perf-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,13 @@ test('[ModifiedExpenseMessage] getForReportAction on 1k reports and policies', a
});

await waitForBatchedUpdates();
await measureFunction(() => getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: mockedPolicyTags, currentUserLogin: CURRENT_USER_LOGIN}));
await measureFunction(() =>
getForReportAction({
translate: translateLocal,
reportAction,
policy: undefined,
policyTags: mockedPolicyTags,
currentUserLogin: CURRENT_USER_LOGIN,
}),
);
});
Loading
Loading