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/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml
index 82cd62c5e832..975f1f69e219 100644
--- a/.github/workflows/deployExpensifyHelp.yml
+++ b/.github/workflows/deployExpensifyHelp.yml
@@ -52,6 +52,14 @@ jobs:
projectName: helpdot
directory: ./docs/_site
+ - name: Setup Cloudflare CLI
+ run: pip3 install cloudflare
+
+ - name: Purge Cloudflare cache
+ run: /home/runner/.local/bin/cli4 --delete hosts=["help.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache
+ env:
+ CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }}
+
- name: Leave a comment on the PR
uses: actions-cool/maintain-one-comment@de04bd2a3750d86b324829a3ff34d47e48e16f4b
if: ${{ github.event_name == 'pull_request' }}
diff --git a/Gemfile.lock b/Gemfile.lock
index 93dab195ebdd..fcf4f878e2de 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -81,7 +81,8 @@ GEM
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
- domain_name (0.6.20231109)
+ domain_name (0.5.20190701)
+ unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
escape (0.0.4)
@@ -261,6 +262,9 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
+ unf (0.1.4)
+ unf_ext
+ unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
webrick (1.8.1)
word_wrap (1.0.0)
@@ -294,4 +298,4 @@ RUBY VERSION
ruby 2.6.10p210
BUNDLED WITH
- 2.1.4
+ 2.4.7
diff --git a/__mocks__/@ua/react-native-airship.js b/__mocks__/@ua/react-native-airship.js
index 29be662e96a1..65e7c1a8b97e 100644
--- a/__mocks__/@ua/react-native-airship.js
+++ b/__mocks__/@ua/react-native-airship.js
@@ -28,6 +28,7 @@ const Airship = {
enableUserNotifications: () => Promise.resolve(false),
clearNotifications: jest.fn(),
getNotificationStatus: () => Promise.resolve({airshipOptIn: false, systemEnabled: false}),
+ getActiveNotifications: () => Promise.resolve([]),
},
contact: {
identify: jest.fn(),
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 685e9f206eb4..b7ed29f176dc 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 1001041200
- versionName "1.4.12-0"
+ versionCode 1001041402
+ versionName "1.4.14-2"
}
flavorDimensions "default"
diff --git a/android/app/src/main/java/com/expensify/chat/MainApplication.java b/android/app/src/main/java/com/expensify/chat/MainApplication.java
index a4f2bc97416d..bc76be739949 100644
--- a/android/app/src/main/java/com/expensify/chat/MainApplication.java
+++ b/android/app/src/main/java/com/expensify/chat/MainApplication.java
@@ -3,7 +3,6 @@
import android.content.Context;
import android.database.CursorWindow;
-import androidx.appcompat.app.AppCompatDelegate;
import androidx.multidex.MultiDexApplication;
import com.expensify.chat.bootsplash.BootSplashPackage;
@@ -67,9 +66,6 @@ public ReactNativeHost getReactNativeHost() {
public void onCreate() {
super.onCreate();
- // Use night (dark) mode so native UI defaults to dark theme.
- AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
-
SoLoader.init(this, /* native exopackage */ false);
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
diff --git a/assets/animations/Fireworks.lottie b/assets/animations/Fireworks.lottie
index f5a782c62f3a..142efdcd8fdc 100644
Binary files a/assets/animations/Fireworks.lottie and b/assets/animations/Fireworks.lottie differ
diff --git a/assets/animations/ReviewingBankInfo.lottie b/assets/animations/ReviewingBankInfo.lottie
index 93addc052e8b..a9974366cae7 100644
Binary files a/assets/animations/ReviewingBankInfo.lottie and b/assets/animations/ReviewingBankInfo.lottie differ
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/desktop/package-lock.json b/desktop/package-lock.json
index 39b186beb022..6f62e6a5ba00 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -8,7 +8,7 @@
"license": "MIT",
"dependencies": {
"electron-context-menu": "^2.3.0",
- "electron-log": "^4.4.7",
+ "electron-log": "^4.4.8",
"electron-serve": "^1.2.0",
"electron-updater": "^6.1.6",
"node-machine-id": "^1.1.12"
@@ -140,9 +140,9 @@
"integrity": "sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw=="
},
"node_modules/electron-log": {
- "version": "4.4.7",
- "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.7.tgz",
- "integrity": "sha512-uFZQdgevOp9Fn5lDOrJMU/bmmYxDLZitbIHJM7VXN+cpB59ZnPt1FQL4bOf/Dl2gaIMPYJEfXx38GvJma5iV6A=="
+ "version": "4.4.8",
+ "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.8.tgz",
+ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
},
"node_modules/electron-serve": {
"version": "1.2.0",
@@ -531,9 +531,9 @@
"integrity": "sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw=="
},
"electron-log": {
- "version": "4.4.7",
- "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.7.tgz",
- "integrity": "sha512-uFZQdgevOp9Fn5lDOrJMU/bmmYxDLZitbIHJM7VXN+cpB59ZnPt1FQL4bOf/Dl2gaIMPYJEfXx38GvJma5iV6A=="
+ "version": "4.4.8",
+ "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.8.tgz",
+ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
},
"electron-serve": {
"version": "1.2.0",
diff --git a/desktop/package.json b/desktop/package.json
index 7689c18f0dbd..7545e4b57dba 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -5,7 +5,7 @@
"scripts": {},
"dependencies": {
"electron-context-menu": "^2.3.0",
- "electron-log": "^4.4.7",
+ "electron-log": "^4.4.8",
"electron-serve": "^1.2.0",
"electron-updater": "^6.1.6",
"node-machine-id": "^1.1.12"
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/articles/expensify-classic/integrations/accounting-integrations/Certinia.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Certinia.md
new file mode 100644
index 000000000000..65361ba1af9a
--- /dev/null
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/Certinia.md
@@ -0,0 +1,150 @@
+---
+title: Certinia
+description: Guide to connecting Expensify and Certinia FFA and PSA/SRP (formerly known as FinancialForce)
+---
+# Overview
+[Cetinia](https://use.expensify.com/financialforce) (Formerly known as FinancialForce)is a cloud-based software solution that provides a range of financial management and accounting applications built on the Salesforce platform. There are two versions: PSA/SRP and FFA and we support both.
+
+# Before connecting to Certinia
+Install the Expensify bundle in Certinia using the relevant installer:
+* [PSA/SRP](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t2M000002J0BHD%252Fpackaging%252FinstallPackage.apexp%253Fp0%253D04t2M000002J0BH)
+* [FFA](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t4p000001UQVj)
+
+## Check contact details in Certinia
+First, make sure you have a user and contact in Certinia that match your main email in Expensify. Then, create contacts for all employees who will be sending expense reports. Ensure that each contact's email matches the one they use in their Expensify account.
+
+## If you use PSA/SRP
+Each report approver needs both a User and a Contact. The user does not need to have a SalesForce license. These can be free chatter users.
+Set permission controls in Certinia for your user for each contact/resource.
+* Go to Permission Controls
+ - Create a new permission control
+ - Set yourself (exporter) as the user
+ - Select the resource (report submitter)
+ - Grant all available permissions
+* Set permissions on any project you are exporting to
+ - Go to **Projects** > _select a project_ > **Project Attributes** > **Allow Expenses Without Assignment**
+ - Select the project > **Edit**
+ - Under the Project Attributes section, check **Allow Expenses Without Assignment**
+* Set up Expense Types (categories in Expensify - _SRP only_)
+ - Go to **Main Menu** > _+ symbol_ > **Expense Type GLA Mappings**
+ - Click **New** to add new mappings
+
+# How to connect to Certinia
+1. Go to **Settings** > **Workspaces** > **Groups** > _[Workspace Name]_ > **Connections** in Expensify
+2. Click **Create a New Certinia (FinancialForce) Connection**
+3. Log into your Certinia account
+4. Expensify and Certinia will begin to sync (in Expensify)
+
+# How to configure export settings for Certinia
+## Preferred Exporter
+The preferred exporter is the user who will be the main exporter of reports. This person will receive the notifications for errors.
+
+## Payable Invoice Status and Date
+Reports can be exported as Complete or In Progress, using date of last expense, submitted date or exported date.
+
+## Reimbursable and non-reimbursable exports
+Both reimbursable and non-reimbursable reports are exported as payable invoices (FFA) or expense reports (PSA/SRP). If you have both Reimbursable and Non-Reimbursable expenses on a single report, we will create a separate payable invoice/expense report for each type.
+
+## Default Vendor (FFA)
+Choose from the full list of vendors from your Certinia FFA account, this will be applied to the non-reimbursable payable invoices.
+
+# How to Configure coding for Certinia
+## Company
+Select which FinancialForce company to import from/export to.
+
+## Chart of Accounts (FFA)
+Prepaid Expense Type and Profit & Loss accounts are imported to be used as categories on each expense.
+
+## Expense Type GLA Mappings (PSA/SRP)
+Your Expense Type GLA Mappings are enabled in Expensify to use as categories on each expense when using both PSA and SRP; however, PSA will not import or export categories, while SRP will.
+
+## Dimensions (FFA)
+We import four dimension levels and each has three options to select from:
+
+* Do not map: FinancialForce defaults will apply to the payable invoice, without importing into Expensify
+* Tags: These are shown in the Tag section of your workspace, and employees can select them on each expense created
+* Report fields: These will show in the Reports section of your workspace. Employees can select one to be applied at the header level i.e. the entire report.
+
+## Projects, Assignments, or Projects & Assignments (PSA/SRP)
+These can be imported as tags with **Milestones** being optional. When selecting to import only projects, we will derive the account from the project. If an assignment is selected, we will derive both the account and project from the assignment.
+
+Note: If you are using a project that does not have an assignment, the box **Allow Expenses Without Assignment** must be checked on the project in FinancialForce.
+
+## Tax
+Import tax rates from Certinia to apply to expenses.
+
+# How to configure advanced settings for Certinia
+## Auto Sync
+Auto Sync in Certinia performs daily updates to your coding. Additionally, it automatically exports reports after they receive final approval. For Non-Reimbursable expenses, syncing happens immediately upon final approval of the report. In the case of Reimbursable expenses, syncing occurs as soon as the report is reimbursed or marked as reimbursed.
+
+## Export tax as non-billable
+When exporting Billable expenses, this dictates whether you will also bill the tax component to your clients/customers.
+
+# Deep Dive
+## Multi-Currency in Certinia PSA/SRP
+When exporting to Certinia PSA/SRP you may see up to three different currencies on the expense report in Certinia, if employees are submitting expenses in more than one original currency.
+* Summary Total Reimbursement Amount: this currency is derived from the currency of the project selected on the expense.
+* Amount field on the Expense line: this currency is derived from the Expensify workspace default report currency.
+* Reimbursable Amount on the Expense line: this currency is derived from the currency of the resource with an email matching the report submitter.
+
+# FAQ
+## What happens if the report can’t be exported to Certinia?
+* The preferred exporter will receive an email outlining the issue and any specific error messages
+* Any error messages preventing the export from taking place will be recorded in the report’s history
+* The report will be listed in the exporter’s Expensify Inbox as awaiting export.
+
+## If I enable Auto Sync, what happens to existing approved and reimbursed reports?
+You can activate Auto Sync without worry because it relies on Final Approval to trigger auto-export. Existing Approved reports won't be affected. However, for Approved reports that haven't been exported to Certinia, you'll need to either manually export them or mark them as manually entered.
+
+## How do I export tax?
+Tax rates are created in Expensify through the tax tracking feature under **Settings** > **Workspaces** > **Groups** > _[Workspace Name]_ > **Tax**. We export the tax amount calculated on the expenses.
+
+## How do reports map to Payable Invoices in Certinia FFA?
+* Account Name - Account associated with Expensify submitter’s email address
+* Reference 1 - Report URL
+* Invoice Description - Report title
+
+## How do reports map to Expense Reports in Certinia PSA/SRP?
+* Expense report name - Report title
+* Resource - User associated with Expensify submitter’s email address
+* Description - Report URL
+* Approver - Expensify report approver
+
+# Sync and Export Errors
+
+## ExpensiError FF0047: You must have an Ops Edit permission to edit approved records.
+This error indicates that the permission control setup between the connected user and the report submitter or region is missing Ops Edit permission.
+
+In Certinia go to Permission Controls and click the one you need to edit. Make sure that Expense Ops Edit is selected under Permissions.
+
+## ExpensiError FF0076: Could not find employee in Certinia
+Go to Contacts in Certinia and add the report creator/submitter's Expensify email address to their employee record, or create a record with that email listed.
+
+If a record already exists then search for their email address to confirm it is not associated with multiple records.
+
+## ExpensiError FF0089: Expense Reports for this Project require an Assignment
+This error indicates that the project needs to have the permissions adjusted in Certinia
+
+Go to Projects > [project name] > Project Attributes and check Allow Expense Without Assignment.
+
+## ExpensiError FF0091: Bad Field Name — [field] is invalid for [object]
+This means the field in question is not accessible to the user profile in Certinia for the user whose credentials were used to make the connection within Expensify.
+
+To correct this:
+* Go to Setup > Build > expand Create > Object within Certinia
+* Then go to Payable Invoice > Custom Fields and Relationships
+* Click View Field Accessibility
+* Find the employee profile in the list and select Hidden
+* Make sure both checkboxes for Visible are selected
+
+Once this step has been completed, sync the connection within Expensify by going to **Settings** > **Workspaces** > **Groups** > _[Workspace Name]_ > **Connections** > **Sync Now** and then attempt to export the report again.
+
+## ExpensiError FF0132: Insufficient access. Make sure you are connecting to Certinia with a user that has the 'Modify All Data' permission
+
+Log into Certinia and go to Setup > Manage Users > Users and find the user whose credentials made the connection.
+
+* Click on their profile on the far right side of the page
+* Go to System > System Permissions
+* Enable Modify All Data and save
+
+Sync the connection within Expensify by going to **Settings** > **Workspaces** > **Groups** > _[Workspace Name]_ > **Connections** > **Sync Now** and then attempt to export the report again
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/FinancalForce.md b/docs/articles/expensify-classic/integrations/accounting-integrations/FinancalForce.md
deleted file mode 100644
index 18c78ac7fc10..000000000000
--- a/docs/articles/expensify-classic/integrations/accounting-integrations/FinancalForce.md
+++ /dev/null
@@ -1,111 +0,0 @@
----
-title: Financial Force
-description: Guide to connecting Expensify and FinancialForce FFA and PSA/SRP
----
-# Overview
-[FinancialForce](https://use.expensify.com/financialforce) is a cloud-based software solution that provides a range of financial management and accounting applications built on the Salesforce platform. There are two versions: PSA/SRP and FFA and we support both.
-
-# Before connecting to FinancialForce
-Install the Expensify bundle in SalesForce using the relevant installer:
-* [PSA/SRP](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t2M000002J0BHD%252Fpackaging%252FinstallPackage.apexp%253Fp0%253D04t2M000002J0BH)
-* [FFA](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t4p000001UQVj)
-
-## Check contact details in FF
-First, make sure you have a user and contact in FinancialForce that match your main email in Expensify. Then, create contacts for all employees who will be sending expense reports. Ensure that each contact's email matches the one they use in their Expensify account.
-
-## If you use PSA/SRP
-Each report approver needs both a User and a Contact. The user does not need to have a SalesForce license. These can be free chatter users.
-Set permission controls in FinancialForce for your user for each contact/resource.
-* Go to Permission Controls
- - Create a new permission control
- - Set yourself (exporter) as the user
- - Select the resource (report submitter)
- - Grant all available permissions
-* Set permissions on any project you are exporting to
- - Go to **Projects** > _select a project_ > **Project Attributes** > **Allow Expenses Without Assignment**
- - Select the project > **Edit**
- - Under the Project Attributes section, check **Allow Expenses Without Assignment**
-* Set up Expense Types (categories in Expensify - _SRP only_)
- - Go to **Main Menu** > _+ symbol_ > **Expense Type GLA Mappings**
- - Click **New** to add new mappings
-
-# How to connect to FinancialForce
-1. Go to **Settings** > **Policies** > **Groups** > _[Policy Name]_ > **Connections in Expensify**
-2. Click **Create a New FinancialForce Connection**
-3. Log into your FinancialForce account
-4. Expensify and FinancialForce will begin to sync (in Expensify)
-
-# How to configure export settings for FinancialForce
-## Preferred Exporter
-The preferred exporter is the user who will be the main exporter of reports. This person will receive the notifications for errors.
-
-## Payable Invoice Status and Date
-Reports can be exported as Complete or In Progress, using date of last expense, submitted date or exported date.
-
-## Reimbursable and non-reimbursable exports
-Both reimbursable and non-reimbursable reports are exported as payable invoices (FFA) or expense reports (PSA/SRP). If you have both Reimbursable and Non-Reimbursable expenses on a single report, we will create a separate payable invoice/expense report for each type.
-
-## Default Vendor (FFA)
-Choose from the full list of vendors from your FinancialForce FFA account, this will be applied to the non-reimbursable payable invoices.
-
-# How to Configure coding for Financial Force
-## Company
-Select which FinancialForce company to import from/export to.
-
-## Chart of Accounts (FFA)
-Prepaid Expense Type and Profit & Loss accounts are imported to be used as categories on each expense.
-
-## Expense Type GLA Mappings (PSA/SRP)
-Your Expense Type GLA Mappings are enabled in Expensify to use as categories on each expense when using both PSA and SRP; however, PSA will not import or export categories, while SRP will.
-
-## Dimensions (FFA)
-We import four dimension levels and each has three options to select from:
-
-* Do not map: FinancialForce defaults will apply to the payable invoice, without importing into Expensify
-* Tags: These are shown in the Tag section of your policy, and employees can select them on each expense created
-* Report fields: These will show in the Reports section of your policy. Employees can select one to be applied at the header level i.e. the entire report.
-
-## Projects, Assignments, or Projects & Assignments (PSA/SRP)
-These can be imported as tags with **Milestones** being optional. When selecting to import only projects, we will derive the account from the project. If an assignment is selected, we will derive both the account and project from the assignment.
-
-Note: If you are using a project that does not have an assignment, the box **Allow Expenses Without Assignment** must be checked on the project in FinancialForce.
-
-## Tax
-Import tax rates from FinancialForce to apply to expenses.
-
-# How to configure advanced settings for Financial Force
-## Auto Sync
-Auto Sync in FinancialForce performs daily updates to your coding. Additionally, it automatically exports reports after they receive final approval. For Non-Reimbursable expenses, syncing happens immediately upon final approval of the report. In the case of Reimbursable expenses, syncing occurs as soon as the report is reimbursed or marked as reimbursed.
-
-## Export tax as non-billable
-When exporting Billable expenses, this dictates whether you will also bill the tax component to your clients/customers.
-
-# Deep Dive
-## Multi-Currency in FinancialForce PSA/SRP
-When exporting to FinancialForce PSA/SRP you may see up to three different currencies on the expense report in FinancialForce, if employees are submitting expenses in more than one original currency.
-* Summary Total Reimbursement Amount: this currency is derived from the currency of the project selected on the expense.
-* Amount field on the Expense line: this currency is derived from the Expensify policy default report currency.
-* Reimbursable Amount on the Expense line: this currency is derived from the currency of the resource with an email matching the report submitter.
-
-# FAQ
-## What happens if the report can’t be exported to FinancialForce?
-* The preferred exporter will receive an email outlining the issue and any specific error messages
-* Any error messages preventing the export from taking place will be recorded in the report’s history
-* The report will be listed in the exporter’s Expensify Inbox as awaiting export.
-
-## If I enable Auto Sync, what happens to existing approved and reimbursed reports?
-You can activate Auto Sync without worry because it relies on Final Approval to trigger auto-export. Existing Approved reports won't be affected. However, for Approved reports that haven't been exported to FinancialForce, you'll need to either manually export them or mark them as manually entered.
-
-## How do I export tax?
-Tax rates are created in Expensify through the tax tracking feature under **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Tax**. We export the tax amount calculated on the expenses.
-
-## How do reports map to Payable Invoices in FinancialForce FFA?
-* Account Name - Account associated with Expensify submitter’s email address
-* Reference 1 - Report URL
-* Invoice Description - Report title
-
-## How do reports map to Expense Reports in FinancialForce PSA/SRP?
-* Expense report name - Report title
-* Resource - User associated with Expensify submitter’s email address
-* Description - Report URL
-* Approver - Expensify report approver
diff --git a/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md
new file mode 100644
index 000000000000..3c5bc0fe2421
--- /dev/null
+++ b/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md
@@ -0,0 +1,56 @@
+---
+title: Budgets
+description: Track employee spending across categories and tags by using Expensify's Budgets feature.
+---
+
+# About
+Expensify’s Budgets feature allows you to:
+- Set monthly and yearly budgets
+- Track spending across categories and tags on an individual and workspace basis
+- Get notified when a budget has met specific thresholds
+
+# How-to
+## Category Budgets
+1. Navigate to **Settings > Group > [Workspace Name] > Categories**
+2. Click the **Edit Rules** button for the category you want to add a budget to
+3. Select the **Budget** tab at the top of the modal that opens
+4. Click the switch next to **Enable Budget**
+5. Once enabled, you will see additional settings to configure:
+ - **Budget frequency**: you can select if you want this to be a monthly or a yearly budget
+ - **Total workspace budget**: you can enter an amount if you want to set a budget for the entire workspace
+ - **Per individual budget**: you can enter an amount if you want to set a budget per person
+ - **Notification threshold** - this is the % in which you will be notified as the budgets are hit
+
+## Single-level Tag Budgets
+1. Navigate to **Settings > Group > [Workspace Name] > Tags**
+2. Click **Edit Budget** next to the tag you want to add a budget to
+3. Click the switch next to **Enable Budget**
+4. Once enabled, you will see additional settings to configure:
+ - **Budget frequency**: you can select if you want this to be a monthly or a yearly budget
+ - **Total workspace budget**: you can enter an amount if you want to set a budget for the entire workspace
+ - **Per individual budget**: you can enter an amount if you want to set a budget per person
+ - **Notification threshold** - this is the % in which you will be notified as the budgets are hit
+
+## Multi-level Tag Budgets
+1. Navigate to **Settings > Group > [Workspace Name] > Tags**
+2. Click the **Edit Tags** button
+3. Click the **Edit Budget** button next to the subtag you want to apply a budget to
+4. Click the switch next to **Enable Budget**
+5. Once enabled, you will see additional settings to configure:
+ - **Budget frequency**: you can select if you want this to be a monthly or a yearly budget
+ - **Total workspace budget**: you can enter an amount if you want to set a budget for the entire workspace
+ - **Per individual budget**: you can enter an amount if you want to set a budget per person
+ - **Notification threshold** - this is the % in which you will be notified as the budgets are hit
+
+# FAQ
+## Can I import budgets as a CSV?
+At this time, you cannot import budgets via CSV since we don’t import categories or tags from direct accounting integrations.
+
+## When will I be notified as a budget is hit?
+Notifications are sent twice:
+ - When your notification threshold is hit (i.e, if you set this as 50%, you’ll be notified when 50% of the budget is met)
+ - When 100% of the budget is met
+
+## How will I be notified when a budget is hit?
+A message will be sent in the #admins room of the Workspace.
+
diff --git a/docs/articles/new-expensify/get-paid-back/Referral-Program.md b/docs/articles/new-expensify/get-paid-back/Referral-Program.md
index 34a35f5dc7c8..6ffb923aeb76 100644
--- a/docs/articles/new-expensify/get-paid-back/Referral-Program.md
+++ b/docs/articles/new-expensify/get-paid-back/Referral-Program.md
@@ -12,13 +12,16 @@ As a thank you, every time you bring a new customer into New Expensify, you'll g
# How to get paid to refer anyone to New Expensify
-The sky's the limit for this referral program! Your referral can be anyone - a friend, family member, boss, coworker, neighbor, or even social media follower. We're making it as easy as possible to get that cold hard referral $$$.
+The sky's the limit for this referral program! Your referral can be anyone - a friend, family member, boss, coworker, neighbor, or even social media follower. We're making it as easy as possible to get that cold hard $$$.
-1. There are a bunch of different ways to kick off a referral in New Expensify:
+1. There are a bunch of different ways to refer someone to New Expensify:
- Start a chat
- Request money
- Send money
- - @ mention someone
+ - Split a bill
+ - Assign them a task
+ - @ mention them
+ - Invite them to a room
- Add them to a workspace
2. You'll get $250 for each referral as long as:
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 f4ef6d22bea6..67c47dbb30cf 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.4.12
+ 1.4.14
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.12.0
+ 1.4.14.2
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
@@ -120,8 +120,6 @@
UIInterfaceOrientationPortrait
UIInterfaceOrientationPortraitUpsideDown
- UIUserInterfaceStyle
- Dark
UIViewControllerBasedStatusBarAppearance
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index c7fb13979540..d13112319dd6 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.4.12
+ 1.4.14
CFBundleSignature
????
CFBundleVersion
- 1.4.12.0
+ 1.4.14.2
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 390511397b0e..0fac30a26430 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1,25 +1,25 @@
PODS:
- - Airship (16.11.3):
- - Airship/Automation (= 16.11.3)
- - Airship/Basement (= 16.11.3)
- - Airship/Core (= 16.11.3)
- - Airship/ExtendedActions (= 16.11.3)
- - Airship/MessageCenter (= 16.11.3)
- - Airship/Automation (16.11.3):
+ - Airship (16.12.1):
+ - Airship/Automation (= 16.12.1)
+ - Airship/Basement (= 16.12.1)
+ - Airship/Core (= 16.12.1)
+ - Airship/ExtendedActions (= 16.12.1)
+ - Airship/MessageCenter (= 16.12.1)
+ - Airship/Automation (16.12.1):
- Airship/Core
- - Airship/Basement (16.11.3)
- - Airship/Core (16.11.3):
+ - Airship/Basement (16.12.1)
+ - Airship/Core (16.12.1):
- Airship/Basement
- - Airship/ExtendedActions (16.11.3):
+ - Airship/ExtendedActions (16.12.1):
- Airship/Core
- - Airship/MessageCenter (16.11.3):
+ - Airship/MessageCenter (16.12.1):
- Airship/Core
- - Airship/PreferenceCenter (16.11.3):
+ - Airship/PreferenceCenter (16.12.1):
- Airship/Core
- - AirshipFrameworkProxy (2.0.8):
- - Airship (= 16.11.3)
- - Airship/MessageCenter (= 16.11.3)
- - Airship/PreferenceCenter (= 16.11.3)
+ - AirshipFrameworkProxy (2.1.1):
+ - Airship (= 16.12.1)
+ - Airship/MessageCenter (= 16.12.1)
+ - Airship/PreferenceCenter (= 16.12.1)
- AppAuth (1.6.2):
- AppAuth/Core (= 1.6.2)
- AppAuth/ExternalUserAgent (= 1.6.2)
@@ -558,8 +558,8 @@ PODS:
- React-jsinspector (0.72.4)
- React-logger (0.72.4):
- glog
- - react-native-airship (15.2.6):
- - AirshipFrameworkProxy (= 2.0.8)
+ - react-native-airship (15.3.1):
+ - AirshipFrameworkProxy (= 2.1.1)
- React-Core
- react-native-blob-util (0.17.3):
- React-Core
@@ -1160,8 +1160,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
- Airship: c70eed50e429f97f5adb285423c7291fb7a032ae
- AirshipFrameworkProxy: 7bc4130c668c6c98e2d4c60fe4c9eb61a999be99
+ Airship: 2f4510b497a8200780752a5e0304a9072bfffb6d
+ AirshipFrameworkProxy: ea1b6c665c798637b93c465b5e505be3011f1d9d
AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570
boost: 57d2868c099736d80fcd648bf211b4431e51a558
BVLinearGradient: 421743791a59d259aec53f4c58793aad031da2ca
@@ -1224,7 +1224,7 @@ SPEC CHECKSUMS:
React-jsiexecutor: c7f826e40fa9cab5d37cab6130b1af237332b594
React-jsinspector: aaed4cf551c4a1c98092436518c2d267b13a673f
React-logger: da1ebe05ae06eb6db4b162202faeafac4b435e77
- react-native-airship: 5d19f4ba303481cf4101ff9dee9249ef6a8a6b64
+ react-native-airship: 6ded22e4ca54f2f80db80b7b911c2b9b696d9335
react-native-blob-util: 99f4d79189252f597fe0d810c57a3733b1b1dea6
react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151
react-native-config: 7cd105e71d903104e8919261480858940a6b9c0e
diff --git a/package-lock.json b/package-lock.json
index c03809caece0..1c2ae325575d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.12-0",
+ "version": "1.4.14-2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.12-0",
+ "version": "1.4.14-2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -41,7 +41,7 @@
"@rnmapbox/maps": "^10.0.11",
"@shopify/flash-list": "^1.6.1",
"@types/node": "^18.14.0",
- "@ua/react-native-airship": "^15.2.6",
+ "@ua/react-native-airship": "^15.3.1",
"awesome-phonenumber": "^5.4.0",
"babel-polyfill": "^6.26.0",
"canvas-size": "^1.2.6",
@@ -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",
@@ -20437,9 +20437,9 @@
}
},
"node_modules/@ua/react-native-airship": {
- "version": "15.2.6",
- "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.6.tgz",
- "integrity": "sha512-dVlBPPYXD/4SEshv/X7mmt3xF8WfnNqiSNzCyqJSLAZ1aJuPpP9Z5WemCYsa2iv6goRZvtJSE4P79QKlfoTwXw==",
+ "version": "15.3.1",
+ "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.3.1.tgz",
+ "integrity": "sha512-g5YX4/fpBJ0ml//7ave8HE68uF4QFriCuei0xJwK+ClzbTDWYB6OldvE/wj5dMpgQ95ZFSbr5LU77muYScxXLQ==",
"engines": {
"node": ">= 16.0.0"
},
@@ -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",
@@ -67439,9 +67433,9 @@
}
},
"@ua/react-native-airship": {
- "version": "15.2.6",
- "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.6.tgz",
- "integrity": "sha512-dVlBPPYXD/4SEshv/X7mmt3xF8WfnNqiSNzCyqJSLAZ1aJuPpP9Z5WemCYsa2iv6goRZvtJSE4P79QKlfoTwXw==",
+ "version": "15.3.1",
+ "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.3.1.tgz",
+ "integrity": "sha512-g5YX4/fpBJ0ml//7ave8HE68uF4QFriCuei0xJwK+ClzbTDWYB6OldvE/wj5dMpgQ95ZFSbr5LU77muYScxXLQ==",
"requires": {}
},
"@vercel/ncc": {
@@ -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 c6a9d9fde386..7281ab12cfa7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.12-0",
+ "version": "1.4.14-2",
"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.",
@@ -89,7 +89,7 @@
"@rnmapbox/maps": "^10.0.11",
"@shopify/flash-list": "^1.6.1",
"@types/node": "^18.14.0",
- "@ua/react-native-airship": "^15.2.6",
+ "@ua/react-native-airship": "^15.3.1",
"awesome-phonenumber": "^5.4.0",
"babel-polyfill": "^6.26.0",
"canvas-size": "^1.2.6",
@@ -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/patches/react-native-fast-image+8.6.3.patch b/patches/react-native-fast-image+8.6.3+001+initial.patch
similarity index 100%
rename from patches/react-native-fast-image+8.6.3.patch
rename to patches/react-native-fast-image+8.6.3+001+initial.patch
diff --git a/patches/react-native-fast-image+8.6.3+002+bitmap-downsampling.patch b/patches/react-native-fast-image+8.6.3+002+bitmap-downsampling.patch
new file mode 100644
index 000000000000..a626d5b16b2f
--- /dev/null
+++ b/patches/react-native-fast-image+8.6.3+002+bitmap-downsampling.patch
@@ -0,0 +1,62 @@
+diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java
+index 1339f5c..9dfec0c 100644
+--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java
++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java
+@@ -176,7 +176,8 @@ class FastImageViewWithUrl extends AppCompatImageView {
+ .apply(FastImageViewConverter
+ .getOptions(context, imageSource, mSource)
+ .placeholder(mDefaultSource) // show until loaded
+- .fallback(mDefaultSource)); // null will not be treated as error
++ .fallback(mDefaultSource))
++ .transform(new ResizeTransformation());
+
+ if (key != null)
+ builder.listener(new FastImageRequestListener(key));
+diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/ResizeTransformation.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/ResizeTransformation.java
+new file mode 100644
+index 0000000..1daa227
+--- /dev/null
++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/ResizeTransformation.java
+@@ -0,0 +1,41 @@
++package com.dylanvann.fastimage;
++
++ import android.content.Context;
++ import android.graphics.Bitmap;
++
++ import androidx.annotation.NonNull;
++
++ import com.bumptech.glide.load.Transformation;
++ import com.bumptech.glide.load.engine.Resource;
++ import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool;
++ import com.bumptech.glide.load.resource.bitmap.BitmapResource;
++
++ import java.security.MessageDigest;
++
++ public class ResizeTransformation implements Transformation {
++
++ private final double MAX_BYTES = 25000000.0;
++
++ @NonNull
++ @Override
++ public Resource transform(@NonNull Context context, @NonNull Resource resource, int outWidth, int outHeight) {
++ Bitmap toTransform = resource.get();
++
++ if (toTransform.getByteCount() > MAX_BYTES) {
++ double scaleFactor = Math.sqrt(MAX_BYTES / (double) toTransform.getByteCount());
++ int newHeight = (int) (outHeight * scaleFactor);
++ int newWidth = (int) (outWidth * scaleFactor);
++
++ BitmapPool pool = GlideApp.get(context).getBitmapPool();
++ Bitmap scaledBitmap = Bitmap.createScaledBitmap(toTransform, newWidth, newHeight, true);
++ return BitmapResource.obtain(scaledBitmap, pool);
++ }
++
++ return resource;
++ }
++
++ @Override
++ public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) {
++ messageDigest.update(("ResizeTransformation").getBytes());
++ }
++ }
+\ No newline at end of file
diff --git a/src/App.js b/src/App.js
index 12fc6a9426e1..3553900bbc7f 100644
--- a/src/App.js
+++ b/src/App.js
@@ -8,8 +8,8 @@ import {SafeAreaProvider} from 'react-native-safe-area-context';
import '../wdyr';
import ColorSchemeWrapper from './components/ColorSchemeWrapper';
import ComposeProviders from './components/ComposeProviders';
-import CustomStatusBar from './components/CustomStatusBar';
-import CustomStatusBarContextProvider from './components/CustomStatusBar/CustomStatusBarContextProvider';
+import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackground';
+import CustomStatusBarAndBackgroundContextProvider from './components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider';
import ErrorBoundary from './components/ErrorBoundary';
import HTMLEngineProvider from './components/HTMLEngineProvider';
import {LocaleContextProvider} from './components/LocaleContextProvider';
@@ -68,10 +68,10 @@ function App() {
ReportAttachmentsProvider,
PickerStateProvider,
EnvironmentProvider,
- CustomStatusBarContextProvider,
+ CustomStatusBarAndBackgroundContextProvider,
]}
>
-
+
diff --git a/src/CONST.ts b/src/CONST.ts
index c0e3d64b5eee..bc0a0c3216f0 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/naming-convention */
+import dateAdd from 'date-fns/add';
+import dateSubtract from 'date-fns/sub';
import Config from 'react-native-config';
import * as KeyCommand from 'react-native-key-command';
import * as Url from './libs/Url';
@@ -18,6 +20,8 @@ const PLATFORM_IOS = 'iOS';
const ANDROID_PACKAGE_NAME = 'com.expensify.chat';
const CURRENT_YEAR = new Date().getFullYear();
const PULL_REQUEST_NUMBER = Config?.PULL_REQUEST_NUMBER ?? '';
+const MAX_DATE = dateAdd(new Date(), {years: 1});
+const MIN_DATE = dateSubtract(new Date(), {years: 20});
const keyModifierControl = KeyCommand?.constants?.keyModifierControl ?? 'keyModifierControl';
const keyModifierCommand = KeyCommand?.constants?.keyModifierCommand ?? 'keyModifierCommand';
@@ -77,6 +81,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,
@@ -97,6 +107,8 @@ const CONST = {
// Numbers were arbitrarily picked.
MIN_YEAR: CURRENT_YEAR - 100,
MAX_YEAR: CURRENT_YEAR + 100,
+ MAX_DATE,
+ MIN_DATE,
},
DATE_BIRTH: {
@@ -464,6 +476,7 @@ const CONST = {
ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/',
// Use Environment.getEnvironmentURL to get the complete URL with port number
DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:',
+ EXPENSIFY_INBOX_URL: 'https://www.expensify.com/inbox',
SIGN_IN_FORM_WIDTH: 300,
@@ -533,11 +546,13 @@ const CONST = {
DELETE_TAG: 'POLICYCHANGELOG_DELETE_TAG',
IMPORT_CUSTOM_UNIT_RATES: 'POLICYCHANGELOG_IMPORT_CUSTOM_UNIT_RATES',
IMPORT_TAGS: 'POLICYCHANGELOG_IMPORT_TAGS',
+ INDIVIDUAL_BUDGET_NOTIFICATION: 'POLICYCHANGELOG_INDIVIDUAL_BUDGET_NOTIFICATION',
INVITE_TO_ROOM: 'POLICYCHANGELOG_INVITETOROOM',
REMOVE_FROM_ROOM: 'POLICYCHANGELOG_REMOVEFROMROOM',
SET_AUTOREIMBURSEMENT: 'POLICYCHANGELOG_SET_AUTOREIMBURSEMENT',
SET_AUTO_JOIN: 'POLICYCHANGELOG_SET_AUTO_JOIN',
SET_CATEGORY_NAME: 'POLICYCHANGELOG_SET_CATEGORY_NAME',
+ SHARED_BUDGET_NOTIFICATION: 'POLICYCHANGELOG_SHARED_BUDGET_NOTIFICATION',
UPDATE_ACH_ACCOUNT: 'POLICYCHANGELOG_UPDATE_ACH_ACCOUNT',
UPDATE_APPROVER_RULE: 'POLICYCHANGELOG_UPDATE_APPROVER_RULE',
UPDATE_AUDIT_RATE: 'POLICYCHANGELOG_UPDATE_AUDIT_RATE',
@@ -688,6 +703,7 @@ const CONST = {
TIMING: {
CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action',
SEARCH_RENDER: 'search_render',
+ CHAT_RENDER: 'chat_render',
HOMEPAGE_INITIAL_RENDER: 'homepage_initial_render',
REPORT_INITIAL_RENDER: 'report_initial_render',
SWITCH_REPORT: 'switch_report',
diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts
index a3a041e65684..c68a950d3501 100644
--- a/src/NAVIGATORS.ts
+++ b/src/NAVIGATORS.ts
@@ -4,6 +4,7 @@
* */
export default {
CENTRAL_PANE_NAVIGATOR: 'CentralPaneNavigator',
+ LEFT_MODAL_NAVIGATOR: 'LeftModalNavigator',
RIGHT_MODAL_NAVIGATOR: 'RightModalNavigator',
FULL_SCREEN_NAVIGATOR: 'FullScreenNavigator',
} as const;
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/SCREENS.ts b/src/SCREENS.ts
index 2cd263237866..9e52ea0a38ca 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -81,10 +81,12 @@ const SCREENS = {
SAVE_THE_WORLD: {
ROOT: 'SaveTheWorld_Root',
},
+ LEFT_MODAL: {
+ SEARCH: 'Search',
+ },
RIGHT_MODAL: {
SETTINGS: 'Settings',
NEW_CHAT: 'NewChat',
- SEARCH: 'Search',
DETAILS: 'Details',
PROFILE: 'Profile',
REPORT_DETAILS: 'Report_Details',
diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.js
index 4d01fa108e2a..4abe5655e307 100644
--- a/src/components/AddPaymentMethodMenu.js
+++ b/src/components/AddPaymentMethodMenu.js
@@ -48,6 +48,9 @@ const propTypes = {
/** Currently logged in user accountID */
accountID: PropTypes.number,
}),
+
+ /** Whether the personal bank account option should be shown */
+ shouldShowPersonalBankAccountOption: PropTypes.bool,
};
const defaultProps = {
@@ -59,9 +62,10 @@ const defaultProps = {
},
anchorRef: () => {},
session: {},
+ shouldShowPersonalBankAccountOption: false,
};
-function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignment, anchorRef, iouReport, onItemSelected, session}) {
+function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignment, anchorRef, iouReport, onItemSelected, session, shouldShowPersonalBankAccountOption}) {
const {translate} = useLocalize();
// Users can choose to pay with business bank account in case of Expense reports or in case of P2P IOU report
@@ -70,6 +74,8 @@ function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignme
ReportUtils.isExpenseReport(iouReport) ||
(ReportUtils.isIOUReport(iouReport) && !ReportActionsUtils.hasRequestFromCurrentAccount(lodashGet(iouReport, 'reportID', 0), lodashGet(session, 'accountID', 0)));
+ const canUsePersonalBankAccount = shouldShowPersonalBankAccountOption || ReportUtils.isIOUReport(iouReport);
+
return (
- {props.isSmallScreenWidth && }
- downloadAttachment(source)}
- shouldShowCloseButton={!props.isSmallScreenWidth}
- shouldShowBackButton={props.isSmallScreenWidth}
- onBackButtonPress={closeModal}
- onCloseButtonPress={closeModal}
- shouldShowThreeDotsButton={shouldShowThreeDotsButton}
- threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)}
- threeDotsMenuItems={threeDotsMenuItems}
- shouldOverlay
- />
-
- {!_.isEmpty(props.report) && !props.isReceiptAttachment ? (
-
- ) : (
- Boolean(sourceForAttachmentView) &&
- shouldLoadAttachment && (
-
+ {props.isSmallScreenWidth && }
+ downloadAttachment(source)}
+ shouldShowCloseButton={!props.isSmallScreenWidth}
+ shouldShowBackButton={props.isSmallScreenWidth}
+ onBackButtonPress={closeModal}
+ onCloseButtonPress={closeModal}
+ shouldShowThreeDotsButton={shouldShowThreeDotsButton}
+ threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)}
+ threeDotsMenuItems={threeDotsMenuItems}
+ shouldOverlay
+ />
+
+ {!_.isEmpty(props.report) && !props.isReceiptAttachment ? (
+
- )
- )}
-
- {/* If we have an onConfirm method show a confirmation button */}
- {Boolean(props.onConfirm) && (
-
- {({safeAreaPaddingBottomStyle}) => (
-
-
-
+ )
)}
-
- )}
- {props.isReceiptAttachment && (
-
- )}
+
+ {/* If we have an onConfirm method show a confirmation button */}
+ {Boolean(props.onConfirm) && (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+ )}
+
+ )}
+ {props.isReceiptAttachment && (
+
+ )}
+
{!props.isReceiptAttachment && (
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js
deleted file mode 100644
index 7a083d71b591..000000000000
--- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js
+++ /dev/null
@@ -1,189 +0,0 @@
-/* eslint-disable es/no-optional-chaining */
-import PropTypes from 'prop-types';
-import React, {useContext, useEffect, useRef, useState} from 'react';
-import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native';
-import * as AttachmentsPropTypes from '@components/Attachments/propTypes';
-import Image from '@components/Image';
-import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext';
-import ImageTransformer from './ImageTransformer';
-import ImageWrapper from './ImageWrapper';
-
-function getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}) {
- const imageScaleX = canvasWidth / imageWidth;
- const imageScaleY = canvasHeight / imageHeight;
-
- return {imageScaleX, imageScaleY};
-}
-
-const cachedDimensions = new Map();
-
-const pagePropTypes = {
- /** Whether source url requires authentication */
- isAuthTokenRequired: PropTypes.bool,
-
- /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */
- source: AttachmentsPropTypes.attachmentSourcePropType.isRequired,
-
- isActive: PropTypes.bool.isRequired,
-};
-
-const defaultProps = {
- isAuthTokenRequired: false,
-};
-
-function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialIsActive}) {
- const {canvasWidth, canvasHeight} = useContext(AttachmentCarouselPagerContext);
-
- const dimensions = cachedDimensions.get(source);
-
- const [isActive, setIsActive] = useState(initialIsActive);
- // We delay setting a page to active state by a (few) millisecond(s),
- // to prevent the image transformer from flashing while still rendering
- // Instead, we show the fallback image while the image transformer is loading the image
- useEffect(() => {
- if (initialIsActive) {
- setTimeout(() => setIsActive(true), 1);
- } else {
- setIsActive(false);
- }
- }, [initialIsActive]);
-
- const [initialActivePageLoad, setInitialActivePageLoad] = useState(isActive);
- const isImageLoaded = useRef(null);
- const [isImageLoading, setIsImageLoading] = useState(false);
- const [isFallbackLoading, setIsFallbackLoading] = useState(false);
- const [showFallback, setShowFallback] = useState(true);
-
- // We delay hiding the fallback image while image transformer is still rendering
- useEffect(() => {
- if (isImageLoading || showFallback) {
- setShowFallback(true);
- } else {
- setTimeout(() => setShowFallback(false), 100);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isImageLoading]);
-
- return (
- <>
- {isActive && (
-
-
- {
- setIsImageLoading(true);
- }}
- onLoadEnd={() => {
- setShowFallback(false);
- setIsImageLoading(false);
- isImageLoaded.current = true;
- }}
- onLoad={(evt) => {
- const imageWidth = (evt.nativeEvent?.width || 0) / PixelRatio.get();
- const imageHeight = (evt.nativeEvent?.height || 0) / PixelRatio.get();
-
- const {imageScaleX, imageScaleY} = getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight});
-
- // Don't update the dimensions if they are already set
- if (
- dimensions?.imageWidth !== imageWidth ||
- dimensions?.imageHeight !== imageHeight ||
- dimensions?.imageScaleX !== imageScaleX ||
- dimensions?.imageScaleY !== imageScaleY
- ) {
- cachedDimensions.set(source, {
- ...dimensions,
- imageWidth,
- imageHeight,
- imageScaleX,
- imageScaleY,
- });
- }
-
- // On the initial render of the active page, the onLoadEnd event is never fired.
- // That's why we instead set isImageLoading to false in the onLoad event.
- if (initialActivePageLoad) {
- setInitialActivePageLoad(false);
- setIsImageLoading(false);
- setTimeout(() => setShowFallback(false), 100);
- isImageLoaded.current = true;
- }
- }}
- />
-
-
- )}
-
- {/* Keep rendering the image without gestures as fallback while ImageTransformer is loading the image */}
- {(showFallback || !isActive) && (
-
- {
- setIsImageLoading(true);
- if (isImageLoaded.current) {
- return;
- }
- setIsFallbackLoading(true);
- }}
- onLoadEnd={() => {
- if (isImageLoaded.current) {
- return;
- }
- setIsFallbackLoading(false);
- }}
- onLoad={(evt) => {
- const imageWidth = evt.nativeEvent.width;
- const imageHeight = evt.nativeEvent.height;
-
- const {imageScaleX, imageScaleY} = getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight});
- const minImageScale = Math.min(imageScaleX, imageScaleY);
-
- const scaledImageWidth = imageWidth * minImageScale;
- const scaledImageHeight = imageHeight * minImageScale;
-
- // Don't update the dimensions if they are already set
- if (dimensions?.scaledImageWidth === scaledImageWidth && dimensions?.scaledImageHeight === scaledImageHeight) {
- return;
- }
-
- cachedDimensions.set(source, {
- ...dimensions,
- scaledImageWidth,
- scaledImageHeight,
- });
- }}
- style={dimensions == null ? undefined : {width: dimensions.scaledImageWidth, height: dimensions.scaledImageHeight}}
- />
-
- )}
-
- {/* Show activity indicator while ImageTransfomer is still loading the image. */}
- {isActive && isFallbackLoading && !isImageLoaded.current && (
-
- )}
- >
- );
-}
-
-AttachmentCarouselPage.propTypes = pagePropTypes;
-AttachmentCarouselPage.defaultProps = defaultProps;
-AttachmentCarouselPage.displayName = 'AttachmentCarouselPage';
-
-export default AttachmentCarouselPage;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js
index 39535288e22d..abaf06900853 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js
+++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js
@@ -1,5 +1,5 @@
import {createContext} from 'react';
-const AttachmentCarouselContextPager = createContext(null);
+const AttachmentCarouselPagerContext = createContext(null);
-export default AttachmentCarouselContextPager;
+export default AttachmentCarouselPagerContext;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js
deleted file mode 100644
index b639eb291bb1..000000000000
--- a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {StyleSheet} from 'react-native';
-import Animated from 'react-native-reanimated';
-import useThemeStyles from '@hooks/useThemeStyles';
-
-const imageWrapperPropTypes = {
- children: PropTypes.node.isRequired,
-};
-
-function ImageWrapper({children}) {
- const styles = useThemeStyles();
- return (
-
- {children}
-
- );
-}
-
-ImageWrapper.propTypes = imageWrapperPropTypes;
-ImageWrapper.displayName = 'ImageWrapper';
-
-export default ImageWrapper;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js
index 15c98ece62cb..553e963a3461 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/index.js
+++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js
@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
-import React, {useImperativeHandle, useMemo, useRef, useState} from 'react';
+import React, {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
-import {createNativeWrapper, GestureHandlerRootView} from 'react-native-gesture-handler';
+import {createNativeWrapper} from 'react-native-gesture-handler';
import PagerView from 'react-native-pager-view';
import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useEvent, useHandler, useSharedValue} from 'react-native-reanimated';
import _ from 'underscore';
@@ -51,8 +51,6 @@ const pagerPropTypes = {
onSwipeDown: PropTypes.func,
onPinchGestureChange: PropTypes.func,
forwardedRef: refPropTypes,
- containerWidth: PropTypes.number.isRequired,
- containerHeight: PropTypes.number.isRequired,
};
const pagerDefaultProps = {
@@ -66,20 +64,7 @@ const pagerDefaultProps = {
forwardedRef: null,
};
-function AttachmentCarouselPager({
- items,
- renderItem,
- initialIndex,
- onPageSelected,
- onTap,
- onSwipe = noopWorklet,
- onSwipeSuccess,
- onSwipeDown,
- onPinchGestureChange,
- forwardedRef,
- containerWidth,
- containerHeight,
-}) {
+function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onTap, onSwipe = noopWorklet, onSwipeSuccess, onSwipeDown, onPinchGestureChange, forwardedRef}) {
const styles = useThemeStyles();
const shouldPagerScroll = useSharedValue(true);
const pagerRef = useRef(null);
@@ -101,6 +86,11 @@ function AttachmentCarouselPager({
const [activePage, setActivePage] = useState(initialIndex);
+ useEffect(() => {
+ setActivePage(initialIndex);
+ activeIndex.value = initialIndex;
+ }, [activeIndex, initialIndex]);
+
// we use reanimated for this since onPageSelected is called
// in the middle of the pager animation
useAnimatedReaction(
@@ -128,8 +118,6 @@ function AttachmentCarouselPager({
const contextValue = useMemo(
() => ({
- canvasWidth: containerWidth,
- canvasHeight: containerHeight,
isScrolling,
pagerRef,
shouldPagerScroll,
@@ -139,33 +127,31 @@ function AttachmentCarouselPager({
onSwipeSuccess,
onSwipeDown,
}),
- [containerWidth, containerHeight, isScrolling, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown],
+ [isScrolling, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown],
);
return (
-
-
-
- {_.map(items, (item, index) => (
-
- {renderItem({item, index, isActive: index === activePage})}
-
- ))}
-
-
-
+
+
+ {_.map(items, (item, index) => (
+
+ {renderItem({item, index, isActive: index === activePage})}
+
+ ))}
+
+
);
}
diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js
index 8f225d426dca..974bb92bf3c8 100644
--- a/src/components/Attachments/AttachmentCarousel/index.js
+++ b/src/components/Attachments/AttachmentCarousel/index.js
@@ -139,10 +139,11 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
setShouldShowArrows(!shouldShowArrows) : undefined}
/>
),
- [activeSource, canUseTouchScreen, setShouldShowArrows, shouldShowArrows],
+ [activeSource, attachments.length, canUseTouchScreen, setShouldShowArrows, shouldShowArrows],
);
return (
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js
index 374b2d47d12d..f5479b73abdb 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.js
+++ b/src/components/Attachments/AttachmentCarousel/index.native.js
@@ -1,8 +1,9 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
-import {Keyboard, PixelRatio, View} from 'react-native';
+import {Keyboard, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import BlockingView from '@components/BlockingViews/BlockingView';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import * as Illustrations from '@components/Icon/Illustrations';
import withLocalize from '@components/withLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -20,13 +21,11 @@ import useCarouselArrows from './useCarouselArrows';
function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, translate, onClose}) {
const styles = useThemeStyles();
const pagerRef = useRef(null);
-
- const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0});
- const [page, setPage] = useState(0);
+ const [page, setPage] = useState();
const [attachments, setAttachments] = useState([]);
- const [activeSource, setActiveSource] = useState(source);
const [isPinchGestureRunning, setIsPinchGestureRunning] = useState(true);
const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows();
+ const [activeSource, setActiveSource] = useState(source);
const compareImage = useCallback((attachment) => attachment.source === source, [source]);
@@ -95,61 +94,63 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
* @returns {JSX.Element}
*/
const renderItem = useCallback(
- ({item, isActive}) => (
+ ({item, index, isActive}) => (
setShouldShowArrows(!shouldShowArrows)}
/>
),
- [activeSource, setShouldShowArrows, shouldShowArrows],
+ [activeSource, attachments.length, page, setShouldShowArrows, shouldShowArrows],
);
return (
- setContainerDimensions({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)})
- }
onMouseEnter={() => setShouldShowArrows(true)}
onMouseLeave={() => setShouldShowArrows(false)}
>
- {page === -1 ? (
-
+ {page == null ? (
+
) : (
<>
- cycleThroughAttachments(-1)}
- onForward={() => cycleThroughAttachments(1)}
- autoHideArrow={autoHideArrows}
- cancelAutoHideArrow={cancelAutoHideArrows}
- />
-
- {containerDimensions.width > 0 && containerDimensions.height > 0 && (
- updatePage(newPage)}
- onPinchGestureChange={(newIsPinchGestureRunning) => {
- setIsPinchGestureRunning(newIsPinchGestureRunning);
- if (!newIsPinchGestureRunning && !shouldShowArrows) {
- setShouldShowArrows(true);
- }
- }}
- onSwipeDown={onClose}
- containerWidth={containerDimensions.width}
- containerHeight={containerDimensions.height}
- ref={pagerRef}
+ {page === -1 ? (
+
+ ) : (
+ <>
+ cycleThroughAttachments(-1)}
+ onForward={() => cycleThroughAttachments(1)}
+ autoHideArrow={autoHideArrows}
+ cancelAutoHideArrow={cancelAutoHideArrows}
+ />
+
+ updatePage(newPage)}
+ onPinchGestureChange={(newIsPinchGestureRunning) => {
+ setIsPinchGestureRunning(newIsPinchGestureRunning);
+ if (!newIsPinchGestureRunning && !shouldShowArrows) {
+ setShouldShowArrows(true);
+ }
+ }}
+ onSwipeDown={onClose}
+ ref={pagerRef}
+ />
+ >
)}
>
)}
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
index 78b69be077aa..f53b993f6053 100755
--- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
@@ -12,17 +12,38 @@ const propTypes = {
...withLocalizePropTypes,
};
-function AttachmentViewImage({source, file, isAuthTokenRequired, loadComplete, onPress, isImage, onScaleChanged, translate, onError}) {
+function AttachmentViewImage({
+ source,
+ file,
+ isAuthTokenRequired,
+ isUsedInCarousel,
+ isSingleCarouselItem,
+ carouselItemIndex,
+ carouselActiveItemIndex,
+ isFocused,
+ loadComplete,
+ onPress,
+ onError,
+ isImage,
+ onScaleChanged,
+ translate,
+}) {
const styles = useThemeStyles();
const children = (
);
+
return onPress ? (
- ) : (
-
- );
-
- return onPress ? (
-
- {children}
-
- ) : (
- children
- );
-}
-
-AttachmentViewImage.propTypes = propTypes;
-AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps;
-AttachmentViewImage.displayName = 'AttachmentViewImage';
-
-export default compose(memo, withLocalize)(AttachmentViewImage);
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index 79d1b6f407b9..94faa13fbb0f 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -63,7 +63,6 @@ function AttachmentView({
source,
file,
isAuthTokenRequired,
- isUsedInCarousel,
onPress,
shouldShowLoadingSpinnerIcon,
shouldShowDownloadIcon,
@@ -72,10 +71,14 @@ function AttachmentView({
onToggleKeyboard,
translate,
isFocused,
+ isUsedInCarousel,
+ isSingleCarouselItem,
+ carouselItemIndex,
+ carouselActiveItemIndex,
+ isUsedInAttachmentModal,
isWorkspaceAvatar,
fallbackSource,
transaction,
- isUsedInAttachmentModal,
}) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -132,10 +135,11 @@ function AttachmentView({
{},
- isUsedInAttachmentModal: false,
};
export {attachmentViewPropTypes, attachmentViewDefaultProps};
diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
index 4ad3d45544b0..c2320f7c0202 100644
--- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
+++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
@@ -4,6 +4,7 @@ import {View} from 'react-native';
// We take ScrollView from this package to properly handle the scrolling of AutoCompleteSuggestions in chats since one scroll is nested inside another
import {ScrollView} from 'react-native-gesture-handler';
import Animated, {Easing, FadeOutDown, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
+import ColorSchemeWrapper from '@components/ColorSchemeWrapper';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -84,18 +85,20 @@ function BaseAutoCompleteSuggestions(
style={[styles.autoCompleteSuggestionsContainer, animatedStyles]}
exiting={FadeOutDown.duration(100).easing(Easing.inOut(Easing.ease))}
>
- rowHeight.value}
- extraData={highlightedSuggestionIndex}
- />
+
+ rowHeight.value}
+ extraData={highlightedSuggestionIndex}
+ />
+
);
}
diff --git a/src/components/AutoUpdateTime.js b/src/components/AutoUpdateTime.tsx
similarity index 63%
rename from src/components/AutoUpdateTime.js
rename to src/components/AutoUpdateTime.tsx
index b9aa3446fa12..258bdb281eb2 100644
--- a/src/components/AutoUpdateTime.js
+++ b/src/components/AutoUpdateTime.tsx
@@ -2,42 +2,35 @@
* Displays the user's local time and updates it every minute.
* The time auto-update logic is extracted to this component to avoid re-rendering a more complex component, e.g. DetailsPage.
*/
-import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import DateUtils from '@libs/DateUtils';
+import {Timezone} from '@src/types/onyx/PersonalDetails';
import Text from './Text';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
+import withLocalize, {WithLocalizeProps} from './withLocalize';
-const propTypes = {
+type AutoUpdateTimeProps = WithLocalizeProps & {
/** Timezone of the user from their personal details */
- timezone: PropTypes.shape({
- /** Value of selected timezone */
- selected: PropTypes.string,
-
- /** Whether timezone is automatically set */
- automatic: PropTypes.bool,
- }).isRequired,
- ...withLocalizePropTypes,
+ timezone: Timezone;
};
-function AutoUpdateTime(props) {
+function AutoUpdateTime({timezone, preferredLocale, translate}: AutoUpdateTimeProps) {
const styles = useThemeStyles();
- /**
- * @returns {Date} Returns the locale Date object
- */
- const getCurrentUserLocalTime = useCallback(
- () => DateUtils.getLocalDateFromDatetime(props.preferredLocale, null, props.timezone.selected),
- [props.preferredLocale, props.timezone.selected],
- );
+ /** @returns Returns the locale Date object */
+ const getCurrentUserLocalTime = useCallback(() => DateUtils.getLocalDateFromDatetime(preferredLocale, undefined, timezone.selected), [preferredLocale, timezone.selected]);
const [currentUserLocalTime, setCurrentUserLocalTime] = useState(getCurrentUserLocalTime);
const minuteRef = useRef(new Date().getMinutes());
- const timezoneName = useMemo(() => DateUtils.getZoneAbbreviation(currentUserLocalTime, props.timezone.selected), [currentUserLocalTime, props.timezone.selected]);
+ const timezoneName = useMemo(() => {
+ if (timezone.selected) {
+ return DateUtils.getZoneAbbreviation(currentUserLocalTime, timezone.selected);
+ }
+ return '';
+ }, [currentUserLocalTime, timezone.selected]);
useEffect(() => {
- // If the any of the props that getCurrentUserLocalTime depends on change, we want to update the displayed time immediately
+ // If any of the props that getCurrentUserLocalTime depends on change, we want to update the displayed time immediately
setCurrentUserLocalTime(getCurrentUserLocalTime());
// Also, if the user leaves this page open, we want to make sure the displayed time is updated every minute when the clock changes
@@ -58,7 +51,7 @@ function AutoUpdateTime(props) {
style={[styles.textLabelSupporting, styles.mb1]}
numberOfLines={1}
>
- {props.translate('detailsPage.localTime')}
+ {translate('detailsPage.localTime')}
{DateUtils.formatToLocalTime(currentUserLocalTime)} {timezoneName}
@@ -67,6 +60,5 @@ function AutoUpdateTime(props) {
);
}
-AutoUpdateTime.propTypes = propTypes;
AutoUpdateTime.displayName = 'AutoUpdateTime';
export default withLocalize(AutoUpdateTime);
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/CustomStatusBar/CustomStatusBarContext.tsx b/src/components/CustomStatusBar/CustomStatusBarContext.tsx
deleted file mode 100644
index b2c317b05c25..000000000000
--- a/src/components/CustomStatusBar/CustomStatusBarContext.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import {createContext} from 'react';
-
-type CustomStatusBarContextType = {
- isRootStatusBarDisabled: boolean;
- disableRootStatusBar: (isDisabled: boolean) => void;
-};
-
-const CustomStatusBarContext = createContext({isRootStatusBarDisabled: false, disableRootStatusBar: () => undefined});
-
-export default CustomStatusBarContext;
-export {type CustomStatusBarContextType};
diff --git a/src/components/CustomStatusBar/CustomStatusBarContextProvider.tsx b/src/components/CustomStatusBar/CustomStatusBarContextProvider.tsx
deleted file mode 100644
index 27a5cac5d8cc..000000000000
--- a/src/components/CustomStatusBar/CustomStatusBarContextProvider.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import React, {useMemo, useState} from 'react';
-import CustomStatusBarContext from './CustomStatusBarContext';
-
-function CustomStatusBarContextProvider({children}: React.PropsWithChildren) {
- const [isRootStatusBarDisabled, disableRootStatusBar] = useState(false);
- const value = useMemo(
- () => ({
- isRootStatusBarDisabled,
- disableRootStatusBar,
- }),
- [isRootStatusBarDisabled],
- );
-
- return {children};
-}
-
-export default CustomStatusBarContextProvider;
diff --git a/src/components/CustomStatusBar/index.tsx b/src/components/CustomStatusBar/index.tsx
deleted file mode 100644
index 2c1af898786d..000000000000
--- a/src/components/CustomStatusBar/index.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import {EventListenerCallback, NavigationContainerEventMap} from '@react-navigation/native';
-import React, {useCallback, useContext, useEffect} from 'react';
-import useTheme from '@hooks/useTheme';
-import {navigationRef} from '@libs/Navigation/Navigation';
-import StatusBar from '@libs/StatusBar';
-import CustomStatusBarContext from './CustomStatusBarContext';
-import updateStatusBarAppearance from './updateStatusBarAppearance';
-
-type CustomStatusBarProps = {
- /** Whether the CustomStatusBar is nested within another CustomStatusBar.
- * A nested CustomStatusBar will disable the "root" CustomStatusBar. */
- isNested: boolean;
-};
-
-// eslint-disable-next-line react/function-component-definition
-function CustomStatusBar({isNested = false}: CustomStatusBarProps) {
- const {isRootStatusBarDisabled, disableRootStatusBar} = useContext(CustomStatusBarContext);
- const theme = useTheme();
-
- const isDisabled = !isNested && isRootStatusBarDisabled;
-
- useEffect(() => {
- if (isNested) {
- disableRootStatusBar(true);
- }
-
- return () => {
- if (!isNested) {
- return;
- }
- disableRootStatusBar(false);
- };
- }, [disableRootStatusBar, isNested]);
-
- const updateStatusBarStyle = useCallback>(() => {
- if (isDisabled) {
- return;
- }
-
- // Set the status bar colour depending on the current route.
- // If we don't have any colour defined for a route, fall back to
- // appBG color.
- const currentRoute = navigationRef.getCurrentRoute();
-
- let currentScreenBackgroundColor = theme.appBG;
- let statusBarStyle = theme.statusBarStyle;
- if (currentRoute && 'name' in currentRoute && currentRoute.name in theme.PAGE_THEMES) {
- const screenTheme = theme.PAGE_THEMES[currentRoute.name];
- currentScreenBackgroundColor = screenTheme.backgroundColor;
- statusBarStyle = screenTheme.statusBarStyle;
- }
-
- updateStatusBarAppearance({backgroundColor: currentScreenBackgroundColor, statusBarStyle});
- }, [isDisabled, theme.PAGE_THEMES, theme.appBG, theme.statusBarStyle]);
-
- useEffect(() => {
- navigationRef.addListener('state', updateStatusBarStyle);
-
- return () => navigationRef.removeListener('state', updateStatusBarStyle);
- }, [updateStatusBarStyle]);
-
- useEffect(() => {
- if (isDisabled) {
- return;
- }
-
- updateStatusBarAppearance({statusBarStyle: theme.statusBarStyle});
- }, [isDisabled, theme.statusBarStyle]);
-
- if (isDisabled) {
- return null;
- }
-
- return ;
-}
-
-CustomStatusBar.displayName = 'CustomStatusBar';
-
-export default CustomStatusBar;
diff --git a/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContext.tsx b/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContext.tsx
new file mode 100644
index 000000000000..4a1a1cd2f964
--- /dev/null
+++ b/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContext.tsx
@@ -0,0 +1,11 @@
+import {createContext} from 'react';
+
+type CustomStatusBarAndBackgroundContextType = {
+ isRootStatusBarDisabled: boolean;
+ disableRootStatusBar: (isDisabled: boolean) => void;
+};
+
+const CustomStatusBarAndBackgroundContext = createContext({isRootStatusBarDisabled: false, disableRootStatusBar: () => undefined});
+
+export default CustomStatusBarAndBackgroundContext;
+export {type CustomStatusBarAndBackgroundContextType};
diff --git a/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider.tsx b/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider.tsx
new file mode 100644
index 000000000000..b4d553b60d0f
--- /dev/null
+++ b/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider.tsx
@@ -0,0 +1,17 @@
+import React, {useMemo, useState} from 'react';
+import CustomStatusBarAndBackgroundContext from './CustomStatusBarAndBackgroundContext';
+
+function CustomStatusBarAndBackgroundContextProvider({children}: React.PropsWithChildren) {
+ const [isRootStatusBarDisabled, disableRootStatusBar] = useState(false);
+ const value = useMemo(
+ () => ({
+ isRootStatusBarDisabled,
+ disableRootStatusBar,
+ }),
+ [isRootStatusBarDisabled],
+ );
+
+ return {children};
+}
+
+export default CustomStatusBarAndBackgroundContextProvider;
diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx
new file mode 100644
index 000000000000..b84f9c6a6630
--- /dev/null
+++ b/src/components/CustomStatusBarAndBackground/index.tsx
@@ -0,0 +1,113 @@
+import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
+import useTheme from '@hooks/useTheme';
+import {navigationRef} from '@libs/Navigation/Navigation';
+import StatusBar from '@libs/StatusBar';
+import CustomStatusBarAndBackgroundContext from './CustomStatusBarAndBackgroundContext';
+import updateGlobalBackgroundColor from './updateGlobalBackgroundColor';
+import updateStatusBarAppearance from './updateStatusBarAppearance';
+
+type CustomStatusBarAndBackgroundProps = {
+ /** Whether the CustomStatusBar is nested within another CustomStatusBar.
+ * A nested CustomStatusBar will disable the "root" CustomStatusBar. */
+ isNested: boolean;
+};
+
+function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBackgroundProps) {
+ const {isRootStatusBarDisabled, disableRootStatusBar} = useContext(CustomStatusBarAndBackgroundContext);
+ const theme = useTheme();
+ const [statusBarStyle, setStatusBarStyle] = useState(theme.statusBarStyle);
+
+ const isDisabled = !isNested && isRootStatusBarDisabled;
+
+ // Disable the root status bar when a nested status bar is rendered
+ useEffect(() => {
+ if (isNested) {
+ disableRootStatusBar(true);
+ }
+
+ return () => {
+ if (!isNested) {
+ return;
+ }
+ disableRootStatusBar(false);
+ };
+ }, [disableRootStatusBar, isNested]);
+
+ const listenerCount = useRef(0);
+ const updateStatusBarStyle = useCallback(
+ (listenerId?: number) => {
+ // Check if this function is either called through the current navigation listener or the general useEffect which listens for theme changes.
+ if (listenerId !== undefined && listenerId !== listenerCount.current) {
+ return;
+ }
+
+ // Set the status bar colour depending on the current route.
+ // If we don't have any colour defined for a route, fall back to
+ // appBG color.
+ let currentRoute: ReturnType | undefined;
+ if (navigationRef.isReady()) {
+ currentRoute = navigationRef.getCurrentRoute();
+ }
+
+ let currentScreenBackgroundColor = theme.appBG;
+ let newStatusBarStyle = theme.statusBarStyle;
+ if (currentRoute && 'name' in currentRoute && currentRoute.name in theme.PAGE_THEMES) {
+ const screenTheme = theme.PAGE_THEMES[currentRoute.name];
+ currentScreenBackgroundColor = screenTheme.backgroundColor;
+ newStatusBarStyle = screenTheme.statusBarStyle;
+ }
+
+ // Don't update the status bar style if it's the same as the current one, to prevent flashing.
+ if (newStatusBarStyle === statusBarStyle) {
+ updateStatusBarAppearance({backgroundColor: currentScreenBackgroundColor});
+ } else {
+ updateStatusBarAppearance({backgroundColor: currentScreenBackgroundColor, statusBarStyle: newStatusBarStyle});
+ setStatusBarStyle(newStatusBarStyle);
+ }
+ },
+ [statusBarStyle, theme.PAGE_THEMES, theme.appBG, theme.statusBarStyle],
+ );
+
+ // Add navigation state listeners to update the status bar every time the route changes
+ // We have to pass a count as the listener id, because "react-navigation" somehow doesn't remove listeners properyl
+ useEffect(() => {
+ if (isDisabled) {
+ return;
+ }
+
+ const listenerId = ++listenerCount.current;
+ const listener = () => updateStatusBarStyle(listenerId);
+
+ navigationRef.addListener('state', listener);
+ return () => navigationRef.removeListener('state', listener);
+ }, [isDisabled, theme.appBG, updateStatusBarStyle]);
+
+ // Update the status bar style everytime the theme changes
+ useEffect(() => {
+ if (isDisabled) {
+ return;
+ }
+
+ updateStatusBarStyle();
+ }, [isDisabled, theme, updateStatusBarStyle]);
+
+ // Update the global background (on web) everytime the theme changes.
+ // The background of the html element needs to be updated, otherwise you will see a big contrast when resizing the window or when the keyboard is open on iOS web.
+ useEffect(() => {
+ if (isDisabled) {
+ return;
+ }
+
+ updateGlobalBackgroundColor(theme);
+ }, [isDisabled, theme]);
+
+ if (isDisabled) {
+ return null;
+ }
+
+ return ;
+}
+
+CustomStatusBarAndBackground.displayName = 'CustomStatusBarAndBackground';
+
+export default CustomStatusBarAndBackground;
diff --git a/src/components/CustomStatusBarAndBackground/updateGlobalBackgroundColor/index.ts b/src/components/CustomStatusBarAndBackground/updateGlobalBackgroundColor/index.ts
new file mode 100644
index 000000000000..dac994ba8597
--- /dev/null
+++ b/src/components/CustomStatusBarAndBackground/updateGlobalBackgroundColor/index.ts
@@ -0,0 +1,5 @@
+import type UpdateGlobalBackgroundColor from './types';
+
+const updateGlobalBackgroundColor: UpdateGlobalBackgroundColor = () => undefined;
+
+export default updateGlobalBackgroundColor;
diff --git a/src/components/CustomStatusBarAndBackground/updateGlobalBackgroundColor/index.website.ts b/src/components/CustomStatusBarAndBackground/updateGlobalBackgroundColor/index.website.ts
new file mode 100644
index 000000000000..481d866dbe4f
--- /dev/null
+++ b/src/components/CustomStatusBarAndBackground/updateGlobalBackgroundColor/index.website.ts
@@ -0,0 +1,8 @@
+import UpdateGlobalBackgroundColor from './types';
+
+const updateGlobalBackgroundColor: UpdateGlobalBackgroundColor = (theme) => {
+ const htmlElement = document.getElementsByTagName('html')[0];
+ htmlElement.style.setProperty('background-color', theme.appBG);
+};
+
+export default updateGlobalBackgroundColor;
diff --git a/src/components/CustomStatusBarAndBackground/updateGlobalBackgroundColor/types.ts b/src/components/CustomStatusBarAndBackground/updateGlobalBackgroundColor/types.ts
new file mode 100644
index 000000000000..83bd36a9428a
--- /dev/null
+++ b/src/components/CustomStatusBarAndBackground/updateGlobalBackgroundColor/types.ts
@@ -0,0 +1,5 @@
+import {ThemeColors} from '@styles/theme/types';
+
+type UpdateGlobalBackgroundColor = (theme: ThemeColors) => void;
+
+export default UpdateGlobalBackgroundColor;
diff --git a/src/components/CustomStatusBar/updateStatusBarAppearance/index.android.ts b/src/components/CustomStatusBarAndBackground/updateStatusBarAppearance/index.android.ts
similarity index 100%
rename from src/components/CustomStatusBar/updateStatusBarAppearance/index.android.ts
rename to src/components/CustomStatusBarAndBackground/updateStatusBarAppearance/index.android.ts
diff --git a/src/components/CustomStatusBar/updateStatusBarAppearance/index.ios.ts b/src/components/CustomStatusBarAndBackground/updateStatusBarAppearance/index.ios.ts
similarity index 100%
rename from src/components/CustomStatusBar/updateStatusBarAppearance/index.ios.ts
rename to src/components/CustomStatusBarAndBackground/updateStatusBarAppearance/index.ios.ts
diff --git a/src/components/CustomStatusBar/updateStatusBarAppearance/index.ts b/src/components/CustomStatusBarAndBackground/updateStatusBarAppearance/index.ts
similarity index 100%
rename from src/components/CustomStatusBar/updateStatusBarAppearance/index.ts
rename to src/components/CustomStatusBarAndBackground/updateStatusBarAppearance/index.ts
diff --git a/src/components/CustomStatusBar/updateStatusBarAppearance/types.ts b/src/components/CustomStatusBarAndBackground/updateStatusBarAppearance/types.ts
similarity index 100%
rename from src/components/CustomStatusBar/updateStatusBarAppearance/types.ts
rename to src/components/CustomStatusBarAndBackground/updateStatusBarAppearance/types.ts
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/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx
similarity index 68%
rename from src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js
rename to src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx
index 622767b8a5f8..c404ff5fa71f 100644
--- a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js
+++ b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx
@@ -1,40 +1,34 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+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 compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import * as OnyxTypes from '@src/types/onyx';
-const propTypes = {
- openLinkInBrowser: PropTypes.func.isRequired,
-
- session: PropTypes.shape({
- /** Currently logged-in user email */
- email: PropTypes.string,
- }),
-
- ...withLocalizePropTypes,
+type DeeplinkRedirectLoadingIndicatorOnyxProps = {
+ /** Current user session */
+ session: OnyxEntry;
};
-const defaultProps = {
- session: {
- email: '',
- },
+type DeeplinkRedirectLoadingIndicatorProps = DeeplinkRedirectLoadingIndicatorOnyxProps & {
+ /** Opens the link in the browser */
+ openLinkInBrowser: (value: boolean) => void;
};
-function DeeplinkRedirectLoadingIndicator({translate, openLinkInBrowser, session}) {
+function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: DeeplinkRedirectLoadingIndicatorProps) {
+ const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
+
return (
@@ -46,8 +40,8 @@ function DeeplinkRedirectLoadingIndicator({translate, openLinkInBrowser, session
/>
{translate('deeplinkWrapper.launching')}
-
- {translate('deeplinkWrapper.loggedInAs', {email: session.email})}
+
+ {translate('deeplinkWrapper.loggedInAs', {email: session?.email ?? ''})}
{translate('deeplinkWrapper.doNotSeePrompt')} openLinkInBrowser(true)}>{translate('deeplinkWrapper.tryAgain')}
{translate('deeplinkWrapper.or')} Navigation.navigate(ROUTES.HOME)}>{translate('deeplinkWrapper.continueInWeb')}.
@@ -66,15 +60,10 @@ function DeeplinkRedirectLoadingIndicator({translate, openLinkInBrowser, session
);
}
-DeeplinkRedirectLoadingIndicator.propTypes = propTypes;
-DeeplinkRedirectLoadingIndicator.defaultProps = defaultProps;
DeeplinkRedirectLoadingIndicator.displayName = 'DeeplinkRedirectLoadingIndicator';
-export default compose(
- withLocalize,
- withOnyx({
- session: {
- key: ONYXKEYS.SESSION,
- },
- }),
-)(DeeplinkRedirectLoadingIndicator);
+export default withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+})(DeeplinkRedirectLoadingIndicator);
diff --git a/src/components/DeeplinkWrapper/index.js b/src/components/DeeplinkWrapper/index.js
deleted file mode 100644
index de50d9bdf134..000000000000
--- a/src/components/DeeplinkWrapper/index.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import PropTypes from 'prop-types';
-
-const propTypes = {
- /** Children to render. */
- children: PropTypes.node.isRequired,
-};
-
-function DeeplinkWrapper({children}) {
- return children;
-}
-
-DeeplinkWrapper.propTypes = propTypes;
-
-export default DeeplinkWrapper;
diff --git a/src/components/DeeplinkWrapper/index.tsx b/src/components/DeeplinkWrapper/index.tsx
new file mode 100644
index 000000000000..4b0382bd6b14
--- /dev/null
+++ b/src/components/DeeplinkWrapper/index.tsx
@@ -0,0 +1,9 @@
+import DeeplinkWrapperProps from './types';
+
+function DeeplinkWrapper({children}: DeeplinkWrapperProps) {
+ return children;
+}
+
+DeeplinkWrapper.displayName = 'DeeplinkWrapper';
+
+export default DeeplinkWrapper;
diff --git a/src/components/DeeplinkWrapper/index.website.js b/src/components/DeeplinkWrapper/index.website.tsx
similarity index 79%
rename from src/components/DeeplinkWrapper/index.website.js
rename to src/components/DeeplinkWrapper/index.website.tsx
index d81c99657dd8..2cae91e2f2a0 100644
--- a/src/components/DeeplinkWrapper/index.website.js
+++ b/src/components/DeeplinkWrapper/index.website.tsx
@@ -1,7 +1,5 @@
import Str from 'expensify-common/lib/str';
-import PropTypes from 'prop-types';
import {useEffect, useRef, useState} from 'react';
-import _ from 'underscore';
import * as Browser from '@libs/Browser';
import Navigation from '@libs/Navigation/Navigation';
import navigationRef from '@libs/Navigation/navigationRef';
@@ -10,17 +8,9 @@ import * as App from '@userActions/App';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
+import DeeplinkWrapperProps from './types';
-const propTypes = {
- /** Children to render. */
- children: PropTypes.node.isRequired,
- /** User authentication status */
- isAuthenticated: PropTypes.bool.isRequired,
- /** The auto authentication status */
- autoAuthState: PropTypes.string,
-};
-
-function isMacOSWeb() {
+function isMacOSWeb(): boolean {
return !Browser.isMobile() && typeof navigator === 'object' && typeof navigator.userAgent === 'string' && /Mac/i.test(navigator.userAgent) && !/Electron/i.test(navigator.userAgent);
}
@@ -38,10 +28,11 @@ function promptToOpenInDesktopApp() {
App.beginDeepLinkRedirect(!isMagicLink);
}
}
-function DeeplinkWrapper({children, isAuthenticated, autoAuthState}) {
- const [currentScreen, setCurrentScreen] = useState();
+
+function DeeplinkWrapper({children, isAuthenticated, autoAuthState}: DeeplinkWrapperProps) {
+ const [currentScreen, setCurrentScreen] = useState();
const [hasShownPrompt, setHasShownPrompt] = useState(false);
- const removeListener = useRef();
+ const removeListener = useRef<() => void>();
useEffect(() => {
// If we've shown the prompt and still have a listener registered,
@@ -55,21 +46,21 @@ function DeeplinkWrapper({children, isAuthenticated, autoAuthState}) {
setHasShownPrompt(false);
Navigation.isNavigationReady().then(() => {
// Get initial route
- const initialRoute = navigationRef.current.getCurrentRoute();
- setCurrentScreen(initialRoute.name);
+ const initialRoute = navigationRef.current?.getCurrentRoute();
+ setCurrentScreen(initialRoute?.name);
- removeListener.current = navigationRef.current.addListener('state', (event) => {
+ removeListener.current = navigationRef.current?.addListener('state', (event) => {
setCurrentScreen(Navigation.getRouteNameFromStateEvent(event));
});
});
}
}, [hasShownPrompt, isAuthenticated]);
+
useEffect(() => {
// According to the design, we don't support unlink in Desktop app https://github.com/Expensify/App/issues/19681#issuecomment-1610353099
- const isUnsupportedDeeplinkRoute = _.some([CONST.REGEX.ROUTES.UNLINK_LOGIN], (unsupportRouteRegex) => {
- const routeRegex = new RegExp(unsupportRouteRegex);
- return routeRegex.test(window.location.pathname);
- });
+ const routeRegex = new RegExp(CONST.REGEX.ROUTES.UNLINK_LOGIN);
+ const isUnsupportedDeeplinkRoute = routeRegex.test(window.location.pathname);
+
// Making a few checks to exit early before checking authentication status
if (!isMacOSWeb() || isUnsupportedDeeplinkRoute || hasShownPrompt || CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV || autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED) {
return;
@@ -99,5 +90,6 @@ function DeeplinkWrapper({children, isAuthenticated, autoAuthState}) {
return children;
}
-DeeplinkWrapper.propTypes = propTypes;
+DeeplinkWrapper.displayName = 'DeeplinkWrapper';
+
export default DeeplinkWrapper;
diff --git a/src/components/DeeplinkWrapper/types.ts b/src/components/DeeplinkWrapper/types.ts
new file mode 100644
index 000000000000..dfd56b62573d
--- /dev/null
+++ b/src/components/DeeplinkWrapper/types.ts
@@ -0,0 +1,11 @@
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type DeeplinkWrapperProps = ChildrenProps & {
+ /** User authentication status */
+ isAuthenticated: boolean;
+
+ /** The auto authentication status */
+ autoAuthState?: string;
+};
+
+export default DeeplinkWrapperProps;
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 (
{
@@ -155,6 +155,11 @@ const FormProvider = forwardRef(
}
}
}
+
+ if (isMatch && leadingSpaceIndex === -1) {
+ return;
+ }
+
// Add a validation error here because it is a string value that contains HTML characters
validateErrors[inputID] = 'common.error.invalidCharacter';
});
diff --git a/src/components/FormAlertWithSubmitButton.js b/src/components/FormAlertWithSubmitButton.js
deleted file mode 100644
index 86e88c27b388..000000000000
--- a/src/components/FormAlertWithSubmitButton.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import useThemeStyles from '@hooks/useThemeStyles';
-import Button from './Button';
-import FormAlertWrapper from './FormAlertWrapper';
-
-const propTypes = {
- /** Text for the button */
- buttonText: PropTypes.string.isRequired,
-
- /** Styles for container element */
- // eslint-disable-next-line react/forbid-prop-types
- containerStyles: PropTypes.arrayOf(PropTypes.object),
-
- /** Whether to show the alert text */
- isAlertVisible: PropTypes.bool.isRequired,
-
- /** Whether the button is disabled */
- isDisabled: PropTypes.bool,
-
- /** Is the button in a loading state */
- isLoading: PropTypes.bool,
-
- /** Whether message is in html format */
- isMessageHtml: PropTypes.bool,
-
- /** Error message to display above button */
- message: PropTypes.string,
-
- /** Callback fired when the "fix the errors" link is pressed */
- onFixTheErrorsLinkPressed: PropTypes.func,
-
- /** Submit function */
- onSubmit: PropTypes.func.isRequired,
-
- /** Should the button be enabled when offline */
- enabledWhenOffline: PropTypes.bool,
-
- /** Disable press on enter for submit button */
- disablePressOnEnter: PropTypes.bool,
-
- /** Whether the form submit action is dangerous */
- isSubmitActionDangerous: PropTypes.bool,
-
- /** Custom content to display in the footer after submit button */
- footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
-
- /** Styles for the button */
- // eslint-disable-next-line react/forbid-prop-types
- buttonStyles: PropTypes.arrayOf(PropTypes.object),
-
- /** Whether to use a smaller submit button size */
- useSmallerSubmitButtonSize: PropTypes.bool,
-
- /** Style for the error message for submit button */
- errorMessageStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
-};
-
-const defaultProps = {
- message: '',
- isDisabled: false,
- isMessageHtml: false,
- containerStyles: [],
- isLoading: false,
- onFixTheErrorsLinkPressed: () => {},
- 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/FormAlertWrapper.js b/src/components/FormAlertWrapper.js
deleted file mode 100644
index 6062ea8f7803..000000000000
--- a/src/components/FormAlertWrapper.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import FormHelpMessage from './FormHelpMessage';
-import networkPropTypes from './networkPropTypes';
-import {withNetwork} from './OnyxProvider';
-import RenderHTML from './RenderHTML';
-import Text from './Text';
-import TextLink from './TextLink';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-
-const propTypes = {
- /** Wrapped child components */
- children: PropTypes.func.isRequired,
-
- /** Styles for container element */
- // eslint-disable-next-line react/forbid-prop-types
- containerStyles: PropTypes.arrayOf(PropTypes.object),
-
- /** Whether to show the alert text */
- isAlertVisible: PropTypes.bool,
-
- /** Whether message is in html format */
- isMessageHtml: PropTypes.bool,
-
- /** Error message to display above button */
- message: PropTypes.string,
-
- /** Props to detect online status */
- network: networkPropTypes.isRequired,
-
- /** Callback fired when the "fix the errors" link is pressed */
- onFixTheErrorsLinkPressed: PropTypes.func,
-
- /** Style for the error message for submit button */
- errorMessageStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- containerStyles: [],
- errorMessageStyle: [],
- isAlertVisible: false,
- isMessageHtml: false,
- message: '',
- onFixTheErrorsLinkPressed: () => {},
-};
-
-// The FormAlertWrapper offers a standardized way of showing error messages and offline functionality.
-//
-// This component takes other components as a child prop. It will then render any wrapped components as a function using "render props",
-// and passes it a (bool) isOffline parameter. Child components can then use the isOffline variable to determine offline behavior.
-function FormAlertWrapper(props) {
- const styles = useThemeStyles();
- let children;
- if (_.isEmpty(props.message)) {
- children = (
-
- {`${props.translate('common.please')} `}
-
- {props.translate('common.fixTheErrors')}
-
- {` ${props.translate('common.inTheFormBeforeContinuing')}.`}
-
- );
- } else if (props.isMessageHtml) {
- children = ${props.message}`} />;
- }
- return (
-
- {props.isAlertVisible && (
-
- {children}
-
- )}
- {props.children(props.network.isOffline)}
-
- );
-}
-
-FormAlertWrapper.propTypes = propTypes;
-FormAlertWrapper.defaultProps = defaultProps;
-FormAlertWrapper.displayName = 'FormAlertWrapper';
-
-export default compose(withLocalize, withNetwork())(FormAlertWrapper);
diff --git a/src/components/FormAlertWrapper.tsx b/src/components/FormAlertWrapper.tsx
new file mode 100644
index 000000000000..a144bf069502
--- /dev/null
+++ b/src/components/FormAlertWrapper.tsx
@@ -0,0 +1,90 @@
+import React, {ReactNode} from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Network from '@src/types/onyx/Network';
+import FormHelpMessage from './FormHelpMessage';
+import {withNetwork} from './OnyxProvider';
+import RenderHTML from './RenderHTML';
+import Text from './Text';
+import TextLink from './TextLink';
+
+type FormAlertWrapperProps = {
+ /** Wrapped child components */
+ children: (isOffline?: boolean) => ReactNode;
+
+ /** Styles for container element */
+ containerStyles?: StyleProp;
+
+ /** Style for the error message for submit button */
+ errorMessageStyle?: StyleProp;
+
+ /** Whether to show the alert text */
+ isAlertVisible?: boolean;
+
+ /** Whether message is in html format */
+ isMessageHtml?: boolean;
+
+ /** Error message to display above button */
+ message?: string;
+
+ /** Props to detect online status */
+ network: Network;
+
+ /** Callback fired when the "fix the errors" link is pressed */
+ onFixTheErrorsLinkPressed?: () => void;
+};
+
+// The FormAlertWrapper offers a standardized way of showing error messages and offline functionality.
+//
+// This component takes other components as a child prop. It will then render any wrapped components as a function using "render props",
+// and passes it a (bool) isOffline parameter. Child components can then use the isOffline variable to determine offline behavior.
+function FormAlertWrapper({
+ children,
+ containerStyles,
+ errorMessageStyle,
+ isAlertVisible = false,
+ isMessageHtml = false,
+ message = '',
+ network,
+ onFixTheErrorsLinkPressed = () => {},
+}: FormAlertWrapperProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ let content;
+ if (!message?.length) {
+ content = (
+
+ {`${translate('common.please')} `}
+
+ {translate('common.fixTheErrors')}
+
+ {` ${translate('common.inTheFormBeforeContinuing')}.`}
+
+ );
+ } else if (isMessageHtml) {
+ content = ${message}`} />;
+ }
+
+ return (
+
+ {isAlertVisible && (
+
+ {content}
+
+ )}
+ {children(!!network.isOffline)}
+
+ );
+}
+
+FormAlertWrapper.displayName = 'FormAlertWrapper';
+
+export default withNetwork()(FormAlertWrapper);
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 && (
{},
+ onError: () => {},
+ style: {},
+};
+
+function Lightbox({isAuthTokenRequired, source, onScaleChanged, onPress, onError, style, index, activeIndex, hasSiblingCarouselItems, zoomRange}) {
+ const StyleUtils = useStyleUtils();
+
+ const [containerSize, setContainerSize] = useState({width: 0, height: 0});
+ const isContainerLoaded = containerSize.width !== 0 && containerSize.height !== 0;
+
+ const [imageDimensions, _setImageDimensions] = useState(() => cachedDimensions.get(source));
+ const setImageDimensions = (newDimensions) => {
+ _setImageDimensions(newDimensions);
+ cachedDimensions.set(source, newDimensions);
+ };
+
+ const isItemActive = index === activeIndex;
+ const [isActive, setActive] = useState(isItemActive);
+ const [isImageLoaded, setImageLoaded] = useState(false);
+
+ const isInactiveCarouselItem = hasSiblingCarouselItems && !isActive;
+ const [isFallbackVisible, setFallbackVisible] = useState(isInactiveCarouselItem);
+ const [isFallbackLoaded, setFallbackLoaded] = useState(false);
+
+ const isLightboxLoaded = imageDimensions?.lightboxSize != null;
+ const isLightboxInRange = useMemo(() => {
+ if (NUMBER_OF_CONCURRENT_LIGHTBOXES === -1) {
+ return true;
+ }
+
+ const indexCanvasOffset = Math.floor((NUMBER_OF_CONCURRENT_LIGHTBOXES - 1) / 2) || 0;
+ const indexOutOfRange = index > activeIndex + indexCanvasOffset || index < activeIndex - indexCanvasOffset;
+ return !indexOutOfRange;
+ }, [activeIndex, index]);
+ const isLightboxVisible = isLightboxInRange && (isActive || isLightboxLoaded || isFallbackLoaded);
+
+ const isLoading = isActive && (!isContainerLoaded || !isImageLoaded);
+
+ const updateCanvasSize = useCallback(
+ ({nativeEvent}) => setContainerSize({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)}),
+ [],
+ );
+
+ // We delay setting a page to active state by a (few) millisecond(s),
+ // to prevent the image transformer from flashing while still rendering
+ // Instead, we show the fallback image while the image transformer is loading the image
+ useEffect(() => {
+ if (isItemActive) {
+ setTimeout(() => setActive(true), 1);
+ } else {
+ setActive(false);
+ }
+ }, [isItemActive]);
+
+ useEffect(() => {
+ if (isLightboxVisible) {
+ return;
+ }
+ setImageLoaded(false);
+ }, [isLightboxVisible]);
+
+ useEffect(() => {
+ if (!hasSiblingCarouselItems) {
+ return;
+ }
+
+ if (isActive) {
+ if (isImageLoaded && isFallbackVisible) {
+ // We delay hiding the fallback image while image transformer is still rendering
+ setTimeout(() => {
+ setFallbackVisible(false);
+ setFallbackLoaded(false);
+ }, 100);
+ }
+ } else {
+ if (isLightboxVisible && isLightboxLoaded) {
+ return;
+ }
+
+ // Show fallback when the image goes out of focus or when the image is loading
+ setFallbackVisible(true);
+ }
+ }, [hasSiblingCarouselItems, isActive, isImageLoaded, isFallbackVisible, isLightboxLoaded, isLightboxVisible]);
+
+ const fallbackSize = useMemo(() => {
+ if (!hasSiblingCarouselItems || (imageDimensions?.lightboxSize == null && imageDimensions?.fallbackSize == null) || containerSize.width === 0 || containerSize.height === 0) {
+ return;
+ }
+
+ const imageSize = imageDimensions.lightboxSize || imageDimensions.fallbackSize;
+
+ const {minScale} = getCanvasFitScale({canvasSize: containerSize, contentSize: imageSize});
+
+ return {
+ width: PixelRatio.roundToNearestPixel(imageSize.width * minScale),
+ height: PixelRatio.roundToNearestPixel(imageSize.height * minScale),
+ };
+ }, [containerSize, hasSiblingCarouselItems, imageDimensions]);
+
+ return (
+
+ {isContainerLoaded && (
+ <>
+ {isLightboxVisible && (
+
+
+ setImageLoaded(true)}
+ onLoad={(e) => {
+ const width = (e.nativeEvent?.width || 0) / PixelRatio.get();
+ const height = (e.nativeEvent?.height || 0) / PixelRatio.get();
+ setImageDimensions({...imageDimensions, lightboxSize: {width, height}});
+ }}
+ />
+
+
+ )}
+
+ {/* Keep rendering the image without gestures as fallback if the carousel item is not active and while the lightbox is loading the image */}
+ {isFallbackVisible && (
+
+ setFallbackLoaded(true)}
+ onLoad={(e) => {
+ const width = e.nativeEvent?.width || 0;
+ const height = e.nativeEvent?.height || 0;
+
+ if (imageDimensions?.lightboxSize != null) {
+ return;
+ }
+
+ setImageDimensions({...imageDimensions, fallbackSize: {width, height}});
+ }}
+ />
+
+ )}
+
+ {/* Show activity indicator while the lightbox is still loading the image. */}
+ {isLoading && (
+
+ )}
+ >
+ )}
+
+ );
+}
+
+Lightbox.propTypes = propTypes;
+Lightbox.defaultProps = defaultProps;
+Lightbox.displayName = 'Lightbox';
+
+export default Lightbox;
diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx
index 2fa4e1c749e6..3c649a8cb546 100644
--- a/src/components/LocaleContextProvider.tsx
+++ b/src/components/LocaleContextProvider.tsx
@@ -30,13 +30,13 @@ type LocaleContextProps = {
translate: (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;
/** Formats a datetime to local date and time string */
- datetimeToCalendarTime: (datetime: string, includeTimezone: boolean, isLowercase: boolean) => string;
+ datetimeToCalendarTime: (datetime: string, includeTimezone: boolean, isLowercase?: boolean) => string;
/** Updates date-fns internal locale */
updateLocale: () => void;
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/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 65bffa9bffd6..fc3e4b095873 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -521,6 +521,7 @@ function MoneyRequestConfirmationList(props) {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
}}
+ shouldShowPersonalBankAccountOption
/>
) : (
{scaleX: number; scaleY: number; minScale: number; maxScale: number};
+
+const getCanvasFitScale: GetCanvasFitScale = ({canvasSize, contentSize}) => {
+ const scaleX = canvasSize.width / contentSize.width;
+ const scaleY = canvasSize.height / contentSize.height;
+
+ const minScale = Math.min(scaleX, scaleY);
+ const maxScale = Math.max(scaleX, scaleY);
+
+ return {scaleX, scaleY, minScale, maxScale};
+};
+
+export default getCanvasFitScale;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js b/src/components/MultiGestureCanvas/index.js
similarity index 71%
rename from src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js
rename to src/components/MultiGestureCanvas/index.js
index 4bce03b6f25e..c5fd2632c22d 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js
+++ b/src/components/MultiGestureCanvas/index.js
@@ -1,5 +1,3 @@
-/* eslint-disable es/no-optional-chaining */
-import PropTypes from 'prop-types';
import React, {useContext, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
@@ -15,18 +13,19 @@ import Animated, {
withDecay,
withSpring,
} from 'react-native-reanimated';
+import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext';
+import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
-import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext';
-import ImageWrapper from './ImageWrapper';
-
-const MIN_ZOOM_SCALE_WITHOUT_BOUNCE = 1;
-const MAX_ZOOM_SCALE_WITHOUT_BOUNCE = 20;
-
-const MIN_ZOOM_SCALE_WITH_BOUNCE = MIN_ZOOM_SCALE_WITHOUT_BOUNCE * 0.7;
-const MAX_ZOOM_SCALE_WITH_BOUNCE = MAX_ZOOM_SCALE_WITHOUT_BOUNCE * 1.5;
+import getCanvasFitScale from './getCanvasFitScale';
+import {defaultZoomRange, multiGestureCanvasDefaultProps, multiGestureCanvasPropTypes} from './propTypes';
const DOUBLE_TAP_SCALE = 3;
+const zoomScaleBounceFactors = {
+ min: 0.7,
+ max: 1.5,
+};
+
const SPRING_CONFIG = {
mass: 1,
stiffness: 1000,
@@ -39,44 +38,54 @@ function clamp(value, lowerBound, upperBound) {
return Math.min(Math.max(lowerBound, value), upperBound);
}
-const imageTransformerPropTypes = {
- imageWidth: PropTypes.number,
- imageHeight: PropTypes.number,
- imageScaleX: PropTypes.number,
- imageScaleY: PropTypes.number,
- scaledImageWidth: PropTypes.number,
- scaledImageHeight: PropTypes.number,
- isActive: PropTypes.bool.isRequired,
- children: PropTypes.node.isRequired,
-};
+function getDeepDefaultProps({contentSize: contentSizeProp = {}, zoomRange: zoomRangeProp = {}}) {
+ const contentSize = {
+ width: contentSizeProp.width == null ? 1 : contentSizeProp.width,
+ height: contentSizeProp.height == null ? 1 : contentSizeProp.height,
+ };
-const imageTransformerDefaultProps = {
- imageWidth: 0,
- imageHeight: 0,
- imageScaleX: 1,
- imageScaleY: 1,
- scaledImageWidth: 0,
- scaledImageHeight: 0,
-};
+ const zoomRange = {
+ min: zoomRangeProp.min == null ? defaultZoomRange.min : zoomRangeProp.min,
+ max: zoomRangeProp.max == null ? defaultZoomRange.max : zoomRangeProp.max,
+ };
-function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, scaledImageWidth, scaledImageHeight, isActive, children}) {
+ return {contentSize, zoomRange};
+}
+
+function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, children, ...props}) {
const styles = useThemeStyles();
- const {canvasWidth, canvasHeight, onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = useContext(AttachmentCarouselPagerContext);
+ const StyleUtils = useStyleUtils();
+ const {contentSize, zoomRange} = getDeepDefaultProps(props);
+
+ const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
+
+ const pagerRefFallback = useRef(null);
+ const {onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = attachmentCarouselPagerContext || {
+ onTap: () => undefined,
+ onSwipe: () => undefined,
+ onSwipeSuccess: () => undefined,
+ onPinchGestureChange: () => undefined,
+ pagerRef: pagerRefFallback,
+ shouldPagerScroll: false,
+ isScrolling: false,
+ ...props,
+ };
- const minImageScale = useMemo(() => Math.min(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]);
- const maxImageScale = useMemo(() => Math.max(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]);
+ const {minScale: minContentScale, maxScale: maxContentScale} = useMemo(() => getCanvasFitScale({canvasSize, contentSize}), [canvasSize, contentSize]);
+ const scaledWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]);
+ const scaledHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]);
// On double tap zoom to fill, but at least 3x zoom
- const doubleTapScale = useMemo(() => Math.max(maxImageScale / minImageScale, DOUBLE_TAP_SCALE), [maxImageScale, minImageScale]);
+ const doubleTapScale = useMemo(() => Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale), [maxContentScale, minContentScale]);
const zoomScale = useSharedValue(1);
- // Adding together the pinch zoom scale and the initial scale to fit the image into the canvas
- // Using the smaller imageScale, so that the immage is not bigger than the canvas
+ // Adding together the pinch zoom scale and the initial scale to fit the content into the canvas
+ // Using the smaller content scale, so that the immage is not bigger than the canvas
// and not smaller than needed to fit
- const totalScale = useDerivedValue(() => zoomScale.value * minImageScale, [minImageScale]);
+ const totalScale = useDerivedValue(() => zoomScale.value * minContentScale, [minContentScale]);
- const zoomScaledImageWidth = useDerivedValue(() => imageWidth * totalScale.value, [imageWidth]);
- const zoomScaledImageHeight = useDerivedValue(() => imageHeight * totalScale.value, [imageHeight]);
+ const zoomScaledContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]);
+ const zoomScaledContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]);
// used for pan gesture
const translateY = useSharedValue(0);
@@ -104,22 +113,22 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
// store scale in between gestures
const pinchScaleOffset = useSharedValue(1);
- // disable pan vertically when image is smaller than screen
- const canPanVertically = useDerivedValue(() => canvasHeight < zoomScaledImageHeight.value, [canvasHeight]);
+ // disable pan vertically when content is smaller than screen
+ const canPanVertically = useDerivedValue(() => canvasSize.height < zoomScaledContentHeight.value, [canvasSize.height]);
- // calculates bounds of the scaled image
+ // calculates bounds of the scaled content
// can we pan left/right/up/down
// can be used to limit gesture or implementing tension effect
const getBounds = useWorkletCallback(() => {
let rightBoundary = 0;
let topBoundary = 0;
- if (canvasWidth < zoomScaledImageWidth.value) {
- rightBoundary = Math.abs(canvasWidth - zoomScaledImageWidth.value) / 2;
+ if (canvasSize.width < zoomScaledContentWidth.value) {
+ rightBoundary = Math.abs(canvasSize.width - zoomScaledContentWidth.value) / 2;
}
- if (canvasHeight < zoomScaledImageHeight.value) {
- topBoundary = Math.abs(zoomScaledImageHeight.value - canvasHeight) / 2;
+ if (canvasSize.height < zoomScaledContentHeight.value) {
+ topBoundary = Math.abs(zoomScaledContentHeight.value - canvasSize.height) / 2;
}
const maxVector = {x: rightBoundary, y: topBoundary};
@@ -142,7 +151,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
canPanLeft: target.x < maxVector.x,
canPanRight: target.x > minVector.x,
};
- }, [canvasWidth, canvasHeight]);
+ }, [canvasSize.width, canvasSize.height]);
const afterPanGesture = useWorkletCallback(() => {
const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds();
@@ -166,7 +175,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
const deceleration = 0.9915;
if (isInBoundaryX) {
- if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE) {
+ if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= zoomRange.max) {
offsetX.value = withDecay({
velocity: panVelocityX.value,
clamp: [minVector.x, maxVector.x],
@@ -181,8 +190,8 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
if (isInBoundaryY) {
if (
Math.abs(panVelocityY.value) > 0 &&
- zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE &&
- // limit vertical pan only when image is smaller than screen
+ zoomScale.value <= zoomRange.max &&
+ // limit vertical pan only when content is smaller than screen
offsetY.value !== minVector.y &&
offsetY.value !== maxVector.y
) {
@@ -210,42 +219,42 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
stopAnimation();
- const canvasOffsetX = Math.max(0, (canvasWidth - scaledImageWidth) / 2);
- const canvasOffsetY = Math.max(0, (canvasHeight - scaledImageHeight) / 2);
+ const canvasOffsetX = Math.max(0, (canvasSize.width - scaledWidth) / 2);
+ const canvasOffsetY = Math.max(0, (canvasSize.height - scaledHeight) / 2);
- const imageFocal = {
- x: clamp(canvasFocalX - canvasOffsetX, 0, scaledImageWidth),
- y: clamp(canvasFocalY - canvasOffsetY, 0, scaledImageHeight),
+ const contentFocal = {
+ x: clamp(canvasFocalX - canvasOffsetX, 0, scaledWidth),
+ y: clamp(canvasFocalY - canvasOffsetY, 0, scaledHeight),
};
const canvasCenter = {
- x: canvasWidth / 2,
- y: canvasHeight / 2,
+ x: canvasSize.width / 2,
+ y: canvasSize.height / 2,
};
- const originImageCenter = {
- x: scaledImageWidth / 2,
- y: scaledImageHeight / 2,
+ const originContentCenter = {
+ x: scaledWidth / 2,
+ y: scaledHeight / 2,
};
- const targetImageSize = {
- width: scaledImageWidth * doubleTapScale,
- height: scaledImageHeight * doubleTapScale,
+ const targetContentSize = {
+ width: scaledWidth * doubleTapScale,
+ height: scaledHeight * doubleTapScale,
};
- const targetImageCenter = {
- x: targetImageSize.width / 2,
- y: targetImageSize.height / 2,
+ const targetContentCenter = {
+ x: targetContentSize.width / 2,
+ y: targetContentSize.height / 2,
};
const currentOrigin = {
- x: (targetImageCenter.x - canvasCenter.x) * -1,
- y: (targetImageCenter.y - canvasCenter.y) * -1,
+ x: (targetContentCenter.x - canvasCenter.x) * -1,
+ y: (targetContentCenter.y - canvasCenter.y) * -1,
};
const koef = {
- x: (1 / originImageCenter.x) * imageFocal.x - 1,
- y: (1 / originImageCenter.y) * imageFocal.y - 1,
+ x: (1 / originContentCenter.x) * contentFocal.x - 1,
+ y: (1 / originContentCenter.y) * contentFocal.y - 1,
};
const target = {
@@ -253,7 +262,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
y: currentOrigin.y * koef.y,
};
- if (targetImageSize.height < canvasHeight) {
+ if (targetContentSize.height < canvasSize.height) {
target.y = 0;
}
@@ -262,7 +271,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG);
pinchScaleOffset.value = doubleTapScale;
},
- [scaledImageWidth, scaledImageHeight, canvasWidth, canvasHeight],
+ [scaledWidth, scaledHeight, canvasSize, doubleTapScale],
);
const reset = useWorkletCallback((animated) => {
@@ -295,6 +304,10 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
} else {
zoomToCoordinates(evt.x, evt.y);
}
+
+ if (onScaleChanged != null) {
+ runOnJS(onScaleChanged)(zoomScale.value);
+ }
});
const panGestureRef = useRef(Gesture.Pan());
@@ -396,7 +409,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
};
offsetY.value = withSpring(
- maybeInvert(imageHeight * 2),
+ maybeInvert(contentSize.height * 2),
{
stiffness: 50,
damping: 30,
@@ -423,10 +436,10 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
const getAdjustedFocal = useWorkletCallback(
(focalX, focalY) => ({
- x: focalX - (canvasWidth / 2 + offsetX.value),
- y: focalY - (canvasHeight / 2 + offsetY.value),
+ x: focalX - (canvasSize.width / 2 + offsetX.value),
+ y: focalY - (canvasSize.height / 2 + offsetY.value),
}),
- [canvasWidth, canvasHeight],
+ [canvasSize.width, canvasSize.height],
);
// used to store event scale value when we limit scale
@@ -455,7 +468,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
.onChange((evt) => {
const newZoomScale = pinchScaleOffset.value * evt.scale;
- if (zoomScale.value >= MIN_ZOOM_SCALE_WITH_BOUNCE && zoomScale.value <= MAX_ZOOM_SCALE_WITH_BOUNCE) {
+ if (zoomScale.value >= zoomRange.min * zoomScaleBounceFactors.min && zoomScale.value <= zoomRange.max * zoomScaleBounceFactors.max) {
zoomScale.value = newZoomScale;
pinchGestureScale.value = evt.scale;
}
@@ -464,7 +477,7 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
const newPinchTranslateX = adjustedFocal.x + pinchGestureScale.value * origin.x.value * -1;
const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * origin.y.value * -1;
- if (zoomScale.value >= MIN_ZOOM_SCALE_WITHOUT_BOUNCE && zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE) {
+ if (zoomScale.value >= zoomRange.min && zoomScale.value <= zoomRange.max) {
pinchTranslateX.value = newPinchTranslateX;
pinchTranslateY.value = newPinchTranslateY;
} else {
@@ -480,12 +493,12 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
pinchScaleOffset.value = zoomScale.value;
pinchGestureScale.value = 1;
- if (pinchScaleOffset.value < MIN_ZOOM_SCALE_WITHOUT_BOUNCE) {
- pinchScaleOffset.value = MIN_ZOOM_SCALE_WITHOUT_BOUNCE;
- zoomScale.value = withSpring(MIN_ZOOM_SCALE_WITHOUT_BOUNCE, SPRING_CONFIG);
- } else if (pinchScaleOffset.value > MAX_ZOOM_SCALE_WITHOUT_BOUNCE) {
- pinchScaleOffset.value = MAX_ZOOM_SCALE_WITHOUT_BOUNCE;
- zoomScale.value = withSpring(MAX_ZOOM_SCALE_WITHOUT_BOUNCE, SPRING_CONFIG);
+ if (pinchScaleOffset.value < zoomRange.min) {
+ pinchScaleOffset.value = zoomRange.min;
+ zoomScale.value = withSpring(zoomRange.min, SPRING_CONFIG);
+ } else if (pinchScaleOffset.value > zoomRange.max) {
+ pinchScaleOffset.value = zoomRange.max;
+ zoomScale.value = withSpring(zoomRange.max, SPRING_CONFIG);
}
if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) {
@@ -494,6 +507,10 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
}
pinchGestureRunning.value = false;
+
+ if (onScaleChanged != null) {
+ runOnJS(onScaleChanged)(zoomScale.value);
+ }
});
const [isPinchGestureInUse, setIsPinchGestureInUse] = useState(false);
@@ -556,25 +573,30 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
style={[
styles.flex1,
{
- width: canvasWidth,
+ width: canvasSize.width,
+ overflow: 'hidden',
},
]}
>
-
+
{children}
-
+
);
}
-ImageTransformer.propTypes = imageTransformerPropTypes;
-ImageTransformer.defaultProps = imageTransformerDefaultProps;
-ImageTransformer.displayName = 'ImageTransformer';
+MultiGestureCanvas.propTypes = multiGestureCanvasPropTypes;
+MultiGestureCanvas.defaultProps = multiGestureCanvasDefaultProps;
+MultiGestureCanvas.displayName = 'MultiGestureCanvas';
-export default ImageTransformer;
+export default MultiGestureCanvas;
+export {defaultZoomRange, zoomScaleBounceFactors};
diff --git a/src/components/MultiGestureCanvas/propTypes.js b/src/components/MultiGestureCanvas/propTypes.js
new file mode 100644
index 000000000000..f1961ec0e156
--- /dev/null
+++ b/src/components/MultiGestureCanvas/propTypes.js
@@ -0,0 +1,73 @@
+import PropTypes from 'prop-types';
+
+const defaultZoomRange = {
+ min: 1,
+ max: 20,
+};
+
+const zoomRangePropTypes = {
+ /** Range of zoom that can be applied to the content by pinching or double tapping. */
+ zoomRange: PropTypes.shape({
+ min: PropTypes.number,
+ max: PropTypes.number,
+ }),
+};
+
+const zoomRangeDefaultProps = {
+ zoomRange: {
+ min: defaultZoomRange.min,
+ max: defaultZoomRange.max,
+ },
+};
+
+const multiGestureCanvasPropTypes = {
+ ...zoomRangePropTypes,
+
+ /**
+ * Wheter the canvas is currently active (in the screen) or not.
+ * Disables certain gestures and functionality
+ */
+ isActive: PropTypes.bool,
+
+ /** Handles scale changed event */
+ onScaleChanged: PropTypes.func,
+
+ /** The width and height of the canvas.
+ * This is needed in order to properly scale the content in the canvas
+ */
+ canvasSize: PropTypes.shape({
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ }).isRequired,
+
+ /** The width and height of the content.
+ * This is needed in order to properly scale the content in the canvas
+ */
+ contentSize: PropTypes.shape({
+ width: PropTypes.number,
+ height: PropTypes.number,
+ }),
+
+ /** The scale factors (scaleX, scaleY) that are used to scale the content (width/height) to the canvas size.
+ * `scaledWidth` and `scaledHeight` reflect the actual size of the content after scaling.
+ */
+ contentScaling: PropTypes.shape({
+ scaleX: PropTypes.number,
+ scaleY: PropTypes.number,
+ scaledWidth: PropTypes.number,
+ scaledHeight: PropTypes.number,
+ }),
+
+ /** Content that should be transformed inside the canvas (images, pdf, ...) */
+ children: PropTypes.node.isRequired,
+};
+
+const multiGestureCanvasDefaultProps = {
+ isActive: true,
+ onScaleChanged: () => undefined,
+ contentSize: undefined,
+ contentScaling: undefined,
+ zoomRange: undefined,
+};
+
+export {defaultZoomRange, zoomRangePropTypes, zoomRangeDefaultProps, multiGestureCanvasPropTypes, multiGestureCanvasDefaultProps};
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}
/>
) : (
ReactElement;
@@ -47,7 +49,7 @@ function BasePicker(
// Windows will reuse the text color of the select for each one of the options
// so we might need to color accordingly so it doesn't blend with the background.
- const pickerPlaceholder = Object.keys(placeholder).length > 0 ? {...placeholder, color: theme.pickerOptionsTextColor} : {};
+ const pickerPlaceholder = Object.keys(placeholder).length > 0 ? {...placeholder, color: theme.text} : {};
useEffect(() => {
if (!!value || !items || items.length !== 1 || !onInputChange) {
@@ -136,6 +138,17 @@ function BasePicker(
},
}));
+ /**
+ * We pass light text on Android, since Android Native alerts have a dark background in all themes for now.
+ */
+ const itemColor = useMemo(() => {
+ if (getOperatingSystem() === CONST.OS.ANDROID) {
+ return theme.textLight;
+ }
+
+ return theme.text;
+ }, [theme]);
+
const hasError = !!errorText;
if (isDisabled) {
@@ -165,7 +178,7 @@ function BasePicker(
({...item, color: theme.pickerOptionsTextColor}))}
+ items={items.map((item) => ({...item, color: itemColor}))}
style={size === 'normal' ? styles.picker(isDisabled, backgroundColor) : styles.pickerSmall(backgroundColor)}
useNativeAndroidPickerStyle={false}
placeholder={pickerPlaceholder}
diff --git a/src/components/PinButton.js b/src/components/PinButton.js
deleted file mode 100644
index 182e49054100..000000000000
--- a/src/components/PinButton.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import reportPropTypes from '@pages/reportPropTypes';
-import * as Report from '@userActions/Report';
-import * as Session from '@userActions/Session';
-import CONST from '@src/CONST';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-import PressableWithFeedback from './Pressable/PressableWithFeedback';
-import Tooltip from './Tooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-
-const propTypes = {
- /** Report to pin */
- report: reportPropTypes,
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- report: null,
-};
-
-function PinButton(props) {
- const theme = useTheme();
- const styles = useThemeStyles();
- return (
-
- Report.togglePinnedState(props.report.reportID, props.report.isPinned))}
- style={[styles.touchableButtonImage]}
- ariaChecked={props.report.isPinned}
- accessibilityLabel={props.report.isPinned ? props.translate('common.unPin') : props.translate('common.pin')}
- role={CONST.ROLE.BUTTON}
- >
-
-
-
- );
-}
-
-PinButton.displayName = 'PinButton';
-PinButton.propTypes = propTypes;
-PinButton.defaultProps = defaultProps;
-
-export default withLocalize(PinButton);
diff --git a/src/components/PinButton.tsx b/src/components/PinButton.tsx
new file mode 100644
index 000000000000..2ae74853d571
--- /dev/null
+++ b/src/components/PinButton.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as ReportActions from '@userActions/Report';
+import * as Session from '@userActions/Session';
+import CONST from '@src/CONST';
+import type {Report} from '@src/types/onyx';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import PressableWithFeedback from './Pressable/PressableWithFeedback';
+import Tooltip from './Tooltip';
+
+type PinButtonProps = {
+ /** Report to pin */
+ report: Report;
+};
+
+function PinButton({report}: PinButtonProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+ ReportActions.togglePinnedState(report.reportID, report.isPinned ?? false))}
+ style={styles.touchableButtonImage}
+ accessibilityLabel={report.isPinned ? translate('common.unPin') : translate('common.pin')}
+ role={CONST.ROLE.BUTTON}
+ >
+
+
+
+ );
+}
+
+PinButton.displayName = 'PinButton';
+
+export default PinButton;
diff --git a/src/components/PressableWithSecondaryInteraction/index.native.tsx b/src/components/PressableWithSecondaryInteraction/index.native.tsx
index f3cef029aa65..77dc9452f986 100644
--- a/src/components/PressableWithSecondaryInteraction/index.native.tsx
+++ b/src/components/PressableWithSecondaryInteraction/index.native.tsx
@@ -1,4 +1,4 @@
-import React, {forwardRef} from 'react';
+import React, {forwardRef, ReactNode} from 'react';
import {GestureResponderEvent, TextProps} from 'react-native';
import {PressableRef} from '@components/Pressable/GenericPressable/types';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
@@ -36,7 +36,7 @@ function PressableWithSecondaryInteraction(
suppressHighlighting={suppressHighlighting}
onLongPress={onSecondaryInteraction ? executeSecondaryInteraction : undefined}
>
- {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/RNTextInput.tsx b/src/components/RNTextInput.tsx
index 28555abe3266..ff812e7c799b 100644
--- a/src/components/RNTextInput.tsx
+++ b/src/components/RNTextInput.tsx
@@ -2,16 +2,20 @@ import React, {ForwardedRef} from 'react';
// eslint-disable-next-line no-restricted-imports
import {TextInput, TextInputProps} from 'react-native';
import Animated, {AnimatedProps} from 'react-native-reanimated';
+import useTheme from '@hooks/useTheme';
// Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function RNTextInputWithRef(props: TextInputProps, ref: ForwardedRef>>) {
+ const theme = useTheme();
+
return (
{
if (typeof ref !== 'function') {
return;
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 817c88d456db..d024ef43a69d 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -101,8 +101,7 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
originalCurrency: transactionOriginalCurrency,
cardID: transactionCardID,
} = ReportUtils.getTransactionDetails(transaction);
- const isEmptyMerchant =
- transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
+ const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction);
let formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : '';
const hasPendingWaypoints = lodashGet(transaction, 'pendingFields.waypoints', null);
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/ReportActionItem/TaskAction.js b/src/components/ReportActionItem/TaskAction.js
deleted file mode 100644
index 5825d623a5ca..000000000000
--- a/src/components/ReportActionItem/TaskAction.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as TaskUtils from '@libs/TaskUtils';
-
-const propTypes = {
- /** Name of the reportAction action */
- actionName: PropTypes.string.isRequired,
-
- /** The ID of the associated taskReport */
- // eslint-disable-next-line react/no-unused-prop-types -- This is used in the withOnyx HOC
- taskReportID: PropTypes.string.isRequired,
-
- ...withLocalizePropTypes,
-};
-
-function TaskAction(props) {
- const styles = useThemeStyles();
- return (
- <>
-
- {TaskUtils.getTaskReportActionMessage(props.actionName)}
-
- >
- );
-}
-
-TaskAction.propTypes = propTypes;
-TaskAction.displayName = 'TaskAction';
-
-export default withLocalize(TaskAction);
diff --git a/src/components/ReportActionItem/TaskAction.tsx b/src/components/ReportActionItem/TaskAction.tsx
new file mode 100644
index 000000000000..b10be4e86fe8
--- /dev/null
+++ b/src/components/ReportActionItem/TaskAction.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import {View} from 'react-native';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as TaskUtils from '@libs/TaskUtils';
+
+type TaskActionProps = {
+ /** Name of the reportAction action */
+ actionName: string;
+};
+
+function TaskAction({actionName}: TaskActionProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ {TaskUtils.getTaskReportActionMessage(actionName)}
+
+ );
+}
+
+TaskAction.displayName = 'TaskAction';
+
+export default TaskAction;
diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js
index ed875bb04af2..a7728045f407 100644
--- a/src/components/ReportActionItem/TaskPreview.js
+++ b/src/components/ReportActionItem/TaskPreview.js
@@ -54,6 +54,12 @@ const propTypes = {
ownerAccountID: PropTypes.number,
}),
+ /** The policy of root parent report */
+ rootParentReportpolicy: PropTypes.shape({
+ /** The role of current user */
+ role: PropTypes.string,
+ }),
+
/** The chat report associated with taskReport */
chatReportID: PropTypes.string.isRequired,
@@ -72,6 +78,7 @@ const propTypes = {
const defaultProps = {
...withCurrentUserPersonalDetailsDefaultProps,
taskReport: {},
+ rootParentReportpolicy: {},
isHovered: false,
};
@@ -116,7 +123,7 @@ function TaskPreview(props) {
style={[styles.mr2]}
containerStyle={[styles.taskCheckbox]}
isChecked={isTaskCompleted}
- disabled={!Task.canModifyTask(props.taskReport, props.currentUserPersonalDetails.accountID)}
+ disabled={!Task.canModifyTask(props.taskReport, props.currentUserPersonalDetails.accountID, lodashGet(props.rootParentReportpolicy, 'role', ''))}
onPress={Session.checkIfActionIsAllowed(() => {
if (isTaskCompleted) {
Task.reopenTask(props.taskReport);
@@ -149,5 +156,9 @@ export default compose(
key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`,
initialValue: {},
},
+ rootParentReportpolicy: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID || '0'}`,
+ selector: (policy) => _.pick(policy, ['role']),
+ },
}),
)(TaskPreview);
diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js
index bb8945495018..7f7b177136ed 100644
--- a/src/components/ReportActionItem/TaskView.js
+++ b/src/components/ReportActionItem/TaskView.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import React, {useEffect} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
import Checkbox from '@components/Checkbox';
import Hoverable from '@components/Hoverable';
import Icon from '@components/Icon';
@@ -36,6 +37,12 @@ const propTypes = {
/** The report currently being looked at */
report: reportPropTypes.isRequired,
+ /** The policy of root parent report */
+ policy: PropTypes.shape({
+ /** The role of current user */
+ role: PropTypes.string,
+ }),
+
/** Whether we should display the horizontal rule below the component */
shouldShowHorizontalRule: PropTypes.bool.isRequired,
@@ -44,6 +51,10 @@ const propTypes = {
...withCurrentUserPersonalDetailsPropTypes,
};
+const defaultProps = {
+ policy: {},
+};
+
function TaskView(props) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -55,7 +66,7 @@ function TaskView(props) {
const assigneeTooltipDetails = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([props.report.managerID], props.personalDetails), false);
const isCompleted = ReportUtils.isCompletedTaskReport(props.report);
const isOpen = ReportUtils.isOpenTaskReport(props.report);
- const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID);
+ const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID, lodashGet(props.policy, 'role', ''));
const disableState = !canModifyTask;
const isDisableInteractive = !canModifyTask || !isOpen;
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
@@ -188,6 +199,7 @@ function TaskView(props) {
}
TaskView.propTypes = propTypes;
+TaskView.defaultProps = defaultProps;
TaskView.displayName = 'TaskView';
export default compose(
@@ -198,5 +210,12 @@ export default compose(
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
+ policy: {
+ key: ({report}) => {
+ const rootParentReport = ReportUtils.getRootParentReport(report);
+ return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`;
+ },
+ selector: (policy) => _.pick(policy, ['role']),
+ },
}),
)(TaskView);
diff --git a/src/components/RoomNameInput/roomNameInputPropTypes.js b/src/components/RoomNameInput/roomNameInputPropTypes.js
index 7f8292f0123e..60be8430b056 100644
--- a/src/components/RoomNameInput/roomNameInputPropTypes.js
+++ b/src/components/RoomNameInput/roomNameInputPropTypes.js
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
+import refPropTypes from '@components/refPropTypes';
const propTypes = {
/** Callback to execute when the text input is modified correctly */
@@ -14,7 +15,7 @@ const propTypes = {
errorText: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]),
/** A ref forwarded to the TextInput */
- forwardedRef: PropTypes.func,
+ forwardedRef: refPropTypes,
/** The ID used to uniquely identify the input in a Form */
inputID: PropTypes.string,
diff --git a/src/components/Search.tsx b/src/components/Search.tsx
new file mode 100644
index 000000000000..10820f44738d
--- /dev/null
+++ b/src/components/Search.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import {PressableWithFeedback} from './Pressable';
+import Text from './Text';
+import Tooltip from './Tooltip';
+
+type SearchProps = {
+ // Callback fired when component is pressed
+ onPress: (event?: GestureResponderEvent | KeyboardEvent) => void;
+
+ // Text explaining what the user can search for
+ placeholder?: string;
+
+ // Text showing up in a tooltip when component is hovered
+ tooltip?: string;
+
+ // Styles to apply on the outer element
+ style?: StyleProp;
+};
+
+function Search({onPress, placeholder, tooltip, style}: SearchProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+
+ {({hovered}) => (
+
+
+
+ {placeholder ?? translate('common.searchWithThreeDots')}
+
+
+ )}
+
+
+ );
+}
+
+Search.displayName = 'Search';
+
+export type {SearchProps};
+export default Search;
diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js
index 1ffdfd78664d..0c8e193af4cc 100644
--- a/src/components/SettlementButton.js
+++ b/src/components/SettlementButton.js
@@ -81,6 +81,9 @@ const propTypes = {
horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
}),
+
+ /** Whether the personal bank account option should be shown */
+ shouldShowPersonalBankAccountOption: PropTypes.bool,
};
const defaultProps = {
@@ -110,6 +113,7 @@ const defaultProps = {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, // caret for dropdown is at right, so horizontal anchor is at RIGHT
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP
},
+ shouldShowPersonalBankAccountOption: false,
};
function SettlementButton({
@@ -132,6 +136,7 @@ function SettlementButton({
shouldHidePaymentOptions,
shouldShowApproveButton,
style,
+ shouldShowPersonalBankAccountOption,
}) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
@@ -220,6 +225,7 @@ function SettlementButton({
chatReportID={chatReportID}
iouReport={iouReport}
anchorAlignment={kycWallAnchorAlignment}
+ shouldShowPersonalBankAccountOption={shouldShowPersonalBankAccountOption}
>
{(triggerKYCFlow, buttonRef) => (
{},
+ 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) => (
;
+
+ /** The policy of root parent report */
+ policy: OnyxEntry;
};
type TaskHeaderActionButtonProps = TaskHeaderActionButtonOnyxProps & {
@@ -20,7 +23,7 @@ type TaskHeaderActionButtonProps = TaskHeaderActionButtonOnyxProps & {
report: OnyxTypes.Report;
};
-function TaskHeaderActionButton({report, session}: TaskHeaderActionButtonProps) {
+function TaskHeaderActionButton({report, session, policy}: TaskHeaderActionButtonProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -28,7 +31,7 @@ function TaskHeaderActionButton({report, session}: TaskHeaderActionButtonProps)
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index 2e888a5471b8..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);
@@ -358,18 +359,14 @@ function ReportActionItem(props) {
props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED ||
props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED
) {
- children = (
-
- );
+ children = ;
} else if (ReportActionsUtils.isCreatedTaskReportAction(props.action)) {
children = (
);
} 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);
@@ -426,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..55b031c198e0 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -192,6 +192,9 @@ function ReportActionItemMessageEdit(props) {
}
return () => {
+ InputFocus.callback(() => setIsFocused(false));
+ InputFocus.inputFocusChange(false);
+
// Skip if the current report action is not active
if (!isActive()) {
return;
@@ -288,8 +291,6 @@ function ReportActionItemMessageEdit(props) {
* Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content.
*/
const deleteDraft = useCallback(() => {
- InputFocus.callback(() => setIsFocused(false));
- InputFocus.inputFocusChange(false);
debouncedSaveDraft.cancel();
Report.saveReportActionDraft(props.reportID, props.action, '');
@@ -475,7 +476,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/home/report/ReportActionItemThread.js b/src/pages/home/report/ReportActionItemThread.tsx
similarity index 59%
rename from src/pages/home/report/ReportActionItemThread.js
rename to src/pages/home/report/ReportActionItemThread.tsx
index 35ef49dc97fd..e38021cf6ec1 100644
--- a/src/pages/home/report/ReportActionItemThread.js
+++ b/src/pages/home/report/ReportActionItemThread.tsx
@@ -1,62 +1,59 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {Text, View} from 'react-native';
-import avatarPropTypes from '@components/avatarPropTypes';
import MultipleAvatars from '@components/MultipleAvatars';
import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
+import type {Icon} from '@src/types/onyx/OnyxCommon';
-const propTypes = {
+type ReportActionItemThreadProps = {
/** List of participant icons for the thread */
- icons: PropTypes.arrayOf(avatarPropTypes).isRequired,
+ icons: Icon[];
/** Number of comments under the thread */
- numberOfReplies: PropTypes.number.isRequired,
+ numberOfReplies: number;
/** Time of the most recent reply */
- mostRecentReply: PropTypes.string.isRequired,
+ mostRecentReply: string;
/** ID of child thread report */
- childReportID: PropTypes.string.isRequired,
+ childReportID: string;
/** Whether the thread item / message is being hovered */
- isHovered: PropTypes.bool.isRequired,
+ isHovered: boolean;
/** The function that should be called when the thread is LongPressed or right-clicked */
- onSecondaryInteraction: PropTypes.func.isRequired,
-
- ...withLocalizePropTypes,
- ...windowDimensionsPropTypes,
+ onSecondaryInteraction: () => void;
};
-function ReportActionItemThread(props) {
+function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childReportID, isHovered, onSecondaryInteraction}: ReportActionItemThreadProps) {
const styles = useThemeStyles();
- const numberOfRepliesText = props.numberOfReplies > CONST.MAX_THREAD_REPLIES_PREVIEW ? `${CONST.MAX_THREAD_REPLIES_PREVIEW}+` : `${props.numberOfReplies}`;
- const replyText = props.numberOfReplies === 1 ? props.translate('threads.reply') : props.translate('threads.replies');
- const timeStamp = props.datetimeToCalendarTime(props.mostRecentReply, false);
+ const {translate, datetimeToCalendarTime} = useLocalize();
+
+ const numberOfRepliesText = numberOfReplies > CONST.MAX_THREAD_REPLIES_PREVIEW ? `${CONST.MAX_THREAD_REPLIES_PREVIEW}+` : `${numberOfReplies}`;
+ const replyText = numberOfReplies === 1 ? translate('threads.reply') : translate('threads.replies');
+
+ const timeStamp = datetimeToCalendarTime(mostRecentReply, false);
return (
{
- Report.navigateToAndOpenChildReport(props.childReportID);
+ Report.navigateToAndOpenChildReport(childReportID);
}}
role={CONST.ROLE.BUTTON}
- accessibilityLabel={`${props.numberOfReplies} ${replyText}`}
- onSecondaryInteraction={props.onSecondaryInteraction}
+ accessibilityLabel={`${numberOfReplies} ${replyText}`}
+ onSecondaryInteraction={onSecondaryInteraction}
>
@@ -80,7 +77,6 @@ function ReportActionItemThread(props) {
);
}
-ReportActionItemThread.propTypes = propTypes;
ReportActionItemThread.displayName = 'ReportActionItemThread';
-export default compose(withLocalize, withWindowDimensions)(ReportActionItemThread);
+export default ReportActionItemThread;
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index 46abbfc71b84..e2ae7b947fcc 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -17,6 +17,7 @@ import compose from '@libs/compose';
import DateUtils from '@libs/DateUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import Visibility from '@libs/Visibility';
import reportPropTypes from '@pages/reportPropTypes';
import variables from '@styles/variables';
import * as Report from '@userActions/Report';
@@ -190,7 +191,7 @@ function ReportActionsList({
}
if (ReportUtils.isUnread(report)) {
- if (scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD) {
+ if (Visibility.isVisible() && scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD) {
Report.readNewestAction(report.reportID);
} else {
readActionSkipped.current = true;
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
>
-
- _.chain(transaction.participants)
- .map((participant) => {
- const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false);
- return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails);
- })
- .filter((participant) => !!participant.login || !!participant.text)
- .value(),
+ _.map(transaction.participants, (participant) => {
+ const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false);
+ return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails);
+ }),
[transaction.participants, personalDetails],
);
const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]);
diff --git a/src/pages/iou/request/step/IOURequestStepDate.js b/src/pages/iou/request/step/IOURequestStepDate.js
index c90779af47ee..84a67c30a4d4 100644
--- a/src/pages/iou/request/step/IOURequestStepDate.js
+++ b/src/pages/iou/request/step/IOURequestStepDate.js
@@ -1,8 +1,7 @@
-import dateAdd from 'date-fns/add';
-import dateSubtract from 'date-fns/sub';
import React from 'react';
import DatePicker from '@components/DatePicker';
import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -10,6 +9,7 @@ import compose from '@libs/compose';
import * as IOUUtils from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as IOU from '@userActions/IOU';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import IOURequestStepRoutePropTypes from './IOURequestStepRoutePropTypes';
@@ -67,12 +67,13 @@ function IOURequestStepDate({
submitButtonText={translate('common.save')}
enabledWhenOffline
>
-
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.js b/src/pages/iou/request/step/IOURequestStepParticipants.js
index 85d67ea34bae..ec670b828146 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.js
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.js
@@ -71,6 +71,8 @@ function IOURequestStepParticipants({
const goToNextStep = useCallback(() => {
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]}
>
{
+ Link.openExternalLink(CONST.EXPENSIFY_INBOX_URL);
+ },
+ shouldShowRightIcon: true,
+ iconRight: Expensicons.NewWindow,
+ link: CONST.EXPENSIFY_INBOX_URL,
+ },
{
translationKey: 'initialSettingsPage.signOut',
icon: Expensicons.Exit,
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
index b468607ed8f3..25227d747a93 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
+++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
@@ -21,6 +21,7 @@ import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeSt
import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
+import * as Session from '@userActions/Session';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -119,12 +120,22 @@ class ContactMethodDetailsPage extends Component {
}
componentDidUpdate(prevProps) {
- const validatedDate = lodashGet(this.props.loginList, [this.getContactMethod(), 'validatedDate']);
- const prevValidatedDate = lodashGet(prevProps.loginList, [this.getContactMethod(), 'validatedDate']);
+ const contactMethod = this.getContactMethod();
+ const validatedDate = lodashGet(this.props.loginList, [contactMethod, 'validatedDate']);
+ const prevValidatedDate = lodashGet(prevProps.loginList, [contactMethod, 'validatedDate']);
+ const loginData = lodashGet(this.props.loginList, contactMethod, {});
+ const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID;
// Navigate to methods page on successful magic code verification
// validatedDate property is responsible to decide the status of the magic code verification
if (!prevValidatedDate && validatedDate) {
+ // If the selected contactMethod is the current session['login'] and the account is unvalidated,
+ // the current authToken is invalid after the successful magic code verification.
+ // So we need to sign out the user and redirect to the sign in page.
+ if (isDefaultContactMethod) {
+ Session.signOutAndRedirectToSignIn();
+ return;
+ }
Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route);
}
}
@@ -209,7 +220,9 @@ class ContactMethodDetailsPage extends Component {
* @param {Boolean} isOpen
*/
toggleDeleteModal(isOpen) {
- this.setState({isDeleteModalOpen: isOpen});
+ InteractionManager.runAfterInteractions(() => {
+ 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 (
- Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report.reportID))}
/>
-
+
);
diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js
index 3d4c7f4ac6fb..131e71ccb2d0 100644
--- a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js
+++ b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js
@@ -1,4 +1,3 @@
-import {parsePhoneNumber} from 'awesome-phonenumber';
import Str from 'expensify-common/lib/str';
import PropTypes from 'prop-types';
import React from 'react';
@@ -8,6 +7,7 @@ import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import FormUtils from '@libs/FormUtils';
+import {parsePhoneNumber} from '@libs/PhoneNumber';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js
index 513da42b9011..376c69beb955 100644
--- a/src/pages/settings/Wallet/ExpensifyCardPage.js
+++ b/src/pages/settings/Wallet/ExpensifyCardPage.js
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
-import React, {useState} from 'react';
+import React, {useEffect, useMemo, useState} from 'react';
import {ScrollView, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
@@ -86,7 +86,7 @@ const propTypes = {
};
const defaultProps = {
- cardList: {},
+ cardList: null,
draftValues: {
addressLine1: '',
addressLine2: '',
@@ -125,17 +125,21 @@ function ExpensifyCardPage({
const styles = useThemeStyles();
const {isOffline} = useNetwork();
const {translate} = useLocalize();
- const domainCards = CardUtils.getDomainCards(cardList)[domain];
- const virtualCard = _.find(domainCards, (card) => 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 (
(formError ? translate(formError) : ''), [formError, translate]);
const serverErrorText = useMemo(() => ErrorUtils.getLatestErrorMessage(props.account), [props.account]);
- const hasError = !_.isEmpty(serverErrorText);
+ const shouldShowServerError = !_.isEmpty(serverErrorText) && _.isEmpty(formErrorText);
return (
<>
@@ -270,7 +270,7 @@ function LoginForm(props) {
autoCorrect={false}
inputMode={CONST.INPUT_MODE.EMAIL}
errorText={formErrorText}
- hasError={hasError}
+ hasError={shouldShowServerError}
maxLength={CONST.LOGIN_CHARACTER_LIMIT}
/>
@@ -287,14 +287,14 @@ function LoginForm(props) {
// We need to unmount the submit button when the component is not visible so that the Enter button
// key handler gets unsubscribed
props.isVisible && (
-
+
{
diff --git a/src/pages/signin/SignInModal.js b/src/pages/signin/SignInModal.js
index 1bb8b6065a15..10f048d31380 100644
--- a/src/pages/signin/SignInModal.js
+++ b/src/pages/signin/SignInModal.js
@@ -1,9 +1,11 @@
import React from 'react';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
-import useThemeStyles from '@hooks/useThemeStyles';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
import Navigation from '@libs/Navigation/Navigation';
import * as Session from '@userActions/Session';
+import SCREENS from '@src/SCREENS';
import SignInPage from './SignInPage';
const propTypes = {};
@@ -11,7 +13,9 @@ const propTypes = {};
const defaultProps = {};
function SignInModal() {
- const styles = useThemeStyles();
+ const theme = useTheme();
+ const StyleUtils = useStyleUtils();
+
if (!Session.isAnonymousUser()) {
// Sign in in RHP is only for anonymous users
Navigation.isNavigationReady().then(() => {
@@ -20,7 +24,7 @@ function SignInModal() {
}
return (
-
+
{
+ const rootParentReport = ReportUtils.getRootParentReport(report);
+ return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`;
+ },
+ selector: (policy) => _.pick(policy, ['role']),
+ },
}),
)(TaskAssigneeSelectorModal);
diff --git a/src/pages/tasks/TaskDescriptionPage.js b/src/pages/tasks/TaskDescriptionPage.js
index c5dab0dc2f94..3a6999d4408a 100644
--- a/src/pages/tasks/TaskDescriptionPage.js
+++ b/src/pages/tasks/TaskDescriptionPage.js
@@ -1,8 +1,11 @@
import {useFocusEffect} from '@react-navigation/native';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
import React, {useCallback, useRef} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
@@ -28,12 +31,19 @@ const propTypes = {
/** The report currently being looked at */
report: reportPropTypes,
+ /** The policy of parent report */
+ rootParentReportPolicy: PropTypes.shape({
+ /** The role of current user */
+ role: PropTypes.string,
+ }),
+
/* Onyx Props */
...withLocalizePropTypes,
};
const defaultProps = {
report: {},
+ rootParentReportPolicy: {},
};
const parser = new ExpensiMark();
@@ -64,7 +74,7 @@ function TaskDescriptionPage(props) {
const focusTimeoutRef = useRef(null);
const isOpen = ReportUtils.isOpenTaskReport(props.report);
- const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID);
+ const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID, lodashGet(props.rootParentReportPolicy, 'role', ''));
const isTaskNonEditable = ReportUtils.isTaskReport(props.report) && (!canModifyTask || !isOpen);
useFocusEffect(
@@ -138,5 +148,12 @@ export default compose(
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`,
},
+ rootParentReportPolicy: {
+ key: ({report}) => {
+ const rootParentReport = ReportUtils.getRootParentReport(report);
+ return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`;
+ },
+ selector: (policy) => _.pick(policy, ['role']),
+ },
}),
)(TaskDescriptionPage);
diff --git a/src/pages/tasks/TaskTitlePage.js b/src/pages/tasks/TaskTitlePage.js
index 9b3d28a0d032..9b393a8a2374 100644
--- a/src/pages/tasks/TaskTitlePage.js
+++ b/src/pages/tasks/TaskTitlePage.js
@@ -1,3 +1,5 @@
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
import React, {useCallback, useRef} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
@@ -24,12 +26,19 @@ const propTypes = {
/** The report currently being looked at */
report: reportPropTypes,
+ /** The policy of parent report */
+ rootParentReportPolicy: PropTypes.shape({
+ /** The role of current user */
+ role: PropTypes.string,
+ }),
+
/* Onyx Props */
...withLocalizePropTypes,
};
const defaultProps = {
report: {},
+ rootParentReportPolicy: {},
};
function TaskTitlePage(props) {
@@ -70,7 +79,7 @@ function TaskTitlePage(props) {
const inputRef = useRef(null);
const isOpen = ReportUtils.isOpenTaskReport(props.report);
- const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID);
+ const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID, lodashGet(props.rootParentReportPolicy, 'role', ''));
const isTaskNonEditable = ReportUtils.isTaskReport(props.report) && (!canModifyTask || !isOpen);
return (
@@ -130,5 +139,12 @@ export default compose(
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`,
},
+ rootParentReportPolicy: {
+ key: ({report}) => {
+ const rootParentReport = ReportUtils.getRootParentReport(report);
+ return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`;
+ },
+ selector: (policy) => _.pick(policy, ['role']),
+ },
}),
)(TaskTitlePage);
diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js
index 7a28558ee587..16da273750fa 100644
--- a/src/pages/workspace/WorkspaceInvitePage.js
+++ b/src/pages/workspace/WorkspaceInvitePage.js
@@ -225,7 +225,7 @@ function WorkspaceInvitePage(props) {
if (usersToInvite.length === 0 && CONST.EXPENSIFY_EMAILS.includes(searchValue)) {
return translate('messages.errorMessageInvalidEmail');
}
- if (usersToInvite.length === 0 && excludedUsers.includes(searchValue)) {
+ if (usersToInvite.length === 0 && excludedUsers.includes(OptionsListUtils.addSMSDomainIfPhoneNumber(searchValue))) {
return translate('messages.userIsAlreadyMember', {login: searchValue, name: policyName});
}
return OptionsListUtils.getHeaderMessage(personalDetails.length !== 0, usersToInvite.length > 0, searchValue);
diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js
index d09790af58d5..6f86e871e8ae 100644
--- a/src/pages/workspace/WorkspaceNewRoomPage.js
+++ b/src/pages/workspace/WorkspaceNewRoomPage.js
@@ -3,8 +3,12 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import BlockingView from '@components/BlockingViews/BlockingView';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
-import Form from '@components/Form';
+import Button from '@components/Button';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import * as Illustrations from '@components/Icon/Illustrations';
import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
import OfflineIndicator from '@components/OfflineIndicator';
import RoomNameInput from '@components/RoomNameInput';
@@ -27,10 +31,10 @@ import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import variables from '@styles/variables';
-import * as App from '@userActions/App';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
const propTypes = {
/** All reports shared with the user */
@@ -190,7 +194,15 @@ function WorkspaceNewRoomPage(props) {
[props.reports],
);
- const workspaceOptions = useMemo(() => _.map(PolicyUtils.getActivePolicies(props.policies), (policy) => ({label: policy.name, key: policy.id, value: policy.id})), [props.policies]);
+ const workspaceOptions = useMemo(
+ () =>
+ _.map(PolicyUtils.getActivePolicies(props.policies), (policy) => ({
+ label: policy.name,
+ key: policy.id,
+ value: policy.id,
+ })),
+ [props.policies],
+ );
const writeCapabilityOptions = useMemo(
() =>
@@ -216,13 +228,28 @@ function WorkspaceNewRoomPage(props) {
const {inputCallbackRef} = useAutoFocusInput();
+ const renderEmptyWorkspaceView = () => (
+ <>
+
+ Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)}
+ style={[styles.mh5, styles.mb5]}
+ />
+ {isSmallScreenWidth && }
+ >
+ );
+
return (
- App.createWorkspaceWithPolicyDraftAndNavigateToIt()}
- >
+
- {({insets}) => (
-
-
- {isSmallScreenWidth && }
-
- )}
+ {visibilityDescription}
+
+ {isSmallScreenWidth && }
+
+ )
+ }
);
diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js
index c422b0bbe16d..d17efb4dbe1e 100644
--- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js
+++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js
@@ -1,9 +1,10 @@
import lodashGet from 'lodash/get';
-import React from 'react';
+import React, {useEffect} from 'react';
import {Keyboard, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
-import Form from '@components/Form';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import {withNetwork} from '@components/OnyxProvider';
import Picker from '@components/Picker';
@@ -16,7 +17,6 @@ import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator';
import Navigation from '@libs/Navigation/Navigation';
import * as NumberUtils from '@libs/NumberUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
-import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes';
import withPolicy, {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy';
import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections';
import * as BankAccounts from '@userActions/BankAccounts';
@@ -26,9 +26,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
const propTypes = {
- /** Bank account attached to free plan */
- reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes,
-
...policyPropTypes,
...withLocalizePropTypes,
...withThemeStylesPropTypes,
@@ -39,82 +36,30 @@ const defaultProps = {
...policyDefaultProps,
};
-class WorkspaceRateAndUnitPage extends React.Component {
- constructor(props) {
- super(props);
- this.submit = this.submit.bind(this);
- this.validate = this.validate.bind(this);
-
- this.state = {
- rate: 0,
- unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
- };
- }
-
- componentDidMount() {
- this.resetRateAndUnit();
-
- if (lodashGet(this.props, 'policy.customUnits', []).length !== 0) {
+function WorkspaceRateAndUnitPage(props) {
+ useEffect(() => {
+ 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"
/>
- };
+
+function Template(args: SearchProps) {
+ // 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 = {
+ onPress: () => alert('Pressed'),
+};
+
+const CustomPlaceholderAndTooltip: StoryType = Template.bind({});
+CustomPlaceholderAndTooltip.args = {
+ placeholder: 'Search for a specific thing...',
+ tooltip: 'Custom tooltip text',
+ onPress: () => alert('This component has custom placeholder text. Also custom tooltip text when hovered.'),
+};
+
+const CustomBackground: StoryType = Template.bind({});
+CustomBackground.args = {
+ onPress: () => alert('This component has custom styles applied'),
+ style: {backgroundColor: 'darkgreen'},
+};
+
+export default story;
+export {Default, CustomPlaceholderAndTooltip, CustomBackground};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 9e02335bde0d..aececf93beb9 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -1380,6 +1380,41 @@ const styles = (theme: ThemeColors) =>
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 : variables.lhpBorderRadius,
+ borderBottomRightRadius: isSmallScreenWidth ? 0 : variables.lhpBorderRadius,
+ overflow: 'hidden',
+ } satisfies ViewStyle),
+
RHPNavigatorContainer: (isSmallScreenWidth: boolean) =>
({
width: isSmallScreenWidth ? '100%' : variables.sideBarWidth,
@@ -1601,14 +1636,15 @@ const styles = (theme: ThemeColors) =>
marginBottom: 4,
},
- overlayStyles: (current: OverlayStylesParams) =>
+ overlayStyles: (current: OverlayStylesParams, isModalOnTheLeft: boolean) =>
({
...positioning.pFixed,
// We need to stretch the overlay to cover the sidebar and the translate animation distance.
- left: -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: 0,
+ right: isModalOnTheLeft ? -2 * variables.sideBarWidth : 0,
backgroundColor: theme.overlay,
opacity: current.progress.interpolate({
inputRange: [0, 1],
@@ -1963,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: {
@@ -1984,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',
},
@@ -2015,8 +2051,8 @@ const styles = (theme: ThemeColors) =>
secondAvatarSubscriptCompact: {
position: 'absolute',
- bottom: -1,
- right: -1,
+ bottom: -4,
+ right: -4,
},
secondAvatarSubscriptSmallNormal: {
@@ -2372,7 +2408,7 @@ const styles = (theme: ThemeColors) =>
anonymousRoomFooterLogoTaglineText: {
fontFamily: fontFamily.EXP_NEUE,
fontSize: variables.fontSizeMedium,
- color: theme.textLight,
+ color: theme.text,
},
signInButtonAvatar: {
width: 80,
@@ -2655,6 +2691,8 @@ const styles = (theme: ThemeColors) =>
paddingVertical: 12,
},
+ moneyRequestAmountContainer: {minHeight: variables.inputHeight + 2 * (variables.formErrorLineHeight + 8)},
+
requestPreviewBox: {
marginTop: 12,
maxWidth: variables.reportPreviewMaxWidth,
@@ -3028,6 +3066,31 @@ const styles = (theme: ThemeColors) =>
flex: 1,
},
+ searchPressable: {
+ height: 40,
+ },
+
+ searchContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingHorizontal: 24,
+ backgroundColor: theme.highlightBG,
+ borderRadius: variables.componentBorderRadiusRounded,
+ },
+
+ searchContainerHovered: {
+ backgroundColor: theme.border,
+ },
+
+ searchInputStyle: {
+ color: colors.productDark800,
+ fontSize: 13,
+ lineHeight: 16,
+ width: '100%',
+ },
+
threeDotsPopoverOffset: (windowWidth: number) =>
({
...getPopOverVerticalOffset(60),
@@ -3533,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: {
@@ -3706,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 714802095810..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,
@@ -65,7 +65,6 @@ const darkTheme = {
dropUIBG: 'rgba(6,27,9,0.92)',
receiptDropUIBG: 'rgba(3, 212, 124, 0.84)',
checkBox: colors.green400,
- pickerOptionsTextColor: colors.productDark900,
imageCropBackgroundColor: colors.productDark700,
fallbackIconColor: colors.green700,
reactionActiveBackground: colors.green600,
@@ -120,7 +119,7 @@ const darkTheme = {
statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
},
[SCREENS.SETTINGS.PROFILE.STATUS]: {
- backgroundColor: colors.darkAppBackground,
+ backgroundColor: colors.productDark100,
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
},
[SCREENS.SETTINGS.ROOT]: {
@@ -131,6 +130,10 @@ const darkTheme = {
backgroundColor: colors.pink800,
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
},
+ [SCREENS.RIGHT_MODAL.SIGN_IN]: {
+ backgroundColor: colors.productDark200,
+ statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
+ },
},
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index 8d401f7533d7..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,
@@ -65,7 +65,6 @@ const lightTheme = {
dropUIBG: 'rgba(252, 251, 249, 0.92)',
receiptDropUIBG: 'rgba(3, 212, 124, 0.84)',
checkBox: colors.green400,
- pickerOptionsTextColor: colors.productLight900,
imageCropBackgroundColor: colors.productLight700,
fallbackIconColor: colors.green700,
reactionActiveBackground: colors.green100,
@@ -112,16 +111,16 @@ const lightTheme = {
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
},
[SCREENS.SETTINGS.WALLET.ROOT]: {
- backgroundColor: colors.productDark100,
- statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
+ backgroundColor: colors.productLight100,
+ statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
},
[SCREENS.SETTINGS.SECURITY]: {
backgroundColor: colors.ice500,
statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
},
[SCREENS.SETTINGS.PROFILE.STATUS]: {
- backgroundColor: colors.green700,
- statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
+ backgroundColor: colors.productLight100,
+ statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
},
[SCREENS.SETTINGS.ROOT]: {
backgroundColor: colors.productLight200,
@@ -131,6 +130,10 @@ const lightTheme = {
backgroundColor: colors.pink800,
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
},
+ [SCREENS.RIGHT_MODAL.SIGN_IN]: {
+ backgroundColor: colors.productDark200,
+ statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
+ },
},
statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts
index 56da65ddd17d..b443295b8167 100644
--- a/src/styles/theme/types.ts
+++ b/src/styles/theme/types.ts
@@ -68,7 +68,6 @@ type ThemeColors = {
dropUIBG: Color;
receiptDropUIBG: Color;
checkBox: Color;
- pickerOptionsTextColor: Color;
imageCropBackgroundColor: Color;
fallbackIconColor: Color;
reactionActiveBackground: Color;
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index c392e61f0814..de87d2b5dd59 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1,5 +1,4 @@
-import {CSSProperties} from 'react';
-import {Animated, DimensionValue, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native';
+import {Animated, DimensionValue, PressableStateCallbackType, StyleProp, StyleSheet, TextStyle, ViewStyle} from 'react-native';
import {EdgeInsets} from 'react-native-safe-area-context';
import {ValueOf} from 'type-fest';
import * as Browser from '@libs/Browser';
@@ -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};
}
@@ -1430,6 +1430,8 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
return containerStyles;
},
+
+ getFullscreenCenteredContentStyles: () => [StyleSheet.absoluteFill, styles.justifyContentCenter, styles.alignItemsCenter],
});
type StyleUtilsType = ReturnType;
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 aec355bf81b6..767f724dd571 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -195,7 +195,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/Session.ts b/src/types/onyx/Session.ts
index faaa493b1286..aa4d52075d34 100644
--- a/src/types/onyx/Session.ts
+++ b/src/types/onyx/Session.ts
@@ -27,6 +27,9 @@ type Session = {
/** Server side errors keyed by microtime */
errors?: OnyxCommon.Errors;
+
+ /** User signed in with short lived token */
+ signedInWithShortLivedAuthToken?: boolean;
};
export default Session;
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/e2e/config.js b/tests/e2e/config.js
index e6d6dafb1b27..41c1668fb6ba 100644
--- a/tests/e2e/config.js
+++ b/tests/e2e/config.js
@@ -10,6 +10,7 @@ const TEST_NAMES = {
AppStartTime: 'App start time',
OpenSearchPage: 'Open search page TTI',
ReportTyping: 'Report typing',
+ ChatOpening: 'Chat opening',
};
/**
@@ -78,5 +79,8 @@ module.exports = {
autoFocus: true,
},
},
+ [TEST_NAMES.ChatOpening]: {
+ name: TEST_NAMES.ChatOpening,
+ },
},
};
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/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 45ddf44b244a..999107f0b3ae 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -1291,65 +1291,6 @@ describe('OptionsListUtils', () => {
},
];
- const smallTagsListWithParentChild = {
- Movies: {
- enabled: true,
- name: 'Movies',
- },
- 'Movies: Avengers: Endgame': {
- enabled: true,
- name: 'Movies: Avengers: Endgame',
- unencodedName: 'Movies: Avengers: Endgame',
- },
- Places: {
- enabled: false,
- name: 'Places',
- },
- Task: {
- enabled: true,
- name: 'Task',
- },
- };
-
- const smallResultListWithParentChild = [
- {
- title: '',
- shouldShow: false,
- indexOffset: 0,
- // data sorted alphabetically by name
- data: [
- {
- text: 'Movies',
- keyForList: 'Movies',
- searchText: 'Movies',
- tooltipText: 'Movies',
- isDisabled: false,
- },
- {
- text: ' Avengers',
- keyForList: 'Movies: Avengers',
- searchText: 'Movies: Avengers',
- tooltipText: 'Avengers',
- isDisabled: true,
- },
- {
- text: ' Endgame',
- keyForList: 'Movies: Avengers: Endgame',
- searchText: 'Movies: Avengers: Endgame',
- tooltipText: 'Endgame',
- isDisabled: false,
- },
- {
- text: 'Task',
- keyForList: 'Task',
- searchText: 'Task',
- tooltipText: 'Task',
- isDisabled: false,
- },
- ],
- },
- ];
-
const smallResult = OptionsListUtils.getFilteredOptions(REPORTS, PERSONAL_DETAILS, [], emptySearch, [], [], false, false, false, {}, [], true, smallTagsList);
expect(smallResult.tagOptions).toStrictEqual(smallResultList);
@@ -1412,26 +1353,9 @@ describe('OptionsListUtils', () => {
recentlyUsedTags,
);
expect(largeWrongSearchResult.tagOptions).toStrictEqual(largeWrongSearchResultList);
-
- const smallResultWithParentChild = OptionsListUtils.getFilteredOptions(
- REPORTS,
- PERSONAL_DETAILS,
- [],
- emptySearch,
- [],
- [],
- false,
- false,
- false,
- {},
- [],
- true,
- smallTagsListWithParentChild,
- );
- expect(smallResultWithParentChild.tagOptions).toStrictEqual(smallResultListWithParentChild);
});
- it('getIndentedOptionTree()', () => {
+ it('getCategoryOptionTree()', () => {
const categories = {
Meals: {
enabled: true,
@@ -1744,8 +1668,8 @@ describe('OptionsListUtils', () => {
},
];
- expect(OptionsListUtils.getIndentedOptionTree(categories)).toStrictEqual(result);
- expect(OptionsListUtils.getIndentedOptionTree(categories, true)).toStrictEqual(resultOneLine);
+ expect(OptionsListUtils.getCategoryOptionTree(categories)).toStrictEqual(result);
+ expect(OptionsListUtils.getCategoryOptionTree(categories, true)).toStrictEqual(resultOneLine);
});
it('sortCategories', () => {
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);
+ });
+ });
+ });
+});