diff --git a/.eslintrc.js b/.eslintrc.js
index 85a4e86797b6..822a7f66b474 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -14,11 +14,21 @@ const restrictedImportPaths = [
importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight'],
message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from 'src/components/Pressable' instead.",
},
+ {
+ name: 'awesome-phonenumber',
+ importNames: ['parsePhoneNumber'],
+ message: "Please use '@libs/PhoneNumber' instead.",
+ },
{
name: 'react-native-safe-area-context',
importNames: ['useSafeAreaInsets', 'SafeAreaConsumer', 'SafeAreaInsetsContext'],
message: "Please use 'useSafeAreaInsets' from 'src/hooks/useSafeAreaInset' and/or 'SafeAreaConsumer' from 'src/components/SafeAreaConsumer' instead.",
},
+ {
+ name: 'react',
+ importNames: ['CSSProperties'],
+ message: "Please use 'ViewStyle', 'TextStyle', 'ImageStyle' from 'react-native' instead.",
+ },
];
const restrictedImportPatterns = [
diff --git a/android/app/build.gradle b/android/app/build.gradle
index d059f2a7e9f2..597d85de1c09 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001041308
- versionName "1.4.13-8"
+ versionCode 1001041401
+ versionName "1.4.14-1"
}
flavorDimensions "default"
diff --git a/contributingGuides/TS_STYLE.md b/contributingGuides/TS_STYLE.md
index bc62020ffd54..a583941bf71d 100644
--- a/contributingGuides/TS_STYLE.md
+++ b/contributingGuides/TS_STYLE.md
@@ -24,6 +24,8 @@
- [1.17 `.tsx`](#tsx)
- [1.18 No inline prop types](#no-inline-prop-types)
- [1.19 Satisfies operator](#satisfies-operator)
+ - [1.20 Hooks instead of HOCs](#hooks-instead-of-hocs)
+ - [1.21 `compose` usage](#compose-usage)
- [Exception to Rules](#exception-to-rules)
- [Communication Items](#communication-items)
- [Migration Guidelines](#migration-guidelines)
@@ -124,7 +126,7 @@ type Foo = {
-- [1.2](#d-ts-extension) **`d.ts` Extension**: Do not use `d.ts` file extension even when a file contains only type declarations. Only exceptions are `src/types/global.d.ts` and `src/types/modules/*.d.ts` files in which third party packages can be modified using module augmentation. Refer to the [Communication Items](#communication-items) section to learn more about module augmentation.
+- [1.2](#d-ts-extension) **`d.ts` Extension**: Do not use `d.ts` file extension even when a file contains only type declarations. Only exceptions are `src/types/global.d.ts` and `src/types/modules/*.d.ts` files in which third party packages and JavaScript's built-in modules (e.g. `window` object) can be modified using module augmentation. Refer to the [Communication Items](#communication-items) section to learn more about module augmentation.
> Why? Type errors in `d.ts` files are not checked by TypeScript [^1].
@@ -509,6 +511,102 @@ type Foo = {
} satisfies Record;
```
+
+
+- [1.20](#hooks-instead-of-hocs) **Hooks instead of HOCs**: Replace HOCs usage with Hooks whenever possible.
+
+ > Why? Hooks are easier to use (can be used inside the function component), and don't need nesting or `compose` when exporting the component. It also allows us to remove `compose` completely in some components since it has been bringing up some issues with TypeScript. Read the [`compose` usage](#compose-usage) section for further information about the TypeScript issues with `compose`.
+
+ > Note: Because Onyx doesn't provide a hook yet, in a component that accesses Onyx data with `withOnyx` HOC, please make sure that you don't use other HOCs (if applicable) to avoid HOC nesting.
+
+ ```tsx
+ // BAD
+ type ComponentOnyxProps = {
+ session: OnyxEntry;
+ };
+
+ type ComponentProps = WindowDimensionsProps &
+ WithLocalizeProps &
+ ComponentOnyxProps & {
+ someProp: string;
+ };
+
+ function Component({windowWidth, windowHeight, translate, session, someProp}: ComponentProps) {
+ // component's code
+ }
+
+ export default compose(
+ withWindowDimensions,
+ withLocalize,
+ withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ }),
+ )(Component);
+
+ // GOOD
+ type ComponentOnyxProps = {
+ session: OnyxEntry;
+ };
+
+ type ComponentProps = ComponentOnyxProps & {
+ someProp: string;
+ };
+
+ function Component({session, someProp}: ComponentProps) {
+ const {windowWidth, windowHeight} = useWindowDimensions();
+ const {translate} = useLocalize();
+ // component's code
+ }
+
+ // There is no hook alternative for withOnyx yet.
+ export default withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ })(Component);
+ ```
+
+
+
+- [1.21](#compose-usage) **`compose` usage**: Avoid the usage of `compose` function to compose HOCs in TypeScript files. Use nesting instead.
+
+ > Why? `compose` function doesn't work well with TypeScript when dealing with several HOCs being used in a component, many times resulting in wrong types and errors. Instead, nesting can be used to allow a seamless use of multiple HOCs and result in a correct return type of the compoment. Also, you can use [hooks instead of HOCs](#hooks-instead-of-hocs) whenever possible to minimize or even remove the need of HOCs in the component.
+
+ ```ts
+ // BAD
+ export default compose(
+ withCurrentUserPersonalDetails,
+ withReportOrNotFound(),
+ withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ }),
+ )(Component);
+
+ // GOOD
+ export default withCurrentUserPersonalDetails(
+ withReportOrNotFound()(
+ withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ })(Component),
+ ),
+ );
+
+ // GOOD - alternative to HOC nesting
+ const ComponentWithOnyx = withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ })(Component);
+ const ComponentWithReportOrNotFound = withReportOrNotFound()(ComponentWithOnyx);
+ export default withCurrentUserPersonalDetails(ComponentWithReportOrNotFound);
+ ```
+
## Exception to Rules
Most of the rules are enforced in ESLint or checked by TypeScript. If you think your particular situation warrants an exception, post the context in the `#expensify-open-source` Slack channel with your message prefixed with `TS EXCEPTION:`. The internal engineer assigned to the PR should be the one that approves each exception, however all discussion regarding granting exceptions should happen in the public channel instead of the GitHub PR page so that the TS migration team can access them easily.
@@ -521,7 +619,7 @@ This rule will apply until the migration is done. After the migration, discussio
> Comment in the `#expensify-open-source` Slack channel if any of the following situations are encountered. Each comment should be prefixed with `TS ATTENTION:`. Internal engineers will access each situation and prescribe solutions to each case. Internal engineers should refer to general solutions to each situation that follows each list item.
-- I think types definitions in a third party library is incomplete or incorrect
+- I think types definitions in a third party library or JavaScript's built-in module are incomplete or incorrect
When the library indeed contains incorrect or missing type definitions and it cannot be updated, use module augmentation to correct them. All module augmentation code should be contained in `/src/types/modules/*.d.ts`, each library as a separate file.
@@ -540,7 +638,7 @@ declare module "external-library-name" {
> This section contains instructions that are applicable during the migration.
-- 🚨 DO NOT write new code in TypeScript yet. The only time you write TypeScript code is when the file you're editing has already been migrated to TypeScript by the migration team. This guideline will be updated once it's time for new code to be written in TypeScript. If you're doing a major overhaul or refactoring of particular features or utilities of App and you believe it might be beneficial to migrate relevant code to TypeScript as part of the refactoring, please ask in the #expensify-open-source channel about it (and prefix your message with `TS ATTENTION:`).
+- 🚨 DO NOT write new code in TypeScript yet. The only time you write TypeScript code is when the file you're editing has already been migrated to TypeScript by the migration team, or when you need to add new files under `src/libs`, `src/hooks`, `src/styles`, and `src/languages` directories. This guideline will be updated once it's time for new code to be written in TypeScript. If you're doing a major overhaul or refactoring of particular features or utilities of App and you believe it might be beneficial to migrate relevant code to TypeScript as part of the refactoring, please ask in the #expensify-open-source channel about it (and prefix your message with `TS ATTENTION:`).
- If you're migrating a module that doesn't have a default implementation (i.e. `index.ts`, e.g. `getPlatform`), convert `index.website.js` to `index.ts`. Without `index.ts`, TypeScript cannot get type information where the module is imported.
@@ -579,6 +677,25 @@ object?.foo ?? 'bar';
const y: number = 123; // TS error: Unused '@ts-expect-error' directive.
```
+- The TS issue I'm working on is blocked by another TS issue because of type errors. What should I do?
+
+ In order to proceed with the migration faster, we are now allowing the use of `@ts-expect-error` annotation to temporally suppress those errors and help you unblock your issues. The only requirements is that you MUST add the annotation with a comment explaining that it must be removed when the blocking issue is migrated, e.g.:
+
+ ```tsx
+ return (
+
+ );
+ ```
+
+ **You will also need to reference the blocking issue in your PR.** You can find all the TS issues [here](https://github.com/orgs/Expensify/projects/46).
+
## Learning Resources
### Quickest way to learn TypeScript
diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml
index e320b690c226..d4e12d396ceb 100644
--- a/docs/_data/_routes.yml
+++ b/docs/_data/_routes.yml
@@ -31,7 +31,7 @@ platforms:
- href: billing-and-subscriptions
title: Billing & Subscriptions
- icon: /assets/images/money-wings.svg
+ icon: /assets/images/subscription-annual.svg
description: Here is where you can review Expensify's billing and subscription options, plan types, and payment methods.
- href: expense-and-report-features
@@ -71,7 +71,7 @@ platforms:
- href: send-payments
title: Send Payments
- icon: /assets/images/money-wings.svg
+ icon: /assets/images/send-money.svg
description: Uncover step-by-step guidance on sending direct reimbursements to employees, paying an invoice to a vendor, and utilizing third-party payment options.
- href: workspace-and-domain-settings
@@ -105,7 +105,7 @@ platforms:
- href: billing-and-plan-types
title: Billing & Plan Types
- icon: /assets/images/money-wings.svg
+ icon: /assets/images/subscription-annual.svg
description: Here is where you can review Expensify's billing and subscription options, plan types, and payment methods.
- href: expensify-card
diff --git a/docs/assets/images/money-wings.svg b/docs/assets/images/money-wings.svg
deleted file mode 100644
index 87ffdf28ec4b..000000000000
--- a/docs/assets/images/money-wings.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/assets/images/send-money.svg b/docs/assets/images/send-money.svg
new file mode 100644
index 000000000000..e858f0d5c327
--- /dev/null
+++ b/docs/assets/images/send-money.svg
@@ -0,0 +1,25 @@
+
diff --git a/docs/assets/images/subscription-annual.svg b/docs/assets/images/subscription-annual.svg
new file mode 100644
index 000000000000..a4b99a43b16e
--- /dev/null
+++ b/docs/assets/images/subscription-annual.svg
@@ -0,0 +1,23 @@
+
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 5aac6a284866..897a300143b7 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.4.13
+ 1.4.14
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.13.8
+ 1.4.14.1
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index ab2d9de9573d..01316af0c986 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.4.13
+ 1.4.14
CFBundleSignature
????
CFBundleVersion
- 1.4.13.8
+ 1.4.14.1
diff --git a/package-lock.json b/package-lock.json
index b1b7102bc9fb..2d58e7cc7f4d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.13-8",
+ "version": "1.4.14-1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.13-8",
+ "version": "1.4.14-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -50,7 +50,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
@@ -29894,8 +29894,8 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0",
- "integrity": "sha512-s9l/Zy3UjDBrq0WTkgEue1DXLRkkYtuqnANQlVmODHJ9HkJADjrVSv2D0U3ltqd9X7vLCLCmmwl5AUE6466gGg==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849",
+ "integrity": "sha512-H7UrLgWIr8mCoPc1oxbeYW2RwLzUWI6jdjbV6cRnrlp8cDW3IyZISF+BQSPFDj7bMhNAbczQPtEOE1gld21Cvg==",
"license": "MIT",
"dependencies": {
"classnames": "2.3.1",
@@ -29911,7 +29911,7 @@
"simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5",
"string.prototype.replaceall": "^1.0.6",
"ua-parser-js": "^1.0.35",
- "underscore": "1.13.1"
+ "underscore": "1.13.6"
}
},
"node_modules/expensify-common/node_modules/prop-types": {
@@ -29983,12 +29983,6 @@
"node": "*"
}
},
- "node_modules/expensify-common/node_modules/underscore": {
- "version": "1.13.1",
- "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
- "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==",
- "license": "MIT"
- },
"node_modules/express": {
"version": "4.18.1",
"license": "MIT",
@@ -74403,9 +74397,9 @@
}
},
"expensify-common": {
- "version": "git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0",
- "integrity": "sha512-s9l/Zy3UjDBrq0WTkgEue1DXLRkkYtuqnANQlVmODHJ9HkJADjrVSv2D0U3ltqd9X7vLCLCmmwl5AUE6466gGg==",
- "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0",
+ "version": "git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849",
+ "integrity": "sha512-H7UrLgWIr8mCoPc1oxbeYW2RwLzUWI6jdjbV6cRnrlp8cDW3IyZISF+BQSPFDj7bMhNAbczQPtEOE1gld21Cvg==",
+ "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849",
"requires": {
"classnames": "2.3.1",
"clipboard": "2.0.4",
@@ -74420,7 +74414,7 @@
"simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5",
"string.prototype.replaceall": "^1.0.6",
"ua-parser-js": "^1.0.35",
- "underscore": "1.13.1"
+ "underscore": "1.13.6"
},
"dependencies": {
"prop-types": {
@@ -74467,11 +74461,6 @@
"version": "1.0.35",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
"integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA=="
- },
- "underscore": {
- "version": "1.13.1",
- "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
- "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
}
}
},
diff --git a/package.json b/package.json
index c667423b9e69..d4726491e36e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.13-8",
+ "version": "1.4.14-1",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -98,7 +98,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#927c8409e4454e15a1b95ed0a312ff8fee38f0f0",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#398bf7c6a6d37f229a41d92bd7a4324c0fd32849",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
diff --git a/src/CONST.ts b/src/CONST.ts
index 219807587a25..b29456ba170b 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -77,6 +77,12 @@ const CONST = {
AVATAR_MAX_WIDTH_PX: 4096,
AVATAR_MAX_HEIGHT_PX: 4096,
+ BREADCRUMB_TYPE: {
+ ROOT: 'root',
+ STRONG: 'strong',
+ NORMAL: 'normal',
+ },
+
DEFAULT_AVATAR_COUNT: 24,
OLD_DEFAULT_AVATAR_COUNT: 8,
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 0cc7934ad007..b4282cd8b842 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -373,7 +373,7 @@ type OnyxValues = {
[ONYXKEYS.NETWORK]: OnyxTypes.Network;
[ONYXKEYS.CUSTOM_STATUS_DRAFT]: OnyxTypes.CustomStatusDraft;
[ONYXKEYS.INPUT_FOCUSED]: boolean;
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: Record;
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: OnyxTypes.PersonalDetailsList;
[ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails;
[ONYXKEYS.TASK]: OnyxTypes.Task;
[ONYXKEYS.CURRENCY_LIST]: Record;
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index 3c764b36f3eb..d9e4ef2c0f6e 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -276,6 +276,11 @@ function AddressSearch({
values.state = stateFallback;
}
+ // Set the state to be the same as the city in case the state is empty.
+ if (_.isEmpty(values.state)) {
+ values.state = values.city;
+ }
+
// Some edge-case addresses may lack both street_number and route in the API response, resulting in an empty "values.street"
// We are setting up a fallback to ensure "values.street" is populated with a relevant value
if (!values.street && details.adr_address) {
diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx
index 7dadd86debfe..8604d20130c7 100644
--- a/src/components/ArchivedReportFooter.tsx
+++ b/src/components/ArchivedReportFooter.tsx
@@ -30,14 +30,14 @@ function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}}
const originalMessage = reportClosedAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED ? reportClosedAction.originalMessage : null;
const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT;
- let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [report.ownerAccountID, 'displayName']);
+ let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[report?.ownerAccountID ?? 0]?.displayName);
let oldDisplayName: string | undefined;
if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) {
const newAccountID = originalMessage?.newAccountID;
const oldAccountID = originalMessage?.oldAccountID;
- displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [newAccountID, 'displayName']);
- oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [oldAccountID, 'displayName']);
+ displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[newAccountID ?? 0]?.displayName);
+ oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[oldAccountID ?? 0]?.displayName);
}
const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT;
diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx
index 5ea21502f2ca..b9bae33d7e23 100644
--- a/src/components/AvatarWithDisplayName.tsx
+++ b/src/components/AvatarWithDisplayName.tsx
@@ -65,7 +65,6 @@ function AvatarWithDisplayName({
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false);
const shouldShowSubscriptAvatar = ReportUtils.shouldReportShowSubscript(report);
const isExpenseRequest = ReportUtils.isExpenseRequest(report);
- const defaultSubscriptSize = isExpenseRequest ? CONST.AVATAR_SIZE.SMALL_NORMAL : size;
const avatarBorderColor = isAnonymous ? theme.highlightBG : theme.componentBG;
const actorAccountID = useRef(null);
@@ -118,7 +117,7 @@ function AvatarWithDisplayName({
backgroundColor={avatarBorderColor}
mainAvatar={icons[0]}
secondaryAvatar={icons[1]}
- size={defaultSubscriptSize}
+ size={size}
/>
) : (
;
+};
+
+function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const [primaryBreadcrumb, secondaryBreadcrumb] = breadcrumbs;
+
+ return (
+
+ {primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT ? (
+
+
+ }
+ shouldShowEnvironmentBadge
+ />
+
+ ) : (
+
+ {primaryBreadcrumb.text}
+
+ )}
+
+ {!!secondaryBreadcrumb && (
+ <>
+ /
+
+ {secondaryBreadcrumb.text}
+
+ >
+ )}
+
+ );
+}
+
+Breadcrumbs.displayName = 'Breadcrumbs';
+
+export type {BreadcrumbsProps};
+export default Breadcrumbs;
diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx
index 23bc068e8fe0..715603ea362e 100644
--- a/src/components/Checkbox.tsx
+++ b/src/components/Checkbox.tsx
@@ -9,7 +9,7 @@ import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
-type CheckboxProps = ChildrenProps & {
+type CheckboxProps = Partial & {
/** Whether checkbox is checked */
isChecked?: boolean;
@@ -91,7 +91,7 @@ function Checkbox(
ref={ref}
style={[StyleUtils.getCheckboxPressableStyle(containerBorderRadius + 2), style]} // to align outline on focus, border-radius of pressable should be 2px more than Checkbox
onKeyDown={handleSpaceKey}
- role={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
+ role={CONST.ROLE.CHECKBOX}
aria-checked={isChecked}
accessibilityLabel={accessibilityLabel}
pressDimmingValue={1}
diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js
deleted file mode 100644
index 24f61c305dda..000000000000
--- a/src/components/CheckboxWithLabel.js
+++ /dev/null
@@ -1,146 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {useState} from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import useThemeStyles from '@hooks/useThemeStyles';
-import variables from '@styles/variables';
-import Checkbox from './Checkbox';
-import FormHelpMessage from './FormHelpMessage';
-import PressableWithFeedback from './Pressable/PressableWithFeedback';
-import refPropTypes from './refPropTypes';
-import Text from './Text';
-
-/**
- * Returns an error if the required props are not provided
- * @param {Object} props
- * @returns {Error|null}
- */
-const requiredPropsCheck = (props) => {
- if (!props.label && !props.LabelComponent) {
- return new Error('One of "label" or "LabelComponent" must be provided');
- }
-
- if (props.label && typeof props.label !== 'string') {
- return new Error('Prop "label" must be a string');
- }
-
- if (props.LabelComponent && typeof props.LabelComponent !== 'function') {
- return new Error('Prop "LabelComponent" must be a function');
- }
-};
-
-const propTypes = {
- /** Whether the checkbox is checked */
- isChecked: PropTypes.bool,
-
- /** Called when the checkbox or label is pressed */
- onInputChange: PropTypes.func,
-
- /** Container styles */
- style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
-
- /** Text that appears next to check box */
- label: requiredPropsCheck,
-
- /** Component to display for label */
- LabelComponent: requiredPropsCheck,
-
- /** Error text to display */
- errorText: PropTypes.string,
-
- /** Value for checkbox. This prop is intended to be set by Form.js only */
- value: PropTypes.bool,
-
- /** The default value for the checkbox */
- defaultValue: PropTypes.bool,
-
- /** React ref being forwarded to the Checkbox input */
- forwardedRef: refPropTypes,
-
- /** The ID used to uniquely identify the input in a Form */
- /* eslint-disable-next-line react/no-unused-prop-types */
- inputID: PropTypes.string,
-
- /** Saves a draft of the input value when used in a form */
- /* eslint-disable-next-line react/no-unused-prop-types */
- shouldSaveDraft: PropTypes.bool,
-
- /** An accessibility label for the checkbox */
- accessibilityLabel: PropTypes.string,
-};
-
-const defaultProps = {
- inputID: undefined,
- style: [],
- label: undefined,
- LabelComponent: undefined,
- errorText: '',
- shouldSaveDraft: false,
- isChecked: false,
- value: undefined,
- defaultValue: false,
- forwardedRef: () => {},
- accessibilityLabel: undefined,
- onInputChange: () => {},
-};
-
-function CheckboxWithLabel(props) {
- const styles = useThemeStyles();
- // We need to pick the first value that is strictly a boolean
- // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065
- const [isChecked, setIsChecked] = useState(() => _.find([props.value, props.defaultValue, props.isChecked], (value) => _.isBoolean(value)));
-
- const toggleCheckbox = () => {
- const newState = !isChecked;
- props.onInputChange(newState);
- setIsChecked(newState);
- };
-
- const LabelComponent = props.LabelComponent;
-
- return (
-
-
-
-
- {props.label && {props.label}}
- {LabelComponent && }
-
-
-
-
- );
-}
-
-CheckboxWithLabel.propTypes = propTypes;
-CheckboxWithLabel.defaultProps = defaultProps;
-CheckboxWithLabel.displayName = 'CheckboxWithLabel';
-
-const CheckboxWithLabelWithRef = React.forwardRef((props, ref) => (
-
-));
-
-CheckboxWithLabelWithRef.displayName = 'CheckboxWithLabelWithRef';
-
-export default CheckboxWithLabelWithRef;
diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx
new file mode 100644
index 000000000000..9660c9e1a2e5
--- /dev/null
+++ b/src/components/CheckboxWithLabel.tsx
@@ -0,0 +1,107 @@
+import React, {ComponentType, ForwardedRef, useState} from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import Checkbox from './Checkbox';
+import FormHelpMessage from './FormHelpMessage';
+import PressableWithFeedback from './Pressable/PressableWithFeedback';
+import Text from './Text';
+
+type RequiredLabelProps =
+ | {
+ /** Text that appears next to check box */
+ label: string;
+
+ /** Component to display for label
+ * If label is provided, LabelComponent is not required
+ */
+ LabelComponent?: ComponentType;
+ }
+ | {
+ /** Component to display for label */
+ LabelComponent: ComponentType;
+
+ /** Text that appears next to check box
+ * If LabelComponent is provided, label is not required
+ */
+ label?: string;
+ };
+
+type CheckboxWithLabelProps = RequiredLabelProps & {
+ /** Whether the checkbox is checked */
+ isChecked?: boolean;
+
+ /** Called when the checkbox or label is pressed */
+ onInputChange?: (value?: boolean) => void;
+
+ /** Container styles */
+ style?: StyleProp;
+
+ /** Error text to display */
+ errorText?: string;
+
+ /** Value for checkbox. This prop is intended to be set by Form.js only */
+ value?: boolean;
+
+ /** The default value for the checkbox */
+ defaultValue?: boolean;
+
+ /** The ID used to uniquely identify the input in a Form */
+ /* eslint-disable-next-line react/no-unused-prop-types */
+ inputID?: string;
+
+ /** Saves a draft of the input value when used in a form */
+ // eslint-disable-next-line react/no-unused-prop-types
+ shouldSaveDraft?: boolean;
+
+ /** An accessibility label for the checkbox */
+ accessibilityLabel?: string;
+};
+
+function CheckboxWithLabel(
+ {errorText = '', isChecked: isCheckedProp = false, defaultValue = false, onInputChange = () => {}, LabelComponent, label, accessibilityLabel, style, value}: CheckboxWithLabelProps,
+ ref: ForwardedRef,
+) {
+ const styles = useThemeStyles();
+ // We need to pick the first value that is strictly a boolean
+ // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065
+ const [isChecked, setIsChecked] = useState(() => [value, defaultValue, isCheckedProp].find((item) => typeof item === 'boolean'));
+
+ const toggleCheckbox = () => {
+ onInputChange(!isChecked);
+ setIsChecked(!isChecked);
+ };
+
+ return (
+
+
+
+
+ {label && {label}}
+ {LabelComponent && }
+
+
+
+
+ );
+}
+
+CheckboxWithLabel.displayName = 'CheckboxWithLabel';
+
+export default React.forwardRef(CheckboxWithLabel);
diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js
deleted file mode 100644
index af64831df117..000000000000
--- a/src/components/Composer/index.android.js
+++ /dev/null
@@ -1,147 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
-import {StyleSheet} from 'react-native';
-import _ from 'underscore';
-import RNTextInput from '@components/RNTextInput';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ComposerUtils from '@libs/ComposerUtils';
-
-const propTypes = {
- /** Maximum number of lines in the text input */
- maxLines: PropTypes.number,
-
- /** If the input should clear, it actually gets intercepted instead of .clear() */
- shouldClear: PropTypes.bool,
-
- /** A ref to forward to the text input */
- forwardedRef: PropTypes.func,
-
- /** When the input has cleared whoever owns this input should know about it */
- onClear: PropTypes.func,
-
- /** Set focus to this component the first time it renders.
- * Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */
- autoFocus: PropTypes.bool,
-
- /** Prevent edits and interactions like focus for this input. */
- isDisabled: PropTypes.bool,
-
- /** Selection Object */
- selection: PropTypes.shape({
- start: PropTypes.number,
- end: PropTypes.number,
- }),
-
- /** Whether the full composer can be opened */
- isFullComposerAvailable: PropTypes.bool,
-
- /** Allow the full composer to be opened */
- setIsFullComposerAvailable: PropTypes.func,
-
- /** Whether the composer is full size */
- isComposerFullSize: PropTypes.bool,
-
- /** General styles to apply to the text input */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.any,
-};
-
-const defaultProps = {
- shouldClear: false,
- onClear: () => {},
- autoFocus: false,
- isDisabled: false,
- forwardedRef: null,
- selection: {
- start: 0,
- end: 0,
- },
- maxLines: undefined,
- isFullComposerAvailable: false,
- setIsFullComposerAvailable: () => {},
- isComposerFullSize: false,
- style: null,
-};
-
-function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isComposerFullSize, setIsFullComposerAvailable, ...props}) {
- const textInput = useRef(null);
- const theme = useTheme();
- const styles = useThemeStyles();
-
- /**
- * Set the TextInput Ref
- * @param {Element} el
- */
- const setTextInputRef = useCallback((el) => {
- textInput.current = el;
- if (!_.isFunction(forwardedRef) || textInput.current === null) {
- return;
- }
-
- // This callback prop is used by the parent component using the constructor to
- // get a ref to the inner textInput element e.g. if we do
- // this.textInput = el} /> this will not
- // return a ref to the component, but rather the HTML element by default
- forwardedRef(textInput.current);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- useEffect(() => {
- if (!shouldClear) {
- return;
- }
- textInput.current.clear();
- onClear();
- }, [shouldClear, onClear]);
-
- /**
- * Set maximum number of lines
- * @return {Number}
- */
- const maxNumberOfLines = useMemo(() => {
- if (isComposerFullSize) {
- return 1000000;
- }
- return maxLines;
- }, [isComposerFullSize, maxLines]);
-
- const composerStyles = useMemo(() => {
- StyleSheet.flatten(props.style);
- }, [props.style]);
-
- return (
- ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)}
- rejectResponderTermination={false}
- // Setting a really high number here fixes an issue with the `maxNumberOfLines` prop on TextInput, where on Android the text input would collapse to only one line,
- // when it should actually expand to the container (https://github.com/Expensify/App/issues/11694#issuecomment-1560520670)
- // @Szymon20000 is working on fixing this (android-only) issue in the in the upstream PR (https://github.com/facebook/react-native/pulls?q=is%3Apr+is%3Aopen+maxNumberOfLines)
- // TODO: remove this comment once upstream PR is merged and available in a future release
- maxNumberOfLines={maxNumberOfLines}
- textAlignVertical="center"
- style={[composerStyles]}
- /* eslint-disable-next-line react/jsx-props-no-spreading */
- {...props}
- readOnly={isDisabled}
- />
- );
-}
-
-Composer.propTypes = propTypes;
-Composer.defaultProps = defaultProps;
-
-const ComposerWithRef = React.forwardRef((props, ref) => (
-
-));
-
-ComposerWithRef.displayName = 'ComposerWithRef';
-
-export default ComposerWithRef;
diff --git a/src/components/Composer/index.android.tsx b/src/components/Composer/index.android.tsx
new file mode 100644
index 000000000000..46c2a5f06ded
--- /dev/null
+++ b/src/components/Composer/index.android.tsx
@@ -0,0 +1,96 @@
+import React, {ForwardedRef, useCallback, useEffect, useMemo, useRef} from 'react';
+import {StyleSheet, TextInput} from 'react-native';
+import RNTextInput from '@components/RNTextInput';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as ComposerUtils from '@libs/ComposerUtils';
+import {ComposerProps} from './types';
+
+function Composer(
+ {
+ shouldClear = false,
+ onClear = () => {},
+ isDisabled = false,
+ maxLines,
+ isComposerFullSize = false,
+ setIsFullComposerAvailable = () => {},
+ style,
+ autoFocus = false,
+ selection = {
+ start: 0,
+ end: 0,
+ },
+ isFullComposerAvailable = false,
+ ...props
+ }: ComposerProps,
+ ref: ForwardedRef,
+) {
+ const textInput = useRef(null);
+
+ const styles = useThemeStyles();
+ const theme = useTheme();
+
+ /**
+ * Set the TextInput Ref
+ */
+ const setTextInputRef = useCallback((el: TextInput) => {
+ textInput.current = el;
+ if (typeof ref !== 'function' || textInput.current === null) {
+ return;
+ }
+
+ // This callback prop is used by the parent component using the constructor to
+ // get a ref to the inner textInput element e.g. if we do
+ // this.textInput = el} /> this will not
+ // return a ref to the component, but rather the HTML element by default
+ ref(textInput.current);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!shouldClear) {
+ return;
+ }
+ textInput.current?.clear();
+ onClear();
+ }, [shouldClear, onClear]);
+
+ /**
+ * Set maximum number of lines
+ */
+ const maxNumberOfLines = useMemo(() => {
+ if (isComposerFullSize) {
+ return 1000000;
+ }
+ return maxLines;
+ }, [isComposerFullSize, maxLines]);
+
+ const composerStyles = useMemo(() => StyleSheet.flatten(style), [style]);
+
+ return (
+ ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)}
+ rejectResponderTermination={false}
+ // Setting a really high number here fixes an issue with the `maxNumberOfLines` prop on TextInput, where on Android the text input would collapse to only one line,
+ // when it should actually expand to the container (https://github.com/Expensify/App/issues/11694#issuecomment-1560520670)
+ // @Szymon20000 is working on fixing this (android-only) issue in the in the upstream PR (https://github.com/facebook/react-native/pulls?q=is%3Apr+is%3Aopen+maxNumberOfLines)
+ // TODO: remove this comment once upstream PR is merged and available in a future release
+ maxNumberOfLines={maxNumberOfLines}
+ textAlignVertical="center"
+ style={[composerStyles]}
+ autoFocus={autoFocus}
+ selection={selection}
+ isFullComposerAvailable={isFullComposerAvailable}
+ /* eslint-disable-next-line react/jsx-props-no-spreading */
+ {...props}
+ readOnly={isDisabled}
+ />
+ );
+}
+
+Composer.displayName = 'Composer';
+
+export default React.forwardRef(Composer);
diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js
deleted file mode 100644
index c9947999b273..000000000000
--- a/src/components/Composer/index.ios.js
+++ /dev/null
@@ -1,147 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
-import {StyleSheet} from 'react-native';
-import _ from 'underscore';
-import RNTextInput from '@components/RNTextInput';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ComposerUtils from '@libs/ComposerUtils';
-
-const propTypes = {
- /** If the input should clear, it actually gets intercepted instead of .clear() */
- shouldClear: PropTypes.bool,
-
- /** A ref to forward to the text input */
- forwardedRef: PropTypes.func,
-
- /** When the input has cleared whoever owns this input should know about it */
- onClear: PropTypes.func,
-
- /** Set focus to this component the first time it renders.
- * Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */
- autoFocus: PropTypes.bool,
-
- /** Prevent edits and interactions like focus for this input. */
- isDisabled: PropTypes.bool,
-
- /** Selection Object */
- selection: PropTypes.shape({
- start: PropTypes.number,
- end: PropTypes.number,
- }),
-
- /** Whether the full composer can be opened */
- isFullComposerAvailable: PropTypes.bool,
-
- /** Maximum number of lines in the text input */
- maxLines: PropTypes.number,
-
- /** Allow the full composer to be opened */
- setIsFullComposerAvailable: PropTypes.func,
-
- /** Whether the composer is full size */
- isComposerFullSize: PropTypes.bool,
-
- /** General styles to apply to the text input */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.any,
-};
-
-const defaultProps = {
- shouldClear: false,
- onClear: () => {},
- autoFocus: false,
- isDisabled: false,
- forwardedRef: null,
- selection: {
- start: 0,
- end: 0,
- },
- maxLines: undefined,
- isFullComposerAvailable: false,
- setIsFullComposerAvailable: () => {},
- isComposerFullSize: false,
- style: null,
-};
-
-function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isComposerFullSize, setIsFullComposerAvailable, ...props}) {
- const textInput = useRef(null);
- const theme = useTheme();
- const styles = useThemeStyles();
-
- /**
- * Set the TextInput Ref
- * @param {Element} el
- */
- const setTextInputRef = useCallback((el) => {
- textInput.current = el;
- if (!_.isFunction(forwardedRef) || textInput.current === null) {
- return;
- }
-
- // This callback prop is used by the parent component using the constructor to
- // get a ref to the inner textInput element e.g. if we do
- // this.textInput = el} /> this will not
- // return a ref to the component, but rather the HTML element by default
- forwardedRef(textInput.current);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- useEffect(() => {
- if (!shouldClear) {
- return;
- }
- textInput.current.clear();
- onClear();
- }, [shouldClear, onClear]);
-
- /**
- * Set maximum number of lines
- * @return {Number}
- */
- const maxNumberOfLines = useMemo(() => {
- if (isComposerFullSize) {
- return;
- }
- return maxLines;
- }, [isComposerFullSize, maxLines]);
-
- const composerStyles = useMemo(() => {
- StyleSheet.flatten(props.style);
- }, [props.style]);
-
- // On native layers we like to have the Text Input not focused so the
- // user can read new chats without the keyboard in the way of the view.
- // On Android the selection prop is required on the TextInput but this prop has issues on IOS
- const propsToPass = _.omit(props, 'selection');
- return (
- ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)}
- rejectResponderTermination={false}
- smartInsertDelete={false}
- maxNumberOfLines={maxNumberOfLines}
- style={[composerStyles, styles.verticalAlignMiddle]}
- /* eslint-disable-next-line react/jsx-props-no-spreading */
- {...propsToPass}
- readOnly={isDisabled}
- />
- );
-}
-
-Composer.propTypes = propTypes;
-Composer.defaultProps = defaultProps;
-
-const ComposerWithRef = React.forwardRef((props, ref) => (
-
-));
-
-ComposerWithRef.displayName = 'ComposerWithRef';
-
-export default ComposerWithRef;
diff --git a/src/components/Composer/index.ios.tsx b/src/components/Composer/index.ios.tsx
new file mode 100644
index 000000000000..240dfabded0b
--- /dev/null
+++ b/src/components/Composer/index.ios.tsx
@@ -0,0 +1,91 @@
+import React, {ForwardedRef, useCallback, useEffect, useMemo, useRef} from 'react';
+import {StyleSheet, TextInput} from 'react-native';
+import RNTextInput from '@components/RNTextInput';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as ComposerUtils from '@libs/ComposerUtils';
+import {ComposerProps} from './types';
+
+function Composer(
+ {
+ shouldClear = false,
+ onClear = () => {},
+ isDisabled = false,
+ maxLines,
+ isComposerFullSize = false,
+ setIsFullComposerAvailable = () => {},
+ autoFocus = false,
+ isFullComposerAvailable = false,
+ style,
+ // On native layers we like to have the Text Input not focused so the
+ // user can read new chats without the keyboard in the way of the view.
+ // On Android the selection prop is required on the TextInput but this prop has issues on IOS
+ selection,
+ ...props
+ }: ComposerProps,
+ ref: ForwardedRef,
+) {
+ const textInput = useRef(null);
+
+ const styles = useThemeStyles();
+ const theme = useTheme();
+
+ /**
+ * Set the TextInput Ref
+ */
+ const setTextInputRef = useCallback((el: TextInput) => {
+ textInput.current = el;
+ if (typeof ref !== 'function' || textInput.current === null) {
+ return;
+ }
+
+ // This callback prop is used by the parent component using the constructor to
+ // get a ref to the inner textInput element e.g. if we do
+ // this.textInput = el} /> this will not
+ // return a ref to the component, but rather the HTML element by default
+ ref(textInput.current);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!shouldClear) {
+ return;
+ }
+ textInput.current?.clear();
+ onClear();
+ }, [shouldClear, onClear]);
+
+ /**
+ * Set maximum number of lines
+ */
+ const maxNumberOfLines = useMemo(() => {
+ if (isComposerFullSize) {
+ return;
+ }
+ return maxLines;
+ }, [isComposerFullSize, maxLines]);
+
+ const composerStyles = useMemo(() => StyleSheet.flatten(style), [style]);
+
+ return (
+ ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)}
+ rejectResponderTermination={false}
+ smartInsertDelete={false}
+ style={[composerStyles, styles.verticalAlignMiddle]}
+ maxNumberOfLines={maxNumberOfLines}
+ autoFocus={autoFocus}
+ isFullComposerAvailable={isFullComposerAvailable}
+ /* eslint-disable-next-line react/jsx-props-no-spreading */
+ {...props}
+ readOnly={isDisabled}
+ />
+ );
+}
+
+Composer.displayName = 'Composer';
+
+export default React.forwardRef(Composer);
diff --git a/src/components/Composer/index.js b/src/components/Composer/index.tsx
similarity index 61%
rename from src/components/Composer/index.js
rename to src/components/Composer/index.tsx
index 3af22b63ed69..4ff5c6dbd75f 100755
--- a/src/components/Composer/index.js
+++ b/src/components/Composer/index.tsx
@@ -1,198 +1,107 @@
+import {useNavigation} from '@react-navigation/native';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import React, {BaseSyntheticEvent, ForwardedRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {flushSync} from 'react-dom';
-import {StyleSheet, View} from 'react-native';
-import _ from 'underscore';
+import {DimensionValue, NativeSyntheticEvent, Text as RNText, StyleSheet, TextInput, TextInputKeyPressEventData, TextInputProps, TextInputSelectionChangeEventData, View} from 'react-native';
+import {AnimatedProps} from 'react-native-reanimated';
import RNTextInput from '@components/RNTextInput';
import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import withNavigation from '@components/withNavigation';
import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
-import compose from '@libs/compose';
import * as ComposerUtils from '@libs/ComposerUtils';
import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable';
import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import CONST from '@src/CONST';
-
-const propTypes = {
- /** Maximum number of lines in the text input */
- maxLines: PropTypes.number,
-
- /** The default value of the comment box */
- defaultValue: PropTypes.string,
-
- /** The value of the comment box */
- value: PropTypes.string,
-
- /** Number of lines for the comment */
- numberOfLines: PropTypes.number,
-
- /** Callback method to update number of lines for the comment */
- onNumberOfLinesChange: PropTypes.func,
-
- /** Callback method to handle pasting a file */
- onPasteFile: PropTypes.func,
-
- /** A ref to forward to the text input */
- forwardedRef: PropTypes.func,
-
- /** General styles to apply to the text input */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.any,
-
- /** If the input should clear, it actually gets intercepted instead of .clear() */
- shouldClear: PropTypes.bool,
-
- /** When the input has cleared whoever owns this input should know about it */
- onClear: PropTypes.func,
-
- /** Whether or not this TextInput is disabled. */
- isDisabled: PropTypes.bool,
-
- /** Set focus to this component the first time it renders.
- Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */
- autoFocus: PropTypes.bool,
-
- /** Update selection position on change */
- onSelectionChange: PropTypes.func,
-
- /** Selection Object */
- selection: PropTypes.shape({
- start: PropTypes.number,
- end: PropTypes.number,
- }),
-
- /** Whether the full composer can be opened */
- isFullComposerAvailable: PropTypes.bool,
-
- /** Allow the full composer to be opened */
- setIsFullComposerAvailable: PropTypes.func,
-
- /** Should we calculate the caret position */
- shouldCalculateCaretPosition: PropTypes.bool,
-
- /** Function to check whether composer is covered up or not */
- checkComposerVisibility: PropTypes.func,
-
- /** Whether this is the report action compose */
- isReportActionCompose: PropTypes.bool,
-
- /** Whether the sull composer is open */
- isComposerFullSize: PropTypes.bool,
-
- /** Should make the input only scroll inside the element avoid scroll out to parent */
- shouldContainScroll: PropTypes.bool,
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- defaultValue: undefined,
- value: undefined,
- numberOfLines: 0,
- onNumberOfLinesChange: () => {},
- maxLines: -1,
- onPasteFile: () => {},
- shouldClear: false,
- onClear: () => {},
- style: null,
- isDisabled: false,
- autoFocus: false,
- forwardedRef: null,
- onSelectionChange: () => {},
- selection: {
- start: 0,
- end: 0,
- },
- isFullComposerAvailable: false,
- setIsFullComposerAvailable: () => {},
- shouldCalculateCaretPosition: false,
- checkComposerVisibility: () => false,
- isReportActionCompose: false,
- isComposerFullSize: false,
- shouldContainScroll: false,
-};
+import {ComposerProps} from './types';
/**
* Retrieves the characters from the specified cursor position up to the next space or new line.
*
- * @param {string} str - The input string.
- * @param {number} cursorPos - The position of the cursor within the input string.
- * @returns {string} - The substring from the cursor position up to the next space or new line.
+ * @param inputString - The input string.
+ * @param cursorPosition - The position of the cursor within the input string.
+ * @returns - The substring from the cursor position up to the next space or new line.
* If no space or new line is found, returns the substring from the cursor position to the end of the input string.
*/
-const getNextChars = (str, cursorPos) => {
+const getNextChars = (inputString: string, cursorPosition: number): string => {
// Get the substring starting from the cursor position
- const substr = str.substring(cursorPos);
+ const subString = inputString.substring(cursorPosition);
// Find the index of the next space or new line character
- const spaceIndex = substr.search(/[ \n]/);
+ const spaceIndex = subString.search(/[ \n]/);
if (spaceIndex === -1) {
- return substr;
+ return subString;
}
// If there is a space or new line, return the substring up to the space or new line
- return substr.substring(0, spaceIndex);
+ return subString.substring(0, spaceIndex);
};
// Enable Markdown parsing.
// On web we like to have the Text Input field always focused so the user can easily type a new chat
-function Composer({
- value,
- defaultValue,
- maxLines,
- onKeyPress,
- style,
- shouldClear,
- autoFocus,
- translate,
- isFullComposerAvailable,
- shouldCalculateCaretPosition,
- numberOfLines: numberOfLinesProp,
- isDisabled,
- forwardedRef,
- navigation,
- onClear,
- onPasteFile,
- onSelectionChange,
- onNumberOfLinesChange,
- setIsFullComposerAvailable,
- checkComposerVisibility,
- selection: selectionProp,
- isReportActionCompose,
- isComposerFullSize,
- shouldContainScroll,
- ...props
-}) {
+function Composer(
+ {
+ value,
+ defaultValue,
+ maxLines = -1,
+ onKeyPress = () => {},
+ style,
+ shouldClear = false,
+ autoFocus = false,
+ isFullComposerAvailable = false,
+ shouldCalculateCaretPosition = false,
+ numberOfLines: numberOfLinesProp = 0,
+ isDisabled = false,
+ onClear = () => {},
+ onPasteFile = () => {},
+ onSelectionChange = () => {},
+ onNumberOfLinesChange = () => {},
+ setIsFullComposerAvailable = () => {},
+ checkComposerVisibility = () => false,
+ selection: selectionProp = {
+ start: 0,
+ end: 0,
+ },
+ isReportActionCompose = false,
+ isComposerFullSize = false,
+ shouldContainScroll = false,
+ ...props
+ }: ComposerProps,
+ ref: ForwardedRef>>,
+) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {windowWidth} = useWindowDimensions();
- const textRef = useRef(null);
- const textInput = useRef(null);
+ const navigation = useNavigation();
+ const textRef = useRef(null);
+ const textInput = useRef<(HTMLTextAreaElement & TextInput) | null>(null);
const [numberOfLines, setNumberOfLines] = useState(numberOfLinesProp);
- const [selection, setSelection] = useState({
+ const [selection, setSelection] = useState<
+ | {
+ start: number;
+ end?: number;
+ }
+ | undefined
+ >({
start: selectionProp.start,
end: selectionProp.end,
});
const [caretContent, setCaretContent] = useState('');
const [valueBeforeCaret, setValueBeforeCaret] = useState('');
const [textInputWidth, setTextInputWidth] = useState('');
- const isScrollBarVisible = useIsScrollBarVisible(textInput, value);
+ const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? '');
useEffect(() => {
if (!shouldClear) {
return;
}
- textInput.current.clear();
+ textInput.current?.clear();
setNumberOfLines(1);
onClear();
}, [shouldClear, onClear]);
@@ -208,55 +117,55 @@ function Composer({
/**
* Adds the cursor position to the selection change event.
- *
- * @param {Event} event
*/
- const addCursorPositionToSelectionChange = (event) => {
+ const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => {
+ const webEvent = event as BaseSyntheticEvent;
+
if (shouldCalculateCaretPosition) {
// we do flushSync to make sure that the valueBeforeCaret is updated before we calculate the caret position to receive a proper position otherwise we will calculate position for the previous state
flushSync(() => {
- setValueBeforeCaret(event.target.value.slice(0, event.nativeEvent.selection.start));
- setCaretContent(getNextChars(value, event.nativeEvent.selection.start));
+ setValueBeforeCaret(webEvent.target.value.slice(0, webEvent.nativeEvent.selection.start));
+ setCaretContent(getNextChars(value ?? '', webEvent.nativeEvent.selection.start));
});
const selectionValue = {
- start: event.nativeEvent.selection.start,
- end: event.nativeEvent.selection.end,
- positionX: textRef.current.offsetLeft - CONST.SPACE_CHARACTER_WIDTH,
- positionY: textRef.current.offsetTop,
+ start: webEvent.nativeEvent.selection.start,
+ end: webEvent.nativeEvent.selection.end,
+ positionX: (textRef.current?.offsetLeft ?? 0) - CONST.SPACE_CHARACTER_WIDTH,
+ positionY: textRef.current?.offsetTop,
};
+
onSelectionChange({
+ ...webEvent,
nativeEvent: {
+ ...webEvent.nativeEvent,
selection: selectionValue,
},
});
setSelection(selectionValue);
} else {
- onSelectionChange(event);
- setSelection(event.nativeEvent.selection);
+ onSelectionChange(webEvent);
+ setSelection(webEvent.nativeEvent.selection);
}
};
/**
* Set pasted text to clipboard
- * @param {String} text
*/
- const paste = useCallback((text) => {
+ const paste = useCallback((text?: string) => {
try {
document.execCommand('insertText', false, text);
// Pointer will go out of sight when a large paragraph is pasted on the web. Refocusing the input keeps the cursor in view.
- textInput.current.blur();
- textInput.current.focus();
+ textInput.current?.blur();
+ textInput.current?.focus();
// eslint-disable-next-line no-empty
} catch (e) {}
}, []);
/**
* Manually place the pasted HTML into Composer
- *
- * @param {String} html - pasted HTML
*/
const handlePastedHTML = useCallback(
- (html) => {
+ (html: string) => {
const parser = new ExpensiMark();
paste(parser.htmlToMarkdown(html));
},
@@ -265,12 +174,10 @@ function Composer({
/**
* Paste the plaintext content into Composer.
- *
- * @param {ClipboardEvent} event
*/
const handlePastePlainText = useCallback(
- (event) => {
- const plainText = event.clipboardData.getData('text/plain');
+ (event: ClipboardEvent) => {
+ const plainText = event.clipboardData?.getData('text/plain');
paste(plainText);
},
[paste],
@@ -279,44 +186,43 @@ function Composer({
/**
* Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file,
* Otherwise, convert pasted HTML to Markdown and set it on the composer.
- *
- * @param {ClipboardEvent} event
*/
const handlePaste = useCallback(
- (event) => {
+ (event: ClipboardEvent) => {
const isVisible = checkComposerVisibility();
- const isFocused = textInput.current.isFocused();
+ const isFocused = textInput.current?.isFocused();
if (!(isVisible || isFocused)) {
return;
}
if (textInput.current !== event.target) {
+ const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null;
+
// To make sure the composer does not capture paste events from other inputs, we check where the event originated
// If it did originate in another input, we return early to prevent the composer from handling the paste
- const isTargetInput = event.target.nodeName === 'INPUT' || event.target.nodeName === 'TEXTAREA' || event.target.contentEditable === 'true';
+ const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true';
if (isTargetInput) {
return;
}
- textInput.current.focus();
+ textInput.current?.focus();
}
event.preventDefault();
- const {files, types} = event.clipboardData;
const TEXT_HTML = 'text/html';
// If paste contains files, then trigger file management
- if (files.length > 0) {
+ if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) {
// Prevent the default so we do not post the file name into the text box
- onPasteFile(event.clipboardData.files[0]);
+ onPasteFile(event.clipboardData?.files[0]);
return;
}
// If paste contains HTML
- if (types.includes(TEXT_HTML)) {
- const pastedHTML = event.clipboardData.getData(TEXT_HTML);
+ if (event.clipboardData?.types.includes(TEXT_HTML)) {
+ const pastedHTML = event.clipboardData?.getData(TEXT_HTML);
const domparser = new DOMParser();
const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images;
@@ -342,11 +248,11 @@ function Composer({
* divide by line height to get the total number of rows for the textarea.
*/
const updateNumberOfLines = useCallback(() => {
- if (textInput.current === null) {
+ if (!textInput.current) {
return;
}
// we reset the height to 0 to get the correct scrollHeight
- textInput.current.style.height = 0;
+ textInput.current.style.height = '0';
const computedStyle = window.getComputedStyle(textInput.current);
const lineHeight = parseInt(computedStyle.lineHeight, 10) || 20;
const paddingTopAndBottom = parseInt(computedStyle.paddingBottom, 10) + parseInt(computedStyle.paddingTop, 10);
@@ -372,8 +278,8 @@ function Composer({
const unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste));
const unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste));
- if (_.isFunction(forwardedRef)) {
- forwardedRef(textInput.current);
+ if (typeof ref === 'function') {
+ ref(textInput.current);
}
if (textInput.current) {
@@ -392,9 +298,9 @@ function Composer({
}, []);
const handleKeyPress = useCallback(
- (e) => {
+ (e: NativeSyntheticEvent) => {
// Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed
- if (!onKeyPress || isEnterWhileComposition(e)) {
+ if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) {
return;
}
onKeyPress(e);
@@ -410,10 +316,7 @@ function Composer({
opacity: 0,
}}
>
-
+
{`${valueBeforeCaret} `}
(textInput.current = el)}
+ ref={(el: TextInput & HTMLTextAreaElement) => (textInput.current = el)}
selection={selection}
style={inputStyleMemo}
value={value}
- forwardedRef={forwardedRef}
defaultValue={defaultValue}
autoFocus={autoFocus}
/* eslint-disable-next-line react/jsx-props-no-spreading */
@@ -474,9 +376,8 @@ function Composer({
textInput.current.focus();
});
- if (props.onFocus) {
- props.onFocus(e);
- }
+
+ props.onFocus?.(e);
}}
/>
{shouldCalculateCaretPosition && renderElementForCaretPosition}
@@ -484,18 +385,6 @@ function Composer({
);
}
-Composer.propTypes = propTypes;
-Composer.defaultProps = defaultProps;
Composer.displayName = 'Composer';
-const ComposerWithRef = React.forwardRef((props, ref) => (
-
-));
-
-ComposerWithRef.displayName = 'ComposerWithRef';
-
-export default compose(withLocalize, withNavigation)(ComposerWithRef);
+export default React.forwardRef(Composer);
diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts
new file mode 100644
index 000000000000..cc0654b68019
--- /dev/null
+++ b/src/components/Composer/types.ts
@@ -0,0 +1,76 @@
+import {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native';
+
+type TextSelection = {
+ start: number;
+ end?: number;
+};
+
+type ComposerProps = {
+ /** Maximum number of lines in the text input */
+ maxLines?: number;
+
+ /** The default value of the comment box */
+ defaultValue?: string;
+
+ /** The value of the comment box */
+ value?: string;
+
+ /** Number of lines for the comment */
+ numberOfLines?: number;
+
+ /** Callback method to update number of lines for the comment */
+ onNumberOfLinesChange?: (numberOfLines: number) => void;
+
+ /** Callback method to handle pasting a file */
+ onPasteFile?: (file?: File) => void;
+
+ /** General styles to apply to the text input */
+ // eslint-disable-next-line react/forbid-prop-types
+ style?: StyleProp;
+
+ /** If the input should clear, it actually gets intercepted instead of .clear() */
+ shouldClear?: boolean;
+
+ /** When the input has cleared whoever owns this input should know about it */
+ onClear?: () => void;
+
+ /** Whether or not this TextInput is disabled. */
+ isDisabled?: boolean;
+
+ /** Set focus to this component the first time it renders.
+ Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */
+ autoFocus?: boolean;
+
+ /** Update selection position on change */
+ onSelectionChange?: (event: NativeSyntheticEvent) => void;
+
+ /** Selection Object */
+ selection?: TextSelection;
+
+ /** Whether the full composer can be opened */
+ isFullComposerAvailable?: boolean;
+
+ /** Allow the full composer to be opened */
+ setIsFullComposerAvailable?: (value: boolean) => void;
+
+ /** Should we calculate the caret position */
+ shouldCalculateCaretPosition?: boolean;
+
+ /** Function to check whether composer is covered up or not */
+ checkComposerVisibility?: () => boolean;
+
+ /** Whether this is the report action compose */
+ isReportActionCompose?: boolean;
+
+ /** Whether the sull composer is open */
+ isComposerFullSize?: boolean;
+
+ onKeyPress?: (event: NativeSyntheticEvent) => void;
+
+ onFocus?: (event: NativeSyntheticEvent) => void;
+
+ /** Should make the input only scroll inside the element avoid scroll out to parent */
+ shouldContainScroll?: boolean;
+};
+
+export type {TextSelection, ComposerProps};
diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js
index 8af550c9dc66..a2ca930690ac 100644
--- a/src/components/DatePicker/index.js
+++ b/src/components/DatePicker/index.js
@@ -1,18 +1,21 @@
import {setYear} from 'date-fns';
import _ from 'lodash';
import PropTypes from 'prop-types';
-import React, {useEffect, useState} from 'react';
+import React, {forwardRef, useState} from 'react';
import {View} from 'react-native';
-import InputWrapper from '@components/Form/InputWrapper';
import * as Expensicons from '@components/Icon/Expensicons';
+import refPropTypes from '@components/refPropTypes';
import TextInput from '@components/TextInput';
import {propTypes as baseTextInputPropTypes, defaultProps as defaultBaseTextInputPropTypes} from '@components/TextInput/BaseTextInput/baseTextInputPropTypes';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import CalendarPicker from './CalendarPicker';
const propTypes = {
+ /** React ref being forwarded to the DatePicker input */
+ forwardedRef: refPropTypes,
+
/**
* The datepicker supports any value that `new Date()` can parse.
* `onInputChange` would always be called with a Date (or null)
@@ -33,7 +36,12 @@ const propTypes = {
/** A maximum date of calendar to select */
maxDate: PropTypes.objectOf(Date),
- ...withLocalizePropTypes,
+ /** A function that is passed by FormWrapper */
+ onInputChange: PropTypes.func.isRequired,
+
+ /** A function that is passed by FormWrapper */
+ onTouched: PropTypes.func.isRequired,
+
...baseTextInputPropTypes,
};
@@ -44,40 +52,33 @@ const datePickerDefaultProps = {
value: undefined,
};
-function DatePicker({containerStyles, defaultValue, disabled, errorText, inputID, isSmallScreenWidth, label, maxDate, minDate, onInputChange, onTouched, placeholder, translate, value}) {
+function DatePicker({forwardedRef, containerStyles, defaultValue, disabled, errorText, inputID, isSmallScreenWidth, label, maxDate, minDate, onInputChange, onTouched, placeholder, value}) {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
const [selectedDate, setSelectedDate] = useState(value || defaultValue || undefined);
- useEffect(() => {
- if (selectedDate === value || _.isUndefined(value)) {
- return;
- }
- setSelectedDate(value);
- }, [selectedDate, value]);
-
- useEffect(() => {
+ const onSelected = (newValue) => {
if (_.isFunction(onTouched)) {
onTouched();
}
if (_.isFunction(onInputChange)) {
- onInputChange(selectedDate);
+ onInputChange(newValue);
}
- // To keep behavior from class component state update callback, we want to run effect only when the selected date is changed.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedDate]);
+ setSelectedDate(newValue);
+ };
return (
-
@@ -103,4 +104,14 @@ DatePicker.propTypes = propTypes;
DatePicker.defaultProps = datePickerDefaultProps;
DatePicker.displayName = 'DatePicker';
-export default withLocalize(DatePicker);
+const DatePickerWithRef = forwardRef((props, ref) => (
+
+));
+
+DatePickerWithRef.displayName = 'DatePickerWithRef';
+
+export default DatePickerWithRef;
diff --git a/src/components/DisplayNames/types.ts b/src/components/DisplayNames/types.ts
index 94e4fc7c39c6..5137d6f54108 100644
--- a/src/components/DisplayNames/types.ts
+++ b/src/components/DisplayNames/types.ts
@@ -29,7 +29,7 @@ type DisplayNamesProps = {
tooltipEnabled?: boolean;
/** Arbitrary styles of the displayName text */
- textStyles: StyleProp;
+ textStyles?: StyleProp;
/**
* Overrides the text that's read by the screen reader when the user interacts with the element. By default, the
@@ -42,3 +42,5 @@ type DisplayNamesProps = {
};
export default DisplayNamesProps;
+
+export type {DisplayNameWithTooltip};
diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js
index 869fe1edbfe5..5888bf30b71a 100644
--- a/src/components/EmojiPicker/EmojiPickerButton.js
+++ b/src/components/EmojiPicker/EmojiPickerButton.js
@@ -5,8 +5,10 @@ import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import Tooltip from '@components/Tooltip/PopoverAnchorTooltip';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import withNavigationFocus from '@components/withNavigationFocus';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
+import compose from '@libs/compose';
import getButtonState from '@libs/getButtonState';
import * as EmojiPickerAction from '@userActions/EmojiPickerAction';
@@ -43,6 +45,9 @@ function EmojiPickerButton(props) {
style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]}
disabled={props.isDisabled}
onPress={() => {
+ if (!props.isFocused) {
+ return;
+ }
if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) {
EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.emojiPickerID);
} else {
@@ -66,4 +71,4 @@ function EmojiPickerButton(props) {
EmojiPickerButton.propTypes = propTypes;
EmojiPickerButton.defaultProps = defaultProps;
EmojiPickerButton.displayName = 'EmojiPickerButton';
-export default withLocalize(EmojiPickerButton);
+export default compose(withLocalize, withNavigationFocus)(EmojiPickerButton);
diff --git a/src/components/EnvironmentBadge.tsx b/src/components/EnvironmentBadge.tsx
index 6babbf119445..3a8445f62880 100644
--- a/src/components/EnvironmentBadge.tsx
+++ b/src/components/EnvironmentBadge.tsx
@@ -29,7 +29,7 @@ function EnvironmentBadge() {
success={environment === CONST.ENVIRONMENT.STAGING || environment === CONST.ENVIRONMENT.ADHOC}
error={environment !== CONST.ENVIRONMENT.STAGING && environment !== CONST.ENVIRONMENT.ADHOC}
text={text}
- badgeStyles={[styles.alignSelfEnd, styles.headerEnvBadge]}
+ badgeStyles={[styles.alignSelfStart, styles.headerEnvBadge]}
textStyles={[styles.headerEnvBadgeText]}
environment={environment}
/>
diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.tsx
similarity index 68%
rename from src/components/ExceededCommentLength.js
rename to src/components/ExceededCommentLength.tsx
index 3fd6688944f7..6cd11cc44a5c 100644
--- a/src/components/ExceededCommentLength.js
+++ b/src/components/ExceededCommentLength.tsx
@@ -1,23 +1,13 @@
-import PropTypes from 'prop-types';
import React from 'react';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import Text from './Text';
-const propTypes = {
- shouldShowError: PropTypes.bool.isRequired,
-};
-
-const defaultProps = {};
-
-function ExceededCommentLength(props) {
+function ExceededCommentLength() {
const styles = useThemeStyles();
const {numberFormat, translate} = useLocalize();
- if (!props.shouldShowError) {
- return null;
- }
return (
{},
- enabledWhenOffline: false,
- disablePressOnEnter: false,
- isSubmitActionDangerous: false,
- useSmallerSubmitButtonSize: false,
- footerContent: null,
- buttonStyles: [],
- errorMessageStyle: [],
-};
-
-function FormAlertWithSubmitButton(props) {
- const styles = useThemeStyles();
- const buttonStyles = [_.isEmpty(props.footerContent) ? {} : styles.mb3, ...props.buttonStyles];
-
- return (
-
- {(isOffline) => (
-
- {isOffline && !props.enabledWhenOffline ? (
-
- ) : (
-
- )}
- {props.footerContent}
-
- )}
-
- );
-}
-
-FormAlertWithSubmitButton.propTypes = propTypes;
-FormAlertWithSubmitButton.defaultProps = defaultProps;
-FormAlertWithSubmitButton.displayName = 'FormAlertWithSubmitButton';
-
-export default FormAlertWithSubmitButton;
diff --git a/src/components/FormAlertWithSubmitButton.tsx b/src/components/FormAlertWithSubmitButton.tsx
new file mode 100644
index 000000000000..d8e30b27371d
--- /dev/null
+++ b/src/components/FormAlertWithSubmitButton.tsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Button from './Button';
+import FormAlertWrapper from './FormAlertWrapper';
+
+type FormAlertWithSubmitButtonProps = {
+ /** Error message to display above button */
+ message?: string;
+
+ /** Whether the button is disabled */
+ isDisabled?: boolean;
+
+ /** Whether message is in html format */
+ isMessageHtml?: boolean;
+
+ /** Styles for container element */
+ containerStyles?: StyleProp;
+
+ /** Is the button in a loading state */
+ isLoading?: boolean;
+
+ /** Callback fired when the "fix the errors" link is pressed */
+ onFixTheErrorsLinkPressed?: () => void;
+
+ /** Submit function */
+ onSubmit: () => void;
+
+ /** Should the button be enabled when offline */
+ enabledWhenOffline?: boolean;
+
+ /** Disable press on enter for submit button */
+ disablePressOnEnter?: boolean;
+
+ /** Whether the form submit action is dangerous */
+ isSubmitActionDangerous?: boolean;
+
+ /** Custom content to display in the footer after submit button */
+ footerContent?: React.ReactNode;
+
+ /** Styles for the button */
+ buttonStyles?: StyleProp;
+
+ /** Whether to show the alert text */
+ isAlertVisible: boolean;
+
+ /** Text for the button */
+ buttonText: string;
+
+ /** Whether to use a smaller submit button size */
+ useSmallerSubmitButtonSize?: boolean;
+
+ /** Style for the error message for submit button */
+ errorMessageStyle?: StyleProp;
+};
+
+function FormAlertWithSubmitButton({
+ message = '',
+ isDisabled = false,
+ isMessageHtml = false,
+ containerStyles,
+ isLoading = false,
+ onFixTheErrorsLinkPressed = () => {},
+ enabledWhenOffline = false,
+ disablePressOnEnter = false,
+ isSubmitActionDangerous = false,
+ footerContent = null,
+ buttonStyles,
+ buttonText,
+ isAlertVisible,
+ onSubmit,
+ useSmallerSubmitButtonSize = false,
+ errorMessageStyle,
+}: FormAlertWithSubmitButtonProps) {
+ const styles = useThemeStyles();
+ const style = [!footerContent ? {} : styles.mb3, buttonStyles];
+
+ return (
+
+ {(isOffline: boolean | undefined) => (
+
+ {isOffline && !enabledWhenOffline ? (
+
+ ) : (
+
+ )}
+ {footerContent}
+
+ )}
+
+ );
+}
+
+FormAlertWithSubmitButton.displayName = 'FormAlertWithSubmitButton';
+
+export default FormAlertWithSubmitButton;
diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
index 86ddf0a52bb3..d663275a405c 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
@@ -64,6 +64,7 @@ function BaseHTMLEngineProvider(props) {
tagName: 'next-steps',
mixedUAStyles: {...styles.textLabelSupporting},
}),
+ 'next-steps-email': defaultHTMLElementModels.span.extend({tagName: 'next-steps-email'}),
}),
[styles.colorMuted, styles.formError, styles.mb0, styles.textLabelSupporting],
);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/NextStepsEmailRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/NextStepsEmailRenderer.tsx
new file mode 100644
index 000000000000..c5d3a15a30e2
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/NextStepsEmailRenderer.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+type NextStepsEmailRendererProps = {
+ tnode: {
+ data: string;
+ };
+};
+
+function NextStepsEmailRenderer({tnode}: NextStepsEmailRendererProps) {
+ const styles = useThemeStyles();
+
+ return {tnode.data};
+}
+
+NextStepsEmailRenderer.displayName = 'NextStepsEmailRenderer';
+
+export default NextStepsEmailRenderer;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/index.js
index 69f8eeac798e..45a9ce893d9f 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/index.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.js
@@ -4,6 +4,7 @@ import EditedRenderer from './EditedRenderer';
import ImageRenderer from './ImageRenderer';
import MentionHereRenderer from './MentionHereRenderer';
import MentionUserRenderer from './MentionUserRenderer';
+import NextStepsEmailRenderer from './NextStepsEmailRenderer';
import PreRenderer from './PreRenderer';
/**
@@ -20,4 +21,5 @@ export default {
pre: PreRenderer,
'mention-user': MentionUserRenderer,
'mention-here': MentionHereRenderer,
+ 'next-steps-email': NextStepsEmailRenderer,
};
diff --git a/src/components/IFrame.js b/src/components/IFrame.tsx
similarity index 80%
rename from src/components/IFrame.js
rename to src/components/IFrame.tsx
index aa85ad03ffbf..7520ad869507 100644
--- a/src/components/IFrame.js
+++ b/src/components/IFrame.tsx
@@ -1,15 +1,20 @@
-/* eslint-disable es/no-nullish-coalescing-operators */
-import PropTypes from 'prop-types';
import React, {useEffect, useState} from 'react';
-import {withOnyx} from 'react-native-onyx';
+import {OnyxEntry, withOnyx} from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';
+import {Session} from '@src/types/onyx';
-function getNewDotURL(url) {
+type OldDotIFrameOnyxProps = {
+ session: OnyxEntry;
+};
+
+type OldDotIFrameProps = OldDotIFrameOnyxProps;
+
+function getNewDotURL(url: string): string {
const urlObj = new URL(url);
const paramString = urlObj.searchParams.get('param') ?? '';
const pathname = urlObj.pathname.slice(1);
- let params;
+ let params: Record;
try {
params = JSON.parse(paramString);
} catch {
@@ -48,7 +53,7 @@ function getNewDotURL(url) {
return pathname;
}
-function getOldDotURL(url) {
+function getOldDotURL(url: string): string {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const paths = pathname.slice(1).split('/');
@@ -86,35 +91,27 @@ function getOldDotURL(url) {
return pathname;
}
-const propTypes = {
- // The session of the logged in person
- session: PropTypes.shape({
- // The email of the logged in person
- email: PropTypes.string,
-
- // The authToken of the logged in person
- authToken: PropTypes.string,
- }).isRequired,
-};
-
-function OldDotIFrame({session}) {
+function OldDotIFrame({session}: OldDotIFrameProps) {
const [oldDotURL, setOldDotURL] = useState('https://staging.expensify.com');
useEffect(() => {
setOldDotURL(`https://expensify.com.dev/${getOldDotURL(window.location.href)}`);
- window.addEventListener('message', (event) => {
+ window.addEventListener('message', (event: MessageEvent) => {
const url = event.data;
// TODO: use this value to navigate to a new path
- // eslint-disable-next-line no-unused-vars
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const newDotURL = getNewDotURL(url);
});
}, []);
useEffect(() => {
+ if (!session) {
+ return;
+ }
document.cookie = `authToken=${session.authToken}; domain=expensify.com.dev; path=/;`;
document.cookie = `email=${session.email}; domain=expensify.com.dev; path=/;`;
- }, [session.authToken, session.email]);
+ }, [session]);
return (
)}
- {!shouldShowGreenDotIndicator && optionItem.isPinned && (
+ {!shouldShowGreenDotIndicator && !hasBrickError && optionItem.isPinned && (
(phraseKey: TKey, ...phraseParameters: Localize.PhraseParameters>) => string;
/** Formats number formatted according to locale and options */
- numberFormat: (number: number, options: Intl.NumberFormatOptions) => string;
+ numberFormat: (number: number, options?: Intl.NumberFormatOptions) => string;
/** Converts a datetime into a localized string representation that's relative to current moment in time */
datetimeToRelative: (datetime: string) => string;
diff --git a/src/components/Lottie/index.js b/src/components/Lottie/index.js
deleted file mode 100644
index ec4ae54b355d..000000000000
--- a/src/components/Lottie/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import Lottie from './Lottie';
-
-export default Lottie;
diff --git a/src/components/Lottie/Lottie.tsx b/src/components/Lottie/index.tsx
similarity index 100%
rename from src/components/Lottie/Lottie.tsx
rename to src/components/Lottie/index.tsx
diff --git a/src/components/MapView/MapView.website.tsx b/src/components/MapView/MapView.website.tsx
index 7910d7f93a29..778ef66449d4 100644
--- a/src/components/MapView/MapView.website.tsx
+++ b/src/components/MapView/MapView.website.tsx
@@ -183,7 +183,7 @@ const MapView = forwardRef(
latitude: currentPosition?.latitude,
zoom: initialState.zoom,
}}
- style={StyleUtils.getTextColorStyle(theme.mapAttributionText) as React.CSSProperties}
+ style={StyleUtils.getTextColorStyle(theme.mapAttributionText)}
mapStyle={styleURL}
>
{waypoints?.map(({coordinate, markerComponent, id}) => {
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
deleted file mode 100644
index b1f6b7f7319a..000000000000
--- a/src/components/MenuItem.js
+++ /dev/null
@@ -1,412 +0,0 @@
-import ExpensiMark from 'expensify-common/lib/ExpensiMark';
-import React, {useEffect, useMemo} from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import useStyleUtils from '@hooks/useStyleUtils';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import useWindowDimensions from '@hooks/useWindowDimensions';
-import ControlSelection from '@libs/ControlSelection';
-import convertToLTR from '@libs/convertToLTR';
-import * as DeviceCapabilities from '@libs/DeviceCapabilities';
-import getButtonState from '@libs/getButtonState';
-import variables from '@styles/variables';
-import * as Session from '@userActions/Session';
-import CONST from '@src/CONST';
-import Avatar from './Avatar';
-import Badge from './Badge';
-import DisplayNames from './DisplayNames';
-import FormHelpMessage from './FormHelpMessage';
-import Hoverable from './Hoverable';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-import * as defaultWorkspaceAvatars from './Icon/WorkspaceDefaultAvatars';
-import menuItemPropTypes from './menuItemPropTypes';
-import MultipleAvatars from './MultipleAvatars';
-import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction';
-import RenderHTML from './RenderHTML';
-import SelectCircle from './SelectCircle';
-import Text from './Text';
-
-const propTypes = menuItemPropTypes;
-
-const defaultProps = {
- badgeText: undefined,
- shouldShowRightIcon: false,
- shouldShowSelectedState: false,
- shouldShowBasicTitle: false,
- shouldShowDescriptionOnTop: false,
- shouldShowHeaderTitle: false,
- shouldParseTitle: false,
- wrapperStyle: [],
- style: undefined,
- titleStyle: {},
- shouldShowTitleIcon: false,
- titleIcon: () => {},
- descriptionTextStyle: undefined,
- success: false,
- icon: undefined,
- secondaryIcon: undefined,
- iconWidth: undefined,
- iconHeight: undefined,
- description: undefined,
- iconRight: Expensicons.ArrowRight,
- iconStyles: [],
- iconFill: undefined,
- secondaryIconFill: undefined,
- focused: false,
- disabled: false,
- isSelected: false,
- subtitle: undefined,
- iconType: CONST.ICON_TYPE_ICON,
- onPress: () => {},
- onSecondaryInteraction: undefined,
- interactive: true,
- fallbackIcon: Expensicons.FallbackAvatar,
- brickRoadIndicator: '',
- floatRightAvatars: [],
- shouldStackHorizontally: false,
- avatarSize: CONST.AVATAR_SIZE.DEFAULT,
- floatRightAvatarSize: undefined,
- shouldBlockSelection: false,
- hoverAndPressStyle: [],
- furtherDetails: '',
- furtherDetailsIcon: undefined,
- isAnonymousAction: false,
- isSmallAvatarSubscriptMenu: false,
- title: '',
- numberOfLinesTitle: 1,
- shouldGreyOutWhenDisabled: true,
- error: '',
- shouldRenderAsHTML: false,
- rightLabel: '',
- rightComponent: undefined,
- shouldShowRightComponent: false,
- titleWithTooltips: [],
- shouldCheckActionAllowedOnPress: true,
-};
-
-const MenuItem = React.forwardRef((props, ref) => {
- const theme = useTheme();
- const styles = useThemeStyles();
- const StyleUtils = useStyleUtils();
- const style = StyleUtils.combineStyles(props.style, styles.popoverMenuItem);
- const {isSmallScreenWidth} = useWindowDimensions();
- const [html, setHtml] = React.useState('');
-
- const isDeleted = _.contains(style, styles.offlineFeedback.deleted);
- const descriptionVerticalMargin = props.shouldShowDescriptionOnTop ? styles.mb1 : styles.mt1;
- const titleTextStyle = StyleUtils.combineStyles(
- [
- styles.flexShrink1,
- styles.popoverMenuText,
- props.icon && !_.isArray(props.icon) && (props.avatarSize === CONST.AVATAR_SIZE.SMALL ? styles.ml2 : styles.ml3),
- props.shouldShowBasicTitle ? undefined : styles.textStrong,
- props.shouldShowHeaderTitle ? styles.textHeadlineH1 : undefined,
- props.numberOfLinesTitle !== 1 ? styles.preWrap : styles.pre,
- props.interactive && props.disabled ? {...styles.userSelectNone} : undefined,
- styles.ltr,
- isDeleted ? styles.offlineFeedback.deleted : undefined,
- props.titleTextStyle,
- ],
- props.titleStyle,
- );
- const descriptionTextStyle = StyleUtils.combineStyles([
- styles.textLabelSupporting,
- props.icon && !_.isArray(props.icon) ? styles.ml3 : undefined,
- props.title ? descriptionVerticalMargin : StyleUtils.getFontSizeStyle(variables.fontSizeNormal),
- props.descriptionTextStyle || styles.breakWord,
- isDeleted ? styles.offlineFeedback.deleted : undefined,
- ]);
-
- const fallbackAvatarSize = props.viewMode === CONST.OPTION_MODE.COMPACT ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT;
-
- const titleRef = React.useRef('');
- useEffect(() => {
- if (!props.title || (titleRef.current.length && titleRef.current === props.title) || !props.shouldParseTitle) {
- return;
- }
- const parser = new ExpensiMark();
- setHtml(parser.replace(props.title));
- titleRef.current = props.title;
- }, [props.title, props.shouldParseTitle]);
-
- const getProcessedTitle = useMemo(() => {
- let title = '';
- if (props.shouldRenderAsHTML) {
- title = convertToLTR(props.title);
- }
-
- if (props.shouldParseTitle) {
- title = html;
- }
-
- return title ? `${title}` : '';
- }, [props.title, props.shouldRenderAsHTML, props.shouldParseTitle, html]);
-
- const hasPressableRightComponent = props.iconRight || (props.rightComponent && props.shouldShowRightComponent);
-
- const renderTitleContent = () => {
- if (props.titleWithTooltips && _.isArray(props.titleWithTooltips) && props.titleWithTooltips.length > 0) {
- return (
-
- );
- }
-
- return convertToLTR(props.title);
- };
-
- const onPressAction = (e) => {
- if (props.disabled || !props.interactive) {
- return;
- }
-
- if (e && e.type === 'click') {
- e.currentTarget.blur();
- }
-
- props.onPress(e);
- };
-
- return (
-
- {(isHovered) => (
- props.shouldBlockSelection && isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
- onPressOut={ControlSelection.unblock}
- onSecondaryInteraction={props.onSecondaryInteraction}
- style={({pressed}) => [
- props.containerStyle,
- props.errorText ? styles.pb5 : {},
- style,
- !props.interactive && styles.cursorDefault,
- StyleUtils.getButtonBackgroundColorStyle(getButtonState(props.focused || isHovered, pressed, props.success, props.disabled, props.interactive), true),
- (isHovered || pressed) && props.hoverAndPressStyle,
- ...(_.isArray(props.wrapperStyle) ? props.wrapperStyle : [props.wrapperStyle]),
- props.shouldGreyOutWhenDisabled && props.disabled && styles.buttonOpacityDisabled,
- ]}
- disabled={props.disabled}
- ref={ref}
- role={CONST.ROLE.MENUITEM}
- accessibilityLabel={props.title ? props.title.toString() : ''}
- >
- {({pressed}) => (
- <>
-
- {Boolean(props.label) && (
-
-
- {props.label}
-
-
- )}
-
- {Boolean(props.icon) && _.isArray(props.icon) && (
-
- )}
- {Boolean(props.icon) && !_.isArray(props.icon) && (
-
- {props.iconType === CONST.ICON_TYPE_ICON && (
-
- )}
- {props.iconType === CONST.ICON_TYPE_WORKSPACE && (
-
- )}
- {props.iconType === CONST.ICON_TYPE_AVATAR && (
-
- )}
-
- )}
- {Boolean(props.secondaryIcon) && (
-
-
-
- )}
-
- {Boolean(props.description) && props.shouldShowDescriptionOnTop && (
-
- {props.description}
-
- )}
-
- {Boolean(props.title) && (Boolean(props.shouldRenderAsHTML) || (Boolean(props.shouldParseTitle) && Boolean(html.length))) && (
-
-
-
- )}
- {!props.shouldRenderAsHTML && !props.shouldParseTitle && Boolean(props.title) && (
-
- {renderTitleContent()}
-
- )}
- {Boolean(props.shouldShowTitleIcon) && (
-
-
-
- )}
-
- {Boolean(props.description) && !props.shouldShowDescriptionOnTop && (
-
- {props.description}
-
- )}
- {Boolean(props.error) && (
-
- {props.error}
-
- )}
- {Boolean(props.furtherDetails) && (
-
-
-
- {props.furtherDetails}
-
-
- )}
-
-
-
-
- {Boolean(props.badgeText) && (
-
- )}
- {/* Since subtitle can be of type number, we should allow 0 to be shown */}
- {(props.subtitle || props.subtitle === 0) && (
-
- {props.subtitle}
-
- )}
- {!_.isEmpty(props.floatRightAvatars) && (
-
-
-
- )}
- {Boolean(props.brickRoadIndicator) && (
-
-
-
- )}
- {Boolean(props.rightLabel) && (
-
- {props.rightLabel}
-
- )}
- {Boolean(props.shouldShowRightIcon) && (
-
-
-
- )}
- {props.shouldShowRightComponent && props.rightComponent}
- {props.shouldShowSelectedState && }
-
- {Boolean(props.errorText) && (
-
- )}
- >
- )}
-
- )}
-
- );
-});
-
-MenuItem.propTypes = propTypes;
-MenuItem.defaultProps = defaultProps;
-MenuItem.displayName = 'MenuItem';
-
-export default MenuItem;
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
new file mode 100644
index 000000000000..c2cc4abce6c5
--- /dev/null
+++ b/src/components/MenuItem.tsx
@@ -0,0 +1,598 @@
+import ExpensiMark from 'expensify-common/lib/ExpensiMark';
+import React, {FC, ForwardedRef, forwardRef, ReactNode, useEffect, useMemo, useRef, useState} from 'react';
+import {GestureResponderEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import {AnimatedStyle} from 'react-native-reanimated';
+import {SvgProps} from 'react-native-svg';
+import {ValueOf} from 'type-fest';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import ControlSelection from '@libs/ControlSelection';
+import convertToLTR from '@libs/convertToLTR';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import getButtonState from '@libs/getButtonState';
+import {AvatarSource} from '@libs/UserUtils';
+import variables from '@styles/variables';
+import * as Session from '@userActions/Session';
+import CONST from '@src/CONST';
+import {Icon as IconType} from '@src/types/onyx/OnyxCommon';
+import Avatar from './Avatar';
+import Badge from './Badge';
+import DisplayNames from './DisplayNames';
+import {DisplayNameWithTooltip} from './DisplayNames/types';
+import FormHelpMessage from './FormHelpMessage';
+import Hoverable from './Hoverable';
+import Icon, {SrcProps} from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import * as defaultWorkspaceAvatars from './Icon/WorkspaceDefaultAvatars';
+import MultipleAvatars from './MultipleAvatars';
+import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction';
+import RenderHTML from './RenderHTML';
+import SelectCircle from './SelectCircle';
+import Text from './Text';
+
+type ResponsiveProps = {
+ /** Function to fire when component is pressed */
+ onPress: (event: GestureResponderEvent | KeyboardEvent) => void;
+
+ interactive?: true;
+};
+
+type UnresponsiveProps = {
+ onPress?: undefined;
+
+ /** Whether the menu item should be interactive at all */
+ interactive: false;
+};
+
+type IconProps = {
+ /** Flag to choose between avatar image or an icon */
+ iconType: typeof CONST.ICON_TYPE_ICON;
+
+ /** Icon to display on the left side of component */
+ icon: (props: SrcProps) => ReactNode;
+};
+
+type AvatarProps = {
+ iconType: typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE;
+
+ icon: AvatarSource;
+};
+
+type NoIcon = {
+ iconType?: undefined;
+
+ icon?: undefined;
+};
+
+type MenuItemProps = (ResponsiveProps | UnresponsiveProps) &
+ (IconProps | AvatarProps | NoIcon) & {
+ /** Text to be shown as badge near the right end. */
+ badgeText?: string;
+
+ /** Used to apply offline styles to child text components */
+ style?: ViewStyle;
+
+ /** Any additional styles to apply */
+ wrapperStyle?: StyleProp;
+
+ /** Any additional styles to apply on the outer element */
+ containerStyle?: StyleProp;
+
+ /** Used to apply styles specifically to the title */
+ titleStyle?: ViewStyle;
+
+ /** Any adjustments to style when menu item is hovered or pressed */
+ hoverAndPressStyle: StyleProp>;
+
+ /** Additional styles to style the description text below the title */
+ descriptionTextStyle?: StyleProp;
+
+ /** The fill color to pass into the icon. */
+ iconFill?: string;
+
+ /** Secondary icon to display on the left side of component, right of the icon */
+ secondaryIcon?: (props: SrcProps) => React.ReactNode;
+
+ /** The fill color to pass into the secondary icon. */
+ secondaryIconFill?: string;
+
+ /** Icon Width */
+ iconWidth?: number;
+
+ /** Icon Height */
+ iconHeight?: number;
+
+ /** Any additional styles to pass to the icon container. */
+ iconStyles?: StyleProp;
+
+ /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */
+ fallbackIcon?: FC;
+
+ /** An icon to display under the main item */
+ furtherDetailsIcon?: (props: SrcProps) => ReactNode;
+
+ /** Boolean whether to display the title right icon */
+ shouldShowTitleIcon?: boolean;
+
+ /** Icon to display at right side of title */
+ titleIcon?: (props: SrcProps) => ReactNode;
+
+ /** Boolean whether to display the right icon */
+ shouldShowRightIcon?: boolean;
+
+ /** Overrides the icon for shouldShowRightIcon */
+ iconRight?: (props: SrcProps) => ReactNode;
+
+ /** Should render component on the right */
+ shouldShowRightComponent?: boolean;
+
+ /** Component to be displayed on the right */
+ rightComponent?: ReactNode;
+
+ /** A description text to show under the title */
+ description?: string;
+
+ /** Should the description be shown above the title (instead of the other way around) */
+ shouldShowDescriptionOnTop?: boolean;
+
+ /** Error to display below the title */
+ error?: string;
+
+ /** Error to display at the bottom of the component */
+ errorText?: string;
+
+ /** A boolean flag that gives the icon a green fill if true */
+ success?: boolean;
+
+ /** Whether item is focused or active */
+ focused?: boolean;
+
+ /** Should we disable this menu item? */
+ disabled?: boolean;
+
+ /** Text that appears above the title */
+ label?: string;
+
+ /** Label to be displayed on the right */
+ rightLabel?: string;
+
+ /** Text to display for the item */
+ title?: string;
+
+ /** A right-aligned subtitle for this menu option */
+ subtitle?: string | number;
+
+ /** Should the title show with normal font weight (not bold) */
+ shouldShowBasicTitle?: boolean;
+
+ /** Should we make this selectable with a checkbox */
+ shouldShowSelectedState?: boolean;
+
+ /** Whether this item is selected */
+ isSelected?: boolean;
+
+ /** Prop to identify if we should load avatars vertically instead of diagonally */
+ shouldStackHorizontally: boolean;
+
+ /** Prop to represent the size of the avatar images to be shown */
+ avatarSize?: (typeof CONST.AVATAR_SIZE)[keyof typeof CONST.AVATAR_SIZE];
+
+ /** Avatars to show on the right of the menu item */
+ floatRightAvatars?: IconType[];
+
+ /** Prop to represent the size of the float right avatar images to be shown */
+ floatRightAvatarSize?: ValueOf;
+
+ /** Affects avatar size */
+ viewMode?: ValueOf;
+
+ /** Used to truncate the text with an ellipsis after computing the text layout */
+ numberOfLinesTitle?: number;
+
+ /** Whether we should use small avatar subscript sizing the for menu item */
+ isSmallAvatarSubscriptMenu?: boolean;
+
+ /** The type of brick road indicator to show. */
+ brickRoadIndicator?: ValueOf;
+
+ /** Should render the content in HTML format */
+ shouldRenderAsHTML?: boolean;
+
+ /** Should we grey out the menu item when it is disabled? */
+ shouldGreyOutWhenDisabled?: boolean;
+
+ /** The action accept for anonymous user or not */
+ isAnonymousAction?: boolean;
+
+ /** Flag to indicate whether or not text selection should be disabled from long-pressing the menu item. */
+ shouldBlockSelection?: boolean;
+
+ /** Whether should render title as HTML or as Text */
+ shouldParseTitle?: false;
+
+ /** Should check anonymous user in onPress function */
+ shouldCheckActionAllowedOnPress?: boolean;
+
+ /** Text to display under the main item */
+ furtherDetails?: string;
+
+ /** The function that should be called when this component is LongPressed or right-clicked. */
+ onSecondaryInteraction: () => void;
+
+ /** Array of objects that map display names to their corresponding tooltip */
+ titleWithTooltips: DisplayNameWithTooltip[];
+ };
+
+function MenuItem(
+ {
+ interactive = true,
+ onPress,
+ badgeText,
+ style,
+ wrapperStyle,
+ containerStyle,
+ titleStyle,
+ hoverAndPressStyle,
+ descriptionTextStyle,
+ viewMode = CONST.OPTION_MODE.DEFAULT,
+ numberOfLinesTitle = 1,
+ icon,
+ iconFill,
+ secondaryIcon,
+ secondaryIconFill,
+ iconType = CONST.ICON_TYPE_ICON,
+ iconWidth,
+ iconHeight,
+ iconStyles,
+ fallbackIcon = Expensicons.FallbackAvatar,
+ shouldShowTitleIcon = false,
+ titleIcon,
+ shouldShowRightIcon = false,
+ iconRight = Expensicons.ArrowRight,
+ furtherDetailsIcon,
+ furtherDetails,
+ description,
+ error,
+ errorText,
+ success = false,
+ focused = false,
+ disabled = false,
+ title,
+ subtitle,
+ shouldShowBasicTitle,
+ label,
+ rightLabel,
+ shouldShowSelectedState = false,
+ isSelected = false,
+ shouldStackHorizontally = false,
+ shouldShowDescriptionOnTop = false,
+ shouldShowRightComponent = false,
+ rightComponent,
+ floatRightAvatars = [],
+ floatRightAvatarSize,
+ avatarSize = CONST.AVATAR_SIZE.DEFAULT,
+ isSmallAvatarSubscriptMenu = false,
+ brickRoadIndicator,
+ shouldRenderAsHTML = false,
+ shouldGreyOutWhenDisabled = true,
+ isAnonymousAction = false,
+ shouldBlockSelection = false,
+ shouldParseTitle = false,
+ shouldCheckActionAllowedOnPress = true,
+ onSecondaryInteraction,
+ titleWithTooltips,
+ }: MenuItemProps,
+ ref: ForwardedRef,
+) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const combinedStyle = StyleUtils.combineStyles(style ?? {}, styles.popoverMenuItem);
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const [html, setHtml] = useState('');
+ const titleRef = useRef('');
+
+ const isDeleted = style && Array.isArray(style) ? style.includes(styles.offlineFeedback.deleted) : false;
+ const descriptionVerticalMargin = shouldShowDescriptionOnTop ? styles.mb1 : styles.mt1;
+ const fallbackAvatarSize = viewMode === CONST.OPTION_MODE.COMPACT ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT;
+ const combinedTitleTextStyle = StyleUtils.combineStyles(
+ [
+ styles.flexShrink1,
+ styles.popoverMenuText,
+ // eslint-disable-next-line no-nested-ternary
+ icon && !Array.isArray(icon) ? (avatarSize === CONST.AVATAR_SIZE.SMALL ? styles.ml2 : styles.ml3) : {},
+ shouldShowBasicTitle ? {} : styles.textStrong,
+ numberOfLinesTitle !== 1 ? styles.preWrap : styles.pre,
+ interactive && disabled ? {...styles.userSelectNone} : {},
+ styles.ltr,
+ isDeleted ? styles.offlineFeedback.deleted : {},
+ ],
+ titleStyle ?? {},
+ );
+ const descriptionTextStyles = StyleUtils.combineStyles([
+ styles.textLabelSupporting,
+ icon && !Array.isArray(icon) ? styles.ml3 : {},
+ title ? descriptionVerticalMargin : StyleUtils.getFontSizeStyle(variables.fontSizeNormal),
+ (descriptionTextStyle as TextStyle) || styles.breakWord,
+ isDeleted ? styles.offlineFeedback.deleted : {},
+ ]);
+
+ useEffect(() => {
+ if (!title || (titleRef.current.length && titleRef.current === title) || !shouldParseTitle) {
+ return;
+ }
+ const parser = new ExpensiMark();
+ setHtml(parser.replace(title));
+ titleRef.current = title;
+ }, [title, shouldParseTitle]);
+
+ const getProcessedTitle = useMemo(() => {
+ let processedTitle = '';
+ if (shouldRenderAsHTML) {
+ processedTitle = title ? convertToLTR(title) : '';
+ }
+
+ if (shouldParseTitle) {
+ processedTitle = html;
+ }
+
+ return processedTitle ? `${processedTitle}` : '';
+ }, [title, shouldRenderAsHTML, shouldParseTitle, html]);
+
+ const hasPressableRightComponent = iconRight || (shouldShowRightComponent && rightComponent);
+
+ const renderTitleContent = () => {
+ if (title && titleWithTooltips && Array.isArray(titleWithTooltips) && titleWithTooltips.length > 0) {
+ return (
+
+ );
+ }
+
+ return title ? convertToLTR(title) : '';
+ };
+
+ const onPressAction = (event: GestureResponderEvent | KeyboardEvent | undefined) => {
+ if (disabled || !interactive) {
+ return;
+ }
+
+ if (event?.type === 'click') {
+ (event.currentTarget as HTMLElement).blur();
+ }
+
+ if (onPress && event) {
+ onPress(event);
+ }
+ };
+
+ return (
+
+ {(isHovered) => (
+ shouldBlockSelection && isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
+ onPressOut={ControlSelection.unblock}
+ onSecondaryInteraction={onSecondaryInteraction}
+ style={({pressed}) =>
+ [
+ containerStyle,
+ errorText ? styles.pb5 : {},
+ combinedStyle,
+ !interactive && styles.cursorDefault,
+ StyleUtils.getButtonBackgroundColorStyle(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true),
+ (isHovered || pressed) && hoverAndPressStyle,
+ ...(Array.isArray(wrapperStyle) ? wrapperStyle : [wrapperStyle]),
+ shouldGreyOutWhenDisabled && disabled && styles.buttonOpacityDisabled,
+ ] as StyleProp
+ }
+ disabled={disabled}
+ ref={ref}
+ role={CONST.ROLE.MENUITEM}
+ accessibilityLabel={title ? title.toString() : ''}
+ accessible
+ >
+ {({pressed}) => (
+ <>
+
+ {!!label && (
+
+ {label}
+
+ )}
+
+ {!!icon && Array.isArray(icon) && (
+
+ )}
+ {icon && !Array.isArray(icon) && (
+
+ {typeof icon !== 'string' && iconType === CONST.ICON_TYPE_ICON && (
+
+ )}
+ {icon && iconType === CONST.ICON_TYPE_WORKSPACE && (
+
+ )}
+ {iconType === CONST.ICON_TYPE_AVATAR && (
+
+ )}
+
+ )}
+ {secondaryIcon && (
+
+
+
+ )}
+
+ {!!description && shouldShowDescriptionOnTop && (
+
+ {description}
+
+ )}
+
+ {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && (
+
+
+
+ )}
+ {!shouldRenderAsHTML && !shouldParseTitle && !!title && (
+
+ {renderTitleContent()}
+
+ )}
+ {shouldShowTitleIcon && titleIcon && (
+
+
+
+ )}
+
+ {!!description && !shouldShowDescriptionOnTop && (
+
+ {description}
+
+ )}
+ {!!error && (
+
+ {error}
+
+ )}
+ {furtherDetailsIcon && !!furtherDetails && (
+
+
+
+ {furtherDetails}
+
+
+ )}
+
+
+
+
+ {badgeText && (
+
+ )}
+ {/* Since subtitle can be of type number, we should allow 0 to be shown */}
+ {(subtitle ?? subtitle === 0) && (
+
+ {subtitle}
+
+ )}
+ {floatRightAvatars?.length > 0 && (
+
+
+
+ )}
+ {!!brickRoadIndicator && (
+
+
+
+ )}
+ {!!rightLabel && (
+
+ {rightLabel}
+
+ )}
+ {shouldShowRightIcon && (
+
+
+
+ )}
+ {shouldShowRightComponent && rightComponent}
+ {shouldShowSelectedState && }
+
+ {!!errorText && (
+
+ )}
+ >
+ )}
+
+ )}
+
+ );
+}
+
+MenuItem.displayName = 'MenuItem';
+
+export type {MenuItemProps};
+export default forwardRef(MenuItem);
diff --git a/src/components/MenuItemWithTopDescription.js b/src/components/MenuItemWithTopDescription.js
deleted file mode 100644
index 8215b7eb3a19..000000000000
--- a/src/components/MenuItemWithTopDescription.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import MenuItem from './MenuItem';
-import menuItemPropTypes from './menuItemPropTypes';
-
-const propTypes = menuItemPropTypes;
-
-function MenuItemWithTopDescription(props) {
- return (
-
- );
-}
-
-MenuItemWithTopDescription.propTypes = propTypes;
-MenuItemWithTopDescription.displayName = 'MenuItemWithTopDescription';
-
-const MenuItemWithTopDescriptionWithRef = React.forwardRef((props, ref) => (
-
-));
-
-MenuItemWithTopDescriptionWithRef.displayName = 'MenuItemWithTopDescriptionWithRef';
-
-export default MenuItemWithTopDescriptionWithRef;
diff --git a/src/components/MenuItemWithTopDescription.tsx b/src/components/MenuItemWithTopDescription.tsx
new file mode 100644
index 000000000000..48fa95ecf637
--- /dev/null
+++ b/src/components/MenuItemWithTopDescription.tsx
@@ -0,0 +1,20 @@
+import React, {ForwardedRef, forwardRef} from 'react';
+import {View} from 'react-native';
+import MenuItem from './MenuItem';
+import type {MenuItemProps} from './MenuItem';
+
+function MenuItemWithTopDescription(props: MenuItemProps, ref: ForwardedRef) {
+ return (
+
+ );
+}
+
+MenuItemWithTopDescription.displayName = 'MenuItemWithTopDescription';
+
+export default forwardRef(MenuItemWithTopDescription);
diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js
index f73ddef0dfa0..3e6ce7e5be52 100644
--- a/src/components/MoneyReportHeader.js
+++ b/src/components/MoneyReportHeader.js
@@ -4,16 +4,22 @@ import React, {useMemo} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import GoogleMeetIcon from '@assets/images/google-meet.svg';
+import ZoomIcon from '@assets/images/zoom-icon.svg';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import compose from '@libs/compose';
import * as CurrencyUtils from '@libs/CurrencyUtils';
+import * as HeaderUtils from '@libs/HeaderUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import iouReportPropTypes from '@pages/iouReportPropTypes';
import nextStepPropTypes from '@pages/nextStepPropTypes';
import reportPropTypes from '@pages/reportPropTypes';
import * as IOU from '@userActions/IOU';
+import * as Link from '@userActions/Link';
+import * as Session from '@userActions/Session';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -70,6 +76,7 @@ const defaultProps = {
function MoneyReportHeader({session, personalDetails, policy, chatReport, nextStep, report: moneyRequestReport, isSmallScreenWidth}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const {windowWidth} = useWindowDimensions();
const reimbursableTotal = ReportUtils.getMoneyRequestReimbursableTotal(moneyRequestReport);
const isApproved = ReportUtils.isReportApproved(moneyRequestReport);
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
@@ -101,6 +108,24 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableTotal, moneyRequestReport.currency);
const isMoreContentShown = shouldShowNextSteps || (shouldShowAnyButton && isSmallScreenWidth);
+ const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(moneyRequestReport)];
+ if (!ReportUtils.isArchivedRoom(chatReport)) {
+ threeDotsMenuItems.push({
+ icon: ZoomIcon,
+ text: translate('videoChatButtonAndMenu.zoom'),
+ onSelected: Session.checkIfActionIsAllowed(() => {
+ Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL);
+ }),
+ });
+ threeDotsMenuItems.push({
+ icon: GoogleMeetIcon,
+ text: translate('videoChatButtonAndMenu.googleMeet'),
+ onSelected: Session.checkIfActionIsAllowed(() => {
+ Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL);
+ }),
+ });
+ }
+
return (
Navigation.goBack(ROUTES.HOME, false, true)}
// Shows border if no buttons or next steps are showing below the header
shouldShowBorderBottom={!(shouldShowAnyButton && isSmallScreenWidth) && !(shouldShowNextSteps && !isSmallScreenWidth)}
+ shouldShowThreeDotsButton
+ threeDotsMenuItems={threeDotsMenuItems}
+ threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)}
>
{shouldShowSettlementButton && !isSmallScreenWidth && (
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js
index 1a8c395ddd8b..6118523d813d 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.js
@@ -141,7 +141,6 @@ function OptionRow(props) {
: props.backgroundColor;
const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
const isMultipleParticipant = lodashGet(props.option, 'participantsList.length', 0) > 1;
- const defaultSubscriptSize = props.option.isExpenseRequest ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT;
// We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(
@@ -208,7 +207,7 @@ function OptionRow(props) {
mainAvatar={props.option.icons[0]}
secondaryAvatar={props.option.icons[1]}
backgroundColor={hovered ? hoveredBackgroundColor : subscriptColor}
- size={defaultSubscriptSize}
+ size={CONST.AVATAR_SIZE.DEFAULT}
/>
) : (
- {children}
+ {children as ReactNode}
);
}
diff --git a/src/components/PressableWithSecondaryInteraction/types.ts b/src/components/PressableWithSecondaryInteraction/types.ts
index cf286afcb63a..bf999e9692b5 100644
--- a/src/components/PressableWithSecondaryInteraction/types.ts
+++ b/src/components/PressableWithSecondaryInteraction/types.ts
@@ -1,54 +1,53 @@
-import {GestureResponderEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
+import type {GestureResponderEvent} from 'react-native';
import {PressableWithFeedbackProps} from '@components/Pressable/PressableWithFeedback';
-import ChildrenProps from '@src/types/utils/ChildrenProps';
-
-type PressableWithSecondaryInteractionProps = PressableWithFeedbackProps &
- ChildrenProps & {
- /** The function that should be called when this pressable is pressed */
- onPress: (event?: GestureResponderEvent) => void;
-
- /** The function that should be called when this pressable is pressedIn */
- onPressIn?: (event?: GestureResponderEvent) => void;
-
- /** The function that should be called when this pressable is pressedOut */
- onPressOut?: (event?: GestureResponderEvent) => void;
-
- /**
- * The function that should be called when this pressable is LongPressed or right-clicked.
- *
- * This function should be stable, preferably wrapped in a `useCallback` so that it does not
- * cause several re-renders.
- */
- onSecondaryInteraction?: (event: GestureResponderEvent | MouseEvent) => void;
-
- /** Prevent the default ContextMenu on web/Desktop */
- preventDefaultContextMenu?: boolean;
-
- /** Use Text instead of Pressable to create inline layout.
- * It has few limitations in comparison to Pressable.
- *
- * - No support for delayLongPress.
- * - No support for pressIn and pressOut events.
- * - No support for opacity
- *
- * Note: Web uses styling instead of Text due to no support of LongPress. Thus above pointers are not valid for web.
- */
- inline?: boolean;
-
- /** Disable focus trap for the element on secondary interaction */
- withoutFocusOnSecondaryInteraction?: boolean;
-
- /** Opacity to reduce to when active */
- activeOpacity?: number;
-
- /** Used to apply styles to the Pressable */
- style?: StyleProp;
-
- /** Whether the long press with hover behavior is enabled */
- enableLongPressWithHover?: boolean;
-
- /** Whether the text has a gray highlights on press down (for IOS only) */
- suppressHighlighting?: boolean;
- };
+import type {ParsableStyle} from '@styles/utils/types';
+
+type PressableWithSecondaryInteractionProps = PressableWithFeedbackProps & {
+ /** The function that should be called when this pressable is pressed */
+ onPress: (event?: GestureResponderEvent) => void;
+
+ /** The function that should be called when this pressable is pressedIn */
+ onPressIn?: (event?: GestureResponderEvent) => void;
+
+ /** The function that should be called when this pressable is pressedOut */
+ onPressOut?: (event?: GestureResponderEvent) => void;
+
+ /**
+ * The function that should be called when this pressable is LongPressed or right-clicked.
+ *
+ * This function should be stable, preferably wrapped in a `useCallback` so that it does not
+ * cause several re-renders.
+ */
+ onSecondaryInteraction?: (event: GestureResponderEvent | MouseEvent) => void;
+
+ /** Prevent the default ContextMenu on web/Desktop */
+ preventDefaultContextMenu?: boolean;
+
+ /** Use Text instead of Pressable to create inline layout.
+ * It has few limitations in comparison to Pressable.
+ *
+ * - No support for delayLongPress.
+ * - No support for pressIn and pressOut events.
+ * - No support for opacity
+ *
+ * Note: Web uses styling instead of Text due to no support of LongPress. Thus above pointers are not valid for web.
+ */
+ inline?: boolean;
+
+ /** Disable focus trap for the element on secondary interaction */
+ withoutFocusOnSecondaryInteraction?: boolean;
+
+ /** Opacity to reduce to when active */
+ activeOpacity?: number;
+
+ /** Used to apply styles to the Pressable */
+ style?: ParsableStyle;
+
+ /** Whether the long press with hover behavior is enabled */
+ enableLongPressWithHover?: boolean;
+
+ /** Whether the text has a gray highlights on press down (for IOS only) */
+ suppressHighlighting?: boolean;
+};
export default PressableWithSecondaryInteractionProps;
diff --git a/src/components/ReportActionItem/RenameAction.js b/src/components/ReportActionItem/RenameAction.js
deleted file mode 100644
index 52039b7b593b..000000000000
--- a/src/components/ReportActionItem/RenameAction.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React from 'react';
-import Text from '@components/Text';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
-
-const propTypes = {
- /** All the data of the action */
- action: PropTypes.shape(reportActionPropTypes).isRequired,
-
- ...withLocalizePropTypes,
- ...withCurrentUserPersonalDetailsPropTypes,
-};
-
-function RenameAction(props) {
- const styles = useThemeStyles();
- const currentUserAccountID = lodashGet(props.currentUserPersonalDetails, 'accountID', '');
- const userDisplayName = lodashGet(props.action, ['person', 0, 'text']);
- const actorAccountID = lodashGet(props.action, 'actorAccountID', '');
- const displayName = actorAccountID === currentUserAccountID ? `${props.translate('common.you')}` : `${userDisplayName}`;
- const oldName = lodashGet(props.action, 'originalMessage.oldName', '');
- const newName = lodashGet(props.action, 'originalMessage.newName', '');
-
- return (
-
- {displayName}
- {props.translate('newRoomPage.renamedRoomAction', {oldName, newName})}
-
- );
-}
-
-RenameAction.propTypes = propTypes;
-RenameAction.displayName = 'RenameAction';
-
-export default compose(withLocalize, withCurrentUserPersonalDetails)(RenameAction);
diff --git a/src/components/ReportActionItem/RenameAction.tsx b/src/components/ReportActionItem/RenameAction.tsx
new file mode 100644
index 000000000000..ef9317ecac0e
--- /dev/null
+++ b/src/components/ReportActionItem/RenameAction.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import Text from '@components/Text';
+import withCurrentUserPersonalDetails, {type WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import type {ReportAction} from '@src/types/onyx';
+
+type RenameActionProps = WithCurrentUserPersonalDetailsProps & {
+ /** All the data of the action */
+ action: ReportAction;
+};
+
+function RenameAction({currentUserPersonalDetails, action}: RenameActionProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const currentUserAccountID = currentUserPersonalDetails.accountID ?? '';
+ const userDisplayName = action.person?.[0]?.text;
+ const actorAccountID = action.actorAccountID ?? '';
+ const displayName = actorAccountID === currentUserAccountID ? `${translate('common.you')}` : `${userDisplayName}`;
+ const originalMessage = action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED ? action.originalMessage : null;
+ const oldName = originalMessage?.oldName ?? '';
+ const newName = originalMessage?.newName ?? '';
+
+ return (
+
+ {displayName}
+ {translate('newRoomPage.renamedRoomAction', {oldName, newName})}
+
+ );
+}
+
+RenameAction.displayName = 'RenameAction';
+
+export default withCurrentUserPersonalDetails(RenameAction);
diff --git a/src/components/SingleOptionSelector.js b/src/components/SingleOptionSelector.tsx
similarity index 68%
rename from src/components/SingleOptionSelector.js
rename to src/components/SingleOptionSelector.tsx
index 15cf83116bd1..bc912aacf41d 100644
--- a/src/components/SingleOptionSelector.js
+++ b/src/components/SingleOptionSelector.tsx
@@ -1,42 +1,35 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
-import _ from 'underscore';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
+import {TranslationPaths} from '@src/languages/types';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import SelectCircle from './SelectCircle';
import Text from './Text';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-const propTypes = {
+type Item = {
+ key: string;
+ label: TranslationPaths;
+};
+
+type SingleOptionSelectorProps = {
/** Array of options for the selector, key is a unique identifier, label is a localize key that will be translated and displayed */
- options: PropTypes.arrayOf(
- PropTypes.shape({
- key: PropTypes.string,
- label: PropTypes.string,
- }),
- ),
+ options?: Item[];
/** Key of the option that is currently selected */
- selectedOptionKey: PropTypes.string,
+ selectedOptionKey?: string;
/** Function to be called when an option is selected */
- onSelectOption: PropTypes.func,
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- options: [],
- selectedOptionKey: undefined,
- onSelectOption: () => {},
+ onSelectOption?: (item: Item) => void;
};
-function SingleOptionSelector({options, selectedOptionKey, onSelectOption, translate}) {
+function SingleOptionSelector({options = [], selectedOptionKey, onSelectOption = () => {}}: SingleOptionSelectorProps) {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
return (
- {_.map(options, (option) => (
+ {options.map((option) => (
@@ -26,9 +23,9 @@ function ExpiredValidateCodeModal(props) {
src={Illustrations.ToddBehindCloud}
/>
- {props.translate('validateCodeModal.expiredCodeTitle')}
+ {translate('validateCodeModal.expiredCodeTitle')}
- {props.translate('validateCodeModal.expiredCodeDescription')}
+ {translate('validateCodeModal.expiredCodeDescription')}
@@ -43,6 +40,5 @@ function ExpiredValidateCodeModal(props) {
);
}
-ExpiredValidateCodeModal.propTypes = propTypes;
ExpiredValidateCodeModal.displayName = 'ExpiredValidateCodeModal';
-export default withLocalize(ExpiredValidateCodeModal);
+export default ExpiredValidateCodeModal;
diff --git a/src/components/ValidateCode/JustSignedInModal.js b/src/components/ValidateCode/JustSignedInModal.tsx
similarity index 60%
rename from src/components/ValidateCode/JustSignedInModal.js
rename to src/components/ValidateCode/JustSignedInModal.tsx
index fedb92c49ee3..19e67b0c56fe 100644
--- a/src/components/ValidateCode/JustSignedInModal.js
+++ b/src/components/ValidateCode/JustSignedInModal.tsx
@@ -1,42 +1,38 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
-const propTypes = {
- ...withLocalizePropTypes,
-
+type JustSignedInModalProps = {
/** Whether the 2FA is needed to get fully authenticated. */
- is2FARequired: PropTypes.bool.isRequired,
+ is2FARequired: boolean;
};
-function JustSignedInModal(props) {
+function JustSignedInModal({is2FARequired}: JustSignedInModalProps) {
const theme = useTheme();
const styles = useThemeStyles();
+ const {translate} = useLocalize();
return (
- {props.translate(props.is2FARequired ? 'validateCodeModal.tfaRequiredTitle' : 'validateCodeModal.successfulSignInTitle')}
+ {translate(is2FARequired ? 'validateCodeModal.tfaRequiredTitle' : 'validateCodeModal.successfulSignInTitle')}
-
- {props.translate(props.is2FARequired ? 'validateCodeModal.tfaRequiredDescription' : 'validateCodeModal.successfulSignInDescription')}
-
+ {translate(is2FARequired ? 'validateCodeModal.tfaRequiredDescription' : 'validateCodeModal.successfulSignInDescription')}
@@ -51,7 +47,6 @@ function JustSignedInModal(props) {
);
}
-JustSignedInModal.propTypes = propTypes;
JustSignedInModal.displayName = 'JustSignedInModal';
-export default withLocalize(JustSignedInModal);
+export default JustSignedInModal;
diff --git a/src/components/ValidateCode/ValidateCodeModal.js b/src/components/ValidateCode/ValidateCodeModal.tsx
similarity index 56%
rename from src/components/ValidateCode/ValidateCodeModal.js
rename to src/components/ValidateCode/ValidateCodeModal.tsx
index a2fea513f851..ce3022484345 100644
--- a/src/components/ValidateCode/ValidateCodeModal.js
+++ b/src/components/ValidateCode/ValidateCodeModal.tsx
@@ -1,47 +1,36 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import {compose} from 'underscore';
+import {OnyxEntry, withOnyx} from 'react-native-onyx';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import * as Session from '@userActions/Session';
import ONYXKEYS from '@src/ONYXKEYS';
+import {Session as SessionType} from '@src/types/onyx';
-const propTypes = {
- /** Code to display. */
- code: PropTypes.string.isRequired,
-
- /** The ID of the account to which the code belongs. */
- accountID: PropTypes.string.isRequired,
-
+type ValidateCodeModalOnyxProps = {
/** Session of currently logged in user */
- session: PropTypes.shape({
- /** Currently logged in user authToken */
- authToken: PropTypes.string,
- }),
-
- ...withLocalizePropTypes,
+ session: OnyxEntry;
};
-const defaultProps = {
- session: {
- authToken: null,
- },
+type ValidateCodeModalProps = ValidateCodeModalOnyxProps & {
+ /** Code to display. */
+ code: string;
+ /** The ID of the account to which the code belongs. */
+ accountID: number;
};
-function ValidateCodeModal(props) {
+function ValidateCodeModal({code, accountID, session = {}}: ValidateCodeModalProps) {
const theme = useTheme();
const styles = useThemeStyles();
- const signInHere = useCallback(() => Session.signInWithValidateCode(props.accountID, props.code), [props.accountID, props.code]);
+ const signInHere = useCallback(() => Session.signInWithValidateCode(accountID, code), [accountID, code]);
+ const {translate} = useLocalize();
return (
@@ -53,20 +42,20 @@ function ValidateCodeModal(props) {
src={Illustrations.MagicCode}
/>
- {props.translate('validateCodeModal.title')}
+ {translate('validateCodeModal.title')}
-
- {props.translate('validateCodeModal.description')}
- {!lodashGet(props, 'session.authToken', null) && (
+
+ {translate('validateCodeModal.description')}
+ {!session?.authToken && (
<>
- {props.translate('validateCodeModal.or')} {props.translate('validateCodeModal.signInHere')}
+ {translate('validateCodeModal.or')} {translate('validateCodeModal.signInHere')}
>
)}
- {props.shouldShowSignInHere ? '!' : '.'}
+ .
- {props.code}
+ {code}
@@ -81,13 +70,8 @@ function ValidateCodeModal(props) {
);
}
-ValidateCodeModal.propTypes = propTypes;
-ValidateCodeModal.defaultProps = defaultProps;
ValidateCodeModal.displayName = 'ValidateCodeModal';
-export default compose(
- withLocalize,
- withOnyx({
- session: {key: ONYXKEYS.SESSION},
- }),
-)(ValidateCodeModal);
+export default withOnyx({
+ session: {key: ONYXKEYS.SESSION},
+})(ValidateCodeModal);
diff --git a/src/components/withCurrentUserPersonalDetails.tsx b/src/components/withCurrentUserPersonalDetails.tsx
index a97067c32c72..289e2254952f 100644
--- a/src/components/withCurrentUserPersonalDetails.tsx
+++ b/src/components/withCurrentUserPersonalDetails.tsx
@@ -37,7 +37,7 @@ export default function (accountPersonalDetails ? {...accountPersonalDetails, accountID} : {}),
+ () => (accountPersonalDetails ? {...accountPersonalDetails, accountID} : {}) as CurrentUserPersonalDetails,
[accountPersonalDetails, accountID],
);
return (
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 872e451452ba..fec747ae253b 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -594,11 +594,10 @@ export default {
noReimbursableExpenses: 'This report has an invalid amount',
pendingConversionMessage: "Total will update when you're back online",
changedTheRequest: 'changed the request',
- setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `set the ${valueName} to ${newValueToDisplay}`,
+ setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `the ${valueName} to ${newValueToDisplay}`,
setTheDistance: ({newDistanceToDisplay, newAmountToDisplay}: SetTheDistanceParams) => `set the distance to ${newDistanceToDisplay}, which set the amount to ${newAmountToDisplay}`,
- removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) => `removed the ${valueName} (previously ${oldValueToDisplay})`,
- updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) =>
- `changed the ${valueName} to ${newValueToDisplay} (previously ${oldValueToDisplay})`,
+ removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) => `the ${valueName} (previously ${oldValueToDisplay})`,
+ updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) => `the ${valueName} to ${newValueToDisplay} (previously ${oldValueToDisplay})`,
updatedTheDistance: ({newDistanceToDisplay, oldDistanceToDisplay, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceParams) =>
`changed the distance to ${newDistanceToDisplay} (previously ${oldDistanceToDisplay}), which updated the amount to ${newAmountToDisplay} (previously ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`,
@@ -622,6 +621,9 @@ export default {
},
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`,
enableWallet: 'Enable Wallet',
+ set: 'set',
+ changed: 'changed',
+ removed: 'removed',
},
notificationPreferencesPage: {
header: 'Notification preferences',
@@ -1897,7 +1899,7 @@ export default {
parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `From ${rootReportName}${workspaceName ? ` in ${workspaceName}` : ''}`,
},
qrCodes: {
- copyUrlToClipboard: 'Copy URL to clipboard',
+ copy: 'Copy',
copied: 'Copied!',
},
moderation: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 44f75e351437..73ee616d57bb 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -587,13 +587,12 @@ export default {
noReimbursableExpenses: 'El importe de este informe no es válido',
pendingConversionMessage: 'El total se actualizará cuando estés online',
changedTheRequest: 'cambió la solicitud',
- setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `estableció ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay}`,
+ setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay}`,
setTheDistance: ({newDistanceToDisplay, newAmountToDisplay}: SetTheDistanceParams) =>
`estableció la distancia a ${newDistanceToDisplay}, lo que estableció el importe a ${newAmountToDisplay}`,
- removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) =>
- `eliminó ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} (previamente ${oldValueToDisplay})`,
+ removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) => `${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} (previamente ${oldValueToDisplay})`,
updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) =>
- `cambió ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay} (previamente ${oldValueToDisplay})`,
+ `${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay} (previamente ${oldValueToDisplay})`,
updatedTheDistance: ({newDistanceToDisplay, oldDistanceToDisplay, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceParams) =>
`cambió la distancia a ${newDistanceToDisplay} (previamente ${oldDistanceToDisplay}), lo que cambió el importe a ${newAmountToDisplay} (previamente ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
@@ -617,6 +616,9 @@ export default {
},
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su Billetera`,
enableWallet: 'Habilitar Billetera',
+ set: 'estableció',
+ changed: 'cambió',
+ removed: 'eliminó',
},
notificationPreferencesPage: {
header: 'Preferencias de avisos',
@@ -2383,7 +2385,7 @@ export default {
parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `De ${rootReportName}${workspaceName ? ` en ${workspaceName}` : ''}`,
},
qrCodes: {
- copyUrlToClipboard: 'Copiar URL al portapapeles',
+ copy: 'Copiar',
copied: '¡Copiado!',
},
moderation: {
diff --git a/src/libs/ComposerUtils/types.ts b/src/libs/ComposerUtils/types.ts
deleted file mode 100644
index a417d951ff51..000000000000
--- a/src/libs/ComposerUtils/types.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-type ComposerProps = {
- isFullComposerAvailable: boolean;
- setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
-};
-
-export default ComposerProps;
diff --git a/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts b/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts
index 761abb8c9c8f..64c526484760 100644
--- a/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts
+++ b/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts
@@ -1,5 +1,5 @@
+import {ComposerProps} from '@components/Composer/types';
import CONST from '@src/CONST';
-import ComposerProps from './types';
/**
* Update isFullComposerAvailable if needed
@@ -8,7 +8,7 @@ import ComposerProps from './types';
function updateIsFullComposerAvailable(props: ComposerProps, numberOfLines: number) {
const isFullComposerAvailable = numberOfLines >= CONST.COMPOSER.FULL_COMPOSER_MIN_LINES;
if (isFullComposerAvailable !== props.isFullComposerAvailable) {
- props.setIsFullComposerAvailable(isFullComposerAvailable);
+ props.setIsFullComposerAvailable?.(isFullComposerAvailable);
}
}
diff --git a/src/libs/ComposerUtils/updateNumberOfLines/types.ts b/src/libs/ComposerUtils/updateNumberOfLines/types.ts
index c121eaaef319..2fe1465fa194 100644
--- a/src/libs/ComposerUtils/updateNumberOfLines/types.ts
+++ b/src/libs/ComposerUtils/updateNumberOfLines/types.ts
@@ -1,5 +1,5 @@
import {NativeSyntheticEvent, TextInputContentSizeChangeEventData} from 'react-native';
-import ComposerProps from '@libs/ComposerUtils/types';
+import {ComposerProps} from '@components/Composer/types';
import {type ThemeStyles} from '@styles/index';
type UpdateNumberOfLines = (props: ComposerProps, event: NativeSyntheticEvent, styles: ThemeStyles) => void;
diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts
index 57a9d773cc9d..9d77858cb2a9 100644
--- a/src/libs/GetPhysicalCardUtils.ts
+++ b/src/libs/GetPhysicalCardUtils.ts
@@ -1,4 +1,3 @@
-import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import {Login} from '@src/types/onyx';
import Navigation from './Navigation/Navigation';
@@ -82,7 +81,7 @@ function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDe
}
// Redirect the user if he's not allowed to be on the current step
- Navigation.navigate(expectedRoute, CONST.NAVIGATION.ACTION_TYPE.REPLACE);
+ Navigation.goBack(expectedRoute);
}
/**
diff --git a/src/libs/LocalePhoneNumber.ts b/src/libs/LocalePhoneNumber.ts
index e50f3be87c84..933aa7937560 100644
--- a/src/libs/LocalePhoneNumber.ts
+++ b/src/libs/LocalePhoneNumber.ts
@@ -1,7 +1,7 @@
-import {parsePhoneNumber} from 'awesome-phonenumber';
import Str from 'expensify-common/lib/str';
import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';
+import {parsePhoneNumber} from './PhoneNumber';
let countryCodeByIP: number;
Onyx.connect({
diff --git a/src/libs/LoginUtils.ts b/src/libs/LoginUtils.ts
index 742f9bfe16ce..dca84b9b11e0 100644
--- a/src/libs/LoginUtils.ts
+++ b/src/libs/LoginUtils.ts
@@ -1,9 +1,9 @@
-import {parsePhoneNumber} from 'awesome-phonenumber';
import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST';
import Str from 'expensify-common/lib/str';
import Onyx from 'react-native-onyx';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import {parsePhoneNumber} from './PhoneNumber';
let countryCodeByIP: number;
Onyx.connect({
diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts
new file mode 100644
index 000000000000..c3d9b0a85339
--- /dev/null
+++ b/src/libs/ModifiedExpenseMessage.ts
@@ -0,0 +1,226 @@
+import {format} from 'date-fns';
+import Onyx from 'react-native-onyx';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import {PolicyTags, ReportAction} from '@src/types/onyx';
+import * as CurrencyUtils from './CurrencyUtils';
+import * as Localize from './Localize';
+import * as PolicyUtils from './PolicyUtils';
+import * as ReportUtils from './ReportUtils';
+import {ExpenseOriginalMessage} from './ReportUtils';
+
+let allPolicyTags: Record = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.POLICY_TAGS,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ if (!value) {
+ allPolicyTags = {};
+ return;
+ }
+ allPolicyTags = value;
+ },
+});
+
+/**
+ * Builds the partial message fragment for a modified field on the expense.
+ */
+function buildMessageFragmentForValue(
+ newValue: string,
+ oldValue: string,
+ valueName: string,
+ valueInQuotes: boolean,
+ setFragments: string[],
+ removalFragments: string[],
+ changeFragments: string[],
+ shouldConvertToLowercase = true,
+) {
+ const newValueToDisplay = valueInQuotes ? `"${newValue}"` : newValue;
+ const oldValueToDisplay = valueInQuotes ? `"${oldValue}"` : oldValue;
+ const displayValueName = shouldConvertToLowercase ? valueName.toLowerCase() : valueName;
+
+ if (!oldValue) {
+ const fragment = Localize.translateLocal('iou.setTheRequest', {valueName: displayValueName, newValueToDisplay});
+ setFragments.push(fragment);
+ } else if (!newValue) {
+ const fragment = Localize.translateLocal('iou.removedTheRequest', {valueName: displayValueName, oldValueToDisplay});
+ removalFragments.push(fragment);
+ } else {
+ const fragment = Localize.translateLocal('iou.updatedTheRequest', {valueName: displayValueName, newValueToDisplay, oldValueToDisplay});
+ changeFragments.push(fragment);
+ }
+}
+
+/**
+ * Get the message line for a modified expense.
+ */
+function getMessageLine(prefix: string, messageFragments: string[]): string {
+ if (messageFragments.length === 0) {
+ return '';
+ }
+ return messageFragments.reduce((acc, value, index) => {
+ if (index === messageFragments.length - 1) {
+ if (messageFragments.length === 1) {
+ return `${acc} ${value}.`;
+ }
+ if (messageFragments.length === 2) {
+ return `${acc} ${Localize.translateLocal('common.and')} ${value}.`;
+ }
+ return `${acc}, ${Localize.translateLocal('common.and')} ${value}.`;
+ }
+ if (index === 0) {
+ return `${acc} ${value}`;
+ }
+ return `${acc}, ${value}`;
+ }, prefix);
+}
+
+function getForDistanceRequest(newDistance: string, oldDistance: string, newAmount: string, oldAmount: string): string {
+ if (!oldDistance) {
+ return Localize.translateLocal('iou.setTheDistance', {newDistanceToDisplay: newDistance, newAmountToDisplay: newAmount});
+ }
+ return Localize.translateLocal('iou.updatedTheDistance', {
+ newDistanceToDisplay: newDistance,
+ oldDistanceToDisplay: oldDistance,
+ newAmountToDisplay: newAmount,
+ oldAmountToDisplay: oldAmount,
+ });
+}
+
+/**
+ * Get the report action message when expense has been modified.
+ *
+ * ModifiedExpense::getNewDotComment in Web-Expensify should match this.
+ * If we change this function be sure to update the backend as well.
+ */
+function getForReportAction(reportAction: ReportAction): string {
+ if (reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) {
+ return '';
+ }
+ const reportActionOriginalMessage = reportAction.originalMessage as ExpenseOriginalMessage | undefined;
+ const policyID = ReportUtils.getReportPolicyID(reportAction.reportID) ?? '';
+ const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {};
+ const policyTagListName = PolicyUtils.getTagListName(policyTags) || Localize.translateLocal('common.tag');
+
+ const removalFragments: string[] = [];
+ const setFragments: string[] = [];
+ const changeFragments: string[] = [];
+
+ const hasModifiedAmount =
+ reportActionOriginalMessage &&
+ 'oldAmount' in reportActionOriginalMessage &&
+ 'oldCurrency' in reportActionOriginalMessage &&
+ 'amount' in reportActionOriginalMessage &&
+ 'currency' in reportActionOriginalMessage;
+
+ const hasModifiedMerchant = reportActionOriginalMessage && 'oldMerchant' in reportActionOriginalMessage && 'merchant' in reportActionOriginalMessage;
+ if (hasModifiedAmount) {
+ const oldCurrency = reportActionOriginalMessage?.oldCurrency ?? '';
+ const oldAmount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage?.oldAmount ?? 0, oldCurrency);
+
+ const currency = reportActionOriginalMessage?.currency ?? '';
+ const amount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage?.amount ?? 0, currency);
+
+ // Only Distance edits should modify amount and merchant (which stores distance) in a single transaction.
+ // We check the merchant is in distance format (includes @) as a sanity check
+ if (hasModifiedMerchant && (reportActionOriginalMessage?.merchant ?? '').includes('@')) {
+ return getForDistanceRequest(reportActionOriginalMessage?.merchant ?? '', reportActionOriginalMessage?.oldMerchant ?? '', amount, oldAmount);
+ }
+
+ buildMessageFragmentForValue(amount, oldAmount, Localize.translateLocal('iou.amount'), false, setFragments, removalFragments, changeFragments);
+ }
+
+ const hasModifiedComment = reportActionOriginalMessage && 'oldComment' in reportActionOriginalMessage && 'newComment' in reportActionOriginalMessage;
+ if (hasModifiedComment) {
+ buildMessageFragmentForValue(
+ reportActionOriginalMessage?.newComment ?? '',
+ reportActionOriginalMessage?.oldComment ?? '',
+ Localize.translateLocal('common.description'),
+ true,
+ setFragments,
+ removalFragments,
+ changeFragments,
+ );
+ }
+
+ const hasModifiedCreated = reportActionOriginalMessage && 'oldCreated' in reportActionOriginalMessage && 'created' in reportActionOriginalMessage;
+ if (hasModifiedCreated) {
+ // Take only the YYYY-MM-DD value as the original date includes timestamp
+ let formattedOldCreated: Date | string = new Date(reportActionOriginalMessage?.oldCreated ? reportActionOriginalMessage.oldCreated : 0);
+ formattedOldCreated = format(formattedOldCreated, CONST.DATE.FNS_FORMAT_STRING);
+ buildMessageFragmentForValue(
+ reportActionOriginalMessage?.created ?? '',
+ formattedOldCreated,
+ Localize.translateLocal('common.date'),
+ false,
+ setFragments,
+ removalFragments,
+ changeFragments,
+ );
+ }
+
+ if (hasModifiedMerchant) {
+ buildMessageFragmentForValue(
+ reportActionOriginalMessage?.merchant ?? '',
+ reportActionOriginalMessage?.oldMerchant ?? '',
+ Localize.translateLocal('common.merchant'),
+ true,
+ setFragments,
+ removalFragments,
+ changeFragments,
+ );
+ }
+
+ const hasModifiedCategory = reportActionOriginalMessage && 'oldCategory' in reportActionOriginalMessage && 'category' in reportActionOriginalMessage;
+ if (hasModifiedCategory) {
+ buildMessageFragmentForValue(
+ reportActionOriginalMessage?.category ?? '',
+ reportActionOriginalMessage?.oldCategory ?? '',
+ Localize.translateLocal('common.category'),
+ true,
+ setFragments,
+ removalFragments,
+ changeFragments,
+ );
+ }
+
+ const hasModifiedTag = reportActionOriginalMessage && 'oldTag' in reportActionOriginalMessage && 'tag' in reportActionOriginalMessage;
+ if (hasModifiedTag) {
+ buildMessageFragmentForValue(
+ reportActionOriginalMessage?.tag ?? '',
+ reportActionOriginalMessage?.oldTag ?? '',
+ policyTagListName,
+ true,
+ setFragments,
+ removalFragments,
+ changeFragments,
+ policyTagListName === Localize.translateLocal('common.tag'),
+ );
+ }
+
+ const hasModifiedBillable = reportActionOriginalMessage && 'oldBillable' in reportActionOriginalMessage && 'billable' in reportActionOriginalMessage;
+ if (hasModifiedBillable) {
+ buildMessageFragmentForValue(
+ reportActionOriginalMessage?.billable ?? '',
+ reportActionOriginalMessage?.oldBillable ?? '',
+ Localize.translateLocal('iou.request'),
+ true,
+ setFragments,
+ removalFragments,
+ changeFragments,
+ );
+ }
+
+ const message =
+ getMessageLine(`\n${Localize.translateLocal('iou.changed')}`, changeFragments) +
+ getMessageLine(`\n${Localize.translateLocal('iou.set')}`, setFragments) +
+ getMessageLine(`\n${Localize.translateLocal('iou.removed')}`, removalFragments);
+ if (message === '') {
+ return Localize.translateLocal('iou.changedTheRequest');
+ }
+ return `${message.substring(1, message.length)}`;
+}
+
+export default {
+ getForReportAction,
+};
diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
index 3be7de786223..c80ae9914347 100644
--- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
+++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
@@ -47,7 +47,8 @@ export default (isSmallScreenWidth: boolean, themeStyles: ThemeStyles): ScreenOp
// This is necessary to cover translated sidebar with overlay.
width: isSmallScreenWidth ? '100%' : '200%',
- transform: [{translateX: isSmallScreenWidth ? 0 : -variables.sideBarWidth}],
+ // LHP should be displayed in place of the sidebar
+ left: isSmallScreenWidth ? 0 : -variables.sideBarWidth,
},
},
homeScreen: {
diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts
index bb680bf4cb27..6468670fce99 100644
--- a/src/libs/Navigation/linkTo.ts
+++ b/src/libs/Navigation/linkTo.ts
@@ -114,11 +114,6 @@ export default function linkTo(navigation: NavigationContainerRef {
+ const isEmail = Str.isValidEmail(part.text);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const tagType = part.type || 'span';
- nextStepHTML += `<${tagType}>${Str.safeEscape(part.text)}${tagType}>`;
+ let tagType = part.type ?? 'span';
+ let content = Str.safeEscape(part.text);
+
+ if (isEmail) {
+ tagType = 'next-steps-email';
+ content = EmailUtils.prefixMailSeparatorsWithBreakOpportunities(content);
+ }
+
+ nextStepHTML += `<${tagType}>${content}${tagType}>`;
});
const formattedHtml = nextStepHTML
diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts
index 242248b17794..6bd6c73982eb 100644
--- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts
+++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts
@@ -2,6 +2,7 @@
import Str from 'expensify-common/lib/str';
import {ImageSourcePropType} from 'react-native';
import EXPENSIFY_ICON_URL from '@assets/images/expensify-logo-round-clearspace.png';
+import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage';
import * as ReportUtils from '@libs/ReportUtils';
import * as AppUpdate from '@userActions/AppUpdate';
import {Report, ReportAction} from '@src/types/onyx';
@@ -108,7 +109,7 @@ export default {
pushModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, usesIcon = false) {
const title = reportAction.person?.map((f) => f.text).join(', ') ?? '';
- const body = ReportUtils.getModifiedExpenseMessage(reportAction);
+ const body = ModifiedExpenseMessage.getForReportAction(reportAction);
const icon = usesIcon ? EXPENSIFY_ICON_URL : '';
const data = {
reportID: report.reportID,
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index cc7ef66f7a43..ce201b84cab2 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -1,5 +1,4 @@
/* eslint-disable no-continue */
-import {parsePhoneNumber} from 'awesome-phonenumber';
import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import lodashOrderBy from 'lodash/orderBy';
@@ -13,9 +12,11 @@ import * as ErrorUtils from './ErrorUtils';
import * as LocalePhoneNumber from './LocalePhoneNumber';
import * as Localize from './Localize';
import * as LoginUtils from './LoginUtils';
+import ModifiedExpenseMessage from './ModifiedExpenseMessage';
import Navigation from './Navigation/Navigation';
import Permissions from './Permissions';
import * as PersonalDetailsUtils from './PersonalDetailsUtils';
+import * as PhoneNumber from './PhoneNumber';
import * as ReportActionUtils from './ReportActionsUtils';
import * as ReportUtils from './ReportUtils';
import * as TaskUtils from './TaskUtils';
@@ -115,7 +116,7 @@ Onyx.connect({
* @return {String}
*/
function addSMSDomainIfPhoneNumber(login) {
- const parsedPhoneNumber = parsePhoneNumber(login);
+ const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(login);
if (parsedPhoneNumber.possible && !Str.isValidEmail(login)) {
return parsedPhoneNumber.number.e164 + CONST.SMS.DOMAIN;
}
@@ -407,7 +408,7 @@ function getLastMessageTextForReport(report) {
} else if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml, translationKey: report.lastMessageTranslationKey})) {
lastMessageTextFromReport = `[${Localize.translateLocal(report.lastMessageTranslationKey || 'common.attachment')}]`;
} else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) {
- const properSchemaForModifiedExpenseMessage = ReportUtils.getModifiedExpenseMessage(lastReportAction);
+ const properSchemaForModifiedExpenseMessage = ModifiedExpenseMessage.getForReportAction(lastReportAction);
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true);
} else if (
lastActionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED ||
@@ -518,7 +519,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, {
(lastReportActions[report.reportID] && lastReportActions[report.reportID].originalMessage && lastReportActions[report.reportID].originalMessage.reason) ||
CONST.REPORT.ARCHIVE_REASON.DEFAULT;
lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, {
- displayName: archiveReason.displayName || PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails, 'displayName'),
+ displayName: archiveReason.displayName || PersonalDetailsUtils.getDisplayNameOrDefault(lodashGet(lastActorDetails, 'displayName')),
policyName: ReportUtils.getPolicyName(report),
});
}
@@ -1132,7 +1133,7 @@ function getOptions(
let recentReportOptions = [];
let personalDetailsOptions = [];
const reportMapForAccountIDs = {};
- const parsedPhoneNumber = parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue)));
+ const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue)));
const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number.e164 : searchInputValue.toLowerCase();
// Filter out all the reports that shouldn't be displayed
@@ -1213,7 +1214,7 @@ function getOptions(
// This is a temporary fix for all the logic that's been breaking because of the new privacy changes
// See https://github.com/Expensify/Expensify/issues/293465 for more context
// Moreover, we should not override the personalDetails object, otherwise the createOption util won't work properly, it returns incorrect tooltipText
- const havingLoginPersonalDetails = !includeP2P ? {} : _.pick(personalDetails, (detail) => Boolean(detail.login));
+ const havingLoginPersonalDetails = !includeP2P ? {} : _.pick(personalDetails, (detail) => Boolean(detail.login) && !detail.isOptimisticPersonalDetail);
let allPersonalDetailsOptions = _.map(havingLoginPersonalDetails, (personalDetail) =>
createOption([personalDetail.accountID], personalDetails, reportMapForAccountIDs[personalDetail.accountID], reportActions, {
showChatPreviewLine,
@@ -1391,7 +1392,7 @@ function getOptions(
}
return {
- personalDetails: _.filter(personalDetailsOptions, (personalDetailsOption) => !personalDetailsOption.isOptimisticPersonalDetail),
+ personalDetails: personalDetailsOptions,
recentReports: recentReportOptions,
userToInvite: canInviteUser ? userToInvite : null,
currentUserOption,
@@ -1633,7 +1634,7 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma
return Localize.translate(preferredLocale, 'common.maxParticipantsReached', {count: CONST.REPORT.MAXIMUM_PARTICIPANTS});
}
- const isValidPhone = parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible;
+ const isValidPhone = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible;
const isValidEmail = Str.isValidEmail(searchValue);
diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js
deleted file mode 100644
index b5335eab0762..000000000000
--- a/src/libs/PersonalDetailsUtils.js
+++ /dev/null
@@ -1,222 +0,0 @@
-import lodashGet from 'lodash/get';
-import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import ONYXKEYS from '@src/ONYXKEYS';
-import * as LocalePhoneNumber from './LocalePhoneNumber';
-import * as Localize from './Localize';
-import * as UserUtils from './UserUtils';
-
-let personalDetails = [];
-let allPersonalDetails = {};
-Onyx.connect({
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- callback: (val) => {
- personalDetails = _.values(val);
- allPersonalDetails = val;
- },
-});
-
-/**
- * @param {Object | Null} passedPersonalDetails
- * @param {Array | String} pathToDisplayName
- * @param {String} [defaultValue] optional default display name value
- * @returns {String}
- */
-function getDisplayNameOrDefault(passedPersonalDetails, pathToDisplayName, defaultValue = '') {
- const displayName = lodashGet(passedPersonalDetails, pathToDisplayName);
-
- return displayName || defaultValue || Localize.translateLocal('common.hidden');
-}
-
-/**
- * Given a list of account IDs (as number) it will return an array of personal details objects.
- * @param {Array} accountIDs - Array of accountIDs
- * @param {Number} currentUserAccountID
- * @param {Boolean} shouldChangeUserDisplayName - It will replace the current user's personal detail object's displayName with 'You'.
- * @returns {Array} - Array of personal detail objects
- */
-function getPersonalDetailsByIDs(accountIDs, currentUserAccountID, shouldChangeUserDisplayName = false) {
- return _.chain(accountIDs)
- .filter((accountID) => !!allPersonalDetails[accountID])
- .map((accountID) => {
- const detail = allPersonalDetails[accountID];
-
- if (shouldChangeUserDisplayName && currentUserAccountID === detail.accountID) {
- return {
- ...detail,
- displayName: Localize.translateLocal('common.you'),
- };
- }
-
- return detail;
- })
- .value();
-}
-
-/**
- * Given a list of logins, find the associated personal detail and return related accountIDs.
- *
- * @param {Array} logins Array of user logins
- * @returns {Array} - Array of accountIDs according to passed logins
- */
-function getAccountIDsByLogins(logins) {
- return _.reduce(
- logins,
- (foundAccountIDs, login) => {
- const currentDetail = _.find(personalDetails, (detail) => detail.login === login);
- if (!currentDetail) {
- // generate an account ID because in this case the detail is probably new, so we don't have a real accountID yet
- foundAccountIDs.push(UserUtils.generateAccountID(login));
- } else {
- foundAccountIDs.push(Number(currentDetail.accountID));
- }
- return foundAccountIDs;
- },
- [],
- );
-}
-
-/**
- * Given a list of accountIDs, find the associated personal detail and return related logins.
- *
- * @param {Array} accountIDs Array of user accountIDs
- * @returns {Array} - Array of logins according to passed accountIDs
- */
-function getLoginsByAccountIDs(accountIDs) {
- return _.reduce(
- accountIDs,
- (foundLogins, accountID) => {
- const currentDetail = _.find(personalDetails, (detail) => Number(detail.accountID) === Number(accountID)) || {};
- if (currentDetail.login) {
- foundLogins.push(currentDetail.login);
- }
- return foundLogins;
- },
- [],
- );
-}
-
-/**
- * Given a list of logins and accountIDs, return Onyx data for users with no existing personal details stored
- *
- * @param {Array} logins Array of user logins
- * @param {Array} accountIDs Array of user accountIDs
- * @returns {Object} - Object with optimisticData, successData and failureData (object of personal details objects)
- */
-function getNewPersonalDetailsOnyxData(logins, accountIDs) {
- const optimisticData = {};
- const successData = {};
- const failureData = {};
-
- _.each(logins, (login, index) => {
- const accountID = accountIDs[index];
-
- if (_.isEmpty(allPersonalDetails[accountID])) {
- optimisticData[accountID] = {
- login,
- accountID,
- avatar: UserUtils.getDefaultAvatarURL(accountID),
- displayName: LocalePhoneNumber.formatPhoneNumber(login),
- };
-
- /**
- * Cleanup the optimistic user to ensure it does not permanently persist.
- * This is done to prevent duplicate entries (upon success) since the BE will return other personal details with the correct account IDs.
- */
- successData[accountID] = null;
- }
- });
-
- return {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- value: optimisticData,
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- value: successData,
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- value: failureData,
- },
- ],
- };
-}
-
-/**
- * Applies common formatting to each piece of an address
- *
- * @param {String} piece - address piece to format
- * @returns {String} - formatted piece
- */
-function formatPiece(piece) {
- return piece ? `${piece}, ` : '';
-}
-
-/**
- *
- * @param {String} street1 - street line 1
- * @param {String} street2 - street line 2
- * @returns {String} formatted street
- */
-function getFormattedStreet(street1 = '', street2 = '') {
- return `${street1}\n${street2}`;
-}
-
-/**
- *
- * @param {*} street - formatted address
- * @returns {[string, string]} [street1, street2]
- */
-function getStreetLines(street = '') {
- const streets = street.split('\n');
- return [streets[0], streets[1]];
-}
-
-/**
- * Formats an address object into an easily readable string
- *
- * @param {OnyxTypes.PrivatePersonalDetails} privatePersonalDetails - details object
- * @returns {String} - formatted address
- */
-function getFormattedAddress(privatePersonalDetails) {
- const {address} = privatePersonalDetails;
- const [street1, street2] = getStreetLines(address.street);
- const formattedAddress = formatPiece(street1) + formatPiece(street2) + formatPiece(address.city) + formatPiece(address.state) + formatPiece(address.zip) + formatPiece(address.country);
-
- // Remove the last comma of the address
- return formattedAddress.trim().replace(/,$/, '');
-}
-
-/**
- * @param {Object} personalDetail - details object
- * @returns {String | undefined} - The effective display name
- */
-function getEffectiveDisplayName(personalDetail) {
- if (personalDetail) {
- return LocalePhoneNumber.formatPhoneNumber(personalDetail.login) || personalDetail.displayName;
- }
-
- return undefined;
-}
-
-export {
- getDisplayNameOrDefault,
- getPersonalDetailsByIDs,
- getAccountIDsByLogins,
- getLoginsByAccountIDs,
- getNewPersonalDetailsOnyxData,
- getFormattedAddress,
- getFormattedStreet,
- getStreetLines,
- getEffectiveDisplayName,
-};
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
new file mode 100644
index 000000000000..8bb4ac0aea3e
--- /dev/null
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -0,0 +1,211 @@
+import Onyx, {OnyxEntry} from 'react-native-onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+import * as OnyxTypes from '@src/types/onyx';
+import {PersonalDetails, PersonalDetailsList} from '@src/types/onyx';
+import * as LocalePhoneNumber from './LocalePhoneNumber';
+import * as Localize from './Localize';
+import * as UserUtils from './UserUtils';
+
+let personalDetails: Array = [];
+let allPersonalDetails: OnyxEntry = {};
+Onyx.connect({
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ callback: (val) => {
+ personalDetails = Object.values(val ?? {});
+ allPersonalDetails = val;
+ },
+});
+
+/**
+ * @param [defaultValue] optional default display name value
+ */
+function getDisplayNameOrDefault(displayName?: string, defaultValue = ''): string {
+ return displayName ?? defaultValue ?? Localize.translateLocal('common.hidden');
+}
+
+/**
+ * Given a list of account IDs (as number) it will return an array of personal details objects.
+ * @param accountIDs - Array of accountIDs
+ * @param currentUserAccountID
+ * @param shouldChangeUserDisplayName - It will replace the current user's personal detail object's displayName with 'You'.
+ * @returns - Array of personal detail objects
+ */
+function getPersonalDetailsByIDs(accountIDs: number[], currentUserAccountID: number, shouldChangeUserDisplayName = false): OnyxTypes.PersonalDetails[] {
+ const result: OnyxTypes.PersonalDetails[] = accountIDs
+ .filter((accountID) => !!allPersonalDetails?.[accountID])
+ .map((accountID) => {
+ const detail = (allPersonalDetails?.[accountID] ?? {}) as OnyxTypes.PersonalDetails;
+
+ if (shouldChangeUserDisplayName && currentUserAccountID === detail.accountID) {
+ return {
+ ...detail,
+ displayName: Localize.translateLocal('common.you'),
+ };
+ }
+
+ return detail;
+ });
+
+ return result;
+}
+
+/**
+ * Given a list of logins, find the associated personal detail and return related accountIDs.
+ *
+ * @param logins Array of user logins
+ * @returns Array of accountIDs according to passed logins
+ */
+function getAccountIDsByLogins(logins: string[]): number[] {
+ return logins.reduce((foundAccountIDs, login) => {
+ const currentDetail = personalDetails.find((detail) => detail?.login === login);
+ if (!currentDetail) {
+ // generate an account ID because in this case the detail is probably new, so we don't have a real accountID yet
+ foundAccountIDs.push(UserUtils.generateAccountID(login));
+ } else {
+ foundAccountIDs.push(Number(currentDetail.accountID));
+ }
+ return foundAccountIDs;
+ }, []);
+}
+
+/**
+ * Given a list of accountIDs, find the associated personal detail and return related logins.
+ *
+ * @param accountIDs Array of user accountIDs
+ * @returns Array of logins according to passed accountIDs
+ */
+function getLoginsByAccountIDs(accountIDs: number[]): string[] {
+ return accountIDs.reduce((foundLogins: string[], accountID) => {
+ const currentDetail: Partial = personalDetails.find((detail) => Number(detail?.accountID) === Number(accountID)) ?? {};
+ if (currentDetail.login) {
+ foundLogins.push(currentDetail.login);
+ }
+ return foundLogins;
+ }, []);
+}
+
+/**
+ * Given a list of logins and accountIDs, return Onyx data for users with no existing personal details stored
+ *
+ * @param logins Array of user logins
+ * @param accountIDs Array of user accountIDs
+ * @returns Object with optimisticData, successData and failureData (object of personal details objects)
+ */
+function getNewPersonalDetailsOnyxData(logins: string[], accountIDs: number[]) {
+ const optimisticData: PersonalDetailsList = {};
+ const successData: PersonalDetailsList = {};
+ const failureData: PersonalDetailsList = {};
+
+ logins.forEach((login, index) => {
+ const accountID = accountIDs[index];
+
+ if (allPersonalDetails && Object.keys(allPersonalDetails?.[accountID] ?? {}).length === 0) {
+ optimisticData[accountID] = {
+ login,
+ accountID,
+ avatar: UserUtils.getDefaultAvatarURL(accountID),
+ displayName: LocalePhoneNumber.formatPhoneNumber(login),
+ };
+
+ /**
+ * Cleanup the optimistic user to ensure it does not permanently persist.
+ * This is done to prevent duplicate entries (upon success) since the BE will return other personal details with the correct account IDs.
+ */
+ successData[accountID] = null;
+ }
+ });
+
+ return {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ value: optimisticData,
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ value: successData,
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ value: failureData,
+ },
+ ],
+ };
+}
+
+/**
+ * Applies common formatting to each piece of an address
+ *
+ * @param piece - address piece to format
+ * @returns - formatted piece
+ */
+function formatPiece(piece?: string): string {
+ return piece ? `${piece}, ` : '';
+}
+
+/**
+ *
+ * @param street1 - street line 1
+ * @param street2 - street line 2
+ * @returns formatted street
+ */
+function getFormattedStreet(street1 = '', street2 = '') {
+ return `${street1}\n${street2}`;
+}
+
+/**
+ *
+ * @param - formatted address
+ * @returns [street1, street2]
+ */
+function getStreetLines(street = '') {
+ const streets = street.split('\n');
+ return [streets[0], streets[1]];
+}
+
+/**
+ * Formats an address object into an easily readable string
+ *
+ * @param privatePersonalDetails - details object
+ * @returns - formatted address
+ */
+function getFormattedAddress(privatePersonalDetails: OnyxTypes.PrivatePersonalDetails): string {
+ const {address} = privatePersonalDetails;
+ const [street1, street2] = getStreetLines(address?.street);
+ const formattedAddress =
+ formatPiece(street1) + formatPiece(street2) + formatPiece(address?.city) + formatPiece(address?.state) + formatPiece(address?.zip) + formatPiece(address?.country);
+
+ // Remove the last comma of the address
+ return formattedAddress.trim().replace(/,$/, '');
+}
+
+/**
+ * @param personalDetail - details object
+ * @returns - The effective display name
+ */
+function getEffectiveDisplayName(personalDetail?: PersonalDetails): string | undefined {
+ if (personalDetail) {
+ return LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? '') || personalDetail.displayName;
+ }
+
+ return undefined;
+}
+
+export {
+ getDisplayNameOrDefault,
+ getPersonalDetailsByIDs,
+ getAccountIDsByLogins,
+ getLoginsByAccountIDs,
+ getNewPersonalDetailsOnyxData,
+ getFormattedAddress,
+ getFormattedStreet,
+ getStreetLines,
+ getEffectiveDisplayName,
+};
diff --git a/src/libs/PhoneNumber.ts b/src/libs/PhoneNumber.ts
new file mode 100644
index 000000000000..a702de2039e3
--- /dev/null
+++ b/src/libs/PhoneNumber.ts
@@ -0,0 +1,42 @@
+// eslint-disable-next-line no-restricted-imports
+import {parsePhoneNumber as originalParsePhoneNumber, ParsedPhoneNumber, ParsedPhoneNumberInvalid, PhoneNumberParseOptions} from 'awesome-phonenumber';
+import CONST from '@src/CONST';
+
+/**
+ * Wraps awesome-phonenumber's parsePhoneNumber function to handle the case where we want to treat
+ * a US phone number that's technically valid as invalid. eg: +115005550009.
+ * See https://github.com/Expensify/App/issues/28492
+ */
+function parsePhoneNumber(phoneNumber: string, options?: PhoneNumberParseOptions): ParsedPhoneNumber {
+ const parsedPhoneNumber = originalParsePhoneNumber(phoneNumber, options);
+ if (!parsedPhoneNumber.possible) {
+ return parsedPhoneNumber;
+ }
+
+ const phoneNumberWithoutSpecialChars = phoneNumber.replace(CONST.REGEX.SPECIAL_CHARS_WITHOUT_NEWLINE, '');
+ if (!/^\+11[0-9]{10}$/.test(phoneNumberWithoutSpecialChars)) {
+ return parsedPhoneNumber;
+ }
+
+ const countryCode = phoneNumberWithoutSpecialChars.substring(0, 2);
+ const phoneNumberWithoutCountryCode = phoneNumberWithoutSpecialChars.substring(2);
+
+ return {
+ ...parsedPhoneNumber,
+ valid: false,
+ possible: false,
+ number: {
+ ...parsedPhoneNumber.number,
+
+ // mimic the behavior of awesome-phonenumber
+ e164: phoneNumberWithoutSpecialChars,
+ international: `${countryCode} ${phoneNumberWithoutCountryCode}`,
+ national: phoneNumberWithoutCountryCode,
+ rfc3966: `tel:${countryCode}-${phoneNumberWithoutCountryCode}`,
+ significant: phoneNumberWithoutCountryCode,
+ },
+ } as ParsedPhoneNumberInvalid;
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export {parsePhoneNumber};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index 347e5b68e960..220f58fe671e 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -31,7 +31,7 @@ function hasPolicyMemberError(policyMembers: OnyxEntry): boolean
* Check if the policy has any error fields.
*/
function hasPolicyErrorFields(policy: OnyxEntry): boolean {
- return Object.keys(policy?.errorFields ?? {}).some((fieldErrors) => Object.keys(fieldErrors ?? {}).length > 0);
+ return Object.values(policy?.errorFields ?? {}).some((fieldErrors) => Object.keys(fieldErrors ?? {}).length > 0);
}
/**
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 24e795919649..8ac32675bb4e 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -1,4 +1,3 @@
-import {format} from 'date-fns';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import Str from 'expensify-common/lib/str';
import {isEmpty} from 'lodash';
@@ -16,7 +15,7 @@ import CONST from '@src/CONST';
import {ParentNavigationSummaryParams, TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, PolicyTags, Report, ReportAction, Session, Transaction} from '@src/types/onyx';
+import {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, Session, Transaction} from '@src/types/onyx';
import {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
import {IOUMessage, OriginalMessageActionName, OriginalMessageCreated} from '@src/types/onyx/OriginalMessage';
import {NotificationPreference} from '@src/types/onyx/Report';
@@ -402,21 +401,6 @@ Onyx.connect({
callback: (value) => (loginList = value),
});
-let allPolicyTags: Record = {};
-
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.POLICY_TAGS,
- waitForCollectionCallback: true,
- callback: (value) => {
- if (!value) {
- allPolicyTags = {};
- return;
- }
-
- allPolicyTags = value;
- },
-});
-
let allTransactions: OnyxCollection = {};
Onyx.connect({
@@ -430,10 +414,6 @@ Onyx.connect({
},
});
-function getPolicyTags(policyID: string) {
- return allPolicyTags[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {};
-}
-
function getChatType(report: OnyxEntry): ValueOf | undefined {
return report?.chatType;
}
@@ -946,7 +926,7 @@ function hasOnlyDistanceRequestTransactions(iouReportID: string | undefined): bo
* If the report is a thread and has a chat type set, it is a workspace chat.
*/
function isWorkspaceThread(report: OnyxEntry): boolean {
- return isThread(report) && !isDM(report);
+ return isThread(report) && isChatReport(report) && !isDM(report);
}
/**
@@ -1458,12 +1438,12 @@ function getDisplayNamesWithTooltips(
return personalDetailsListArray
.map((user) => {
- const accountID = Number(user.accountID);
+ const accountID = Number(user?.accountID);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport, shouldFallbackToHidden) || user.login || '';
+ const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport, shouldFallbackToHidden) || user?.login || '';
const avatar = UserUtils.getDefaultAvatar(accountID);
- let pronouns = user.pronouns;
+ let pronouns = user?.pronouns ?? undefined;
if (pronouns?.startsWith(CONST.PRONOUNS.PREFIX)) {
const pronounTranslationKey = pronouns.replace(CONST.PRONOUNS.PREFIX, '');
pronouns = Localize.translateLocal(`pronouns.${pronounTranslationKey}` as TranslationPaths);
@@ -1472,7 +1452,7 @@ function getDisplayNamesWithTooltips(
return {
displayName,
avatar,
- login: user.login ?? '',
+ login: user?.login ?? '',
accountID,
pronouns,
};
@@ -2036,140 +2016,6 @@ function getReportPreviewMessage(
return Localize.translateLocal(containsNonReimbursable ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', {payer: payerName ?? '', amount: formattedAmount});
}
-/**
- * Get the proper message schema for modified expense message.
- */
-
-function getProperSchemaForModifiedExpenseMessage(newValue: string, oldValue: string, valueName: string, valueInQuotes: boolean, shouldConvertToLowercase = true): string {
- const newValueToDisplay = valueInQuotes ? `"${newValue}"` : newValue;
- const oldValueToDisplay = valueInQuotes ? `"${oldValue}"` : oldValue;
- const displayValueName = shouldConvertToLowercase ? valueName.toLowerCase() : valueName;
-
- if (!oldValue) {
- return Localize.translateLocal('iou.setTheRequest', {valueName: displayValueName, newValueToDisplay});
- }
- if (!newValue) {
- return Localize.translateLocal('iou.removedTheRequest', {valueName: displayValueName, oldValueToDisplay});
- }
- return Localize.translateLocal('iou.updatedTheRequest', {valueName: displayValueName, newValueToDisplay, oldValueToDisplay});
-}
-
-/**
- * Get the proper message schema for modified distance message.
- */
-function getProperSchemaForModifiedDistanceMessage(newDistance: string, oldDistance: string, newAmount: string, oldAmount: string): string {
- if (!oldDistance) {
- return Localize.translateLocal('iou.setTheDistance', {newDistanceToDisplay: newDistance, newAmountToDisplay: newAmount});
- }
- return Localize.translateLocal('iou.updatedTheDistance', {
- newDistanceToDisplay: newDistance,
- oldDistanceToDisplay: oldDistance,
- newAmountToDisplay: newAmount,
- oldAmountToDisplay: oldAmount,
- });
-}
-
-/**
- * Get the report action message when expense has been modified.
- *
- * ModifiedExpense::getNewDotComment in Web-Expensify should match this.
- * If we change this function be sure to update the backend as well.
- */
-function getModifiedExpenseMessage(reportAction: OnyxEntry): string | undefined {
- const reportActionOriginalMessage = reportAction?.originalMessage as ExpenseOriginalMessage | undefined;
- if (isEmptyObject(reportActionOriginalMessage)) {
- return Localize.translateLocal('iou.changedTheRequest');
- }
- const reportID = reportAction?.reportID ?? '';
- const policyID = getReport(reportID)?.policyID ?? '';
- const policyTags = getPolicyTags(policyID);
- const policyTag = PolicyUtils.getTag(policyTags);
- const policyTagListName = policyTag?.name ?? Localize.translateLocal('common.tag');
-
- const hasModifiedAmount =
- reportActionOriginalMessage &&
- 'oldAmount' in reportActionOriginalMessage &&
- 'oldCurrency' in reportActionOriginalMessage &&
- 'amount' in reportActionOriginalMessage &&
- 'currency' in reportActionOriginalMessage;
-
- const hasModifiedMerchant = reportActionOriginalMessage && 'oldMerchant' in reportActionOriginalMessage && 'merchant' in reportActionOriginalMessage;
- if (hasModifiedAmount) {
- const oldCurrency = reportActionOriginalMessage?.oldCurrency;
- const oldAmount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage?.oldAmount ?? 0, oldCurrency ?? '');
-
- const currency = reportActionOriginalMessage?.currency;
- const amount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage?.amount ?? 0, currency);
-
- // Only Distance edits should modify amount and merchant (which stores distance) in a single transaction.
- // We check the merchant is in distance format (includes @) as a sanity check
- if (hasModifiedMerchant && reportActionOriginalMessage?.merchant?.includes('@')) {
- return getProperSchemaForModifiedDistanceMessage(reportActionOriginalMessage?.merchant, reportActionOriginalMessage?.oldMerchant ?? '', amount, oldAmount);
- }
-
- return getProperSchemaForModifiedExpenseMessage(amount, oldAmount, Localize.translateLocal('iou.amount'), false);
- }
-
- const hasModifiedComment = reportActionOriginalMessage && 'oldComment' in reportActionOriginalMessage && 'newComment' in reportActionOriginalMessage;
- if (hasModifiedComment) {
- return getProperSchemaForModifiedExpenseMessage(
- reportActionOriginalMessage?.newComment ?? '',
- reportActionOriginalMessage?.oldComment ?? '',
- Localize.translateLocal('common.description'),
- true,
- );
- }
-
- const hasModifiedCreated = reportActionOriginalMessage && 'oldCreated' in reportActionOriginalMessage && 'created' in reportActionOriginalMessage;
- if (hasModifiedCreated) {
- // Take only the YYYY-MM-DD value as the original date includes timestamp
- let formattedOldCreated: Date | string = new Date(reportActionOriginalMessage?.oldCreated ? reportActionOriginalMessage.oldCreated : 0);
- formattedOldCreated = format(formattedOldCreated, CONST.DATE.FNS_FORMAT_STRING);
-
- return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage?.created ?? '', formattedOldCreated?.toString?.(), Localize.translateLocal('common.date'), false);
- }
-
- if (hasModifiedMerchant) {
- return getProperSchemaForModifiedExpenseMessage(
- reportActionOriginalMessage?.merchant ?? '',
- reportActionOriginalMessage?.oldMerchant ?? '',
- Localize.translateLocal('common.merchant'),
- true,
- );
- }
-
- const hasModifiedCategory = reportActionOriginalMessage && 'oldCategory' in reportActionOriginalMessage && 'category' in reportActionOriginalMessage;
- if (hasModifiedCategory) {
- return getProperSchemaForModifiedExpenseMessage(
- reportActionOriginalMessage?.category ?? '',
- reportActionOriginalMessage?.oldCategory ?? '',
- Localize.translateLocal('common.category'),
- true,
- );
- }
-
- const hasModifiedTag = reportActionOriginalMessage && 'oldTag' in reportActionOriginalMessage && 'tag' in reportActionOriginalMessage;
- if (hasModifiedTag) {
- return getProperSchemaForModifiedExpenseMessage(
- reportActionOriginalMessage.tag ?? '',
- reportActionOriginalMessage.oldTag ?? '',
- policyTagListName,
- true,
- policyTagListName === Localize.translateLocal('common.tag'),
- );
- }
-
- const hasModifiedBillable = reportActionOriginalMessage && 'oldBillable' in reportActionOriginalMessage && 'billable' in reportActionOriginalMessage;
- if (hasModifiedBillable) {
- return getProperSchemaForModifiedExpenseMessage(
- reportActionOriginalMessage?.billable ?? '',
- reportActionOriginalMessage?.oldBillable ?? '',
- Localize.translateLocal('iou.request'),
- true,
- );
- }
-}
-
/**
* Given the updates user made to the request, compose the originalMessage
* object of the modified expense action.
@@ -3741,6 +3587,13 @@ function getReportIDFromLink(url: string | null): string {
return reportID;
}
+/**
+ * Get the report policyID given a reportID
+ */
+function getReportPolicyID(reportID?: string): string | undefined {
+ return getReport(reportID)?.policyID;
+}
+
/**
* Check if the chat report is linked to an iou that is waiting for the current user to add a credit bank account.
*/
@@ -4373,6 +4226,7 @@ export {
getReport,
getReportNotificationPreference,
getReportIDFromLink,
+ getReportPolicyID,
getRouteFromLink,
getDeletedParentActionMessageForChatReport,
getLastVisibleMessage,
@@ -4448,7 +4302,6 @@ export {
getParentReport,
getRootParentReport,
getReportPreviewMessage,
- getModifiedExpenseMessage,
canUserPerformWriteAction,
getOriginalReportID,
canAccessReport,
@@ -4492,4 +4345,4 @@ export {
shouldAutoFocusOnKeyPress,
};
-export type {OptionData, OptimisticChatReport};
+export type {ExpenseOriginalMessage, OptionData, OptimisticChatReport};
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 1813d4f0a795..4744426ecfd3 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -344,7 +344,7 @@ function getOptionData(
case CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED: {
lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, {
policyName: ReportUtils.getPolicyName(report, false, policy),
- displayName: PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails, 'displayName'),
+ displayName: PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails?.displayName),
});
break;
}
diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts
index e95b62cc2437..0fad9c2c2a75 100644
--- a/src/libs/UserUtils.ts
+++ b/src/libs/UserUtils.ts
@@ -7,7 +7,7 @@ import * as defaultAvatars from '@components/Icon/DefaultAvatars';
import {ConciergeAvatar, FallbackAvatar} from '@components/Icon/Expensicons';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import {PersonalDetails} from '@src/types/onyx';
+import {PersonalDetailsList} from '@src/types/onyx';
import Login from '@src/types/onyx/Login';
import hashCode from './hashCode';
@@ -17,7 +17,7 @@ type AvatarSource = React.FC | string;
type LoginListIndicator = ValueOf | '';
-let allPersonalDetails: OnyxEntry>;
+let allPersonalDetails: OnyxEntry;
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (val) => (allPersonalDetails = _.isEmpty(val) ? {} : val),
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index ba977312fcfb..6d4f486663ec 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -1,4 +1,3 @@
-import {parsePhoneNumber} from 'awesome-phonenumber';
import {addYears, endOfMonth, format, isAfter, isBefore, isSameDay, isValid, isWithinInterval, parse, parseISO, startOfDay, subYears} from 'date-fns';
import {URL_REGEX_WITH_REQUIRED_PROTOCOL} from 'expensify-common/lib/Url';
import isDate from 'lodash/isDate';
@@ -10,6 +9,7 @@ import * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import * as CardUtils from './CardUtils';
import DateUtils from './DateUtils';
import * as LoginUtils from './LoginUtils';
+import {parsePhoneNumber} from './PhoneNumber';
import StringUtils from './StringUtils';
/**
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index d7043ee7b3eb..ca38e0dd5902 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -125,7 +125,8 @@ function signOutAndRedirectToSignIn() {
* @param isAnonymousAction The action is allowed for anonymous or not
* @returns same callback if the action is allowed, otherwise a function that signs out and redirects to sign in
*/
-function checkIfActionIsAllowed unknown>(callback: TCallback, isAnonymousAction = false): TCallback | (() => void) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function checkIfActionIsAllowed any>(callback: TCallback, isAnonymousAction = false): TCallback | (() => void) {
if (isAnonymousUser() && !isAnonymousAction) {
return () => signOutAndRedirectToSignIn();
}
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index 86bd1b31714d..2132ae1bdc61 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -59,9 +59,6 @@ function addStop(transactionID: string) {
function saveWaypoint(transactionID: string, index: string, waypoint: RecentWaypoint | null, isDraft = false) {
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {
- pendingFields: {
- waypoints: isDraft ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
- },
comment: {
waypoints: {
[`waypoint${index}`]: waypoint,
diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js
index b0362a69b4d6..bc7ff5543d66 100755
--- a/src/pages/DetailsPage.js
+++ b/src/pages/DetailsPage.js
@@ -1,4 +1,3 @@
-import {parsePhoneNumber} from 'awesome-phonenumber';
import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
@@ -22,6 +21,7 @@ import UserDetailsTooltip from '@components/UserDetailsTooltip';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
+import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import * as Report from '@userActions/Report';
diff --git a/src/pages/EditRequestCreatedPage.js b/src/pages/EditRequestCreatedPage.js
index fbe1b3c782a7..6810414d7921 100644
--- a/src/pages/EditRequestCreatedPage.js
+++ b/src/pages/EditRequestCreatedPage.js
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import DatePicker from '@components/DatePicker';
import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -34,7 +35,8 @@ function EditRequestCreatedPage({defaultCreated, onSubmit}) {
submitButtonText={translate('common.save')}
enabledWhenOffline
>
-
- ({value: key, label: translate(`companyStep.incorporationTypes.${key}`)}))}
+ items={_.map(_.keys(CONST.INCORPORATION_TYPES), (key) => ({
+ value: key,
+ label: translate(`companyStep.incorporationTypes.${key}`),
+ }))}
placeholder={{value: '', label: '-'}}
defaultValue={getDefaultStateForField('incorporationType')}
shouldSaveDraft
/>
- {},
};
function IdentityForm(props) {
@@ -152,7 +148,6 @@ function IdentityForm(props) {
role={CONST.ROLE.PRESENTATION}
value={props.values.firstName}
defaultValue={props.defaultValues.firstName}
- onChangeText={(value) => props.onFieldChange({firstName: value})}
errorText={props.errors.firstName ? props.translate('bankAccount.error.firstName') : ''}
/>
@@ -166,19 +161,18 @@ function IdentityForm(props) {
role={CONST.ROLE.PRESENTATION}
value={props.values.lastName}
defaultValue={props.defaultValues.lastName}
- onChangeText={(value) => props.onFieldChange({lastName: value})}
errorText={props.errors.lastName ? props.translate('bankAccount.error.lastName') : ''}
/>
- props.onFieldChange({dob: value})}
errorText={dobErrorText}
minDate={minDate}
maxDate={maxDate}
@@ -193,7 +187,6 @@ function IdentityForm(props) {
containerStyles={[styles.mt4]}
inputMode={CONST.INPUT_MODE.NUMERIC}
defaultValue={props.defaultValues.ssnLast4}
- onChangeText={(value) => props.onFieldChange({ssnLast4: value})}
errorText={props.errors.ssnLast4 ? props.translate('bankAccount.error.ssnLast4') : ''}
maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.SSN}
/>
@@ -205,7 +198,6 @@ function IdentityForm(props) {
values={_.omit(props.values, identityFormInputKeys)}
defaultValues={_.omit(props.defaultValues, identityFormInputKeys)}
errors={props.errors}
- onFieldChange={props.onFieldChange}
/>
);
diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js
index 4ec31b29f914..e04ffbb352fc 100755
--- a/src/pages/ReportParticipantsPage.js
+++ b/src/pages/ReportParticipantsPage.js
@@ -60,7 +60,7 @@ const getAllParticipants = (report, personalDetails, translate) =>
.map((accountID, index) => {
const userPersonalDetail = lodashGet(personalDetails, accountID, {displayName: personalDetails.displayName || translate('common.hidden'), avatar: ''});
const userLogin = LocalePhoneNumber.formatPhoneNumber(userPersonalDetail.login || '') || translate('common.hidden');
- const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(userPersonalDetail, 'displayName');
+ const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(userPersonalDetail.displayName);
return {
alternateText: userLogin,
diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js
index c63db694159f..1f062a42f8bf 100644
--- a/src/pages/ShareCodePage.js
+++ b/src/pages/ShareCodePage.js
@@ -100,7 +100,7 @@ class ShareCodePage extends React.Component {
Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(activeRoute))}
iconWidth={variables.signInLogoWidthLargeScreen}
- iconHeight={variables.lhnLogoWidth}
+ iconHeight={variables.signInLogoHeightLargeScreen}
/>
{
setMenuVisibility(false);
@@ -283,4 +310,4 @@ AttachmentPickerWithMenuItems.propTypes = propTypes;
AttachmentPickerWithMenuItems.defaultProps = defaultProps;
AttachmentPickerWithMenuItems.displayName = 'AttachmentPickerWithMenuItems';
-export default AttachmentPickerWithMenuItems;
+export default withNavigationFocus(AttachmentPickerWithMenuItems);
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
index 396e94d9cf9d..b23d9b554488 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
@@ -446,7 +446,7 @@ function ReportActionCompose({
>
{!isSmallScreenWidth && }
-
+ {hasExceededMaxCommentLength && }
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index d4731d3b929b..c81e47016dcc 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -40,6 +40,7 @@ import ControlSelection from '@libs/ControlSelection';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import focusTextInputAfterAnimation from '@libs/focusTextInputAfterAnimation';
+import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage';
import Navigation from '@libs/Navigation/Navigation';
import Permissions from '@libs/Permissions';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
@@ -148,8 +149,8 @@ function ReportActionItem(props) {
const isReportActionLinked = props.linkedReportActionID === props.action.reportActionID;
const highlightedBackgroundColorIfNeeded = useMemo(
- () => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(theme.highlightBG) : {}),
- [StyleUtils, isReportActionLinked, theme.highlightBG],
+ () => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(theme.hoverComponentBG) : {}),
+ [StyleUtils, isReportActionLinked, theme.hoverComponentBG],
);
const originalMessage = lodashGet(props.action, 'originalMessage', {});
const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(props.action);
@@ -374,7 +375,7 @@ function ReportActionItem(props) {
);
} else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) {
- const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [props.report.ownerAccountID, 'displayName']);
+ const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(lodashGet(personalDetails, [props.report.ownerAccountID, 'displayName']));
const paymentType = lodashGet(props.action, 'originalMessage.paymentType', '');
const isSubmitterOfUnsettledReport = ReportUtils.isCurrentUserSubmitter(props.report.reportID) && !ReportUtils.isSettled(props.report.reportID);
@@ -422,12 +423,12 @@ function ReportActionItem(props) {
);
} else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTDEQUEUED) {
- const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [props.report.ownerAccountID, 'displayName']);
+ const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(lodashGet(personalDetails, [props.report.ownerAccountID, 'displayName']));
const amount = CurrencyUtils.convertToDisplayString(props.report.total, props.report.currency);
children = ;
} else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) {
- children = ;
+ children = ;
} else {
const hasBeenFlagged = !_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], moderationDecision);
children = (
diff --git a/src/pages/home/report/ReportActionItemDate.js b/src/pages/home/report/ReportActionItemDate.js
deleted file mode 100644
index 58471a88061f..000000000000
--- a/src/pages/home/report/ReportActionItemDate.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {memo} from 'react';
-import {withCurrentDate} from '@components/OnyxProvider';
-import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-
-const propTypes = {
- /** UTC timestamp for when the action was created */
- created: PropTypes.string.isRequired,
- ...withLocalizePropTypes,
-};
-
-function ReportActionItemDate(props) {
- const styles = useThemeStyles();
- return {props.datetimeToCalendarTime(props.created)};
-}
-
-ReportActionItemDate.propTypes = propTypes;
-ReportActionItemDate.displayName = 'ReportActionItemDate';
-
-export default compose(
- withLocalize,
-
- /** This component is hooked to the current date so that relative times can update when necessary
- * e.g. past midnight */
- withCurrentDate(),
- memo,
-)(ReportActionItemDate);
diff --git a/src/pages/home/report/ReportActionItemDate.tsx b/src/pages/home/report/ReportActionItemDate.tsx
new file mode 100644
index 000000000000..a8c5c208151a
--- /dev/null
+++ b/src/pages/home/report/ReportActionItemDate.tsx
@@ -0,0 +1,31 @@
+import React, {memo} from 'react';
+import {OnyxEntry} from 'react-native-onyx';
+import {withCurrentDate} from '@components/OnyxProvider';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+type ReportActionItemDateOnyxProps = {
+ /**
+ * UTC timestamp for when the action was created.
+ * This Onyx prop is hooked to the current date so that relative times can update when necessary
+ * e.g. past midnight.
+ */
+ // eslint-disable-next-line react/no-unused-prop-types
+ currentDate: OnyxEntry;
+};
+
+type ReportActionItemDateProps = ReportActionItemDateOnyxProps & {
+ created: string;
+};
+
+function ReportActionItemDate({created}: ReportActionItemDateProps) {
+ const {datetimeToCalendarTime} = useLocalize();
+ const styles = useThemeStyles();
+
+ return {datetimeToCalendarTime(created, false, false)};
+}
+
+ReportActionItemDate.displayName = 'ReportActionItemDate';
+
+export default memo(withCurrentDate()(ReportActionItemDate));
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index 3da0fad72f0a..41e411d398b8 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -475,7 +475,7 @@ function ReportActionItemMessageEdit(props) {
-
+ {hasExceededMaxCommentLength && }
>
);
}
diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js
index 81827073aa49..5737d876779f 100644
--- a/src/pages/home/report/ReportActionItemSingle.js
+++ b/src/pages/home/report/ReportActionItemSingle.js
@@ -170,7 +170,7 @@ function ReportActionItemSingle(props) {
icons={[icon, secondaryAvatar]}
isInReportAction
shouldShowTooltip
- secondAvatarStyle={[StyleUtils.getBackgroundAndBorderStyle(theme.appBG), props.isHovered ? StyleUtils.getBackgroundAndBorderStyle(theme.highlightBG) : undefined]}
+ secondAvatarStyle={[StyleUtils.getBackgroundAndBorderStyle(theme.appBG), props.isHovered ? StyleUtils.getBackgroundAndBorderStyle(theme.hoverComponentBG) : undefined]}
/>
);
}
diff --git a/src/pages/iou/MoneyRequestDatePage.js b/src/pages/iou/MoneyRequestDatePage.js
index b7d1c4002da1..f6159abd73f6 100644
--- a/src/pages/iou/MoneyRequestDatePage.js
+++ b/src/pages/iou/MoneyRequestDatePage.js
@@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import DatePicker from '@components/DatePicker';
import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -99,7 +100,8 @@ function MoneyRequestDatePage({iou, route, selectedTab}) {
submitButtonText={translate('common.save')}
enabledWhenOffline
>
-
- {
const nextStepIOUType = numberOfParticipants.current === 1 ? iouType : CONST.IOU.TYPE.SPLIT;
+ IOU.resetMoneyRequestTag_temporaryForRefactor(transactionID);
+ IOU.resetMoneyRequestCategory_temporaryForRefactor(transactionID);
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(nextStepIOUType, transactionID, selectedReportID.current || reportID));
}, [iouType, transactionID, reportID]);
diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js
index c9075d896deb..652e07674ae0 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.js
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.js
@@ -260,7 +260,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
onMouseDown(event, [AMOUNT_VIEW_ID])}
- style={[styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]}
+ style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]}
>
{
+ this.setState({isDeleteModalOpen: isOpen});
+ });
Keyboard.dismiss();
}
diff --git a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js
index 569435048383..8dda0ea0025d 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js
+++ b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js
@@ -202,7 +202,7 @@ function StatusClearAfterPage({currentUserPersonalDetails, customStatus}) {
containerStyle={styles.pr2}
onPress={() => Navigation.navigate(ROUTES.SETTINGS_STATUS_CLEAR_AFTER_DATE)}
errorText={customDateError}
- titleTextStyle={styles.flex1}
+ titleStyle={styles.flex1}
brickRoadIndicator={redBrickDateIndicator}
/>
Navigation.navigate(ROUTES.SETTINGS_STATUS_CLEAR_AFTER_TIME)}
errorText={customTimeError}
- titleTextStyle={styles.flex1}
+ titleStyle={styles.flex1}
brickRoadIndicator={redBrickTimeIndicator}
/>
>
diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js
index bf21d3cd2b54..3c4d7b3887c0 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusPage.js
+++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js
@@ -14,6 +14,7 @@ import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
@@ -123,6 +124,8 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
return {};
}, [brickRoadIndicator]);
+ const {inputCallbackRef} = useAutoFocusInput();
+
return (
- card.isVirtual) || {};
- const physicalCard = _.find(domainCards, (card) => !card.isVirtual) || {};
+ const domainCards = useMemo(() => cardList && CardUtils.getDomainCards(cardList)[domain], [cardList, domain]);
+ const virtualCard = useMemo(() => (domainCards && _.find(domainCards, (card) => card.isVirtual)) || {}, [domainCards]);
+ const physicalCard = useMemo(() => (domainCards && _.find(domainCards, (card) => !card.isVirtual)) || {}, [domainCards]);
const [isLoading, setIsLoading] = useState(false);
+ const [isNotFound, setIsNotFound] = useState(false);
const [details, setDetails] = useState({});
const [cardDetailsError, setCardDetailsError] = useState('');
- if (_.isEmpty(virtualCard) && _.isEmpty(physicalCard)) {
- return Navigation.goBack(ROUTES.SETTINGS_WALLET)} />;
- }
+ useEffect(() => {
+ if (!cardList) {
+ return;
+ }
+ setIsNotFound(_.isEmpty(virtualCard) && _.isEmpty(physicalCard));
+ }, [cardList, physicalCard, virtualCard]);
const formattedAvailableSpendAmount = CurrencyUtils.convertToDisplayString(physicalCard.availableSpend || virtualCard.availableSpend || 0);
@@ -164,6 +168,10 @@ function ExpensifyCardPage({
const hasDetectedIndividualFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL);
const cardDetailsErrorObject = cardDetailsError ? {error: cardDetailsError} : {};
+ if (isNotFound) {
+ return Navigation.goBack(ROUTES.SETTINGS_WALLET)} />;
+ }
+
return (
{
+ if (lodashGet(props, 'policy.customUnits', []).length !== 0) {
return;
}
- // When this page is accessed directly from url, the policy.customUnits data won't be available,
- // and we should trigger Policy.openWorkspaceReimburseView to get the data
BankAccounts.setReimbursementAccountLoading(true);
- Policy.openWorkspaceReimburseView(this.props.policy.id);
- }
-
- componentDidUpdate(prevProps) {
- // We should update rate input when rate data is fetched
- if (prevProps.reimbursementAccount.isLoading === this.props.reimbursementAccount.isLoading) {
- return;
- }
-
- this.resetRateAndUnit();
- }
+ Policy.openWorkspaceReimburseView(props.policy.id);
+ }, [props]);
- getUnitItems() {
- return [
- {label: this.props.translate('common.kilometers'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS},
- {label: this.props.translate('common.miles'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES},
- ];
- }
+ const unitItems = [
+ {label: props.translate('common.kilometers'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS},
+ {label: props.translate('common.miles'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES},
+ ];
- getRateDisplayValue(value) {
- const numValue = this.getNumericValue(value);
- if (Number.isNaN(numValue)) {
- return '';
- }
- return numValue.toString().replace('.', this.props.toLocaleDigit('.')).substring(0, value.length);
- }
-
- getNumericValue(value) {
- const numValue = NumberUtils.parseFloatAnyLocale(value.toString());
- if (Number.isNaN(numValue)) {
- return NaN;
- }
- return numValue.toFixed(3);
- }
-
- resetRateAndUnit() {
- const distanceCustomUnit = _.find(lodashGet(this.props, 'policy.customUnits', {}), (unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
- const distanceCustomRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE);
-
- this.setState({
- rate: PolicyUtils.getUnitRateValue(distanceCustomRate, this.props.toLocaleDigit),
- unit: lodashGet(distanceCustomUnit, 'attributes.unit', CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES),
- });
- }
-
- saveUnitAndRate(unit, rate) {
- const distanceCustomUnit = _.find(lodashGet(this.props, 'policy.customUnits', {}), (u) => u.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
+ const saveUnitAndRate = (unit, rate) => {
+ const distanceCustomUnit = _.find(lodashGet(props, 'policy.customUnits', {}), (customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
if (!distanceCustomUnit) {
return;
}
const currentCustomUnitRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (r) => r.name === CONST.CUSTOM_UNITS.DEFAULT_RATE);
const unitID = lodashGet(distanceCustomUnit, 'customUnitID', '');
const unitName = lodashGet(distanceCustomUnit, 'name', '');
- const rateNumValue = PolicyUtils.getNumericValue(rate, this.props.toLocaleDigit);
+ const rateNumValue = PolicyUtils.getNumericValue(rate, props.toLocaleDigit);
const newCustomUnit = {
customUnitID: unitID,
@@ -125,19 +70,19 @@ class WorkspaceRateAndUnitPage extends React.Component {
rate: rateNumValue * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET,
},
};
- Policy.updateWorkspaceCustomUnitAndRate(this.props.policy.id, distanceCustomUnit, newCustomUnit, this.props.policy.lastModified);
- }
+ Policy.updateWorkspaceCustomUnitAndRate(props.policy.id, distanceCustomUnit, newCustomUnit, props.policy.lastModified);
+ };
- submit() {
- this.saveUnitAndRate(this.state.unit, this.state.rate);
+ const submit = (values) => {
+ saveUnitAndRate(values.unit, values.rate);
Keyboard.dismiss();
- Navigation.goBack(ROUTES.WORKSPACE_REIMBURSE.getRoute(this.props.policy.id));
- }
+ Navigation.goBack(ROUTES.WORKSPACE_REIMBURSE.getRoute(props.policy.id));
+ };
- validate(values) {
+ const validate = (values) => {
const errors = {};
- const decimalSeparator = this.props.toLocaleDigit('.');
- const outputCurrency = lodashGet(this.props, 'policy.outputCurrency', CONST.CURRENCY.USD);
+ const decimalSeparator = props.toLocaleDigit('.');
+ const outputCurrency = lodashGet(props, 'policy.outputCurrency', CONST.CURRENCY.USD);
// Allow one more decimal place for accuracy
const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i');
if (!rateValueRegex.test(values.rate) || values.rate === '') {
@@ -146,73 +91,73 @@ class WorkspaceRateAndUnitPage extends React.Component {
errors.rate = 'workspace.reimburse.lowRateError';
}
return errors;
- }
-
- render() {
- const distanceCustomUnit = _.find(lodashGet(this.props, 'policy.customUnits', {}), (unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
- const distanceCustomRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE);
- return (
-
- {() => (
-
- )}
-
- );
- }
+
+
+
+ )}
+
+ );
}
WorkspaceRateAndUnitPage.propTypes = propTypes;
WorkspaceRateAndUnitPage.defaultProps = defaultProps;
+WorkspaceRateAndUnitPage.displayName = 'WorkspaceRateAndUnitPage';
export default compose(
withPolicy,
diff --git a/src/stories/Breadcrumbs.stories.tsx b/src/stories/Breadcrumbs.stories.tsx
new file mode 100644
index 000000000000..60e1900534f9
--- /dev/null
+++ b/src/stories/Breadcrumbs.stories.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import Breadcrumbs, {BreadcrumbsProps} from '@components/Breadcrumbs';
+import CONST from '@src/CONST';
+
+/**
+ * We use the Component Story Format for writing stories. Follow the docs here:
+ *
+ * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
+ */
+const story = {
+ title: 'Components/Breadcrumbs',
+ component: Breadcrumbs,
+};
+
+type StoryType = typeof Template & {args?: Partial};
+
+function Template(args: BreadcrumbsProps) {
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ return ;
+}
+
+// Arguments can be passed to the component by binding
+// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
+const Default: StoryType = Template.bind({});
+Default.args = {
+ breadcrumbs: [
+ {
+ type: CONST.BREADCRUMB_TYPE.ROOT,
+ },
+ {
+ text: 'Chats',
+ },
+ ],
+};
+
+const FirstBreadcrumbStrong: StoryType = Template.bind({});
+FirstBreadcrumbStrong.args = {
+ breadcrumbs: [
+ {
+ text: "Cathy's Croissants",
+ type: CONST.BREADCRUMB_TYPE.STRONG,
+ },
+ {
+ text: 'Chats',
+ },
+ ],
+};
+
+export default story;
+export {Default, FirstBreadcrumbStrong};
diff --git a/src/stories/Form.stories.js b/src/stories/Form.stories.js
index a937c6732e9b..7802b59605a5 100644
--- a/src/stories/Form.stories.js
+++ b/src/stories/Form.stories.js
@@ -69,7 +69,8 @@ function Template(args) {
containerStyles={[defaultStyles.mt4]}
hint="No PO box"
/>
-
justifyContent: 'center',
textDecorationLine: 'none',
},
+
+ breadcrumb: {
+ color: theme.textSupporting,
+ fontSize: variables.fontSizeh1,
+ lineHeight: variables.lineHeightSizeh1,
+ ...headlineFont,
+ },
+
+ breadcrumbStrong: {
+ color: theme.text,
+ fontSize: variables.fontSizeXLarge,
+ },
+
+ breadcrumbSeparator: {
+ color: theme.icon,
+ fontSize: variables.fontSizeXLarge,
+ lineHeight: variables.lineHeightSizeh1,
+ ...headlineFont,
+ },
+
+ breadcrumbLogo: {
+ top: 1.66, // Pixel-perfect alignment due to a small difference between logo height and breadcrumb text height
+ height: variables.lineHeightSizeh1,
+ },
+
LHPNavigatorContainer: (isSmallScreenWidth: boolean) =>
({
width: isSmallScreenWidth ? '100%' : variables.sideBarWidth,
position: 'absolute',
left: 0,
height: '100%',
- borderTopRightRadius: isSmallScreenWidth ? 0 : 24,
- borderBottomRightRadius: isSmallScreenWidth ? 0 : 24,
+ borderTopRightRadius: isSmallScreenWidth ? 0 : variables.lhpBorderRadius,
+ borderBottomRightRadius: isSmallScreenWidth ? 0 : variables.lhpBorderRadius,
overflow: 'hidden',
} satisfies ViewStyle),
+
RHPNavigatorContainer: (isSmallScreenWidth: boolean) =>
({
width: isSmallScreenWidth ? '100%' : variables.sideBarWidth,
@@ -1614,7 +1640,8 @@ const styles = (theme: ThemeColors) =>
({
...positioning.pFixed,
// We need to stretch the overlay to cover the sidebar and the translate animation distance.
- left: isModalOnTheLeft ? 0 : -2 * variables.sideBarWidth,
+ // The overlay must also cover borderRadius of the LHP component
+ left: isModalOnTheLeft ? -variables.lhpBorderRadius : -2 * variables.sideBarWidth,
top: 0,
bottom: 0,
right: isModalOnTheLeft ? -2 * variables.sideBarWidth : 0,
@@ -1972,14 +1999,14 @@ const styles = (theme: ThemeColors) =>
height: 24,
width: 24,
backgroundColor: theme.icon,
- borderRadius: 24,
+ borderRadius: 12,
},
singleAvatarSmall: {
- height: 18,
- width: 18,
+ height: 16,
+ width: 16,
backgroundColor: theme.icon,
- borderRadius: 18,
+ borderRadius: 8,
},
singleAvatarMedium: {
@@ -1993,17 +2020,17 @@ const styles = (theme: ThemeColors) =>
position: 'absolute',
right: -18,
bottom: -18,
- borderWidth: 3,
- borderRadius: 30,
+ borderWidth: 2,
+ borderRadius: 14,
borderColor: 'transparent',
},
secondAvatarSmall: {
position: 'absolute',
- right: -13,
- bottom: -13,
- borderWidth: 3,
- borderRadius: 18,
+ right: -14,
+ bottom: -14,
+ borderWidth: 2,
+ borderRadius: 10,
borderColor: 'transparent',
},
@@ -2024,8 +2051,8 @@ const styles = (theme: ThemeColors) =>
secondAvatarSubscriptCompact: {
position: 'absolute',
- bottom: -1,
- right: -1,
+ bottom: -4,
+ right: -4,
},
secondAvatarSubscriptSmallNormal: {
@@ -2664,6 +2691,8 @@ const styles = (theme: ThemeColors) =>
paddingVertical: 12,
},
+ moneyRequestAmountContainer: {minHeight: variables.inputHeight + 2 * (variables.formErrorLineHeight + 8)},
+
requestPreviewBox: {
marginTop: 12,
maxWidth: variables.reportPreviewMaxWidth,
@@ -3567,12 +3596,15 @@ const styles = (theme: ThemeColors) =>
},
headerEnvBadge: {
- marginLeft: 0,
- marginBottom: 2,
+ position: 'absolute',
+ bottom: -8,
+ left: -8,
height: 12,
+ width: 22,
paddingLeft: 4,
paddingRight: 4,
alignItems: 'center',
+ zIndex: -1,
},
headerEnvBadgeText: {
@@ -3740,8 +3772,8 @@ const styles = (theme: ThemeColors) =>
},
reportPreviewBoxHoverBorder: {
- borderColor: theme.border,
- backgroundColor: theme.border,
+ borderColor: theme.cardBG,
+ backgroundColor: theme.cardBG,
},
reportContainerBorderRadius: {
diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts
index daa1a72d35dd..a2954a4fca03 100644
--- a/src/styles/theme/themes/dark.ts
+++ b/src/styles/theme/themes/dark.ts
@@ -41,7 +41,7 @@ const darkTheme = {
inverse: colors.productDark900,
shadow: colors.black,
componentBG: colors.productDark100,
- hoverComponentBG: colors.productDark200,
+ hoverComponentBG: colors.productDark300,
activeComponentBG: colors.productDark400,
signInSidebar: colors.green800,
sidebar: colors.productDark200,
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index 86d150d15cad..d4819898b83c 100644
--- a/src/styles/theme/themes/light.ts
+++ b/src/styles/theme/themes/light.ts
@@ -9,7 +9,7 @@ const lightTheme = {
splashBG: colors.green400,
highlightBG: colors.productLight200,
border: colors.productLight400,
- borderLighter: colors.productLight600,
+ borderLighter: colors.productLight400,
borderFocus: colors.green400,
icon: colors.productLight700,
iconMenu: colors.green400,
@@ -41,7 +41,7 @@ const lightTheme = {
inverse: colors.productLight900,
shadow: colors.black,
componentBG: colors.productLight100,
- hoverComponentBG: colors.productLight200,
+ hoverComponentBG: colors.productLight300,
activeComponentBG: colors.productLight400,
signInSidebar: colors.green800,
sidebar: colors.productLight200,
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 1dbe0b8587fd..de87d2b5dd59 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1,4 +1,3 @@
-import {CSSProperties} from 'react';
import {Animated, DimensionValue, PressableStateCallbackType, StyleProp, StyleSheet, TextStyle, ViewStyle} from 'react-native';
import {EdgeInsets} from 'react-native-safe-area-context';
import {ValueOf} from 'type-fest';
@@ -31,6 +30,7 @@ import {
EReceiptColorName,
EreceiptColorStyle,
ParsableStyle,
+ TextColorStyle,
WorkspaceColorStyle,
} from './types';
@@ -118,7 +118,7 @@ const avatarFontSizes: Partial> = {
const avatarBorderWidths: Partial> = {
[CONST.AVATAR_SIZE.DEFAULT]: 3,
- [CONST.AVATAR_SIZE.SMALL_SUBSCRIPT]: 1,
+ [CONST.AVATAR_SIZE.SMALL_SUBSCRIPT]: 2,
[CONST.AVATAR_SIZE.MID_SUBSCRIPT]: 2,
[CONST.AVATAR_SIZE.SUBSCRIPT]: 2,
[CONST.AVATAR_SIZE.SMALL]: 2,
@@ -402,7 +402,7 @@ function getBackgroundColorStyle(backgroundColor: string): ViewStyle {
/**
* Returns a style for text color
*/
-function getTextColorStyle(color: string): TextStyle {
+function getTextColorStyle(color: string): TextColorStyle {
return {
color,
};
@@ -620,7 +620,7 @@ function getMinimumHeight(minHeight: number): ViewStyle {
/**
* Get minimum width as style
*/
-function getMinimumWidth(minWidth: number): ViewStyle | CSSProperties {
+function getMinimumWidth(minWidth: number): ViewStyle {
return {
minWidth,
};
@@ -665,11 +665,11 @@ function getHorizontalStackedAvatarBorderStyle({theme, isHovered, isPressed, isI
let borderColor = shouldUseCardBackground ? theme.cardBG : theme.appBG;
if (isHovered) {
- borderColor = isInReportAction ? theme.highlightBG : theme.border;
+ borderColor = isInReportAction ? theme.hoverComponentBG : theme.border;
}
if (isPressed) {
- borderColor = isInReportAction ? theme.highlightBG : theme.buttonPressedBG;
+ borderColor = isInReportAction ? theme.hoverComponentBG : theme.buttonPressedBG;
}
return {borderColor};
@@ -867,7 +867,7 @@ function getEmojiPickerListHeight(hasAdditionalSpace: boolean, windowHeight: num
/**
* Returns padding vertical based on number of lines
*/
-function getComposeTextAreaPadding(numberOfLines: number, isComposerFullSize: boolean): ViewStyle {
+function getComposeTextAreaPadding(numberOfLines: number, isComposerFullSize: boolean): TextStyle {
let paddingValue = 5;
// Issue #26222: If isComposerFullSize paddingValue will always be 5 to prevent padding jumps when adding multiple lines.
if (!isComposerFullSize) {
@@ -913,7 +913,7 @@ function getMenuItemTextContainerStyle(isSmallAvatarSubscriptMenu: boolean): Vie
/**
* Returns color style
*/
-function getColorStyle(color: string): ViewStyle | CSSProperties {
+function getColorStyle(color: string): TextColorStyle {
return {color};
}
diff --git a/src/styles/utils/objectFit.ts b/src/styles/utils/objectFit.ts
index 9d5e4141d6de..51f0c33b5457 100644
--- a/src/styles/utils/objectFit.ts
+++ b/src/styles/utils/objectFit.ts
@@ -1,4 +1,3 @@
-import {CSSProperties} from 'react';
import {ViewStyle} from 'react-native';
export default {
@@ -14,4 +13,4 @@ export default {
oFNone: {
objectFit: 'none',
},
-} satisfies Record;
+} satisfies Record;
diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts
index c8ec7352d463..212d532c1b23 100644
--- a/src/styles/utils/sizing.ts
+++ b/src/styles/utils/sizing.ts
@@ -62,6 +62,10 @@ export default {
maxWidth: 'auto',
},
+ mw75: {
+ maxWidth: '75%',
+ },
+
mw100: {
maxWidth: '100%',
},
diff --git a/src/styles/utils/types.ts b/src/styles/utils/types.ts
index c7e1fc60a142..40a261beee71 100644
--- a/src/styles/utils/types.ts
+++ b/src/styles/utils/types.ts
@@ -42,6 +42,7 @@ type AvatarSize = {width: number};
type WorkspaceColorStyle = {backgroundColor: ColorValue; fill: ColorValue};
type EreceiptColorStyle = {backgroundColor: ColorValue; color: ColorValue};
+type TextColorStyle = {color: string};
export type {
AllStyles,
@@ -56,4 +57,5 @@ export type {
AvatarSize,
WorkspaceColorStyle,
EreceiptColorStyle,
+ TextColorStyle,
};
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 65d7f6a0311d..4904a224327a 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -5,7 +5,7 @@ import {PixelRatio} from 'react-native';
* method always returns the defaultValue (first param). When the device font size increases/decreases, the PixelRatio.getFontScale() value increases/decreases as well.
* This means that if you have text and its 'fontSize' is 19, the device font size changed to the 5th level on the iOS slider and the actual fontSize is 19 * PixelRatio.getFontScale()
* = 19 * 1.11 = 21.09. Since we are disallowing font scaling we need to calculate it manually. We calculate it with: PixelRatio.getFontScale() * defaultValue > maxValue ? maxValue :
- * defaultValue * PixelRatio getFontScale() This means that the fontSize is increased/decreased when the device font size changes up to maxValue (second param)
+ * defaultValue * PixelRatio.getFontScale() This means that the fontSize is increased/decreased when the device font size changes up to maxValue (second param)
*/
function getValueUsingPixelRatio(defaultValue: number, maxValue: number): number {
return PixelRatio.getFontScale() * defaultValue > maxValue ? maxValue : defaultValue * PixelRatio.getFontScale();
@@ -39,9 +39,9 @@ export default {
avatarSizeSmall: 28,
avatarSizeSmaller: 24,
avatarSizeSubscript: 20,
- avatarSizeMidSubscript: 18,
+ avatarSizeMidSubscript: 16,
avatarSizeMentionIcon: 16,
- avatarSizeSmallSubscript: 14,
+ avatarSizeSmallSubscript: 12,
defaultAvatarPreviewSize: 360,
fabBottom: 25,
fontSizeOnlyEmojis: 30,
@@ -138,11 +138,12 @@ export default {
signInLogoHeight: 34,
signInLogoWidth: 120,
signInLogoWidthLargeScreen: 144,
+ signInLogoHeightLargeScreen: 108,
signInLogoWidthPill: 132,
tabSelectorButtonHeight: 40,
tabSelectorButtonPadding: 12,
- lhnLogoWidth: 108,
- lhnLogoHeight: 28,
+ lhnLogoWidth: 95.09,
+ lhnLogoHeight: 22.33,
signInLogoWidthLargeScreenPill: 162,
modalContentMaxWidth: 360,
listItemHeightNormal: 64,
@@ -192,4 +193,6 @@ export default {
cardPreviewHeight: 148,
cardPreviewWidth: 235,
cardNameWidth: 156,
+
+ lhpBorderRadius: 24,
} as const;
diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts
index 5d67da0b885e..25ff1c6c73b8 100644
--- a/src/types/modules/react-native.d.ts
+++ b/src/types/modules/react-native.d.ts
@@ -3,6 +3,7 @@
/* eslint-disable @typescript-eslint/no-empty-interface */
/* eslint-disable @typescript-eslint/consistent-type-definitions */
+// eslint-disable-next-line no-restricted-imports
import {CSSProperties, FocusEventHandler, KeyboardEventHandler, MouseEventHandler, PointerEventHandler, UIEventHandler, WheelEventHandler} from 'react';
import 'react-native';
import {BootSplashModule} from '@libs/BootSplash/types';
@@ -283,7 +284,11 @@ declare module 'react-native' {
enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
readOnly?: boolean;
}
- interface TextInputProps extends WebTextInputProps {}
+ interface TextInputProps extends WebTextInputProps {
+ // TODO: remove once the app is updated to RN 0.73
+ smartInsertDelete?: boolean;
+ isFullComposerAvailable?: boolean;
+ }
/**
* Image
diff --git a/src/types/onyx/Bank.ts b/src/types/onyx/Bank.ts
index b6312e039079..43346f956cb0 100644
--- a/src/types/onyx/Bank.ts
+++ b/src/types/onyx/Bank.ts
@@ -1,4 +1,3 @@
-import {CSSProperties} from 'react';
import {ViewStyle} from 'react-native';
import {SvgProps} from 'react-native-svg';
import {ValueOf} from 'type-fest';
@@ -9,7 +8,7 @@ type BankIcon = {
iconSize?: number;
iconHeight?: number;
iconWidth?: number;
- iconStyles?: Array;
+ iconStyles?: ViewStyle[];
};
type BankName = ValueOf;
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 6123469aa813..19b3e75ca74c 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -193,7 +193,24 @@ type OriginalMessagePolicyTask = {
type OriginalMessageModifiedExpense = {
actionName: typeof CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE;
- originalMessage: unknown;
+ originalMessage: {
+ oldMerchant?: string;
+ merchant?: string;
+ oldCurrency?: string;
+ currency?: string;
+ oldAmount?: number;
+ amount?: number;
+ oldComment?: string;
+ newComment?: string;
+ oldCreated?: string;
+ created?: string;
+ oldCategory?: string;
+ category?: string;
+ oldTag?: string;
+ tag?: string;
+ oldBillable?: string;
+ billable?: string;
+ };
};
type OriginalMessageReimbursementQueued = {
diff --git a/src/types/onyx/PaymentMethod.ts b/src/types/onyx/PaymentMethod.ts
index 4a9722911bf9..f62234b021b9 100644
--- a/src/types/onyx/PaymentMethod.ts
+++ b/src/types/onyx/PaymentMethod.ts
@@ -1,4 +1,3 @@
-import {CSSProperties} from 'react';
import {ViewStyle} from 'react-native';
import {SvgProps} from 'react-native-svg';
import BankAccount from './BankAccount';
@@ -10,7 +9,7 @@ type PaymentMethod = (BankAccount | Fund) & {
iconSize?: number;
iconHeight?: number;
iconWidth?: number;
- iconStyles?: Array;
+ iconStyles?: ViewStyle[];
};
export default PaymentMethod;
diff --git a/src/types/onyx/PersonalDetails.ts b/src/types/onyx/PersonalDetails.ts
index bd2599fee0ca..9f613cbf4f1e 100644
--- a/src/types/onyx/PersonalDetails.ts
+++ b/src/types/onyx/PersonalDetails.ts
@@ -73,7 +73,7 @@ type PersonalDetails = {
status?: string;
};
-type PersonalDetailsList = Record;
+type PersonalDetailsList = Record;
export default PersonalDetails;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index 110bdb024a8c..9967f49fd377 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -57,7 +57,6 @@ import WalletTransfer from './WalletTransfer';
export type {
Account,
- UserLocation,
AccountData,
AddDebitCardForm,
BankAccount,
@@ -89,16 +88,16 @@ export type {
PersonalDetailsList,
PlaidData,
Policy,
- PolicyCategory,
PolicyCategories,
+ PolicyCategory,
PolicyMember,
PolicyMembers,
PolicyTag,
PolicyTags,
PrivatePersonalDetails,
+ RecentWaypoint,
RecentlyUsedCategories,
RecentlyUsedTags,
- RecentWaypoint,
ReimbursementAccount,
ReimbursementAccountDraft,
Report,
@@ -116,6 +115,7 @@ export type {
Transaction,
TransactionViolation,
User,
+ UserLocation,
UserWallet,
ViolationName,
WalletAdditionalDetails,
diff --git a/tests/perf-test/ModifiedExpenseMessage.perf-test.ts b/tests/perf-test/ModifiedExpenseMessage.perf-test.ts
new file mode 100644
index 000000000000..1997d55d8a05
--- /dev/null
+++ b/tests/perf-test/ModifiedExpenseMessage.perf-test.ts
@@ -0,0 +1,64 @@
+import {randAmount} from '@ngneat/falso';
+import Onyx from 'react-native-onyx';
+import {measureFunction} from 'reassure';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import {Policy, Report} from '@src/types/onyx';
+import ModifiedExpenseMessage from '../../src/libs/ModifiedExpenseMessage';
+import createCollection from '../utils/collections/createCollection';
+import createRandomPolicy from '../utils/collections/policies';
+import createRandomReportAction from '../utils/collections/reportActions';
+import createRandomReport from '../utils/collections/reports';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+const runs = CONST.PERFORMANCE_TESTS.RUNS;
+
+beforeAll(() =>
+ Onyx.init({
+ keys: ONYXKEYS,
+ safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
+ }),
+);
+
+// Clear out Onyx after each test so that each test starts with a clean state
+afterEach(() => {
+ Onyx.clear();
+});
+
+const getMockedReports = (length = 500) =>
+ createCollection(
+ (item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`,
+ (index) => createRandomReport(index),
+ length,
+ );
+
+const getMockedPolicies = (length = 500) =>
+ createCollection(
+ (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`,
+ (index) => createRandomPolicy(index),
+ length,
+ );
+
+const mockedReportsMap = getMockedReports(5000) as Record<`${typeof ONYXKEYS.COLLECTION.REPORT}`, Report>;
+const mockedPoliciesMap = getMockedPolicies(5000) as Record<`${typeof ONYXKEYS.COLLECTION.POLICY}`, Policy>;
+
+test('[ModifiedExpenseMessage] getForReportAction on 5k reports and policies', async () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ amount: randAmount(),
+ currency: CONST.CURRENCY.USD,
+ oldAmount: randAmount(),
+ oldCurrency: CONST.CURRENCY.USD,
+ },
+ };
+
+ await Onyx.multiSet({
+ ...mockedPoliciesMap,
+ ...mockedReportsMap,
+ });
+
+ await waitForBatchedUpdates();
+ await measureFunction(() => ModifiedExpenseMessage.getForReportAction(reportAction), {runs});
+});
diff --git a/tests/perf-test/ReportUtils.perf-test.ts b/tests/perf-test/ReportUtils.perf-test.ts
index ab6ee72a0082..b931ae85a7da 100644
--- a/tests/perf-test/ReportUtils.perf-test.ts
+++ b/tests/perf-test/ReportUtils.perf-test.ts
@@ -1,4 +1,3 @@
-import {randAmount} from '@ngneat/falso';
import Onyx from 'react-native-onyx';
import {measureFunction} from 'reassure';
import * as ReportUtils from '@libs/ReportUtils';
@@ -133,29 +132,6 @@ test('[ReportUtils] getReportPreviewMessage on 5k policies', async () => {
await measureFunction(() => ReportUtils.getReportPreviewMessage(report, reportAction, shouldConsiderReceiptBeingScanned, isPreviewMessageForParentChatReport, policy), {runs});
});
-test('[ReportUtils] getModifiedExpenseMessage on 5k reports and policies', async () => {
- const reportAction = {
- ...createRandomReportAction(1),
- actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
- originalMessage: {
- originalMessage: {
- amount: randAmount(),
- currency: CONST.CURRENCY.USD,
- oldAmount: randAmount(),
- oldCurrency: CONST.CURRENCY.USD,
- },
- },
- };
-
- await Onyx.multiSet({
- ...mockedPoliciesMap,
- ...mockedReportsMap,
- });
-
- await waitForBatchedUpdates();
- await measureFunction(() => ReportUtils.getModifiedExpenseMessage(reportAction), {runs});
-});
-
test('[ReportUtils] getReportName on 1k participants', async () => {
const report = {...createRandomReport(1), chatType: undefined, participantAccountIDs};
const policy = createRandomPolicy(1);
diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts
new file mode 100644
index 000000000000..02990aa5c751
--- /dev/null
+++ b/tests/unit/ModifiedExpenseMessageTest.ts
@@ -0,0 +1,279 @@
+import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage';
+import CONST from '@src/CONST';
+import createRandomReportAction from '../utils/collections/reportActions';
+
+describe('ModifiedExpenseMessage', () => {
+ describe('getForAction', () => {
+ describe('when the amount is changed', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ amount: 1800,
+ currency: CONST.CURRENCY.USD,
+ oldAmount: 1255,
+ oldCurrency: CONST.CURRENCY.USD,
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = `changed the amount to $18.00 (previously $12.55).`;
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the amount is changed and the description is removed', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ amount: 1800,
+ currency: CONST.CURRENCY.USD,
+ oldAmount: 1255,
+ oldCurrency: CONST.CURRENCY.USD,
+ newComment: '',
+ oldComment: 'this is for the shuttle',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = 'changed the amount to $18.00 (previously $12.55).\nremoved the description (previously "this is for the shuttle").';
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the amount is changed, the description is removed, and category is set', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ amount: 1800,
+ currency: CONST.CURRENCY.USD,
+ oldAmount: 1255,
+ oldCurrency: CONST.CURRENCY.USD,
+ newComment: '',
+ oldComment: 'this is for the shuttle',
+ category: 'Benefits',
+ oldCategory: '',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = 'changed the amount to $18.00 (previously $12.55).\nset the category to "Benefits".\nremoved the description (previously "this is for the shuttle").';
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the amount and merchant are changed', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ merchant: 'Taco Bell',
+ oldMerchant: 'Big Belly',
+ amount: 1800,
+ currency: CONST.CURRENCY.USD,
+ oldAmount: 1255,
+ oldCurrency: CONST.CURRENCY.USD,
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = 'changed the amount to $18.00 (previously $12.55) and the merchant to "Taco Bell" (previously "Big Belly").';
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the amount and merchant are changed, the description is removed, and category is set', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ merchant: 'Taco Bell',
+ oldMerchant: 'Big Belly',
+ amount: 1800,
+ currency: CONST.CURRENCY.USD,
+ oldAmount: 1255,
+ oldCurrency: CONST.CURRENCY.USD,
+ newComment: '',
+ oldComment: 'this is for the shuttle',
+ category: 'Benefits',
+ oldCategory: '',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult =
+ 'changed the amount to $18.00 (previously $12.55) and the merchant to "Taco Bell" (previously "Big Belly").\nset the category to "Benefits".\nremoved the description (previously "this is for the shuttle").';
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the amount, comment and merchant are changed', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ merchant: 'Taco Bell',
+ oldMerchant: 'Big Belly',
+ amount: 1800,
+ currency: CONST.CURRENCY.USD,
+ oldAmount: 1255,
+ oldCurrency: CONST.CURRENCY.USD,
+ newComment: 'I bought it on the way',
+ oldComment: 'from the business trip',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult =
+ 'changed the amount to $18.00 (previously $12.55), the description to "I bought it on the way" (previously "from the business trip"), and the merchant to "Taco Bell" (previously "Big Belly").';
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the merchant is removed', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ merchant: '',
+ oldMerchant: 'Big Belly',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = `removed the merchant (previously "Big Belly").`;
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the merchant and the description are removed', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ merchant: '',
+ oldMerchant: 'Big Belly',
+ newComment: '',
+ oldComment: 'minishore',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = `removed the description (previously "minishore") and the merchant (previously "Big Belly").`;
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the merchant, the category and the description are removed', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ merchant: '',
+ oldMerchant: 'Big Belly',
+ newComment: '',
+ oldComment: 'minishore',
+ category: '',
+ oldCategory: 'Benefits',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = `removed the description (previously "minishore"), the merchant (previously "Big Belly"), and the category (previously "Benefits").`;
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the merchant is set', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ oldMerchant: '',
+ merchant: 'Big Belly',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = `set the merchant to "Big Belly".`;
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the merchant and the description are set', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ oldMerchant: '',
+ merchant: 'Big Belly',
+ oldComment: '',
+ newComment: 'minishore',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = `set the description to "minishore" and the merchant to "Big Belly".`;
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('when the merchant, the category and the description are set', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ originalMessage: {
+ oldMerchant: '',
+ merchant: 'Big Belly',
+ oldComment: '',
+ newComment: 'minishore',
+ oldCategory: '',
+ category: 'Benefits',
+ },
+ };
+
+ it('returns the correct text message', () => {
+ const expectedResult = `set the description to "minishore", the merchant to "Big Belly", and the category to "Benefits".`;
+
+ const result = ModifiedExpenseMessage.getForReportAction(reportAction);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+ });
+});
diff --git a/tests/unit/PhoneNumberTest.js b/tests/unit/PhoneNumberTest.js
new file mode 100644
index 000000000000..f720dc6a88e1
--- /dev/null
+++ b/tests/unit/PhoneNumberTest.js
@@ -0,0 +1,43 @@
+import {parsePhoneNumber} from '@libs/PhoneNumber';
+
+describe('PhoneNumber', () => {
+ describe('parsePhoneNumber', () => {
+ it('Should return valid phone number', () => {
+ const validNumbers = [
+ '+1 (234) 567-8901',
+ '+12345678901',
+ '+54 11 8765-4321',
+ '+49 30 123456',
+ '+44 20 8759 9036',
+ '+34 606 49 95 99',
+ ' + 1 2 3 4 5 6 7 8 9 0 1',
+ '+ 4 4 2 0 8 7 5 9 9 0 3 6',
+ '+1 ( 2 3 4 ) 5 6 7 - 8 9 0 1',
+ ];
+
+ validNumbers.forEach((givenPhone) => {
+ const parsedPhone = parsePhoneNumber(givenPhone);
+ expect(parsedPhone.valid).toBe(true);
+ expect(parsedPhone.possible).toBe(true);
+ });
+ });
+ it('Should return invalid phone number if US number has extra 1 after country code', () => {
+ const validNumbers = ['+1 1 (234) 567-8901', '+112345678901', '+115550123355', '+ 1 1 5 5 5 0 1 2 3 3 5 5'];
+
+ validNumbers.forEach((givenPhone) => {
+ const parsedPhone = parsePhoneNumber(givenPhone);
+ expect(parsedPhone.valid).toBe(false);
+ expect(parsedPhone.possible).toBe(false);
+ });
+ });
+ it('Should return invalid phone number', () => {
+ const invalidNumbers = ['+165025300001', 'John Doe', '123', 'email@domain.com'];
+
+ invalidNumbers.forEach((givenPhone) => {
+ const parsedPhone = parsePhoneNumber(givenPhone);
+ expect(parsedPhone.valid).toBe(false);
+ expect(parsedPhone.possible).toBe(false);
+ });
+ });
+ });
+});