Skip to content

Commit a8234f8

Browse files
committed
refactor: thread localeCompare through utils
1 parent 1f85c8b commit a8234f8

15 files changed

Lines changed: 425 additions & 90 deletions

File tree

src/libs/Localize/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,5 +199,23 @@ function getDevicePreferredLocale(): Locale {
199199
return RNLocalize.findBestLanguageTag(Object.values(CONST.LOCALES))?.languageTag ?? CONST.LOCALES.DEFAULT;
200200
}
201201

202+
const COLLATOR_OPTIONS: Intl.CollatorOptions = {usage: 'sort', sensitivity: 'variant', numeric: true, caseFirst: 'upper'};
203+
const localeCompareCache = new Map<string, (a: string, b: string) => number>();
204+
205+
/**
206+
* Locale-aware string comparator for non-React callers (mirrors `translateLocal`). React code should use the
207+
* `localeCompare` returned by `useLocalize` instead.
208+
*/
209+
function localeCompareLocal(a: string, b: string): number {
210+
const locale = IntlStore.getCurrentLocale() ?? '';
211+
let compare = localeCompareCache.get(locale);
212+
if (!compare) {
213+
const collator = new Intl.Collator(locale || undefined, COLLATOR_OPTIONS);
214+
compare = (left, right) => collator.compare(left, right);
215+
localeCompareCache.set(locale, compare);
216+
}
217+
return compare(a, b);
218+
}
219+
202220
// eslint-disable-next-line @typescript-eslint/no-deprecated
203-
export {translate, translateLocal, formatList, formatMessageElementList, getDevicePreferredLocale};
221+
export {translate, translateLocal, localeCompareLocal, formatList, formatMessageElementList, getDevicePreferredLocale};

src/libs/MergeTransactionUtils.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,14 @@ function selectTargetAndSourceTransactionsForMerge(
498498
* @param translate - The translation function
499499
* @returns The formatted display string for the field value
500500
*/
501-
function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy: Policy | undefined, translate: LocaleContextProps['translate'], reports?: Array<OnyxEntry<Report>>): string {
501+
function getDisplayValue(
502+
field: MergeFieldKey,
503+
transaction: Transaction,
504+
policy: Policy | undefined,
505+
translate: LocaleContextProps['translate'],
506+
localeCompare: LocaleContextProps['localeCompare'],
507+
reports?: Array<OnyxEntry<Report>>,
508+
): string {
502509
const fieldValue = getMergeFieldValue(getTransactionDetails(transaction), transaction, field);
503510

504511
if (isEmptyMergeValue(fieldValue) || fieldValue === undefined) {
@@ -528,7 +535,7 @@ function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy:
528535
return transaction?.reportName ?? getReportName(getReportOrDraftReport(SafeString(fieldValue), reports));
529536
}
530537
if (field === 'attendees') {
531-
return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue) : '';
538+
return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue, localeCompare) : '';
532539
}
533540

