diff --git a/__mocks__/@react-native-clipboard/clipboard.js b/__mocks__/@react-native-clipboard/clipboard.js
deleted file mode 100644
index e56e290c3cc9..000000000000
--- a/__mocks__/@react-native-clipboard/clipboard.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import MockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock';
-
-export default MockClipboard;
diff --git a/__mocks__/@react-native-clipboard/clipboard.ts b/__mocks__/@react-native-clipboard/clipboard.ts
new file mode 100644
index 000000000000..75b6f09f5345
--- /dev/null
+++ b/__mocks__/@react-native-clipboard/clipboard.ts
@@ -0,0 +1,3 @@
+import clipboardMock from '@react-native-clipboard/clipboard/jest/clipboard-mock';
+
+export default clipboardMock;
diff --git a/__mocks__/react-native-document-picker.js b/__mocks__/react-native-document-picker.ts
similarity index 64%
rename from __mocks__/react-native-document-picker.js
rename to __mocks__/react-native-document-picker.ts
index 8cba2bc1eba4..6d26a0227fc3 100644
--- a/__mocks__/react-native-document-picker.js
+++ b/__mocks__/react-native-document-picker.ts
@@ -1,9 +1,16 @@
-export default {
- getConstants: jest.fn(),
+import type {pick, pickDirectory, releaseSecureAccess, types} from 'react-native-document-picker';
+
+type ReactNativeDocumentPickerMock = {
+ pick: typeof pick;
+ releaseSecureAccess: typeof releaseSecureAccess;
+ pickDirectory: typeof pickDirectory;
+ types: typeof types;
+};
+
+const reactNativeDocumentPickerMock: ReactNativeDocumentPickerMock = {
pick: jest.fn(),
releaseSecureAccess: jest.fn(),
pickDirectory: jest.fn(),
-
types: Object.freeze({
allFiles: 'public.item',
audio: 'public.audio',
@@ -21,3 +28,5 @@ export default {
zip: 'public.zip-archive',
}),
};
+
+export default reactNativeDocumentPickerMock;
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 51f7ffc466b6..0b2271d16716 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001045601
- versionName "1.4.56-1"
+ versionCode 1001045605
+ versionName "1.4.56-5"
}
flavorDimensions "default"
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index b8ae9d0a2be5..efa8a25a0614 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -10,7 +10,7 @@
"electron-context-menu": "^2.3.0",
"electron-log": "^4.4.8",
"electron-serve": "^1.3.0",
- "electron-updater": "^6.1.9",
+ "electron-updater": "^6.2.1",
"node-machine-id": "^1.1.12"
}
},
@@ -156,9 +156,9 @@
}
},
"node_modules/electron-updater": {
- "version": "6.1.9",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.9.tgz",
- "integrity": "sha512-omoTwGSG+/H8G62cEZS/dc5Lmc4HohAd4198AP+JNv8H7bfxXUCKekaR6WpsN1n2DiWzvcqOusfGSogZv/uj9w==",
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.2.1.tgz",
+ "integrity": "sha512-83eKIPW14qwZqUUM6wdsIRwVKZyjmHxQ4/8G+1C6iS5PdDt7b1umYQyj1/qPpH510GmHEQe4q0kCPe3qmb3a0Q==",
"dependencies": {
"builder-util-runtime": "9.2.4",
"fs-extra": "^10.1.0",
@@ -541,9 +541,9 @@
"integrity": "sha512-OEC/48ZBJxR6XNSZtCl4cKPyQ1lvsu8yp8GdCplMWwGS1eEyMcEmzML5BRs/io/RLDnpgyf+7rSL+X6ICifRIg=="
},
"electron-updater": {
- "version": "6.1.9",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.9.tgz",
- "integrity": "sha512-omoTwGSG+/H8G62cEZS/dc5Lmc4HohAd4198AP+JNv8H7bfxXUCKekaR6WpsN1n2DiWzvcqOusfGSogZv/uj9w==",
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.2.1.tgz",
+ "integrity": "sha512-83eKIPW14qwZqUUM6wdsIRwVKZyjmHxQ4/8G+1C6iS5PdDt7b1umYQyj1/qPpH510GmHEQe4q0kCPe3qmb3a0Q==",
"requires": {
"builder-util-runtime": "9.2.4",
"fs-extra": "^10.1.0",
diff --git a/desktop/package.json b/desktop/package.json
index 606fcac92500..4249f3fcfba9 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -7,7 +7,7 @@
"electron-context-menu": "^2.3.0",
"electron-log": "^4.4.8",
"electron-serve": "^1.3.0",
- "electron-updater": "^6.1.9",
+ "electron-updater": "^6.2.1",
"node-machine-id": "^1.1.12"
},
"author": "Expensify, Inc.",
diff --git a/docs/articles/expensify-classic/expenses/Distance-Tracking.md b/docs/articles/expensify-classic/expenses/Distance-Tracking.md
deleted file mode 100644
index c0d8956f71ac..000000000000
--- a/docs/articles/expensify-classic/expenses/Distance-Tracking.md
+++ /dev/null
@@ -1,81 +0,0 @@
----
-title: Distance Tracking in Expensify
-description: Learn how distance tracking works in Expensify!
----
-
-# Overview
-
-Expensify provides a convenient feature for tracking your mileage-related expenses. You'll find all the essential information to begin logging your trips below.
-
-# How to Use Distance Tracking
-## Mobile App
-
-First, you’ll want to click the **+** in the top right corner.
-
-If you select **Manually Create**, you’ll be prompted to enter your mileage, select a rate, and code the expense before clicking **Save**.
-
- ![Click manually create or odometer to create a distance request.](https://help.expensify.com/assets/images/ExpensifyHelp_CreateExpense_Mobile.png){:width="100%"}
-
-If you select **Manually Create**:
- - Enter your mileage.
- - Select a rate.
- - Code the expense.
- - Click **Save**.
-
-![Enter your mileage, rate, code the expense, and click save.](https://help.expensify.com/assets/images/ExpensifyHelp_ManualDistance_Mobile.png){:width="100%"}
-
-If you select **Odometer**:
- - Enter your vehicle’s mileage reading before and after your trip.
- - Select your rate.
- - Code the expense.
- - Click **Save**.
-
-![Etner your mileage readings, your rate, code the expense, and click save.](https://help.expensify.com/assets/images/ExpensifyHelp_Odometer_Mobile.png){:width="100%"}
-
-The **Start GPS** option also exists on the mobile app. However, we’ve learned that most customers prefer to track their mileage after their trips (thus not needing to hit that start button!)
-
-We’ve temporarily paused the development of GPS mileage tracking in the mobile app, and we recommend you use one of the above options instead!
-
-
-## Web
-
-Navigate to the **Expenses** page, click **New Expense**, and review the two **Distance** options.
-
-![Select manually create or create from map to create a new distance request.](https://help.expensify.com/assets/images/ExpensifyHelp_CreateExpense.png){:width="100%"}
-
-If you select **Manually Create**:
- - Enter the number of miles for your trip.
- - Mileage rate is automatically selected based on your history, or manually select it if it's your first time.
- - Complete any other applicable coding fields.
- - Click **Save**.
-
-![Enter the number of miles, select your rate, code the expense, and click save.](https://help.expensify.com/assets/images/ExpensifyHelp_ManualDistance.png){:width="100%"}
-
-For **Create from Map** expenses:
- - Add your start and end location, and the distance will be calculated.
- - You can also click **Add Destination** for multiple stops.
- - Leave **Create Receipt** selected if you want a map receipt generated.
- - Click **Save**.
-
-![Enter your start and end locations, and click save.](https://help.expensify.com/assets/images/ExpensifyHelp_ManualDistanceMap.png){:width="100%"}
-
-Once you click **Save**, review the details from your map selection.
- - Select your rate.
- - Enter any other applicable coding.
- - Click **Save**.
-
-![Select your rate, code the expense, and click save.](https://help.expensify.com/assets/images/ExpensifyHelp_ManualDistanceConfirm.png){:width="100%"}
-
-# Mileage Tracking FAQs
-## **How can I change the rate of my mileage expenses?**
-You can change the rate by going to Settings > Workspaces > [Your Workspace] > Expenses > Distance > Add a Mileage Rate.
-If you submit mileage expenses on a group workspace, only workspace admins can do this.
-
-## **Do you plan to add the "Create from Map" option to the mobile app or "Odometer" option to web?**
-Not now, but if that changes, you'll be the first to know!
-
-## **Will you restart maintenance on the mobile app's GPS option anytime soon?**
-Not now, but if that changes, you'll be the first to know!
-
-## **Does Expensify automatically update IRS Mileage rates?**
- We never automatically update mileage rates in Expensify because different companies want the new rates to become effective on different dates.
diff --git a/docs/articles/expensify-classic/expenses/Track-mileage-expenses.md b/docs/articles/expensify-classic/expenses/Track-mileage-expenses.md
new file mode 100644
index 000000000000..e8b9ab0eac75
--- /dev/null
+++ b/docs/articles/expensify-classic/expenses/Track-mileage-expenses.md
@@ -0,0 +1,66 @@
+---
+title: Track mileage expenses
+description: Add mileage-related expenses
+---
+
+
+
+You can track your mileage-related expenses by logging your trips in Expensify. You have a couple of different options for logging distance:
+
+- Web app:
+ - **Manually create**: Manually enter the number of miles for the trip
+ - **Create from map**: Automatically determine the trip distance based on the start and end location.
+- Mobile app:
+ - **Manually create**: Manually enter the miles for the trip and your mileage rate
+ - **Odometer**: Enter your odometer reading before and after the trip
+ - **Start GPS**: Currently under development and unavailable for use.
+
+{% include info.html %}
+When adding a distance expense, the rates available are determined by the rates set in your workspace rate settings. To update these rates or add a new rate, you must be a Workspace Admin.
+{% include end-info.html %}
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+
+1. Click the **Expenses** tab.
+2. Click **New Expense**.
+3. Select the expense type.
+ - **Manually create**:
+ - Enter the number of miles for the trip.
+ - Select your rate.
+ - If desired, select the category, add a description, or select a report to add the expense to.
+ - Click **Save**.
+ - **Create from map**:
+ - Add your start location as point A.
+ - Add your end location as point B.
+ - If applicable, click **Add Destination** to add additional stops.
+ - To generate a map receipt, leave the Create Receipt checkbox selected.
+ - Click **Save**.
+ - Select your rate.
+ - If desired, select the category, add a description, or select a report to add the expense to.
+ - Click **Save**.
+
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+
+1. Click the + icon in the top right corner.
+2. Under the Distance section, select the expense type.
+ - **Manually create**:
+ - Enter your mileage.
+ - Select your rate.
+ - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to.
+ - Click **Save**.
+ - **Odometer**:
+ - Enter your vehicle’s odometer reading before the trip.
+ - Enter your vehicle’s odometer reading after the trip.
+ - Select your rate.
+ - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to.
+ - Click **Save**.
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+
+
diff --git a/docs/articles/expensify-classic/expenses/reports/Create-A-Report.md b/docs/articles/expensify-classic/expenses/reports/Create-A-Report.md
index 88ec2b730d1e..e8dfdbf44bcb 100644
--- a/docs/articles/expensify-classic/expenses/reports/Create-A-Report.md
+++ b/docs/articles/expensify-classic/expenses/reports/Create-A-Report.md
@@ -91,7 +91,7 @@ There are three ways you can change the report layout under the Details section
# How to retract your report (Undo Submit)
-As long as the report is still in a Processing state, you can retract this submission to put the report back to Draft status to make corrections and re-submit.
+You can edit expenses on a report in a **Processing** state so long as it hasn't been approved yet. If a report has been through a level of approval and is still in the **Processing** state, you can retract this submission to put the report back to Draft status to make corrections and re-submit.
To retract a **Processing** report on the web app, click the Undo Submit button at the upper left-hand corner of the report.
diff --git a/docs/articles/expensify-classic/settings/Copilot.md b/docs/articles/expensify-classic/settings/Copilot.md
deleted file mode 100644
index 31bc0eff60e6..000000000000
--- a/docs/articles/expensify-classic/settings/Copilot.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-title: Copilot
-description: Safely delegate tasks without sharing login information.
----
-
-# About
-The Copilot feature allows you to safely delegate tasks without sharing login information. Your chosen user can access your account through their own Expensify account, with customizable permissions to manage expenses, create reports, and more. This can even be extended to users outside your policy or domain.
-
-# How-to
-# How to add a Copilot
-1. Log into the Expensify desktop website.
-2. Navigate to *Settings > Account > Account Details > _Copilot: Delegated Access_*.
-3. Enter the email address or phone number of your Copilot and select whether you want to give them Full Access or the ability to Submit Only.
- - *Full Access Copilot*: Your Copilot will have full access to your account. Nearly every action you can do and everything you can see in your account will also be available to your Copilot. They *will not* have the ability to add or remove other Copilots from your account.
- - *Submit Only Copilot*: Your Copilot will have the same limitations as a Full Access Copilot, with the added restriction of not being able to approve reports on your behalf.
-4. Click Invite Copilot.
-
-If your Copilot already has an Expensify account, they will get an email notifying them that they can now access your account from within their account as well.
-If they do not already have an Expensify account, they will be provided with a link to create one. Once they have created their Expensify account, they will be able to access your account from within their own account.
-
-# How to use Copilot
-A designated copilot can access another account via the Expensify website or the mobile app.
-
-## How to switch to Copilot mode (on the Expensify website):
-1. Click your profile icon in the upper left side of the page.
-2. In the “Copilot Access” section of the dropdown, choose the account you wish to access.
-3. When you Copilot into someone else’s account, the Expensify header will change color and an airplane icon will appear.
-4. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”.
-
-## How to switch to Copilot Mode (on the mobile app):
-1. Tap on the menu icon on the top left-hand side of the screen, then tap your profile icon.
-2. Tap “Switch to Copilot Mode”, then choose the account you wish to access.
-3. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”.
-
-# How to remove a Copilot
-If you ever need to remove a Copilot, you can do so by following the below steps:
-1. Log into the Expensify desktop website
-2. Navigate to *Settings > Your Account > Account Details > _Copilot: Delegated Access_*
-3. Click the red X next to the Copilot you'd like to remove
-
-
-# Deep Dive
-## Copilot Permissions
-A Copilot can do the following actions in your account:
-- Prepare expenses on your behalf
-- Approve and reimburse others' expenses on your behalf (Note: this applies only to **Full Access** Copilots)
-- View and make changes to your account/domain/policy settings
-- View all expenses you can see within your own account
-
-## Copilot restrictions
-A Copilot cannot do the following actions in your account:
-- Change or reset your password
-- Add/remove other Copilots
-
-## Forwarding receipts to receipts@expensify.com as a Copilot
-To ensure a receipt is routed to the Expensify account in which you are a copilot rather than your own you’ll need to do the following:
-1. Forward the email to receipts@expensify.com
-2. Put the email of the account in which you are a copilot in the subject line
-3. Send
-
-
-{% include faq-begin.md %}
-## Can a Copilot's Secondary Login be used to forward receipts?
-Yes! A Copilot can use any of the email addresses tied to their account to forward receipts into the account of the person they're assisting.
-
-## I'm in Copilot mode for an account; Can I add another Copilot to that account on their behalf?
-No, only the original account holder can add another Copilot to the account.
-## Is there a restriction on the number of Copilots I can have or the number of users for whom I can act as a Copilot?
-There is no limit! You can have as many Copilots as you like, and you can be a Copilot for as many users as you need.
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/workspaces/Change-member-workspace-roles.md b/docs/articles/expensify-classic/workspaces/Change-member-workspace-roles.md
new file mode 100644
index 000000000000..29fbc8b46323
--- /dev/null
+++ b/docs/articles/expensify-classic/workspaces/Change-member-workspace-roles.md
@@ -0,0 +1,26 @@
+---
+title: Change member workspace roles
+description: Update a member's role for a workspace
+---
+
+
+To change the roles and permissions for members of your workspace,
+
+1. Hover over Settings, then click **Workspaces**.
+2. Click the **Group** tab on the left.
+3. Click the desired workspace name.
+4. Click the **Members** tab on the left.
+5. Click the Settings icon next to the desired member.
+6. Select a new role for the member.
+
+| | Employee | Auditor | Workspace Admin |
+|---------------------------|----------------------------------|---------|-----------------|
+| Submit reports | Yes | Yes | Yes |
+| Comment on reports | Yes | Yes | Yes |
+| Approve workspace reports | Only reports submitted to them | Yes | Yes |
+| Edit workspace settings | No | No | Yes |
+
+7. If your workspace uses Advanced Approvals, select an “Approves to.” This determines who the member’s reports must be approved by, if applicable. If “no one” is selected, then any one with the Auditor or Workspace Admin role can approve the member’s reports.
+8. Click **Save**.
+
+
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index e8f3bc7c38e2..37741d484d63 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.56.1
+ 1.4.56.5ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index fec078bdb26c..e228808076d0 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature????CFBundleVersion
- 1.4.56.1
+ 1.4.56.5
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 3fce69f13cdc..11f83011d47a 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString1.4.56CFBundleVersion
- 1.4.56.1
+ 1.4.56.5NSExtensionNSExtensionPointIdentifier
diff --git a/metro.config.js b/metro.config.js
index 2422d29aaacf..68ed72d52ba0 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -7,7 +7,7 @@ require('dotenv').config();
const defaultConfig = getDefaultConfig(__dirname);
const isE2ETesting = process.env.E2E_TESTING === 'true';
-const e2eSourceExts = ['e2e.js', 'e2e.ts'];
+const e2eSourceExts = ['e2e.js', 'e2e.ts', 'e2e.tsx'];
/**
* Metro configuration
diff --git a/package-lock.json b/package-lock.json
index 9f0916546172..6e7f23674852 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.56-1",
+ "version": "1.4.56-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.56-1",
+ "version": "1.4.56-5",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -14,7 +14,6 @@
"@expensify/react-native-live-markdown": "0.1.5",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
- "@formatjs/intl-getcanonicallocales": "^2.2.0",
"@formatjs/intl-listformat": "^7.2.2",
"@formatjs/intl-locale": "^3.3.0",
"@formatjs/intl-numberformat": "^8.5.0",
@@ -73,6 +72,7 @@
"react-content-loader": "^7.0.0",
"react-dom": "18.1.0",
"react-error-boundary": "^4.0.11",
+ "react-fast-pdf": "^1.0.6",
"react-map-gl": "^7.1.3",
"react-native": "0.73.2",
"react-native-android-location-enabler": "^2.0.1",
@@ -25183,7 +25183,6 @@
},
"node_modules/dequal": {
"version": "2.0.3",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -38922,6 +38921,74 @@
"react": ">=16.13.1"
}
},
+ "node_modules/react-fast-pdf": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.6.tgz",
+ "integrity": "sha512-CdAnBSZaLCGLSEuiqWLzzXhV9Wvdf1VRixaXCrb3NFrXyeltahF7PY+u7eU6ynrWZGmNI6g0cMLPv0DQhJEeew==",
+ "dependencies": {
+ "react-pdf": "^7.7.0",
+ "react-window": "^1.8.10"
+ },
+ "engines": {
+ "node": "20.10.0",
+ "npm": "10.2.3"
+ },
+ "peerDependencies": {
+ "lodash": "4.x",
+ "prop-types": "15.x",
+ "react": "18.x",
+ "react-dom": "18.x"
+ }
+ },
+ "node_modules/react-fast-pdf/node_modules/pdfjs-dist": {
+ "version": "3.11.174",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
+ "integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "canvas": "^2.11.2",
+ "path2d-polyfill": "^2.0.1"
+ }
+ },
+ "node_modules/react-fast-pdf/node_modules/react-pdf": {
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.7.1.tgz",
+ "integrity": "sha512-cbbf/PuRtGcPPw+HLhMI1f6NSka8OJgg+j/yPWTe95Owf0fK6gmVY7OXpTxMeh92O3T3K3EzfE0ML0eXPGwR5g==",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "dequal": "^2.0.3",
+ "make-cancellable-promise": "^1.3.1",
+ "make-event-props": "^1.6.0",
+ "merge-refs": "^1.2.1",
+ "pdfjs-dist": "3.11.174",
+ "prop-types": "^15.6.2",
+ "tiny-invariant": "^1.0.0",
+ "warning": "^4.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-fast-pdf/node_modules/warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"node_modules/react-freeze": {
"version": "1.0.3",
"license": "MIT",
@@ -40398,8 +40465,9 @@
}
},
"node_modules/react-window": {
- "version": "1.8.9",
- "license": "MIT",
+ "version": "1.8.10",
+ "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.10.tgz",
+ "integrity": "sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
diff --git a/package.json b/package.json
index e6531ae5e7e7..83c3095585be 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.56-1",
+ "version": "1.4.56-5",
"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.",
@@ -50,8 +50,8 @@
"analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production",
"symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map",
"symbolicate:ios": "npx metro-symbolicate main.jsbundle.map",
- "symbolicate-release:ios": "scripts/release-profile.js --platform=ios",
- "symbolicate-release:android": "scripts/release-profile.js --platform=android",
+ "symbolicate-release:ios": "scripts/release-profile.ts --platform=ios",
+ "symbolicate-release:android": "scripts/release-profile.ts --platform=android",
"test:e2e": "ts-node tests/e2e/testRunner.ts --config ./config.local.ts",
"test:e2e:dev": "ts-node tests/e2e/testRunner.ts --config ./config.dev.ts",
"gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh",
@@ -65,7 +65,6 @@
"@expensify/react-native-live-markdown": "0.1.5",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
- "@formatjs/intl-getcanonicallocales": "^2.2.0",
"@formatjs/intl-listformat": "^7.2.2",
"@formatjs/intl-locale": "^3.3.0",
"@formatjs/intl-numberformat": "^8.5.0",
@@ -124,6 +123,7 @@
"react-content-loader": "^7.0.0",
"react-dom": "18.1.0",
"react-error-boundary": "^4.0.11",
+ "react-fast-pdf": "^1.0.6",
"react-map-gl": "^7.1.3",
"react-native": "0.73.2",
"react-native-android-location-enabler": "^2.0.1",
@@ -154,8 +154,8 @@
"react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
- "react-native-release-profiler": "^0.1.6",
"react-native-reanimated": "^3.7.2",
+ "react-native-release-profiler": "^0.1.6",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
diff --git a/scripts/build-desktop.sh b/scripts/build-desktop.sh
index 025559dc4671..efbca35a498c 100755
--- a/scripts/build-desktop.sh
+++ b/scripts/build-desktop.sh
@@ -13,8 +13,18 @@ else
ENV_FILE=".env"
fi
+if [[ -n "$GCP_GEOLOCATION_API_KEY" ]]; then
+ if grep -qE "^GCP_GEOLOCATION_API_KEY=" "$ENV_FILE"; then
+ # Replace the value for the existing key
+ sed -i "s|^GCP_GEOLOCATION_API_KEY=.*$|GCP_GEOLOCATION_API_KEY=$GCP_GEOLOCATION_API_KEY|g" "$ENV_FILE"
+ else
+ # Add the key-value pair to the config file
+ echo "GCP_GEOLOCATION_API_KEY=$GCP_GEOLOCATION_API_KEY" >> "$ENV_FILE"
+ fi
+fi
+
SCRIPTS_DIR=$(dirname "${BASH_SOURCE[0]}")
-source "$SCRIPTS_DIR/shellUtils.sh";
+source "$SCRIPTS_DIR/shellUtils.sh"
title "Bundling Desktop js Bundle Using Webpack"
info " • ELECTRON_ENV: $ELECTRON_ENV"
diff --git a/scripts/release-profile.js b/scripts/release-profile.ts
similarity index 83%
rename from scripts/release-profile.js
rename to scripts/release-profile.ts
index 0f96232bcdca..8ec0979f9f9e 100755
--- a/scripts/release-profile.js
+++ b/scripts/release-profile.ts
@@ -1,13 +1,15 @@
-#!/usr/bin/env node
+#!/usr/bin/env ts-node
+
/* eslint-disable no-console */
+import {execSync} from 'child_process';
+import fs from 'fs';
-const fs = require('fs');
-const {execSync} = require('child_process');
+type ArgsMap = Record;
// Function to parse command-line arguments into a key-value object
-function parseCommandLineArguments() {
+function parseCommandLineArguments(): ArgsMap {
const args = process.argv.slice(2); // Skip node and script paths
- const argsMap = {};
+ const argsMap: ArgsMap = {};
args.forEach((arg) => {
const [key, value] = arg.split('=');
if (key.startsWith('--')) {
@@ -20,14 +22,13 @@ function parseCommandLineArguments() {
// Function to find .cpuprofile files in the current directory
function findCpuProfileFiles() {
const files = fs.readdirSync(process.cwd());
- // eslint-disable-next-line rulesdir/prefer-underscore-method
return files.filter((file) => file.endsWith('.cpuprofile'));
}
const argsMap = parseCommandLineArguments();
// Determine sourcemapPath based on the platform flag passed
-let sourcemapPath;
+let sourcemapPath: string | undefined;
if (argsMap.platform === 'ios') {
sourcemapPath = 'main.jsbundle.map';
} else if (argsMap.platform === 'android') {
@@ -57,7 +58,10 @@ if (cpuProfiles.length === 0) {
const output = execSync(command, {stdio: 'inherit'});
console.log(output.toString());
} catch (error) {
- console.error(`Error executing command: ${error}`);
+ if (error instanceof Error) {
+ console.error(`Error executing command: ${error.toString()}`);
+ }
+
process.exit(1);
}
}
diff --git a/src/CONST.ts b/src/CONST.ts
index 532939c10f1a..955ddda76741 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -335,6 +335,7 @@ const CONST = {
TRACK_EXPENSE: 'trackExpense',
P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests',
WORKFLOWS_DELAYED_SUBMISSION: 'workflowsDelayedSubmission',
+ ACCOUNTING: 'accounting',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -1679,7 +1680,7 @@ const CONST = {
POLICY_ID_FROM_PATH: /\/w\/([a-zA-Z0-9]+)(\/|$)/,
- SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#]+(?:\\.[\\w\\-\\'\\+]+)*`, 'gim'),
+ SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#@]+(?:\\.[\\w\\-\\'\\+]+)*`, 'gim'),
},
PRONOUNS: {
@@ -1850,13 +1851,6 @@ const CONST = {
MAX_INT_FOR_RANDOM_7_DIGIT_VALUE: 10000000,
IOS_KEYBOARD_SPACE_OFFSET: -30,
- PDF_PASSWORD_FORM: {
- // Constants for password-related error responses received from react-pdf.
- REACT_PDF_PASSWORD_RESPONSES: {
- NEED_PASSWORD: 1,
- INCORRECT_PASSWORD: 2,
- },
- },
API_REQUEST_TYPE: {
READ: 'read',
WRITE: 'write',
@@ -2980,7 +2974,7 @@ const CONST = {
CURRENCY: 'XAF',
FORMAT: 'symbol',
SAMPLE_INPUT: '123456.789',
- EXPECTED_OUTPUT: 'FCFA 123,457',
+ EXPECTED_OUTPUT: 'FCFA 123,457',
},
PATHS_TO_TREAT_AS_EXTERNAL: ['NewExpensify.dmg', 'docs/index.html'],
@@ -4149,6 +4143,8 @@ const CONST = {
},
},
},
+
+ MAX_TAX_RATE_DECIMAL_PLACES: 4,
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index c216d5ac288c..6302e0ee4683 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -179,7 +179,7 @@ const ROUTES = {
REPORT: 'r',
REPORT_WITH_ID: {
route: 'r/:reportID?/:reportActionID?',
- getRoute: (reportID: string) => `r/${reportID}` as const,
+ getRoute: (reportID: string, reportActionID?: string) => (reportActionID ? (`r/${reportID}/${reportActionID}` as const) : (`r/${reportID}` as const)),
},
REPORT_AVATAR: {
route: 'r/:reportID/avatar',
diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.tsx
similarity index 63%
rename from src/components/AddPaymentMethodMenu.js
rename to src/components/AddPaymentMethodMenu.tsx
index 803b7f2cdabe..ac9657694500 100644
--- a/src/components/AddPaymentMethodMenu.js
+++ b/src/components/AddPaymentMethodMenu.tsx
@@ -1,78 +1,75 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import type {RefObject} from 'react';
import React from 'react';
+import type {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import useLocalize from '@hooks/useLocalize';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
-import iouReportPropTypes from '@pages/iouReportPropTypes';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {AnchorPosition} from '@src/styles';
+import type {Report, Session} from '@src/types/onyx';
+import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
+import type {EmptyObject} from '@src/types/utils/EmptyObject';
import * as Expensicons from './Icon/Expensicons';
+import type {PaymentMethod} from './KYCWall/types';
import PopoverMenu from './PopoverMenu';
-import refPropTypes from './refPropTypes';
-const propTypes = {
+type AddPaymentMethodMenuOnyxProps = {
+ /** Session info for the currently logged-in user. */
+ session: OnyxEntry;
+};
+
+type AddPaymentMethodMenuProps = AddPaymentMethodMenuOnyxProps & {
/** Should the component be visible? */
- isVisible: PropTypes.bool.isRequired,
+ isVisible: boolean;
/** Callback to execute when the component closes. */
- onClose: PropTypes.func.isRequired,
+ onClose: () => void;
/** Callback to execute when the payment method is selected. */
- onItemSelected: PropTypes.func.isRequired,
+ onItemSelected: (paymentMethod: PaymentMethod) => void;
/** The IOU/Expense report we are paying */
- iouReport: iouReportPropTypes,
+ iouReport?: OnyxEntry | EmptyObject;
/** Anchor position for the AddPaymentMenu. */
- anchorPosition: PropTypes.shape({
- horizontal: PropTypes.number,
- vertical: PropTypes.number,
- }),
+ anchorPosition: AnchorPosition;
/** Where the popover should be positioned relative to the anchor points. */
- anchorAlignment: PropTypes.shape({
- horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
- vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
- }),
+ anchorAlignment?: AnchorAlignment;
/** Popover anchor ref */
- anchorRef: refPropTypes,
-
- /** Session info for the currently logged in user. */
- session: PropTypes.shape({
- /** Currently logged in user accountID */
- accountID: PropTypes.number,
- }),
+ anchorRef: RefObject;
/** Whether the personal bank account option should be shown */
- shouldShowPersonalBankAccountOption: PropTypes.bool,
+ shouldShowPersonalBankAccountOption?: boolean;
};
-const defaultProps = {
- iouReport: {},
- anchorPosition: {},
- anchorAlignment: {
+function AddPaymentMethodMenu({
+ isVisible,
+ onClose,
+ anchorPosition,
+ anchorAlignment = {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
},
- anchorRef: () => {},
- session: {},
- shouldShowPersonalBankAccountOption: false,
-};
-
-function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignment, anchorRef, iouReport, onItemSelected, session, shouldShowPersonalBankAccountOption}) {
+ anchorRef,
+ iouReport,
+ onItemSelected,
+ session,
+ shouldShowPersonalBankAccountOption = false,
+}: AddPaymentMethodMenuProps) {
const {translate} = useLocalize();
// Users can choose to pay with business bank account in case of Expense reports or in case of P2P IOU report
// which then starts a bottom up flow and creates a Collect workspace where the payer is an admin and payee is an employee.
+ const isIOUReport = ReportUtils.isIOUReport(iouReport ?? {});
const canUseBusinessBankAccount =
- ReportUtils.isExpenseReport(iouReport) ||
- (ReportUtils.isIOUReport(iouReport) && !ReportActionsUtils.hasRequestFromCurrentAccount(lodashGet(iouReport, 'reportID', 0), lodashGet(session, 'accountID', 0)));
+ ReportUtils.isExpenseReport(iouReport ?? {}) || (isIOUReport && !ReportActionsUtils.hasRequestFromCurrentAccount(iouReport?.reportID ?? '', session?.accountID ?? 0));
- const canUsePersonalBankAccount = shouldShowPersonalBankAccountOption || ReportUtils.isIOUReport(iouReport);
+ const canUsePersonalBankAccount = shouldShowPersonalBankAccountOption || isIOUReport;
return (
({
session: {
key: ONYXKEYS.SESSION,
},
diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx
index 48035dd884bd..e947c74f7c60 100644
--- a/src/components/AmountForm.tsx
+++ b/src/components/AmountForm.tsx
@@ -37,6 +37,9 @@ type AmountFormProps = {
/** Whether the currency symbol is pressable */
isCurrencyPressable?: boolean;
+
+ /** Custom max amount length. It defaults to CONST.IOU.AMOUNT_MAX_LENGTH */
+ amountMaxLength?: number;
} & Pick &
Pick;
@@ -53,7 +56,7 @@ const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView';
const NUM_PAD_VIEW_ID = 'numPadView';
function AmountForm(
- {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, errorText, onInputChange, onCurrencyButtonPress, isCurrencyPressable = true, ...rest}: AmountFormProps,
+ {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, amountMaxLength, errorText, onInputChange, onCurrencyButtonPress, isCurrencyPressable = true, ...rest}: AmountFormProps,
forwardedRef: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -101,7 +104,7 @@ function AmountForm(
const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount);
// Use a shallow copy of selection to trigger setSelection
// More info: https://github.com/Expensify/App/issues/16385
- if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals)) {
+ if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals, amountMaxLength)) {
setSelection((prevSelection) => ({...prevSelection}));
return;
}
@@ -111,13 +114,13 @@ function AmountForm(
setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length));
onInputChange?.(strippedAmount);
},
- [currentAmount, decimals, onInputChange],
+ [amountMaxLength, currentAmount, decimals, onInputChange],
);
// Modifies the amount to match the decimals for changed currency.
useEffect(() => {
// If the changed currency supports decimals, we can return
- if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) {
+ if (MoneyRequestUtils.validateAmount(currentAmount, decimals, amountMaxLength)) {
return;
}
diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx
index d389ac4b92f0..da8e3694a7d2 100644
--- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx
+++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx
@@ -59,7 +59,6 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow
role={CONST.ROLE.BUTTON}
>
;
};
-type Attachment = {
- source: AvatarSource;
- isAuthTokenRequired: boolean;
- file: FileObject;
- isReceipt: boolean;
- hasBeenFlagged?: boolean;
- reportActionID?: string;
-};
-
type ImagePickerResponse = {
height: number;
name: string;
@@ -79,7 +71,7 @@ type ImagePickerResponse = {
width: number;
};
-type FileObject = File | ImagePickerResponse;
+type FileObject = Partial;
type ChildrenProps = {
displayFileInModal: (data: FileObject) => void;
@@ -181,7 +173,7 @@ function AttachmentModal({
const [isAuthTokenRequiredState, setIsAuthTokenRequiredState] = useState(isAuthTokenRequired);
const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(null);
const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null);
- const [sourceState, setSourceState] = useState(() => source);
+ const [sourceState, setSourceState] = useState(() => source);
const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE);
const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false);
const [confirmButtonFadeAnimation] = useState(() => new Animated.Value(1));
@@ -190,7 +182,7 @@ function AttachmentModal({
const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && isAttachmentInvalid);
- const [file, setFile] = useState | undefined>(
+ const [file, setFile] = useState(
originalFileName
? {
name: originalFileName,
@@ -211,7 +203,7 @@ function AttachmentModal({
(attachment: Attachment) => {
setSourceState(attachment.source);
setFile(attachment.file);
- setIsAuthTokenRequiredState(attachment.isAuthTokenRequired);
+ setIsAuthTokenRequiredState(attachment.isAuthTokenRequired ?? false);
onCarouselAttachmentChange(attachment);
},
[onCarouselAttachmentChange],
@@ -222,7 +214,7 @@ function AttachmentModal({
*/
const getModalType = useCallback(
(sourceURL: string, fileObject: FileObject) =>
- sourceURL && (Str.isPDF(sourceURL) || (fileObject && Str.isPDF(fileObject.name || translate('attachmentView.unknownFilename'))))
+ sourceURL && (Str.isPDF(sourceURL) || (fileObject && Str.isPDF(fileObject.name ?? translate('attachmentView.unknownFilename'))))
? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE
: CONST.MODAL.MODAL_TYPE.CENTERED,
[translate],
@@ -292,14 +284,14 @@ function AttachmentModal({
}, [transaction, report]);
const isValidFile = useCallback((fileObject: FileObject) => {
- if (fileObject.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
+ if (fileObject.size !== undefined && fileObject.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
setIsAttachmentInvalid(true);
setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooLarge');
setAttachmentInvalidReason('attachmentPicker.sizeExceeded');
return false;
}
- if (fileObject.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
+ if (fileObject.size !== undefined && fileObject.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
setIsAttachmentInvalid(true);
setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooSmall');
setAttachmentInvalidReason('attachmentPicker.sizeNotMet');
@@ -352,7 +344,7 @@ function AttachmentModal({
setSourceState(inputSource);
setFile(updatedFile);
setModalType(inputModalType);
- } else {
+ } else if (fileObject.uri) {
const inputModalType = getModalType(fileObject.uri, fileObject);
setIsModalOpen(true);
setSourceState(fileObject.uri);
@@ -536,7 +528,6 @@ function AttachmentModal({
onNavigate={onNavigate}
onClose={closeModal}
source={source}
- onToggleKeyboard={updateConfirmButtonVisibility}
setDownloadButtonVisibility={setDownloadButtonVisibility}
/>
) : (
@@ -546,7 +537,6 @@ function AttachmentModal({
!shouldShowNotFoundPage && (
({
},
})(memo(AttachmentModal));
-export type {Attachment, FileObject};
+export type {FileObject};
diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.js b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.tsx
similarity index 71%
rename from src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.js
rename to src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.tsx
index f4cbffc0e1e4..839e05c419df 100644
--- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.js
+++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.tsx
@@ -1,19 +1,15 @@
-import PropTypes from 'prop-types';
import React from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
import {PixelRatio, View} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-const propTypes = {
+type AttachmentCarouselCellRendererProps = {
/** Cell Container styles */
- style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ style?: StyleProp;
};
-const defaultProps = {
- style: [],
-};
-
-function AttachmentCarouselCellRenderer(props) {
+function AttachmentCarouselCellRenderer(props: AttachmentCarouselCellRendererProps) {
const styles = useThemeStyles();
const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
const modalStyles = styles.centeredModalStyles(isSmallScreenWidth, true);
@@ -28,8 +24,6 @@ function AttachmentCarouselCellRenderer(props) {
);
}
-AttachmentCarouselCellRenderer.propTypes = propTypes;
-AttachmentCarouselCellRenderer.defaultProps = defaultProps;
AttachmentCarouselCellRenderer.displayName = 'AttachmentCarouselCellRenderer';
export default React.memo(AttachmentCarouselCellRenderer);
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselActions.js b/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx
similarity index 73%
rename from src/components/Attachments/AttachmentCarousel/CarouselActions.js
rename to src/components/Attachments/AttachmentCarousel/CarouselActions.tsx
index cf5309222c4e..6138f07809c5 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselActions.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx
@@ -1,25 +1,22 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import {useEffect} from 'react';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import CONST from '@src/CONST';
-const propTypes = {
+type CarouselActionsProps = {
/** Callback to cycle through attachments */
- onCycleThroughAttachments: PropTypes.func.isRequired,
+ onCycleThroughAttachments: (deltaSlide: number) => void;
};
-function CarouselActions({onCycleThroughAttachments}) {
+function CarouselActions({onCycleThroughAttachments}: CarouselActionsProps) {
useEffect(() => {
const shortcutLeftConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT;
const unsubscribeLeftKey = KeyboardShortcut.subscribe(
shortcutLeftConfig.shortcutKey,
- (e) => {
- if (lodashGet(e, 'target.blur')) {
+ (event) => {
+ if (event?.target instanceof HTMLElement) {
// prevents focus from highlighting around the modal
- e.target.blur();
+ event.target.blur();
}
-
onCycleThroughAttachments(-1);
},
shortcutLeftConfig.descriptionKey,
@@ -29,12 +26,11 @@ function CarouselActions({onCycleThroughAttachments}) {
const shortcutRightConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT;
const unsubscribeRightKey = KeyboardShortcut.subscribe(
shortcutRightConfig.shortcutKey,
- (e) => {
- if (lodashGet(e, 'target.blur')) {
+ (event) => {
+ if (event?.target instanceof HTMLElement) {
// prevents focus from highlighting around the modal
- e.target.blur();
+ event.target.blur();
}
-
onCycleThroughAttachments(1);
},
shortcutRightConfig.descriptionKey,
@@ -50,6 +46,4 @@ function CarouselActions({onCycleThroughAttachments}) {
return null;
}
-CarouselActions.propTypes = propTypes;
-
export default CarouselActions;
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js b/src/components/Attachments/AttachmentCarousel/CarouselButtons.tsx
similarity index 75%
rename from src/components/Attachments/AttachmentCarousel/CarouselButtons.js
rename to src/components/Attachments/AttachmentCarousel/CarouselButtons.tsx
index a2c5dadb101d..2037ebdab086 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselButtons.tsx
@@ -1,8 +1,6 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
-import _ from 'underscore';
-import * as AttachmentCarouselViewPropTypes from '@components/Attachments/propTypes';
+import type {Attachment} from '@components/Attachments/types';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import Tooltip from '@components/Tooltip';
@@ -11,36 +9,34 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-const propTypes = {
+type CarouselButtonsProps = {
/** Where the arrows should be visible */
- shouldShowArrows: PropTypes.bool.isRequired,
+ shouldShowArrows: boolean;
/** The current page index */
- page: PropTypes.number.isRequired,
+ page: number;
/** The attachments from the carousel */
- attachments: AttachmentCarouselViewPropTypes.attachmentsPropType.isRequired,
+ attachments: Attachment[];
/** Callback to go one page back */
- onBack: PropTypes.func.isRequired,
+ onBack: () => void;
+
/** Callback to go one page forward */
- onForward: PropTypes.func.isRequired,
+ onForward: () => void;
- autoHideArrow: PropTypes.func,
- cancelAutoHideArrow: PropTypes.func,
-};
+ /** Callback for autohiding carousel button arrows */
+ autoHideArrow?: () => void;
-const defaultProps = {
- autoHideArrow: () => {},
- cancelAutoHideArrow: () => {},
+ /** Callback for cancelling autohiding of carousel button arrows */
+ cancelAutoHideArrow?: () => void;
};
-function CarouselButtons({page, attachments, shouldShowArrows, onBack, onForward, cancelAutoHideArrow, autoHideArrow}) {
+function CarouselButtons({page, attachments, shouldShowArrows, onBack, onForward, cancelAutoHideArrow, autoHideArrow}: CarouselButtonsProps) {
const theme = useTheme();
const styles = useThemeStyles();
const isBackDisabled = page === 0;
- const isForwardDisabled = page === _.size(attachments) - 1;
-
+ const isForwardDisabled = page === attachments.length - 1;
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
@@ -80,8 +76,6 @@ function CarouselButtons({page, attachments, shouldShowArrows, onBack, onForward
) : null;
}
-CarouselButtons.propTypes = propTypes;
-CarouselButtons.defaultProps = defaultProps;
CarouselButtons.displayName = 'CarouselButtons';
export default CarouselButtons;
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx
similarity index 65%
rename from src/components/Attachments/AttachmentCarousel/CarouselItem.js
rename to src/components/Attachments/AttachmentCarousel/CarouselItem.tsx
index b2c9fed64467..4988110538fe 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx
@@ -1,8 +1,8 @@
-import PropTypes from 'prop-types';
import React, {useContext, useState} from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import AttachmentView from '@components/Attachments/AttachmentView';
-import * as AttachmentsPropTypes from '@components/Attachments/propTypes';
+import type {Attachment} from '@components/Attachments/types';
import Button from '@components/Button';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
@@ -12,55 +12,27 @@ import useThemeStyles from '@hooks/useThemeStyles';
import ReportAttachmentsContext from '@pages/home/report/ReportAttachmentsContext';
import CONST from '@src/CONST';
-const propTypes = {
+type CarouselItemProps = {
/** Attachment required information such as the source and file name */
- item: PropTypes.shape({
- /** Report action ID of the attachment */
- reportActionID: PropTypes.string,
-
- /** Whether source URL requires authentication */
- isAuthTokenRequired: PropTypes.bool,
-
- /** URL to full-sized attachment or SVG function */
- source: AttachmentsPropTypes.attachmentSourcePropType.isRequired,
-
- /** Additional information about the attachment file */
- file: PropTypes.shape({
- /** File name of the attachment */
- name: PropTypes.string.isRequired,
- }).isRequired,
-
- /** Whether the attachment has been flagged */
- hasBeenFlagged: PropTypes.bool,
-
- /** The id of the transaction related to the attachment */
- transactionID: PropTypes.string,
-
- duration: PropTypes.number,
- }).isRequired,
+ item: Attachment;
/** onPress callback */
- onPress: PropTypes.func,
+ onPress?: () => void;
- isModalHovered: PropTypes.bool,
+ /** Whether attachment carousel modal is hovered over */
+ isModalHovered?: boolean;
/** Whether the attachment is currently being viewed in the carousel */
- isFocused: PropTypes.bool.isRequired,
-};
-
-const defaultProps = {
- onPress: undefined,
- isModalHovered: false,
+ isFocused: boolean;
};
-function CarouselItem({item, onPress, isFocused, isModalHovered}) {
+function CarouselItem({item, onPress, isFocused, isModalHovered}: CarouselItemProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isAttachmentHidden} = useContext(ReportAttachmentsContext);
- // eslint-disable-next-line es/no-nullish-coalescing-operators
- const [isHidden, setIsHidden] = useState(() => isAttachmentHidden(item.reportActionID) ?? item.hasBeenFlagged);
+ const [isHidden, setIsHidden] = useState(() => (item.reportActionID ? isAttachmentHidden(item.reportActionID) : item.hasBeenFlagged));
- const renderButton = (style) => (
+ const renderButton = (style: StyleProp) => (
@@ -121,8 +94,6 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}) {
);
}
-CarouselItem.propTypes = propTypes;
-CarouselItem.defaultProps = defaultProps;
CarouselItem.displayName = 'CarouselItem';
export default CarouselItem;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts
index 87cabecc5878..87a9108d5f2e 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts
+++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts
@@ -2,11 +2,12 @@ import type {ForwardedRef} from 'react';
import {createContext} from 'react';
import type PagerView from 'react-native-pager-view';
import type {SharedValue} from 'react-native-reanimated';
+import type {AttachmentSource} from '@components/Attachments/types';
/** The pager items array is used within the pager to render and navigate between the images */
type AttachmentCarouselPagerItems = {
/** The source of the image is used to identify each attachment/page in the pager */
- source: string;
+ source: AttachmentSource;
/** The index of the pager item determines the order of the images in the pager */
index: number;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
index 33d9f20b5e57..b7ef9309eb10 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
+++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
@@ -1,5 +1,6 @@
import type {ForwardedRef} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
+import type {NativeSyntheticEvent} from 'react-native';
import {View} from 'react-native';
import type {NativeViewGestureHandlerProps} from 'react-native-gesture-handler';
import {createNativeWrapper} from 'react-native-gesture-handler';
@@ -7,6 +8,7 @@ import type {PagerViewProps} from 'react-native-pager-view';
import PagerView from 'react-native-pager-view';
import Animated, {useAnimatedProps, useSharedValue} from 'react-native-reanimated';
import CarouselItem from '@components/Attachments/AttachmentCarousel/CarouselItem';
+import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import useThemeStyles from '@hooks/useThemeStyles';
import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext';
import usePageScrollHandler from './usePageScrollHandler';
@@ -20,22 +22,24 @@ type AttachmentCarouselPagerHandle = {
setPage: (selectedPage: number) => void;
};
-type Attachment = {
- source: string;
-};
-
type AttachmentCarouselPagerProps = {
/** The attachments to be rendered in the pager. */
items: Attachment[];
/** The source (URL) of the currently active attachment. */
- activeSource: string;
+ activeSource: AttachmentSource;
/** The index of the initial page to be rendered. */
initialPage: number;
/** A callback to be called when the page is changed. */
- onPageSelected: () => void;
+ onPageSelected: (
+ event: NativeSyntheticEvent<
+ Readonly<{
+ position: number;
+ }>
+ >,
+ ) => void;
/**
* A callback that can be used to toggle the attachment carousel arrows, when the scale of the image changes.
@@ -112,6 +116,12 @@ function AttachmentCarouselPager(
onRequestToggleArrows();
}, [isScrollEnabled.value, onRequestToggleArrows]);
+ const extractItemKey = useCallback(
+ (item: Attachment, index: number) =>
+ typeof item.source === 'string' || typeof item.source === 'number' ? `source-${item.source}` : `reportActionID-${item.reportActionID}` ?? `index-${index}`,
+ [],
+ );
+
const contextValue = useMemo(
() => ({
pagerItems,
@@ -146,14 +156,11 @@ function AttachmentCarouselPager(
const carouselItems = items.map((item, index) => (
@@ -179,3 +186,4 @@ function AttachmentCarouselPager(
AttachmentCarouselPager.displayName = 'AttachmentCarouselPager';
export default React.forwardRef(AttachmentCarouselPager);
+export type {AttachmentCarouselPagerHandle};
diff --git a/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js b/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js
deleted file mode 100644
index 5aa665683162..000000000000
--- a/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import PropTypes from 'prop-types';
-import transactionPropTypes from '@components/transactionPropTypes';
-import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
-import reportPropTypes from '@pages/reportPropTypes';
-
-const propTypes = {
- /** source is used to determine the starting index in the array of attachments */
- source: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
-
- /** Callback to update the parent modal's state with a source and name from the attachments array */
- onNavigate: PropTypes.func,
-
- /** Function to change the download button Visibility */
- setDownloadButtonVisibility: PropTypes.func,
-
- /** Object of report actions for this report */
- reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
-
- /** The report currently being looked at */
- report: reportPropTypes.isRequired,
-
- /** The parent of `report` */
- parentReport: reportPropTypes,
-
- /** The report actions of the parent report */
- parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
-
- /** The transaction attached to the parent report action */
- transaction: transactionPropTypes,
-};
-
-const defaultProps = {
- source: '',
- reportActions: {},
- parentReport: {},
- parentReportActions: {},
- transaction: {},
- onNavigate: () => {},
- setDownloadButtonVisibility: () => {},
-};
-
-export {propTypes, defaultProps};
diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.ts
similarity index 82%
rename from src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
rename to src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.ts
index 9524c5203110..342afa1d5366 100644
--- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.ts
@@ -1,20 +1,19 @@
import {Parser as HtmlParser} from 'htmlparser2';
-import _ from 'underscore';
+import type {OnyxEntry} from 'react-native-onyx';
+import type {Attachment} from '@components/Attachments/types';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
import CONST from '@src/CONST';
+import type {ReportAction, ReportActions} from '@src/types/onyx';
/**
* Constructs the initial component state from report actions
- * @param {Object} parentReportAction
- * @param {Object} reportActions
- * @param {Object} transaction
- * @returns {Array}
*/
-function extractAttachmentsFromReport(parentReportAction, reportActions) {
- const actions = [parentReportAction, ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))];
- const attachments = [];
+function extractAttachmentsFromReport(parentReportAction?: OnyxEntry, reportActions?: OnyxEntry) {
+ const actions = [...(parentReportAction ? [parentReportAction] : []), ...ReportActionsUtils.getSortedReportActions(Object.values(reportActions ?? {}))];
+ const attachments: Attachment[] = [];
+
// We handle duplicate image sources by considering the first instance as original. Selecting any duplicate
// and navigating back (<) shows the image preceding the first instance, not the selected duplicate's position.
const uniqueSources = new Set();
@@ -30,7 +29,6 @@ function extractAttachmentsFromReport(parentReportAction, reportActions) {
uniqueSources.add(source);
const splittedUrl = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE].split('/');
attachments.unshift({
- reportActionID: null,
source: tryResolveUrlFromApiRoot(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]),
isAuthTokenRequired: Boolean(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]),
file: {name: splittedUrl[splittedUrl.length - 1]},
@@ -73,14 +71,14 @@ function extractAttachmentsFromReport(parentReportAction, reportActions) {
},
});
- _.forEach(actions, (action, key) => {
+ actions.forEach((action, key) => {
if (!ReportActionsUtils.shouldReportActionBeVisible(action, key) || ReportActionsUtils.isMoneyRequestAction(action)) {
return;
}
- const decision = _.get(action, ['message', 0, 'moderationDecision', 'decision'], '');
+ const decision = action?.message?.[0].moderationDecision?.decision;
const hasBeenFlagged = decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_HIDE || decision === CONST.MODERATION.MODERATOR_DECISION_HIDDEN;
- const html = _.get(action, ['message', 0, 'html'], '').replace('/>', `data-flagged="${hasBeenFlagged}" data-id="${action.reportActionID}"/>`);
+ const html = (action?.message?.[0].html ?? '').replace('/>', `data-flagged="${hasBeenFlagged}" data-id="${action.reportActionID}"/>`);
htmlParser.write(html);
});
htmlParser.end();
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.tsx
similarity index 67%
rename from src/components/Attachments/AttachmentCarousel/index.native.js
rename to src/components/Attachments/AttachmentCarousel/index.native.tsx
index f02b6690ae8e..f6d63fc9307d 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.js
+++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx
@@ -1,63 +1,62 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Keyboard, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import BlockingView from '@components/BlockingViews/BlockingView';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import * as Illustrations from '@components/Icon/Illustrations';
-import withLocalize from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import variables from '@styles/variables';
import ONYXKEYS from '@src/ONYXKEYS';
-import {defaultProps, propTypes} from './attachmentCarouselPropTypes';
import CarouselButtons from './CarouselButtons';
import extractAttachmentsFromReport from './extractAttachmentsFromReport';
+import type {AttachmentCarouselPagerHandle} from './Pager';
import AttachmentCarouselPager from './Pager';
+import type {AttachmentCaraouselOnyxProps, AttachmentCarouselProps} from './types';
import useCarouselArrows from './useCarouselArrows';
-function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, translate, onClose}) {
+function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, onClose}: AttachmentCarouselProps) {
const styles = useThemeStyles();
- const pagerRef = useRef(null);
- const [page, setPage] = useState();
- const [attachments, setAttachments] = useState([]);
- const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows();
- const [activeSource, setActiveSource] = useState(source);
+ const {translate} = useLocalize();
+ const pagerRef = useRef(null);
+ const [page, setPage] = useState();
+ const [attachments, setAttachments] = useState([]);
+ const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows();
+ const [activeSource, setActiveSource] = useState(source);
- const compareImage = useCallback((attachment) => attachment.source === source, [source]);
+ const compareImage = useCallback((attachment: Attachment) => attachment.source === source, [source]);
useEffect(() => {
- const parentReportAction = parentReportActions[report.parentReportActionID];
+ const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined;
const attachmentsFromReport = extractAttachmentsFromReport(parentReportAction, reportActions);
- const initialPage = _.findIndex(attachmentsFromReport, compareImage);
+ const initialPage = attachmentsFromReport.findIndex(compareImage);
// Dismiss the modal when deleting an attachment during its display in preview.
- if (initialPage === -1 && _.find(attachments, compareImage)) {
+ if (initialPage === -1 && attachments.find(compareImage)) {
Navigation.dismissModal();
} else {
setPage(initialPage);
setAttachments(attachmentsFromReport);
// Update the download button visibility in the parent modal
- setDownloadButtonVisibility(initialPage !== -1);
+ if (setDownloadButtonVisibility) {
+ setDownloadButtonVisibility(initialPage !== -1);
+ }
// Update the parent modal's state with the source and name from the mapped attachments
- if (!_.isUndefined(attachmentsFromReport[initialPage])) {
+ if (attachmentsFromReport[initialPage] !== undefined && onNavigate) {
onNavigate(attachmentsFromReport[initialPage]);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reportActions, compareImage]);
- /**
- * Updates the page state when the user navigates between attachments
- * @param {Object} item
- * @param {number} index
- */
+ /** Updates the page state when the user navigates between attachments */
const updatePage = useCallback(
- (newPageIndex) => {
+ (newPageIndex: number) => {
Keyboard.dismiss();
setShouldShowArrows(true);
@@ -66,7 +65,9 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
setPage(newPageIndex);
setActiveSource(item.source);
- onNavigate(item);
+ if (onNavigate) {
+ onNavigate(item);
+ }
},
[setShouldShowArrows, attachments, onNavigate],
);
@@ -76,10 +77,13 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
* @param {Number} deltaSlide
*/
const cycleThroughAttachments = useCallback(
- (deltaSlide) => {
+ (deltaSlide: number) => {
+ if (page === undefined) {
+ return;
+ }
const nextPageIndex = page + deltaSlide;
updatePage(nextPageIndex);
- pagerRef.current.setPage(nextPageIndex);
+ pagerRef.current?.setPage(nextPageIndex);
autoHideArrows();
},
@@ -91,7 +95,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
* @param {Boolean} showArrows if showArrows is passed, it will set the visibility to the passed value
*/
const toggleArrows = useCallback(
- (showArrows) => {
+ (showArrows?: boolean) => {
if (showArrows === undefined) {
setShouldShowArrows((prevShouldShowArrows) => !prevShouldShowArrows);
return;
@@ -148,23 +152,15 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
);
}
-AttachmentCarousel.propTypes = propTypes;
-AttachmentCarousel.defaultProps = defaultProps;
AttachmentCarousel.displayName = 'AttachmentCarousel';
-export default compose(
- withOnyx({
- reportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
- canEvict: false,
- },
- parentReport: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`,
- },
- parentReportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`,
- canEvict: false,
- },
- }),
- withLocalize,
-)(AttachmentCarousel);
+export default withOnyx({
+ parentReportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`,
+ canEvict: false,
+ },
+ reportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
+ canEvict: false,
+ },
+})(AttachmentCarousel);
diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.tsx
similarity index 65%
rename from src/components/Attachments/AttachmentCarousel/index.js
rename to src/components/Attachments/AttachmentCarousel/index.tsx
index ef6a11f6e67c..f05abfd6a0de 100644
--- a/src/components/Attachments/AttachmentCarousel/index.js
+++ b/src/components/Attachments/AttachmentCarousel/index.tsx
@@ -1,25 +1,25 @@
+import isEqual from 'lodash/isEqual';
import React, {useCallback, useEffect, useRef, useState} from 'react';
+import type {ListRenderItemInfo} from 'react-native';
import {FlatList, Keyboard, PixelRatio, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import BlockingView from '@components/BlockingViews/BlockingView';
import * as Illustrations from '@components/Icon/Illustrations';
-import withLocalize from '@components/withLocalize';
-import withWindowDimensions from '@components/withWindowDimensions';
+import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import AttachmentCarouselCellRenderer from './AttachmentCarouselCellRenderer';
-import {defaultProps, propTypes} from './attachmentCarouselPropTypes';
import CarouselActions from './CarouselActions';
import CarouselButtons from './CarouselButtons';
import CarouselItem from './CarouselItem';
import extractAttachmentsFromReport from './extractAttachmentsFromReport';
+import type {AttachmentCaraouselOnyxProps, AttachmentCarouselProps, UpdatePageProps} from './types';
import useCarouselArrows from './useCarouselArrows';
const viewabilityConfig = {
@@ -28,79 +28,79 @@ const viewabilityConfig = {
itemVisiblePercentThreshold: 95,
};
-function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, translate}) {
+function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility}: AttachmentCarouselProps) {
const theme = useTheme();
+ const {translate} = useLocalize();
const styles = useThemeStyles();
- const scrollRef = useRef(null);
+ const scrollRef = useRef(null);
const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
const [containerWidth, setContainerWidth] = useState(0);
const [page, setPage] = useState(0);
- const [attachments, setAttachments] = useState([]);
- const [activeSource, setActiveSource] = useState(source);
- const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows();
+ const [attachments, setAttachments] = useState([]);
+ const [activeSource, setActiveSource] = useState(source);
+ const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows();
- const compareImage = useCallback((attachment) => attachment.source === source, [source]);
+ const compareImage = useCallback((attachment: Attachment) => attachment.source === source, [source]);
useEffect(() => {
- const parentReportAction = parentReportActions[report.parentReportActionID];
- const attachmentsFromReport = extractAttachmentsFromReport(parentReportAction, reportActions);
+ const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined;
+ const attachmentsFromReport = extractAttachmentsFromReport(parentReportAction, reportActions ?? undefined);
- const initialPage = _.findIndex(attachmentsFromReport, compareImage);
-
- if (_.isEqual(attachments, attachmentsFromReport)) {
+ if (isEqual(attachments, attachmentsFromReport)) {
return;
}
+ const initialPage = attachmentsFromReport.findIndex(compareImage);
+
// Dismiss the modal when deleting an attachment during its display in preview.
- if (initialPage === -1 && _.find(attachments, compareImage)) {
+ if (initialPage === -1 && attachments.find(compareImage)) {
Navigation.dismissModal();
} else {
setPage(initialPage);
setAttachments(attachmentsFromReport);
// Update the download button visibility in the parent modal
- setDownloadButtonVisibility(initialPage !== -1);
+ if (setDownloadButtonVisibility) {
+ setDownloadButtonVisibility(initialPage !== -1);
+ }
// Update the parent modal's state with the source and name from the mapped attachments
- if (!_.isUndefined(attachmentsFromReport[initialPage])) {
+ if (attachmentsFromReport[initialPage] !== undefined && onNavigate) {
onNavigate(attachmentsFromReport[initialPage]);
}
}
- }, [attachments, reportActions, parentReportActions, compareImage, report.parentReportActionID, setDownloadButtonVisibility, onNavigate]);
+ }, [reportActions, parentReportActions, compareImage, report.parentReportActionID, attachments, setDownloadButtonVisibility, onNavigate]);
- /**
- * Updates the page state when the user navigates between attachments
- * @param {Object} item
- * @param {number} index
- */
+ /** Updates the page state when the user navigates between attachments */
const updatePage = useCallback(
- ({viewableItems}) => {
+ ({viewableItems}: UpdatePageProps) => {
Keyboard.dismiss();
// Since we can have only one item in view at a time, we can use the first item in the array
// to get the index of the current page
- const entry = _.first(viewableItems);
+ const entry = viewableItems[0];
if (!entry) {
setActiveSource(null);
return;
}
- setPage(entry.index);
- setActiveSource(entry.item.source);
+ if (entry.index !== null) {
+ setPage(entry.index);
+ setActiveSource(entry.item.source);
+ }
- onNavigate(entry.item);
+ if (onNavigate) {
+ onNavigate(entry.item);
+ }
},
[onNavigate],
);
- /**
- * Increments or decrements the index to get another selected item
- * @param {Number} deltaSlide
- */
+ /** Increments or decrements the index to get another selected item */
const cycleThroughAttachments = useCallback(
- (deltaSlide) => {
+ (deltaSlide: number) => {
const nextIndex = page + deltaSlide;
const nextItem = attachments[nextIndex];
@@ -113,14 +113,15 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
[attachments, canUseTouchScreen, page],
);
- /**
- * Calculate items layout information to optimize scrolling performance
- * @param {*} data
- * @param {Number} index
- * @returns {{offset: Number, length: Number, index: Number}}
- */
+ const extractItemKey = useCallback(
+ (item: Attachment, index: number) =>
+ typeof item.source === 'string' || typeof item.source === 'number' ? `source-${item.source}` : `reportActionID-${item.reportActionID}` ?? `index-${index}`,
+ [],
+ );
+
+ /** Calculate items layout information to optimize scrolling performance */
const getItemLayout = useCallback(
- (_data, index) => ({
+ (data: ArrayLike | null | undefined, index: number) => ({
length: containerWidth,
offset: containerWidth * index,
index,
@@ -128,30 +129,17 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
[containerWidth],
);
- /**
- * Defines how a single attachment should be rendered
- * @param {Object} item
- * @param {String} item.reportActionID
- * @param {Boolean} item.isAuthTokenRequired
- * @param {String} item.source
- * @param {Object} item.file
- * @param {String} item.file.name
- * @param {Boolean} item.hasBeenFlagged
- * @returns {JSX.Element}
- */
+ /** Defines how a single attachment should be rendered */
const renderItem = useCallback(
- ({item, index}) => (
+ ({item}: ListRenderItemInfo) => (
setShouldShowArrows((oldState) => !oldState) : undefined}
+ onPress={canUseTouchScreen ? () => setShouldShowArrows((oldState: boolean) => !oldState) : undefined}
isModalHovered={shouldShowArrows}
- index={index}
- activeIndex={page}
/>
),
- [activeSource, attachments.length, canUseTouchScreen, page, setShouldShowArrows, shouldShowArrows],
+ [activeSource, canUseTouchScreen, setShouldShowArrows, shouldShowArrows],
);
return (
@@ -184,7 +172,6 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
{containerWidth > 0 && (
item.source}
+ keyExtractor={extractItemKey}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={updatePage}
/>
@@ -220,24 +207,15 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
);
}
-AttachmentCarousel.propTypes = propTypes;
-AttachmentCarousel.defaultProps = defaultProps;
AttachmentCarousel.displayName = 'AttachmentCarousel';
-export default compose(
- withOnyx({
- reportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
- canEvict: false,
- },
- parentReport: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`,
- },
- parentReportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`,
- canEvict: false,
- },
- }),
- withLocalize,
- withWindowDimensions,
-)(AttachmentCarousel);
+export default withOnyx({
+ parentReportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`,
+ canEvict: false,
+ },
+ reportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
+ canEvict: false,
+ },
+})(AttachmentCarousel);
diff --git a/src/components/Attachments/AttachmentCarousel/types.ts b/src/components/Attachments/AttachmentCarousel/types.ts
new file mode 100644
index 000000000000..8ba3489a5fcf
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/types.ts
@@ -0,0 +1,35 @@
+import type {ViewToken} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import type {Attachment, AttachmentSource} from '@components/Attachments/types';
+import type {Report, ReportActions} from '@src/types/onyx';
+
+type UpdatePageProps = {
+ viewableItems: ViewToken[];
+};
+
+type AttachmentCaraouselOnyxProps = {
+ /** Object of report actions for this report */
+ reportActions: OnyxEntry;
+
+ /** The report actions of the parent report */
+ parentReportActions: OnyxEntry;
+};
+
+type AttachmentCarouselProps = AttachmentCaraouselOnyxProps & {
+ /** Source is used to determine the starting index in the array of attachments */
+ source: AttachmentSource;
+
+ /** Callback to update the parent modal's state with a source and name from the attachments array */
+ onNavigate?: (attachment: Attachment) => void;
+
+ /** Function to change the download button Visibility */
+ setDownloadButtonVisibility?: (isButtonVisible: boolean) => void;
+
+ /** The report currently being looked at */
+ report: Report;
+
+ /** A callback that is called when swipe-down-to-close gesture happens */
+ onClose: () => void;
+};
+
+export type {AttachmentCarouselProps, UpdatePageProps, AttachmentCaraouselOnyxProps};
diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.js b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts
similarity index 72%
rename from src/components/Attachments/AttachmentCarousel/useCarouselArrows.js
rename to src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts
index 0c55c3ae519d..12ca3db4e2ff 100644
--- a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.js
+++ b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts
@@ -1,3 +1,4 @@
+import type {SetStateAction} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import CONST from '@src/CONST';
@@ -5,12 +6,17 @@ import CONST from '@src/CONST';
function useCarouselArrows() {
const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
const [shouldShowArrows, setShouldShowArrowsInternal] = useState(canUseTouchScreen);
- const autoHideArrowTimeout = useRef(null);
+ const autoHideArrowTimeout = useRef(null);
/**
* Cancels the automatic hiding of the arrows.
*/
- const cancelAutoHideArrows = useCallback(() => clearTimeout(autoHideArrowTimeout.current), []);
+ const cancelAutoHideArrows = useCallback(() => {
+ if (!autoHideArrowTimeout.current) {
+ return;
+ }
+ clearTimeout(autoHideArrowTimeout.current);
+ }, []);
/**
* Automatically hide the arrows if there is no interaction for 3 seconds.
@@ -27,7 +33,7 @@ function useCarouselArrows() {
}, [canUseTouchScreen, cancelAutoHideArrows]);
const setShouldShowArrows = useCallback(
- (show = true) => {
+ (show: SetStateAction = true) => {
setShouldShowArrowsInternal(show);
autoHideArrows();
},
@@ -39,7 +45,7 @@ function useCarouselArrows() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- return [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows];
+ return {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows};
}
export default useCarouselArrows;
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx
old mode 100755
new mode 100644
similarity index 54%
rename from src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
rename to src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx
index 67f87b1733d3..c195c1e34554
--- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx
@@ -1,26 +1,31 @@
import React, {memo} from 'react';
import ImageView from '@components/ImageView';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import CONST from '@src/CONST';
-import {attachmentViewImageDefaultProps, attachmentViewImagePropTypes} from './propTypes';
+import type {AttachmentViewProps} from '..';
-const propTypes = {
- ...attachmentViewImagePropTypes,
- ...withLocalizePropTypes,
+type AttachmentViewImageProps = Pick & {
+ url: string;
+
+ loadComplete: boolean;
+
+ isImage: boolean;
+
+ /** Function for handle on error */
+ onError?: () => void;
};
-function AttachmentViewImage({url, file, isAuthTokenRequired, isFocused, loadComplete, onPress, onError, isImage, translate}) {
+function AttachmentViewImage({url, file, isAuthTokenRequired, loadComplete, onPress, onError, isImage}: AttachmentViewImageProps) {
+ const {translate} = useLocalize();
const styles = useThemeStyles();
const children = (
);
@@ -30,7 +35,8 @@ function AttachmentViewImage({url, file, isAuthTokenRequired, isFocused, loadCom
disabled={loadComplete}
style={[styles.flex1, styles.flexRow, styles.alignSelfStretch]}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
- accessibilityLabel={file.name || translate('attachmentView.unknownFilename')}
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ accessibilityLabel={file?.name || translate('attachmentView.unknownFilename')}
>
{children}
@@ -39,8 +45,6 @@ function AttachmentViewImage({url, file, isAuthTokenRequired, isFocused, loadCom
);
}
-AttachmentViewImage.propTypes = propTypes;
-AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps;
AttachmentViewImage.displayName = 'AttachmentViewImage';
-export default compose(memo, withLocalize)(AttachmentViewImage);
+export default memo(AttachmentViewImage);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/propTypes.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/propTypes.js
deleted file mode 100644
index f2a275fc9a21..000000000000
--- a/src/components/Attachments/AttachmentView/AttachmentViewImage/propTypes.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import PropTypes from 'prop-types';
-import {attachmentViewDefaultProps, attachmentViewPropTypes} from '@components/Attachments/AttachmentView/propTypes';
-
-const attachmentViewImagePropTypes = {
- ...attachmentViewPropTypes,
-
- url: PropTypes.string.isRequired,
-
- loadComplete: PropTypes.bool.isRequired,
-
- isImage: PropTypes.bool.isRequired,
-};
-
-const attachmentViewImageDefaultProps = {
- ...attachmentViewDefaultProps,
-
- loadComplete: false,
- isImage: false,
-};
-
-export {attachmentViewImagePropTypes, attachmentViewImageDefaultProps};
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx
similarity index 79%
rename from src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js
rename to src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx
index 2f16b63aacc6..44aeb2a58b81 100644
--- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx
@@ -1,21 +1,8 @@
-import PropTypes from 'prop-types';
import React, {memo, useCallback, useContext, useEffect} from 'react';
+import type {GestureResponderEvent} from 'react-native';
import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext';
import PDFView from '@components/PDFView';
-import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes';
-
-const baseAttachmentViewPdfPropTypes = {
- ...attachmentViewPdfPropTypes,
-
- /** Triggered when the PDF's onScaleChanged event is triggered */
- onScaleChanged: PropTypes.func,
-};
-
-const baseAttachmentViewPdfDefaultProps = {
- ...attachmentViewPdfDefaultProps,
-
- onScaleChanged: undefined,
-};
+import type AttachmentViewPdfProps from './types';
function BaseAttachmentViewPdf({
file,
@@ -28,7 +15,7 @@ function BaseAttachmentViewPdf({
onLoadComplete,
errorLabelStyles,
style,
-}) {
+}: AttachmentViewPdfProps) {
const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
const isScrollEnabled = attachmentCarouselPagerContext === null ? undefined : attachmentCarouselPagerContext.isScrollEnabled;
@@ -46,7 +33,7 @@ function BaseAttachmentViewPdf({
* as well as call the onScaleChanged prop of the AttachmentViewPdf component if defined.
*/
const onScaleChanged = useCallback(
- (newScale) => {
+ (newScale: number) => {
if (onScaleChangedProp !== undefined) {
onScaleChangedProp(newScale);
}
@@ -66,13 +53,13 @@ function BaseAttachmentViewPdf({
* Otherwise it means that the PDF is currently zoomed in, therefore the onTap callback should be ignored
*/
const onPress = useCallback(
- (e) => {
+ (event?: GestureResponderEvent | KeyboardEvent) => {
if (onPressProp !== undefined) {
- onPressProp(e);
+ onPressProp(event);
}
- if (attachmentCarouselPagerContext !== null && isScrollEnabled.value) {
- attachmentCarouselPagerContext.onTap(e);
+ if (attachmentCarouselPagerContext !== null && isScrollEnabled?.value) {
+ attachmentCarouselPagerContext.onTap();
}
},
[attachmentCarouselPagerContext, isScrollEnabled, onPressProp],
@@ -80,10 +67,11 @@ function BaseAttachmentViewPdf({
return (
{
isPanGestureActive.value = false;
+ if (!isScrollEnabled) {
+ return;
+ }
isScrollEnabled.value = true;
});
@@ -93,7 +96,4 @@ function AttachmentViewPdf(props) {
);
}
-AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes;
-AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps;
-
export default memo(AttachmentViewPdf);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.tsx
similarity index 54%
rename from src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js
rename to src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.tsx
index 103ff292760f..4ee60e9dfff5 100644
--- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.tsx
@@ -1,8 +1,8 @@
import React, {memo} from 'react';
import BaseAttachmentViewPdf from './BaseAttachmentViewPdf';
-import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes';
+import type AttachmentViewPdfProps from './types';
-function AttachmentViewPdf(props) {
+function AttachmentViewPdf(props: AttachmentViewPdfProps) {
return (
& {
+ encryptedSourceUrl: string;
+ onLoadComplete: (path: string) => void;
+
+ /** Additional style props */
+ style?: StyleProp;
+
+ /** Styles for the error label */
+ errorLabelStyles?: StyleProp;
+
+ /** Triggered when the PDF's onScaleChanged event is triggered */
+ onScaleChanged?: (scale: number) => void;
+};
+
+export default AttachmentViewPdfProps;
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.js b/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx
similarity index 52%
rename from src/components/Attachments/AttachmentView/AttachmentViewVideo/index.js
rename to src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx
index 2b71e799beed..03e0c0252a66 100644
--- a/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx
@@ -1,28 +1,17 @@
-import PropTypes from 'prop-types';
import React from 'react';
import VideoPlayer from '@components/VideoPlayer';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import type {AttachmentViewProps} from '..';
-const propTypes = {
+type AttachmentViewVideoProps = Pick & {
/** Video file source URL */
- source: PropTypes.string.isRequired,
+ source: string;
- /** Whether the video is currently being hovered over */
- isHovered: PropTypes.bool,
-
- shouldUseSharedVideoElement: PropTypes.bool,
-
- videoDuration: PropTypes.number,
-};
-
-const defaultProps = {
- isHovered: false,
- shouldUseSharedVideoElement: false,
- videoDuration: 0,
+ shouldUseSharedVideoElement?: boolean;
};
-function AttachmentViewVideo({source, isHovered, shouldUseSharedVideoElement, videoDuration}) {
+function AttachmentViewVideo({source, isHovered = false, shouldUseSharedVideoElement = false, duration = 0}: AttachmentViewVideoProps) {
const {isSmallScreen} = useWindowDimensions();
const styles = useThemeStyles();
@@ -31,14 +20,12 @@ function AttachmentViewVideo({source, isHovered, shouldUseSharedVideoElement, vi
url={source}
shouldUseSharedVideoElement={shouldUseSharedVideoElement && !isSmallScreen}
isVideoHovered={isHovered}
- videoDuration={videoDuration}
+ videoDuration={duration}
style={[styles.w100, styles.h100]}
/>
);
}
-AttachmentViewVideo.propTypes = propTypes;
-AttachmentViewVideo.defaultProps = defaultProps;
AttachmentViewVideo.displayName = 'AttachmentViewVideo';
export default React.memo(AttachmentViewVideo);
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.tsx
old mode 100755
new mode 100644
similarity index 66%
rename from src/components/Attachments/AttachmentView/index.js
rename to src/components/Attachments/AttachmentView/index.tsx
index 9fe37734e8ee..2685a5cef407
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.tsx
@@ -1,10 +1,10 @@
import Str from 'expensify-common/lib/str';
-import PropTypes from 'prop-types';
import React, {memo, useEffect, useState} from 'react';
+import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import {ActivityIndicator, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import * as AttachmentsPropTypes from '@components/Attachments/propTypes';
+import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import DistanceEReceipt from '@components/DistanceEReceipt';
import EReceipt from '@components/EReceipt';
import Icon from '@components/Icon';
@@ -13,74 +13,61 @@ import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
import Tooltip from '@components/Tooltip';
import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as CachedPDFPaths from '@libs/actions/CachedPDFPaths';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
-import compose from '@libs/compose';
import * as TransactionUtils from '@libs/TransactionUtils';
+import type {ColorValue} from '@styles/utils/types';
import variables from '@styles/variables';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {Transaction} from '@src/types/onyx';
import AttachmentViewImage from './AttachmentViewImage';
import AttachmentViewPdf from './AttachmentViewPdf';
import AttachmentViewVideo from './AttachmentViewVideo';
-import {attachmentViewDefaultProps, attachmentViewPropTypes} from './propTypes';
-const propTypes = {
- ...attachmentViewPropTypes,
- ...withLocalizePropTypes,
+type AttachmentViewOnyxProps = {
+ transaction: OnyxEntry;
+};
- /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */
- source: AttachmentsPropTypes.attachmentSourcePropType.isRequired,
+type AttachmentViewProps = AttachmentViewOnyxProps &
+ Attachment & {
+ /** Whether this view is the active screen */
+ isFocused?: boolean;
- /** Flag to show/hide download icon */
- shouldShowDownloadIcon: PropTypes.bool,
+ /** Function for handle on press */
+ onPress?: (e?: GestureResponderEvent | KeyboardEvent) => void;
- /** Flag to show the loading indicator */
- shouldShowLoadingSpinnerIcon: PropTypes.bool,
+ /** Whether this AttachmentView is shown as part of a AttachmentCarousel */
+ isUsedInCarousel?: boolean;
- /** Notify parent that the UI should be modified to accommodate keyboard */
- onToggleKeyboard: PropTypes.func,
+ isUsedInAttachmentModal?: boolean;
- /** Extra styles to pass to View wrapper */
- // eslint-disable-next-line react/forbid-prop-types
- containerStyles: PropTypes.arrayOf(PropTypes.object),
+ /** Flag to show/hide download icon */
+ shouldShowDownloadIcon?: boolean;
- /** Denotes whether it is a workspace avatar or not */
- isWorkspaceAvatar: PropTypes.bool,
+ /** Flag to show the loading indicator */
+ shouldShowLoadingSpinnerIcon?: boolean;
- /** Denotes whether it is an icon (ex: SVG) */
- maybeIcon: PropTypes.bool,
+ /** Notify parent that the UI should be modified to accommodate keyboard */
+ onToggleKeyboard?: (shouldFadeOut: boolean) => void;
- /** The id of the transaction related to the attachment */
- // eslint-disable-next-line react/no-unused-prop-types
- transactionID: PropTypes.string,
+ /** Extra styles to pass to View wrapper */
+ containerStyles?: StyleProp;
- /** The id of the report action related to the attachment */
- reportActionID: PropTypes.string,
+ /** Denotes whether it is a workspace avatar or not */
+ isWorkspaceAvatar?: boolean;
- isHovered: PropTypes.bool,
+ /** Denotes whether it is an icon (ex: SVG) */
+ maybeIcon?: boolean;
- optionalVideoDuration: PropTypes.number,
-};
+ fallbackSource?: AttachmentSource;
-const defaultProps = {
- ...attachmentViewDefaultProps,
- shouldShowDownloadIcon: false,
- shouldShowLoadingSpinnerIcon: false,
- onToggleKeyboard: () => {},
- containerStyles: [],
- isWorkspaceAvatar: false,
- maybeIcon: false,
- transactionID: '',
- reportActionID: '',
- isHovered: false,
- optionalVideoDuration: 0,
- fallbackSource: Expensicons.Gallery,
-};
+ isHovered?: boolean;
+ };
function AttachmentView({
source,
@@ -91,7 +78,6 @@ function AttachmentView({
shouldShowDownloadIcon,
containerStyles,
onToggleKeyboard,
- translate,
isFocused,
isUsedInCarousel,
isUsedInAttachmentModal,
@@ -101,21 +87,22 @@ function AttachmentView({
transaction,
reportActionID,
isHovered,
- optionalVideoDuration,
-}) {
+ duration,
+}: AttachmentViewProps) {
+ const {translate} = useLocalize();
const {updateCurrentlyPlayingURL} = usePlaybackContext();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const [loadComplete, setLoadComplete] = useState(false);
- const isVideo = (typeof source === 'string' && Str.isVideo(source)) || (file && Str.isVideo(file.name));
+ const isVideo = (typeof source === 'string' && Str.isVideo(source)) || (file?.name && Str.isVideo(file.name));
useEffect(() => {
if (!isFocused && !(file && isUsedInAttachmentModal)) {
return;
}
- updateCurrentlyPlayingURL(isVideo ? source : null);
- }, [isFocused, isVideo, source, updateCurrentlyPlayingURL, file, isUsedInAttachmentModal]);
+ updateCurrentlyPlayingURL(isVideo && typeof source === 'string' ? source : null);
+ }, [file, isFocused, isUsedInAttachmentModal, isVideo, source, updateCurrentlyPlayingURL]);
const [imageError, setImageError] = useState(false);
@@ -123,11 +110,11 @@ function AttachmentView({
// Handles case where source is a component (ex: SVG) or a number
// Number may represent a SVG or an image
- if ((maybeIcon && typeof source === 'number') || _.isFunction(source)) {
- let iconFillColor = '';
- let additionalStyles = [];
- if (isWorkspaceAvatar) {
- const defaultWorkspaceAvatarColor = StyleUtils.getDefaultWorkspaceAvatarColor(file.name);
+ if (typeof source === 'function' || (maybeIcon && typeof source === 'number')) {
+ let iconFillColor: ColorValue | undefined = '';
+ let additionalStyles: ViewStyle[] = [];
+ if (isWorkspaceAvatar && file) {
+ const defaultWorkspaceAvatarColor = StyleUtils.getDefaultWorkspaceAvatarColor(file.name ?? '');
iconFillColor = defaultWorkspaceAvatarColor.fill;
additionalStyles = [defaultWorkspaceAvatarColor];
}
@@ -143,7 +130,7 @@ function AttachmentView({
);
}
- if (TransactionUtils.hasEReceipt(transaction)) {
+ if (TransactionUtils.hasEReceipt(transaction) && transaction) {
return (
{
- const id = (transaction && transaction.transactionID) || reportActionID;
+ const onPDFLoadComplete = (path: string) => {
+ const id = (transaction && transaction.transactionID) ?? reportActionID;
if (path && id) {
CachedPDFPaths.add(id, path);
}
@@ -177,34 +164,31 @@ function AttachmentView({
return (
);
}
- if (TransactionUtils.isDistanceRequest(transaction)) {
+ if (TransactionUtils.isDistanceRequest(transaction) && transaction) {
return ;
}
// For this check we use both source and file.name since temporary file source is a blob
// both PDFs and images will appear as images when pasted into the text field.
// We also check for numeric source since this is how static images (used for preview) are represented in RN.
- const isImage = typeof source === 'number' || Str.isImage(source);
- if (isImage || (file && Str.isImage(file.name))) {
+ const isImage = typeof source === 'number' || (typeof source === 'string' && Str.isImage(source));
+ if (isImage || (file?.name && Str.isImage(file.name))) {
if (imageError) {
// AttachmentViewImage can't handle icon fallbacks, so we need to handle it here
- if (typeof fallbackSource === 'number' || _.isFunction(fallbackSource)) {
+ if (typeof fallbackSource === 'number' || typeof fallbackSource === 'function') {
return (
{
@@ -234,19 +216,19 @@ function AttachmentView({
);
}
- if (isVideo) {
+ if ((isVideo ?? (file?.name && Str.isVideo(file.name))) && typeof source === 'string') {
return (
);
}
return (
-
+
- {file && file.name}
+ {file?.name}
{!shouldShowLoadingSpinnerIcon && shouldShowDownloadIcon && (
@@ -279,16 +261,14 @@ function AttachmentView({
);
}
-AttachmentView.propTypes = propTypes;
-AttachmentView.defaultProps = defaultProps;
AttachmentView.displayName = 'AttachmentView';
-export default compose(
- memo,
- withLocalize,
- withOnyx({
+export default memo(
+ withOnyx({
transaction: {
key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
},
- }),
-)(AttachmentView);
+ })(AttachmentView),
+);
+
+export type {AttachmentViewProps};
diff --git a/src/components/Attachments/AttachmentView/propTypes.js b/src/components/Attachments/AttachmentView/propTypes.js
deleted file mode 100644
index 0a0d654912d3..000000000000
--- a/src/components/Attachments/AttachmentView/propTypes.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import PropTypes from 'prop-types';
-import * as AttachmentsPropTypes from '@components/Attachments/propTypes';
-
-const attachmentViewPropTypes = {
- /** Whether source url requires authentication */
- isAuthTokenRequired: PropTypes.bool,
-
- /** File object can be an instance of File or Object */
- file: AttachmentsPropTypes.attachmentFilePropType,
-
- /** Whether this view is the active screen */
- isFocused: PropTypes.bool,
-
- /** Whether this AttachmentView is shown as part of a AttachmentCarousel */
- isUsedInCarousel: PropTypes.bool,
-
- /** Function for handle on press */
- onPress: PropTypes.func,
-
- /** Handles scale changed event */
- onScaleChanged: PropTypes.func,
-};
-
-const attachmentViewDefaultProps = {
- isAuthTokenRequired: false,
- file: {
- name: '',
- },
- isFocused: false,
- isSingleElement: false,
- isUsedInCarousel: false,
- isUsedInAttachmentModal: false,
- onPress: undefined,
- onScaleChanged: () => {},
-};
-
-export {attachmentViewPropTypes, attachmentViewDefaultProps};
diff --git a/src/components/Attachments/propTypes.js b/src/components/Attachments/propTypes.js
deleted file mode 100644
index 13adc468ce64..000000000000
--- a/src/components/Attachments/propTypes.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import PropTypes from 'prop-types';
-
-const attachmentSourcePropType = PropTypes.oneOfType([PropTypes.string, PropTypes.func, PropTypes.number]);
-const attachmentFilePropType = PropTypes.shape({
- name: PropTypes.string.isRequired,
-});
-
-const attachmentPropType = PropTypes.shape({
- /** Whether source url requires authentication */
- isAuthTokenRequired: PropTypes.bool,
-
- /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */
- source: attachmentSourcePropType.isRequired,
-
- /** File object can be an instance of File or Object */
- file: attachmentFilePropType.isRequired,
-});
-
-const attachmentsPropType = PropTypes.arrayOf(attachmentPropType);
-
-export {attachmentSourcePropType, attachmentFilePropType, attachmentPropType, attachmentsPropType};
diff --git a/src/components/Attachments/types.ts b/src/components/Attachments/types.ts
new file mode 100644
index 000000000000..835482ca99d9
--- /dev/null
+++ b/src/components/Attachments/types.ts
@@ -0,0 +1,30 @@
+import type {FileObject} from '@components/AttachmentModal';
+import type IconAsset from '@src/types/utils/IconAsset';
+
+type AttachmentSource = string | IconAsset | number;
+
+type Attachment = {
+ /** Report action ID of the attachment */
+ reportActionID?: string;
+
+ /** Whether source url requires authentication */
+ isAuthTokenRequired?: boolean;
+
+ /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */
+ source: AttachmentSource;
+
+ /** File object can be an instance of File or Object */
+ file?: FileObject;
+
+ /** Whether the attachment has been flagged */
+ hasBeenFlagged?: boolean;
+
+ /** The id of the transaction related to the attachment */
+ transactionID?: string;
+
+ isReceipt?: boolean;
+
+ duration?: number;
+};
+
+export type {AttachmentSource, Attachment};
diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx
index b80d6a138c9e..453e72dc761f 100644
--- a/src/components/ContextMenuItem.tsx
+++ b/src/components/ContextMenuItem.tsx
@@ -1,6 +1,6 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef, useImperativeHandle} from 'react';
-import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
+import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useThrottledButtonState from '@hooks/useThrottledButtonState';
@@ -46,6 +46,9 @@ type ContextMenuItemProps = {
wrapperStyle?: StyleProp;
shouldPreventDefaultFocusOnPress?: boolean;
+
+ /** The ref of mini context menu item */
+ buttonRef?: React.RefObject;
};
type ContextMenuItemHandle = {
@@ -66,6 +69,7 @@ function ContextMenuItem(
shouldLimitWidth = true,
wrapperStyle,
shouldPreventDefaultFocusOnPress = true,
+ buttonRef = {current: null},
}: ContextMenuItemProps,
ref: ForwardedRef,
) {
@@ -94,6 +98,7 @@ function ContextMenuItem(
return isMini ? (
{
const [emojiPopoverAnchorOrigin, setEmojiPopoverAnchorOrigin] = useState(DEFAULT_ANCHOR_ORIGIN);
const [activeID, setActiveID] = useState();
const emojiPopoverAnchorRef = useRef(null);
+ const emojiAnchorDimension = useRef({
+ width: 0,
+ height: 0,
+ });
const onModalHide = useRef(() => {});
const onEmojiSelected = useRef(() => {});
const activeEmoji = useRef();
@@ -76,7 +80,14 @@ const EmojiPicker = forwardRef((props, ref) => {
// eslint-disable-next-line es/no-optional-chaining
onWillShow?.();
setIsEmojiPickerVisible(true);
- setEmojiPopoverAnchorPosition(value);
+ setEmojiPopoverAnchorPosition({
+ horizontal: value.horizontal,
+ vertical: value.vertical,
+ });
+ emojiAnchorDimension.current = {
+ width: value.width,
+ height: value.height,
+ };
setEmojiPopoverAnchorOrigin(anchorOriginValue);
setActiveID(id);
});
@@ -155,7 +166,14 @@ const EmojiPicker = forwardRef((props, ref) => {
return;
}
calculateAnchorPosition(emojiPopoverAnchor.current, emojiPopoverAnchorOrigin).then((value) => {
- setEmojiPopoverAnchorPosition(value);
+ setEmojiPopoverAnchorPosition({
+ horizontal: value.horizontal,
+ vertical: value.vertical,
+ });
+ emojiAnchorDimension.current = {
+ width: value.width,
+ height: value.height,
+ };
});
});
return () => {
@@ -192,7 +210,9 @@ const EmojiPicker = forwardRef((props, ref) => {
anchorAlignment={emojiPopoverAnchorOrigin}
outerStyle={StyleUtils.getOuterModalStyle(windowHeight, props.viewportOffsetTop)}
innerContainerStyle={styles.popoverInnerContainer}
+ anchorDimensions={emojiAnchorDimension.current}
avoidKeyboard
+ shoudSwitchPositionIfOverflow
>
func,
shouldNavigateToTopMostReport = false,
style,
}: HeaderWithBackButtonProps) {
@@ -68,7 +66,6 @@ function HeaderWithBackButton({
const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState();
const {translate} = useLocalize();
const {isKeyboardShown} = useKeyboardState();
- const waitForNavigate = useWaitForNavigation();
// If the icon is present, the header bar should be taller and use different font.
const isCentralPaneSettings = !!icon;
@@ -175,7 +172,7 @@ function HeaderWithBackButton({
Navigation.navigate(ROUTES.GET_ASSISTANCE.getRoute(guidesCallTaskID, Navigation.getActiveRoute()))))}
+ onPress={() => Navigation.navigate(ROUTES.GET_ASSISTANCE.getRoute(guidesCallTaskID, Navigation.getActiveRoute()))}
style={[styles.touchableButtonImage]}
role="button"
accessibilityLabel={translate('getAssistancePage.questionMarkButtonTooltip')}
diff --git a/src/components/Image/BaseImage.native.tsx b/src/components/Image/BaseImage.native.tsx
index 3be1933af50c..3f692d0d95cf 100644
--- a/src/components/Image/BaseImage.native.tsx
+++ b/src/components/Image/BaseImage.native.tsx
@@ -1,17 +1,22 @@
import {Image as ExpoImage} from 'expo-image';
import type {ImageLoadEventData} from 'expo-image';
-import {useCallback} from 'react';
+import {useCallback, useRef} from 'react';
import type {BaseImageProps} from './types';
function BaseImage({onLoad, ...props}: BaseImageProps) {
+ const isLoadedRef = useRef(false);
const imageLoadedSuccessfully = useCallback(
(event: ImageLoadEventData) => {
if (!onLoad) {
return;
}
+ if (isLoadedRef.current === true) {
+ return;
+ }
// We override `onLoad`, so both web and native have the same signature
const {width, height} = event.source;
+ isLoadedRef.current = true;
onLoad({nativeEvent: {width, height}});
},
[onLoad],
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.e2e.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.e2e.tsx
new file mode 100644
index 000000000000..5df39ec02c89
--- /dev/null
+++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.e2e.tsx
@@ -0,0 +1,54 @@
+import React, {forwardRef, useMemo} from 'react';
+import type {FlatListProps, ScrollViewProps, ViewToken} from 'react-native';
+import {FlatList} from 'react-native';
+import type {ReportAction} from '@src/types/onyx';
+
+type BaseInvertedFlatListProps = FlatListProps & {
+ shouldEnableAutoScrollToTopThreshold?: boolean;
+};
+
+const AUTOSCROLL_TO_TOP_THRESHOLD = 128;
+
+let localViewableItems: ViewToken[];
+const getViewableItems = () => localViewableItems;
+
+function BaseInvertedFlatListE2e(props: BaseInvertedFlatListProps, ref: React.ForwardedRef>) {
+ const {shouldEnableAutoScrollToTopThreshold, ...rest} = props;
+
+ const handleViewableItemsChanged = useMemo(
+ () =>
+ ({viewableItems}: {viewableItems: ViewToken[]}) => {
+ localViewableItems = viewableItems;
+ },
+ [],
+ );
+
+ const maintainVisibleContentPosition = useMemo(() => {
+ const config: ScrollViewProps['maintainVisibleContentPosition'] = {
+ // This needs to be 1 to avoid using loading views as anchors.
+ minIndexForVisible: 1,
+ };
+
+ if (shouldEnableAutoScrollToTopThreshold) {
+ config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD;
+ }
+
+ return config;
+ }, [shouldEnableAutoScrollToTopThreshold]);
+
+ return (
+
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...rest}
+ ref={ref}
+ maintainVisibleContentPosition={maintainVisibleContentPosition}
+ inverted
+ onViewableItemsChanged={handleViewableItemsChanged}
+ />
+ );
+}
+
+BaseInvertedFlatListE2e.displayName = 'BaseInvertedFlatListE2e';
+
+export default forwardRef(BaseInvertedFlatListE2e);
+export {getViewableItems};
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx
similarity index 100%
rename from src/components/InvertedFlatList/BaseInvertedFlatList.tsx
rename to src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
index 6835bcf3f5fc..110256ba166b 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -35,6 +35,7 @@ import MultipleAvatars from './MultipleAvatars';
import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction';
import RenderHTML from './RenderHTML';
import SelectCircle from './SelectCircle';
+import SubscriptAvatar from './SubscriptAvatar';
import Text from './Text';
type IconProps = {
@@ -154,6 +155,8 @@ type MenuItemBaseProps = {
/** Text that appears above the title */
label?: string;
+ isLabelHoverable?: boolean;
+
/** Label to be displayed on the right */
rightLabel?: string;
@@ -184,12 +187,18 @@ type MenuItemBaseProps = {
/** Prop to represent the size of the float right avatar images to be shown */
floatRightAvatarSize?: ValueOf;
+ /** Whether the secondary right avatar should show as a subscript */
+ shouldShowSubscriptRightAvatar?: boolean;
+
/** Affects avatar size */
viewMode?: ValueOf;
/** Used to truncate the text with an ellipsis after computing the text layout */
numberOfLinesTitle?: number;
+ /** Used to truncate the description with an ellipsis after computing the text layout */
+ numberOfLinesDescription?: number;
+
/** Whether we should use small avatar subscript sizing the for menu item */
isSmallAvatarSubscriptMenu?: boolean;
@@ -255,6 +264,7 @@ function MenuItem(
badgeStyle,
viewMode = CONST.OPTION_MODE.DEFAULT,
numberOfLinesTitle = 1,
+ numberOfLinesDescription = 2,
icon,
iconFill,
secondaryIcon,
@@ -280,6 +290,7 @@ function MenuItem(
subtitle,
shouldShowBasicTitle,
label,
+ isLabelHoverable = true,
rightLabel,
shouldShowSelectedState = false,
isSelected = false,
@@ -289,6 +300,7 @@ function MenuItem(
rightComponent,
floatRightAvatars = [],
floatRightAvatarSize,
+ shouldShowSubscriptRightAvatar = false,
avatarSize = CONST.AVATAR_SIZE.DEFAULT,
isSmallAvatarSubscriptMenu = false,
brickRoadIndicator,
@@ -404,240 +416,261 @@ function MenuItem(
};
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),
- !focused && (isHovered || pressed) && hoverAndPressStyle,
- ...(Array.isArray(wrapperStyle) ? wrapperStyle : [wrapperStyle]),
- shouldGreyOutWhenDisabled && disabled && styles.buttonOpacityDisabled,
- ] as StyleProp
- }
- disabledStyle={shouldUseDefaultCursorWhenDisabled && [styles.cursorDefault]}
- disabled={disabled || isExecuting}
- ref={ref}
- role={CONST.ROLE.MENUITEM}
- accessibilityLabel={title ? title.toString() : ''}
- accessible
- >
- {({pressed}) => (
- <>
-
- {!!label && (
-
- {label}
-
- )}
-
- {!!icon && Array.isArray(icon) && (
-
+
+ {!!label && !isLabelHoverable && (
+
+ {label}
+
+ )}
+
+ {(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),
+ !focused && (isHovered || pressed) && hoverAndPressStyle,
+ ...(Array.isArray(wrapperStyle) ? wrapperStyle : [wrapperStyle]),
+ shouldGreyOutWhenDisabled && disabled && styles.buttonOpacityDisabled,
+ ] as StyleProp
+ }
+ disabledStyle={shouldUseDefaultCursorWhenDisabled && [styles.cursorDefault]}
+ disabled={disabled || isExecuting}
+ ref={ref}
+ role={CONST.ROLE.MENUITEM}
+ accessibilityLabel={title ? title.toString() : ''}
+ accessible
+ >
+ {({pressed}) => (
+ <>
+
+ {!!label && isLabelHoverable && (
+
+
+ {label}
+
+
)}
- {!icon && shouldPutLeftPaddingWhenNoIcon && }
- {icon && !Array.isArray(icon) && (
-
- {typeof icon !== 'string' && iconType === CONST.ICON_TYPE_ICON && (
+
+ {!!icon && Array.isArray(icon) && (
+
+ )}
+ {!icon && shouldPutLeftPaddingWhenNoIcon && }
+ {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}
+
)}
- {icon && iconType === CONST.ICON_TYPE_WORKSPACE && (
-
+
+ {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && (
+
+
+
+ )}
+ {!shouldRenderAsHTML && !shouldParseTitle && !!title && (
+
+ {renderTitleContent()}
+
+ )}
+ {shouldShowTitleIcon && titleIcon && (
+
+
+
+ )}
+
+ {!!description && !shouldShowDescriptionOnTop && (
+
+ {description}
+
)}
- {iconType === CONST.ICON_TYPE_AVATAR && (
-
+ {error}
+
+ )}
+ {!!furtherDetails && (
+
+ {!!furtherDetailsIcon && (
+
+ )}
+
+ {furtherDetails}
+
+
+ )}
+
+
+
+
+ {badgeText && (
+
+ )}
+ {/* Since subtitle can be of type number, we should allow 0 to be shown */}
+ {(subtitle === 0 || subtitle) && (
+
+ {subtitle}
+
+ )}
+ {floatRightAvatars?.length > 0 && (
+
+ {shouldShowSubscriptRightAvatar ? (
+
+ ) : (
+
)}
)}
- {secondaryIcon && (
-
+ {!!brickRoadIndicator && (
+
)}
-
- {!!description && shouldShowDescriptionOnTop && (
-
- {description}
-
- )}
-
- {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && (
-
-
-
- )}
- {!shouldRenderAsHTML && !shouldParseTitle && !!title && (
-
- {renderTitleContent()}
-
- )}
- {shouldShowTitleIcon && titleIcon && (
-
-
-
- )}
+ {!title && !!rightLabel && (
+
+ {rightLabel}
- {!!description && !shouldShowDescriptionOnTop && (
-
- {description}
-
- )}
- {!!error && (
-
- {error}
-
- )}
- {!!furtherDetails && (
-
- {!!furtherDetailsIcon && (
-
- )}
-
- {furtherDetails}
-
-
- )}
-
+ )}
+ {shouldShowRightIcon && (
+
+
+
+ )}
+ {shouldShowRightComponent && rightComponent}
+ {shouldShowSelectedState && }
-
-
- {badgeText && (
-
)}
- {/* Since subtitle can be of type number, we should allow 0 to be shown */}
- {(subtitle === 0 || subtitle) && (
-
- {subtitle}
-
- )}
- {floatRightAvatars?.length > 0 && (
-
-
-
- )}
- {!!brickRoadIndicator && (
-
-
-
- )}
- {!title && !!rightLabel && (
-
- {rightLabel}
-
- )}
- {shouldShowRightIcon && (
-
-
-
- )}
- {shouldShowRightComponent && rightComponent}
- {shouldShowSelectedState && }
-
- {!!errorText && (
-
- )}
- >
- )}
-
- )}
-
+ >
+ )}
+
+ )}
+
+
);
}
diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx
index 98bc47e41bbe..dedaba500a9c 100644
--- a/src/components/MultipleAvatars.tsx
+++ b/src/components/MultipleAvatars.tsx
@@ -101,8 +101,7 @@ function MultipleAvatars({
}),
[styles],
);
-
- const secondAvatarStyle = secondAvatarStyleProp ?? [StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)];
+ const secondAvatarStyle = secondAvatarStyleProp ?? [StyleUtils.getBackgroundAndBorderStyle(isHovered ? theme.activeComponentBG : theme.componentBG)];
let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction);
const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]);
diff --git a/src/components/PDFView/WebPDFDocument.js b/src/components/PDFView/WebPDFDocument.js
deleted file mode 100644
index dd9d1e066b19..000000000000
--- a/src/components/PDFView/WebPDFDocument.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import 'core-js/features/array/at';
-import PropTypes from 'prop-types';
-import React, {memo, useCallback} from 'react';
-import {Document} from 'react-pdf';
-import {VariableSizeList as List} from 'react-window';
-import _ from 'underscore';
-import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
-import Text from '@components/Text';
-import stylePropTypes from '@styles/stylePropTypes';
-import CONST from '@src/CONST';
-import PageRenderer from './WebPDFPageRenderer';
-
-const propTypes = {
- /** Index of the PDF page to be displayed passed by VariableSizeList */
- errorLabelStyles: stylePropTypes,
- /** Returns translated string for given locale and phrase */
- translate: PropTypes.func.isRequired,
- /** The source URL from which to load PDF file to be displayed */
- sourceURL: PropTypes.string.isRequired,
- /** Callback invoked when the PDF document is loaded successfully */
- onDocumentLoadSuccess: PropTypes.func.isRequired,
- /** Viewport info of all PDF pages */
- pageViewportsLength: PropTypes.number.isRequired,
- /** Sets attributes to list container */
- setListAttributes: PropTypes.func.isRequired,
- /** Indicates, whether the screen is of small width */
- isSmallScreenWidth: PropTypes.bool.isRequired,
- /** Height of PDF document container view */
- containerHeight: PropTypes.number.isRequired,
- /** Width of PDF document container view */
- containerWidth: PropTypes.number.isRequired,
- /** The number of pages of the PDF file to be rendered */
- numPages: PropTypes.number,
- /** Function that calculates the height of a page of the PDF document */
- calculatePageHeight: PropTypes.func.isRequired,
- /** Function that calculates the devicePixelRatio the page should be rendered with */
- getDevicePixelRatio: PropTypes.func.isRequired,
- /** The estimated height of a single PDF page for virtualized rendering purposes */
- estimatedItemSize: PropTypes.number.isRequired,
- /** The width of a page in the PDF file */
- pageWidth: PropTypes.number.isRequired,
- /** The style applied to the list component */
- listStyle: stylePropTypes,
- /** Function that should initiate that the user should be prompted for password to the PDF file */
- initiatePasswordChallenge: PropTypes.func.isRequired,
- /** Either:
- * - `string` - the password provided by the user to unlock the PDF file
- * - `undefined` if password isn't needed to view the PDF file
- * - `null` if the password is required but hasn't been provided yet */
- password: PropTypes.string,
-};
-
-const defaultProps = {
- errorLabelStyles: [],
- numPages: null,
- listStyle: undefined,
- password: undefined,
-};
-
-const WebPDFDocument = memo(
- ({
- errorLabelStyles,
- translate,
- sourceURL,
- onDocumentLoadSuccess,
- pageViewportsLength,
- setListAttributes,
- isSmallScreenWidth,
- containerHeight,
- containerWidth,
- numPages,
- calculatePageHeight,
- getDevicePixelRatio,
- estimatedItemSize,
- pageWidth,
- listStyle,
- initiatePasswordChallenge,
- password,
- }) => {
- const onPassword = useCallback(
- (callback, reason) => {
- if (reason === CONST.PDF_PASSWORD_FORM.REACT_PDF_PASSWORD_RESPONSES.NEED_PASSWORD) {
- if (password) {
- callback(password);
- } else {
- initiatePasswordChallenge(reason);
- }
- } else if (reason === CONST.PDF_PASSWORD_FORM.REACT_PDF_PASSWORD_RESPONSES.INCORRECT_PASSWORD) {
- initiatePasswordChallenge(reason);
- }
- },
- [password, initiatePasswordChallenge],
- );
-
- return (
- }
- error={{translate('attachmentView.failedToLoadPDF')}}
- file={sourceURL}
- options={{
- cMapUrl: 'cmaps/',
- cMapPacked: true,
- }}
- externalLinkTarget="_blank"
- onLoadSuccess={onDocumentLoadSuccess}
- onPassword={onPassword}
- >
- {!!pageViewportsLength && (
-
- {PageRenderer}
-
- )}
-
- );
- },
- (prevProps, nextProps) => _.isEqual(prevProps, nextProps),
-);
-
-WebPDFDocument.displayName = 'WebPDFDocument';
-WebPDFDocument.propTypes = propTypes;
-WebPDFDocument.defaultProps = defaultProps;
-
-export default WebPDFDocument;
diff --git a/src/components/PDFView/WebPDFPageRenderer.js b/src/components/PDFView/WebPDFPageRenderer.js
deleted file mode 100644
index 15af0bb88e39..000000000000
--- a/src/components/PDFView/WebPDFPageRenderer.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {memo} from 'react';
-import {View} from 'react-native';
-import {Page} from 'react-pdf';
-import _ from 'underscore';
-import stylePropTypes from '@styles/stylePropTypes';
-import PDFViewConstants from './constants';
-
-const propTypes = {
- /** Index of the PDF page to be displayed passed by VariableSizeList */
- index: PropTypes.number.isRequired,
-
- /** Page extra data passed by VariableSizeList's data prop */
- data: PropTypes.shape({
- /** Width of a single page in the document */
- pageWidth: PropTypes.number.isRequired,
- /** Function that calculates the height of a page given its index */
- calculatePageHeight: PropTypes.func.isRequired,
- /** Function that calculates the pixel ratio for a page given its calculated width and height */
- getDevicePixelRatio: PropTypes.func.isRequired,
- /** The estimated height of a single page in the document */
- estimatedItemSize: PropTypes.number.isRequired,
- }).isRequired,
-
- /** Additional style props passed by VariableSizeList */
- style: stylePropTypes.isRequired,
-};
-
-const WebPDFPageRenderer = memo(
- ({index: pageIndex, data, style}) => {
- const {pageWidth, calculatePageHeight, getDevicePixelRatio, estimatedItemSize} = data;
-
- const pageHeight = calculatePageHeight(pageIndex);
- const devicePixelRatio = getDevicePixelRatio(pageWidth, pageHeight);
-
- return (
-
-
-
- );
- },
- (prevProps, nextProps) => _.isEqual(prevProps, nextProps),
-);
-
-WebPDFPageRenderer.displayName = 'WebPDFPageRenderer';
-WebPDFPageRenderer.propTypes = propTypes;
-
-export default WebPDFPageRenderer;
diff --git a/src/components/PDFView/constants.js b/src/components/PDFView/constants.js
deleted file mode 100644
index a45beddfbb68..000000000000
--- a/src/components/PDFView/constants.js
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * Each page has a default border. The app should take this size into account
- * when calculates the page width and height.
- */
-const PAGE_BORDER = 9;
-
-/**
- * Pages should be more narrow than the container on large screens. The app should take this size into account
- * when calculates the page width.
- */
-const LARGE_SCREEN_SIDE_SPACING = 40;
-
-const REQUIRED_PASSWORD_MISSING = null;
-
-export default {PAGE_BORDER, LARGE_SCREEN_SIDE_SPACING, REQUIRED_PASSWORD_MISSING};
diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js
index 9706f8e06cc1..e69b52b74e95 100644
--- a/src/components/PDFView/index.js
+++ b/src/components/PDFView/index.js
@@ -1,53 +1,30 @@
import 'core-js/features/array/at';
-import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker';
import React, {Component} from 'react';
+import {PDFPreviewer} from 'react-fast-pdf';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import {pdfjs} from 'react-pdf';
import _ from 'underscore';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
+import Text from '@components/Text';
import withLocalize from '@components/withLocalize';
import withThemeStyles from '@components/withThemeStyles';
import withWindowDimensions from '@components/withWindowDimensions';
import compose from '@libs/compose';
-import Log from '@libs/Log';
import variables from '@styles/variables';
import * as CanvasSize from '@userActions/CanvasSize';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import PDFViewConstants from './constants';
import PDFPasswordForm from './PDFPasswordForm';
import * as pdfViewPropTypes from './pdfViewPropTypes';
-import PDFDocument from './WebPDFDocument';
class PDFView extends Component {
constructor(props) {
super(props);
this.state = {
- numPages: null,
- pageViewports: [],
- containerWidth: props.windowWidth,
- containerHeight: props.windowHeight,
- password: undefined,
- /** used to keep the PDFPasswordForm mounted (for it to maintain state) while password is being verified */
- isCheckingPassword: false,
- isPasswordInvalid: false,
isKeyboardOpen: false,
};
- this.onDocumentLoadSuccess = this.onDocumentLoadSuccess.bind(this);
- this.initiatePasswordChallenge = this.initiatePasswordChallenge.bind(this);
- this.attemptPDFLoad = this.attemptPDFLoad.bind(this);
this.toggleKeyboardOnSmallScreens = this.toggleKeyboardOnSmallScreens.bind(this);
- this.calculatePageHeight = this.calculatePageHeight.bind(this);
- this.calculatePageWidth = this.calculatePageWidth.bind(this);
- this.getDevicePixelRatio = _.memoize(this.getDevicePixelRatio.bind(this));
- this.setListAttributes = this.setListAttributes.bind(this);
-
- const workerURL = URL.createObjectURL(new Blob([pdfWorkerSource], {type: 'text/javascript'}));
- if (pdfjs.GlobalWorkerOptions.workerSrc !== workerURL) {
- pdfjs.GlobalWorkerOptions.workerSrc = workerURL;
- }
-
this.retrieveCanvasLimits();
}
@@ -69,134 +46,6 @@ class PDFView extends Component {
}
}
- /**
- * Upon successful document load, combine an array of page viewports,
- * set the number of pages on PDF,
- * hide/reset PDF password form, and notify parent component that
- * user input is no longer required.
- *
- * @param {Object} pdf - The PDF file instance
- * @param {Number} pdf.numPages - Number of pages of the PDF file
- * @param {Function} pdf.getPage - A method to get page by its number. It requires to have the context. It should be the pdf itself.
- * @memberof PDFView
- */
- onDocumentLoadSuccess(pdf) {
- const {numPages} = pdf;
-
- Promise.all(
- _.times(numPages, (index) => {
- const pageNumber = index + 1;
-
- return pdf.getPage(pageNumber).then((page) => page.getViewport({scale: 1}));
- }),
- ).then((pageViewports) => {
- this.setState({
- pageViewports,
- numPages,
- isPasswordInvalid: false,
- isCheckingPassword: false,
- });
- });
- }
-
- /**
- * Sets attributes to list container.
- * It unblocks a default scroll by keyboard of browsers.
- * @param {Object|undefined} ref
- */
- setListAttributes(ref) {
- if (!ref) {
- return;
- }
-
- // Useful for elements that should not be navigated to directly using the "Tab" key,
- // but need to have keyboard focus set to them.
- // eslint-disable-next-line no-param-reassign
- ref.tabIndex = -1;
- }
-
- /**
- * Calculate the devicePixelRatio the page should be rendered with
- * Each platform has a different default devicePixelRatio and different canvas limits, we need to verify that
- * with the default devicePixelRatio it will be able to diplay the pdf correctly, if not we must change the devicePixelRatio.
- * @param {Number} width of the page
- * @param {Number} height of the page
- * @returns {Number} devicePixelRatio for this page on this platform
- */
- getDevicePixelRatio(width, height) {
- const nbPixels = width * height;
- const ratioHeight = this.props.maxCanvasHeight / height;
- const ratioWidth = this.props.maxCanvasWidth / width;
- const ratioArea = Math.sqrt(this.props.maxCanvasArea / nbPixels);
- const ratio = Math.min(ratioHeight, ratioArea, ratioWidth);
-
- return ratio > window.devicePixelRatio ? undefined : ratio;
- }
-
- /**
- * Calculates a proper page height. The method should be called only when there are page viewports.
- * It is based on a ratio between the specific page viewport width and provided page width.
- * Also, the app should take into account the page borders.
- * @param {Number} pageIndex
- * @returns {Number}
- */
- calculatePageHeight(pageIndex) {
- if (this.state.pageViewports.length === 0 || _.some(this.state.pageViewports, (viewport) => !viewport)) {
- Log.warn('Dev error: calculatePageHeight() in PDFView called too early');
-
- return 0;
- }
-
- const pageViewport = this.state.pageViewports[pageIndex];
- const pageWidth = this.calculatePageWidth();
- const scale = pageWidth / pageViewport.width;
- const actualHeight = pageViewport.height * scale + PDFViewConstants.PAGE_BORDER * 2;
-
- return actualHeight;
- }
-
- /**
- * Calculates a proper page width.
- * It depends on a screen size. Also, the app should take into account the page borders.
- * @returns {Number}
- */
- calculatePageWidth() {
- const pdfContainerWidth = this.state.containerWidth;
- const pageWidthOnLargeScreen = Math.min(pdfContainerWidth - PDFViewConstants.LARGE_SCREEN_SIDE_SPACING * 2, variables.pdfPageMaxWidth);
- const pageWidth = this.props.isSmallScreenWidth ? this.state.containerWidth : pageWidthOnLargeScreen;
-
- return pageWidth + PDFViewConstants.PAGE_BORDER * 2;
- }
-
- /**
- * Initiate password challenge process. The WebPDFDocument
- * component calls this handler to indicate that a PDF requires a
- * password, or to indicate that a previously provided password was
- * invalid.
- *
- * The PasswordResponses constants used below were copied from react-pdf
- * because they're not exported in entry.webpack.
- *
- * @param {Number} reason Reason code for password request
- */
- initiatePasswordChallenge(reason) {
- if (reason === CONST.PDF_PASSWORD_FORM.REACT_PDF_PASSWORD_RESPONSES.NEED_PASSWORD) {
- this.setState({password: PDFViewConstants.REQUIRED_PASSWORD_MISSING, isCheckingPassword: false});
- } else if (reason === CONST.PDF_PASSWORD_FORM.REACT_PDF_PASSWORD_RESPONSES.INCORRECT_PASSWORD) {
- this.setState({password: PDFViewConstants.REQUIRED_PASSWORD_MISSING, isPasswordInvalid: true, isCheckingPassword: false});
- }
- }
-
- /**
- * Send password to react-pdf via its callback so that it can attempt to load
- * the PDF.
- *
- * @param {String} password Password to send via callback to react-pdf
- */
- attemptPDFLoad(password) {
- this.setState({password, isCheckingPassword: true});
- }
-
/**
* On small screens notify parent that the keyboard has opened or closed.
*
@@ -229,59 +78,33 @@ class PDFView extends Component {
renderPDFView() {
const styles = this.props.themeStyles;
- const pageWidth = this.calculatePageWidth();
const outerContainerStyle = [styles.w100, styles.h100, styles.justifyContentCenter, styles.alignItemsCenter];
- const pdfContainerStyle = [styles.PDFView, styles.noSelect, this.props.style];
- // If we're requesting a password then we need to hide - but still render -
- // the PDF component.
- if (this.state.password === PDFViewConstants.REQUIRED_PASSWORD_MISSING || this.state.isCheckingPassword) {
- pdfContainerStyle.push(styles.invisible);
- }
-
- const estimatedItemSize = this.calculatePageHeight(0);
-
return (
-
- this.setState({containerWidth: width, containerHeight: height})}
- >
-
-
- {(this.state.password === PDFViewConstants.REQUIRED_PASSWORD_MISSING || this.state.isCheckingPassword) && (
- this.setState({isPasswordInvalid: false})}
- isPasswordInvalid={this.state.isPasswordInvalid}
- onPasswordFieldFocused={this.toggleKeyboardOnSmallScreens}
- />
- )}
+
+ }
+ ErrorComponent={{this.props.translate('attachmentView.failedToLoadPDF')}}
+ renderPasswordForm={({isPasswordInvalid, onSubmit, onPasswordChange}) => (
+
+ )}
+ />
);
}
@@ -302,6 +125,7 @@ class PDFView extends Component {
);
}
}
+
PDFView.propTypes = pdfViewPropTypes.propTypes;
PDFView.defaultProps = pdfViewPropTypes.defaultProps;
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 8f54de5182f8..1fd1c8ef5a3b 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -203,11 +203,17 @@ function PopoverMenu({
title={item.text}
shouldCheckActionAllowedOnPress={false}
description={item.description}
+ numberOfLinesDescription={item.numberOfLinesDescription}
onPress={() => selectItem(menuIndex)}
focused={focusedIndex === menuIndex}
displayInDefaultIconColor={item.displayInDefaultIconColor}
shouldShowRightIcon={item.shouldShowRightIcon}
shouldPutLeftPaddingWhenNoIcon={item.shouldPutLeftPaddingWhenNoIcon}
+ label={item.label}
+ isLabelHoverable={item.isLabelHoverable}
+ floatRightAvatars={item.floatRightAvatars}
+ floatRightAvatarSize={item.floatRightAvatarSize}
+ shouldShowSubscriptRightAvatar={item.shouldShowSubscriptRightAvatar}
disabled={item.disabled}
/>
))}
diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx
index deda6dbd217a..af6f91a64026 100644
--- a/src/components/PopoverWithMeasuredContent.tsx
+++ b/src/components/PopoverWithMeasuredContent.tsx
@@ -6,7 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import PopoverWithMeasuredContentUtils from '@libs/PopoverWithMeasuredContentUtils';
import CONST from '@src/CONST';
-import type {AnchorPosition} from '@src/styles';
+import type {AnchorDimensions, AnchorPosition} from '@src/styles';
import Popover from './Popover';
import type {PopoverProps} from './Popover/types';
import type {WindowDimensionsProps} from './withWindowDimensions/types';
@@ -15,6 +15,12 @@ type PopoverWithMeasuredContentProps = Omit
diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx
index 0ea8ea308d6a..690a9485f099 100644
--- a/src/components/SettlementButton.tsx
+++ b/src/components/SettlementButton.tsx
@@ -143,9 +143,8 @@ function SettlementButton({
const session = useSession();
const chatReport = ReportUtils.getReport(chatReportID);
const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicyExpenseChat(chatReport as OnyxEntry);
- const shouldShowPaywithExpensifyOption =
- !isPaidGroupPolicy ||
- (!shouldHidePaymentOptions && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && policy?.reimburserEmail === session?.email);
+ const shouldShowPaywithExpensifyOption = !isPaidGroupPolicy || (!shouldHidePaymentOptions && ReportUtils.isPayer(session, iouReport as OnyxEntry));
+ const shouldShowPayElsewhereOption = !isPaidGroupPolicy || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL;
const paymentButtonOptions = useMemo(() => {
const buttonOptions = [];
const isExpenseReport = ReportUtils.isExpenseReport(iouReport);
@@ -189,7 +188,9 @@ function SettlementButton({
if (isExpenseReport && shouldShowPaywithExpensifyOption) {
buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]);
}
- buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]);
+ if (shouldShowPayElsewhereOption) {
+ buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]);
+ }
if (shouldShowApproveButton) {
buttonOptions.push(approveButtonOption);
diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx
index 61a13d271e7d..936bd23b530d 100644
--- a/src/components/TaxPicker.tsx
+++ b/src/components/TaxPicker.tsx
@@ -37,7 +37,7 @@ function TaxPicker({selectedTaxRate = '', taxRates, insets, onSubmit}: TaxPicker
const shouldShowTextInput = !isTaxRatesCountBelowThreshold;
- const getTaxName = useCallback((key: string) => taxRates?.taxes[key].name, [taxRates?.taxes]);
+ const getTaxName = useCallback((key: string) => taxRates?.taxes[key]?.name, [taxRates?.taxes]);
const selectedOptions = useMemo(() => {
if (!selectedTaxRate) {
diff --git a/src/components/TimePicker/TimePicker.js b/src/components/TimePicker/TimePicker.tsx
similarity index 80%
rename from src/components/TimePicker/TimePicker.js
rename to src/components/TimePicker/TimePicker.tsx
index 4d4520fedeea..17cd93db432b 100644
--- a/src/components/TimePicker/TimePicker.js
+++ b/src/components/TimePicker/TimePicker.tsx
@@ -1,12 +1,12 @@
-import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import type {ForwardedRef} from 'react';
import {View} from 'react-native';
-import _ from 'underscore';
+import type {GestureResponderEvent, NativeSyntheticEvent} from 'react-native';
+import type {TextInput} from 'react-native-gesture-handler';
import AmountTextInput from '@components/AmountTextInput';
import BigNumberPad from '@components/BigNumberPad';
import Button from '@components/Button';
import FormHelpMessage from '@components/FormHelpMessage';
-import refPropTypes from '@components/refPropTypes';
import Text from '@components/Text';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
@@ -19,24 +19,17 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import CONST from '@src/CONST';
import setCursorPosition from './setCursorPosition';
-const propTypes = {
- /** Refs forwarded to the TextInputWithCurrencySymbol */
- forwardedRef: refPropTypes,
+type MinuteHourRefs = {hourRef: TextInput | null; minuteRef: TextInput | null};
+type TimePickerProps = {
/** Default value for the inputs */
- defaultValue: PropTypes.string,
+ defaultValue?: string;
/** Callback to call when the Save button is pressed */
- onSubmit: PropTypes.func.isRequired,
+ onSubmit: (timeString: string) => void;
/** Callback to call when the input changes */
- onInputChange: PropTypes.func,
-};
-
-const defaultProps = {
- forwardedRef: null,
- onInputChange: () => {},
- defaultValue: '',
+ onInputChange?: (timeString: string) => void;
};
const AMOUNT_VIEW_ID = 'amountView';
@@ -45,14 +38,14 @@ const NUM_PAD_VIEW_ID = 'numPadView';
/**
* Replace the sub-string of the given string with the provided value
- * @param {String} originalString - the string that will be modified
- * @param {String} newSubstring - the replacement string
- * @param {Number} from - the start index of the sub-string to replace
- * @param {Number} to - the end index of the sub-string to replace
+ * @param originalString - the string that will be modified
+ * @param newSubstring - the replacement string
+ * @param from - the start index of the sub-string to replace
+ * @param to - the end index of the sub-string to replace
*
- * @returns {String} - the modified string with the range (from, to) replaced with the provided value
+ * @returns - the modified string with the range (from, to) replaced with the provided value
*/
-function insertAtPosition(originalString, newSubstring, from, to) {
+function insertAtPosition(originalString: string, newSubstring: string, from: number, to: number): string {
// Check for invalid positions
if (from < 0 || to < 0 || from > originalString.length || to > originalString.length) {
return originalString;
@@ -72,13 +65,13 @@ function insertAtPosition(originalString, newSubstring, from, to) {
/**
* Replace the sub-string of the given string with zeros
- * @param {String} originalString - the string that will be modified
- * @param {Number} from - the start index of the sub-string to replace
- * @param {Number} to - the end index of the sub-string to replace
+ * @param originalString - the string that will be modified
+ * @param from - the start index of the sub-string to replace
+ * @param to - the end index of the sub-string to replace
*
- * @returns {String} - the modified string with the range (from, to) replaced with zeros
+ * @returns - the modified string with the range (from, to) replaced with zeros
*/
-function replaceRangeWithZeros(originalString, from, to) {
+function replaceRangeWithZeros(originalString: string, from: number, to: number): string {
const normalizedFrom = Math.max(from, 0);
const normalizedTo = Math.min(to, 2);
const replacement = '0'.repeat(normalizedTo - normalizedFrom);
@@ -87,12 +80,13 @@ function replaceRangeWithZeros(originalString, from, to) {
/**
* Clear the value under selection of an input (either hours or minutes) by replacing it with zeros
- * @param {String} value - current value of the input
- * @param {Object} selection - current selection of the input
- * @param {Function} setValue - the function that modifies the value of the input
- * @param {Function} setSelection - the function that modifies the selection of the input
+ *
+ * @param value - current value of the input
+ * @param selection - current selection of the input
+ * @param setValue - the function that modifies the value of the input
+ * @param setSelection - the function that modifies the selection of the input
*/
-function clearSelectedValue(value, selection, setValue, setSelection) {
+function clearSelectedValue(value: string, selection: {start: number; end: number}, setValue: (value: string) => void, setSelection: (value: {start: number; end: number}) => void) {
let newValue;
let newCursorPosition;
@@ -109,7 +103,7 @@ function clearSelectedValue(value, selection, setValue, setSelection) {
setSelection({start: newCursorPosition, end: newCursorPosition});
}
-function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
+function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: TimePickerProps, ref: ForwardedRef) {
const {numberFormat, translate} = useLocalize();
const {isExtraSmallScreenHeight} = useWindowDimensions();
const styles = useThemeStyles();
@@ -125,8 +119,8 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
const [amPmValue, setAmPmValue] = useState(() => DateUtils.get12HourTimeObjectFromDate(value).period);
const lastPressedKey = useRef('');
- const hourInputRef = useRef(null);
- const minuteInputRef = useRef(null);
+ const hourInputRef = useRef(null);
+ const minuteInputRef = useRef(null);
const {inputCallbackRef} = useAutoFocusInput();
@@ -134,7 +128,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
const focusHourInputOnLastCharacter = useCallback(() => setCursorPosition(2, hourInputRef, setSelectionHour), []);
const validate = useCallback(
- (time) => {
+ (time: string) => {
const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({timeString: time || `${hours}:${minutes} ${amPmValue}`, dateTimeString: defaultValue});
setError(!isValid);
return isValid;
@@ -154,10 +148,10 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
// This function receive value from hour input and validate it
// The valid format is HH(from 00 to 12). If the user input 9, it will be 09. If user try to change 09 to 19 it would skip the first character
- const handleHourChange = (text) => {
+ const handleHourChange = (text: string) => {
// Replace spaces with 0 to implement the following digit removal by pressing space
const trimmedText = text.replace(/ /g, '0');
- if (_.isEmpty(trimmedText)) {
+ if (!trimmedText) {
resetHours();
return;
}
@@ -179,13 +173,13 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
// To support the forward-removal using Delete key
newHour = `0${firstDigit}`;
newSelection = 1;
- } else if (firstDigit <= 1) {
+ } else if (Number(firstDigit) <= 1) {
/*
The first entered digit is 0 or 1.
If the first digit is 0, we can safely append the second digit.
If the first digit is 1, we must check the second digit to ensure it is not greater than 2, amd replace it with 0 otherwise.
*/
- newHour = `${firstDigit}${firstDigit === '1' && secondDigit > 2 ? 0 : secondDigit}`;
+ newHour = `${firstDigit}${firstDigit === '1' && Number(secondDigit) > 2 ? 0 : secondDigit}`;
newSelection = 1;
} else {
// The first entered digit is 2-9. We should replace the whole value by prepending 0 to the entered digit.
@@ -210,7 +204,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
// There is an active selection of the second digit
newHour = trimmedText.substring(0, 2).padEnd(2, '0');
newSelection = trimmedText.length === 1 ? 1 : 2;
- } else if (trimmedText.length === 1 && trimmedText <= 1) {
+ } else if (trimmedText.length === 1 && Number(trimmedText) <= 1) {
/*
The trimmed text is either 0 or 1.
We are either replacing hours with a single digit, or removing the last digit.
@@ -224,10 +218,11 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
newSelection = 2;
}
- if (newHour > 24) {
+ const newHourNumber = Number(newHour);
+ if (newHourNumber > 24) {
newHour = hours;
- } else if (newHour > 12) {
- newHour = String(newHour - 12).padStart(2, '0');
+ } else if (newHourNumber > 12) {
+ newHour = String(newHourNumber - 12).padStart(2, '0');
setAmPmValue(CONST.TIME_PERIOD.PM);
}
@@ -242,10 +237,10 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
This function receives value from the minutes input and validates it.
The valid format is MM(from 00 to 59). If the user enters 9, it will be prepended to 09. If the user tries to change 09 to 99, it would skip the character
*/
- const handleMinutesChange = (text) => {
+ const handleMinutesChange = (text: string) => {
// Replace spaces with 0 to implement the following digit removal by pressing space
const trimmedText = text.replace(/ /g, '0');
- if (_.isEmpty(trimmedText)) {
+ if (!trimmedText) {
resetMinutes();
return;
}
@@ -265,7 +260,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
// To support the forward-removal using Delete key
newMinute = `0${firstDigit}`;
newSelection = 1;
- } else if (firstDigit <= 5) {
+ } else if (Number(firstDigit) <= 5) {
// The first entered digit is 0-5, we can safely append the second digit.
newMinute = `${firstDigit}${trimmedText[2] || 0}`;
newSelection = 1;
@@ -292,7 +287,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
// There is an active selection of the second digit
newMinute = trimmedText.substring(0, 2).padEnd(2, '0');
newSelection = trimmedText.length === 1 ? 1 : 2;
- } else if (trimmedText.length === 1 && trimmedText <= 5) {
+ } else if (trimmedText.length === 1 && Number(trimmedText) <= 5) {
/*
The trimmed text is from 0 to 5.
We are either replacing minutes with a single digit, or removing the last digit.
@@ -306,7 +301,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
newSelection = 2;
}
- if (newMinute > 59) {
+ if (Number(newMinute) > 59) {
newMinute = minutes;
}
@@ -317,15 +312,13 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
/**
* Update amount with number or Backspace pressed for BigNumberPad.
* Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button
- *
- * @param {String} key
*/
const updateAmountNumberPad = useCallback(
- (key) => {
- const isHourFocused = hourInputRef.current.isFocused();
- const isMinuteFocused = minuteInputRef.current.isFocused();
+ (key: string) => {
+ const isHourFocused = hourInputRef.current?.isFocused();
+ const isMinuteFocused = minuteInputRef.current?.isFocused();
if (!isHourFocused && !isMinuteFocused) {
- minuteInputRef.current.focus();
+ minuteInputRef.current?.focus();
}
if (key === '.') {
@@ -370,13 +363,11 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
);
const arrowLeftCallback = useCallback(
- (e) => {
- const isMinuteFocused = minuteInputRef.current.isFocused();
+ (e?: GestureResponderEvent | KeyboardEvent) => {
+ const isMinuteFocused = minuteInputRef.current?.isFocused();
if (isMinuteFocused && selectionMinute.start === 0) {
- if (e) {
- // Check e to be truthy to avoid crashing on Android (e is undefined there)
- e.preventDefault();
- }
+ // Check e to be truthy to avoid crashing on Android (e is undefined there)
+ e?.preventDefault();
focusHourInputOnLastCharacter();
}
},
@@ -384,14 +375,12 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
[selectionHour, selectionMinute],
);
const arrowRightCallback = useCallback(
- (e) => {
- const isHourFocused = hourInputRef.current.isFocused();
+ (e?: GestureResponderEvent | KeyboardEvent) => {
+ const isHourFocused = hourInputRef.current?.isFocused();
if (isHourFocused && selectionHour.start === 2) {
- if (e) {
- // Check e to be truthy to avoid crashing on Android (e is undefined there)
- e.preventDefault();
- }
+ // Check e to be truthy to avoid crashing on Android (e is undefined there)
+ e?.preventDefault();
focusMinuteInputOnFirstCharacter();
}
},
@@ -403,8 +392,8 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, arrowConfig);
const handleFocusOnBackspace = useCallback(
- (e) => {
- if (selectionMinute.start !== 0 || selectionMinute.end !== 0 || e.key !== 'Backspace') {
+ (e: NativeSyntheticEvent) => {
+ if (selectionMinute.start !== 0 || selectionMinute.end !== 0 || e.nativeEvent.key !== 'Backspace') {
return;
}
e.preventDefault();
@@ -422,7 +411,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
}
return (
@@ -457,15 +446,14 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
lastPressedKey.current = e.nativeEvent.key;
}}
onChangeAmount={handleHourChange}
- role={CONST.ACCESSIBILITY_ROLE.TEXT}
- ref={(ref) => {
- if (typeof forwardedRef === 'function') {
- forwardedRef({refHour: ref, minuteRef: minuteInputRef.current});
- } else if (forwardedRef && _.has(forwardedRef, 'current')) {
+ ref={(textInputRef) => {
+ if (typeof ref === 'function') {
+ ref({hourRef: textInputRef as TextInput | null, minuteRef: minuteInputRef.current});
+ } else if (ref && 'current' in ref) {
// eslint-disable-next-line no-param-reassign
- forwardedRef.current = {hourRef: ref, minuteRef: minuteInputRef.current};
+ ref.current = {hourRef: textInputRef as TextInput | null, minuteRef: minuteInputRef.current};
}
- hourInputRef.current = ref;
+ hourInputRef.current = textInputRef as TextInput | null;
}}
onSelectionChange={(e) => {
setSelectionHour(e.nativeEvent.selection);
@@ -473,7 +461,6 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
style={styles.timePickerInput}
touchableInputWrapperStyle={styles.timePickerHeight100}
selection={selectionHour}
- showSoftInputOnFocus={false}
/>
{CONST.COLON} {
- if (typeof forwardedRef === 'function') {
- forwardedRef({refHour: hourInputRef.current, minuteRef: ref});
- } else if (forwardedRef && _.has(forwardedRef, 'current')) {
+ ref={(textInputRef) => {
+ if (typeof ref === 'function') {
+ ref({hourRef: hourInputRef.current, minuteRef: textInputRef as TextInput | null});
+ } else if (ref && 'current' in ref) {
// eslint-disable-next-line no-param-reassign
- minuteInputRef.current = {hourRef: hourInputRef.current, minuteInputRef: ref};
+ ref.current = {hourRef: hourInputRef.current, minuteRef: textInputRef as TextInput | null};
}
- minuteInputRef.current = ref;
- inputCallbackRef(ref);
+ minuteInputRef.current = textInputRef as TextInput | null;
+ inputCallbackRef(textInputRef as TextInput | null);
}}
onSelectionChange={(e) => {
setSelectionMinute(e.nativeEvent.selection);
@@ -501,7 +487,6 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
style={styles.timePickerInput}
touchableInputWrapperStyle={styles.timePickerHeight100}
selection={selectionMinute}
- showSoftInputOnFocus={false}
/>
@@ -520,7 +505,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
/>