Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2936,6 +2936,13 @@ const CONST = {
CONCIERGE_DISPLAY_NAME: 'Concierge',
CONCIERGE_GREETING_ACTION_ID: 'concierge-greeting',

// English values of `common.hidden` and `common.you`, for text that is stored in English regardless of the
// viewer's locale. IMPORTANT: keep in sync with `en.ts`. See getDisplayNameOrDefaultEnLocale.
EN_LOCALE_TEXT: {
HIDDEN: 'Hidden',
YOU: 'you',
},

INTEGRATION_ENTITY_MAP_TYPES: {
DEFAULT: 'DEFAULT',
NONE: 'NONE',
Expand Down
67 changes: 51 additions & 16 deletions src/libs/PersonalDetailsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ Onyx.connect({

const regexMergedAccount = new RegExp(CONST.REGEX.MERGED_ACCOUNT_PREFIX);

type DisplayNameOrDefaultParams = {
passedPersonalDetails?: Partial<PersonalDetails> | null;
defaultValue?: string;
shouldFallbackToHidden?: boolean;
shouldAddCurrentUserPostfix?: boolean;
formatPhoneNumber: LocaleContextProps['formatPhoneNumber'];
};

function getDisplayNameOrDefault(
passedPersonalDetails?: Partial<PersonalDetails> | null,
defaultValue = '',
Expand Down Expand Up @@ -108,25 +116,20 @@ function getDisplayNameOrDefault(
return shouldFallbackToHidden ? hiddenTranslation : '';
}

function temporaryGetDisplayNameOrDefault({
/**
* Shared implementation behind {@link temporaryGetDisplayNameOrDefault} and {@link getDisplayNameOrDefaultEnLocale}.
* The two callers differ only in where the `Hidden` fallback and the `(you)` postfix come from, so they resolve
* those strings and pass them in.
*/
function buildDisplayNameOrDefault({
passedPersonalDetails,
defaultValue = '',
shouldFallbackToHidden = true,
shouldAddCurrentUserPostfix = false,
youAfterTranslation,
translate,
formatPhoneNumber,
}: {
passedPersonalDetails?: Partial<PersonalDetails> | null;
defaultValue?: string;
shouldFallbackToHidden?: boolean;
shouldAddCurrentUserPostfix?: boolean;
youAfterTranslation?: string;
translate: LocalizedTranslate;
formatPhoneNumber: LocaleContextProps['formatPhoneNumber'];
}): string {
const temporaryHiddenTranslation = translate('common.hidden');
const temporaryYouTranslation = translate('common.you').toLowerCase();
hiddenText,
youText,
}: DisplayNameOrDefaultParams & {hiddenText: string; youText: string}): string {
let displayName = passedPersonalDetails?.displayName ?? '';

const login = passedPersonalDetails?.login ?? '';
Expand All @@ -144,7 +147,7 @@ function temporaryGetDisplayNameOrDefault({
}

if (shouldAddCurrentUserPostfix && !!displayName) {
displayName = `${displayName} (${youAfterTranslation ?? temporaryYouTranslation})`;
displayName = `${displayName} (${youText})`;
}

if (passedPersonalDetails?.accountID === CONST.ACCOUNT_ID.CONCIERGE) {
Expand All @@ -165,7 +168,38 @@ function temporaryGetDisplayNameOrDefault({
}
return login;
}
return shouldFallbackToHidden ? temporaryHiddenTranslation : '';
return shouldFallbackToHidden ? hiddenText : '';
}

function temporaryGetDisplayNameOrDefault({
youAfterTranslation,
translate,
...params
}: DisplayNameOrDefaultParams & {
youAfterTranslation?: string;
translate: LocalizedTranslate;
}): string {
return buildDisplayNameOrDefault({
...params,
hiddenText: translate('common.hidden'),
youText: youAfterTranslation ?? translate('common.you').toLowerCase(),
});
}

/**
* Same as {@link temporaryGetDisplayNameOrDefault} but always resolves the `Hidden` fallback and the `(you)`
* postfix in English. Used for building optimistic report action messages, which are stored on the action in
* English regardless of the viewer's locale, so they must not go through `translate`. This mirrors
* `convertToDisplayStringEnLocale` in `CurrencyUtils`.
*
* The strings are hardcoded rather than read via `translate(CONST.LOCALES.EN, …)` because locale bundles are
* loaded lazily (see `IntlStore`), so the `en` bundle is not guaranteed to be in memory when optimistic data
* is built.
*
* IMPORTANT: keep these in sync with `common.hidden` and `common.you` in `en.ts`.
*/
function getDisplayNameOrDefaultEnLocale(params: DisplayNameOrDefaultParams): string {
return buildDisplayNameOrDefault({...params, hiddenText: CONST.EN_LOCALE_TEXT.HIDDEN, youText: CONST.EN_LOCALE_TEXT.YOU});
}

function getPersonalDetailsByID(accountID: number | undefined, personalDetailsList: OnyxEntry<PersonalDetailsList>): PersonalDetails | undefined {
Expand Down Expand Up @@ -605,4 +639,5 @@ export {
areAddressAndPersonalDetailsMissing,
areTravelPersonalDetailsMissing,
temporaryGetDisplayNameOrDefault,
getDisplayNameOrDefaultEnLocale,
};
70 changes: 53 additions & 17 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,14 @@ import Parser from './Parser';
import {getParsedMessageWithShortMentions} from './ParsingUtils';
import {getBankAccountLastFourDigits} from './PaymentUtils';
import Permissions from './Permissions';
import {getAccountIDsByLogins, getDisplayNameOrDefault, getLoginByAccountID, getPersonalDetailByEmail, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils';
import {
getAccountIDsByLogins,
getDisplayNameOrDefault,
getDisplayNameOrDefaultEnLocale,
getLoginByAccountID,
getPersonalDetailByEmail,
temporaryGetDisplayNameOrDefault,
} from './PersonalDetailsUtils';
import {
canSendInvoiceFromWorkspace,
getActivePolicies,
Expand Down Expand Up @@ -3590,6 +3597,7 @@ function getDisplayNameForParticipant({
shouldRemoveDomain = false,
formatPhoneNumber,
translate,
shouldUseEnLocale = false,
}: {
accountID?: number;
shouldUseShortForm?: boolean;
Expand All @@ -3599,6 +3607,12 @@ function getDisplayNameForParticipant({
shouldRemoveDomain?: boolean;
formatPhoneNumber: LocaleContextProps['formatPhoneNumber'];
translate?: LocalizedTranslate;
/**
* Resolve the `Hidden` fallback and the `(you)` postfix in English instead of the viewer's locale. Set this
* for names that end up in text stored on a report action, which is English only. See
* {@link getReportPreviewReportActionMessage}.
*/
shouldUseEnLocale?: boolean;
}): string {
if (!accountID) {
return '';
Expand Down Expand Up @@ -3631,23 +3645,31 @@ function getDisplayNameForParticipant({
// For selfDM, we display the user's displayName followed by '(you)' as a postfix
const shouldAddPostfix = shouldAddCurrentUserPostfix && accountID === deprecatedCurrentUserAccountID;

let longName = translate
? temporaryGetDisplayNameOrDefault({
passedPersonalDetails: personalDetails,
defaultValue: formattedLogin,
shouldFallbackToHidden,
shouldAddCurrentUserPostfix: shouldAddPostfix,
translate,
formatPhoneNumber,
})
: getDisplayNameOrDefault(personalDetails, formattedLogin, shouldFallbackToHidden, shouldAddPostfix);
const displayNameParams = {
passedPersonalDetails: personalDetails,
defaultValue: formattedLogin,
shouldFallbackToHidden,
shouldAddCurrentUserPostfix: shouldAddPostfix,
formatPhoneNumber,
};

let longName: string;
if (shouldUseEnLocale) {
longName = getDisplayNameOrDefaultEnLocale(displayNameParams);
} else if (translate) {
longName = temporaryGetDisplayNameOrDefault({...displayNameParams, translate});
} else {
longName = getDisplayNameOrDefault(personalDetails, formattedLogin, shouldFallbackToHidden, shouldAddPostfix);
}

if (shouldRemoveDomain && longName === formattedLogin) {
longName = longName.split('@').at(0) ?? '';
}

const hiddenText = shouldUseEnLocale ? CONST.EN_LOCALE_TEXT.HIDDEN : (translate?.('common.hidden') ?? hiddenTranslation);

// If the user's personal details (first name) should be hidden, make sure we return "hidden" instead of the short name
if (shouldFallbackToHidden && longName === (translate ? translate('common.hidden') : hiddenTranslation)) {
if (shouldFallbackToHidden && longName === hiddenText) {
return longName;
}

Expand Down Expand Up @@ -6027,6 +6049,9 @@ function getReportPreviewMessage(
* avoids depending on the async-loaded locale bundles. For a localized preview shown in the UI, call
* {@link getReportPreviewMessage} with the caller's `translate`.
*
* Participant names go through `getDisplayNameForParticipant` with `shouldUseEnLocale`, so the `Hidden` fallback
* stays English here too rather than following the viewer's locale.
*
* IMPORTANT: keep the English strings here in sync with the `iou.*` entries in `en.ts` and with the branching
* in {@link getReportPreviewMessage}.
*/
Expand Down Expand Up @@ -6110,7 +6135,12 @@ function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBase
const policyName = getPolicyName({report: parentReport ?? report, policy});
const payerName = isExpenseReport(report)
? policyName
: getDisplayNameForParticipant({accountID: report.managerID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils});
: getDisplayNameForParticipant({
accountID: report.managerID,
shouldUseShortForm: !isPreviewMessageForParentChatReport,
formatPhoneNumber: formatPhoneNumberPhoneUtils,
shouldUseEnLocale: true,
});

const formattedAmount = convertToDisplayStringEnLocale(totalAmount, report.currency, getCurrencyDecimals);

Expand Down Expand Up @@ -6167,7 +6197,7 @@ function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBase
let actualPayerName =
report.managerID === deprecatedCurrentUserAccountID && !isForListPreview
? ''
: getDisplayNameForParticipant({accountID: payerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils});
: getDisplayNameForParticipant({accountID: payerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, shouldUseEnLocale: true});

actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName;
const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName;
Expand Down Expand Up @@ -6200,7 +6230,8 @@ function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBase
}

if (report.isWaitingOnBankAccount) {
const submitterDisplayName = getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? '';
const submitterDisplayName =
getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, shouldUseEnLocale: true}) ?? '';
return `started payment, but is waiting for ${submitterDisplayName} to add a personal bank account.`;
}

Expand Down Expand Up @@ -6229,13 +6260,18 @@ function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBase
// We only want to show the actor name in the preview if it's not the current user who took the action
const requestorName =
lastActorID && lastActorID !== deprecatedCurrentUserAccountID
? getDisplayNameForParticipant({accountID: lastActorID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils})
? getDisplayNameForParticipant({
accountID: lastActorID,
shouldUseShortForm: !isPreviewMessageForParentChatReport,
formatPhoneNumber: formatPhoneNumberPhoneUtils,
shouldUseEnLocale: true,
})
: '';
return `${requestorName ? `${requestorName}: ` : ''}${amountToDisplay}${comment ? ` for ${comment}` : ''}`;
}

if (containsNonReimbursable) {
const ownerName = getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? '';
const ownerName = getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, shouldUseEnLocale: true}) ?? '';
return `${ownerName} spent ${formattedAmount}`;
}
return `${payerName ?? ''} owes ${formattedAmount}${comment ? ` for ${comment}` : ''}`;
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/ReportUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17482,6 +17482,65 @@ describe('ReportUtils', () => {
expect(result).toBe(getReportPreviewMessage(englishTranslate, convertToDisplayString, {reportOrID: report}));
expect(result).toContain('owes');
});

it('resolves a nameless participant to the English "Hidden" regardless of the viewer locale', async () => {
const hiddenManagerAccountID = 246810;
const iouReport: Report = {
...LHNTestUtils.getFakeReport(),
reportID: 'preview-en-hidden-report',
type: CONST.REPORT.TYPE.IOU,
currency: CONST.CURRENCY.USD,
managerID: hiddenManagerAccountID,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
};
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport);
// A participant with no name falls back to the "hidden" copy, which used to follow the viewer's locale
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
[hiddenManagerAccountID]: {accountID: hiddenManagerAccountID, login: '', displayName: ''},
});

await IntlStore.load(CONST.LOCALES.EN).then(waitForBatchedUpdates);
const englishResult = getReportPreviewReportActionMessage({reportOrID: iouReport}, getCurrencyDecimalsLocal);

await IntlStore.load(CONST.LOCALES.ES).then(waitForBatchedUpdates);
const spanishResult = getReportPreviewReportActionMessage({reportOrID: iouReport}, getCurrencyDecimalsLocal);

expect(spanishResult).toBe(englishResult);
expect(spanishResult).toContain('Hidden');
expect(spanishResult).not.toContain(translate(CONST.LOCALES.ES, 'common.hidden'));
expect(spanishResult).toContain('owes');
});

it('keeps the non-reimbursable "spent" owner name in English regardless of the viewer locale', async () => {
const hiddenOwnerAccountID = 246811;
const iouReport: Report = {
...LHNTestUtils.getFakeReport(),
reportID: 'preview-en-spent-report',
type: CONST.REPORT.TYPE.IOU,
currency: CONST.CURRENCY.USD,
ownerAccountID: hiddenOwnerAccountID,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
};
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport);
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
[hiddenOwnerAccountID]: {accountID: hiddenOwnerAccountID, login: '', displayName: ''},
});
// A non-reimbursable transaction routes the preview into the "spent" branch
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}preview-en-spent-transaction`, {
transactionID: 'preview-en-spent-transaction',
reportID: iouReport.reportID,
reimbursable: false,
amount: 100,
currency: CONST.CURRENCY.USD,
});
await IntlStore.load(CONST.LOCALES.ES).then(waitForBatchedUpdates);

const result = getReportPreviewReportActionMessage({reportOrID: iouReport}, getCurrencyDecimalsLocal);

expect(result).toContain('Hidden spent');
});
});
});

Expand Down
Loading