534541
if (field === 'taxValue') {
@@ -554,6 +561,7 @@ function buildMergeFieldsData(
554561
targetTransactionPolicy: Policy | undefined,
555562
sourceTransactionPolicy: Policy | undefined,
556563
translate: LocaleContextProps['translate'],
564+
localeCompare: LocaleContextProps['localeCompare'],
557565
reports: Array<OnyxEntry<Report>> = [],
558566
): MergeFieldData[] {
559567
if (!targetTransaction || !sourceTransaction) {
@@ -568,12 +576,12 @@ function buildMergeFieldsData(
568576
const options: MergeFieldOption[] = [
569577
{
570578
transaction: targetTransaction,
571-
displayValue: getDisplayValue(field, targetTransaction, targetTransactionPolicy, translate, reports),
579+
displayValue: getDisplayValue(field, targetTransaction, targetTransactionPolicy, translate, localeCompare, reports),
572580
isSelected: selectedTransactionId === targetTransaction.transactionID,
573581
},
574582
{
575583
transaction: sourceTransaction,
576-
displayValue: getDisplayValue(field, sourceTransaction, sourceTransactionPolicy, translate, reports),
584+
displayValue: getDisplayValue(field, sourceTransaction, sourceTransactionPolicy, translate, localeCompare, reports),
577585
isSelected: selectedTransactionId === sourceTransaction.transactionID,
578586
},
579587
];

src/libs/ModifiedExpenseMessage.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import isEmpty from 'lodash/isEmpty';
22
import type {OnyxEntry} from 'react-native-onyx';
33
import type {Entries, ValueOf} from 'type-fest';
4-
import type {LocalizedTranslate} from '@components/LocaleContextProvider';
4+
import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider';
55
import CONST from '@src/CONST';
66
import ROUTES from '@src/ROUTES';
77
import type {Policy, PolicyTagLists, Report, ReportAction, ReportAttributesDerivedValue} from '@src/types/onyx';
@@ -246,6 +246,7 @@ function getRulesModifiedMessage(
246246
*/
247247
function getForReportAction({
248248
translate,
249+
localeCompare,
249250
reportAction,
250251
policy,
251252
movedFromReport,
@@ -255,6 +256,7 @@ function getForReportAction({
255256
reportAttributes,
256257
}: {
257258
translate: LocalizedTranslate;
259+
localeCompare: LocaleContextProps['localeCompare'];
258260
reportAction: OnyxEntry<ReportAction>;
259261
policy: OnyxEntry<Policy>;
260262
movedFromReport?: OnyxEntry<Report>;
@@ -452,7 +454,7 @@ function getForReportAction({
452454

453455
const hasModifiedAttendees = isReportActionOriginalMessageAnObject && 'oldAttendees' in reportActionOriginalMessage && 'newAttendees' in reportActionOriginalMessage;
454456
if (hasModifiedAttendees) {
455-
const [oldAttendees, attendees] = getFormattedAttendees(reportActionOriginalMessage.newAttendees, reportActionOriginalMessage.oldAttendees);
457+
const [oldAttendees, attendees] = getFormattedAttendees(localeCompare, reportActionOriginalMessage.newAttendees, reportActionOriginalMessage.oldAttendees);
456458
buildMessageFragmentForValue(translate, oldAttendees, attendees, translate('iou.attendees'), false, setFragments, removalFragments, changeFragments);
457459
}
458460

src/libs/Notification/LocalNotification/BrowserNotifications.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {ImageSourcePropType} from 'react-native';
44
import EXPENSIFY_ICON_URL from '@assets/images/expensify-logo-round-clearspace.png';
55
import * as AppUpdate from '@libs/actions/AppUpdate';
66
// eslint-disable-next-line @typescript-eslint/no-deprecated -- translateLocal is deprecated; BrowserNotifications is non-React code that cannot use the translate hook
7-
import {translateLocal} from '@libs/Localize';
7+
import {localeCompareLocal, translateLocal} from '@libs/Localize';
88
import {getForReportAction} from '@libs/ModifiedExpenseMessage';
99
import {getTextFromHtml} from '@libs/ReportActionsUtils';
1010
import {getReportName} from '@libs/ReportNameUtils';
@@ -153,6 +153,7 @@ export default {
153153
const bodyWithHTML = getForReportAction({
154154
// eslint-disable-next-line @typescript-eslint/no-deprecated -- translateLocal is deprecated; BrowserNotifications is non-React code that cannot use the translate hook
155155
translate: translateLocal,
156+
localeCompare: localeCompareLocal,
156157
reportAction,
157158
policy,
158159
movedFromReport,

src/libs/OptionsListUtils/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
1515
import {isReportMessageAttachment} from '@libs/isReportMessageAttachment';
1616
import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from '@libs/LocalePhoneNumber';
1717
// eslint-disable-next-line @typescript-eslint/no-deprecated
18-
import {translateLocal} from '@libs/Localize';
18+
import {localeCompareLocal, translateLocal} from '@libs/Localize';
1919
import {appendCountryCode, getPhoneNumberWithoutSpecialChars} from '@libs/LoginUtils';
2020
import {MaxHeap} from '@libs/MaxHeap';
2121
import {MinHeap} from '@libs/MinHeap';
@@ -754,6 +754,7 @@ function getLastMessageTextForReport({
754754
} else if (isModifiedExpenseAction(lastReportAction)) {
755755
const properSchemaForModifiedExpenseMessageWithHTML = getForReportAction({
756756
translate,
757+
localeCompare: localeCompareLocal,
757758
reportAction: lastReportAction,
758759
policy,
759760
movedFromReport,

src/libs/ReportNameUtils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
2525
import {convertToDisplayString} from './CurrencyUtils';
2626
import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber';
2727
// eslint-disable-next-line @typescript-eslint/no-deprecated
28-
import {translateLocal} from './Localize';
28+
import {localeCompareLocal, translateLocal} from './Localize';
2929
// eslint-disable-next-line import/no-cycle
3030
import {getForReportAction, getMovedReportID} from './ModifiedExpenseMessage';
3131
import Parser from './Parser';
@@ -835,6 +835,7 @@ function computeChatThreadReportName(
835835
const movedToReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(parentReportAction, CONST.REPORT.MOVE_TYPE.TO)}`];
836836
const modifiedMessageWithHTML = getForReportAction({
837837
translate,
838+
localeCompare: localeCompareLocal,
838839
reportAction: parentReportAction,
839840
movedFromReport,
840841
movedToReport,

src/libs/ReportUtils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ import getBase62ReportID from './getBase62ReportID';
111111
import {isReportMessageAttachment} from './isReportMessageAttachment';
112112
import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber';
113113
// eslint-disable-next-line @typescript-eslint/no-deprecated
114-
import {translateLocal} from './Localize';
114+
import {localeCompareLocal, translateLocal} from './Localize';
115115
import Log from './Log';
116116
import {isEmailPublicDomain} from './LoginUtils';
117117
// eslint-disable-next-line import/no-cycle
@@ -5862,6 +5862,7 @@ function getReportName(reportNameInformation: GetReportNameParams): string {
58625862
const modifiedMessageWithHTML = getForReportAction({
58635863
// eslint-disable-next-line @typescript-eslint/no-deprecated -- translateLocal is deprecated; getReportName is non-React code that cannot use the translate hook
58645864
translate: translateLocal,
5865+
localeCompare: localeCompareLocal,
58655866
reportAction: parentReportAction,
58665867
policy,
58675868
movedFromReport,

src/libs/TransactionUtils/index.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import lodashSet from 'lodash/set';
55
import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx';
66
import Onyx from 'react-native-onyx';
77
import type {ValueOf} from 'type-fest';
8+
import type {LocaleContextProps} from '@components/LocaleContextProvider';
89
import type {Coordinate} from '@components/MapView/MapViewTypes';
910
import utils from '@components/MapView/utils';
1011
import type {UnreportedExpenseListItemType} from '@components/Search/SearchList/ListItem/types';
@@ -1201,39 +1202,24 @@ function getAttendees(transaction: OnyxInputOrEntry<Transaction>, currentUserPer
12011202
return attendees;
12021203
}
12031204

1204-
// Mirrors LocaleContextProvider (same options + same IntlStore source) so non-React callers sort by the user's preferred locale.
1205-
const ATTENDEES_DISPLAY_COLLATOR_OPTIONS: Intl.CollatorOptions = {usage: 'sort', sensitivity: 'variant', numeric: true, caseFirst: 'upper'};
1206-
const attendeesDisplayCollatorCache = new Map<string, Intl.Collator>();
1207-
1208-
function getAttendeesDisplayCollator(): Intl.Collator {
1209-
const locale = IntlStore.getCurrentLocale() ?? '';
1210-
let collator = attendeesDisplayCollatorCache.get(locale);
1211-
if (!collator) {
1212-
collator = new Intl.Collator(locale || undefined, ATTENDEES_DISPLAY_COLLATOR_OPTIONS);
1213-
attendeesDisplayCollatorCache.set(locale, collator);
1214-
}
1215-
return collator;
1216-
}
1217-
12181205
/**
12191206
* Returns attendees joined as an alphabetically sorted display string. Sort is part of the contract — every consumer relies on it.
12201207
*/
1221-
function getAttendeesListDisplayString(attendees: Attendee[]): string {
1222-
const collator = getAttendeesDisplayCollator();
1208+
function getAttendeesListDisplayString(attendees: Attendee[], localeCompare: LocaleContextProps['localeCompare']): string {
12231209
// Lowercase to match sortAlphabetically (the pill sort) so joined string and pill order never disagree on case.
12241210
return [...attendees]
1225-
.sort((a, b) => collator.compare((a.displayName ?? a.login ?? '').toLowerCase(), (b.displayName ?? b.login ?? '').toLowerCase()))
1211+
.sort((a, b) => localeCompare((a.displayName ?? a.login ?? '').toLowerCase(), (b.displayName ?? b.login ?? '').toLowerCase()))
12261212
.map((item) => item.displayName ?? item.login)
12271213
.join(', ');
12281214
}
12291215

12301216
/**
12311217
* Return the list of attendees as a string and modified list of attendees as a string if present.
12321218
*/
1233-
function getFormattedAttendees(modifiedAttendees?: Attendee[], attendees?: Attendee[]): [string, string] {
1219+
function getFormattedAttendees(localeCompare: LocaleContextProps['localeCompare'], modifiedAttendees?: Attendee[], attendees?: Attendee[]): [string, string] {
12341220
const oldAttendees = modifiedAttendees ?? [];
12351221
const newAttendees = attendees ?? [];
1236-
return [getAttendeesListDisplayString(oldAttendees), getAttendeesListDisplayString(newAttendees)];
1222+
return [getAttendeesListDisplayString(oldAttendees, localeCompare), getAttendeesListDisplayString(newAttendees, localeCompare)];
12371223
}
12381224

12391225
/**

src/pages/TransactionMerge/DetailsReviewPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) {
150150
// Build merge fields array with all necessary information
151151
const mergeFields = useMemo(
152152
() =>
153-
buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionPolicy, sourceTransactionPolicy, translate, [
153+
buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionPolicy, sourceTransactionPolicy, translate, localeCompare, [
154154
targetTransactionReport,
155155
sourceTransactionReport,
156156
]),
@@ -164,6 +164,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) {
164164
targetTransactionPolicy,
165165
sourceTransactionPolicy,
166166
translate,
167+
localeCompare,
167168
],
168169
);
169170

src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {getEnvironmentURL} from '@libs/Environment/Environment';
1818
import fileDownload from '@libs/fileDownload';
1919
import getAttachmentDetails from '@libs/fileDownload/getAttachmentDetails';
2020
import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from '@libs/LocalePhoneNumber';
21+
import {localeCompareLocal} from '@libs/Localize';
2122
import {getForReportAction} from '@libs/ModifiedExpenseMessage';
2223
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
2324
import Navigation from '@libs/Navigation/Navigation';
@@ -852,6 +853,7 @@ const ContextMenuActions: ContextMenuAction[] = [
852853
} else if (isModifiedExpenseAction(reportAction)) {
853854
const modifyExpenseMessageWithHTML = getForReportAction({
854855
translate,
856+
localeCompare: localeCompareLocal,
855857
reportAction,
856858
policy,
857859
movedFromReport,

0 commit comments

Comments
 (0)